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 §54). 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. 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) { in, _, err := inspect(r, opts) return in, err } func inspect(r io.Reader, opts InspectOptions) (*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", FramingVersion, prelude.PublicHeaderLen, prelude.SealedControlLen)) // 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 if err := extension.CheckCritical(h.Critical, opts.Extensions); err != nil { return in, nil, in.fail(4, "header validation", fmt.Errorf("capsule: PUBLIC_HEADER: %w", err)) } in.UnusableExtensions = extension.CheckNoncritical(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 }