You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
330 lines
16 KiB
330 lines
16 KiB
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.Contains(late.Lines()[1], "no antes de la fecha de apertura") {
|
|
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
|
|
}
|
|
|
|
v := capsule.EvaluateSecurityIn(area(cmstest.Token(subject[:], signedAt, cmstest.TokenOptions{}, 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, cmstest.TokenOptions{}, tsa)))
|
|
if v := capsule.EvaluateSecurityIn(a, c); v.Signature != capsule.VerdictNoSignature || v.Seal != capsule.VerdictSealed {
|
|
t.Errorf("a seal without a signature: %+v", v)
|
|
}
|
|
|
|
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) }
|