// Writes src/lib/dkc/testing/signing-vectors.json, the interoperability of // the hooks of the writer of this library with the Go reference at // spec-v0.12: what capsule.EncryptFiles writes when it signs with alg 1 or // alg 2 and seals with seal_type 2, and how Go reads what encryptFiles of // this library writes. // // cases: for each recipe of this file, EncryptFiles runs while crypto/rand // reads a ChaCha20 keystream under SHA-256(seed) and a zero nonce, and each // draw is recorded in hexadecimal, in its order: the salt of the head, // capsule_id, I_PAYLOAD, what age draws for the measured seal, the real seal // and PAYLOAD_AGE. The hooks are those of the tests of package capsule // (signed_test.go): an author key from a seed (alg 1), and the CMS signatures // and RFC 3161 tokens of internal/cms/cmstest (alg 2 and seal_type 2), whose // 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 this library hand the writer the same signatures and tokens, check // that it asks for them over the same messages, and write the same bytes with // the same draws. For each capsule it records its length, its SHA-256, its // SECURITY_CBOR and the size of its area, and what capsule.Open gives, with // and without the author key saved under a label: the verdicts, the lines that // show them, the head and the files. For each error, its text. // // samples: with -samples, the capsules that scripts/signing-ts-samples.mjs // writes with encryptFiles of this library, with its own random values and // its own certificates, opened with capsule.Open, with their verdicts and // lines. // // 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: at the tag spec-v0.12 when it was written, and at // 4f78854, the draft v0.16, since. From the root of this repository: // // node scripts/signing-ts-samples.mjs > /tmp/ts-signing.json // commit=$(git -C ../datekeys-go rev-parse '4f78854^{commit}') // tmp=$(mktemp -d) // git -C ../datekeys-go archive "$commit" | tar -x -C "$tmp" // mkdir "$tmp/signingvectors" // cp scripts/signing-go-vectors_test.go "$tmp/signingvectors/" // (cd "$tmp/signingvectors" && go test -run TestSigningVectors -count=1 \ // -args -source "$commit" -samples /tmp/ts-signing.json \ // -out "$OLDPWD/src/lib/dkc/testing/signing-vectors.json") // rm -rf "$tmp" // // The cases are the same on every run with Go 1.26.8; the samples are // random, and frozen with what Go gives for them. For v0.16 the frozen // samples were read again, with their "ts" as this library reads them now, // and the tokens of the sealer, without accuracy, give S5 (spec v0.16, // §29.11). package signingvectors import ( "bytes" "context" "crypto/ed25519" "crypto/elliptic" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "flag" "io" "os" "runtime" "testing" "testing/cryptotest" "time" "golang.org/x/crypto/chacha20" datekeys "g.activething.com/go/DateKeys" "g.activething.com/go/DateKeys/authorkey" "g.activething.com/go/DateKeys/capsule" "g.activething.com/go/DateKeys/datekey" "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" ) var ( sourceFlag = flag.String("source", "", "the commit of datekeys-go that this tree exports") outFlag = flag.String("out", "", "the JSON file to write") samplesFlag = flag.String("samples", "", "the output of scripts/signing-ts-samples.mjs") ) 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 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; -1 for the most that still fails as too large 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 Late bool `json:"late,omitempty"` // the authority seals an hour after the time of the round } type fileIn struct { Path string `json:"path"` Text string `json:"text"` } type recipe struct { Name string `json:"name"` Seed string `json:"seed"` Files []fileIn `json:"files,omitempty"` Comment string `json:"comment,omitempty"` Author string `json:"author,omitempty"` AuthorKey *authorIn `json:"author_key,omitempty"` CMS *cmsIn `json:"cms,omitempty"` Sealer *sealerIn `json:"sealer,omitempty"` LargeArea bool `json:"large_area,omitempty"` TestAreaLen uint32 `json:"test_area_len,omitempty"` } const authorSeed = "a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0" func recipes() []recipe { files := []fileIn{{"nota.txt", "Hola.\n"}, {"fotos/año 2026.txt", "Una foto que no es una foto.\n"}} key := func() *authorIn { return &authorIn{Seed: authorSeed} } var out []recipe add := func(r recipe) { r.Seed = "signing " + r.Name out = append(out, r) } // Capsules. add(recipe{Name: "alg 1", Files: files, Comment: "Firmado con mi clave.", Author: "Ana López", AuthorKey: key()}) add(recipe{Name: "alg 1 and a seal", Files: files, AuthorKey: key(), Sealer: &sealerIn{}}) add(recipe{Name: "a seal alone", Files: files[:1], Sealer: &sealerIn{}}) add(recipe{Name: "alg 2, two signers, sealed", Files: files, Comment: "Firmado por los dos.", CMS: &cmsIn{Signers: []string{"Ana López", "Luis Gómez"}}}) add(recipe{Name: "alg 2, a large area", Files: files[:1], CMS: &cmsIn{Signers: []string{"Ana López"}, Junk: 40000}, LargeArea: true}) add(recipe{Name: "alg 1, a large area that does not widen", Comment: "Solo un comentario.", AuthorKey: key(), LargeArea: true}) add(recipe{Name: "alg 1, an area of 512", Files: files[:1], AuthorKey: key(), TestAreaLen: 512}) add(recipe{Name: "a seal after the date", Files: files[:1], AuthorKey: key(), Sealer: &sealerIn{Late: true}}) // Errors. add(recipe{Name: "AuthorKey and CMSSigner", Files: files, AuthorKey: key(), CMS: &cmsIn{Signers: []string{"Ana López"}}}) add(recipe{Name: "CMSSigner and Sealer", Files: files, CMS: &cmsIn{Signers: []string{"Ana López"}}, Sealer: &sealerIn{}}) add(recipe{Name: "a test area and LargeArea", Files: files, AuthorKey: key(), TestAreaLen: 512, LargeArea: true}) add(recipe{Name: "an author key of 31 bytes", Files: files, AuthorKey: &authorIn{Seed: authorSeed, Bad: "short_key"}}) add(recipe{Name: "a signature of zeros", Files: files, AuthorKey: &authorIn{Seed: authorSeed, Bad: "zero_signature"}}) add(recipe{Name: "a signature of zeros and a seal", Files: files, AuthorKey: &authorIn{Seed: authorSeed, Bad: "zero_signature"}, Sealer: &sealerIn{}}) add(recipe{Name: "alg 2 that does not fit", Files: files, CMS: &cmsIn{Signers: []string{"Ana López"}, Junk: 40000}}) add(recipe{Name: "the signing application fails", Files: files, CMS: &cmsIn{Signers: []string{"Ana López"}, Error: "la persona canceló la firma"}}) add(recipe{Name: "the authority fails", Files: files, AuthorKey: key(), Sealer: &sealerIn{Error: "the authority does not answer"}}) add(recipe{Name: "alg 2 without a required signer", Files: files, CMS: &cmsIn{Signers: []string{"Ana López"}, Extra: "Luis Gómez"}}) add(recipe{Name: "alg 2 without seals", Files: files, CMS: &cmsIn{Signers: []string{"Luis Gómez"}, Unsealed: true}}) add(recipe{Name: "alg 2 that is not a signature", Files: files, CMS: &cmsIn{Signers: []string{"Ana López"}, Garbage: true}}) add(recipe{Name: "SIGNERS empty", Files: files, CMS: &cmsIn{}}) add(recipe{Name: "SIGNERS twice", Files: files, CMS: &cmsIn{Signers: []string{"Ana López", "Ana López"}}}) // Last, since its search draws from the generator of the test. add(recipe{Name: "alg 2 too large for any area", Files: files[:1], CMS: &cmsIn{Signers: []string{"Ana López"}, Junk: -1}, LargeArea: true}) return out } // seeded is the crypto/rand.Reader of a case: the ChaCha20 keystream under // SHA-256(seed) and a zero nonce. It records every draw. type seeded struct { c *chacha20.Cipher draws [][]byte } 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, bytes.Clone(p)) return len(p), nil } // --------------------------------------------------------------------------- // The keys and the hooks 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.tsa = cmstest.NewECDSA("TSA de prueba", elliptic.P256(), certFrom, certTo) return c } type hooks struct{ rec obj } func (k *hooks) set(name string, v any) { k.rec[name] = v } 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 } 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 { 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) der(msg []byte, junk int) []byte { o := cmstest.Options{Junk: 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) } } return cmstest.Signature(msg, o, s.signers()...) } 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 switch { case s.in.Garbage: der = []byte("not a signature") case s.in.Junk < 0: // The most junk whose signature still fits in key 2 of the // reader, 64 KiB, while SECURITY_CBOR is more than 64 KiB. base := len(s.der(msg, 1)) junk := 65536 - 45 - base for der = s.der(msg, junk); len(der) > 65536-45; der = s.der(msg, junk) { junk-- } default: der = s.der(msg, s.in.Junk) } s.k.set("cms_der", h(der)) return der, nil } 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 } // --------------------------------------------------------------------------- // Writing and opening func sourceOf(f fileIn) capsule.Source { return capsule.Source{Path: f.Path, Size: int64(len(f.Text)), Open: func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader([]byte(f.Text))), nil }} } // The genesis of Quicknet: the capsules open at round 1000. var genesis = time.Unix(profile.Quicknet().GenesisTime, 0).UTC() func run(r recipe, c *certs) (*capsule.Result, []byte, *seeded, obj, error) { q := profile.Quicknet() opts := capsule.EncryptOptions{ Profile: q, UnlockAt: must(datekey.RoundTime(q, 1000)), Now: func() time.Time { return genesis }, Comment: r.Comment, Author: r.Author, LargeArea: r.LargeArea, TestVectors: r.TestAreaLen != 0, TestAreaLen: r.TestAreaLen, } 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: genesis, k: k} } if r.Sealer != nil { when := genesis if r.Sealer.Late { when = opts.UnlockAt.Add(time.Hour) } opts.Sealer = &sealHook{in: *r.Sealer, c: c, when: when, k: k} } var sources []capsule.Source for _, f := range r.Files { sources = append(sources, sourceOf(f)) } 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 } func releases() provider.ReleaseSource { return testkit.NewSource(testkit.Release(1000)) } // opened is what capsule.Open gives for dkc, with the author keys saved. func opened(dkc []byte, keys map[string]string) obj { files := &testkit.MemorySink{} o := capsule.OpenOptions{ Registry: testkit.Registry(), Source: releases(), Sink: files, AuthorKeys: keys, Now: func() time.Time { return time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC) }, } 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() } return v } v["result"] = "ok" fs := []obj{} for i, f := range res.Head.Files { fs = append(fs, obj{"path": f.Path, "size": f.Size, "sha256": sum(files.Files[i])}) } v["files"] = fs v["head"] = h(must(capsule.EncodeHead(res.Head))) v["verdicts"] = []string{string(res.Verdicts.Signature), string(res.Verdicts.Seal)} if res.Verdicts.AuthorKey != ([32]byte{}) { v["author_key"] = h(res.Verdicts.AuthorKey[:]) } v["lines"] = res.Verdicts.Lines() v["area_len"] = res.AreaLen v["length"] = res.PayloadLength return v } func caseOf(r recipe, c *certs) obj { res, dkc, s, rec, err := run(r, c) draws := []obj{} for _, d := range s.draws { draws = append(draws, obj{"n": len(d), "hex": h(d)}) } out := obj{"recipe": r, "draws": draws} if len(rec) > 0 { out["hooks"] = rec } if err != nil { out["error"] = err.Error() if len(dkc) != 0 { panic(r.Name + ": an error after writing") } return out } out["written"] = obj{"length": len(dkc), "sha256": sum(dkc), "capsule_id": h(res.CapsuleID[:]), "body_length": res.Length} out["opened"] = opened(dkc, nil) if r.AuthorKey != nil { pub := must(authorkey.PublicString(must(authorkey.NewFromSeed(unhex(r.AuthorKey.Seed))).Public())) out["opened_saved"] = opened(dkc, map[string]string{pub: "mi clave de 2026"}) } return out } type sampleIn struct { Name string `json:"name"` DKC string `json:"dkc"` AuthorKeys map[string]string `json:"author_keys,omitempty"` TS obj `json:"ts"` } func TestSigningVectors(t *testing.T) { if *sourceFlag == "" || *outFlag == "" { t.Skip("run with -args -source COMMIT -out FILE [-samples FILE]") } cryptotest.SetGlobalRandom(t, 20261006) c := newCerts() cases := []obj{} for _, r := range recipes() { v := caseOf(r, c) cases = append(cases, v) if e, ok := v["error"]; ok { t.Logf("%s: %s", r.Name, e) } else { t.Logf("%s: %d bytes, %v", r.Name, v["written"].(obj)["length"], v["opened"].(obj)["verdicts"]) } } samples := []obj{} if *samplesFlag != "" { var in struct { Samples []sampleIn `json:"samples"` } must(0, json.Unmarshal(must(os.ReadFile(*samplesFlag)), &in)) for _, s := range in.Samples { dkc := unhex(s.DKC) v := obj{"name": s.Name, "dkc": s.DKC, "ts": s.TS, "opened": opened(dkc, nil)} if s.AuthorKeys != nil { v["author_keys"] = s.AuthorKeys v["opened_saved"] = opened(dkc, s.AuthorKeys) } samples = append(samples, v) t.Logf("sample %s: %v", s.Name, v["opened"].(obj)["verdicts"]) } } 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 when it signs and seals, for each recipe, while crypto/rand reads the keystream of ChaCha20 under SHA-256(seed) with a zero nonce, with each draw and what each hook was given and returned, and how capsule.Open reads it; the text of each error; and how capsule.Open reads the samples that encryptFiles of datekeys-ts wrote (scripts/signing-go-vectors_test.go). The capsules are time_only for round 1000 of Quicknet, written at its genesis.", "source": *sourceFlag, "go": runtime.Version(), "release": h(testkit.Release(1000).Signature), "cases": cases, "samples": samples, })) must(0, os.WriteFile(*outFlag, buf.Bytes(), 0o644)) t.Logf("wrote %s, %d bytes", *outFlag, buf.Len()) }