// Writes test/vectors/capsule_writer.json, the vectors of the writer of // capsules of format 3 of datekeys-dart, stage 6b of docs/PLAN_dart.md: // what capsule.EncryptFiles of datekeys-go writes for each recipe of this // file, and the text of each error it gives. // // EncryptFiles draws every random value from crypto/rand: the portable // identity, the salt of the head, capsule_id, I_PAYLOAD, the dummies of // INNER_ACCESS_AGE and their permutation, what age draws for the measured // seal, the real seal and PAYLOAD_AGE, and the credential_id of the .dkk. // Here crypto/rand.Reader reads the keystream of SeededRandomSource of // lib/src/random.dart (ChaCha20 under SHA-256(seed), zero nonce), as in // tool/age_writer_go_vectors.go: with the same seed the writer of // datekeys-dart draws the same values, in the same order, and must write the // same bytes. Each case records the size of each draw. // // A recipe that signs or seals runs the hooks of the tests of package // capsule (signed_test.go): an Ed25519 author key from a seed (alg 1), the // CMS signatures and RFC 3161 tokens of internal/cms/cmstest (alg 2 and // seal_type 2). ECDSA and RSA draw from Go's internal generator, which only // testing/cryptotest.SetGlobalRandom fixes, in a test binary: this file runs // as a test. What each hook was given and returned is recorded, so that the // tests of datekeys-dart give the writer the same signatures and tokens and // check that it asks for them over the same messages. // // For each capsule written it records its length and SHA-256, the capsule // itself when it is small, the .dkk, the Result, its PUBLIC_HEADER, its // CONTROL_CBOR and its head; it opens the capsule with capsule.Open, with // each credential alone, all together and none, and records the files, the // verdicts and the area; and it encodes PUBLIC_HEADER, CONTROL_CBOR and the // .dkk again and compares them with what was written. For each error it // records the text, the code, and the number of bytes written before it. // // It imports internal packages, so it runs as a test in an export of // datekeys-go made with git archive, which it does not change, never in the // repository itself. From the root of datekeys-dart: // // commit=$(git -C ../datekeys-go rev-parse v0.12) // out=$PWD/test/vectors // tmp=$(mktemp -d) // git -C ../datekeys-go archive "$commit" | tar -x -C "$tmp" // mkdir "$tmp/capsulewriter" // cp tool/capsule_writer_go_vectors_test.go "$tmp/capsulewriter/" // (cd "$tmp/capsulewriter" && go test -run TestWriteVectors -count=1 \ // -args -source "$commit" -out "$out") // rm -rf "$tmp" // // It also writes test/vectors/capsule_writer.g.dart, the cases marked node, // for the tests that also run compiled to JavaScript. Every run writes the // same bytes with Go 1.26.8. package capsulewriter import ( "bytes" "context" "crypto/ed25519" "crypto/elliptic" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "flag" "fmt" "io" "os" "path/filepath" "runtime" "strings" "testing" "testing/cryptotest" "time" "filippo.io/age" "golang.org/x/crypto/chacha20" datekeys "g.activething.com/go/DateKeys" "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/codec/bech32" "g.activething.com/go/DateKeys/datekey" "g.activething.com/go/DateKeys/extension" "g.activething.com/go/DateKeys/internal/cms/cmstest" "g.activething.com/go/DateKeys/internal/testkit" "g.activething.com/go/DateKeys/profile" "g.activething.com/go/DateKeys/provider" "g.activething.com/go/DateKeys/wordkey" ) var ( sourceFlag = flag.String("source", "", "the commit of datekeys-go that this tree exports") outFlag = flag.String("out", "", "the directory test/vectors of datekeys-dart") ) type obj = map[string]any func h(b []byte) string { return hex.EncodeToString(b) } func sum(b []byte) string { s := sha256.Sum256(b) return h(s[:]) } func must[T any](v T, err error) T { if err != nil { panic(err) } return v } func unhex(s string) []byte { return must(hex.DecodeString(s)) } // --------------------------------------------------------------------------- // The recipes type extIn struct { ID string `json:"id"` Version uint64 `json:"version"` Data *string `json:"data,omitempty"` // hex; absent for none } // fileIn is a file of a recipe: its content is the text, or n bytes of the // pattern (31·i + 7) mod 256, and its declared size the length of the // content unless size is given. type fileIn struct { Path string `json:"path"` Text *string `json:"text,omitempty"` Pattern int `json:"pattern,omitempty"` Size *int64 `json:"size,omitempty"` MTime []int64 `json:"mtime,omitempty"` // seconds and nanoseconds since 1970 // Fault is what goes wrong with its source: // - open1, open2: the first or the second Open fails with "the file is // gone"; // - short1, short2: that reading gives one byte less; // - long1, long2: that reading gives one byte more, 0x78; // - change2: the second reading gives the content with its first byte // XOR 1; // - read2: the second reading gives the first half of the content and // then fails with "the disk failed". Fault string `json:"fault,omitempty"` } type authorIn struct { Seed string `json:"seed"` // hex, 32 bytes Bad string `json:"bad,omitempty"` // zero_signature, short_key } type cmsIn struct { Signers []string `json:"signers"` // names of the certificates that sign Extra string `json:"extra,omitempty"` // a required signer that does not sign Unsealed bool `json:"unsealed,omitempty"` // no seal in the signatures Junk int `json:"junk,omitempty"` // bytes of an unsigned attribute Error string `json:"error,omitempty"` // Sign fails with this text Garbage bool `json:"garbage,omitempty"` // Sign returns bytes that are no signature } type sealerIn struct { Error string `json:"error,omitempty"` // Seal fails with this text } type recipe struct { Name string `json:"name"` Seed string `json:"seed"` Round uint64 `json:"round"` // UnlockNs is added to the time of the round: the requested instant. UnlockNs int64 `json:"unlock_ns,omitempty"` // Now is the clock, in RFC 3339; the genesis of Quicknet by default. Now string `json:"now,omitempty"` Policy string `json:"policy,omitempty"` // time_and_key; time_only by default // Identities are labels: the raw secret of each is // SHA-256("identity " + label), and its recipient is given. Identities []string `json:"identities,omitempty"` // RawRecipients are given after them, as raw public keys. RawRecipients []string `json:"raw_recipients,omitempty"` Portable bool `json:"portable,omitempty"` Words []string `json:"words,omitempty"` Padding int `json:"padding,omitempty"` Files []fileIn `json:"files,omitempty"` // ManyFiles adds that many empty files, f00000 and on. ManyFiles int `json:"many_files,omitempty"` Comment string `json:"comment,omitempty"` Author string `json:"author,omitempty"` Note string `json:"note,omitempty"` Critical []extIn `json:"critical,omitempty"` Noncritical []extIn `json:"noncritical,omitempty"` ControlCritical []extIn `json:"control_critical,omitempty"` ControlNoncritical []extIn `json:"control_noncritical,omitempty"` HeadCritical []extIn `json:"head_critical,omitempty"` HeadNoncritical []extIn `json:"head_noncritical,omitempty"` AuthorKey *authorIn `json:"author_key,omitempty"` CMS *cmsIn `json:"cms,omitempty"` Sealer *sealerIn `json:"sealer,omitempty"` LargeArea bool `json:"large_area,omitempty"` TestVectors bool `json:"test_vectors,omitempty"` TestAreaLen uint32 `json:"test_area_len,omitempty"` // Profile is "" for Quicknet, or genesis_plus_one, Quicknet with a // genesis time one second later, which its chain hash does not match. Profile string `json:"profile,omitempty"` // Node puts the case in the .g.dart, for the tests compiled to // JavaScript. Node bool `json:"node,omitempty"` } func ptr[T any](v T) *T { return &v } func ext(id string, version uint64, data string) extIn { d := hex.EncodeToString([]byte(data)) return extIn{ID: id, Version: version, Data: &d} } func text(path, s string) fileIn { return fileIn{Path: path, Text: ptr(s)} } var words = []string{"faro", "nube", "trigo", "menta", "barco", "lince"} func ids(n int) []string { var out []string for i := range n { out = append(out, fmt.Sprintf("holder %d", i)) } return out } const authorSeed = "a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0" func recipes() []recipe { small := func(name string) recipe { return recipe{Name: name, Round: 1000, TestVectors: true, TestAreaLen: 512} } var out []recipe add := func(r recipe) { out = append(out, r) } // Capsules. r := small("time_only, one file, area of 512") r.Files, r.Node = []fileIn{text("nota.txt", "Hola.\n")}, true add(r) r = small("time_only, a comment alone, bloque256") r.Comment, r.Author, r.Padding, r.Node = "Una línea.\r\nOtra.\rFin", "Ana López", 1, true add(r) r = small("time_and_key, a portable key") r.Policy, r.Portable, r.Node = "time_and_key", true, true r.Files = []fileIn{text("a.txt", "a"), text("b/c.txt", "")} add(r) r = small("time_and_key, three recipients and a portable key, a note and extensions") r.Policy, r.Portable, r.Identities = "time_and_key", true, ids(3) r.Note = "Para Ana, en su cumpleaños" r.Files = []fileIn{text("carta.txt", "Querida Ana:\n")} r.Critical = []extIn{ext("org.example.crit", 1, "c")} r.Noncritical = []extIn{ext("org.example.b", 2, "nb"), {ID: "org.example.a", Version: 1}} r.ControlCritical = []extIn{ext("org.example.ctrl", 1, "k")} r.ControlNoncritical = []extIn{ext("org.example.ctrl2", 3, "kk")} r.HeadCritical = []extIn{ext("org.example.head", 1, "h")} r.HeadNoncritical = []extIn{ext("org.example.head2", 1, "hh")} add(r) add(recipe{Name: "time_only, the common area, files in byte order with mtimes", Round: 1000, Comment: "Fotos del verano", Author: "Luis", Files: []fileIn{ {Path: "vacío.txt", Text: ptr("")}, {Path: "nota.txt", Text: ptr("Hola.\n"), MTime: []int64{1727712000, 0}}, {Path: "fotos/playa.jpg", Pattern: 200000, MTime: []int64{1727712000, 999999999}}, {Path: "\U0001F600.txt", Text: ptr("emoji")}, {Path: "~.txt", Text: ptr("tilde")}, {Path: "fotos/a.txt", Text: ptr("a"), MTime: []int64{0, 0}}, }}) add(recipe{Name: "time_only, mtimes out of range and at its ends", Round: 1000, Padding: 1, Files: []fileIn{ {Path: "a", Text: ptr("1"), MTime: []int64{-1, 999999999}}, {Path: "b", Text: ptr("2"), MTime: []int64{253402300799, 999999999}}, {Path: "c", Text: ptr("3"), MTime: []int64{253402300800, 0}}, {Path: "d", Text: ptr("4"), MTime: []int64{-62135596800, 0}}, {Path: "e", Text: ptr("5"), MTime: []int64{-62135596800, 1}}, }}) add(recipe{Name: "time_and_key, fifteen recipients and a portable key", Round: 1000, Policy: "time_and_key", Identities: ids(15), Portable: true, Files: []fileIn{{Path: "x.bin", Pattern: 65536}}}) add(recipe{Name: "time_and_key, sixteen recipients", Round: 1001, Policy: "time_and_key", Identities: ids(16), Files: []fileIn{{Path: "x.bin", Pattern: 65537}}}) add(recipe{Name: "time_and_key, a key of words and a recipient", Round: 1000, Policy: "time_and_key", Identities: ids(1), Words: words, Files: []fileIn{text("secreto.txt", "42")}}) add(recipe{Name: "a requested instant one nanosecond after round 1000", Round: 1000, UnlockNs: 1, Files: []fileIn{text("a", "a")}}) add(recipe{Name: "a requested instant one nanosecond before round 1000", Round: 1000, UnlockNs: -1, Now: "2023-08-23T15:09:30.5Z", Files: []fileIn{text("a", "a")}}) r = small("signed with alg 1, area of 512") r.AuthorKey, r.Files, r.Node = &authorIn{Seed: authorSeed}, []fileIn{text("nota.txt", "Hola.\n")}, true add(r) add(recipe{Name: "signed with alg 1 and sealed", Round: 1000, AuthorKey: &authorIn{Seed: authorSeed}, Sealer: &sealerIn{}, Policy: "time_and_key", Portable: true, Files: []fileIn{text("nota.txt", "Hola.\n")}}) add(recipe{Name: "sealed, without a signature", Round: 1000, Sealer: &sealerIn{}, Files: []fileIn{text("nota.txt", "Hola.\n")}}) add(recipe{Name: "signed with alg 2 by two signers, each sealed", Round: 1000, CMS: &cmsIn{Signers: []string{"Ana López", "Luis Gómez"}}, Files: []fileIn{text("nota.txt", "Hola.\n")}}) add(recipe{Name: "a signature of 40 KiB with LargeArea: the area of 64 KiB", Round: 1000, LargeArea: true, CMS: &cmsIn{Signers: []string{"Ana López"}, Junk: 40 << 10}, Files: []fileIn{text("nota.txt", "Hola.\n")}}) add(recipe{Name: "LargeArea without a signature keeps the common area", Round: 1000, LargeArea: true, Files: []fileIn{text("nota.txt", "x")}}) add(recipe{Name: "content of whole chunks", Round: 1000, Padding: 1, Files: []fileIn{{Path: "a", Pattern: 65536}, {Path: "b", Pattern: 131072 - 12 - 32768 - 200}}}) add(recipe{Name: "unknown extensions of the application in every array", Round: 1000, Policy: "time_and_key", Words: words, Portable: true, Critical: []extIn{{ID: "z", Version: 4294967295}}, HeadNoncritical: []extIn{ext("org.example.n", 1, "x")}, Files: []fileIn{text("a", "a")}}) // Errors of the options. e := func(name string, f func(r *recipe)) { r := recipe{Name: name, Round: 1000, Files: []fileIn{text("a.txt", "a")}, Node: true} f(&r) add(r) } e("TestAreaLen without TestVectors", func(r *recipe) { r.TestAreaLen = 512 }) e("TestAreaLen not a multiple of 512", func(r *recipe) { r.TestVectors, r.TestAreaLen = true, 1000 }) e("TestAreaLen above the largest", func(r *recipe) { r.TestVectors, r.TestAreaLen = true, 66048 }) e("TestAreaLen with LargeArea", func(r *recipe) { r.TestVectors, r.TestAreaLen, r.LargeArea = true, 512, true }) e("AuthorKey and CMSSigner", func(r *recipe) { r.AuthorKey, r.CMS = &authorIn{Seed: authorSeed}, &cmsIn{Signers: []string{"Ana López"}} }) e("CMSSigner and Sealer", func(r *recipe) { r.CMS, r.Sealer = &cmsIn{Signers: []string{"Ana López"}}, &sealerIn{} }) e("a public note with a line feed", func(r *recipe) { r.Note = "dos\nlíneas" }) e("a public note of 1025 bytes", func(r *recipe) { r.Note = strings.Repeat("a", 1025) }) e("datekeys.note in CONTROL_CBOR", func(r *recipe) { r.ControlNoncritical = []extIn{ext("datekeys.note", 1, "hola")} }) e("datekeys.capsule in PUBLIC_HEADER", func(r *recipe) { r.Noncritical = []extIn{ext("datekeys.capsule", 1, "x")} }) e("datekeys.note with invalid data", func(r *recipe) { r.Noncritical = []extIn{ext("datekeys.note", 1, "a\tb")} }) e("datekeys.note without data", func(r *recipe) { r.Noncritical = []extIn{{ID: "datekeys.note", Version: 1}} }) e("datekeys.note in the critical array of the head", func(r *recipe) { r.HeadCritical = []extIn{ext("datekeys.note", 1, "hola")} }) e("an extension in both arrays", func(r *recipe) { r.Critical = []extIn{ext("org.example.x", 1, "a")} r.Noncritical = []extIn{ext("org.example.x", 2, "b")} }) e("a profile whose chain hash does not match", func(r *recipe) { r.Profile = "genesis_plus_one" }) e("an instant that is now", func(r *recipe) { r.Now = "2023-08-23T15:59:24Z" }) e("an instant after now by one nanosecond", func(r *recipe) { r.Now = "2023-08-23T15:59:23.999999999Z"; r.Node = false }) e("an instant before the genesis", func(r *recipe) { r.Round, r.UnlockNs, r.Now = 1, -5e9, "2023-08-23T14:00:00Z" }) e("time_only with a recipient", func(r *recipe) { r.Identities = ids(1) }) e("time_only with a portable key", func(r *recipe) { r.Portable = true }) e("time_only with words", func(r *recipe) { r.Words = words }) e("time_and_key without credentials", func(r *recipe) { r.Policy = "time_and_key" }) e("seventeen credentials", func(r *recipe) { r.Policy, r.Identities, r.Portable = "time_and_key", ids(16), true }) e("seventeen credentials with words", func(r *recipe) { r.Policy, r.Identities, r.Words = "time_and_key", ids(16), words }) e("a recipient with bit 255 set", func(r *recipe) { r.Policy, r.Identities = "time_and_key", ids(1) raw := must(agewrap.RawX25519Recipient(identity("holder 0").Recipient())) raw[31] |= 0x80 r.RawRecipients = []string{h(raw)} }) e("a recipient of u = p", func(r *recipe) { r.Policy = "time_and_key" r.RawRecipients = []string{"edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"} }) e("a recipient of low order", func(r *recipe) { r.Policy = "time_and_key" r.RawRecipients = []string{h(make([]byte, 32))} }) e("a recipient listed twice", func(r *recipe) { r.Policy, r.Identities = "time_and_key", ids(2) r.RawRecipients = []string{h(must(agewrap.RawX25519Recipient(identity("holder 1").Recipient())))} }) e("five words", func(r *recipe) { r.Policy, r.Words = "time_and_key", words[:5] }) e("words with a control character", func(r *recipe) { r.Policy, r.Words = "time_and_key", append(append([]string(nil), words...), "a\u0085b") }) e("words with an invisible character", func(r *recipe) { r.Policy, r.Words = "time_and_key", append(append([]string(nil), words...), "a​b") }) e("no file and no comment", func(r *recipe) { r.Files = nil }) e("a comment that is a lone CR", func(r *recipe) { r.Files, r.Comment = nil, "\r" }) e("a comment with a control character", func(r *recipe) { r.Comment = "hola\x01" }) e("a comment of 16385 bytes", func(r *recipe) { r.Comment = strings.Repeat("ñ", 8192) + "a" }) e("a declared author with a line feed", func(r *recipe) { r.Author = "Ana\nLópez" }) e("a declared author of 257 bytes", func(r *recipe) { r.Author = strings.Repeat("a", 257) }) e("a path given twice", func(r *recipe) { r.Files = []fileIn{text("b", "1"), text("a", "2"), text("b", "3")} }) e("an empty path", func(r *recipe) { r.Files = []fileIn{text("", "1")} }) e("a path of 1025 bytes", func(r *recipe) { r.Files = []fileIn{text(strings.Repeat("a", 1025), "1")} }) e("an absolute path", func(r *recipe) { r.Files = []fileIn{text("/etc/passwd", "1")} }) e("a path with ..", func(r *recipe) { r.Files = []fileIn{text("a/../b", "1")} }) e("a path with a reserved name", func(r *recipe) { r.Files = []fileIn{text("docs/CON.txt", "1")} }) e("a file that is a folder too", func(r *recipe) { r.Files = []fileIn{text("a/b", "1"), text("a", "2"), text("c", "3")} }) e("two paths that fold to the same key", func(r *recipe) { r.Files = []fileIn{text("x/Año.txt", "1"), text("x/AÑO.txt", "2")} }) e("a negative size", func(r *recipe) { r.Files = []fileIn{{Path: "a", Text: ptr(""), Size: ptr(int64(-1))}} }) e("files beyond L_MAX", func(r *recipe) { r.Files = []fileIn{text("a", "a"), {Path: "b", Text: ptr(""), Size: ptr(int64(capsule.MaxPayloadLength))}} }) e("L beyond L_MAX", func(r *recipe) { r.Files = []fileIn{{Path: "b", Text: ptr(""), Size: ptr(int64(capsule.MaxPayloadLength - 100))}} }) e("65536 files", func(r *recipe) { r.Files, r.ManyFiles, r.Node = nil, 65536, false }) for _, f := range []string{"open1", "short1", "long1", "open2", "short2", "long2", "change2", "read2"} { e("a source that fails: "+f, func(r *recipe) { r.Files = []fileIn{text("a.txt", "a"), {Path: "b.bin", Pattern: 100000, Fault: f}} r.Node = false }) } e("a source that changes in a small file", func(r *recipe) { r.Files = []fileIn{{Path: "a", Text: ptr("abc"), Fault: "change2"}} }) // Errors of the hooks. e("an author key whose signature does not verify", func(r *recipe) { r.AuthorKey = &authorIn{Seed: authorSeed, Bad: "zero_signature"} }) e("an author key of 31 bytes", func(r *recipe) { r.AuthorKey = &authorIn{Seed: authorSeed, Bad: "short_key"} }) e("a CMS signature that lacks a required signer", func(r *recipe) { r.CMS, r.Node = &cmsIn{Signers: []string{"Ana López"}, Extra: "Falta"}, false }) e("a CMS signature without seals", func(r *recipe) { r.CMS, r.Node = &cmsIn{Signers: []string{"Ana López"}, Unsealed: true}, false }) e("a CMS signature of 40 KiB without LargeArea", func(r *recipe) { r.CMS, r.Node = &cmsIn{Signers: []string{"Ana López"}, Junk: 40 << 10}, false }) e("a CMS signature larger than a test area", func(r *recipe) { r.CMS, r.TestVectors, r.TestAreaLen, r.Node = &cmsIn{Signers: []string{"Ana López"}}, true, 512, false }) e("a CMS signer that fails", func(r *recipe) { r.CMS = &cmsIn{Signers: []string{"Ana López"}, Error: "the person cancelled"} }) e("a CMS signer that returns garbage", func(r *recipe) { r.CMS = &cmsIn{Signers: []string{"Ana López"}, Garbage: true} }) e("a CMS signer without signers", func(r *recipe) { r.CMS = &cmsIn{} }) e("a sealer that fails", func(r *recipe) { r.Sealer = &sealerIn{Error: "the authority is down"} }) for i := range out { if out[i].Seed == "" { out[i].Seed = "capsule writer " + out[i].Name } } return out } // --------------------------------------------------------------------------- // crypto/rand from a seed // seeded is the crypto/rand.Reader of a case: the ChaCha20 keystream under // SHA-256(seed) and a zero nonce, the stream of SeededRandomSource. It // records the size of every read. type seeded struct { c *chacha20.Cipher draws []int } func newSeeded(seed string) *seeded { key := sha256.Sum256([]byte(seed)) return &seeded{c: must(chacha20.NewUnauthenticatedCipher(key[:], make([]byte, chacha20.NonceSize)))} } func (s *seeded) Read(p []byte) (int, error) { clear(p) s.c.XORKeyStream(p, p) s.draws = append(s.draws, len(p)) return len(p), nil } // --------------------------------------------------------------------------- // The keys and the hooks // identity is the X25519 identity of a label: its raw secret is // SHA-256("identity " + label). func identity(label string) *age.X25519Identity { s := sha256.Sum256([]byte("identity " + label)) return must(agewrap.X25519IdentityFromRaw(s[:])) } // recipientOfRaw is the age recipient of 32 raw bytes, whatever they are: // age.ParseX25519Recipient accepts any point. func recipientOfRaw(raw []byte) *age.X25519Recipient { return must(age.ParseX25519Recipient(must(bech32.Encode("age", raw)))) } // The certificates of the CMS hooks, made in this order under the fixed // randomness of the test, and the authority. type certs struct { byName map[string]cmstest.Signer tsa cmstest.Signer } var ( certFrom, certTo = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2040, 1, 1, 0, 0, 0, 0, time.UTC) ) func newCerts() *certs { c := &certs{byName: map[string]cmstest.Signer{}} c.byName["Ana López"] = cmstest.NewECDSA("Ana López", elliptic.P256(), certFrom, certTo) c.byName["Luis Gómez"] = cmstest.NewRSA("Luis Gómez", 2048, certFrom, certTo) c.byName["Falta"] = cmstest.NewECDSA("Falta", elliptic.P256(), certFrom, certTo) c.tsa = cmstest.NewECDSA("TSA de prueba", elliptic.P256(), certFrom, certTo) return c } // hooks records what each hook was given and what it returned. type hooks struct { rec obj } func (k *hooks) set(name string, v any) { k.rec[name] = v } // authorHook is the author key of a recipe. type authorHook struct { key *authorkey.Key bad string k *hooks } func (a *authorHook) Public() []byte { pub := a.key.Public() if a.bad == "short_key" { pub = pub[:31] } a.k.set("author_public", h(pub)) return pub } func (a *authorHook) Sign(msg []byte) []byte { sig := a.key.Sign(msg) if a.bad == "zero_signature" { sig = make([]byte, ed25519.SignatureSize) } a.k.set("author_message", h(msg)) a.k.set("author_signature", h(sig)) return sig } // cmsHook signs as a signing application, as cmsSigner of signed_test.go. type cmsHook struct { in cmsIn c *certs when time.Time k *hooks } func (s *cmsHook) signers() []cmstest.Signer { var out []cmstest.Signer for _, n := range s.in.Signers { out = append(out, s.c.byName[n]) } return out } func (s *cmsHook) Signers() [][32]byte { var out [][32]byte for _, x := range s.signers() { out = append(out, sha256.Sum256(x.Cert.Raw)) } if s.in.Extra != "" { out = append(out, sha256.Sum256(s.c.byName[s.in.Extra].Cert.Raw)) } list := []string{} for _, x := range out { list = append(list, h(x[:])) } s.k.set("cms_signers", list) return out } func (s *cmsHook) Sign(msg []byte) ([]byte, error) { s.k.set("cms_message", h(msg)) if s.in.Error != "" { return nil, errors.New(s.in.Error) } var der []byte if s.in.Garbage { der = []byte("not a signature") } else { o := cmstest.Options{Junk: s.in.Junk} if !s.in.Unsealed { o.Token = func(sig []byte) []byte { return cmstest.Token(sig, s.when, cmstest.TokenOptions{Accuracy: time.Second}, s.c.tsa) } } der = cmstest.Signature(msg, o, s.signers()...) } s.k.set("cms_der", h(der)) return der, nil } // sealHook asks the authority, as sealer of signed_test.go. type sealHook struct { in sealerIn c *certs when time.Time k *hooks } func (s *sealHook) Seal(subject [32]byte) ([]byte, error) { s.k.set("seal_subject", h(subject[:])) if s.in.Error != "" { return nil, errors.New(s.in.Error) } token := cmstest.Token(subject[:], s.when, cmstest.TokenOptions{}, s.c.tsa) s.k.set("seal_token", h(token)) return token, nil } // --------------------------------------------------------------------------- // Sources func pattern(n int) []byte { b := make([]byte, n) for i := range b { b[i] = byte(31*i + 7) } return b } func contentOf(f fileIn) []byte { if f.Text != nil { return []byte(*f.Text) } return pattern(f.Pattern) } type errReader struct{ err error } func (r errReader) Read([]byte) (int, error) { return 0, r.err } func sourceOf(f fileIn) capsule.Source { content := contentOf(f) size := int64(len(content)) if f.Size != nil { size = *f.Size } calls := 0 var mtime time.Time if f.MTime != nil { mtime = time.Unix(f.MTime[0], f.MTime[1]) } return capsule.Source{Path: f.Path, Size: size, ModTime: mtime, Open: func() (io.ReadCloser, error) { if f.Size != nil && *f.Size != int64(len(content)) { panic("a source opened whose size is not its content's") } calls++ c := content switch fmt.Sprintf("%s/%d", f.Fault, calls) { case "open1/1", "open2/2": return nil, errors.New("the file is gone") case "short1/1", "short2/2": c = c[:len(c)-1] case "long1/1", "long2/2": c = append(bytes.Clone(c), 'x') case "change2/2": c = bytes.Clone(c) c[0] ^= 1 case "read2/2": return io.NopCloser(io.MultiReader(bytes.NewReader(c[:len(c)/2]), errReader{errors.New("the disk failed")})), nil } return io.NopCloser(bytes.NewReader(c)), nil }} } // --------------------------------------------------------------------------- // Running a recipe func exts(list []extIn) []extension.Extension { var out []extension.Extension for _, e := range list { x := extension.Extension{ID: e.ID, Version: e.Version} if e.Data != nil { x.Data = unhex(*e.Data) if x.Data == nil { x.Data = []byte{} } } out = append(out, x) } return out } func profileOf(name string) *profile.Profile { p := profile.Quicknet() switch name { case "": case "genesis_plus_one": p.GenesisTime++ default: panic("profile " + name) } return p } func nowOf(r recipe, p *profile.Profile) time.Time { if r.Now == "" { return time.Unix(p.GenesisTime, 0).UTC() } return must(time.Parse(time.RFC3339Nano, r.Now)) } // run writes the capsule of r with EncryptFiles while crypto/rand reads the // keystream of its seed. func run(r recipe, c *certs) (*capsule.Result, []byte, *seeded, obj, error) { p := profileOf(r.Profile) q := profile.Quicknet() unlock := must(datekey.RoundTime(q, r.Round)).Add(time.Duration(r.UnlockNs)) now := nowOf(r, q) opts := capsule.EncryptOptions{ Profile: p, UnlockAt: unlock, Now: func() time.Time { return now }, NewPortableKey: r.Portable, Words: r.Words, Padding: capsule.Padding(r.Padding), Critical: exts(r.Critical), Noncritical: exts(r.Noncritical), ControlCritical: exts(r.ControlCritical), ControlNoncritical: exts(r.ControlNoncritical), HeadCritical: exts(r.HeadCritical), HeadNoncritical: exts(r.HeadNoncritical), Comment: r.Comment, Author: r.Author, PublicNote: r.Note, LargeArea: r.LargeArea, TestVectors: r.TestVectors, TestAreaLen: r.TestAreaLen, } if r.Policy == "time_and_key" { opts.Policy = capsule.TimeAndKey } for _, l := range r.Identities { opts.Recipients = append(opts.Recipients, identity(l).Recipient()) } for _, raw := range r.RawRecipients { opts.Recipients = append(opts.Recipients, recipientOfRaw(unhex(raw))) } k := &hooks{rec: obj{}} if r.AuthorKey != nil { opts.AuthorKey = &authorHook{key: must(authorkey.NewFromSeed(unhex(r.AuthorKey.Seed))), bad: r.AuthorKey.Bad, k: k} } if r.CMS != nil { opts.CMSSigner = &cmsHook{in: *r.CMS, c: c, when: now, k: k} } if r.Sealer != nil { opts.Sealer = &sealHook{in: *r.Sealer, c: c, when: now, k: k} } var sources []capsule.Source for _, f := range r.Files { sources = append(sources, sourceOf(f)) } for i := range r.ManyFiles { sources = append(sources, sourceOf(text(fmt.Sprintf("f%05d", i), ""))) } s := newSeeded(r.Seed) old := rand.Reader rand.Reader = s var dst bytes.Buffer res, err := capsule.EncryptFiles(&dst, sources, opts) rand.Reader = old return res, dst.Bytes(), s, k.rec, err } // --------------------------------------------------------------------------- // Opening func releases() provider.ReleaseSource { return testkit.NewSource(testkit.Release(1000), testkit.Release(1001)) } func extSet(r recipe) extension.Set { s := extension.Set{} for _, l := range [][]extIn{r.Critical, r.Noncritical, r.ControlCritical, r.ControlNoncritical, r.HeadCritical, r.HeadNoncritical} { for _, e := range l { s[e.ID] = append(s[e.ID], e.Version) } } return s } func opened(r recipe, dkc []byte, ids []age.Identity, dkk []byte) obj { files := &testkit.MemorySink{} o := capsule.OpenOptions{ Registry: testkit.Registry(), Extensions: extSet(r), Source: releases(), Identities: ids, Sink: files, Now: func() time.Time { return time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC) }, } if dkk != nil { o.AccessKeyFile = bytes.NewReader(dkk) } res, err := capsule.Open(context.Background(), nil, bytes.NewReader(dkc), o) v := obj{} if err != nil { v["result"] = datekeys.Code(err) if v["result"] == "" { v["result"] = "error: " + err.Error() } if res != nil && res.Inspection != nil && len(res.Inspection.Checks) > 0 { v["step"] = res.Inspection.Checks[len(res.Inspection.Checks)-1].Step } return v } v["result"] = "ok" var fs []obj for i, f := range res.Head.Files { fj := obj{"path": f.Path, "size": f.Size, "sha256": sum(files.Files[i])} if f.HasMTime { fj["mtime"] = f.MTime } fs = append(fs, fj) } if fs == nil { fs = []obj{} } v["files"] = fs v["head"] = h(must(capsule.EncodeHead(res.Head))) v["verdicts"] = []string{string(res.Verdicts.Signature), string(res.Verdicts.Seal)} v["area_len"] = res.AreaLen v["length"] = res.PayloadLength v["padded_length"] = res.PaddedLength return v } func opens(r recipe, dkc []byte, res *capsule.Result, dkk []byte) []obj { if r.Policy != "time_and_key" { v := opened(r, dkc, nil, nil) v["with"] = "time" return []obj{v} } var out []obj var all []age.Identity for _, l := range r.Identities { id := identity(l) v := opened(r, dkc, []age.Identity{id}, nil) v["with"] = "identity " + l out = append(out, v) all = append(all, id) } if len(r.Words) != 0 { id := must(wordkey.Identity(r.Words, profile.Quicknet().ChainHash[:], res.DateKey.Round, res.CapsuleID[:])) v := opened(r, dkc, []age.Identity{id}, nil) v["with"] = "words" out = append(out, v) all = append(all, id) } if dkk != nil { v := opened(r, dkc, nil, dkk) v["with"] = "portable" out = append(out, v) } v := opened(r, dkc, all, dkk) v["with"] = "all" out = append(out, v) v = opened(r, dkc, nil, nil) v["with"] = "none" out = append(out, v) return out } // layers opens SEALED_CONTROL with the release and, in time_and_key, with // the first credential, and returns PUBLIC_HEADER, CONTROL_CBOR and whether // they and the .dkk encode again to the bytes written. func layers(r recipe, dkc []byte, res *capsule.Result, dkk []byte) (header, control []byte, same bool) { pre := must(capsule.ParsePrelude(dkc)) header = dkc[capsule.PreludeSize : capsule.PreludeSize+int(pre.PublicHeaderLen)] sealed := dkc[capsule.PreludeSize+int(pre.PublicHeaderLen) : capsule.PreludeSize+int(pre.PublicHeaderLen)+int(pre.SealedControlLen)] hd := must(capsule.DecodeHeader(header)) same = bytes.Equal(must(capsule.EncodeHeader(hd)), header) tid := must(agewrap.NewTimeIdentity(profile.Quicknet(), res.DateKey.Round, testkit.Release(res.DateKey.Round))) control = must(io.ReadAll(must(age.Decrypt(bytes.NewReader(sealed), tid)))) if r.Policy == "time_and_key" { var id age.Identity switch { case len(r.Identities) > 0: id = identity(r.Identities[0]) case dkk != nil: k := must(accesskey.Decode(bytes.NewReader(dkk))) id = must(agewrap.X25519IdentityFromRaw(k.Material)) default: id = must(wordkey.Identity(r.Words, profile.Quicknet().ChainHash[:], res.DateKey.Round, res.CapsuleID[:])) } aid := must(agewrap.NewAccessIdentity(agewrap.AccessSlots, id)) control = must(io.ReadAll(must(age.Decrypt(bytes.NewReader(control), aid)))) } c := must(capsule.DecodeControl(control, pre.Format)) same = same && bytes.Equal(must(capsule.EncodeControl(c, pre.Format)), control) if dkk != nil { k := must(accesskey.Decode(bytes.NewReader(dkk))) var b bytes.Buffer must(0, accesskey.Encode(&b, k)) same = same && bytes.Equal(b.Bytes(), dkk) } return header, control, same } // --------------------------------------------------------------------------- // The vectors func caseOf(r recipe, c *certs) obj { res, dkc, s, rec, err := run(r, c) draws := s.draws if draws == nil { draws = []int{} } out := obj{"recipe": r, "draws": draws} if len(rec) > 0 { out["hooks"] = rec } if err != nil { e := obj{"text": err.Error(), "written": len(dkc)} if code := datekeys.Code(err); code != "" { e["code"] = code } out["error"] = e return out } var dkk []byte if res.PortableKey != nil { var b bytes.Buffer must(0, accesskey.Encode(&b, res.PortableKey)) dkk = b.Bytes() } header, control, same := layers(r, dkc, res, dkk) w := obj{ "length_dkc": len(dkc), "sha256": sum(dkc), "round": res.DateKey.Round, "unlock_at": res.UnlockAt.UTC().Format(time.RFC3339Nano), "capsule_id": h(res.CapsuleID[:]), "length": res.Length, "padding": int(res.Padding), "padded_length": res.PaddedLength, "head": h(must(capsule.EncodeHead(res.Head))), "header": h(header), "control": h(control), "reencoded": same, } if len(dkc) <= 4096 { w["dkc"] = h(dkc) } if dkk != nil { w["dkk"] = h(dkk) } out["written"] = w out["opens"] = opens(r, dkc, res, dkk) return out } func TestWriteVectors(t *testing.T) { if *sourceFlag == "" || *outFlag == "" { t.Skip("run with -args -source COMMIT -out DIR") } cryptotest.SetGlobalRandom(t, 20261006) c := newCerts() var cases, node []obj for _, r := range recipes() { v := caseOf(r, c) cases = append(cases, v) if r.Node { node = append(node, v) } if e, ok := v["error"]; ok { t.Logf("%s: %s", r.Name, e.(obj)["text"]) } else { t.Logf("%s: %d bytes", r.Name, v["written"].(obj)["length_dkc"]) } } rel := obj{} for _, round := range []uint64{1000, 1001} { rel[fmt.Sprint(round)] = h(testkit.Release(round).Signature) } doc := func(cases []obj) []byte { var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) enc.SetIndent("", " ") must(0, enc.Encode(obj{ "description": "What capsule.EncryptFiles of datekeys-go writes for each recipe while crypto/rand reads the keystream of SeededRandomSource (ChaCha20 under SHA-256(seed), zero nonce), with the size of each draw and what each hook was given and returned; and the text, the code and the bytes written of each error (tool/capsule_writer_go_vectors_test.go). A capsule written is opened with capsule.Open with each credential, all together and none. A file of n bytes of pattern has (31·i + 7) mod 256 as byte i; the raw secret of the identity of a label is SHA-256(\"identity \" + label).", "source": *sourceFlag, "go": runtime.Version(), "releases": rel, "cases": cases, })) if bytes.Contains(buf.Bytes(), []byte("'''")) { t.Fatal("the JSON holds three quotes") } return buf.Bytes() } full := doc(cases) path := filepath.Join(*outFlag, "capsule_writer.json") must(0, os.WriteFile(path, full, 0o644)) t.Logf("wrote %s, %d bytes", path, len(full)) dart := "// Generated by tool/capsule_writer_go_vectors_test.go: the cases of\n" + "// test/vectors/capsule_writer.json marked node, for the tests that also run\n" + "// compiled to JavaScript, where no file can be read. Do not edit.\n\n" + "/// Part of test/vectors/capsule_writer.json.\n" + "const capsuleWriterJson = r'''\n" + string(doc(node)) + "''';\n" dpath := filepath.Join(*outFlag, "capsule_writer.g.dart") must(0, os.WriteFile(dpath, []byte(dart), 0o644)) t.Logf("wrote %s", dpath) }