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/inspect.go

239 lines
9.0 KiB

package capsule
import (
"bytes"
"errors"
"fmt"
"io"
"time"
"filippo.io/age"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/agewrap"
"g.activething.com/go/DateKeys/datekey"
"g.activething.com/go/DateKeys/extension"
"g.activething.com/go/DateKeys/profile"
)
// InspectOptions configures Inspect.
type InspectOptions struct {
// Registry holds the locally pinned profiles. Required.
Registry profile.Registry
// Extensions lists the extensions the application implements. Nil knows
// none, the state of the base protocol V1. When it is also an
// extension.DataValidator, the data of the known extensions is checked
Spec v0.8.2: corrections from the formal review A formal review of the whole v0.8.2 text found it approvable after these corrections, recorded in §76 ("Correcciones de la revisión formal"): - §27 no longer calls header_binding the authenticity of PUBLIC_HEADER: it binds the header to the opened control, never authorship or date (§55.1); the age MAC only protects against whoever lacks the file key. - §63 steps 9 and 10: a network source (relay, Release API, cache) MUST verify every response and gives ERR_RELEASE_UNAVAILABLE at step 9 when none verifies; the step-10 codes are for a directly supplied release. The reference already behaved so; TestReleaseFromANetworkSource pins both paths. - §54 and §72: registrations declare the objects and arrays where an extension may appear, and a known extension out of place counts as unknown there. The reference gains the optional extension.Placement interface, used at steps 4, 9.a and 14. - §63 step 11 fixes the GT serialization hashed by H2 (kilic/kyber order) with the frozen vector H2(e(G1, G2))[:16] = cb87319f..., shared as testdata/vectors/tlock_ibe.json; H2-H4 are cited to drand/kyber. - Step 5 makes the SEALED_CONTROL read mandatory, step 15 names ERR_HEADER_BINDING, §21 makes capsule_id 16 CSPRNG bytes a MUST, §76 is made accurate (four dk1.json vectors, the §36 time_only rule, two cases rewritten against the texts that really existed), and editorial fixes in §5, §36, §55.1, §69.1 and §77. §73 lists the three new decisions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 weeks ago
// (spec §54). When it is also an extension.Placement, an extension it
// knows is known only in the objects and arrays it registers it for, and
// unknown elsewhere (spec §54, §72).
Extensions extension.Registry
}
// CheckResult records one step of the flow of spec §63.
type CheckResult struct {
Step int `json:"step"`
Name string `json:"name"`
OK bool `json:"ok"`
Detail string `json:"detail,omitempty"`
// Error is the normative code of a failed step, for example
// "ERR_ROUND_MISMATCH".
Error string `json:"error,omitempty"`
}
// StanzaInfo is the visible part of an age recipient stanza.
type StanzaInfo struct {
Type string `json:"type"`
Args []string `json:"args"`
}
// Inspection is the result of the pre-unlock validation, steps 1 to 8 of
// spec §63. It is produced without network access and without secrets.
Implement capsule format 2 of spec v0.9 The reference moves to the DateKeys Protocol Specification v0.9, approved by its author on 29 September 2026. Encrypt writes capsule format 2 only; Open and Inspect read formats 1 and 2, and a format 1 capsule keeps the verdict v0.8.2 gave it. Format 2 (spec §22, §29.1, §31, §39): - VERSION in the PRELUDE is the capsule format, capsule.Format; any other value is ERR_UNSUPPORTED_VERSION at step 2. - CONTROL_CBOR has the schema version of its format. Version 2 adds key 6, payload_length (8 bytes, big-endian, at most L_MAX = 2^53 - 2^46), and key 7, padding (1 bloque256, 2 reforzado); it is 103 bytes without extensions, whatever L. - The payload is the content padded with zeros to P = rule(L). Step 17 checks the length and the zeros, and Open writes only the first L bytes. - INNER_ACCESS_AGE holds exactly 16 X25519 stanzas: 1 to 16 credentials, and a dummy in each slot left, in a uniformly random order. Writer rules (spec §62.1): EncryptOptions.Length is required and the source must deliver exactly that many bytes; recipients that are not canonical or of low order are rejected (agewrap.CheckX25519Recipient); self-checks of the header, the control, INNER_ACCESS_AGE and PAYLOAD_AGE. The CLI measures its input, takes -padding and reports the format. Test data: seven format 2 fixtures, padding vectors checked against math/big, format 2 CBOR vectors, and the mutation corpus in both formats with the 22 cases of the third list of spec §64, built without randomness by sealing the fixtures again with their known keys and nonces. The format 1 fixtures are kept byte for byte and never regenerated; the differential corpus keeps its 1825 cases and adds a block per format 2 fixture. The spec copy loses its "to be implemented" markers, and the READMEs, CHANGELOG, traceability and testdata/README.md follow v0.9. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 week ago
// Prelude.Format is the format of the capsule once step 2 has passed: a
// caller should show it, because format 1 does not hide the number of
// credentials or the exact length of the content (spec §55.2, §70).
type Inspection struct {
Prelude Prelude
PublicHeader []byte // exact PUBLIC_HEADER bytes
Header *Header
Profile *profile.Profile
UnlockAt time.Time // effective round time of the DateKey
PayloadOffset int64
OuterStanzas []StanzaInfo // OUTER_TIME_AGE
PayloadStanzas []StanzaInfo // PAYLOAD_AGE
// UnusableExtensions are the known noncritical PUBLIC_HEADER extensions
// whose data InspectOptions.Extensions rejects. The capsule stays valid;
// the application must not use them (spec §54).
UnusableExtensions []extension.Unusable
Checks []CheckResult
}
func (in *Inspection) pass(step int, name, detail string) {
in.Checks = append(in.Checks, CheckResult{Step: step, Name: name, OK: true, Detail: detail})
}
func (in *Inspection) fail(step int, name string, err error) error {
in.Checks = append(in.Checks, CheckResult{Step: step, Name: name, Detail: err.Error(), Error: datekeys.Code(err)})
return err
}
// parsed carries what Open needs after the inspection.
type parsed struct {
prelude [PreludeSize]byte
sealed []byte // OUTER_TIME_AGE
payload io.Reader // positioned at the start of PAYLOAD_AGE
}
// Inspect runs steps 1 to 8 of spec §63 on the .dkc read from r: framing,
// canonical PUBLIC_HEADER, canonical DateKey, pinned profile, known critical
// extensions, the stanza structure of OUTER_TIME_AGE and PAYLOAD_AGE, and the
// round and chain hash of the tlock stanza. It never contacts a release
// source and never uses a secret, so an invalid capsule is rejected before it
// can cause an observable query (spec §27, §63).
//
// Inspect reads the prelude, the header, SEALED_CONTROL and the age header of
// the payload; it does not read the rest of the payload. On failure it
// returns the partial Inspection together with the error.
func Inspect(r io.Reader, opts InspectOptions) (*Inspection, error) {
Format 3, step 3: the reader Open reads format 3 (spec 29.2 to 29.7, 63 steps 17 and 18): the PRELUDE accepts VERSION 3, and the files go to a Sink. - Sink: Begin with the validated head, Create for each file in the order of the head, and Commit only after every check of step 17; after any failure that follows a successful Begin, Abort, once. A format 3 capsule without a Sink fails right after step 2 with ErrSinkRequired, a caller error with no code, no failed step and no request; a capsule of format 1 or 2 without dst fails there too. - Step 17 in its substeps: the frame and the area, security and its verdicts, which never fail, the head, the files filling CONTENT, the SHA-256 of each file and the padding. A failure of age or a plaintext whose length is not P prevails; otherwise the first substep that fails decides, and a code other than ERR_INTEGRITY is reported only after reading PAYLOAD_AGE to its end. - The reads of BODY grow with the bytes received, never with AREA_LEN, HEAD_LEN or a declared size (spec 57); a test measures it. - A failure of the Sink is the caller's own error with ERR_INTEGRITY, as one of dst is in formats 1 and 2. - Opened gains Head, Verdicts, AreaLen and UnusableHeadExtensions. - Test data: "version changed" sets VERSION 4, and the format 2 list gains "format 2 time_only relabeled format 3", which fails at step 14, as section 64 of spec v0.10 lists: 126 cases, 89 of the spec. The randomly built capsules keep their recorded bytes. - testkit: Build writes format 3 and can edit the padded plaintext; Head3, Body3, DiscardSink and MemorySink build and open BODY. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 week ago
in, _, err := inspect(r, opts, nil)
return in, err
}
Format 3, step 3: the reader Open reads format 3 (spec 29.2 to 29.7, 63 steps 17 and 18): the PRELUDE accepts VERSION 3, and the files go to a Sink. - Sink: Begin with the validated head, Create for each file in the order of the head, and Commit only after every check of step 17; after any failure that follows a successful Begin, Abort, once. A format 3 capsule without a Sink fails right after step 2 with ErrSinkRequired, a caller error with no code, no failed step and no request; a capsule of format 1 or 2 without dst fails there too. - Step 17 in its substeps: the frame and the area, security and its verdicts, which never fail, the head, the files filling CONTENT, the SHA-256 of each file and the padding. A failure of age or a plaintext whose length is not P prevails; otherwise the first substep that fails decides, and a code other than ERR_INTEGRITY is reported only after reading PAYLOAD_AGE to its end. - The reads of BODY grow with the bytes received, never with AREA_LEN, HEAD_LEN or a declared size (spec 57); a test measures it. - A failure of the Sink is the caller's own error with ERR_INTEGRITY, as one of dst is in formats 1 and 2. - Opened gains Head, Verdicts, AreaLen and UnusableHeadExtensions. - Test data: "version changed" sets VERSION 4, and the format 2 list gains "format 2 time_only relabeled format 3", which fails at step 14, as section 64 of spec v0.10 lists: 126 cases, 89 of the spec. The randomly built capsules keep their recorded bytes. - testkit: Build writes format 3 and can edit the padded plaintext; Head3, Body3, DiscardSink and MemorySink build and open BODY. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 week ago
// inspect runs steps 1 to 8. afterPrelude, when not nil, runs right after
// step 2 passes: its error, a caller's one, ends inspect there.
func inspect(r io.Reader, opts InspectOptions, afterPrelude func(Prelude) error) (*Inspection, *parsed, error) {
in := &Inspection{}
if opts.Registry == nil {
return in, nil, errors.New("capsule: InspectOptions.Registry is required")
}
// Steps 1 and 2: parse DKC1 and validate the prelude.
var pre [PreludeSize]byte
n, err := io.ReadFull(r, pre[:])
if err != nil && n >= 4 && string(pre[:4]) == Magic {
return in, nil, in.fail(1, "parse DKC1", fmt.Errorf("capsule: truncated prelude: %w", datekeys.ErrIntegrity))
}
prelude, err := ParsePrelude(pre[:n])
if errors.Is(err, datekeys.ErrInvalidMagic) {
return in, nil, in.fail(1, "parse DKC1", err)
}
in.pass(1, "parse DKC1", "magic DKC1")
if err != nil {
return in, nil, in.fail(2, "prelude", err)
}
in.Prelude = prelude
in.PayloadOffset = prelude.PayloadOffset()
in.pass(2, "prelude", fmt.Sprintf("DKC1 v%d, PUBLIC_HEADER_LEN=%d, SEALED_CONTROL_LEN=%d",
Implement capsule format 2 of spec v0.9 The reference moves to the DateKeys Protocol Specification v0.9, approved by its author on 29 September 2026. Encrypt writes capsule format 2 only; Open and Inspect read formats 1 and 2, and a format 1 capsule keeps the verdict v0.8.2 gave it. Format 2 (spec §22, §29.1, §31, §39): - VERSION in the PRELUDE is the capsule format, capsule.Format; any other value is ERR_UNSUPPORTED_VERSION at step 2. - CONTROL_CBOR has the schema version of its format. Version 2 adds key 6, payload_length (8 bytes, big-endian, at most L_MAX = 2^53 - 2^46), and key 7, padding (1 bloque256, 2 reforzado); it is 103 bytes without extensions, whatever L. - The payload is the content padded with zeros to P = rule(L). Step 17 checks the length and the zeros, and Open writes only the first L bytes. - INNER_ACCESS_AGE holds exactly 16 X25519 stanzas: 1 to 16 credentials, and a dummy in each slot left, in a uniformly random order. Writer rules (spec §62.1): EncryptOptions.Length is required and the source must deliver exactly that many bytes; recipients that are not canonical or of low order are rejected (agewrap.CheckX25519Recipient); self-checks of the header, the control, INNER_ACCESS_AGE and PAYLOAD_AGE. The CLI measures its input, takes -padding and reports the format. Test data: seven format 2 fixtures, padding vectors checked against math/big, format 2 CBOR vectors, and the mutation corpus in both formats with the 22 cases of the third list of spec §64, built without randomness by sealing the fixtures again with their known keys and nonces. The format 1 fixtures are kept byte for byte and never regenerated; the differential corpus keeps its 1825 cases and adds a block per format 2 fixture. The spec copy loses its "to be implemented" markers, and the READMEs, CHANGELOG, traceability and testdata/README.md follow v0.9. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 week ago
prelude.Format, prelude.PublicHeaderLen, prelude.SealedControlLen))
Format 3, step 3: the reader Open reads format 3 (spec 29.2 to 29.7, 63 steps 17 and 18): the PRELUDE accepts VERSION 3, and the files go to a Sink. - Sink: Begin with the validated head, Create for each file in the order of the head, and Commit only after every check of step 17; after any failure that follows a successful Begin, Abort, once. A format 3 capsule without a Sink fails right after step 2 with ErrSinkRequired, a caller error with no code, no failed step and no request; a capsule of format 1 or 2 without dst fails there too. - Step 17 in its substeps: the frame and the area, security and its verdicts, which never fail, the head, the files filling CONTENT, the SHA-256 of each file and the padding. A failure of age or a plaintext whose length is not P prevails; otherwise the first substep that fails decides, and a code other than ERR_INTEGRITY is reported only after reading PAYLOAD_AGE to its end. - The reads of BODY grow with the bytes received, never with AREA_LEN, HEAD_LEN or a declared size (spec 57); a test measures it. - A failure of the Sink is the caller's own error with ERR_INTEGRITY, as one of dst is in formats 1 and 2. - Opened gains Head, Verdicts, AreaLen and UnusableHeadExtensions. - Test data: "version changed" sets VERSION 4, and the format 2 list gains "format 2 time_only relabeled format 3", which fails at step 14, as section 64 of spec v0.10 lists: 126 cases, 89 of the spec. The randomly built capsules keep their recorded bytes. - testkit: Build writes format 3 and can edit the padded plaintext; Head3, Body3, DiscardSink and MemorySink build and open BODY. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 week ago
if afterPrelude != nil {
if err := afterPrelude(prelude); err != nil {
return in, nil, err
}
}
// Step 3: read the exact PUBLIC_HEADER bytes.
hb, err := readExactly(r, int64(prelude.PublicHeaderLen))
if err != nil {
return in, nil, in.fail(3, "public header", fmt.Errorf("capsule: truncated PUBLIC_HEADER: %w", datekeys.ErrIntegrity))
}
in.PublicHeader = hb
in.pass(3, "public header", fmt.Sprintf("%d bytes", len(hb)))
// Step 4: canonical CBOR, canonical DateKey, pinned profile, known
// critical extensions with valid data.
h, err := DecodeHeader(hb)
if err != nil {
return in, nil, in.fail(4, "header validation", err)
}
in.Header = h
p, ok := opts.Registry.Lookup(h.DateKey.ProfileID)
if !ok {
return in, nil, in.fail(4, "header validation", fmt.Errorf("capsule: profile %q is not pinned: %w", h.DateKey.ProfileID, datekeys.ErrUnknownProfile))
}
in.Profile = p
Spec v0.8.2: corrections from the formal review A formal review of the whole v0.8.2 text found it approvable after these corrections, recorded in §76 ("Correcciones de la revisión formal"): - §27 no longer calls header_binding the authenticity of PUBLIC_HEADER: it binds the header to the opened control, never authorship or date (§55.1); the age MAC only protects against whoever lacks the file key. - §63 steps 9 and 10: a network source (relay, Release API, cache) MUST verify every response and gives ERR_RELEASE_UNAVAILABLE at step 9 when none verifies; the step-10 codes are for a directly supplied release. The reference already behaved so; TestReleaseFromANetworkSource pins both paths. - §54 and §72: registrations declare the objects and arrays where an extension may appear, and a known extension out of place counts as unknown there. The reference gains the optional extension.Placement interface, used at steps 4, 9.a and 14. - §63 step 11 fixes the GT serialization hashed by H2 (kilic/kyber order) with the frozen vector H2(e(G1, G2))[:16] = cb87319f..., shared as testdata/vectors/tlock_ibe.json; H2-H4 are cited to drand/kyber. - Step 5 makes the SEALED_CONTROL read mandatory, step 15 names ERR_HEADER_BINDING, §21 makes capsule_id 16 CSPRNG bytes a MUST, §76 is made accurate (four dk1.json vectors, the §36 time_only rule, two cases rewritten against the texts that really existed), and editorial fixes in §5, §36, §55.1, §69.1 and §77. §73 lists the three new decisions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 weeks ago
if err := extension.CheckCriticalIn(extension.PublicHeader, h.Critical, opts.Extensions); err != nil {
return in, nil, in.fail(4, "header validation", fmt.Errorf("capsule: PUBLIC_HEADER: %w", err))
}
Spec v0.8.2: corrections from the formal review A formal review of the whole v0.8.2 text found it approvable after these corrections, recorded in §76 ("Correcciones de la revisión formal"): - §27 no longer calls header_binding the authenticity of PUBLIC_HEADER: it binds the header to the opened control, never authorship or date (§55.1); the age MAC only protects against whoever lacks the file key. - §63 steps 9 and 10: a network source (relay, Release API, cache) MUST verify every response and gives ERR_RELEASE_UNAVAILABLE at step 9 when none verifies; the step-10 codes are for a directly supplied release. The reference already behaved so; TestReleaseFromANetworkSource pins both paths. - §54 and §72: registrations declare the objects and arrays where an extension may appear, and a known extension out of place counts as unknown there. The reference gains the optional extension.Placement interface, used at steps 4, 9.a and 14. - §63 step 11 fixes the GT serialization hashed by H2 (kilic/kyber order) with the frozen vector H2(e(G1, G2))[:16] = cb87319f..., shared as testdata/vectors/tlock_ibe.json; H2-H4 are cited to drand/kyber. - Step 5 makes the SEALED_CONTROL read mandatory, step 15 names ERR_HEADER_BINDING, §21 makes capsule_id 16 CSPRNG bytes a MUST, §76 is made accurate (four dk1.json vectors, the §36 time_only rule, two cases rewritten against the texts that really existed), and editorial fixes in §5, §36, §55.1, §69.1 and §77. §73 lists the three new decisions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 weeks ago
in.UnusableExtensions = extension.CheckNoncriticalIn(extension.PublicHeader, h.Noncritical, opts.Extensions)
in.pass(4, "header validation", fmt.Sprintf("capsule_id=%s datekey=%s policy=%s profile=%s%s",
h.CapsuleIDHex(), h.DateKey.Compact(), h.Policy, p.ID, unusable(in.UnusableExtensions)))
// Step 5: OUTER_TIME_AGE holds exactly one stanza, of type tlock.
sealed, err := readExactly(r, int64(prelude.SealedControlLen))
if err != nil {
return in, nil, in.fail(5, "sealed control structure", fmt.Errorf("capsule: truncated SEALED_CONTROL: %w", datekeys.ErrIntegrity))
}
outer, err := agewrap.Stanzas(bytes.NewReader(sealed))
if err != nil {
return in, nil, in.fail(5, "sealed control structure", fmt.Errorf("capsule: SEALED_CONTROL: %w", err))
}
in.OuterStanzas = infos(outer)
if len(outer) != 1 || outer[0].Type != agewrap.StanzaTLock {
err := fmt.Errorf("capsule: OUTER_TIME_AGE must hold exactly one tlock stanza, found %d: %w", len(outer), datekeys.ErrPolicyStructureMismatch)
return in, nil, in.fail(5, "sealed control structure", err)
}
in.pass(5, "sealed control structure", "one tlock stanza")
// Step 6: PAYLOAD_AGE holds exactly one stanza, of type X25519. Only its
// age header is read; the bytes consumed are replayed for decryption.
var captured bytes.Buffer
payloadStanzas, err := agewrap.Stanzas(io.TeeReader(r, &captured))
if err != nil {
return in, nil, in.fail(6, "payload structure", fmt.Errorf("capsule: PAYLOAD_AGE: %w", err))
}
in.PayloadStanzas = infos(payloadStanzas)
if err := agewrap.CheckPayloadStanzas(payloadStanzas); err != nil {
return in, nil, in.fail(6, "payload structure", err)
}
in.pass(6, "payload structure", "one X25519 stanza")
// Step 7: resolve and verify the time condition locally.
if err := h.DateKey.Validate(p); err != nil {
return in, nil, in.fail(7, "condition", err)
}
unlock, err := datekey.RoundTime(p, h.DateKey.Round)
if err != nil {
return in, nil, in.fail(7, "condition", err)
}
in.UnlockAt = unlock
in.pass(7, "condition", fmt.Sprintf("round %d, unlock at %s", h.DateKey.Round, unlock.Format(time.RFC3339)))
// Step 8: the tlock stanza names the DateKey round and the pinned chain.
if err := agewrap.CheckTimeStanzas(outer, p, h.DateKey.Round); err != nil {
return in, nil, in.fail(8, "tlock stanza", err)
}
in.pass(8, "tlock stanza", fmt.Sprintf("round %d, chain %s", h.DateKey.Round, p.ChainHashHex()))
return in, &parsed{
prelude: pre,
sealed: sealed,
payload: io.MultiReader(bytes.NewReader(captured.Bytes()), r),
}, nil
}
// readExactly reads n bytes. The buffer grows with the data actually read, so
// a short file that declares a large length (within the §57 limits) does not
// force an allocation of that size.
func readExactly(r io.Reader, n int64) ([]byte, error) {
var b bytes.Buffer
if _, err := io.CopyN(&b, r, n); err != nil {
return nil, err
}
return b.Bytes(), nil
}
// unusable describes the unusable extensions for a check detail.
func unusable(u []extension.Unusable) string {
if len(u) == 0 {
return ""
}
return fmt.Sprintf(", %d unusable noncritical extensions", len(u))
}
func infos(stanzas []*age.Stanza) []StanzaInfo {
out := make([]StanzaInfo, len(stanzas))
for i, s := range stanzas {
out[i] = StanzaInfo{Type: s.Type, Args: s.Args}
}
return out
}

Powered by TurnKey Linux.