Initial implementation of the DateKeys Protocol v0.8.1
Reference implementation in Go, built from the implementation plan
(milestones M0 to M5): datekey, profile, provider, codec, agewrap,
extension, capsule, accesskey, the datekeys CLI, official vectors and
fixtures, the mutation corpus, fuzz targets, interop and live tests,
CI workflows, traceability and policy documents.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 weeks ago
|
|
|
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"
|
Initial implementation of the DateKeys Protocol v0.8.1
Reference implementation in Go, built from the implementation plan
(milestones M0 to M5): datekey, profile, provider, codec, agewrap,
extension, capsule, accesskey, the datekeys CLI, official vectors and
fixtures, the mutation corpus, fuzz targets, interop and live tests,
CI workflows, traceability and policy documents.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 weeks ago
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// 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).
|
Initial implementation of the DateKeys Protocol v0.8.1
Reference implementation in Go, built from the implementation plan
(milestones M0 to M5): datekey, profile, provider, codec, agewrap,
extension, capsule, accesskey, the datekeys CLI, official vectors and
fixtures, the mutation corpus, fuzz targets, interop and live tests,
CI workflows, traceability and policy documents.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 weeks ago
|
|
|
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
|
Initial implementation of the DateKeys Protocol v0.8.1
Reference implementation in Go, built from the implementation plan
(milestones M0 to M5): datekey, profile, provider, codec, agewrap,
extension, capsule, accesskey, the datekeys CLI, official vectors and
fixtures, the mutation corpus, fuzz targets, interop and live tests,
CI workflows, traceability and policy documents.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 weeks ago
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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.
|
Initial implementation of the DateKeys Protocol v0.8.1
Reference implementation in Go, built from the implementation plan
(milestones M0 to M5): datekey, profile, provider, codec, agewrap,
extension, capsule, accesskey, the datekeys CLI, official vectors and
fixtures, the mutation corpus, fuzz targets, interop and live tests,
CI workflows, traceability and policy documents.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 weeks ago
|
|
|
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)))
|
Initial implementation of the DateKeys Protocol v0.8.1
Reference implementation in Go, built from the implementation plan
(milestones M0 to M5): datekey, profile, provider, codec, agewrap,
extension, capsule, accesskey, the datekeys CLI, official vectors and
fixtures, the mutation corpus, fuzz targets, interop and live tests,
CI workflows, traceability and policy documents.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 weeks ago
|
|
|
|
|
|
|
|
// 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))
|
|
|
|
|
}
|
|
|
|
|
|
Initial implementation of the DateKeys Protocol v0.8.1
Reference implementation in Go, built from the implementation plan
(milestones M0 to M5): datekey, profile, provider, codec, agewrap,
extension, capsule, accesskey, the datekeys CLI, official vectors and
fixtures, the mutation corpus, fuzz targets, interop and live tests,
CI workflows, traceability and policy documents.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 weeks ago
|
|
|
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
|
|
|
|
|
}
|