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

227 lines
8.3 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 §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
}

Powered by TurnKey Linux.