package capsule_test import ( "bytes" "crypto" "crypto/ecdsa" "crypto/elliptic" "crypto/sha256" "encoding/hex" "slices" "strings" "testing" "time" "g.activething.com/go/DateKeys/authorkey" "g.activething.com/go/DateKeys/capsule" "g.activething.com/go/DateKeys/internal/cms/cmstest" ) var ( certFrom = time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) certTo = time.Date(2032, 1, 1, 0, 0, 0, 0, time.UTC) roundTime = time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC) signedAt = time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) ) func testContext() *capsule.SecurityContext { c := &capsule.SecurityContext{RoundTime: roundTime} c.ControlCommit[0], c.HeadDigest[0] = 1, 2 return c } // quotedNames are the holders of the required signers of v as the text of F6 // writes them. func quotedNames(v capsule.Verdicts) string { var names []string for _, s := range v.Detail.Signers { names = append(names, "«"+s.Holder+"»") } return strings.Join(names, ", ") } // cmsArea builds the SECURITY_CBOR of a capsule with an alg 2 signature by // the signers, who all must sign, sealing each signature with tsa at when. func cmsArea(t *testing.T, c *capsule.SecurityContext, required []cmstest.Signer, signers []cmstest.Signer, tsa cmstest.Signer, when time.Time, seal []byte) []byte { t.Helper() var hashes [][32]byte for _, s := range required { hashes = append(hashes, sha256Sum(s.Cert.Raw)) } list, err := capsule.EncodeSigners(hashes) if err != nil { t.Fatal(err) } msg := capsule.AuthorMessage(c.ControlCommit, c.HeadDigest, capsule.SignersDigest(capsule.AlgCMS, list)) opts := cmstest.Options{} if tsa.Key != nil { opts.Token = func(sig []byte) []byte { return cmstest.Token(sig, when, cmstest.TokenOptions{Accuracy: time.Second}, tsa) } } content, err := capsule.EncodeAuthorSignature(capsule.AlgCMS, list, cmstest.Signature(msg, opts, signers...)) if err != nil { t.Fatal(err) } area, err := capsule.EncodeSecurityWith(content, seal) if err != nil { t.Fatal(err) } return area } // Spec v0.11 §29.7, §29.10: alg 2 gives F6 when every required signer is // valid and sealed, and the first of F2, F5 and F1 that applies otherwise. func TestEvaluateCMS(t *testing.T) { ana := cmstest.NewECDSA("Ana López", elliptic.P256(), certFrom, certTo) luis := cmstest.NewRSA("Luis Gómez", 2048, certFrom, certTo) otro := cmstest.NewECDSA("Otro", elliptic.P384(), certFrom, certTo) tsa := cmstest.NewECDSA("TSA de prueba", elliptic.P256(), certFrom, certTo) c := testContext() // A co-signature, each with its seal: F6, with their names. v := capsule.EvaluateSecurityIn(cmsArea(t, c, []cmstest.Signer{ana, luis}, []cmstest.Signer{ana, luis}, tsa, signedAt, nil), c) if v.Signature != capsule.VerdictSignedComplete || v.Seal != capsule.VerdictNoSeal || v.Detail == nil || len(v.Detail.Signers) != 2 { t.Fatalf("a complete co-signature: %+v", v) } // The names between « and », the authority of each seal, and, since the // lines say "before the date", that DateKeys does not check who issued the // seals (spec §29.7). lines := v.Lines() at := signedAt.UTC().Format(time.RFC3339Nano) want := []string{ "Firmado con un certificado a nombre de " + quotedNames(v) + ". DateKeys no comprueba quién lo emitió: para eso, exporta la firma a un validador oficial.", " «" + v.Detail.Signers[0].Holder + "» (emisor según su certificado: «" + v.Detail.Signers[0].Issuer + "»), sellado por «TSA de prueba» el " + at + ", antes de la fecha de apertura.", " «" + v.Detail.Signers[1].Holder + "» (emisor según su certificado: «" + v.Detail.Signers[1].Issuer + "»), sellado por «TSA de prueba» el " + at + ", antes de la fecha de apertura.", " DateKeys no comprueba quién emitió los sellos.", } if !slices.Equal(lines, want) || !strings.Contains(lines[0], "«Ana López»") || !strings.Contains(lines[0], "«Luis Gómez»") { t.Errorf("lines %q, want %q", lines, want) } // A seal after the round time proves nothing before it, and then no line // warns of who issued it. late := capsule.EvaluateSecurityIn(cmsArea(t, c, []cmstest.Signer{ana}, []cmstest.Signer{ana}, tsa, roundTime.Add(time.Hour), nil), c) if late.Signature != capsule.VerdictSignedComplete || len(late.Lines()) != 2 || !strings.HasSuffix(late.Lines()[1], ", sin acreditar que fuera antes de la fecha de apertura: se selló después de esa fecha o demasiado cerca de ella.") || late.Detail.Signers[0].Reason != capsule.ReasonLate { t.Errorf("a late seal: %+v %q", late, late.Lines()) } // A signer who is not required shows apart, with its result in Spanish, // and does not count. f := capsule.EvaluateSecurityIn(cmsArea(t, c, []cmstest.Signer{ana}, []cmstest.Signer{ana, otro}, tsa, signedAt, nil), c) if f.Signature != capsule.VerdictSignedComplete || len(f.Detail.Foreign) != 1 || f.Detail.Foreign[0].Holder != "Otro" || f.Lines()[len(f.Lines())-1] != " Otro firmante, «Otro»: válida. No cuenta." { t.Errorf("a foreign signer: %+v %q", f, f.Lines()) } // F5, and the result of the first required signer (spec §29.10, steps 1 // to 7): the certificate of a signer that expired before the time of a // valid seal is out of validity; a seal whose authority was not valid at // its time is an invalid seal. expired := cmstest.NewECDSA("Ana caducada", elliptic.P256(), certFrom, signedAt.AddDate(0, -1, 0)) small := cmstest.NewRSA("Clave corta", 1024, certFrom, certTo) for name, tc := range map[string]struct { area []byte want capsule.Verdict result string }{ "a required signer is absent": {cmsArea(t, c, []cmstest.Signer{luis}, []cmstest.Signer{ana}, tsa, signedAt, nil), capsule.VerdictSignedIncomplete, "absent"}, "no seal": {cmsArea(t, c, []cmstest.Signer{ana}, []cmstest.Signer{ana}, cmstest.Signer{}, signedAt, nil), capsule.VerdictSignedIncomplete, "without seal"}, "a certificate out of validity at the time of a valid seal": {cmsArea(t, c, []cmstest.Signer{expired}, []cmstest.Signer{expired}, tsa, signedAt, nil), capsule.VerdictSignedIncomplete, "out of validity"}, "a seal whose authority was not valid at its time": {cmsArea(t, c, []cmstest.Signer{ana}, []cmstest.Signer{ana}, tsa, certFrom.AddDate(-1, 0, 0), nil), capsule.VerdictSignedIncomplete, "invalid seal"}, "a key outside the table": {cmsArea(t, c, []cmstest.Signer{small}, []cmstest.Signer{small}, tsa, signedAt, nil), capsule.VerdictSignedIncomplete, "not verifiable"}, "a key 3 beside it": {cmsArea(t, c, []cmstest.Signer{ana}, []cmstest.Signer{ana}, tsa, signedAt, mustSeal(t, capsule.SealTypeTest, []byte{1})), capsule.VerdictSignedIncomplete, "valid"}, } { v := capsule.EvaluateSecurityIn(tc.area, c) if v.Signature != tc.want || v.Detail == nil || v.Detail.Signers[0].Result != tc.result { t.Errorf("%s: %+v, want %s and %q", name, v, tc.want, tc.result) continue } if lines := v.Lines(); lines[0] != capsule.VerdictSignedIncomplete.Text() { t.Errorf("%s: lines %q", name, lines) } } // Another capsule: the signature does not correspond. other := testContext() other.HeadDigest[5] = 9 area := cmsArea(t, c, []cmstest.Signer{ana}, []cmstest.Signer{ana}, tsa, signedAt, nil) if got := capsule.EvaluateSecurityIn(area, other).Signature; got != capsule.VerdictSignatureInvalid { t.Errorf("another head: %s", got) } if got := capsule.EvaluateSecurityIn(area, nil).Signature; got != capsule.VerdictSignatureUnchecked { t.Errorf("without a context: %s", got) } // F1: SIGNERS out of order, empty, too long, with an element of 31 bytes // or one twice, each beside a CMS signature that is valid for the // AUTHOR_MESSAGE of those very SIGNERS: the rule decides, not the CMS. a, l := sha256Sum(ana.Cert.Raw), sha256Sum(luis.Cert.Raw) if bytes.Compare(a[:], l[:]) > 0 { a, l = l, a } bstr := func(h []byte) []byte { return append([]byte{0x58, byte(len(h))}, h...) } var seventeen []byte for range 17 { seventeen = append(seventeen, bstr(a[:])...) } for name, signers := range map[string][]byte{ "SIGNERS out of order": append(append([]byte{0x82}, bstr(l[:])...), bstr(a[:])...), "an empty SIGNERS": {0x80}, "SIGNERS of 17 entries": append([]byte{0x91}, seventeen...), "SIGNERS with 31 bytes": append([]byte{0x81}, bstr(a[:31])...), "SIGNERS with one entry twice": append(append([]byte{0x82}, bstr(a[:])...), bstr(a[:])...), "SIGNERS of indefinite length": append(append([]byte{0x9f}, bstr(a[:])...), 0xff), "SIGNERS with a byte after them": append(append([]byte{0x81}, bstr(a[:])...), 0x00), } { msg := capsule.AuthorMessage(c.ControlCommit, c.HeadDigest, capsule.SignersDigest(capsule.AlgCMS, signers)) tok := func(sig []byte) []byte { return cmstest.Token(sig, signedAt, cmstest.TokenOptions{}, tsa) } content, err := capsule.EncodeAuthorSignature(capsule.AlgCMS, signers, cmstest.Signature(msg, cmstest.Options{Token: tok}, ana, luis)) if err != nil { t.Fatal(err) } area, _ := capsule.EncodeSecurityWith(content, nil) if v := capsule.EvaluateSecurityIn(area, c); v.Signature != capsule.VerdictSignatureUnchecked || v.Detail != nil { t.Errorf("%s: %+v", name, v) } } content, _ := capsule.EncodeAuthorSignature(capsule.AlgCMS, mustSigners(t, ana), []byte("not DER")) area2, _ := capsule.EncodeSecurityWith(content, nil) if got := capsule.EvaluateSecurityIn(area2, c).Signature; got != capsule.VerdictSignatureUnchecked { t.Errorf("not a CMS: %s", got) } } // Spec v0.12 §29.7: a name of a certificate shows when it meets the rules of // the declared author, has at most 64 code points and no two spaces in a // row; otherwise the SHA-256 of the certificate shows, or that of the name of // the issuer for the issuer. func TestCertificateNamesShown(t *testing.T) { tsa := cmstest.NewECDSA("TSA de prueba", elliptic.P256(), certFrom, certTo) c := testContext() sixtyFour := strings.Repeat("ñ", 64) for name, tc := range map[string]struct { cn string shown bool }{ "a name": {"Ana López", true}, "64 code points": {sixtyFour, true}, "65 code points": {sixtyFour + "a", false}, "two spaces in a row": {"Ana López", false}, "an escape": {"Ana\x1b[31mLópez", false}, "U+202E": {"Ana \xe2\x80\xaezepóL", false}, "a byte order mark": {"\xef\xbb\xbfAna López", false}, "a space at the start": {" Ana López", false}, "a line feed": {"Ana\nLópez", false}, "a zero width space": {"Ana\xe2\x80\x8bLópez", false}, "a tag that spells a text": {"Ana\xf3\xa0\x81\x81", false}, "an emoji with its selector": {"Ana \xe2\x9d\xa4\xef\xb8\x8f", true}, "a combining mark, 64 points": {strings.Repeat("n\xcc\x83", 32), true}, } { s := cmstest.NewCert(cmstest.CertSpec{CN: tc.cn, Issuer: cmstest.Name(cmstest.ATV(cmstest.OIDCommonName, cmstest.UTF8(tc.cn)))}, ecdsaKey()) v := capsule.EvaluateSecurityIn(cmsArea(t, c, []cmstest.Signer{s}, []cmstest.Signer{s}, tsa, signedAt, nil), c) if v.Signature != capsule.VerdictSignedComplete { t.Fatalf("%s: %+v", name, v) } want, issuer := tc.cn, tc.cn if !tc.shown { h, hi := sha256Sum(s.Cert.Raw), sha256Sum(s.Cert.RawIssuer) want, issuer = hex.EncodeToString(h[:]), hex.EncodeToString(hi[:]) } if got := v.Detail.Signers[0]; got.Holder != want || got.Issuer != issuer { t.Errorf("%s: holder %q and issuer %q, want %q and %q", name, got.Holder, got.Issuer, want, issuer) } } } func ecdsaKey() *ecdsa.PrivateKey { return cmstest.ECKey(elliptic.P256()) } func mustSigners(t *testing.T, s cmstest.Signer) []byte { t.Helper() b, err := capsule.EncodeSigners([][32]byte{sha256Sum(s.Cert.Raw)}) if err != nil { t.Fatal(err) } return b } func mustSeal(t *testing.T, typ uint64, token []byte) []byte { t.Helper() b, err := capsule.EncodeSeal(typ, token) if err != nil { t.Fatal(err) } return b } func TestEncodeSigners(t *testing.T) { a, b := sha256Sum([]byte("a")), sha256Sum([]byte("b")) if _, err := capsule.EncodeSigners(nil); err == nil { t.Error("an empty list") } if _, err := capsule.EncodeSigners([][32]byte{a, a}); err == nil { t.Error("a certificate twice") } if _, err := capsule.EncodeSigners(make([][32]byte, 17)); err == nil { t.Error("17 certificates") } x, _ := capsule.EncodeSigners([][32]byte{a, b}) y, _ := capsule.EncodeSigners([][32]byte{b, a}) if string(x) != string(y) || len(x) != 1+2*34 { t.Errorf("not sorted: %x", x) } } // Spec v0.11 §29.11: a seal of seal_type 2 seals SEAL_SUBJECT, and gives S4 // before the round time, S5 after it, and S3, S2 and S1 for what does not // verify, does not decode or uses another hash. func TestEvaluateSeal(t *testing.T) { tsa := cmstest.NewECDSA("Autoridad de Sellado", elliptic.P256(), certFrom, certTo) c := testContext() key, _ := authorkey.Generate() msg := capsule.AuthorMessage(c.ControlCommit, c.HeadDigest, capsule.SignersDigest(capsule.AlgEd25519, nil)) sig, _ := capsule.EncodeAuthorSignature(capsule.AlgEd25519, key.Public(), key.Sign(msg)) subject := capsule.SealSubject(c.ControlCommit, c.HeadDigest, capsule.SigPart(sig)) area := func(token []byte) []byte { a, err := capsule.EncodeSecurityWith(sig, mustSeal(t, capsule.SealTypeRFC3161, token)) if err != nil { t.Fatal(err) } return a } second := cmstest.TokenOptions{Accuracy: time.Second} v := capsule.EvaluateSecurityIn(area(cmstest.Token(subject[:], signedAt, second, tsa)), c) if v.Signature != capsule.VerdictSignedOther || v.Seal != capsule.VerdictSealed || v.Detail == nil || v.Detail.SealHolder != "Autoridad de Sellado" { t.Fatalf("a valid seal: %+v", v) } if lines := v.Lines(); len(lines) != 2 || !strings.Contains(lines[1], "Autoridad de Sellado") || !strings.Contains(lines[1], "2026-09-30T12:00:00Z") { t.Errorf("lines %q", v.Lines()) } // Sealed with its own signature part: without key 2 the subject differs. noSig := capsule.SealSubject(c.ControlCommit, c.HeadDigest, capsule.SigPart(nil)) a, _ := capsule.EncodeSecurityWith(nil, mustSeal(t, capsule.SealTypeRFC3161, cmstest.Token(noSig[:], signedAt, second, tsa))) if v := capsule.EvaluateSecurityIn(a, c); v.Signature != capsule.VerdictNoSignature || v.Seal != capsule.VerdictSealed { t.Errorf("a seal without a signature: %+v", v) } // Spec v0.16, §29.7 and §29.11: a valid seal proves that it came before // the round time only with accuracy; without it, S5 and its reason, the // first that holds. for name, tc := range map[string]struct { o cmstest.TokenOptions when time.Time seal capsule.Verdict reason capsule.SealReason }{ "no accuracy, years before": {cmstest.TokenOptions{}, signedAt, capsule.VerdictSealedLate, capsule.ReasonNoAccuracy}, "no accuracy under BTSP": {cmstest.TokenOptions{Policy: cmstest.BTSPPolicy}, signedAt, capsule.VerdictSealedLate, capsule.ReasonNoAccuracyBTSP}, "no accuracy, after the round time": {cmstest.TokenOptions{Policy: cmstest.BTSPPolicy}, roundTime, capsule.VerdictSealedLate, capsule.ReasonLate}, "an accuracy of 0 seconds": {cmstest.TokenOptions{AccuracyRaw: cmstest.Seq(cmstest.Int(0))}, roundTime.Add(-time.Microsecond), capsule.VerdictSealed, capsule.ReasonNone}, "an empty accuracy": {cmstest.TokenOptions{AccuracyRaw: cmstest.Seq()}, signedAt, capsule.VerdictSealed, capsule.ReasonNone}, "BTSP with accuracy": {cmstest.TokenOptions{Policy: cmstest.BTSPPolicy, Accuracy: time.Second}, signedAt, capsule.VerdictSealed, capsule.ReasonNone}, "an accuracy of 0 at the round time": {cmstest.TokenOptions{AccuracyRaw: cmstest.Seq(cmstest.Int(0))}, roundTime, capsule.VerdictSealedLate, capsule.ReasonLate}, } { v := capsule.EvaluateSecurityIn(area(cmstest.Token(subject[:], tc.when, tc.o, tsa)), c) if v.Seal != tc.seal || v.Detail == nil || v.Detail.SealReason != tc.reason { t.Errorf("%s: %+v", name, v) continue } last := v.Lines()[len(v.Lines())-1] if tc.seal == capsule.VerdictSealedLate && last != "No acredita que se sellara antes de la fecha de apertura: "+tc.reason.Text()+"." { t.Errorf("%s: line %q", name, last) } } if capsule.ReasonNone.Text() != "" || capsule.VerdictSealedLate.Text() != "" { t.Error("S5 has no text of its own: Lines writes it with its reason") } for name, tc := range map[string]struct { token []byte ctx *capsule.SecurityContext want capsule.Verdict }{ "after the round time": {cmstest.Token(subject[:], roundTime.Add(time.Minute), cmstest.TokenOptions{}, tsa), c, capsule.VerdictSealedLate}, "the accuracy reaches it": {cmstest.Token(subject[:], roundTime.Add(-time.Second), cmstest.TokenOptions{Accuracy: 2 * time.Second}, tsa), c, capsule.VerdictSealedLate}, "another subject": {cmstest.Token([]byte("other"), signedAt, cmstest.TokenOptions{}, tsa), c, capsule.VerdictSealInvalid}, "a TSTInfo of version 2": {cmstest.Token(subject[:], signedAt, cmstest.TokenOptions{Version: 2}, tsa), c, capsule.VerdictSealUnreadable}, "not DER": {[]byte("not DER"), c, capsule.VerdictSealUnreadable}, "SHA-384 in the imprint": {cmstest.Token(subject[:], signedAt, cmstest.TokenOptions{Hash: crypto.SHA384}, tsa), c, capsule.VerdictSealUnsupported}, "the TSA expired at its time": {cmstest.Token(subject[:], certTo.AddDate(1, 0, 0), cmstest.TokenOptions{}, tsa), c, capsule.VerdictSealInvalid}, } { if got := capsule.EvaluateSecurityIn(area(tc.token), tc.ctx).Seal; got != tc.want { t.Errorf("%s: %s, want %s", name, got, tc.want) } } // Without a context, as a reader of v0.10: S1. if got := capsule.EvaluateSecurity(area(cmstest.Token(subject[:], signedAt, cmstest.TokenOptions{}, tsa))).Seal; got != capsule.VerdictSealUnsupported { t.Errorf("without a context: %s", got) } } func sha256Sum(b []byte) [32]byte { return sha256.Sum256(b) }