// Command genfixtures generates the official DateKeys vectors and fixtures // (spec §65-§68) into testdata/. // // Fixtures are generated once, over rounds that are already published, and // then committed: age randomness cannot be injected through its public API, // so they are decryption and validation fixtures, not byte-reproducible // encryption outputs (spec §67). Existing format 2 and 3 fixtures are never // overwritten unless -force (every one) or -only (the named ones) is given; // vectors are always regenerated, and the tests fail if the implementation // stops reproducing the committed ones. // // The format 1 fixtures are the ones of spec v0.8.2, kept as compatibility // fixtures (spec §67, §70). They are never regenerated: only a generator of // test vectors may write format 1, and these were written by the v0.8.2 // reference itself. The .dkk with an extension (spec §68) is derived from the // portable .dkk of time_and_key_portable, a format 1 fixture, with -only. // // Derived from the fixtures and always regenerated, like the vectors: the // fields of each fixture record that the implementation computes (spec // version, format, lengths and the stages of spec §63), the frozen // "datekeys inspect -json" output of each .dkc (.inspect.json), the // exported mutation corpus (vectors/mutations.json) and the differential // corpus of the pre-unlock checks (vectors/inspect_differential.json). The // mutations whose capsule is built with age randomness keep the bytes // recorded in the committed mutations.json; -force, or -only mutations, // builds them afresh. A fixture a mutation derives from must not be // regenerated alone: regenerate the mutations with it. // // go run ./internal/testkit/genfixtures -out testdata // go run ./internal/testkit/genfixtures -out testdata -only format2_time_only_extensions,mutations package main import ( "bytes" "context" "crypto/sha256" "encoding/hex" "errors" "flag" "fmt" "io" "io/fs" "log" "os" "path/filepath" "strings" "time" "filippo.io/age" "g.activething.com/go/DateKeys/accesskey" "g.activething.com/go/DateKeys/agewrap" "g.activething.com/go/DateKeys/authorkey" "g.activething.com/go/DateKeys/capsule" "g.activething.com/go/DateKeys/datekey" "g.activething.com/go/DateKeys/extension" "g.activething.com/go/DateKeys/internal/inspectview" "g.activething.com/go/DateKeys/internal/testkit" "g.activething.com/go/DateKeys/profile" "g.activething.com/go/DateKeys/provider" ) func main() { out := flag.String("out", "testdata", "output directory") force := flag.Bool("force", false, "overwrite every existing format 2 and 3 fixture") only := flag.String("only", "", "comma-separated format 2 and 3 fixture names to regenerate, overwriting them, \""+extDKK+"\" to derive the .dkk with an extension again, and \"mutations\" to rebuild the randomly built mutations; every other fixture is left untouched") flag.Parse() sel, err := selection(*force, *only) if err != nil { log.Fatal(err) } if err := vectors(filepath.Join(*out, "vectors")); err != nil { log.Fatal(err) } if err := fixtures(filepath.Join(*out, "fixtures"), sel); err != nil { log.Fatal(err) } if err := derived(*out, sel); err != nil { log.Fatal(err) } } // selector decides which fixtures are (re)generated. type selector struct { force bool // overwrite every format 2 and 3 fixture only map[string]bool // when non-empty, regenerate exactly these } func selection(force bool, only string) (selector, error) { sel := selector{force: force, only: map[string]bool{}} if only == "" { return sel, nil } known := map[string]bool{extDKK: true, mutationsName: true} frozen := map[string]bool{} for _, s := range specs() { if s.format == capsule.Format1 { frozen[s.name] = true } else { known[s.name] = true } } for _, name := range strings.Split(only, ",") { if frozen[name] { return sel, fmt.Errorf("-only: %q is a format 1 fixture of spec v0.8.2, kept frozen as a compatibility fixture", name) } if !known[name] { return sel, fmt.Errorf("-only: unknown fixture %q", name) } sel.only[name] = true } return sel, nil } // generate reports whether the fixture name, whose main file is path, is // written. func (s selector) generate(name, path string) bool { if len(s.only) > 0 { return s.only[name] } _, err := os.Stat(path) return s.force || err != nil } func vectors(dir string) error { pv, err := testkit.QuicknetProfileVector() if err != nil { return err } if err := testkit.WriteJSON(filepath.Join(dir, "profile_quicknet.json"), pv); err != nil { return err } if err := testkit.WriteJSON(filepath.Join(dir, "quicknet_rounds.json"), testkit.RoundVectors()); err != nil { return err } if err := testkit.WriteJSON(filepath.Join(dir, "dk1.json"), testkit.DK1Vectors()); err != nil { return err } cv, err := testkit.CBORVectors() if err != nil { return err } if err := testkit.WriteJSON(filepath.Join(dir, "cbor.json"), cv); err != nil { return err } pad, err := testkit.PaddingVectors() if err != nil { return err } if err := testkit.WriteJSON(filepath.Join(dir, "padding.json"), pad); err != nil { return err } iv, err := testkit.IBEVectors() if err != nil { return err } if err := testkit.WriteJSON(filepath.Join(dir, "tlock_ibe.json"), iv); err != nil { return err } return format3Vectors(dir) } // format3Vectors writes the vectors of format 3 (spec §67): the paths, the // keys of R7, the heads and security. func format3Vectors(dir string) error { paths, err := testkit.PathVectors() if err != nil { return err } fold, err := testkit.PathFoldVectors() if err != nil { return err } heads, err := testkit.HeadSchemaVectors() if err != nil { return err } security, err := testkit.SecurityVectors() if err != nil { return err } strict, err := testkit.Ed25519StrictVectors() if err != nil { return err } note, err := testkit.NoteVectors() if err != nil { return err } words, err := testkit.WordKeyVectors() if err != nil { return err } resolved, err := testkit.ResolvedIPVectors() if err != nil { return err } for name, v := range map[string]any{"paths.json": paths, "path_fold.json": fold, "head_schema.json": heads, "security.json": security, "ed25519_strict.json": strict, "note.json": note, "wordkey.json": words, "resolved_ip.json": resolved} { if err := testkit.WriteJSON(filepath.Join(dir, name), v); err != nil { return err } } return frozenVectors(dir) } // mutationsName is the -only name that rebuilds the capsules of the // mutations built with age randomness. const mutationsName = "mutations" // derived writes what is computed from the fixtures: the computed fields of // the records, the inspect outputs, the mutation corpus and the differential // corpus. func derived(out string, sel selector) error { fixtureDir := filepath.Join(out, "fixtures") var names []string for _, s := range specs() { if s.format != capsule.Format3 || differentialBases3[s.name] { names = append(names, s.name) } if err := refreshRecord(fixtureDir, s.name); err != nil { return fmt.Errorf("%s: %w", s.name, err) } if err := inspectOutput(fixtureDir, s.name); err != nil { return fmt.Errorf("%s: %w", s.name, err) } } keys, err := filepath.Glob(filepath.Join(fixtureDir, "*.dkk.json")) if err != nil { return err } for _, path := range keys { var k testkit.DKKFixture if err := testkit.ReadJSON(path, &k); err != nil { return err } k.Spec = testkit.SpecVersion if err := testkit.WriteJSON(path, k); err != nil { return err } } path := filepath.Join(out, "vectors", "mutations.json") var frozen *testkit.MutationFile if !sel.force && !sel.only[mutationsName] { var f testkit.MutationFile switch err := testkit.ReadJSON(path, &f); { case err == nil: frozen = &f case !errors.Is(err, fs.ErrNotExist): return err } } m, err := testkit.MutationCorpus(fixtureDir, frozen) if err != nil { return err } if err := testkit.WriteJSONEdits(path, m); err != nil { return err } // The format 2 fixtures come after the format 1 ones, and the format 3 // ones after them, so the corpus keeps the cases of the earlier bases and // adds a block for each new base. d, err := testkit.InspectDifferential(fixtureDir, names) if err != nil { return err } return testkit.WriteDifferential(filepath.Join(out, "vectors", "inspect_differential.json"), d) } // differentialBases3 are the format 3 fixtures of the differential corpus, // one for each policy: steps 1 to 8 see nothing of format 3 that format 2 // does not have, but VERSION. var differentialBases3 = map[string]bool{"format3_single": true, "format3_time_and_key_portable": true} // refreshRecord recomputes the fields of the record of the fixture name that // the implementation derives from the fixture: the spec version, the // format, the content length and, from a complete opening through the public // API with the credentials the record names, the stages of spec §63. func refreshRecord(dir, name string) error { path := filepath.Join(dir, name+".json") var f testkit.DKCFixture if err := testkit.ReadJSON(path, &f); err != nil { return err } dkc, err := os.ReadFile(filepath.Join(dir, f.File)) if err != nil { return err } plaintext, err := os.ReadFile(filepath.Join(dir, f.PlaintextFile)) if err != nil { return err } sig, err := hex.DecodeString(f.Release.Signature) if err != nil { return err } unlock, err := time.Parse(time.RFC3339, f.UnlockAt) if err != nil { return err } release := provider.Release{Round: f.Release.Round, Signature: sig} oo := capsule.OpenOptions{Registry: testkit.Registry(), Source: testkit.NewSource(release), Now: testkit.Fixed(unlock)} switch { case f.AccessKeyFile != "": b, err := os.ReadFile(filepath.Join(dir, f.AccessKeyFile)) if err != nil { return err } if oo.AccessKey, err = accesskey.Decode(bytes.NewReader(b)); err != nil { return err } default: for _, s := range f.Identities { id, err := age.ParseX25519Identity(s) if err != nil { return err } oo.Identities = append(oo.Identities, id) } } sink := &testkit.MemorySink{} oo.Sink = sink var plain bytes.Buffer opened, err := capsule.Open(context.Background(), &plain, bytes.NewReader(dkc), oo) if err != nil { return fmt.Errorf("fixture does not open: %w", err) } if opened.Format == capsule.Format3 { // The plaintext file holds BODY. if err := record3(&f, plaintext, &opened.Verdicts); err != nil { return err } if err := check3(&f, plaintext, opened, sink); err != nil { return err } } else if !bytes.Equal(plain.Bytes(), plaintext) { return errors.New("fixture plaintext mismatch") } f.Spec = testkit.SpecVersion f.Format = int(opened.Format) f.PayloadLength = opened.PayloadLength f.Padding, f.PaddedLength = int(opened.Padding), opened.PaddedLength f.Stages = nil for _, c := range opened.Inspection.Checks { f.Stages = append(f.Stages, testkit.FixtureStage{Step: c.Step, Name: c.Name, OK: c.OK, Error: c.Error}) } return testkit.WriteJSON(path, f) } // inspectOutput writes .inspect.json: the output of // "datekeys inspect -json -in .dkc" run in the fixture directory. func inspectOutput(dir, name string) error { file := name + ".dkc" dkc, err := os.ReadFile(filepath.Join(dir, file)) if err != nil { return err } in, err := capsule.Inspect(bytes.NewReader(dkc), capsule.InspectOptions{Registry: testkit.Registry()}) var b bytes.Buffer if err := inspectview.New(file, in, err).WriteJSON(&b); err != nil { return err } return os.WriteFile(filepath.Join(dir, name+".inspect.json"), b.Bytes(), 0o644) } type spec struct { name, description string format capsule.Format round uint64 policy capsule.Policy recipients int portable bool padding capsule.Padding plaintext []byte headerExt []extension.Extension controlExt []extension.Extension // Format 3: the files, the comment and the declared author. body, when // not nil, gives the BODY of a capsule that only a generator of test // vectors writes (spec §62.1 rule 13), and testkit.Build writes it. files []file3 comment, author string // signer, when not nil, is the 32-byte seed of the author key that signs // a format 3 fixture written by EncryptFiles (spec v0.11, §29.9). signer []byte // configure, when not nil, sets what only this fixture needs in the // options of EncryptFiles: a signer with certificates, or a sealer. configure func(o *capsule.EncryptOptions) error body func() ([]byte, error) // area, when not 0, is the security area of a fixture of an earlier // version, which EncryptFiles writes only for test vectors: 512 bytes in // the fixtures of v0.10, which are compatibility fixtures (spec §67). area uint32 } // Extension data of the fixtures (spec §54, §72): the header carries the raw // UTF-8 bytes of a label, which are not CBOR; the control carries // {0: 7, 1: "sealed"} in the CBOR profile of spec §58; the .dkk carries // {0: "hand"}. The base protocol decodes none of them. var ( headerExtData = []byte("public label") controlExtData = mustHex("a2000701667365616c6564") dkkExtData = mustHex("a1006468616e64") // signerSeed is the seed of the test key of format3_signed: the SHA-256 // of a text. It is not a secret: anyone may sign with it. signerSeed = func() []byte { h := sha256.Sum256([]byte("DateKeys fixture author key 1")); return h[:] }() ) func mustHex(s string) []byte { b, err := hex.DecodeString(s) if err != nil { panic(err) } return b } func mustExt(id string, version uint64, data []byte) extension.Extension { e, err := extension.New(id, version, data) if err != nil { panic(err) } return e } // specs lists the fixtures: the frozen format 1 fixtures of spec v0.8.2 // first, then the format 2 fixtures of spec v0.9, then the format 3 // fixtures of spec §67. func specs() []spec { large := []byte(strings.Repeat("DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk.\n", 1000)) hExt := mustExt("org.example.label", 1, headerExtData) cExt := mustExt("org.example.note", 2, controlExtData) f1, f2, f3 := capsule.Format1, capsule.Format2, capsule.Format3 when := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) note := []file3{{path: "nota.txt", content: []byte("Hola desde el pasado.\n"), mtime: when}} tree := []file3{ {path: "carta.txt", content: []byte("Querida familia:\n\nAbrid esto juntos.\n"), mtime: when}, {path: "fotos/2025/atardecer.jpg", content: patterned("atardecer", 3000)}, {path: "fotos/2025/playa.jpg", content: patterned("playa", 80000), mtime: when.Add(-24 * time.Hour)}, {path: "fotos/leeme.txt", content: []byte("Las fotos del verano.\n"), mtime: when}, {path: "música/canción.txt", content: []byte("La, la, la.\n"), mtime: when}, } report := []file3{{path: "informe.txt", content: []byte(strings.Repeat("Informe trimestral, sin cifras.\n", 625)), mtime: when}} secret := []file3{{path: "secreto.txt", content: []byte("DateKeys fixture opened with a portable .dkk.\n"), mtime: when}} return []spec{ {name: "time_only", format: f1, description: "time_only capsule, two STREAM chunks, no extensions", round: 1000, policy: capsule.TimeOnly, plaintext: large}, {name: "time_only_extensions", format: f1, description: "time_only capsule with a noncritical PUBLIC_HEADER extension and a noncritical CONTROL_CBOR extension", round: 2000, policy: capsule.TimeOnly, plaintext: []byte("DateKeys fixture with extensions.\n"), headerExt: []extension.Extension{hExt}, controlExt: []extension.Extension{cExt}}, {name: "time_and_key_portable", format: f1, description: "time_and_key capsule whose only recipient is a portable .dkk", round: 1000, policy: capsule.TimeAndKey, portable: true, plaintext: []byte("DateKeys fixture opened with a portable .dkk.\n")}, {name: "time_and_key_recipients", format: f1, description: "time_and_key capsule for two known X25519 recipients and a portable .dkk", round: 1001, policy: capsule.TimeAndKey, recipients: 2, portable: true, plaintext: []byte("DateKeys fixture for several recipients.\n")}, {name: "empty_payload", format: f1, description: "time_only capsule with an empty payload", round: 1001, policy: capsule.TimeOnly, plaintext: []byte{}}, {name: "format2_time_only", format: f2, description: "format 2 time_only capsule, padding code 2 (reforzado): L = 78000, P = 79872, two STREAM chunks", round: 1000, policy: capsule.TimeOnly, padding: capsule.Reforzado, plaintext: large}, {name: "format2_time_only_bloque256", format: f2, description: "format 2 time_only capsule with the content of format2_time_only and padding code 1 (bloque256): L = 78000, P = 78080", round: 1000, policy: capsule.TimeOnly, padding: capsule.Bloque256, plaintext: large}, {name: "format2_empty_payload", format: f2, description: "format 2 time_only capsule with an empty content: L = 0, P = 256", round: 1001, policy: capsule.TimeOnly, padding: capsule.Reforzado, plaintext: []byte{}}, {name: "format2_time_only_extensions", format: f2, description: "format 2 time_only capsule with a noncritical PUBLIC_HEADER extension and a noncritical CONTROL_CBOR extension", round: 2000, policy: capsule.TimeOnly, padding: capsule.Reforzado, plaintext: []byte("DateKeys fixture with extensions.\n"), headerExt: []extension.Extension{hExt}, controlExt: []extension.Extension{cExt}}, {name: "format2_time_and_key_portable", format: f2, description: "format 2 time_and_key capsule with one credential, a portable .dkk, and 15 dummies", round: 1000, policy: capsule.TimeAndKey, portable: true, padding: capsule.Reforzado, plaintext: []byte("DateKeys fixture opened with a portable .dkk.\n")}, {name: "format2_time_and_key_recipients", format: f2, description: "format 2 time_and_key capsule for three known X25519 recipients and a portable .dkk, and 12 dummies", round: 1001, policy: capsule.TimeAndKey, recipients: 3, portable: true, padding: capsule.Reforzado, plaintext: []byte("DateKeys fixture for several recipients.\n")}, {name: "format2_time_and_key_sixteen", format: f2, description: "format 2 time_and_key capsule for sixteen known X25519 recipients, without dummies", round: 2000, policy: capsule.TimeAndKey, recipients: 16, padding: capsule.Reforzado, plaintext: []byte("DateKeys fixture for sixteen recipients.\n")}, {name: "format3_single", format: f3, area: capsule.AreaUnit, description: "format 3 time_only capsule with a single file, nota.txt, with its mtime", round: 1000, policy: capsule.TimeOnly, padding: capsule.Reforzado, files: note}, {name: "format3_tree", format: f3, area: capsule.AreaUnit, description: "format 3 time_only capsule with five files in three folders, one of them over two STREAM chunks and one without mtime, a comment of two lines and a declared author", round: 1001, policy: capsule.TimeOnly, padding: capsule.Reforzado, files: tree, comment: "Para abrir en familia.\nCon cariño, desde 2026.", author: "Ana López"}, {name: "format3_comment_only", format: f3, area: capsule.AreaUnit, description: "format 3 time_only capsule with a comment of two lines, the second one with a TAB, a declared author and no files", round: 1004, policy: capsule.TimeOnly, padding: capsule.Reforzado, comment: "Feliz cumpleaños.\n\tAbre esto dentro de diez años.", author: "Ana"}, {name: "format3_bloque256", format: f3, area: capsule.AreaUnit, description: "format 3 time_only capsule with padding code 1 (bloque256) and one file of 20000 bytes", round: 1000, policy: capsule.TimeOnly, padding: capsule.Bloque256, files: report}, {name: "format3_time_and_key_portable", format: f3, area: capsule.AreaUnit, description: "format 3 time_and_key capsule with one credential, a portable .dkk, and 15 dummies", round: 1000, policy: capsule.TimeAndKey, portable: true, padding: capsule.Reforzado, files: secret}, {name: "format3_area_1024", format: f3, description: "format 3 time_only capsule with a security area of 1024 bytes, as a later version may write it, holding the empty security", round: 1000, policy: capsule.TimeOnly, padding: capsule.Reforzado, body: func() ([]byte, error) { return body3(1024, capsule.EncodeSecurity(), "", "", note) }}, {name: "format3_security_v2", format: f3, description: "format 3 time_only capsule whose security is of version 2: verdict X", round: 1000, policy: capsule.TimeOnly, padding: capsule.Reforzado, body: func() ([]byte, error) { s, err := securityV2() if err != nil { return nil, err } return body3(capsule.AreaUnit, s, "", "", note) }}, {name: "format3_signature_unsupported", format: f3, description: "format 3 time_only capsule with an author-signature of alg 4294967295, a random key of 32 bytes and a random signature of 64: verdicts F1 and S0", round: 1001, policy: capsule.TimeOnly, padding: capsule.Reforzado, body: func() ([]byte, error) { sig, err := unsupportedSignature() if err != nil { return nil, err } s, err := capsule.EncodeSecurityWith(sig, nil) if err != nil { return nil, err } return body3(capsule.AreaUnit, s, "", "", note) }}, {name: "format3_seal_unsupported", format: f3, description: "format 3 time_only capsule with an author-signature of alg 4294967295, as in format3_signature_unsupported, and a seal of seal_type 4294967295, reserved for tests, with a random token of 32 bytes: verdicts F1 and S1", round: 2000, policy: capsule.TimeOnly, padding: capsule.Reforzado, body: func() ([]byte, error) { sig, err := unsupportedSignature() if err != nil { return nil, err } seal, err := capsule.EncodeSeal(capsule.SealTypeTest, randomBytes(32)) if err != nil { return nil, err } s, err := capsule.EncodeSecurityWith(sig, seal) if err != nil { return nil, err } return body3(capsule.AreaUnit, s, "", "", note) }}, {name: "format3_unsigned", format: f3, description: "format 3 time_only capsule with a single file, nota.txt, as format3_signed, without a signature: the area of 32 KiB of spec v0.11 holds the empty security, and P is the one of format3_signed", round: 1000, policy: capsule.TimeOnly, padding: capsule.Reforzado, files: note}, {name: "format3_signed", format: f3, description: "format 3 time_only capsule with a single file, nota.txt, signed with alg 1 by a test key whose seed the record gives: verdict F4, and the commitments and the message of the signature", round: 1000, policy: capsule.TimeOnly, padding: capsule.Reforzado, files: note, signer: signerSeed}, {name: "format3_signed_cms", format: f3, description: "format 3 time_only capsule with a single file, nota.txt, signed with alg 2 by two test certificates, an ECDSA P-256 one and an RSA 2048 one, each sealed by a test time-stamping authority before the round time: verdict F6, with the certificates, the commitments, SIGNERS and the result of each signer in the record", round: 1000, policy: capsule.TimeOnly, padding: capsule.Reforzado, files: note, configure: configureCMS}, {name: "format3_note", format: f3, description: "format 3 time_only capsule with a single file, nota.txt, and the public note «Cartas del viaje a Lisboa» in the noncritical array of PUBLIC_HEADER (spec v0.11, §24.1)", round: 1000, policy: capsule.TimeOnly, padding: capsule.Reforzado, files: note, configure: func(o *capsule.EncryptOptions) error { o.PublicNote = "Cartas del viaje a Lisboa"; return nil }}, {name: "format3_sealed", format: f3, description: "format 3 time_only capsule with a single file, nota.txt, signed with alg 1 by the test key of format3_signed and sealed with seal_type 2 by a test time-stamping authority before the round time: verdicts F4 and S4, with SEAL_SUBJECT and the token in the record", round: 1000, policy: capsule.TimeOnly, padding: capsule.Reforzado, files: note, signer: signerSeed, configure: configureSeal}, } } func fixtures(dir string, sel selector) error { if err := os.MkdirAll(dir, 0o755); err != nil { return err } for _, s := range specs() { path := filepath.Join(dir, s.name+".dkc") if s.format == capsule.Format1 { if _, err := os.Stat(path); err != nil { return fmt.Errorf("%s: the frozen format 1 fixture is missing: %w", s.name, err) } continue } if !sel.generate(s.name, path) { log.Printf("leaving %s untouched", path) continue } if err := generate(dir, s); err != nil { return fmt.Errorf("%s: %w", s.name, err) } log.Printf("generated %s", path) } path := filepath.Join(dir, extDKK+".dkk") if _, err := os.Stat(path); err == nil && !sel.only[extDKK] { log.Printf("leaving %s untouched", path) return nil } if err := deriveDKK(dir); err != nil { return fmt.Errorf("%s: %w", extDKK, err) } log.Printf("generated %s", path) return nil } // extDKK is the .dkk vector with an extension (spec §68): the portable // credential of extDKKSource re-issued with a noncritical extension. It keeps // the credential_id, the key and the capsule_digest, so its bytes are a // function of the source .dkk. const ( extDKK = "time_and_key_portable_extension" extDKKSource = "time_and_key_portable" ) func deriveDKK(dir string) error { src, err := os.ReadFile(filepath.Join(dir, extDKKSource+".dkk")) if err != nil { return err } k, err := accesskey.Decode(bytes.NewReader(src)) if err != nil { return err } k.Noncritical = []extension.Extension{mustExt("org.example.delivery", 1, dkkExtData)} var kb bytes.Buffer if err := accesskey.Encode(&kb, k); err != nil { return err } back, err := accesskey.Decode(bytes.NewReader(kb.Bytes())) if err != nil { return err } // The credential must open its capsule through the public API. dkcFile := extDKKSource + ".dkc" dkc, err := os.ReadFile(filepath.Join(dir, dkcFile)) if err != nil { return err } reg := testkit.Registry() in, err := capsule.Inspect(bytes.NewReader(dkc), capsule.InspectOptions{Registry: reg}) if err != nil { return err } oo := capsule.OpenOptions{Registry: reg, Source: testkit.NewSource(testkit.Release(in.Header.DateKey.Round)), AccessKey: back, Now: testkit.Fixed(in.UnlockAt)} if _, err := capsule.Open(context.Background(), io.Discard, bytes.NewReader(dkc), oo); err != nil { return fmt.Errorf("the .dkk does not open %s: %w", dkcFile, err) } ksum := sha256.Sum256(kb.Bytes()) kf := testkit.DKKFixture{ Description: "portable X25519 .dkk of " + dkcFile + " with a noncritical extension: the credential of " + extDKKSource + ".dkk re-issued with org.example.delivery", Spec: testkit.SpecVersion, File: extDKK + ".dkk", SHA256: hex.EncodeToString(ksum[:]), CredentialID: hex.EncodeToString(back.CredentialID[:]), CapsuleID: hex.EncodeToString(back.CapsuleID[:]), AccessType: back.Type, Material: hex.EncodeToString(back.Material), CapsuleDigest: hex.EncodeToString(back.Verification.CapsuleDigest), Extensions: exts(false, back.Noncritical), Capsule: dkcFile, ExpectedResult: "opens INNER_ACCESS_AGE of " + dkcFile + " and yields its CONTROL_CBOR", } if err := os.WriteFile(filepath.Join(dir, kf.File), kb.Bytes(), 0o644); err != nil { return err } return testkit.WriteJSON(filepath.Join(dir, extDKK+".dkk.json"), kf) } // written is a fixture as its writer wrote it, with the credentials that // only the writer knows. type written struct { dkc []byte ids []*age.X25519Identity portable *accesskey.AccessKey } // write writes the fixture s: format 3 with capsule.EncryptFiles, or with // testkit.Build when only a generator of test vectors may write it, and // format 2 with capsule.Encrypt, as a generator of test vectors. func write(s spec) (*written, error) { p := profile.Quicknet() unlock, err := datekey.RoundTime(p, s.round) if err != nil { return nil, err } if s.body != nil { body, err := s.body() if err != nil { return nil, err } b, err := testkit.Build{Round: s.round, Format: capsule.Format3, Declared: s.policy, Structure: s.policy, Padding: s.padding, Plaintext: body}.Make() if err != nil { return nil, err } return &written{dkc: b.DKC}, nil } opts := capsule.EncryptOptions{ Profile: p, UnlockAt: unlock, Policy: s.policy, NewPortableKey: s.portable, Padding: s.padding, Noncritical: s.headerExt, ControlNoncritical: s.controlExt, Now: testkit.Fixed(testkit.Genesis()), } w := &written{} for range s.recipients { id, err := age.GenerateX25519Identity() if err != nil { return nil, err } w.ids = append(w.ids, id) opts.Recipients = append(opts.Recipients, id.Recipient()) } var dkc bytes.Buffer var res *capsule.Result if s.format == capsule.Format3 { opts.Comment, opts.Author = s.comment, s.author if s.area != 0 { opts.TestVectors, opts.TestAreaLen = true, s.area } if s.configure != nil { if err := s.configure(&opts); err != nil { return nil, err } } if s.signer != nil { k, err := authorkey.NewFromSeed(s.signer) if err != nil { return nil, err } defer k.Clear() opts.AuthorKey = k } res, err = capsule.EncryptFiles(&dkc, sources(s.files), opts) } else { opts.Length, opts.TestVectors = int64(len(s.plaintext)), true res, err = capsule.Encrypt(&dkc, bytes.NewReader(s.plaintext), opts) } if err != nil { return nil, err } w.dkc, w.portable = dkc.Bytes(), res.PortableKey return w, nil } // generate writes the fixture s and records every intermediate value, // recovered by opening it layer by layer. func generate(dir string, s spec) error { p := profile.Quicknet() reg := testkit.Registry() unlock, err := datekey.RoundTime(p, s.round) if err != nil { return err } w, err := write(s) if err != nil { return err } release := testkit.Release(s.round) // Recover every intermediate value by opening the fixture step by step. parts, err := testkit.Split(w.dkc) if err != nil { return err } format := capsule.Format(parts.Prelude[4]) header, err := capsule.DecodeHeader(parts.Header) if err != nil { return err } timeID, err := agewrap.NewTimeIdentity(p, s.round, release) if err != nil { return err } inner, err := decrypt(parts.Sealed, timeID) if err != nil { return err } control := inner f := testkit.DKCFixture{Description: s.description, File: s.name + ".dkc", PlaintextFile: s.name + ".plaintext"} var dkkFile string var dkkBytes []byte if s.policy == capsule.TimeAndKey { st, err := agewrap.Stanzas(bytes.NewReader(inner)) if err != nil { return err } f.InnerStanzas = stanzas(st) var tryIDs []age.Identity for _, id := range w.ids { i, err := opens(id, st) if err != nil { return err } f.Identities = append(f.Identities, id.String()) f.IdentityStanzas = append(f.IdentityStanzas, i) tryIDs = append(tryIDs, id) } if w.portable != nil { var kb bytes.Buffer if err := accesskey.Encode(&kb, w.portable); err != nil { return err } dkkBytes = kb.Bytes() dkkFile = s.name + ".dkk" kid, err := w.portable.Identity() if err != nil { return err } i, err := opens(kid, st) if err != nil { return err } f.AccessKeyStanza = &i tryIDs = append(tryIDs, kid) } accessID, err := agewrap.NewAccessIdentity(agewrap.AccessSlots, tryIDs...) if err != nil { return err } if control, err = decrypt(inner, accessID); err != nil { return err } } ctrl, err := capsule.DecodeControl(control, format) if err != nil { return err } outer, err := agewrap.Stanzas(bytes.NewReader(parts.Sealed)) if err != nil { return err } payload, err := agewrap.Stanzas(bytes.NewReader(parts.Payload)) if err != nil { return err } // The plaintext of PAYLOAD_AGE: the content and its padding, the content // being BODY in format 3. payloadID, err := agewrap.NewPayloadIdentity(ctrl.PayloadIdentity[:]) if err != nil { return err } pt, err := decrypt(parts.Payload, payloadID) if err != nil { return err } padded, err := capsule.PaddedLength(ctrl.PayloadLength, ctrl.Padding) if err != nil { return err } if uint64(len(pt)) != padded || len(bytes.Trim(pt[ctrl.PayloadLength:], "\x00")) != 0 { return errors.New("the plaintext of PAYLOAD_AGE is not the content and its padding") } plaintext := pt[:ctrl.PayloadLength] switch format { case capsule.Format2: if !bytes.Equal(plaintext, s.plaintext) { return errors.New("fixture plaintext mismatch") } case capsule.Format3: // Without the verdicts of Open, which come later: the derived record // that refreshRecord writes takes them from the opening. if err := record3(&f, plaintext, nil); err != nil { return err } } // The fixture must open through the public API with the embedded release. oo := capsule.OpenOptions{Registry: reg, Source: testkit.NewSource(release), Now: testkit.Fixed(unlock)} for _, id := range w.ids { oo.Identities = append(oo.Identities, id) } if s.policy == capsule.TimeAndKey && len(w.ids) == 0 { oo.AccessKey = w.portable } sink := &testkit.MemorySink{} oo.Sink = sink var plain bytes.Buffer opened, err := capsule.Open(context.Background(), &plain, bytes.NewReader(w.dkc), oo) if err != nil { return fmt.Errorf("fixture does not open: %w", err) } if format == capsule.Format3 { // A signature is checked in the context of its capsule, which the // record above does not have yet: the verdicts are those of Open. v := opened.Verdicts f.Verdicts = &testkit.FixtureVerdicts{Signature: string(v.Signature), Seal: string(v.Seal), Lines: v.Lines()} if err := check3(&f, plaintext, opened, sink); err != nil { return err } } else if !bytes.Equal(plain.Bytes(), plaintext) { return errors.New("fixture plaintext mismatch") } sum := sha256.Sum256(w.dkc) psum := sha256.Sum256(plaintext) f.Spec = testkit.SpecVersion f.Format = int(format) f.SHA256 = hex.EncodeToString(sum[:]) f.Release = testkit.FixtureRelease{Round: release.Round, Signature: hex.EncodeToString(release.Signature)} f.Prelude = hex.EncodeToString(parts.Prelude) f.PublicHeader = hex.EncodeToString(parts.Header) f.DateKey = header.DateKey.Compact() f.CapsuleID = hex.EncodeToString(header.CapsuleID[:]) f.AccessPolicy = s.policy.String() f.Structure = s.policy.String() f.UnlockAt = unlock.Format(time.RFC3339) f.HeaderBinding = hex.EncodeToString(ctrl.HeaderBinding[:]) f.OuterStanzas = stanzas(outer) f.PayloadStanzas = stanzas(payload) f.AccessKeyFile = dkkFile f.ControlCBOR = hex.EncodeToString(control) f.PayloadIdentity = hex.EncodeToString(ctrl.PayloadIdentity[:]) f.PayloadLength, f.Padding, f.PaddedLength = ctrl.PayloadLength, int(ctrl.Padding), padded f.PlaintextSHA256 = hex.EncodeToString(psum[:]) // The extensions of the header as it is written: those given, and the // public note that the writer adds (spec v0.11, §24.1). f.HeaderExtensions = append(exts(true, header.Critical), exts(false, header.Noncritical)...) f.ControlExt = exts(false, s.controlExt) if s.signer != nil { f.Signature = &testkit.FixtureSignature{SecretSeed: hex.EncodeToString(s.signer)} } for _, c := range opened.Inspection.Checks { f.Stages = append(f.Stages, testkit.FixtureStage{Step: c.Step, Name: c.Name, OK: c.OK, Error: c.Error}) } if err := os.WriteFile(filepath.Join(dir, f.File), w.dkc, 0o644); err != nil { return err } if err := os.WriteFile(filepath.Join(dir, f.PlaintextFile), plaintext, 0o644); err != nil { return err } if err := testkit.WriteJSON(filepath.Join(dir, s.name+".json"), f); err != nil { return err } if dkkBytes == nil { return nil } k := w.portable ksum := sha256.Sum256(dkkBytes) kf := testkit.DKKFixture{ Description: "portable X25519 .dkk of " + f.File, Spec: testkit.SpecVersion, File: dkkFile, SHA256: hex.EncodeToString(ksum[:]), CredentialID: hex.EncodeToString(k.CredentialID[:]), CapsuleID: hex.EncodeToString(k.CapsuleID[:]), AccessType: k.Type, Material: hex.EncodeToString(k.Material), CapsuleDigest: hex.EncodeToString(k.Verification.CapsuleDigest), Capsule: f.File, ExpectedResult: "opens INNER_ACCESS_AGE of " + f.File + " and yields its CONTROL_CBOR", } if err := os.WriteFile(filepath.Join(dir, dkkFile), dkkBytes, 0o644); err != nil { return err } return testkit.WriteJSON(filepath.Join(dir, s.name+".dkk.json"), kf) } // opens returns the index of the only stanza of st that id unwraps. func opens(id age.Identity, st []*age.Stanza) (int, error) { found := -1 for i, s := range st { fk, err := id.Unwrap([]*age.Stanza{s}) clear(fk) if errors.Is(err, age.ErrIncorrectIdentity) { continue } if err != nil { return 0, err } if found >= 0 { return 0, fmt.Errorf("a credential opens stanzas %d and %d", found, i) } found = i } if found < 0 { return 0, errors.New("a credential opens no stanza") } return found, nil } func decrypt(file []byte, id age.Identity) ([]byte, error) { r, err := age.Decrypt(bytes.NewReader(file), id) if err != nil { return nil, err } var b bytes.Buffer if _, err := b.ReadFrom(r); err != nil { return nil, err } return b.Bytes(), nil } func stanzas(in []*age.Stanza) []testkit.FixtureStanza { out := make([]testkit.FixtureStanza, len(in)) for i, s := range in { out[i] = testkit.FixtureStanza{Type: s.Type, Args: s.Args} } return out } func exts(critical bool, in []extension.Extension) []testkit.FixtureExt { var out []testkit.FixtureExt for _, e := range in { out = append(out, testkit.FixtureExt{Critical: critical, ID: e.ID, Version: e.Version, Data: hex.EncodeToString(e.Data)}) } return out }