Signature plan: alg 2 and the seal of seal_type 2 in the reader and the writer

The reader evaluates a signature of alg 2 (F1, F2, F5, F6, with the signers
named) and a seal of seal_type 2 (S1 to S5) in the context of the capsule,
with SIGNERS in strictly ascending order of certificate hashes. The writer
takes a CMSSigner, which gets AUTHOR_MESSAGE and returns what the person
signed outside, and a Sealer, which asks an authority for the token over
SEAL_SUBJECT; it checks the result with the rules of the reader and writes
nothing unless every required signer is valid and sealed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
v0.11
dev 6 days ago
parent 55a261c792
commit 6cac49b5d8

@ -76,6 +76,17 @@ type EncryptOptions struct {
// Nil for no signature. *authorkey.Key is an AuthorKey. Encrypt, which
// writes format 2, takes none.
AuthorKey AuthorKey
// CMSSigner signs with alg 2, a CMS signature with X.509 certificates
// (spec v0.11, §29.10): EncryptFiles gives it AUTHOR_MESSAGE, which the
// person signs with her signing application, and puts what it returns in
// the security area once it checks that it is complete, with a seal for
// each required signer, as F6. Exclusive with AuthorKey and Sealer. Nil
// for none.
CMSSigner CMSSigner
// Sealer asks for the seal of seal_type 2, an RFC 3161 token over
// SEAL_SUBJECT, after the signature, if there is one (spec §29.11). Nil
// for no seal.
Sealer Sealer
// LargeArea asks EncryptFiles for the security area of 64 KiB instead of
// the common one of 32 KiB, for signatures that do not fit in it (spec
// §29.2, §62.1 rule 13). It never widens on its own: a signature that

@ -168,44 +168,111 @@ func EncryptFiles(dst io.Writer, sources []Source, opts EncryptOptions) (*Result
}
// security returns SECURITY_CBOR for the capsule whose final control is c and
// whose head is head: empty, or with the signature of opts.AuthorKey (spec
// v0.11, §29.3, §29.8, §29.9). It decodes and evaluates what it returns with
// the rules of the reader, in the context of this capsule, and checks that
// it fits in an area of area bytes (§62.1 rules 13, 17 and 19).
// whose head is head: empty, or with the signature of opts.AuthorKey or
// opts.CMSSigner, and the seal of opts.Sealer (spec v0.11, §29.3, §29.8 to
// §29.11). The signature is made first and the seal after it, which seals it.
// It decodes and evaluates what it returns with the rules of the reader, in
// the context of this capsule, and checks that it fits in an area of area
// bytes (§62.1 rules 13, 17, 19 and 21).
func (s *sealer) security(c *Control, head []byte, area uint32) ([]byte, error) {
o := s.opts
switch {
case o.AuthorKey != nil && o.CMSSigner != nil:
return nil, errors.New("capsule: AuthorKey and CMSSigner are exclusive: a capsule has one signature")
case o.CMSSigner != nil && o.Sealer != nil:
return nil, errors.New("capsule: with CMSSigner the seal goes inside each signature (spec §29.10): Sealer must be nil")
}
sc := &SecurityContext{HeadDigest: HeadDigest(head), RoundTime: s.unlock}
want := Verdicts{Signature: VerdictNoSignature, Seal: VerdictNoSeal}
security := EncodeSecurity()
if k := s.opts.AuthorKey; k != nil {
var err error
if sc.ControlCommit, err = ControlCommit(c, Format3); err != nil {
return nil, err
if o.AuthorKey == nil && o.CMSSigner == nil && o.Sealer == nil {
security := EncodeSecurity()
if v := EvaluateSecurityIn(security, sc); v != (Verdicts{Signature: VerdictNoSignature, Seal: VerdictNoSeal}) {
return nil, fmt.Errorf("capsule: self-check: the reader finds the verdicts %s and %s in this security area", v.Signature, v.Seal)
}
pub := k.Public()
return security, nil
}
var err error
if sc.ControlCommit, err = ControlCommit(c, Format3); err != nil {
return nil, err
}
var signature, seal []byte
wantSig := VerdictNoSignature
var key [32]byte
switch {
case o.AuthorKey != nil:
pub := o.AuthorKey.Public()
if len(pub) != 32 {
return nil, fmt.Errorf("capsule: the author key is %d bytes, not 32", len(pub))
}
msg := AuthorMessage(sc.ControlCommit, sc.HeadDigest, SignersDigest(AlgEd25519, nil))
sig := k.Sign(msg)
content, err := EncodeAuthorSignature(AlgEd25519, pub, sig)
if signature, err = EncodeAuthorSignature(AlgEd25519, pub, o.AuthorKey.Sign(msg)); err != nil {
return nil, err
}
wantSig = VerdictSignedOther
copy(key[:], pub)
case o.CMSSigner != nil:
list, err := EncodeSigners(o.CMSSigner.Signers())
if err != nil {
return nil, err
}
if security, err = EncodeSecurityWith(content, nil); err != nil {
// AUTHOR_MESSAGE is what the person sees and signs elsewhere: the
// callback may take as long as she needs.
msg := AuthorMessage(sc.ControlCommit, sc.HeadDigest, SignersDigest(AlgCMS, list))
der, err := o.CMSSigner.Sign(msg)
if err != nil {
return nil, fmt.Errorf("capsule: signing: %w", err)
}
if signature, err = EncodeAuthorSignature(AlgCMS, list, der); err != nil {
return nil, err
}
want.Signature = VerdictSignedOther
copy(want.AuthorKey[:], pub)
wantSig = VerdictSignedComplete
}
wantSeal := VerdictNoSeal
if o.Sealer != nil {
subject := SealSubject(sc.ControlCommit, sc.HeadDigest, SigPart(signature))
token, err := o.Sealer.Seal(subject)
if err != nil {
return nil, fmt.Errorf("capsule: sealing: %w", err)
}
if seal, err = EncodeSeal(SealTypeRFC3161, token); err != nil {
return nil, err
}
wantSeal = VerdictSealed
}
security, err := EncodeSecurityWith(signature, seal)
if err != nil {
return nil, err
}
if len(security) > int(area) {
return nil, fmt.Errorf("capsule: SECURITY_CBOR of %d bytes does not fit in the area of %d bytes", len(security), area)
return nil, fmt.Errorf("capsule: SECURITY_CBOR of %d bytes does not fit in the area of %d bytes: LargeArea asks for 64 KiB", len(security), area)
}
if v := EvaluateSecurityIn(security, sc); v != want {
return nil, fmt.Errorf("capsule: self-check: the reader finds the verdicts %s and %s in this security area, not %s and %s", v.Signature, v.Seal, want.Signature, want.Seal)
v := EvaluateSecurityIn(security, sc)
// A seal that proves nothing before the round time (S5) is still a seal
// that verifies: the writer's clock and the authority's may differ.
sealOK := v.Seal == wantSeal || wantSeal == VerdictSealed && v.Seal == VerdictSealedLate
if v.Signature != wantSig || !sealOK || v.AuthorKey != key {
return nil, fmt.Errorf("capsule: self-check: the reader finds the verdicts %s and %s in this security area, not %s and %s%s", v.Signature, v.Seal, wantSig, wantSeal, detailText(v.Detail))
}
return security, nil
}
// detailText names the signers that failed, for the error of a writer.
func detailText(d *Detail) string {
if d == nil {
return ""
}
var parts []string
for _, l := range d.Signers {
if l.Result != "valid" {
parts = append(parts, l.Holder+": "+l.Result)
}
}
if len(parts) == 0 {
return ""
}
return " (" + strings.Join(parts, "; ") + ")"
}
// newHead checks the files and the texts of opts with the rules of spec
// §29.4 to §29.6, in the words of a writer (spec §62.1 rule 15), and returns
// the head with the files in the byte order of their paths, their layout and

@ -3,6 +3,8 @@ package capsule
import (
"encoding/binary"
"fmt"
"strings"
"time"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/codec"
@ -142,6 +144,19 @@ const (
// VerdictSealUnreadable (S2): a seal that does not decode or breaks its
// schema.
VerdictSealUnreadable Verdict = "S2"
// VerdictSignedIncomplete (F5): a signature of alg 2 with a required
// signer absent, not verifiable, without a seal or with an invalid one or
// out of validity, or with a key 3 (spec v0.11, §29.10).
VerdictSignedIncomplete Verdict = "F5"
// VerdictSignedComplete (F6): a signature of alg 2 with every required
// signer valid and sealed. Verdicts.Detail names them.
VerdictSignedComplete Verdict = "F6"
// VerdictSealInvalid (S3): a seal that does not verify.
VerdictSealInvalid Verdict = "S3"
// VerdictSealed (S4): a valid seal with t + accuracy < round_time.
VerdictSealed Verdict = "S4"
// VerdictSealedLate (S5): a valid seal without margin before round_time.
VerdictSealedLate Verdict = "S5"
)
// Text returns the text of the verdict that the official SDK shows, in
@ -161,6 +176,12 @@ func (v Verdict) Text() string {
return "Lleva un sello de tiempo que esta versión no sabe comprobar: aquí no prueba nada."
case VerdictSealUnreadable:
return "El sello de tiempo es ilegible: no prueba nada."
case VerdictSignedIncomplete:
return "Faltan firmas o sellos que la propia cápsula exige: trátala como no firmada."
case VerdictSealInvalid:
return "El sello no corresponde a este contenido."
case VerdictSealedLate:
return "Sellado después de la fecha de apertura: no prueba nada anterior."
}
return ""
}
@ -172,6 +193,37 @@ type Verdicts struct {
AuthorKey [32]byte
// AuthorLabel is the label of the saved key that signed (F3).
AuthorLabel string
// Detail names the signers of an alg 2 signature and the authority of a
// valid seal; nil otherwise. A pointer, so that Verdicts stays comparable.
Detail *Detail
}
// Detail is what the texts of F6, S4 and S5 name (spec v0.11, §29.7, §29.10).
type Detail struct {
// Signers are the required signers, in the order of SIGNERS, and Foreign
// the SignerInfo of other certificates, which never count.
Signers, Foreign []SignerLine
// SealHolder and SealTime are the holder of the certificate of the
// authority of a valid seal, as §29.7 writes it, and t.
SealHolder string
SealTime time.Time
}
// SignerLine is a signer of an alg 2 signature.
type SignerLine struct {
// Holder is the name of the certificate as §29.7 shows it: the subject,
// or the SHA-256 of the certificate in hexadecimal when it does not meet
// the rules of the declared author.
Holder string
// Issuer is the issuer that the certificate says.
Issuer string
// Result is "valid", "invalid", "absent", "not verifiable", "without
// seal", "invalid seal" or "out of validity".
Result string
// SealTime is t, zero without a seal that verifies. Before is true when t
// plus the accuracy of the seal is before round_time.
SealTime time.Time
Before bool
}
// Lines are the verdicts as the official SDK shows them, in order: X alone,
@ -188,7 +240,31 @@ func (v Verdicts) Lines() []string {
key, _ := bech32.Encode("dkauthor", v.AuthorKey[:])
lines[0] = "Firmado con la clave " + key + ". No prueba quién la tiene."
}
if t := v.Seal.Text(); t != "" {
if v.Signature == VerdictSignedComplete && v.Detail != nil {
names := make([]string, len(v.Detail.Signers))
for i, s := range v.Detail.Signers {
names[i] = s.Holder
}
lines[0] = "Firmado con un certificado a nombre de " + strings.Join(names, ", ") +
". DateKeys no comprueba quién lo emitió: para eso, exporta la firma a un validador oficial."
for _, s := range v.Detail.Signers {
when := "no antes de la fecha de apertura"
if s.Before {
when = "antes de la fecha de apertura"
}
lines = append(lines, fmt.Sprintf(" %s (emisor según su certificado: %s), sellado el %s, %s.", s.Holder, s.Issuer, s.SealTime.UTC().Format(time.RFC3339), when))
}
}
if v.Detail != nil {
for _, s := range v.Detail.Foreign {
lines = append(lines, fmt.Sprintf(" Otro firmante, %s: %s. No cuenta.", s.Holder, s.Result))
}
}
switch t := v.Seal.Text(); {
case v.Seal == VerdictSealed && v.Detail != nil:
lines = append(lines, "Según un sello a nombre de "+v.Detail.SealHolder+", existía el "+v.Detail.SealTime.UTC().Format(time.RFC3339)+
", antes de que la cápsula pudiera abrirse. DateKeys no comprueba quién emitió el sello.")
case t != "":
lines = append(lines, t)
}
return lines

@ -44,6 +44,26 @@ type AuthorKey interface {
Sign(message []byte) []byte
}
// CMSSigner makes the signature of alg 2 (spec v0.11, §29.10). EncryptFiles
// calls Sign with AUTHOR_MESSAGE once the capsule is prepared, and waits: the
// person signs it outside, with AutoFirma or another application, and Sign
// returns the DER of the CMS signature that she got, with its seals.
type CMSSigner interface {
// Signers returns the SHA-256 of the certificate of each required
// signer, from 1 to 16.
Signers() [][32]byte
// Sign returns the detached CMS signature of message, in DER.
Sign(message []byte) ([]byte, error)
}
// Sealer asks an authority for an RFC 3161 time-stamp token (spec §29.11).
type Sealer interface {
// Seal returns the DER of the token over SHA-256(subject), where subject
// is SEAL_SUBJECT; the imprint SHA-256 of the 32 bytes is the one that
// the token must hold.
Seal(subject [32]byte) ([]byte, error)
}
func domainHash(prefix string, parts ...[]byte) [32]byte {
h := sha256.New()
h.Write([]byte(prefix))
@ -164,13 +184,17 @@ func EvaluateSecurityIn(b []byte, c *SecurityContext) Verdicts {
if w.signature != nil {
v.Signature = VerdictSignatureUnchecked
if c != nil {
evaluateSignature(&v, w.signature, c)
evaluateSignature(&v, w, c)
}
}
if w.seal != nil {
if _, err := decodeSeal(w.seal); err != nil {
s, err := decodeSeal(w.seal)
switch {
case err != nil:
v.Seal = VerdictSealUnreadable
} else {
case s.sealType == SealTypeRFC3161 && c != nil:
evaluateSeal(&v, s, w.signature, c)
default:
v.Seal = VerdictSealUnsupported
}
}
@ -181,9 +205,16 @@ func EvaluateSecurityIn(b []byte, c *SecurityContext) Verdicts {
// that does not decode, an alg this reader does not implement or a key or a
// signature of another length; F2 when the signature does not verify; F3 or
// F4 when it does (spec §29.7, §29.9).
func evaluateSignature(v *Verdicts, content []byte, c *SecurityContext) {
a, err := decodeAuthorSignature(content)
if err != nil || a.alg != AlgEd25519 || len(a.key) != 32 || len(a.value) != 64 {
func evaluateSignature(v *Verdicts, w *securityWire, c *SecurityContext) {
a, err := decodeAuthorSignature(w.signature)
if err != nil {
return
}
if a.alg == AlgCMS {
evaluateCMS(v, a, w.seal != nil, c)
return
}
if a.alg != AlgEd25519 || len(a.key) != 32 || len(a.value) != 64 {
return
}
msg := AuthorMessage(c.ControlCommit, c.HeadDigest, SignersDigest(AlgEd25519, nil))

@ -0,0 +1,215 @@
package capsule
import (
"bytes"
"encoding/hex"
"errors"
"time"
"unicode/utf8"
"g.activething.com/go/DateKeys/codec"
"g.activething.com/go/DateKeys/internal/cms"
"g.activething.com/go/DateKeys/internal/pathrule"
)
// SealTypeRFC3161 is the seal_type of a seal that is an RFC 3161 time-stamp
// token (spec v0.11, §29.3, §29.11).
const SealTypeRFC3161 = 2
// MaxSigners is the most required signers of an alg 2 signature (§29.10).
const MaxSigners = 16
// EncodeSigners returns SIGNERS, the content of key 1 of an author-signature
// of alg 2: a CBOR array of 1 to 16 strings of 32 bytes, the SHA-256 of the
// certificate of each required signer, in strictly ascending order of bytes
// (spec §29.10). It sorts them, and fails when there are none, too many or
// two are equal.
func EncodeSigners(hashes [][32]byte) ([]byte, error) {
if len(hashes) < 1 || len(hashes) > MaxSigners {
return nil, errors.New("capsule: SIGNERS holds from 1 to 16 certificates")
}
sorted := append([][32]byte(nil), hashes...)
for i := 1; i < len(sorted); i++ { // insertion sort: at most 16
for j := i; j > 0 && bytes.Compare(sorted[j-1][:], sorted[j][:]) > 0; j-- {
sorted[j-1], sorted[j] = sorted[j], sorted[j-1]
}
}
for i := 1; i < len(sorted); i++ {
if sorted[i-1] == sorted[i] {
return nil, errors.New("capsule: SIGNERS names a certificate twice")
}
}
var e codec.Encoder
e.Array(len(sorted))
for _, h := range sorted {
e.Bstr(h[:])
}
return e.Out()
}
// decodeSigners reads SIGNERS and checks the profile of spec §29.10.
func decodeSigners(b []byte) ([][32]byte, error) {
var out [][32]byte
decode := func(d *codec.Decoder) error {
n, err := d.Array(MaxSigners)
if err != nil {
return err
}
if n < 1 {
return errors.New("SIGNERS is empty")
}
for range n {
h, err := d.Bstr(32, 32)
if err != nil {
return err
}
var x [32]byte
copy(x[:], h)
if len(out) > 0 && bytes.Compare(out[len(out)-1][:], x[:]) >= 0 {
return errors.New("SIGNERS is not in strictly ascending order")
}
out = append(out, x)
}
return nil
}
encode := func(e *codec.Encoder) {
e.Array(len(out))
for _, h := range out {
e.Bstr(h[:])
}
}
if err := codec.Unmarshal(b, decode, encode); err != nil {
return nil, err
}
return out, nil
}
// holderText is how §29.7 shows a name: the name, when it meets the rules of
// the declared author, and the SHA-256 of the certificate otherwise.
func holderText(name string, hash [32]byte) string {
if name != "" && utf8.ValidString(name) && len(name) <= MaxAuthorLen && pathrule.CheckAuthor(name) == nil {
return name
}
return hex.EncodeToString(hash[:])
}
// evaluateCMS sets the verdict of a signature of alg 2 (spec §29.10): F1 for
// content that breaks its profile, F2 when the signature of a required
// signer is invalid, F5 when something the capsule demands is missing, F6
// when every required signer is valid and sealed. hasSeal is whether key 3
// exists, which an alg 2 signature forbids.
func evaluateCMS(v *Verdicts, a *authorSignature, hasSeal bool, c *SecurityContext) {
required, err := decodeSigners(a.key)
if err != nil {
return
}
sd, err := cms.ParseSignature(a.value)
if err != nil {
return
}
msg := AuthorMessage(c.ControlCommit, c.HeadDigest, SignersDigest(AlgCMS, a.key))
detail := &Detail{}
byHash := map[[32]byte]*cms.SignerInfo{}
for _, s := range sd.Signers {
byHash[s.Cert.Hash] = s
}
invalid, incomplete := false, hasSeal
for _, h := range required {
s := byHash[h]
if s == nil {
detail.Signers = append(detail.Signers, SignerLine{Holder: hex.EncodeToString(h[:]), Result: "absent"})
incomplete = true
continue
}
line := signerLine(s, msg, c.RoundTime)
switch line.Result {
case "invalid":
invalid = true
case "valid":
default:
incomplete = true
}
detail.Signers = append(detail.Signers, line)
}
for _, s := range sd.Signers {
if !isRequired(required, s.Cert.Hash) {
detail.Foreign = append(detail.Foreign, signerLine(s, msg, c.RoundTime))
}
}
v.Detail = detail
switch {
case invalid:
v.Signature = VerdictSignatureInvalid
case incomplete:
v.Signature = VerdictSignedIncomplete
default:
v.Signature = VerdictSignedComplete
}
}
func isRequired(required [][32]byte, h [32]byte) bool {
for _, r := range required {
if r == h {
return true
}
}
return false
}
// signerLine checks one SignerInfo as §29.10 orders: not verifiable, invalid,
// without seal, with an invalid seal, out of validity, or valid.
func signerLine(s *cms.SignerInfo, msg []byte, roundTime time.Time) SignerLine {
l := SignerLine{Holder: holderText(s.Cert.Holder(), s.Cert.Hash), Issuer: s.Cert.IssuerName()}
switch s.Check(msg) {
case cms.NotVerifiable:
l.Result = "not verifiable"
return l
case cms.Invalid:
l.Result = "invalid"
return l
}
if s.Token == nil {
l.Result = "without seal"
return l
}
tok, err := cms.ParseToken(s.Token)
if err != nil || !tok.Check(s.Signature) {
l.Result = "invalid seal"
return l
}
if !s.Cert.ValidAt(tok.GenTime) {
l.Result = "out of validity"
return l
}
l.Result, l.SealTime = "valid", tok.GenTime
l.Before = !roundTime.IsZero() && tok.GenTime.Add(tok.Accuracy).Before(roundTime)
return l
}
// evaluateSeal sets the verdict of a seal of seal_type 2 (spec §29.11): S2 or
// S1 for the form and the algorithms, S3 when it does not verify, and S4 or
// S5 when it does. signature is the content of key 2, nil without it.
func evaluateSeal(v *Verdicts, s *seal, signature []byte, c *SecurityContext) {
tok, err := cms.ParseToken(s.token)
switch {
case errors.Is(err, cms.ErrForm):
v.Seal = VerdictSealUnreadable
return
case err != nil || !tok.ImprintIsSHA256():
v.Seal = VerdictSealUnsupported
return
}
subject := SealSubject(c.ControlCommit, c.HeadDigest, SigPart(signature))
if !tok.Check(subject[:]) {
v.Seal = VerdictSealInvalid
return
}
if v.Detail == nil {
v.Detail = &Detail{}
}
v.Detail.SealHolder, v.Detail.SealTime = holderText(tok.TSA.Holder(), tok.TSA.Hash), tok.GenTime
v.Seal = VerdictSealedLate
if !c.RoundTime.IsZero() && tok.GenTime.Add(tok.Accuracy).Before(c.RoundTime) {
v.Seal = VerdictSealed
}
}

@ -0,0 +1,227 @@
package capsule_test
import (
"crypto"
"crypto/elliptic"
"crypto/sha256"
"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
}
// 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)
}
lines := v.Lines()
if !strings.HasPrefix(lines[0], "Firmado con un certificado a nombre de ") || !strings.Contains(lines[0], "Ana López") || !strings.Contains(lines[0], "Luis Gómez") ||
len(lines) != 3 || !strings.Contains(lines[1], "antes de la fecha de apertura") || strings.Contains(lines[1], "no antes") {
t.Errorf("lines %q", lines)
}
// A seal after the round time proves nothing before 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 || !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 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" || !strings.Contains(f.Lines()[len(f.Lines())-1], "No cuenta") {
t.Errorf("a foreign signer: %+v %q", f, f.Lines())
}
for name, tc := range map[string]struct {
area []byte
want capsule.Verdict
}{
"a required signer is absent": {cmsArea(t, c, []cmstest.Signer{ana, luis}, []cmstest.Signer{ana}, tsa, signedAt, nil), capsule.VerdictSignedIncomplete},
"no seal": {cmsArea(t, c, []cmstest.Signer{ana}, []cmstest.Signer{ana}, cmstest.Signer{}, signedAt, nil), capsule.VerdictSignedIncomplete},
"a seal outside the validity of the certificate": {cmsArea(t, c, []cmstest.Signer{ana}, []cmstest.Signer{ana}, tsa, certFrom.AddDate(-1, 0, 0), nil), capsule.VerdictSignedIncomplete},
"a key 3 beside it": {cmsArea(t, c, []cmstest.Signer{ana}, []cmstest.Signer{ana}, tsa, signedAt, mustSeal(t, 1, []byte{1})), capsule.VerdictSignedIncomplete},
} {
if got := capsule.EvaluateSecurityIn(tc.area, c).Signature; got != tc.want {
t.Errorf("%s: %s, want %s", name, got, tc.want)
}
}
// 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 or empty, or a CMS that is not one.
one, two := sha256Sum([]byte("a")), sha256Sum([]byte("b"))
if one[0] > two[0] {
one, two = two, one
}
unsorted := append(append([]byte{0x82, 0x58, 0x20}, two[:]...), append([]byte{0x58, 0x20}, one[:]...)...)
for name, signers := range map[string][]byte{"SIGNERS out of order": unsorted, "an empty SIGNERS": {0x80}} {
content, _ := capsule.EncodeAuthorSignature(capsule.AlgCMS, signers, []byte{0x30, 0x00})
area, _ := capsule.EncodeSecurityWith(content, nil)
if got := capsule.EvaluateSecurityIn(area, c).Signature; got != capsule.VerdictSignatureUnchecked {
t.Errorf("%s: %s", name, got)
}
}
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)
}
}
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) }

@ -3,9 +3,14 @@ package capsule_test
import (
"bytes"
"context"
"crypto/elliptic"
"crypto/sha256"
"encoding/hex"
"g.activething.com/go/DateKeys/internal/cms/cmstest"
"io"
"strings"
"testing"
"time"
"g.activething.com/go/DateKeys/authorkey"
"g.activething.com/go/DateKeys/capsule"
@ -179,3 +184,123 @@ func TestSignedFixtureVerdicts(t *testing.T) {
t.Errorf("removed: %+v", v)
}
}
// cmsSigner is a CMSSigner that signs as a signing application would: its
// certificates sign AUTHOR_MESSAGE, and the authority tsa seals each
// signature at when.
type cmsSigner struct {
signers []cmstest.Signer
tsa cmstest.Signer
when time.Time
seen []byte // the message it was asked to sign
}
func (c *cmsSigner) Signers() (out [][32]byte) {
for _, s := range c.signers {
out = append(out, sha256.Sum256(s.Cert.Raw))
}
return out
}
func (c *cmsSigner) Sign(message []byte) ([]byte, error) {
c.seen = message
opts := cmstest.Options{}
if c.tsa.Key != nil {
opts.Token = func(sig []byte) []byte {
return cmstest.Token(sig, c.when, cmstest.TokenOptions{Accuracy: time.Second}, c.tsa)
}
}
return cmstest.Signature(message, opts, c.signers...), nil
}
// sealer is a Sealer that asks the authority tsa.
type sealer struct {
tsa cmstest.Signer
when time.Time
}
func (s sealer) Seal(subject [32]byte) ([]byte, error) {
return cmstest.Token(subject[:], s.when, cmstest.TokenOptions{}, s.tsa), nil
}
// Spec v0.11 §29.10, §29.11, §62.1 rules 19 and 21: EncryptFiles gives
// AUTHOR_MESSAGE to the CMSSigner, checks that what it returns is complete,
// and Open gives F6; a Sealer seals SEAL_SUBJECT and Open gives S4.
func TestEncryptFilesCMSAndSeal(t *testing.T) {
from, to := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2040, 1, 1, 0, 0, 0, 0, time.UTC)
ana := cmstest.NewECDSA("Ana López", elliptic.P256(), from, to)
luis := cmstest.NewRSA("Luis Gómez", 2048, from, to)
tsa := cmstest.NewECDSA("TSA de prueba", elliptic.P256(), from, to)
opts := files3(t)
when := opts.Now()
signer := &cmsSigner{signers: []cmstest.Signer{ana, luis}, tsa: tsa, when: when}
opts.CMSSigner = signer
var dkc bytes.Buffer
if _, err := capsule.EncryptFiles(&dkc, []capsule.Source{source("nota.txt", "Hola.\n")}, opts); err != nil {
t.Fatal(err)
}
o := openSigned(t, dkc.Bytes(), nil)
if o.Verdicts.Signature != capsule.VerdictSignedComplete || o.Verdicts.Seal != capsule.VerdictNoSeal || len(o.Verdicts.Detail.Signers) != 2 ||
len(signer.seen) != capsule.AuthorMessageSize || capsule.AuthorCode(signer.seen) == "" {
t.Errorf("verdicts %+v, message %q", o.Verdicts, signer.seen)
}
if !o.Verdicts.Detail.Signers[0].Before {
t.Error("the seal does not precede the round time")
}
// A signature that lacks a required signer is not written.
third := cmstest.NewECDSA("Falta", elliptic.P256(), from, to)
missing := &cmsSigner{signers: []cmstest.Signer{ana}, tsa: tsa, when: when}
opts.CMSSigner = &requiring{missing, third}
dkc.Reset()
if _, err := capsule.EncryptFiles(&dkc, []capsule.Source{source("nota.txt", "x")}, opts); err == nil || !strings.Contains(err.Error(), "F5") || dkc.Len() != 0 {
t.Errorf("a missing signer: %v, %d bytes written", err, dkc.Len())
}
// Without seals the signature is incomplete too.
opts.CMSSigner = &cmsSigner{signers: []cmstest.Signer{ana}}
if _, err := capsule.EncryptFiles(&dkc, []capsule.Source{source("nota.txt", "x")}, opts); err == nil || !strings.Contains(err.Error(), "without seal") {
t.Errorf("no seal: %v", err)
}
// A seal over the signature of an author key.
key, _ := authorkey.Generate()
opts.CMSSigner, opts.AuthorKey, opts.Sealer = nil, key, sealer{tsa, when}
dkc.Reset()
if _, err := capsule.EncryptFiles(&dkc, []capsule.Source{source("nota.txt", "Hola.\n")}, opts); err != nil {
t.Fatal(err)
}
o = openSigned(t, dkc.Bytes(), nil)
if o.Verdicts.Signature != capsule.VerdictSignedOther || o.Verdicts.Seal != capsule.VerdictSealed || o.Verdicts.Detail.SealHolder != "TSA de prueba" {
t.Errorf("verdicts %+v", o.Verdicts)
}
// And a seal over a capsule without a signature.
opts.AuthorKey = nil
dkc.Reset()
if _, err := capsule.EncryptFiles(&dkc, []capsule.Source{source("nota.txt", "Hola.\n")}, opts); err != nil {
t.Fatal(err)
}
if o = openSigned(t, dkc.Bytes(), nil); o.Verdicts.Signature != capsule.VerdictNoSignature || o.Verdicts.Seal != capsule.VerdictSealed {
t.Errorf("verdicts %+v", o.Verdicts)
}
// The exclusions.
opts.AuthorKey, opts.CMSSigner = key, signer
if _, err := capsule.EncryptFiles(io.Discard, []capsule.Source{source("a", "x")}, opts); err == nil {
t.Error("AuthorKey and CMSSigner")
}
opts.AuthorKey = nil
if _, err := capsule.EncryptFiles(io.Discard, []capsule.Source{source("a", "x")}, opts); err == nil {
t.Error("CMSSigner and Sealer")
}
}
// requiring asks for one signer more than signs.
type requiring struct {
*cmsSigner
extra cmstest.Signer
}
func (r *requiring) Signers() [][32]byte {
return append(r.cmsSigner.Signers(), sha256.Sum256(r.extra.Cert.Raw))
}

Loading…
Cancel
Save

Powered by TurnKey Linux.