capsule/signature.go has what an author signs and a seal seals, as the spec v0.11 draft defines it (29.8, 29.11): payload_commit, control_commit over CONTROL_SIG, without I_PAYLOAD and with L at zero, head_digest, signers_digest, AUTHOR_MESSAGE as ASCII text of 99 bytes with its code, SIG_PART over the exact content of key 2, and SEAL_SUBJECT. EvaluateSecurityIn checks a signature of alg 1 with the strict profile in the context of a capsule: F4, or F3 with a key the person saved; F2 when it does not verify; F1 without context, as in v0.10, and for alg 2, not yet implemented. Verdicts carries the key and the label for the texts. Not wired yet: the writer does not sign and still writes the area of 512 bytes, and the reader still calls EvaluateSecurity without context. The handoff of docs lists what is left. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>v0.11
parent
7e7681e737
commit
3d85a0b857
@ -0,0 +1,184 @@
|
||||
package capsule
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
|
||||
"g.activething.com/go/DateKeys/codec/bech32"
|
||||
"g.activething.com/go/DateKeys/internal/ed25519strict"
|
||||
)
|
||||
|
||||
// The domain prefixes of what an author signs and a seal seals (spec v0.11,
|
||||
// §29.8, §29.11). Each is followed by a byte 0x00, except in AUTHOR_MESSAGE,
|
||||
// where a line feed follows it.
|
||||
const (
|
||||
payloadCommitPrefix = "datekeys:dkc3:payload:v1"
|
||||
controlCommitPrefix = "datekeys:dkc3:control:v1"
|
||||
headDigestPrefix = "datekeys:dkc3:head:v1"
|
||||
signersDigestPrefix = "datekeys:dkc3:signers:v1"
|
||||
sigPartPrefix = "datekeys:dkc3:sig-part:v1"
|
||||
sealSubjectPrefix = "datekeys:dkc3:seal-subject:v1"
|
||||
// AuthorMessagePrefix is the first line of AUTHOR_MESSAGE.
|
||||
AuthorMessagePrefix = "datekeys:dkc3:author-signature:v1"
|
||||
// AuthorMessageSize is the length of AUTHOR_MESSAGE: the prefix, a line
|
||||
// feed, the 64 hexadecimal digits of its digest and a line feed.
|
||||
AuthorMessageSize = len(AuthorMessagePrefix) + 1 + 64 + 1
|
||||
)
|
||||
|
||||
// The values of alg that this version defines (spec v0.11, §29.3).
|
||||
const (
|
||||
// AlgEd25519 is a strict Ed25519 signature with a key of one's own (§29.9).
|
||||
AlgEd25519 = 1
|
||||
// AlgCMS is a CMS signature with X.509 certificates (§29.10).
|
||||
AlgCMS = 2
|
||||
)
|
||||
|
||||
// AuthorKey signs AUTHOR_MESSAGE with alg 1, as *authorkey.Key does.
|
||||
type AuthorKey interface {
|
||||
// Public returns the public key A, 32 bytes.
|
||||
Public() []byte
|
||||
// Sign returns the Ed25519 signature of message, 64 bytes.
|
||||
Sign(message []byte) []byte
|
||||
}
|
||||
|
||||
func domainHash(prefix string, parts ...[]byte) [32]byte {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(prefix))
|
||||
h.Write([]byte{0})
|
||||
for _, p := range parts {
|
||||
h.Write(p)
|
||||
}
|
||||
var out [32]byte
|
||||
h.Sum(out[:0])
|
||||
return out
|
||||
}
|
||||
|
||||
// PayloadCommit is the commitment to I_PAYLOAD that replaces it in what is
|
||||
// signed (spec §29.8).
|
||||
func PayloadCommit(identity [32]byte) [32]byte {
|
||||
return domainHash(payloadCommitPrefix, identity[:])
|
||||
}
|
||||
|
||||
// ControlCommit is control_commit: the hash of CONTROL_SIG, the control of a
|
||||
// capsule of format f with payload_commit in place of I_PAYLOAD and the eight
|
||||
// bytes of L at zero (spec §29.8). It does not depend on L, so the area can
|
||||
// grow after signing.
|
||||
func ControlCommit(c *Control, f Format) ([32]byte, error) {
|
||||
sig := *c
|
||||
sig.PayloadIdentity = PayloadCommit(c.PayloadIdentity)
|
||||
sig.PayloadLength = 0
|
||||
b, err := EncodeControl(&sig, f)
|
||||
if err != nil {
|
||||
return [32]byte{}, err
|
||||
}
|
||||
return domainHash(controlCommitPrefix, b), nil
|
||||
}
|
||||
|
||||
// HeadDigest is head_digest, the hash of HEAD_CBOR (spec §29.8). The salt of
|
||||
// the head makes it a commitment that hides the files.
|
||||
func HeadDigest(head []byte) [32]byte { return domainHash(headDigestPrefix, head) }
|
||||
|
||||
// SignersDigest is signers_digest for alg and the exact content of key 1 of a
|
||||
// signature of alg 2, signers; nil with alg 1 (spec §29.8).
|
||||
func SignersDigest(alg uint32, signers []byte) [32]byte {
|
||||
return domainHash(signersDigestPrefix, binary.BigEndian.AppendUint32(nil, alg), signers)
|
||||
}
|
||||
|
||||
// AuthorMessage is AUTHOR_MESSAGE, the ASCII text that an author signs:
|
||||
// AuthorMessagePrefix, a line feed, the hexadecimal digest D of the three
|
||||
// commitments and a line feed (spec §29.8).
|
||||
func AuthorMessage(controlCommit, headDigest, signersDigest [32]byte) []byte {
|
||||
d := sha256.Sum256(append(append(controlCommit[:], headDigest[:]...), signersDigest[:]...))
|
||||
m := make([]byte, 0, AuthorMessageSize)
|
||||
m = append(m, AuthorMessagePrefix...)
|
||||
m = append(m, '\n')
|
||||
m = hex.AppendEncode(m, d[:])
|
||||
return append(m, '\n')
|
||||
}
|
||||
|
||||
// AuthorCode is the code of AUTHOR_MESSAGE that a person compares before
|
||||
// signing: the first 8 hexadecimal digits of its digest, in two groups of 4.
|
||||
func AuthorCode(message []byte) string {
|
||||
if len(message) != AuthorMessageSize {
|
||||
return ""
|
||||
}
|
||||
d := message[len(AuthorMessagePrefix)+1:]
|
||||
return string(d[:4]) + "-" + string(d[4:8])
|
||||
}
|
||||
|
||||
// SigPart is SIG_PART: 0x00 without key 2, and 0x01 and the hash of the exact
|
||||
// content of key 2 otherwise, whatever its alg and its verdict (spec §29.11).
|
||||
func SigPart(signature []byte) []byte {
|
||||
if signature == nil {
|
||||
return []byte{0}
|
||||
}
|
||||
h := domainHash(sigPartPrefix, signature)
|
||||
return append([]byte{1}, h[:]...)
|
||||
}
|
||||
|
||||
// SealSubject is SEAL_SUBJECT, what a seal of seal_type 2 seals (spec §29.11).
|
||||
func SealSubject(controlCommit, headDigest [32]byte, sigPart []byte) [32]byte {
|
||||
return domainHash(sealSubjectPrefix, controlCommit[:], headDigest[:], sigPart)
|
||||
}
|
||||
|
||||
// SecurityContext is what the verdicts of a signature or a seal need besides
|
||||
// SECURITY_CBOR: the commitments of the capsule, the time of its round, and
|
||||
// the author keys that the person saved, by their dkauthor1… string, with the
|
||||
// label she gave them (F3). A reader builds it at step 17.6.
|
||||
type SecurityContext struct {
|
||||
ControlCommit, HeadDigest [32]byte
|
||||
RoundTime time.Time
|
||||
AuthorKeys map[string]string
|
||||
}
|
||||
|
||||
// EvaluateSecurityIn returns the verdicts of SECURITY_CBOR in the capsule
|
||||
// that c describes (spec §29.7). Without a context, as EvaluateSecurity, it
|
||||
// checks only the structure: any signature is F1, as in v0.10. It never
|
||||
// fails: security never decides the opening.
|
||||
func EvaluateSecurityIn(b []byte, c *SecurityContext) Verdicts {
|
||||
x := Verdicts{Signature: VerdictUnreadable, Seal: VerdictUnreadable}
|
||||
w, ok := decodeSecurity(b)
|
||||
if !ok {
|
||||
return x
|
||||
}
|
||||
v := Verdicts{Signature: VerdictNoSignature, Seal: VerdictNoSeal}
|
||||
if w.signature != nil {
|
||||
v.Signature = VerdictSignatureUnchecked
|
||||
if c != nil {
|
||||
evaluateSignature(&v, w.signature, c)
|
||||
}
|
||||
}
|
||||
if w.seal != nil {
|
||||
if _, err := decodeSeal(w.seal); err != nil {
|
||||
v.Seal = VerdictSealUnreadable
|
||||
} else {
|
||||
v.Seal = VerdictSealUnsupported
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// evaluateSignature sets the verdict of the content of key 2: F1 for content
|
||||
// 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 {
|
||||
return
|
||||
}
|
||||
msg := AuthorMessage(c.ControlCommit, c.HeadDigest, SignersDigest(AlgEd25519, nil))
|
||||
if !ed25519strict.Verify(a.key, msg, a.value) {
|
||||
v.Signature = VerdictSignatureInvalid
|
||||
return
|
||||
}
|
||||
v.Signature = VerdictSignedOther
|
||||
copy(v.AuthorKey[:], a.key)
|
||||
if s, err := bech32.Encode("dkauthor", a.key); err == nil {
|
||||
if label, ok := c.AuthorKeys[s]; ok {
|
||||
v.Signature, v.AuthorLabel = VerdictSignedSaved, label
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,97 @@
|
||||
package capsule_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"g.activething.com/go/DateKeys/authorkey"
|
||||
"g.activething.com/go/DateKeys/capsule"
|
||||
)
|
||||
|
||||
// Spec v0.11 §29.8: AUTHOR_MESSAGE is ASCII text of 99 bytes with its code,
|
||||
// and control_commit does not depend on L.
|
||||
func TestAuthorMessage(t *testing.T) {
|
||||
var cc, hd [32]byte
|
||||
cc[0], hd[0] = 1, 2
|
||||
m := capsule.AuthorMessage(cc, hd, capsule.SignersDigest(capsule.AlgEd25519, nil))
|
||||
if capsule.AuthorMessageSize != 99 || len(m) != 99 || !bytes.HasPrefix(m, []byte(capsule.AuthorMessagePrefix+"\n")) || m[98] != '\n' {
|
||||
t.Fatalf("AUTHOR_MESSAGE %q", m)
|
||||
}
|
||||
if code := capsule.AuthorCode(m); len(code) != 9 || code[4] != '-' || code[:4] != string(m[34:38]) {
|
||||
t.Errorf("code %q", code)
|
||||
}
|
||||
if capsule.SignersDigest(capsule.AlgEd25519, nil) == capsule.SignersDigest(capsule.AlgCMS, nil) {
|
||||
t.Error("signers_digest does not bind alg")
|
||||
}
|
||||
c := &capsule.Control{PayloadLength: 100, Padding: capsule.Reforzado}
|
||||
c.PayloadIdentity[0] = 7
|
||||
a, err := capsule.ControlCommit(c, capsule.Format3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.PayloadLength = 1 << 40
|
||||
if b, _ := capsule.ControlCommit(c, capsule.Format3); b != a {
|
||||
t.Error("control_commit depends on L")
|
||||
}
|
||||
c.PayloadIdentity[0] = 8
|
||||
if b, _ := capsule.ControlCommit(c, capsule.Format3); b == a {
|
||||
t.Error("control_commit does not depend on I_PAYLOAD")
|
||||
}
|
||||
if capsule.SigPart(nil)[0] != 0 || len(capsule.SigPart([]byte{1})) != 33 {
|
||||
t.Error("SIG_PART")
|
||||
}
|
||||
}
|
||||
|
||||
// Spec v0.11 §29.7, §29.9: a signature of alg 1 gives F4, or F3 with a saved
|
||||
// key; F2 when it does not verify; F1 without the capsule around it, as in
|
||||
// v0.10, and for what this reader does not implement.
|
||||
func TestEvaluateSecurityIn(t *testing.T) {
|
||||
key, _ := authorkey.Generate()
|
||||
ctx := &capsule.SecurityContext{}
|
||||
ctx.ControlCommit[0], ctx.HeadDigest[0] = 1, 2
|
||||
msg := capsule.AuthorMessage(ctx.ControlCommit, ctx.HeadDigest, capsule.SignersDigest(capsule.AlgEd25519, nil))
|
||||
security := func(alg uint64, pub, sig []byte) []byte {
|
||||
content, err := capsule.EncodeAuthorSignature(alg, pub, sig)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := capsule.EncodeSecurityWith(content, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
signed := security(capsule.AlgEd25519, key.Public(), key.Sign(msg))
|
||||
v := capsule.EvaluateSecurityIn(signed, ctx)
|
||||
if v.Signature != capsule.VerdictSignedOther || !bytes.Equal(v.AuthorKey[:], key.Public()) {
|
||||
t.Fatalf("verdicts %+v", v)
|
||||
}
|
||||
pub, _ := authorkey.PublicString(key.Public())
|
||||
if line := v.Lines()[0]; !strings.Contains(line, pub) || !strings.HasSuffix(line, "No prueba quién la tiene.") {
|
||||
t.Errorf("F4 line %q", line)
|
||||
}
|
||||
ctx.AuthorKeys = map[string]string{pub: "Ana"}
|
||||
if v := capsule.EvaluateSecurityIn(signed, ctx); v.Signature != capsule.VerdictSignedSaved || v.Lines()[0] != "Firmado con la clave que guardaste como Ana." {
|
||||
t.Errorf("F3: %+v %q", v, v.Lines())
|
||||
}
|
||||
other := *ctx
|
||||
other.HeadDigest[0] = 3
|
||||
if v := capsule.EvaluateSecurityIn(signed, &other); v.Signature != capsule.VerdictSignatureInvalid {
|
||||
t.Errorf("another head: %s", v.Signature)
|
||||
}
|
||||
for name, b := range map[string][]byte{
|
||||
"no context": signed,
|
||||
"a key of 31": security(capsule.AlgEd25519, key.Public()[:31], key.Sign(msg)),
|
||||
"alg 2, not yet": security(capsule.AlgCMS, []byte{1}, []byte{1}),
|
||||
"an unknown alg 9": security(9, key.Public(), key.Sign(msg)),
|
||||
} {
|
||||
c := ctx
|
||||
if name == "no context" {
|
||||
c = nil
|
||||
}
|
||||
if v := capsule.EvaluateSecurityIn(b, c); v.Signature != capsule.VerdictSignatureUnchecked {
|
||||
t.Errorf("%s: %s", name, v.Signature)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue