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.
DateKeys/capsule/signature2_test.go

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) }

Powered by TurnKey Linux.