From 72e86b8d79e624cf9cb8d5c18310cc2dc2a433d0 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 30 Sep 2026 18:57:42 +0200 Subject: [PATCH] Format 3, step 2: the codec of BODY, security and the head - Format3 and the control of schema version 3, with the keys of version 2 (spec 31). The PRELUDE still rejects VERSION 3 until the reader opens format 3, in step 3, with the test data that expect it. - The frame of BODY (spec 29.2): AREA_LEN, SECURITY_LEN and HEAD_LEN, their limits against L and the zeros of the area, all ERR_INTEGRITY. - security (spec 29.3, 29.7): the outer map, with the signature and the seal as separately encoded byte strings, and its verdicts X, F0, F1, S0, S1 and S2, which never fail. The first row that holds decides, so a seal that breaks its schema is S2 before its type is read. Writers of this version write it empty, 22 bytes. - The head (spec 29.4): layer 2 with its type tag and version 1; layer 3 with the CDDL, R1 and R8; layer 4 in key order, the comment and the declared author, each file with R2 to R6c, R10 and its layout, R7 and R9 over the tree, all ERR_HEAD_INVALID, and then the critical extensions of the new extension.Head object. - ERR_HEAD_INVALID is declared; All lists it once SpecVersion moves to 0.10 with the test data, in step 6. - The control tests take format 4 as the caller error that format 3 was, as section 76 of the spec anticipated. Co-Authored-By: Claude Opus 5.5 --- capsule/format3.go | 727 ++++++++++++++++++++++++++++++++++++++++ capsule/format3_test.go | 281 ++++++++++++++++ capsule/framing.go | 28 +- capsule/framing_test.go | 12 +- errors.go | 5 + extension/extension.go | 3 + 6 files changed, 1044 insertions(+), 12 deletions(-) create mode 100644 capsule/format3.go create mode 100644 capsule/format3_test.go diff --git a/capsule/format3.go b/capsule/format3.go new file mode 100644 index 0000000..f1bc5a6 --- /dev/null +++ b/capsule/format3.go @@ -0,0 +1,727 @@ +package capsule + +import ( + "encoding/binary" + "fmt" + + datekeys "g.activething.com/go/DateKeys" + "g.activething.com/go/DateKeys/codec" + "g.activething.com/go/DateKeys/extension" + "g.activething.com/go/DateKeys/internal/pathrule" +) + +// The content of a format 3 capsule (spec §29.2): the plaintext of +// PAYLOAD_AGE is BODY followed by its padding, and BODY is a frame of 12 +// bytes, the security area, the head and the files. +const ( + // BodyFrameSize is the size of the frame of BODY: AREA_LEN, SECURITY_LEN + // and HEAD_LEN, three unsigned 32-bit big-endian integers. + BodyFrameSize = 12 + // AreaUnit is the unit of AREA_LEN, and MaxAreaLen its maximum. + AreaUnit = 512 + MaxAreaLen = 128 * AreaUnit + // AreaLen is the size of the security area that writers of this version + // write, always, whatever the capsule holds (spec §29.2, §62.1 rule 13). + AreaLen = 512 + // MaxHeadLen is the maximum of HEAD_LEN, 16 MiB. + MaxHeadLen = 16 << 20 + + SecurityTypeTag = "datekeys-security" + SecurityVersion = 1 + HeadTypeTag = "datekeys-head" + HeadVersion = 1 + + // SaltSize is the size of the salt of the head (spec §29.4). + SaltSize = 32 + // Limits of the head, fixed with format 3 (spec §29.4). + MaxCommentLen = pathrule.MaxCommentLen + MaxAuthorLen = pathrule.MaxAuthorLen + MaxFiles = 65535 + MaxPathLen = pathrule.MaxPathLen + // MaxMTime is 9999-12-31T23:59:59Z in seconds since 1970-01-01 UTC. + MaxMTime = 253402300799 + + // maxSecurityItem bounds the byte strings of keys 2 and 3 of security. + maxSecurityItem = 65536 + // maxAlg bounds alg and seal_type. + maxAlg = 1<<32 - 1 +) + +// BodyFrame is the frame of BODY (spec §29.2). +type BodyFrame struct { + AreaLen, SecurityLen, HeadLen uint32 +} + +// Bytes returns the 12 bytes of the frame. +func (f BodyFrame) Bytes() [BodyFrameSize]byte { + var b [BodyFrameSize]byte + binary.BigEndian.PutUint32(b[0:], f.AreaLen) + binary.BigEndian.PutUint32(b[4:], f.SecurityLen) + binary.BigEndian.PutUint32(b[8:], f.HeadLen) + return b +} + +// ContentLength is C, the length of CONTENT in a BODY of length l whose frame +// is f. ParseBodyFrame has checked that it does not underflow. +func (f BodyFrame) ContentLength(l uint64) uint64 { + return l - BodyFrameSize - uint64(f.AreaLen) - uint64(f.HeadLen) +} + +// ParseBodyFrame decodes the frame of BODY from its first 12 bytes and checks +// it against L, the length of BODY (spec §29.2, §63 step 17.2). Every +// violation is ErrIntegrity. The plaintext of PAYLOAD_AGE is at least 256 +// bytes, so the 12 bytes exist even when L is shorter than the frame. +func ParseBodyFrame(b []byte, l uint64) (BodyFrame, error) { + if len(b) != BodyFrameSize { + return BodyFrame{}, fmt.Errorf("capsule: BODY: frame of %d bytes, want %d", len(b), BodyFrameSize) + } + if l < BodyFrameSize { + return BodyFrame{}, fmt.Errorf("capsule: BODY: L = %d is shorter than the frame of %d bytes: %w", l, BodyFrameSize, datekeys.ErrIntegrity) + } + f := BodyFrame{ + AreaLen: binary.BigEndian.Uint32(b[0:]), + SecurityLen: binary.BigEndian.Uint32(b[4:]), + HeadLen: binary.BigEndian.Uint32(b[8:]), + } + switch { + case f.AreaLen < AreaUnit || f.AreaLen > MaxAreaLen || f.AreaLen%AreaUnit != 0: + return f, fmt.Errorf("capsule: BODY: AREA_LEN %d is not a multiple of %d from %d to %d: %w", f.AreaLen, AreaUnit, AreaUnit, MaxAreaLen, datekeys.ErrIntegrity) + case f.SecurityLen < 1 || f.SecurityLen > f.AreaLen: + return f, fmt.Errorf("capsule: BODY: SECURITY_LEN %d is not from 1 to AREA_LEN = %d: %w", f.SecurityLen, f.AreaLen, datekeys.ErrIntegrity) + case f.HeadLen < 1 || f.HeadLen > MaxHeadLen: + return f, fmt.Errorf("capsule: BODY: HEAD_LEN %d is not from 1 to %d: %w", f.HeadLen, MaxHeadLen, datekeys.ErrIntegrity) + case BodyFrameSize+uint64(f.AreaLen)+uint64(f.HeadLen) > l: + return f, fmt.Errorf("capsule: BODY: the frame, the area of %d bytes and the head of %d bytes exceed L = %d: %w", f.AreaLen, f.HeadLen, l, datekeys.ErrIntegrity) + } + return f, nil +} + +// CheckArea checks that the bytes of the security area after SECURITY_CBOR, +// its first securityLen bytes, are zero (spec §29.2): ErrIntegrity if not. +func CheckArea(area []byte, securityLen uint32) error { + for i, c := range area[securityLen:] { + if c != 0 { + return fmt.Errorf("capsule: BODY: byte %d of the security area is not zero: %w", int(securityLen)+i, datekeys.ErrIntegrity) + } + } + return nil +} + +// Verdict is the result of evaluating the signature or the seal of the +// security area (spec §29.7). A verdict never prevents opening. +type Verdict string + +// The verdicts this version can reach (spec §29.7). It implements no alg and +// no seal_type. +const ( + // VerdictUnreadable (X): security fails its layer 2 or 3; it stands for + // both the signature and the seal. + VerdictUnreadable Verdict = "X" + // VerdictNoSignature (F0): no key 2. + VerdictNoSignature Verdict = "F0" + // VerdictSignatureUnchecked (F1): a signature that does not decode, breaks + // its schema or has an alg this reader does not implement. + VerdictSignatureUnchecked Verdict = "F1" + // VerdictNoSeal (S0): no key 3; nothing is shown about the date. + VerdictNoSeal Verdict = "S0" + // VerdictSealUnsupported (S1): a seal_type this reader does not implement. + VerdictSealUnsupported Verdict = "S1" + // VerdictSealUnreadable (S2): a seal that does not decode or breaks its + // schema. + VerdictSealUnreadable Verdict = "S2" +) + +// Text returns the text of the verdict that the official SDK shows, in +// Spanish (spec §29.7), and "" for S0, which shows nothing. +func (v Verdict) Text() string { + switch v { + case VerdictUnreadable: + return "No se han podido comprobar la firma ni el sello: trátala como no firmada y sin fecha probada." + case VerdictNoSignature: + return "Sin firma de autor." + case VerdictSignatureUnchecked: + return "No se ha comprobado ninguna firma: trátala como no firmada." + case VerdictSealUnsupported: + 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." + } + return "" +} + +// Verdicts are the verdicts of the security area of a format 3 capsule. +type Verdicts struct { + Signature, Seal Verdict +} + +// Lines are the verdicts as the official SDK shows them, in order: X alone, +// or the signature and then the seal, when it shows something. +func (v Verdicts) Lines() []string { + if v.Signature == VerdictUnreadable { + return []string{VerdictUnreadable.Text()} + } + lines := []string{v.Signature.Text()} + if t := v.Seal.Text(); t != "" { + lines = append(lines, t) + } + return lines +} + +// securityWire is the outer map of SECURITY_CBOR: keys 2 and 3 hold +// separately encoded CBOR (spec §29.3). +type securityWire struct { + signature, seal []byte // nil when absent +} + +func (w *securityWire) encode(e *codec.Encoder) { + n := 2 + if w.signature != nil { + n++ + } + if w.seal != nil { + n++ + } + e.Map(n) + e.Uint(0) + e.Text(SecurityTypeTag) + e.Uint(1) + e.Uint(SecurityVersion) + if w.signature != nil { + e.Uint(2) + e.Bstr(w.signature) + } + if w.seal != nil { + e.Uint(3) + e.Bstr(w.seal) + } +} + +func (w *securityWire) decode(d *codec.Decoder) error { + pairs, err := d.Map(4) + if err != nil { + return err + } + var seen uint + for range pairs { + k, err := d.Key() + if err != nil { + return err + } + switch k { + case 0: + _, err = d.Text(len(SecurityTypeTag)) + case 1: + _, err = d.Uint(SecurityVersion) + case 2: + w.signature, err = d.Bstr(1, maxSecurityItem) + case 3: + w.seal, err = d.Bstr(1, maxSecurityItem) + default: + return fmt.Errorf("key %d is not defined: %w", k, datekeys.ErrNonCanonicalCBOR) + } + if err != nil { + return fmt.Errorf("key %d: %w", k, err) + } + seen |= 1 << k + } + if err := required(seen, 2); err != nil { + return err + } + return d.EndMap() +} + +// EncodeSecurity returns SECURITY_CBOR as a writer of this version writes it: +// empty, {0: "datekeys-security", 1: 1}, 22 bytes (spec §29.3). +func EncodeSecurity() []byte { + var e codec.Encoder + (&securityWire{}).encode(&e) + b, _ := e.Out() + return b +} + +// EncodeSecurityWith returns SECURITY_CBOR with the given contents of keys 2 +// and 3, nil when absent. This version defines no alg and no seal_type: only +// a generator of test vectors writes them (spec §62.1 rule 13). +func EncodeSecurityWith(signature, seal []byte) ([]byte, error) { + var e codec.Encoder + (&securityWire{signature: signature, seal: seal}).encode(&e) + return e.Out() +} + +// EvaluateSecurity reads SECURITY_CBOR and returns its verdicts (spec §29.3, +// §29.7). It never fails: security never decides the opening. For the +// signature and for the seal apart, the first row of the table of §29.7 that +// holds decides: alg and seal_type are read only from content that decodes +// and meets its schema. +func EvaluateSecurity(b []byte) Verdicts { + x := Verdicts{VerdictUnreadable, VerdictUnreadable} + if tag, version, err := codec.Peek(b); err != nil || tag != SecurityTypeTag || version != SecurityVersion { + return x + } + var w securityWire + if err := codec.Unmarshal(b, w.decode, w.encode); err != nil { + return x + } + v := Verdicts{Signature: VerdictNoSignature, Seal: VerdictNoSeal} + if w.signature != nil { + // A content that does not decode and an alg this version does not + // implement give the same verdict, and this version implements none. + v.Signature = VerdictSignatureUnchecked + } + if w.seal != nil { + if _, err := decodeSeal(w.seal); err != nil { + v.Seal = VerdictSealUnreadable + } else { + v.Seal = VerdictSealUnsupported + } + } + return v +} + +// authorSignature is the content of key 2 of security: {0: alg, 1: public +// key, 2: signature} (spec §29.3). +type authorSignature struct { + alg uint64 + key, value []byte +} + +func (a *authorSignature) encode(e *codec.Encoder) { + e.Map(3) + e.Uint(0) + e.Uint(a.alg) + e.Uint(1) + e.Bstr(a.key) + e.Uint(2) + e.Bstr(a.value) +} + +func (a *authorSignature) decode(d *codec.Decoder) error { + return decodeItem(d, 3, func(k uint64) (err error) { + switch k { + case 0: + a.alg, err = decodeAlg(d) + case 1: + a.key, err = d.Bstr(0, maxSecurityItem) + case 2: + a.value, err = d.Bstr(0, maxSecurityItem) + default: + err = fmt.Errorf("key %d is not defined: %w", k, datekeys.ErrNonCanonicalCBOR) + } + return err + }) +} + +// decodeAuthorSignature decodes the content of key 2 of security. +func decodeAuthorSignature(b []byte) (*authorSignature, error) { + var a authorSignature + if err := codec.Unmarshal(b, a.decode, a.encode); err != nil { + return nil, err + } + return &a, nil +} + +// EncodeAuthorSignature returns the content of key 2 of security for a +// generator of test vectors: this version defines no alg. +func EncodeAuthorSignature(alg uint64, key, signature []byte) ([]byte, error) { + var e codec.Encoder + (&authorSignature{alg, key, signature}).encode(&e) + return e.Out() +} + +// seal is the content of key 3 of security: {0: seal_type, 1: token}. +type seal struct { + sealType uint64 + token []byte +} + +func (s *seal) encode(e *codec.Encoder) { + e.Map(2) + e.Uint(0) + e.Uint(s.sealType) + e.Uint(1) + e.Bstr(s.token) +} + +func (s *seal) decode(d *codec.Decoder) error { + return decodeItem(d, 2, func(k uint64) (err error) { + switch k { + case 0: + s.sealType, err = decodeAlg(d) + case 1: + s.token, err = d.Bstr(0, maxSecurityItem) + default: + err = fmt.Errorf("key %d is not defined: %w", k, datekeys.ErrNonCanonicalCBOR) + } + return err + }) +} + +func decodeSeal(b []byte) (*seal, error) { + var s seal + if err := codec.Unmarshal(b, s.decode, s.encode); err != nil { + return nil, err + } + return &s, nil +} + +// EncodeSeal returns the content of key 3 of security for a generator of +// test vectors: this version defines no seal_type. +func EncodeSeal(sealType uint64, token []byte) ([]byte, error) { + var e codec.Encoder + (&seal{sealType, token}).encode(&e) + return e.Out() +} + +// decodeAlg reads alg or seal_type, from 1 to 2^32 - 1. +func decodeAlg(d *codec.Decoder) (uint64, error) { + v, err := d.Uint(maxAlg) + if err == nil && v == 0 { + err = fmt.Errorf("0 is not defined: %w", datekeys.ErrNonCanonicalCBOR) + } + return v, err +} + +// decodeItem reads a map of exactly n required keys, 0 to n-1, with field. +func decodeItem(d *codec.Decoder, n int, field func(k uint64) error) error { + pairs, err := d.Map(n) + if err != nil { + return err + } + var seen uint + for range pairs { + k, err := d.Key() + if err != nil { + return err + } + if err := field(k); err != nil { + return fmt.Errorf("key %d: %w", k, err) + } + seen |= 1 << k + } + if err := required(seen, n); err != nil { + return err + } + return d.EndMap() +} + +// Head is the head of a format 3 capsule (spec §29.4). +type Head struct { + Salt [SaltSize]byte + // Comment and Author are the comment and the declared author, "" when + // absent. The declared author is text of the creator and proves nothing. + Comment, Author string + // Files are the entries, in strictly ascending byte order of their paths. + Files []File + // Critical and Noncritical are the extensions of the head (keys 6 and 7). + Critical, Noncritical []extension.Extension +} + +// File is an entry of the head: a file of CONTENT. +type File struct { + Path string + Size, Start, End uint64 + SHA256 [32]byte + // MTime is the modification time of the file at its source, in seconds + // since 1970-01-01 UTC, when HasMTime. It is informative. + MTime uint64 + HasMTime bool +} + +// headWire is HEAD_CBOR as it is encoded. +type headWire struct { + h *Head +} + +func (w *headWire) encode(e *codec.Encoder) { + h := w.h + n := 3 + nonEmpty(h.Critical) + nonEmpty(h.Noncritical) + if h.Comment != "" { + n++ + } + if h.Author != "" { + n++ + } + if len(h.Files) > 0 { + n++ + } + e.Map(n) + e.Uint(0) + e.Text(HeadTypeTag) + e.Uint(1) + e.Uint(HeadVersion) + e.Uint(2) + e.Bstr(h.Salt[:]) + if h.Comment != "" { + e.Uint(3) + e.Text(h.Comment) + } + if h.Author != "" { + e.Uint(4) + e.Text(h.Author) + } + if len(h.Files) > 0 { + e.Uint(5) + e.Array(len(h.Files)) + for i := range h.Files { + encodeFile(e, &h.Files[i]) + } + } + encodeExtensions(e, 6, h.Critical, h.Noncritical) +} + +func encodeFile(e *codec.Encoder, f *File) { + n := 5 + if f.HasMTime { + n++ + } + e.Map(n) + e.Uint(0) + e.Text(f.Path) + e.Uint(1) + e.Uint(f.Size) + e.Uint(2) + e.Uint(f.Start) + e.Uint(3) + e.Uint(f.End) + e.Uint(4) + e.Bstr(f.SHA256[:]) + if f.HasMTime { + e.Uint(5) + e.Uint(f.MTime) + } +} + +// decode reads HEAD_CBOR with the rules of the third layer: the CDDL, R1 and +// R8 (spec §29.4, §29.5). The fourth layer comes after, in DecodeHead. +func (w *headWire) decode(d *codec.Decoder) error { + h := w.h + pairs, err := d.Map(8) + if err != nil { + return err + } + var seen uint + for range pairs { + k, err := d.Key() + if err != nil { + return err + } + switch k { + case 0: + _, err = d.Text(len(HeadTypeTag)) + case 1: + _, err = d.Uint(HeadVersion) + case 2: + var salt []byte + if salt, err = d.Bstr(SaltSize, SaltSize); err == nil { + copy(h.Salt[:], salt) + } + case 3: + h.Comment, err = decodeText(d, MaxCommentLen, "comment") + case 4: + h.Author, err = decodeText(d, MaxAuthorLen, "declared author") + case 5: + h.Files, err = decodeFiles(d) + case 6: + h.Critical, err = extension.DecodeArray(d) + case 7: + h.Noncritical, err = extension.DecodeArray(d) + default: + return fmt.Errorf("key %d is not defined: %w", k, datekeys.ErrNonCanonicalCBOR) + } + if err != nil { + return fmt.Errorf("key %d: %w", k, err) + } + seen |= 1 << k + } + if err := required(seen, 3); err != nil { + return err + } + return d.EndMap() +} + +// decodeText reads a text of 1 to max bytes. +func decodeText(d *codec.Decoder, max int, what string) (string, error) { + s, err := d.Text(max) + if err == nil && s == "" { + err = fmt.Errorf("empty %s: %w", what, datekeys.ErrNonCanonicalCBOR) + } + return s, err +} + +// decodeFiles reads the array of entries: 1 to MaxFiles, each path of 1 to +// MaxPathLen bytes (R1), in strictly ascending byte order (R8). +func decodeFiles(d *codec.Decoder) ([]File, error) { + n, err := d.Array(MaxFiles) + if err != nil { + return nil, err + } + if n == 0 { + return nil, fmt.Errorf("empty array of files: %w", datekeys.ErrNonCanonicalCBOR) + } + files := make([]File, 0, min(n, 1024)) + for i := range n { + var f File + if err := decodeItemOptional(d, 5, 6, func(k uint64) (err error) { + switch k { + case 0: + f.Path, err = d.Text(MaxPathLen) + if err == nil && f.Path == "" { + err = fmt.Errorf("R1: the path is empty: %w", datekeys.ErrNonCanonicalCBOR) + } + case 1: + f.Size, err = d.Uint(MaxPayloadLength) + case 2: + f.Start, err = d.Uint(MaxPayloadLength) + case 3: + f.End, err = d.Uint(MaxPayloadLength) + case 4: + var sum []byte + if sum, err = d.Bstr(32, 32); err == nil { + copy(f.SHA256[:], sum) + } + case 5: + f.MTime, err = d.Uint(MaxMTime) + f.HasMTime = err == nil + default: + err = fmt.Errorf("key %d is not defined: %w", k, datekeys.ErrNonCanonicalCBOR) + } + return err + }); err != nil { + return nil, fmt.Errorf("file %d: %w", i+1, err) + } + if i > 0 && f.Path <= files[i-1].Path { + return nil, fmt.Errorf("file %d: R8: the path is not after the path of file %d in byte order: %w", i+1, i, datekeys.ErrNonCanonicalCBOR) + } + files = append(files, f) + } + return files, nil +} + +// decodeItemOptional reads a map whose keys 0 to required-1 are required and +// whose keys up to max-1 are optional. +func decodeItemOptional(d *codec.Decoder, need, max int, field func(k uint64) error) error { + pairs, err := d.Map(max) + if err != nil { + return err + } + var seen uint + for range pairs { + k, err := d.Key() + if err != nil { + return err + } + if err := field(k); err != nil { + return fmt.Errorf("key %d: %w", k, err) + } + seen |= 1 << k + } + if err := required(seen, need); err != nil { + return err + } + return d.EndMap() +} + +// EncodeHead returns HEAD_CBOR for h. The files must already be in byte +// order of their paths. The writer checks the result with DecodeHead, the +// rules of the reader (spec §62.1 rule 17). +func EncodeHead(h *Head) ([]byte, error) { + w := headWire{h: &Head{Salt: h.Salt, Comment: h.Comment, Author: h.Author, Files: h.Files}} + var err error + if w.h.Critical, err = extension.Canonical(h.Critical); err != nil { + return nil, err + } + if w.h.Noncritical, err = extension.Canonical(h.Noncritical); err != nil { + return nil, err + } + if err := extension.CheckDisjoint(w.h.Critical, w.h.Noncritical); err != nil { + return nil, err + } + if len(h.Files) > MaxFiles { + return nil, fmt.Errorf("capsule: head: %d files, more than %d", len(h.Files), MaxFiles) + } + var e codec.Encoder + w.encode(&e) + return e.Out() +} + +// DecodeHead validates and decodes HEAD_CBOR with the layers of spec §69.1 +// (spec §29.4, §63 step 17.4): the type tag and the version (layer 2), the +// CDDL with R1 and R8 (layer 3), and then, in key order, the comment and the +// declared author (§29.6), the files with R2 to R6c, R10 and their layout, +// R7 and R9 over the tree (§29.5), all ErrHeadInvalid, and the critical +// extensions with reg (layer 4). +func DecodeHead(b []byte, reg extension.Registry) (*Head, error) { + if len(b) > MaxHeadLen { + return nil, fmt.Errorf("capsule: head: %d bytes, more than %d: %w", len(b), MaxHeadLen, datekeys.ErrIntegrity) + } + if err := codec.CheckSchema(b, HeadTypeTag, HeadVersion); err != nil { + return nil, fmt.Errorf("capsule: head: %w", err) + } + h := &Head{} + w := headWire{h: h} + if err := codec.Unmarshal(b, w.decode, w.encode); err != nil { + return nil, fmt.Errorf("capsule: head: %w", err) + } + if err := extension.CheckDisjoint(h.Critical, h.Noncritical); err != nil { + return nil, fmt.Errorf("capsule: head: %w", err) + } + if err := checkHeadFields(h); err != nil { + return nil, err + } + if err := extension.CheckCriticalIn(extension.Head, h.Critical, reg); err != nil { + return nil, fmt.Errorf("capsule: head: %w", err) + } + return h, nil +} + +// checkHeadFields applies the rules of the fourth layer with a code of their +// own, ErrHeadInvalid: keys 3, 4 and 5, in that order. +func checkHeadFields(h *Head) error { + invalid := func(what string, err error) error { + return fmt.Errorf("capsule: head: %s%v: %w", what, err, datekeys.ErrHeadInvalid) + } + if h.Comment != "" { + if err := pathrule.CheckComment(h.Comment); err != nil { + return invalid("comment: ", err) + } + } + if h.Author != "" { + if err := pathrule.CheckAuthor(h.Author); err != nil { + return invalid("declared author: ", err) + } + } + var end uint64 + paths := make([]string, len(h.Files)) + for i := range h.Files { + f := &h.Files[i] + if err := pathrule.CheckPath(f.Path); err != nil { + return invalid(fmt.Sprintf("file %d: ", i+1), err) + } + switch { + case f.Start != end: + return invalid(fmt.Sprintf("file %d: ", i+1), fmt.Errorf("start %d is not %d, the end of the file before", f.Start, end)) + case f.End < f.Start || f.End-f.Start != f.Size: + return invalid(fmt.Sprintf("file %d: ", i+1), fmt.Errorf("from start %d to end %d is not the size %d", f.Start, f.End, f.Size)) + } + end = f.End + paths[i] = f.Path + } + if err := pathrule.CheckTree(paths); err != nil { + return invalid("", err) + } + return nil +} + +// CheckHeadEnd checks that the files fill CONTENT, of c bytes: the last one +// ends at C, or C is 0 without files (spec §29.4, §63 step 17.5). +// ErrIntegrity if not. +func CheckHeadEnd(h *Head, c uint64) error { + var end uint64 + if n := len(h.Files); n > 0 { + end = h.Files[n-1].End + } + if end != c { + return fmt.Errorf("capsule: BODY: the files end at byte %d of a content of %d bytes: %w", end, c, datekeys.ErrIntegrity) + } + return nil +} diff --git a/capsule/format3_test.go b/capsule/format3_test.go new file mode 100644 index 0000000..9d5c945 --- /dev/null +++ b/capsule/format3_test.go @@ -0,0 +1,281 @@ +package capsule_test + +import ( + "bytes" + "errors" + "strings" + "testing" + + datekeys "g.activething.com/go/DateKeys" + "g.activething.com/go/DateKeys/capsule" + "g.activething.com/go/DateKeys/extension" +) + +// Spec §29.2: the frame of BODY and its limits. Every violation is +// ERR_INTEGRITY. +func TestBodyFrame(t *testing.T) { + frame := func(area, sec, head uint32) []byte { + b := capsule.BodyFrame{AreaLen: area, SecurityLen: sec, HeadLen: head}.Bytes() + return b[:] + } + // Valid, at the limits. + for _, c := range []struct { + area, sec, head uint32 + l uint64 + }{ + {512, 22, 53, 577}, + {512, 512, 1, 525}, + {65536, 1, 1 << 24, 12 + 65536 + 1<<24}, + {1024, 1024, 100, 1 << 40}, + } { + f, err := capsule.ParseBodyFrame(frame(c.area, c.sec, c.head), c.l) + if err != nil { + t.Errorf("%+v: %v", c, err) + continue + } + if want := c.l - 12 - uint64(c.area) - uint64(c.head); f.ContentLength(c.l) != want { + t.Errorf("%+v: C = %d, want %d", c, f.ContentLength(c.l), want) + } + } + for name, c := range map[string]struct { + area, sec, head uint32 + l uint64 + }{ + "L shorter than the frame": {512, 22, 53, 11}, + "AREA_LEN 0": {0, 22, 53, 1000}, + "AREA_LEN 511": {511, 22, 53, 1000}, + "AREA_LEN 513": {513, 22, 53, 1000}, + "AREA_LEN 66048": {66048, 22, 53, 100000}, + "SECURITY_LEN 0": {512, 0, 53, 1000}, + "SECURITY_LEN above AREA_LEN": {512, 513, 53, 1000}, + "HEAD_LEN 0": {512, 22, 0, 1000}, + "HEAD_LEN 2^24 + 1": {512, 22, 1<<24 + 1, 1 << 30}, + "frame, area and head above L": {512, 22, 53, 576}, + } { + if _, err := capsule.ParseBodyFrame(frame(c.area, c.sec, c.head), c.l); !errors.Is(err, datekeys.ErrIntegrity) { + t.Errorf("%s: %v", name, err) + } + } + area := make([]byte, 512) + copy(area, capsule.EncodeSecurity()) + if err := capsule.CheckArea(area, 22); err != nil { + t.Fatal(err) + } + area[511] = 1 + if err := capsule.CheckArea(area, 22); !errors.Is(err, datekeys.ErrIntegrity) { + t.Fatalf("a byte of the area that is not zero: %v", err) + } +} + +// Spec §29.3, §29.7: the security area and its verdicts, which never fail. +func TestSecurityVerdicts(t *testing.T) { + empty := capsule.EncodeSecurity() + if len(empty) != 22 { + t.Fatalf("empty security is %d bytes, want 22", len(empty)) + } + sig, err := capsule.EncodeAuthorSignature(1, make([]byte, 32), make([]byte, 64)) + if err != nil { + t.Fatal(err) + } + if len(sig) != 105 { + t.Fatalf("an Ed25519 author-signature is %d bytes, want 105", len(sig)) + } + seal, err := capsule.EncodeSeal(1, []byte{1, 2, 3}) + if err != nil { + t.Fatal(err) + } + with := func(sig, seal []byte) []byte { + b, err := capsule.EncodeSecurityWith(sig, seal) + if err != nil { + t.Fatal(err) + } + return b + } + if n := len(with(sig, nil)); n != 130 { + t.Fatalf("security with a signature is %d bytes, want 130", n) + } + x := capsule.Verdicts{Signature: capsule.VerdictUnreadable, Seal: capsule.VerdictUnreadable} + for name, c := range map[string]struct { + b []byte + want capsule.Verdicts + }{ + "empty": {empty, capsule.Verdicts{Signature: "F0", Seal: "S0"}}, + "a signature of alg 1": {with(sig, nil), capsule.Verdicts{Signature: "F1", Seal: "S0"}}, + "a seal of seal_type 1": {with(nil, seal), capsule.Verdicts{Signature: "F0", Seal: "S1"}}, + "both": {with(sig, seal), capsule.Verdicts{Signature: "F1", Seal: "S1"}}, + "alg 0": {with(mustMarshal(t, map[uint64]any{0: uint64(0), 1: []byte{}, 2: []byte{}}), nil), capsule.Verdicts{Signature: "F1", Seal: "S0"}}, + "a signature that is no map": {with([]byte{0x01}, nil), capsule.Verdicts{Signature: "F1", Seal: "S0"}}, + // The first row that holds decides: a seal with an unknown key and an + // unknown seal_type breaks its schema, S2, before its type is read. + "a seal with an unknown key": {with(nil, []byte{0xa3, 0x00, 0x07, 0x01, 0x41, 0x00, 0x02, 0x00}), capsule.Verdicts{Signature: "F0", Seal: "S2"}}, + "seal_type 0": {with(nil, mustMarshal(t, map[uint64]any{0: uint64(0), 1: []byte{1}})), capsule.Verdicts{Signature: "F0", Seal: "S2"}}, + "a seal that is not CBOR": {with(sig, []byte{0xff}), capsule.Verdicts{Signature: "F1", Seal: "S2"}}, + "version 2": {mustMarshal(t, map[uint64]any{0: "datekeys-security", 1: uint64(2)}), x}, + "another type tag": {mustMarshal(t, map[uint64]any{0: "datekeys-head", 1: uint64(1)}), x}, + "an unknown key 4": {mustMarshal(t, map[uint64]any{0: "datekeys-security", 1: uint64(1), 4: []byte{1}}), x}, + "key 2 not a byte string": {mustMarshal(t, map[uint64]any{0: "datekeys-security", 1: uint64(1), 2: uint64(1)}), x}, + "an empty key 2": {mustMarshal(t, map[uint64]any{0: "datekeys-security", 1: uint64(1), 2: []byte{}}), x}, + "a byte more": {append(bytes.Clone(empty), 0), x}, + "not CBOR": {[]byte("security"), x}, + } { + if got := capsule.EvaluateSecurity(c.b); got != c.want { + t.Errorf("%s: %+v, want %+v", name, got, c.want) + } + } + if got := x.Lines(); len(got) != 1 || !strings.HasPrefix(got[0], "No se han podido") { + t.Errorf("X shows %q", got) + } + if got := (capsule.Verdicts{Signature: "F0", Seal: "S0"}).Lines(); len(got) != 1 || got[0] != "Sin firma de autor." { + t.Errorf("F0 and S0 show %q", got) + } + if got := (capsule.Verdicts{Signature: "F1", Seal: "S1"}).Lines(); len(got) != 2 { + t.Errorf("F1 and S1 show %q", got) + } +} + +func sampleHead() *capsule.Head { + h := &capsule.Head{ + Comment: "Para ti ❤️", + Author: "Ana López", + Files: []capsule.File{ + {Path: "fotos/playa.jpg", Size: 10, Start: 0, End: 10, MTime: 1759190400, HasMTime: true}, + {Path: "nota.txt", Size: 5, Start: 10, End: 15}, + }, + } + h.Salt[0] = 1 + h.Files[0].SHA256[0] = 2 + return h +} + +// Spec §29.4: a head round-trips, and its sizes are those of §29.2. +func TestHeadRoundTrip(t *testing.T) { + h := sampleHead() + b, err := capsule.EncodeHead(h) + if err != nil { + t.Fatal(err) + } + got, err := capsule.DecodeHead(b, nil) + if err != nil { + t.Fatal(err) + } + if got.Comment != h.Comment || got.Author != h.Author || len(got.Files) != 2 || got.Files[0] != h.Files[0] || got.Files[1] != h.Files[1] || got.Salt != h.Salt { + t.Fatalf("round trip: %+v", got) + } + if err := capsule.CheckHeadEnd(got, 15); err != nil { + t.Fatal(err) + } + if err := capsule.CheckHeadEnd(got, 16); !errors.Is(err, datekeys.ErrIntegrity) { + t.Fatalf("files that do not fill the content: %v", err) + } + if err := capsule.CheckHeadEnd(&capsule.Head{}, 0); err != nil { + t.Fatal(err) + } + for _, c := range []struct { + h capsule.Head + size int + }{ + {capsule.Head{}, 53}, + {capsule.Head{Comment: "x"}, 56}, + {capsule.Head{Files: []capsule.File{{Path: "nota.txt", Size: 1000, End: 1000, MTime: 1759190400, HasMTime: true}}}, 117}, + {capsule.Head{Files: []capsule.File{{Path: "a"}}}, 100}, + } { + b, err := capsule.EncodeHead(&c.h) + if err != nil { + t.Fatal(err) + } + if len(b) != c.size { + t.Errorf("%+v: %d bytes, want %d", c.h, len(b), c.size) + } + } +} + +// head builds HEAD_CBOR with the test encoder, for heads that the capsule +// encoder refuses to write. +func head(t *testing.T, fields map[uint64]any) []byte { + m := map[uint64]any{0: "datekeys-head", 1: uint64(1), 2: make([]byte, 32)} + for k, v := range fields { + if v == nil { + delete(m, k) + } else { + m[k] = v + } + } + return mustMarshal(t, m) +} + +func file(path string, size, start, end uint64) map[uint64]any { + return map[uint64]any{0: path, 1: size, 2: start, 3: end, 4: make([]byte, 32)} +} + +// Spec §29.4, §69.1: the layers of the head and their codes, and the first +// failing layer decides. +func TestDecodeHeadLayers(t *testing.T) { + many := make([]any, 65536) + for i := range many { + many[i] = file(strings.Repeat("a", 1)+string(rune('a'+i%26))+strings.Repeat("x", i/26%3), 0, 0, 0) + } + for name, c := range map[string]struct { + b []byte + want error + }{ + // Layer 2. + "another type tag": {mustMarshal(t, map[uint64]any{0: "datekeys-control", 1: uint64(1), 2: make([]byte, 32)}), datekeys.ErrNonCanonicalCBOR}, + "version 2": {mustMarshal(t, map[uint64]any{0: "datekeys-head", 1: uint64(2), 2: make([]byte, 32)}), datekeys.ErrUnsupportedVersion}, + "version 2 and ..": {mustMarshal(t, map[uint64]any{0: "datekeys-head", 1: uint64(2), 2: make([]byte, 32), 5: []any{file("..", 0, 0, 0)}}), datekeys.ErrUnsupportedVersion}, + // Layer 3. + "no salt": {head(t, map[uint64]any{2: nil}), datekeys.ErrNonCanonicalCBOR}, + "a salt of 31 bytes": {head(t, map[uint64]any{2: make([]byte, 31)}), datekeys.ErrNonCanonicalCBOR}, + "an empty comment": {head(t, map[uint64]any{3: ""}), datekeys.ErrNonCanonicalCBOR}, + "a comment of 16385 bytes": {head(t, map[uint64]any{3: strings.Repeat("a", 16385)}), datekeys.ErrNonCanonicalCBOR}, + "an author of 257 bytes": {head(t, map[uint64]any{4: strings.Repeat("a", 257)}), datekeys.ErrNonCanonicalCBOR}, + "an empty array of files": {head(t, map[uint64]any{5: []any{}}), datekeys.ErrNonCanonicalCBOR}, + "65536 files": {head(t, map[uint64]any{5: many}), datekeys.ErrNonCanonicalCBOR}, + "R1: an empty path": {head(t, map[uint64]any{5: []any{file("", 0, 0, 0)}}), datekeys.ErrNonCanonicalCBOR}, + "R1: a path of 1025 bytes": {head(t, map[uint64]any{5: []any{file(strings.Repeat("a", 1025), 0, 0, 0)}}), datekeys.ErrNonCanonicalCBOR}, + "R8: b before a": {head(t, map[uint64]any{5: []any{file("b", 0, 0, 0), file("a", 0, 0, 0)}}), datekeys.ErrNonCanonicalCBOR}, + "R8: a repeated path": {head(t, map[uint64]any{5: []any{file("a", 0, 0, 0), file("a", 0, 0, 0)}}), datekeys.ErrNonCanonicalCBOR}, + "R8 before R3: b/.. and a": {head(t, map[uint64]any{5: []any{file("b/..", 0, 0, 0), file("a", 0, 0, 0)}}), datekeys.ErrNonCanonicalCBOR}, + "a size above L_MAX": {head(t, map[uint64]any{5: []any{file("a", capsule.MaxPayloadLength+1, 0, 0)}}), datekeys.ErrNonCanonicalCBOR}, + "an mtime after 9999": {head(t, map[uint64]any{5: []any{map[uint64]any{0: "a", 1: uint64(0), 2: uint64(0), 3: uint64(0), 4: make([]byte, 32), 5: uint64(253402300800)}}}), datekeys.ErrNonCanonicalCBOR}, + "an unknown key 8": {head(t, map[uint64]any{8: uint64(1)}), datekeys.ErrNonCanonicalCBOR}, + "a byte more": {append(head(t, nil), 0), datekeys.ErrNonCanonicalCBOR}, + // Layer 4, in key order. + "a comment with U+202E": {head(t, map[uint64]any{3: "a‮b"}), datekeys.ErrHeadInvalid}, + "a comment with the tag U+E0041": {head(t, map[uint64]any{3: "a\U000E0041"}), datekeys.ErrHeadInvalid}, + "an author with LF": {head(t, map[uint64]any{4: "a\nb"}), datekeys.ErrHeadInvalid}, + "R3: ..": {head(t, map[uint64]any{5: []any{file("..", 0, 0, 0)}}), datekeys.ErrHeadInvalid}, + "R2: /a": {head(t, map[uint64]any{5: []any{file("/a", 0, 0, 0)}}), datekeys.ErrHeadInvalid}, + "R4b: a and VS16": {head(t, map[uint64]any{5: []any{file("a️", 0, 0, 0)}}), datekeys.ErrHeadInvalid}, + "R6: CON.txt": {head(t, map[uint64]any{5: []any{file("CON.txt", 0, 0, 0)}}), datekeys.ErrHeadInvalid}, + "R10: .datekeys-x": {head(t, map[uint64]any{5: []any{file(".datekeys-x", 0, 0, 0)}}), datekeys.ErrHeadInvalid}, + "layout: a first start that is not 0": {head(t, map[uint64]any{5: []any{file("a", 1, 1, 2)}}), datekeys.ErrHeadInvalid}, + "layout: end minus start is not size": {head(t, map[uint64]any{5: []any{file("a", 2, 0, 1)}}), datekeys.ErrHeadInvalid}, + "layout: a gap": {head(t, map[uint64]any{5: []any{file("a", 1, 0, 1), file("b", 1, 2, 3)}}), datekeys.ErrHeadInvalid}, + "R7: A.txt and a.txt": {head(t, map[uint64]any{5: []any{file("A.txt", 0, 0, 0), file("a.txt", 0, 0, 0)}}), datekeys.ErrHeadInvalid}, + "a comment and a path that break": {head(t, map[uint64]any{3: "a‮", 5: []any{file("..", 0, 0, 0)}}), datekeys.ErrHeadInvalid}, + "a path that breaks, then an unknown critical extension": {head(t, map[uint64]any{5: []any{file("..", 0, 0, 0)}, 6: []any{map[uint64]any{0: "x.example", 1: uint64(1)}}}), datekeys.ErrHeadInvalid}, + "an unknown critical extension": {head(t, map[uint64]any{6: []any{map[uint64]any{0: "x.example", 1: uint64(1)}}}), datekeys.ErrExtensionCriticalUnknown}, + } { + if _, err := capsule.DecodeHead(c.b, nil); !errors.Is(err, c.want) { + t.Errorf("%s: %v, want %v", name, err, c.want) + } else if n := codes(err); n != 1 { + t.Errorf("%s: %d normative codes in %v", name, n, err) + } + } + // A known critical extension of the head passes. + b := head(t, map[uint64]any{6: []any{map[uint64]any{0: "x.example", 1: uint64(1)}}}) + if _, err := capsule.DecodeHead(b, extension.Set{"x.example": {1}}); err != nil { + t.Fatal(err) + } +} + +// codes counts the normative errors err wraps, ERR_HEAD_INVALID included. +func codes(err error) int { + n := 0 + for _, e := range append(datekeys.All(), datekeys.ErrHeadInvalid) { + if errors.Is(err, e) { + n++ + } + } + return n +} diff --git a/capsule/framing.go b/capsule/framing.go index df58e96..c54695e 100644 --- a/capsule/framing.go +++ b/capsule/framing.go @@ -51,9 +51,17 @@ const ( // Format2 is the format of spec v0.9, the one Encrypt writes: exactly 16 // stanzas in INNER_ACCESS_AGE and a padded payload (spec §29.1, §39). Format2 Format = 2 + // Format3 is the format of spec v0.10: the padded plaintext of + // PAYLOAD_AGE is BODY, with the security area, the head and several + // files (spec §29.2 to §29.7). + Format3 Format = 3 ) -func (f Format) valid() bool { return f == Format1 || f == Format2 } +func (f Format) valid() bool { return f >= Format1 && f <= Format3 } + +// padded reports whether the payload of format f is padded, with L and the +// padding code in keys 6 and 7 of its control (spec §29.1, §31). +func (f Format) padded() bool { return f == Format2 || f == Format3 } // Policy is the declared access policy of PUBLIC_HEADER (spec §25). type Policy uint8 @@ -126,7 +134,9 @@ func ParsePrelude(b []byte) (Prelude, error) { if len(b) < PreludeSize { return Prelude{}, fmt.Errorf("capsule: truncated prelude: %w", datekeys.ErrIntegrity) } - if !Format(b[4]).valid() { + // Format 3 is read from step 3 of the plan of format 3 on, together with + // the test data that expect VERSION 3 to be rejected here. + if f := Format(b[4]); !f.valid() || f == Format3 { return Prelude{}, fmt.Errorf("capsule: framing version %d: %w", b[4], datekeys.ErrUnsupportedVersion) } if b[5] != 0 || b[6] != 0 || b[7] != 0 { @@ -378,7 +388,7 @@ type controlWire struct { func (w *controlWire) encode(e *codec.Encoder) { n := 4 + nonEmpty(w.Critical) + nonEmpty(w.Noncritical) - if w.Format == Format2 { + if w.Format.padded() { n += 2 } e.Map(n) @@ -391,7 +401,7 @@ func (w *controlWire) encode(e *codec.Encoder) { e.Uint(3) e.Bstr(w.PayloadIdentity) encodeExtensions(e, 4, w.Critical, w.Noncritical) - if w.Format == Format2 { + if w.Format.padded() { e.Uint(6) e.Bstr(w.PayloadLength) e.Uint(7) @@ -404,7 +414,7 @@ func (w *controlWire) encode(e *codec.Encoder) { // version 1. The caller wipes PayloadIdentity, whatever the result. func (w *controlWire) decode(d *codec.Decoder) error { maxPairs := 6 - if w.Format == Format2 { + if w.Format.padded() { maxPairs = 8 } pairs, err := d.Map(maxPairs) @@ -417,7 +427,7 @@ func (w *controlWire) decode(d *codec.Decoder) error { if err != nil { return err } - if (k == 6 || k == 7) && w.Format != Format2 { + if (k == 6 || k == 7) && !w.Format.padded() { return fmt.Errorf("key %d is not defined: %w", k, datekeys.ErrNonCanonicalCBOR) } switch k { @@ -456,7 +466,7 @@ func (w *controlWire) decode(d *codec.Decoder) error { if err := required(seen, 4); err != nil { return err } - if w.Format == Format2 { + if w.Format.padded() { for _, k := range []uint{6, 7} { if seen&(1<