Implement v0.8.2 extension data, limits and writer self-checks

- extension: data is an opaque []byte; New takes []byte and rejects empty
  data; key 2 must be a non-empty shortest-form bstr; at most 64 extensions
  per array; extension_version <= 2^32-1; CheckDisjoint is a linear merge;
  optional DataValidator with ERR_EXTENSION_DATA_INVALID for known critical
  extensions and Unusable reports for known noncritical ones.
- capsule, accesskey: frame limits on encode and decode with ERR_INTEGRITY;
  Encrypt and MarshalBody decode their own output before sealing or
  returning it; unusable extensions are reported.
- profile: period and genesis_time bounded to 2^53-1, genesis decoded as
  unsigned.
- codec: Valid removed; empty values never encode as null.
- Regression tests for the nested-data seal/open asymmetry, the
  nondeterministic verdict on NaN-keyed data and the quadratic disjointness
  check; three new §64 mutations and five more.
- Fixtures: time_only_extensions regenerated with opaque data, new
  time_and_key_portable_extension.dkk; genfixtures gains -only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.8.2
dev 2 weeks ago
parent ace734462f
commit afb44a396e

@ -3,6 +3,81 @@
All notable changes to this module are documented here. The project follows
semantic versioning; `v0.x` versions make no API stability promise.
## Unreleased — specification v0.8.2
Moves the module to the DateKeys Protocol Specification v0.8.2, whose one
normative change closes the extension format (spec §76). Framing and schema
versions do not change.
### Breaking changes
- `extension.New(id, version, data []byte)` takes the opaque data bytes instead
of a value that it encoded as CBOR, and rejects nil or empty data. An
extension without data is the literal `extension.Extension{ID, Version}`,
which omits key 2.
- Extension data (key 2) must be a byte string of at least one byte. Any other
CBOR type, `null` or `h''` at key 2 is now `ERR_NON_CANONICAL_CBOR`, so a
v0.8.1 object with such data no longer decodes. The base protocol never
decodes the content (§54).
- `extension.Wire.Data` is `[]byte`, the content of the byte string, instead of
`cbor.RawMessage`.
- `codec.Valid` is removed: nothing decodes extension data any more.
`codec.FuzzValid` is replaced by `codec.FuzzUnmarshal`.
- At most 64 extensions per array and `extension_version` at most 2^32−1, on
encode and decode (`ERR_NON_CANONICAL_CBOR`). An `extension_id` appears at
most once per object, and arrays are ordered by `extension_id` only.
- Provider Profile: `genesis_time` is an unsigned integer, and `period` and
`genesis_time` are at most 2^53−1; a negative or larger value is
`ERR_NON_CANONICAL_CBOR`.
- `null` in a byte-string field is `ERR_NON_CANONICAL_CBOR` (for example a
`null` `access_material` was `ERR_ACCESS_INVALID`): nil byte strings, arrays
and maps now encode as empty ones, never as `null`.
- `capsule.EncodeHeader` and `capsule.DecodeHeader` enforce the 1 MiB
PUBLIC_HEADER limit and `accesskey.DecodeBody` the 16 MiB BODY limit. Every
frame-limit refusal, including those of `accesskey.MarshalBody` and of
`capsule.Encrypt` for SEALED_CONTROL, now wraps `ERR_INTEGRITY` (§57).
- `profile.Profile.CanonicalCBOR` refuses a `period` or `genesis_time` outside
the schema with `ERR_NON_CANONICAL_CBOR`, as `profile.Decode` does.
- The official fixture `time_only_extensions` is regenerated: its header data
is the raw UTF-8 bytes of "public label" and its control data is
`{0: 7, 1: "sealed"}` (`a2000701667365616c6564`). Every other `.dkc` and
`.dkk` keeps its bytes; the fixture and vector metadata name spec 0.8.2.
### Added
- `ErrExtensionDataInvalid` (`ERR_EXTENSION_DATA_INVALID`, §69).
- `extension.DataValidator`, an optional interface of a `Registry` that
validates the data of the extensions it knows: a known critical extension
with invalid data fails with `ErrExtensionDataInvalid` (§63 steps 4 and 14,
and the `.dkk` check); a known noncritical one is reported in
`capsule.Inspection.UnusableExtensions`, `capsule.Opened.UnusableControlExtensions`
or `capsule.Opened.UnusableAccessKeyExtensions` (`extension.CheckNoncritical`,
`extension.Unusable`) and does not fail.
- Encoder self-checks: `capsule.Encrypt` decodes its PUBLIC_HEADER and
CONTROL_CBOR, and `accesskey.MarshalBody` its body, with the readers'
decoders before sealing or writing (§72).
- `extension.MaxExtensions`, `MaxVersion`, `MaxDataLen` and `codec.MaxSafeUint`.
- The `.dkk` fixture `time_and_key_portable_extension`, which carries a
noncritical extension with data (§68).
- `genfixtures -only NAME[,NAME...]` regenerates the named fixtures only.
- Tests: the three new §64 mutations (data that is not a byte string, `h''`
data, 65 extensions), regression tests for the cases of §76, conformance
checks on the exact data bytes, and the fuzz target
`capsule.FuzzEncodeImpliesDecode` (header, control and `.dkk`).
### Fixed
- `extension.CheckDisjoint` is a linear merge of the two sorted arrays; a
PUBLIC_HEADER with 40 000 + 40 000 extensions took 8.3 s in the pairwise
check (§76, case 6).
- Control data made of 14 or 15 nested arrays was sealed by `Encrypt` and
rejected by `Open` at step 14, after the unlock (§76, case 5).
- Header data `{NaN: 0, NaN: 1}` gave a nondeterministic verdict (§76, case 3).
- `extension.New(id, v, nil)` wrote `null` as data (§76, case 2).
- `codec.Unmarshal` wipes its re-encoding, which after the new self-checks
held a copy of I_PAYLOAD or `access_material`, and `accesskey.DecodeBody`
wipes the material on its error paths.
## Unreleased — v0.1.0
First implementation of the DateKeys Protocol Specification v0.8.1.

@ -63,7 +63,9 @@ Coverage must stay at or above 90 % for `codec`, `capsule`, `accesskey`,
`go run ./internal/testkit/genfixtures -out testdata` regenerates the vectors
and creates missing fixtures. It never overwrites existing fixtures unless
`-force` is given, which is reserved for specification changes.
`-force` (every fixture) or `-only NAME[,NAME...]` (the named ones) is given,
which are reserved for specification changes. Prefer `-only`: every fixture
it does not name keeps its exact bytes.
## Commits and releases

@ -1,7 +1,7 @@
# datekeys-go
Implementación de referencia en Go de la **DateKeys Protocol Specification
v0.8.1** ([`spec/`](spec/DateKeys_Protocol_Specification_v0.8.1.md)).
v0.8.2** ([`spec/`](spec/DateKeys_Protocol_Specification_v0.8.2.md)).
[English version](README.md).
DateKeys cifra datos de forma que solo puedan abrirse a partir de un instante
@ -140,7 +140,7 @@ go test -tags integration ./capsule ./provider/drand # Quicknet en vivo
- `testdata/fixtures`: fixtures oficiales `.dkc`/`.dkk` sobre rondas ya
publicadas, con la firma BLS embebida y todos los valores intermedios (spec
§67, §68); se descifran sin red.
- `capsule/mutation_test.go`: las 20 mutaciones del §64 y 25 más, cada una con
- `capsule/mutation_test.go`: las 23 mutaciones del §64 y 30 más, cada una con
su error y su paso exactos, comprobando además que los fallos previos al
desbloqueo nunca provocan una petición de release.
- [`docs/traceability.md`](docs/traceability.md): sección del spec → código → test.

@ -1,7 +1,7 @@
# datekeys-go
Reference implementation in Go of the **DateKeys Protocol Specification
v0.8.1** ([`spec/`](spec/DateKeys_Protocol_Specification_v0.8.1.md)).
v0.8.2** ([`spec/`](spec/DateKeys_Protocol_Specification_v0.8.2.md)).
[Versión en español](README.es.md).
DateKeys encrypts data so that it can only be opened after a chosen instant.
@ -139,7 +139,7 @@ go test -tags integration ./capsule ./provider/drand # live Quicknet
- `testdata/fixtures`: official `.dkc`/`.dkk` fixtures over published rounds,
with the BLS signature embedded and every intermediate value (spec §67, §68);
they decrypt offline.
- `capsule/mutation_test.go`: the 20 mutations of spec §64 and 25 more, each
- `capsule/mutation_test.go`: the 23 mutations of spec §64 and 30 more, each
with its exact error and step, and a check that pre-unlock failures never
cause a release request.
- [`docs/traceability.md`](docs/traceability.md): spec section → code → test.

@ -19,7 +19,7 @@ The module is pre-1.0 (`v0.x`). Only the latest `v0.x` release receives fixes.
## Scope and assumptions
In scope: every rule of the DateKeys Protocol Specification v0.8.1 this module
In scope: every rule of the DateKeys Protocol Specification v0.8.2 this module
implements (see `docs/traceability.md`), the CLI, and the handling of
untrusted input (`.dkc`, `.dkk`, relay responses).

@ -8,7 +8,7 @@ Allowed without asking:
- Stating compatibility in plain words, for example "implements the DateKey
protocol", "reads and writes DateKeyCap (.dkc) files" or "compatible with
DateKeys v0.8.1", as long as it is true for the version named.
DateKeys v0.8.2", as long as it is true for the version named.
- Referring to this project, its specification or its file formats by name in
documentation, articles and talks.

@ -106,7 +106,8 @@ func (k *AccessKey) validateMaterial() error {
return nil
}
// MarshalBody returns BODY_CBOR, the Deterministic CBOR body of k (spec §41).
// MarshalBody returns BODY_CBOR, the Deterministic CBOR body of k (spec §41),
// after checking that DecodeBody accepts it.
func (k *AccessKey) MarshalBody() ([]byte, error) {
if err := k.validateMaterial(); err != nil {
return nil, err
@ -141,8 +142,16 @@ func (k *AccessKey) MarshalBody() ([]byte, error) {
return nil, err
}
if len(b) > MaxBodyLen {
return nil, fmt.Errorf("accesskey: body of %d bytes exceeds %d", len(b), MaxBodyLen)
clear(b)
return nil, fmt.Errorf("accesskey: BODY_CBOR of %d bytes exceeds %d: %w", len(b), MaxBodyLen, datekeys.ErrIntegrity)
}
// Self-check (spec §72): the reader must accept what is written.
back, err := DecodeBody(b)
if err != nil {
clear(b)
return nil, fmt.Errorf("accesskey: self-check: the reader rejects this body: %w", err)
}
back.Wipe()
return b, nil
}
@ -204,19 +213,24 @@ func Decode(r io.Reader) (*AccessKey, error) {
return DecodeBody(body)
}
// DecodeBody validates and decodes BODY_CBOR.
// DecodeBody validates and decodes BODY_CBOR, which the DKK BODY limit of
// spec §57 bounds whatever the caller read it from.
func DecodeBody(body []byte) (*AccessKey, error) {
if len(body) > MaxBodyLen {
return nil, fmt.Errorf("accesskey: BODY_CBOR of %d bytes exceeds %d: %w", len(body), MaxBodyLen, datekeys.ErrIntegrity)
}
if err := codec.CheckSchema(body, TypeTag, SchemaVersion); err != nil {
return nil, fmt.Errorf("accesskey: %w", err)
}
var w bodyWire
defer func() { clear(w.Material) }()
if err := codec.Unmarshal(body, &w); err != nil {
return nil, fmt.Errorf("accesskey: %w", err)
}
if len(w.CredentialID) != idSize || len(w.CapsuleID) != idSize {
return nil, fmt.Errorf("accesskey: credential_id and capsule_id must be %d bytes: %w", idSize, datekeys.ErrNonCanonicalCBOR)
}
k := &AccessKey{Type: w.AccessType, Material: bytes.Clone(w.Material)}
k := &AccessKey{Type: w.AccessType}
copy(k.CredentialID[:], w.CredentialID)
copy(k.CapsuleID[:], w.CapsuleID)
if w.Verification != nil {
@ -235,9 +249,10 @@ func DecodeBody(body []byte) (*AccessKey, error) {
if err := extension.CheckDisjoint(k.Critical, k.Noncritical); err != nil {
return nil, fmt.Errorf("accesskey: %w", err)
}
k.Material = bytes.Clone(w.Material)
if err := k.validateMaterial(); err != nil {
k.Wipe()
return nil, err
}
clear(w.Material)
return k, nil
}

@ -9,6 +9,8 @@ import (
"io"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"testing"
@ -39,9 +41,22 @@ func loadDKK(t *testing.T, name string) ([]byte, testkit.DKKFixture) {
return b, f
}
var fixtureNames = []string{"time_and_key_portable", "time_and_key_recipients", "time_and_key_portable_extension"}
// fixtureExts lists extensions as the fixtures record them, with the exact
// data bytes.
func fixtureExts(k *accesskey.AccessKey) []testkit.FixtureExt {
var out []testkit.FixtureExt
for i, e := range append(slices.Clone(k.Critical), k.Noncritical...) {
out = append(out, testkit.FixtureExt{Critical: i < len(k.Critical), ID: e.ID, Version: e.Version, Data: hex.EncodeToString(e.Data)})
}
return out
}
// Spec §68: parse, validate and use the official .dkk fixtures.
func TestFixtures(t *testing.T) {
for _, name := range []string{"time_and_key_portable", "time_and_key_recipients"} {
withData := 0
for _, name := range fixtureNames {
t.Run(name, func(t *testing.T) {
b, f := loadDKK(t, name)
k, err := accesskey.Decode(bytes.NewReader(b))
@ -50,10 +65,17 @@ func TestFixtures(t *testing.T) {
}
if hex.EncodeToString(k.CredentialID[:]) != f.CredentialID || hex.EncodeToString(k.CapsuleID[:]) != f.CapsuleID ||
k.Type != f.AccessType || hex.EncodeToString(k.Material) != f.Material ||
k.Verification == nil || hex.EncodeToString(k.Verification.CapsuleDigest) != f.CapsuleDigest ||
len(k.Critical)+len(k.Noncritical) != len(f.Extensions) {
k.Verification == nil || hex.EncodeToString(k.Verification.CapsuleDigest) != f.CapsuleDigest {
t.Fatalf("decoded values differ from the fixture: %v", k)
}
if got := fixtureExts(k); !reflect.DeepEqual(got, f.Extensions) {
t.Fatalf("extensions differ:\n got %+v\nwant %+v", got, f.Extensions)
}
for _, e := range f.Extensions {
if e.Data != "" {
withData++
}
}
// Encode(Decode(x)) == x.
var out bytes.Buffer
if err := accesskey.Encode(&out, k); err != nil || !bytes.Equal(out.Bytes(), b) {
@ -61,7 +83,7 @@ func TestFixtures(t *testing.T) {
}
// Expected result: the identity opens the INNER_ACCESS_AGE of its capsule.
var cf testkit.DKCFixture
if err := testkit.ReadJSON(filepath.Join(fixtures, name+".json"), &cf); err != nil {
if err := testkit.ReadJSON(filepath.Join(fixtures, strings.TrimSuffix(f.Capsule, ".dkc")+".json"), &cf); err != nil {
t.Fatal(err)
}
dkc, _ := os.ReadFile(filepath.Join(fixtures, f.Capsule))
@ -78,6 +100,30 @@ func TestFixtures(t *testing.T) {
}
})
}
// Spec §68: at least one official .dkk carries an extension with data.
if withData == 0 {
t.Fatal("no .dkk fixture carries extension data")
}
}
// The .dkk with an extension is the credential of time_and_key_portable.dkk
// plus one noncritical extension, whose data the base protocol never decodes.
func TestFixtureWithExtension(t *testing.T) {
src, _ := loadDKK(t, "time_and_key_portable")
b, _ := loadDKK(t, "time_and_key_portable_extension")
k, err := accesskey.Decode(bytes.NewReader(b))
if err != nil {
t.Fatal(err)
}
if len(k.Critical) != 0 || len(k.Noncritical) != 1 || k.Noncritical[0].ID != "org.example.delivery" || k.Noncritical[0].Version != 1 ||
hex.EncodeToString(k.Noncritical[0].Data) != "a1006468616e64" {
t.Fatalf("extension %+v", k.Noncritical)
}
k.Noncritical = nil
var out bytes.Buffer
if err := accesskey.Encode(&out, k); err != nil || !bytes.Equal(out.Bytes(), src) {
t.Fatal("without its extension the .dkk differs from time_and_key_portable.dkk")
}
}
func TestSecretsAreNotPrinted(t *testing.T) {
@ -147,6 +193,7 @@ func TestDecodeRejects(t *testing.T) {
{"short digest", with(func(m map[uint64]any) { m[6] = map[uint64]any{0: make([]byte, 31)} }), datekeys.ErrNonCanonicalCBOR},
{"unknown access type", with(func(m map[uint64]any) { m[4] = "mlkem768" }), datekeys.ErrAccessInvalid},
{"short material", with(func(m map[uint64]any) { m[5] = make([]byte, 31) }), datekeys.ErrAccessInvalid},
{"null material", with(func(m map[uint64]any) { m[5] = nil }), datekeys.ErrNonCanonicalCBOR},
{"non-canonical body", frame(append([]byte{0xb9, 0x00, 0x07}, body[1:]...)), datekeys.ErrNonCanonicalCBOR},
} {
t.Run(tc.name, func(t *testing.T) {
@ -207,6 +254,27 @@ func TestIdentityWipeAndEncodeErrors(t *testing.T) {
}
}
// Spec §57: the DKK BODY limit binds the encoder and every decoder entry
// point, whatever the framing says, with the code of a frame violation.
func TestBodyLimit(t *testing.T) {
if _, err := accesskey.DecodeBody(make([]byte, accesskey.MaxBodyLen+1)); !errors.Is(err, datekeys.ErrIntegrity) {
t.Fatalf("DecodeBody above 16 MiB: %v", err)
}
b, _ := loadDKK(t, "time_and_key_portable")
k, err := accesskey.Decode(bytes.NewReader(b))
if err != nil {
t.Fatal(err)
}
big, err := extension.New("org.example.big", 1, make([]byte, accesskey.MaxBodyLen))
if err != nil {
t.Fatal(err)
}
k.Noncritical = []extension.Extension{big}
if err := accesskey.Encode(io.Discard, k); !errors.Is(err, datekeys.ErrIntegrity) {
t.Fatalf("body above 16 MiB encoded: %v", err)
}
}
func TestDecodeBodyExtensionRules(t *testing.T) {
good, _ := loadDKK(t, "time_and_key_portable")
var m map[uint64]any
@ -218,11 +286,25 @@ func TestDecodeBodyExtensionRules(t *testing.T) {
"critical out of order": func(m map[uint64]any) { m[7] = []any{ext("b", 1), ext("a", 1)} },
"noncritical repeated": func(m map[uint64]any) { m[8] = []any{ext("a", 1), ext("a", 2)} },
"both arrays": func(m map[uint64]any) { m[7] = []any{ext("a", 1)}; m[8] = []any{ext("a", 1)} },
// Raw data is copied verbatim by the outer re-encoding, so the
// extension layer must reject 1 encoded in two bytes on its own.
"non-canonical ext data": func(m map[uint64]any) {
// Spec §54: data is absent or a non-empty byte string in its
// shortest encoding; the extension map rejects anything else.
"data length not in shortest form": func(m map[uint64]any) {
m[8] = []any{map[uint64]any{0: "a", 1: uint64(1), 2: cbor.RawMessage{0x58, 0x01, 0x00}}}
},
"data of type text": func(m map[uint64]any) { m[8] = []any{map[uint64]any{0: "a", 1: uint64(1), 2: "x"}} },
"data of type unsigned": func(m map[uint64]any) {
m[8] = []any{map[uint64]any{0: "a", 1: uint64(1), 2: cbor.RawMessage{0x18, 0x01}}}
},
"empty data": func(m map[uint64]any) { m[7] = []any{map[uint64]any{0: "a", 1: uint64(1), 2: []byte{}}} },
"null data": func(m map[uint64]any) { m[8] = []any{map[uint64]any{0: "a", 1: uint64(1), 2: nil}} },
"version 2^32": func(m map[uint64]any) { m[8] = []any{ext("a", 1<<32)} },
"65 extensions": func(m map[uint64]any) {
var exts []any
for i := range 65 {
exts = append(exts, ext(fmt.Sprintf("x.%02d", i), 1))
}
m[8] = exts
},
"empty critical array": func(m map[uint64]any) { m[7] = []any{} },
"extension id not string": func(m map[uint64]any) { m[8] = []any{map[uint64]any{0: uint64(1), 1: uint64(1)}} },
} {
@ -246,7 +328,7 @@ type failingWriter struct{}
func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("disk full") }
func FuzzDecode(f *testing.F) {
for _, name := range []string{"time_and_key_portable", "time_and_key_recipients"} {
for _, name := range fixtureNames {
b, err := os.ReadFile(filepath.Join(fixtures, name+".dkk"))
if err == nil {
f.Add(b)

@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"reflect"
"slices"
"testing"
"time"
@ -17,6 +18,7 @@ import (
"g.activething.com/go/DateKeys/accesskey"
"g.activething.com/go/DateKeys/agewrap"
"g.activething.com/go/DateKeys/capsule"
"g.activething.com/go/DateKeys/extension"
"g.activething.com/go/DateKeys/internal/testkit"
"g.activething.com/go/DateKeys/profile"
"g.activething.com/go/DateKeys/provider"
@ -162,8 +164,8 @@ func TestConformanceFixtures(t *testing.T) {
if re, _ := capsule.EncodeHeader(h); !bytes.Equal(re, parts.Header) {
t.Fatal("EncodeHeader(DecodeHeader(x)) != x")
}
if len(h.Critical)+len(h.Noncritical) != len(f.HeaderExtensions) {
t.Fatal("header extensions differ")
if got := fixtureExts(h.Critical, h.Noncritical); !reflect.DeepEqual(got, f.HeaderExtensions) {
t.Fatalf("header extensions differ:\n got %+v\nwant %+v", got, f.HeaderExtensions)
}
// Opening layer by layer: OUTER_TIME_AGE, INNER_ACCESS_AGE, CONTROL_CBOR.
@ -202,8 +204,8 @@ func TestConformanceFixtures(t *testing.T) {
if re, _ := capsule.EncodeControl(ctrl); !bytes.Equal(re, control) {
t.Fatal("EncodeControl(DecodeControl(x)) != x")
}
if len(ctrl.Critical)+len(ctrl.Noncritical) != len(f.ControlExt) {
t.Fatal("control extensions differ")
if got := fixtureExts(ctrl.Critical, ctrl.Noncritical); !reflect.DeepEqual(got, f.ControlExt) {
t.Fatalf("control extensions differ:\n got %+v\nwant %+v", got, f.ControlExt)
}
payloadID, _ := agewrap.NewPayloadIdentity(ctrl.PayloadIdentity[:])
if got := decryptAge(t, parts.Payload, payloadID); !bytes.Equal(got, f.plaintext) {
@ -226,13 +228,58 @@ func TestConformanceFixtures(t *testing.T) {
if !reflect.DeepEqual(stages, f.Stages) {
t.Fatalf("stages differ:\n got %+v\nwant %+v", stages, f.Stages)
}
if len(opened.ControlNoncritical) != len(ctrl.Noncritical) {
if got := fixtureExts(opened.ControlCritical, opened.ControlNoncritical); !reflect.DeepEqual(got, f.ControlExt) {
t.Fatal("Open does not report the control extensions")
}
})
}
}
// fixtureExts lists extensions as the fixtures record them, with the exact
// data bytes (spec §67, §68).
func fixtureExts(critical, noncritical []extension.Extension) []testkit.FixtureExt {
var out []testkit.FixtureExt
for i, e := range append(slices.Clone(critical), noncritical...) {
out = append(out, testkit.FixtureExt{Critical: i < len(critical), ID: e.ID, Version: e.Version, Data: hex.EncodeToString(e.Data)})
}
return out
}
// The extension data of time_only_extensions, restated independently of its
// JSON: raw UTF-8 bytes in PUBLIC_HEADER, and CBOR in the profile of spec §58
// in CONTROL_CBOR. The base protocol decodes neither.
func TestExtensionFixtureData(t *testing.T) {
f := loadFixture(t, "time_only_extensions")
var out bytes.Buffer
opened, err := capsule.Open(context.Background(), &out, bytes.NewReader(f.dkc), f.openOptions(t))
if err != nil {
t.Fatal(err)
}
h := opened.Inspection.Header
if len(h.Critical) != 0 || len(h.Noncritical) != 1 || h.Noncritical[0].ID != "org.example.label" || h.Noncritical[0].Version != 1 ||
string(h.Noncritical[0].Data) != "public label" {
t.Fatalf("header extension %+v", h.Noncritical)
}
c := opened.ControlNoncritical
if len(opened.ControlCritical) != 0 || len(c) != 1 || c[0].ID != "org.example.note" || c[0].Version != 2 ||
hex.EncodeToString(c[0].Data) != "a2000701667365616c6564" {
t.Fatalf("control extension %+v", c)
}
}
func loadAccessKey(t *testing.T, name string) *accesskey.AccessKey {
t.Helper()
b, err := os.ReadFile(filepath.Join(fixtureDir, name+".dkk"))
if err != nil {
t.Fatal(err)
}
k, err := accesskey.Decode(bytes.NewReader(b))
if err != nil {
t.Fatal(err)
}
return k
}
// Every known recipient of a multi-recipient fixture opens it on its own.
func TestFixtureRecipients(t *testing.T) {
f := loadFixture(t, "time_and_key_recipients")

@ -123,8 +123,8 @@ func Encrypt(dst io.Writer, src io.Reader, opts EncryptOptions) (*Result, error)
if err != nil {
return nil, err
}
if len(headerBytes) > MaxPublicHeaderLen {
return nil, fmt.Errorf("capsule: PUBLIC_HEADER of %d bytes exceeds %d", len(headerBytes), MaxPublicHeaderLen)
if err := selfCheckHeader(headerBytes); err != nil {
return nil, err
}
timeRecipient, err := agewrap.NewTimeRecipient(p, dk.Round)
@ -167,7 +167,7 @@ func Encrypt(dst io.Writer, src io.Reader, opts EncryptOptions) (*Result, error)
return nil, err
}
if len(draftSealed) > MaxSealedControlLen {
return nil, fmt.Errorf("capsule: SEALED_CONTROL of %d bytes exceeds %d", len(draftSealed), MaxSealedControlLen)
return nil, fmt.Errorf("capsule: SEALED_CONTROL of %d bytes exceeds %d: %w", len(draftSealed), MaxSealedControlLen, datekeys.ErrIntegrity)
}
prelude := Prelude{PublicHeaderLen: uint32(len(headerBytes)), SealedControlLen: uint32(len(draftSealed))}
preludeBytes := prelude.Bytes()
@ -183,6 +183,9 @@ func Encrypt(dst io.Writer, src io.Reader, opts EncryptOptions) (*Result, error)
return nil, err
}
defer clear(controlBytes)
if err := selfCheckControl(controlBytes); err != nil {
return nil, err
}
// Steps 9 and 10: SEALED_CONTROL = OUTER_TIME_AGE.
sealed, err := seal(controlBytes)
@ -229,6 +232,28 @@ func Encrypt(dst io.Writer, src io.Reader, opts EncryptOptions) (*Result, error)
return res, nil
}
// selfCheckHeader decodes PUBLIC_HEADER with the reader's decoder before
// anything is sealed or written: a capsule whose header the reader rejects
// would be unusable (spec §72).
func selfCheckHeader(b []byte) error {
if _, err := DecodeHeader(b); err != nil {
return fmt.Errorf("capsule: self-check: the reader rejects this PUBLIC_HEADER: %w", err)
}
return nil
}
// selfCheckControl decodes CONTROL_CBOR with the reader's decoder before it is
// sealed. A control that the reader rejects would only be found at step 14
// of spec §63, after the unlock, when the capsule can no longer be repaired.
func selfCheckControl(b []byte) error {
c, err := DecodeControl(b)
if err != nil {
return fmt.Errorf("capsule: self-check: the reader rejects this CONTROL_CBOR: %w", err)
}
clear(c.PayloadIdentity[:])
return nil
}
// accessRecipients validates the policy options and returns the recipients of
// INNER_ACCESS_AGE, including R_ACCESS when a portable key is requested.
func accessRecipients(opts EncryptOptions) ([]age.Recipient, *age.X25519Identity, error) {

@ -197,8 +197,8 @@ func TestFutureCapsuleStaysLockedWithoutRequests(t *testing.T) {
}
func TestExtensionsRoundTrip(t *testing.T) {
hExt, _ := extension.New("org.example.public", 1, []any{"a", uint64(1)})
cExt, _ := extension.New("org.example.sealed", 3, map[string]any{"k": []byte{1, 2}})
hExt, _ := extension.New("org.example.public", 1, []byte("public"))
cExt, _ := extension.New("org.example.sealed", 3, []byte{0xa1, 0x00, 0x42, 0x01, 0x02})
opts := past(t, 1000)
opts.Noncritical = []extension.Extension{hExt}
opts.ControlNoncritical = []extension.Extension{cExt}

@ -0,0 +1,192 @@
package capsule_test
import (
"bytes"
"context"
"encoding/hex"
"errors"
"fmt"
"strings"
"testing"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/capsule"
"g.activething.com/go/DateKeys/codec"
"g.activething.com/go/DateKeys/extension"
"g.activething.com/go/DateKeys/internal/testkit"
)
// strictRegistry knows every org.example.* extension at version 1 and accepts
// only the data "ok" (spec §54, §72).
type strictRegistry struct{}
func (strictRegistry) Known(id string, v uint64) bool {
return v == 1 && strings.HasPrefix(id, "org.example.")
}
func (strictRegistry) ValidateData(e extension.Extension) error {
if string(e.Data) != "ok" {
return fmt.Errorf("data %x is not \"ok\"", e.Data)
}
return nil
}
func mustExt(t *testing.T, id string, data []byte) extension.Extension {
t.Helper()
e, err := extension.New(id, 1, data)
if err != nil {
t.Fatal(err)
}
return e
}
func mustUnhex(t *testing.T, s string) []byte {
t.Helper()
b, err := hex.DecodeString(s)
if err != nil {
t.Fatal(err)
}
return b
}
// Spec §76, case 5: control data made of 14 or 15 nested CBOR arrays was
// sealed by Encrypt and rejected by Open at step 14, after the unlock, which
// made the capsule unrecoverable. Data is opaque now: it seals and opens.
func TestNestedDataSealsAndOpens(t *testing.T) {
for _, depth := range []int{14, 15, 40} {
data := mustUnhex(t, strings.Repeat("81", depth)+"00")
opts := past(t, 1000)
opts.Noncritical = []extension.Extension{mustExt(t, "org.example.nested", data)}
opts.ControlNoncritical = []extension.Extension{mustExt(t, "org.example.nested", data)}
var dkc bytes.Buffer
if _, err := capsule.Encrypt(&dkc, strings.NewReader("nested"), opts); err != nil {
t.Fatalf("depth %d: %v", depth, err)
}
var out bytes.Buffer
opened, err := capsule.Open(context.Background(), &out, bytes.NewReader(dkc.Bytes()), defaultOpen(1000))
if err != nil || out.String() != "nested" {
t.Fatalf("depth %d: sealed but does not open: %v", depth, err)
}
if !bytes.Equal(opened.ControlNoncritical[0].Data, data) || !bytes.Equal(opened.Inspection.Header.Noncritical[0].Data, data) {
t.Fatalf("depth %d: data changed", depth)
}
}
}
// Spec §76, case 3: header data {NaN: 0, NaN: 1} (a2f97e0000f97e0001) made
// the verdict on one capsule depend on map iteration order. The base protocol
// no longer decodes data, so every run gives the same verdict.
func TestNaNKeyedDataHasOneVerdict(t *testing.T) {
data := mustUnhex(t, "a2f97e0000f97e0001")
opts := past(t, 1000)
opts.Noncritical = []extension.Extension{mustExt(t, "org.example.nan", data)}
var b bytes.Buffer
if _, err := capsule.Encrypt(&b, strings.NewReader("nan"), opts); err != nil {
t.Fatal(err)
}
dkc := b.Bytes()
for i := range 500 {
in, err := capsule.Inspect(bytes.NewReader(dkc), capsule.InspectOptions{Registry: testkit.Registry()})
if err != nil || !bytes.Equal(in.Header.Noncritical[0].Data, data) {
t.Fatalf("Inspect run %d: %v", i, err)
}
}
for i := range 200 {
if _, err := capsule.Open(context.Background(), &bytes.Buffer{}, bytes.NewReader(dkc), defaultOpen(1000)); err != nil {
t.Fatalf("Open run %d: %v", i, err)
}
}
}
// Spec §76, case 6: a PUBLIC_HEADER of about 880 KB with 40 000 + 40 000
// extensions took 8.3 s in the pairwise disjointness check. The 64-extension
// limit rejects it first.
func TestHugeExtensionArraysAreRejected(t *testing.T) {
f := loadFixture(t, "time_only")
parts, _ := testkit.Split(f.dkc)
var m map[uint64]any
if err := codec.Unmarshal(parts.Header, &m); err != nil {
t.Fatal(err)
}
const n = 40_000
crit, non := make([]any, n), make([]any, n)
for i := range n {
crit[i] = map[uint64]any{0: fmt.Sprintf("c%04x", i), 1: uint64(1)}
non[i] = map[uint64]any{0: fmt.Sprintf("n%04x", i), 1: uint64(1)}
}
m[5], m[6] = crit, non
h, err := codec.Marshal(m)
if err != nil {
t.Fatal(err)
}
if len(h) > capsule.MaxPublicHeaderLen {
t.Fatalf("header of %d bytes", len(h))
}
dkc := testkit.Reframe(parts.Prelude, h, parts.Sealed, parts.Payload)
in, err := capsule.Inspect(bytes.NewReader(dkc), capsule.InspectOptions{Registry: testkit.Registry()})
if last := in.Checks[len(in.Checks)-1]; !errors.Is(err, datekeys.ErrNonCanonicalCBOR) || last.Step != 4 {
t.Fatalf("Inspect: %v at %+v", err, last)
}
}
// Spec §54: a known noncritical extension with invalid data leaves the
// capsule valid; it is reported as unusable, in the object where it is.
func TestUnusableNoncriticalExtensions(t *testing.T) {
bad, good := []byte("ko"), []byte("ok")
dkc, o := build(t, testkit.Build{
HeaderNoncritical: []extension.Extension{mustExt(t, "org.example.header", bad), mustExt(t, "org.example.fine", good)},
ControlNoncritical: []extension.Extension{mustExt(t, "org.example.control", bad), {ID: "org.example.nodata", Version: 1}, mustExt(t, "org.other", bad)},
})
o.Extensions = strictRegistry{}
var out bytes.Buffer
opened, err := capsule.Open(context.Background(), &out, bytes.NewReader(dkc), o)
if err != nil || out.String() != "malicious creator" {
t.Fatalf("a noncritical extension with invalid data failed the capsule: %v", err)
}
in := opened.Inspection
if u := in.UnusableExtensions; len(u) != 1 || u[0].ID != "org.example.header" || !errors.Is(u[0].Err, datekeys.ErrExtensionDataInvalid) {
t.Fatalf("header: %+v", u)
}
// An extension without data is known too: the registry rejects its
// absent data. org.other is unknown and ignored.
if u := opened.UnusableControlExtensions; len(u) != 2 || u[0].ID != "org.example.control" || u[1].ID != "org.example.nodata" {
t.Fatalf("control: %+v", u)
}
for _, c := range in.Checks {
if (c.Step == 4 || c.Step == 14) && !strings.Contains(c.Detail, "unusable noncritical") {
t.Fatalf("step %d does not report the unusable extensions: %s", c.Step, c.Detail)
}
}
// Without a validating registry nothing is unusable.
o.Extensions = nil
opened, err = capsule.Open(context.Background(), &bytes.Buffer{}, bytes.NewReader(dkc), o)
if err != nil || opened.Inspection.UnusableExtensions != nil || opened.UnusableControlExtensions != nil {
t.Fatalf("base protocol: %v", err)
}
}
// The official .dkk with an extension (spec §68) opens its capsule; a
// registry that rejects its data reports it without refusing the credential.
func TestAccessKeyFixtureWithExtension(t *testing.T) {
f := loadFixture(t, "time_and_key_portable")
k := loadAccessKey(t, "time_and_key_portable_extension")
if len(k.Noncritical) != 1 || k.Noncritical[0].ID != "org.example.delivery" {
t.Fatalf("extensions %+v", k.Noncritical)
}
o := f.openOptions(t)
o.AccessKey = k
var out bytes.Buffer
opened, err := capsule.Open(context.Background(), &out, bytes.NewReader(f.dkc), o)
if err != nil || !bytes.Equal(out.Bytes(), f.plaintext) || opened.UnusableAccessKeyExtensions != nil {
t.Fatalf("%v", err)
}
o.Extensions = strictRegistry{}
out.Reset()
opened, err = capsule.Open(context.Background(), &out, bytes.NewReader(f.dkc), o)
if err != nil || !bytes.Equal(out.Bytes(), f.plaintext) {
t.Fatalf("%v", err)
}
if u := opened.UnusableAccessKeyExtensions; len(u) != 1 || u[0].ID != "org.example.delivery" || !errors.Is(u[0].Err, datekeys.ErrExtensionDataInvalid) {
t.Fatalf("unusable: %+v", u)
}
}

@ -162,7 +162,8 @@ type headerWire struct {
// CapsuleIDHex returns the capsule_id in hexadecimal.
func (h *Header) CapsuleIDHex() string { return hex.EncodeToString(h.CapsuleID[:]) }
// EncodeHeader returns the Deterministic CBOR bytes of h.
// EncodeHeader returns the Deterministic CBOR bytes of h, at most
// MaxPublicHeaderLen of them (spec §57).
func EncodeHeader(h *Header) ([]byte, error) {
compact := h.DateKey.Compact()
if compact == "" {
@ -188,15 +189,25 @@ func EncodeHeader(h *Header) ([]byte, error) {
if err := extension.CheckDisjoint(h.Critical, h.Noncritical); err != nil {
return nil, err
}
return codec.Marshal(w)
b, err := codec.Marshal(w)
if err != nil {
return nil, err
}
if len(b) > MaxPublicHeaderLen {
return nil, fmt.Errorf("capsule: PUBLIC_HEADER of %d bytes exceeds %d: %w", len(b), MaxPublicHeaderLen, datekeys.ErrIntegrity)
}
return b, nil
}
// DecodeHeader validates and decodes PUBLIC_HEADER bytes (spec §24, §27,
// §63 step 4): canonical CBOR, the schema, a 16-byte capsule_id, a canonical
// DateKey, a V1 access policy and well-formed extension arrays. Whether the
// profile is pinned and the critical extensions known is decided by the
// caller.
// §63 step 4): the §57 limit, canonical CBOR, the schema, a 16-byte
// capsule_id, a canonical DateKey, a V1 access policy and well-formed
// extension arrays. Whether the profile is pinned and the critical extensions
// known is decided by the caller.
func DecodeHeader(b []byte) (*Header, error) {
if len(b) > MaxPublicHeaderLen {
return nil, fmt.Errorf("capsule: PUBLIC_HEADER of %d bytes exceeds %d: %w", len(b), MaxPublicHeaderLen, datekeys.ErrIntegrity)
}
if err := codec.CheckSchema(b, HeaderTypeTag, HeaderVersion); err != nil {
return nil, fmt.Errorf("capsule: PUBLIC_HEADER: %w", err)
}

@ -81,6 +81,34 @@ func marshal(t *testing.T, m map[uint64]any) []byte {
func ext(id string, v uint64) map[uint64]any { return map[uint64]any{0: id, 1: v} }
func extData(id string, data any) map[uint64]any { return map[uint64]any{0: id, 1: uint64(1), 2: data} }
// manyExts returns n distinct extensions in canonical order.
func manyExts(n int) []any {
out := make([]any, n)
for i := range out {
out[i] = ext(fmt.Sprintf("org.example.%03d", i), 1)
}
return out
}
// Spec §54: extension rules shared by PUBLIC_HEADER and CONTROL_CBOR, as edits
// of the extension array at key.
func extensionRules(key uint64) map[string]func(m map[uint64]any) {
return map[string]func(m map[uint64]any){
"data of type text": func(m map[uint64]any) { m[key] = []any{extData("a", "text")} },
"data of type array": func(m map[uint64]any) { m[key] = []any{extData("a", []any{uint64(1)})} },
"data of type unsigned": func(m map[uint64]any) { m[key] = []any{extData("a", uint64(7))} },
"null data": func(m map[uint64]any) { m[key] = []any{extData("a", nil)} },
"empty data h''": func(m map[uint64]any) { m[key] = []any{extData("a", []byte{})} },
"65 extensions": func(m map[uint64]any) { m[key] = manyExts(65) },
"extension_version 2^32": func(m map[uint64]any) { m[key] = []any{ext("a", 1<<32)} },
"extension_version 2^53-1": func(m map[uint64]any) { m[key] = []any{ext("a", 1<<53-1)} },
"extension map with key 3": func(m map[uint64]any) { m[key] = []any{map[uint64]any{0: "a", 1: uint64(1), 3: []byte{1}}} },
"extension without version": func(m map[uint64]any) { m[key] = []any{map[uint64]any{0: "a"}} },
}
}
func TestDecodeHeaderRejects(t *testing.T) {
dk := datekey.DateKey{ProfileID: profile.QuicknetID, Round: 1000}.Compact()
base := func() map[uint64]any {
@ -104,8 +132,17 @@ func TestDecodeHeaderRejects(t *testing.T) {
t.Errorf("%s: got %v, want %v", tc.name, err, tc.want)
}
}
if _, err := capsule.DecodeHeader(marshal(t, base())); err != nil {
t.Fatalf("valid header rejected: %v", err)
for name, edit := range extensionRules(6) {
m := base()
edit(m)
if _, err := capsule.DecodeHeader(marshal(t, m)); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Errorf("%s: got %v", name, err)
}
}
valid := base()
valid[6] = append(manyExts(63), extData("z", []byte{0}))
if h, err := capsule.DecodeHeader(marshal(t, valid)); err != nil || len(h.Noncritical) != 64 {
t.Fatalf("valid header with 64 extensions rejected: %v", err)
}
}
@ -133,6 +170,13 @@ func TestDecodeControlRejects(t *testing.T) {
t.Errorf("%s: got %v, want %v", tc.name, err, tc.want)
}
}
for name, edit := range extensionRules(4) {
m := base()
edit(m)
if _, err := capsule.DecodeControl(marshal(t, m)); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Errorf("%s: got %v", name, err)
}
}
if _, err := capsule.DecodeControl([]byte("age-encryption.org/v1\n")); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Fatalf("garbage control: %v", err)
}
@ -146,9 +190,17 @@ func TestHeaderLimit(t *testing.T) {
opts := past(t, 1000)
opts.Noncritical = []extension.Extension{big}
var dkc bytes.Buffer
if _, err := capsule.Encrypt(&dkc, strings.NewReader("x"), opts); err == nil || dkc.Len() != 0 {
if _, err := capsule.Encrypt(&dkc, strings.NewReader("x"), opts); !errors.Is(err, datekeys.ErrIntegrity) || dkc.Len() != 0 {
t.Fatalf("PUBLIC_HEADER above 1 MiB accepted: %v", err)
}
// Spec §57: the encoder refuses it with the code the decoder uses.
if _, err := capsule.EncodeHeader(&capsule.Header{DateKey: datekey.DateKey{ProfileID: profile.QuicknetID, Round: 1000}, Noncritical: opts.Noncritical}); !errors.Is(err, datekeys.ErrIntegrity) {
t.Fatalf("EncodeHeader above 1 MiB: %v", err)
}
// Spec §57: the decoder applies the limit too, whatever the framing says.
if _, err := capsule.DecodeHeader(make([]byte, capsule.MaxPublicHeaderLen+1)); !errors.Is(err, datekeys.ErrIntegrity) {
t.Fatalf("DecodeHeader above 1 MiB: %v", err)
}
}
// A .dkk whose critical extension the application does not know is refused

@ -5,14 +5,19 @@ import (
"context"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"testing"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/accesskey"
"g.activething.com/go/DateKeys/capsule"
"g.activething.com/go/DateKeys/datekey"
"g.activething.com/go/DateKeys/extension"
"g.activething.com/go/DateKeys/internal/testkit"
"g.activething.com/go/DateKeys/profile"
)
func seedFixtures(f *testing.F, part func(testkit.Parts) []byte) {
@ -124,4 +129,72 @@ func FuzzInspect(f *testing.F) {
})
}
// FuzzEncodeImpliesDecode: whatever EncodeHeader, EncodeControl and
// accesskey.Encode accept, the matching decoder accepts and re-encodes to the
// same bytes. An encoder that writes what its reader rejects makes capsules
// that cannot be opened (spec §72, §76 case 5).
func FuzzEncodeImpliesDecode(f *testing.F) {
f.Add("org.example.label", uint64(1), []byte("public label"), "org.example.note", uint64(2), []byte{0xa2, 0x00, 0x07}, uint8(0), uint8(0))
f.Add("a", uint64(1)<<32, []byte{}, "a", uint64(0), []byte{0x81, 0x81, 0x00}, uint8(0b1011), uint8(63))
f.Add("org.\xff", uint64(0), []byte{0xf6}, "z", uint64(1)<<53, []byte(nil), uint8(0b0100), uint8(64))
dk := datekey.DateKey{ProfileID: profile.QuicknetID, Round: 1000}
f.Fuzz(func(t *testing.T, id1 string, v1 uint64, d1 []byte, id2 string, v2 uint64, d2 []byte, mode, filler uint8) {
// mode bit 0: first extension critical; bit 1: second critical;
// bit 2: first without data; bit 3: second without data. filler
// adds extensions to the noncritical array, up to past the limit.
e1 := extension.Extension{ID: id1, Version: v1, Data: d1}
e2 := extension.Extension{ID: id2, Version: v2, Data: d2}
if mode&4 != 0 {
e1.Data = nil
}
if mode&8 != 0 {
e2.Data = nil
}
var crit, non []extension.Extension
for i, e := range []extension.Extension{e1, e2} {
if mode&(1<<i) != 0 {
crit = append(crit, e)
} else {
non = append(non, e)
}
}
for i := range int(filler % 72) {
non = append(non, extension.Extension{ID: fmt.Sprintf("x.%02d", i), Version: 1})
}
h := &capsule.Header{DateKey: dk, Policy: capsule.Policy(mode >> 4 & 1), Critical: crit, Noncritical: non}
if b, err := capsule.EncodeHeader(h); err == nil {
back, err := capsule.DecodeHeader(b)
if err != nil {
t.Fatalf("EncodeHeader wrote a PUBLIC_HEADER that DecodeHeader rejects: %v", err)
}
if re, err := capsule.EncodeHeader(back); err != nil || !bytes.Equal(re, b) {
t.Fatal("PUBLIC_HEADER does not re-encode to itself")
}
}
c := &capsule.Control{Critical: crit, Noncritical: non}
if b, err := capsule.EncodeControl(c); err == nil {
back, err := capsule.DecodeControl(b)
if err != nil {
t.Fatalf("EncodeControl wrote a CONTROL_CBOR that DecodeControl rejects: %v", err)
}
if re, err := capsule.EncodeControl(back); err != nil || !bytes.Equal(re, b) {
t.Fatal("CONTROL_CBOR does not re-encode to itself")
}
}
k := &accesskey.AccessKey{Type: accesskey.TypeX25519, Material: make([]byte, 32), Critical: crit, Noncritical: non}
var dkk bytes.Buffer
if err := accesskey.Encode(&dkk, k); err == nil {
back, err := accesskey.Decode(bytes.NewReader(dkk.Bytes()))
if err != nil {
t.Fatalf("accesskey.Encode wrote a .dkk that Decode rejects: %v", err)
}
var re bytes.Buffer
if err := accesskey.Encode(&re, back); err != nil || !bytes.Equal(re.Bytes(), dkk.Bytes()) {
t.Fatal(".dkk does not re-encode to itself")
}
}
})
}
func hexDecode(s string) ([]byte, error) { return hex.DecodeString(s) }

@ -20,8 +20,10 @@ import (
type InspectOptions struct {
// Registry holds the locally pinned profiles. Required.
Registry profile.Registry
// Extensions lists the critical extensions the application implements.
// Nil knows none, the state of the base protocol V1.
// 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
}
@ -53,7 +55,11 @@ type Inspection struct {
PayloadOffset int64
OuterStanzas []StanzaInfo // OUTER_TIME_AGE
PayloadStanzas []StanzaInfo // PAYLOAD_AGE
Checks []CheckResult
// 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) {
@ -121,7 +127,7 @@ func inspect(r io.Reader, opts InspectOptions) (*Inspection, *parsed, error) {
in.pass(3, "public header", fmt.Sprintf("%d bytes", len(hb)))
// Step 4: canonical CBOR, canonical DateKey, pinned profile, known
// critical extensions.
// critical extensions with valid data.
h, err := DecodeHeader(hb)
if err != nil {
return in, nil, in.fail(4, "header validation", err)
@ -135,8 +141,9 @@ func inspect(r io.Reader, opts InspectOptions) (*Inspection, *parsed, error) {
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.pass(4, "header validation", fmt.Sprintf("capsule_id=%s datekey=%s policy=%s profile=%s",
h.CapsuleIDHex(), h.DateKey.Compact(), h.Policy, p.ID))
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))
@ -202,6 +209,14 @@ func readExactly(r io.Reader, n int64) ([]byte, error) {
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 {

@ -6,6 +6,7 @@ import (
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"io"
"strings"
"testing"
@ -14,6 +15,7 @@ import (
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/capsule"
"g.activething.com/go/DateKeys/codec"
"g.activething.com/go/DateKeys/datekey"
"g.activething.com/go/DateKeys/extension"
"g.activething.com/go/DateKeys/internal/testkit"
@ -25,7 +27,7 @@ import (
// valid fixture that must fail with one exact normative error at one step.
type mutation struct {
name string
// spec is true for the twenty mutations listed in spec §64.
// spec is true for the twenty-three mutations listed in spec §64.
spec bool
make func(t *testing.T, env *env) (dkc []byte, opts capsule.OpenOptions)
want *datekeys.Error
@ -107,6 +109,22 @@ func headerWithDateKey(t *testing.T, e *env, dk string) []byte {
return testkit.Reframe(e.toParts.Prelude, raw, e.toParts.Sealed, e.toParts.Payload)
}
// headerWithExtensions replaces the noncritical_extensions of the time_only
// fixture header with exts, encoded as given.
func headerWithExtensions(t *testing.T, e *env, exts []any) []byte {
t.Helper()
var m map[uint64]any
if err := codec.Unmarshal(e.toParts.Header, &m); err != nil {
t.Fatal(err)
}
m[6] = exts
h, err := codec.Marshal(m)
if err != nil {
t.Fatal(err)
}
return testkit.Reframe(e.toParts.Prelude, h, e.toParts.Sealed, e.toParts.Payload)
}
func policyByte(t *testing.T, header []byte) int {
// The access_policy entry is the last one of a header without extensions: 0x04 <value>.
i := len(header) - 2
@ -117,7 +135,7 @@ func policyByte(t *testing.T, header []byte) int {
}
var mutations = []mutation{
// ---- The twenty mutations of spec §64 -------------------------------
// ---- The twenty-three mutations of spec §64 -------------------------------
{name: "PUBLIC_HEADER_A + SEALED_CONTROL_B", spec: true, want: datekeys.ErrHeaderBinding, step: 15, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
b, _ := testkit.Split(e.sibling)
@ -227,6 +245,23 @@ var mutations = []mutation{
return s
}})
}},
{name: "extension data of a type other than bstr", spec: true, want: datekeys.ErrNonCanonicalCBOR, step: 4,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
// The v0.8.1 form of the time_only_extensions header: data as a text string.
return headerWithExtensions(t, e, []any{map[uint64]any{0: "org.example.label", 1: uint64(1), 2: "public label"}}), e.to.openOptions(t)
}},
{name: "empty extension data (h'')", spec: true, want: datekeys.ErrNonCanonicalCBOR, step: 4,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return headerWithExtensions(t, e, []any{map[uint64]any{0: "org.example.label", 1: uint64(1), 2: []byte{}}}), e.to.openOptions(t)
}},
{name: "65 extensions in one array", spec: true, want: datekeys.ErrNonCanonicalCBOR, step: 4,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
exts := make([]any, 65)
for i := range exts {
exts[i] = map[uint64]any{0: fmt.Sprintf("org.example.%03d", i), 1: uint64(1)}
}
return headerWithExtensions(t, e, exts), e.to.openOptions(t)
}},
// ---- Further cases ----------------------------------------------------
{name: "magic", want: datekeys.ErrInvalidMagic, step: 1,
@ -280,6 +315,34 @@ var mutations = []mutation{
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return build(t, testkit.Build{ControlCritical: []extension.Extension{{ID: "org.example.must-understand", Version: 1}}})
}},
{name: "known critical PUBLIC_HEADER extension with invalid data", want: datekeys.ErrExtensionDataInvalid, step: 4,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
dkc, o := build(t, testkit.Build{HeaderCritical: []extension.Extension{mustExt(t, "org.example.must-understand", []byte("ko"))}})
o.Extensions = strictRegistry{}
return dkc, o
}},
{name: "known critical CONTROL_CBOR extension with invalid data", want: datekeys.ErrExtensionDataInvalid, step: 14, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
dkc, o := build(t, testkit.Build{ControlCritical: []extension.Extension{mustExt(t, "org.example.must-understand", []byte("ko"))}})
o.Extensions = strictRegistry{}
return dkc, o
}},
{name: "known critical .dkk extension with invalid data", want: datekeys.ErrExtensionDataInvalid, step: 9,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
o := e.tk.openOptions(t)
k := *e.tk.dkk
k.Critical = []extension.Extension{mustExt(t, "org.example.must-understand", []byte("ko"))}
o.AccessKey, o.Extensions = &k, strictRegistry{}
return e.tk.dkc, o
}},
{name: "extension_version above 2^32-1", want: datekeys.ErrNonCanonicalCBOR, step: 4,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return headerWithExtensions(t, e, []any{map[uint64]any{0: "org.example.label", 1: uint64(1) << 32}}), e.to.openOptions(t)
}},
{name: "null extension data", want: datekeys.ErrNonCanonicalCBOR, step: 4,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return headerWithExtensions(t, e, []any{map[uint64]any{0: "org.example.label", 1: uint64(1), 2: nil}}), e.to.openOptions(t)
}},
{name: "time_and_key without credentials", want: datekeys.ErrAccessRequired, step: 9,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
o := e.tk.openOptions(t)
@ -396,8 +459,8 @@ func TestMutationCorpus(t *testing.T) {
}
})
}
if n != 20 {
t.Fatalf("spec §64 lists 20 mutations, the corpus has %d", n)
if n != 23 {
t.Fatalf("spec §64 lists 23 mutations, the corpus has %d", n)
}
}

@ -24,7 +24,8 @@ import (
type OpenOptions struct {
// Registry holds the locally pinned profiles. Required.
Registry profile.Registry
// Extensions lists the critical extensions the application implements.
// Extensions lists the extensions the application implements; see
// InspectOptions.Extensions.
Extensions extension.Registry
// Source fetches the release. Required. Its answer is always verified
// locally.
@ -48,6 +49,12 @@ type Opened struct {
// CONTROL_CBOR, only visible after opening.
ControlCritical []extension.Extension
ControlNoncritical []extension.Extension
// UnusableControlExtensions and UnusableAccessKeyExtensions are the known
// noncritical extensions of CONTROL_CBOR and of the .dkk whose data
// OpenOptions.Extensions rejects. They do not fail Open; the application
// must not use them (spec §54). The PUBLIC_HEADER ones are in Inspection.
UnusableControlExtensions []extension.Unusable
UnusableAccessKeyExtensions []extension.Unusable
}
// Open runs the complete decryption flow of spec §63 and streams the
@ -93,6 +100,7 @@ func Open(ctx context.Context, dst io.Writer, r io.Reader, opts OpenOptions) (*O
if err := checkAccessKey(k, h, opts.Extensions); err != nil {
return out, in.fail(9, "access credential", err)
}
out.UnusableAccessKeyExtensions = extension.CheckNoncritical(k.Noncritical, opts.Extensions)
if k.Verification != nil && seekable {
payload, err := checkCapsuleDigest(r.(io.ReadSeeker), start, in.PayloadOffset, k.Verification.CapsuleDigest)
if err != nil {
@ -109,7 +117,7 @@ func Open(ctx context.Context, dst io.Writer, r io.Reader, opts OpenOptions) (*O
if len(ids) == 0 {
return out, in.fail(9, "access credential", fmt.Errorf("capsule: time_and_key capsule and no identity or .dkk supplied: %w", datekeys.ErrAccessRequired))
}
in.pass(9, "access credential", fmt.Sprintf("%d identities to try", len(ids)))
in.pass(9, "access credential", fmt.Sprintf("%d identities to try%s", len(ids), unusable(out.UnusableAccessKeyExtensions)))
}
// Step 9: obtain the release, never before its round time.
@ -188,7 +196,8 @@ func Open(ctx context.Context, dst io.Writer, r io.Reader, opts OpenOptions) (*O
return out, in.fail(14, "control", fmt.Errorf("capsule: CONTROL_CBOR: %w", err))
}
out.ControlCritical, out.ControlNoncritical = control.Critical, control.Noncritical
in.pass(14, "control", "canonical CONTROL_CBOR")
out.UnusableControlExtensions = extension.CheckNoncritical(control.Noncritical, opts.Extensions)
in.pass(14, "control", "canonical CONTROL_CBOR"+unusable(out.UnusableControlExtensions))
// Step 15: verify header_binding over the exact stored bytes.
binding := HeaderBinding(st.prelude, in.PublicHeader)
@ -216,7 +225,8 @@ func Open(ctx context.Context, dst io.Writer, r io.Reader, opts OpenOptions) (*O
return out, nil
}
// checkAccessKey validates a .dkk against the capsule before it is used.
// checkAccessKey validates a .dkk against the capsule before it is used: the
// capsule_id, and its critical extensions as in step 4.
func checkAccessKey(k *accesskey.AccessKey, h *Header, reg extension.Registry) error {
if k.CapsuleID != h.CapsuleID {
return fmt.Errorf("capsule: the .dkk is for capsule %x, this is %x: %w", k.CapsuleID, h.CapsuleID, datekeys.ErrAccessInvalid)

@ -7,6 +7,12 @@
// ErrNonCanonicalCBOR. The same principle as dk1_ canonicality (spec §19):
// canonicality does not depend on a library promising to reject every
// non-canonical form.
//
// The CBOR profile of the protocol (spec §58: major types 0, 2, 3, 4 and 5
// only, unsigned integer map keys) is enforced by decoding into the typed
// schemas of each package: their fields are unsigned integers, byte strings,
// text strings, arrays and maps, so negative integers, floats and simple
// values fail to decode, and null fails the re-encoding check.
package codec
import (
@ -26,12 +32,27 @@ const (
MaxMapPairs = 65536
)
// MaxSafeUint is 2^53-1, the largest unsigned integer any schema of the
// protocol allows, so that every integer is exact as an IEEE 754 double
// (spec §58).
const MaxSafeUint = 1<<53 - 1
var (
encMode = must(cbor.CoreDetEncOptions().EncMode())
encMode = must(encOptions().EncMode())
decMode = must(decOptions(true).DecMode())
peekMode = must(decOptions(false).DecMode())
)
// encOptions returns Core Deterministic Encoding options in which a nil byte
// string, array or map encodes as an empty one, never as null: null is outside
// the profile of spec §58, so an input null never survives the re-encoding
// check.
func encOptions() cbor.EncOptions {
o := cbor.CoreDetEncOptions()
o.NilContainers = cbor.NilContainerAsEmpty
return o
}
// decOptions returns the strict decoding options. Peek mode ignores unknown
// map keys; the canonical mode reports them.
func decOptions(strict bool) cbor.DecOptions {
@ -73,12 +94,18 @@ func Marshal(v any) ([]byte, error) {
// Every failure wraps datekeys.ErrNonCanonicalCBOR.
//
// Fields of type cbor.RawMessage are copied verbatim and are NOT covered by the
// re-encoding check; callers must validate them with Valid.
// re-encoding check; the caller must validate them.
//
// The re-encoding equals data on success, so it may hold secrets such as
// I_PAYLOAD or access_material; it is wiped on every path. This is best
// effort: the encoder's internal buffer may keep a copy.
func Unmarshal(data []byte, v any) error {
if err := decMode.Unmarshal(data, v); err != nil {
return fmt.Errorf("codec: decode: %v: %w", err, datekeys.ErrNonCanonicalCBOR)
}
if re, err := encMode.Marshal(v); err != nil || !bytes.Equal(re, data) {
re, err := encMode.Marshal(v)
defer clear(re)
if err != nil || !bytes.Equal(re, data) {
return fmt.Errorf("codec: input is not the deterministic encoding of its value: %w", datekeys.ErrNonCanonicalCBOR)
}
return nil
@ -115,12 +142,3 @@ func CheckSchema(data []byte, typeTag string, version uint64) error {
}
return nil
}
// Valid reports whether data is exactly one well-formed CBOR data item in core
// deterministic encoding. It is used for opaque values the protocol does not
// interpret, such as extension data (spec §54). Tags, the simple value
// undefined and map keys that are arrays or maps are rejected.
func Valid(data []byte) error {
var v any
return Unmarshal(data, &v)
}

@ -4,7 +4,6 @@ import (
"encoding/hex"
"errors"
"math/rand/v2"
"strings"
"testing"
datekeys "g.activething.com/go/DateKeys"
@ -58,6 +57,14 @@ func TestUnmarshalRejectsNonCanonical(t *testing.T) {
{"wrong type", "a300617801617a024101"},
{"not a map", "83006178" + "01"},
{"empty input", ""},
// Outside the CBOR profile of spec §58.
{"negative integer", "a3006178012002" + "4101"},
{"float", "a300617801f9400002" + "4101"},
{"true", "a300617801f502" + "4101"},
{"null byte string", "a30061780117" + "02f6"},
{"undefined byte string", "a30061780117" + "02f7"},
{"null text", "a300f60117" + "024101"},
{"text map key", "a300617801176162" + "4101"},
} {
t.Run(tc.name, func(t *testing.T) {
var s sample
@ -69,33 +76,6 @@ func TestUnmarshalRejectsNonCanonical(t *testing.T) {
}
}
func TestValid(t *testing.T) {
for _, h := range []string{
"00", "17", "1818", "20", "3bffffffffffffffff", "40", "60", "80", "a0", "f4", "f5", "f6",
"f97e00", "f93c00", "fa47c35000", "a2016161026162", "a1416101", "8201820203",
} {
if err := codec.Valid(mustHex(t, h)); err != nil {
t.Errorf("%s rejected: %v", h, err)
}
}
for _, h := range []string{
"1817", // 23 encoded in two bytes
"f7", // undefined
"fb3ff0000000000000", // 1.0 as float64 instead of float16
"fa7fc00000", // NaN not in the canonical f97e00 form
"a2026162016161", // keys out of order
"c101", // tag
"9f01ff", // indefinite-length array
"a1810101", // array as map key
"0000", // two items
strings.Repeat("81", codec.MaxNestedLevels+4) + "00", // nesting beyond the limit
} {
if err := codec.Valid(mustHex(t, h)); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Errorf("%s accepted or wrong error: %v", h, err)
}
}
}
func TestCheckSchema(t *testing.T) {
b, _ := codec.Marshal(sample{Type: "datekeycap", N: 1, Bytes: []byte{}})
if err := codec.CheckSchema(b, "datekeycap", 1); err != nil {
@ -153,20 +133,22 @@ func TestErrorsCarryTheNormativeCode(t *testing.T) {
}
}
func FuzzValid(f *testing.F) {
for _, h := range []string{"a400617801170241010a82011901f4", "f97e00", "a2016161026162", "9f01ff"} {
// FuzzUnmarshal: whatever Unmarshal accepts is the deterministic encoding of
// the decoded value.
func FuzzUnmarshal(f *testing.F) {
for _, h := range []string{"a400617801170241010a82011901f4", "a30061780117024101", "a3006178011702f6", "9f01ff"} {
b, _ := hex.DecodeString(h)
f.Add(b)
}
f.Fuzz(func(t *testing.T, b []byte) {
if codec.Valid(b) != nil {
var s sample
if err := codec.Unmarshal(b, &s); err != nil {
if !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Fatalf("error without ErrNonCanonicalCBOR: %v", err)
}
return
}
var v any
if err := codec.Unmarshal(b, &v); err != nil {
t.Fatalf("Valid accepted what Unmarshal rejects: %v", err)
}
re, err := codec.Marshal(v)
re, err := codec.Marshal(s)
if err != nil || string(re) != string(b) {
t.Fatalf("accepted a non-canonical item %x", b)
}

@ -1,4 +1,4 @@
# Traceability: DateKeys Protocol Specification v0.8.1 ↔ datekeys-go
# Traceability: DateKeys Protocol Specification v0.8.2 ↔ datekeys-go
This table maps every normative section of the specification to the code that
implements it and to the tests that exercise it. It is updated in the same
@ -7,7 +7,7 @@ reviewer together with the specification, the fixtures and the mutation corpus
(plan §10).
Paths are relative to the repository root. `§` numbers refer to
`spec/DateKeys_Protocol_Specification_v0.8.1.md`.
`spec/DateKeys_Protocol_Specification_v0.8.2.md`.
## Section map
@ -18,7 +18,7 @@ Paths are relative to the repository root. `§` numbers refer to
| 7 | Threat model | creator model in `internal/testkit.Build`, `RewriteAge`; third-party edits in the mutation corpus | `agewrap.TestTimeIdentityStrictness`, `TestPayloadIdentityStrictness`, `TestAccessIdentityStrictness`, `capsule.TestMutationCorpus` |
| 9 | Provider abstraction | `provider.Condition`, `provider.Release`, `provider.ReleaseSource` | `provider/*` |
| 10 | Provider Profile | `profile.Profile`, `Profile.Validate` | `profile.TestValidateRejectsTamperedProfiles` |
| 11 | Canonical profile encoding, `profile_hash` | `Profile.CanonicalCBOR`, `Profile.Hash`, `profile.Decode` | `profile.TestQuicknetMatchesGoldenVector`, `TestQuicknetCBORLayout`, `TestDecodeRoundTrip`, `FuzzDecode`; `testdata/vectors/profile_quicknet.json` |
| 11 | Canonical profile encoding, `profile_hash`; `period` in 1..2^53−1, `genesis_time` in 0..2^53−1 | `Profile.CanonicalCBOR`, `Profile.Hash`, `profile.Decode` (unsigned `genesis_time`, `codec.MaxSafeUint`) | `profile.TestQuicknetMatchesGoldenVector`, `TestQuicknetCBORLayout`, `TestDecodeRoundTrip`, `TestIntegerRanges`, `FuzzDecode`; `testdata/vectors/profile_quicknet.json` |
| 12 | Quicknet Provider Profile V1 | `profile.Quicknet`, `profile.Quicknet*` constants | `profile.TestQuicknetMatchesGoldenVector` |
| 13 | Root of trust | `profile.NewRegistry`, `profile.Pin`, `profile.Default`, `QuicknetProfileHash`; chain-hash self-check in `Profile.Validate` | `profile.TestRegistry`; mutations *unknown profile*, *empty registry* |
| 14 | DateKey | `datekey.DateKey` | `datekey/*` |
@ -31,7 +31,7 @@ Paths are relative to the repository root. `§` numbers refer to
| 21 | `capsule_id` | `capsule.Encrypt` (16 bytes from `crypto/rand`), `capsule.DecodeHeader` | `capsule.TestPortableKeysAreNeverReused` |
| 22 | `.dkc` framing | `capsule.Prelude`, `capsule.ParsePrelude` | mutations *version changed*, *flags != 0*, *reserved != 0*, *magic*, length limits; `capsule.FuzzParsePrelude` |
| 23 | PRELUDE | `Prelude.Bytes` | `capsule.TestConformanceFixtures` |
| 24 | PUBLIC_HEADER | `capsule.Header`, `EncodeHeader`, `DecodeHeader` | `capsule.TestConformanceFixtures`, `FuzzDecodeHeader`; mutations *header schema version changed*, *unknown key in PUBLIC_HEADER* |
| 24 | PUBLIC_HEADER; keys 5 and 6 optional, 1 to 64 extensions each | `capsule.Header`, `EncodeHeader`, `DecodeHeader` | `capsule.TestConformanceFixtures` (exact extension data), `TestDecodeHeaderRejects`, `FuzzDecodeHeader`, `FuzzEncodeImpliesDecode`; mutations *header schema version changed*, *unknown key in PUBLIC_HEADER* |
| 25 | Declared access policy | `capsule.Policy`; `capsule.Open` step 12 | mutations *access_policy=… with … structure* (four cases), *undefined access_policy* |
| 26 | Header binding | `capsule.HeaderBinding`; `capsule.Open` step 15 | `capsule.TestConformanceFixtures`; mutation *PUBLIC_HEADER_A + SEALED_CONTROL_B* |
| 27 | Pre-unlock validation | `capsule.Inspect` (steps 1–8), `agewrap.Stanzas` probe | `capsule.TestMutationCorpus` (no release request for any pre-unlock failure), `FuzzInspect` |
@ -39,7 +39,7 @@ Paths are relative to the repository root. `§` numbers refer to
| 29 | PAYLOAD_AGE | `capsule.Encrypt` step 4; `agewrap.PayloadIdentity`, `agewrap.CheckPayloadStanzas` | `agewrap.TestPayloadIdentityStrictness`; mutation *extra stanza in PAYLOAD_AGE* |
| 30 | PAYLOAD_AGE is a complete age file | `filippo.io/age` public API only | `capsule.TestInteropAgeOpensPayload` (`-tags interop`, official `age` CLI) |
| 30.1 | CONTROL_CBOR ↔ PAYLOAD_AGE binding | `agewrap.PayloadIdentity` | mutation *SEALED_CONTROL_A + PAYLOAD_AGE_B*; `agewrap.TestPayloadIdentityStrictness` |
| 31 | CONTROL_CBOR | `capsule.Control`, `EncodeControl`, `DecodeControl` | `capsule.TestConformanceFixtures`, `FuzzDecodeControl`; mutation *unknown critical CONTROL_CBOR extension* |
| 31 | CONTROL_CBOR; keys 4 and 5 optional; extension entry rules | `capsule.Control`, `EncodeControl`, `DecodeControl`; `extension` | `capsule.TestConformanceFixtures` (exact extension data), `TestDecodeControlRejects`, `FuzzDecodeControl`, `FuzzEncodeImpliesDecode`; mutation *unknown critical CONTROL_CBOR extension* |
| 32 | `time_only` | `capsule.Encrypt`; `agewrap.TimeRecipient` | fixtures `time_only*`, `empty_payload`; `capsule.TestInteropTleOpensSealedControl` (`-tags interop`, official `tle` CLI) |
| 33 | `time_and_key` | `capsule.Encrypt` (`seal`); `agewrap.AccessIdentity` | fixtures `time_and_key_*`; `capsule.TestEncryptRoundTripBothPolicies` |
| 34 | SEALED_CONTROL | `capsule.Encrypt`; `capsule.Open` step 11 | `capsule.TestConformanceFixtures` |
@ -53,7 +53,7 @@ Paths are relative to the repository root. `§` numbers refer to
| 41 | `.dkk` BODY_CBOR | `AccessKey.MarshalBody`, `accesskey.DecodeBody` | `accesskey.TestFixtures` |
| 42 | `credential_id` | `capsule.Encrypt` (16 bytes from `crypto/rand`) | `capsule.TestPortableKeysAreNeverReused` |
| 43 | `verification_metadata` | `accesskey.Verification`; `capsule.Open` (`checkCapsuleDigest`, seekable readers) | `accesskey.TestDecodeRejects` *empty verification map*; mutation *capsule_digest of the .dkk does not match* |
| 44 | Application extensions in `.dkk` | `AccessKey.Critical/Noncritical`; `capsule.Open` (`checkAccessKey`) | `accesskey.TestEncodeRejectsAbsenceAsEmptyMap` |
| 44 | Application extensions in `.dkk` | `AccessKey.Critical/Noncritical`; `capsule.Open` (`checkAccessKey`, `Opened.UnusableAccessKeyExtensions`) | `accesskey.TestEncodeRejectsAbsenceAsEmptyMap`, `TestDecodeBodyExtensionRules`, `TestFixtureWithExtension`; `capsule.TestAccessKeyFixtureWithExtension`; mutation *known critical .dkk extension with invalid data* |
| 45 | Release API | `provider.ReleaseSource` interface only (server out of scope, plan §2) | — |
| 46 | Release Queue | out of scope (server) | — |
| 47 | Release Cache | every release is verified again: `capsule.Open` step 10 and `agewrap.TimeIdentity` | mutations *release of another round* |
@ -63,27 +63,28 @@ Paths are relative to the repository root. `§` numbers refer to
| 51 | Quicknet release verification | `provider.Verify` | `provider.TestVerifyPublishedReleases`, `TestVerifyRejects`, `TestVerifyUsesThePinnedKeyOnly` |
| 52 | DNS / MITM | `provider/drand` (no redirects, bounded responses, BLS) | `drand.TestRedirectsAreNotFollowed`, `TestRejectMalformedRelayResponses`, `TestRandomnessMustMatchWhenPresent` |
| 53 | Harvest now, decrypt later | `cmd/datekeys` warning beyond one year | `cmd/datekeys.TestLongHorizonWarning` |
| 54 | Extensions | `extension` | `extension/*`; mutations *unknown critical … extension*; `capsule.TestKnownCriticalExtensions` |
| 54 | Extensions: data absent or a non-empty opaque byte string, never decoded; 1 to 64 per array; `extension_version` ≤ 2^32−1; one `extension_id` per object | `extension.New`, `Wire.UnmarshalCBOR` (explicit key 2 check), `Encode`, `Decode`, `CheckDisjoint` (linear merge), `CheckCritical`, `CheckNoncritical`, `Unusable` | `extension.TestNew`, `TestWireData`, `TestEncodeRejects`, `TestDecodeRejects`, `TestCheckDisjoint`, `TestCheckDisjointIsLinear`, `TestCheckCritical`, `TestCheckNoncritical`; `capsule.TestKnownCriticalExtensions`, `TestUnusableNoncriticalExtensions`; mutations *unknown critical … extension*, *known critical … extension with invalid data*, *extension_version above 2^32-1*, *null extension data* |
| 55 | Auxiliary integrity | `capsule_digest` treated as UX only | — |
| 56 | Atomic plaintext output | `capsule.Open` contract; `cmd/datekeys.writeAtomic` | `cmd/datekeys.TestOutputNotPublishedOnFailureOrOverwrite`, `TestDecryptFailuresLeaveNothing` |
| 57 | Parser limits | `capsule.MaxPublicHeaderLen`, `MaxSealedControlLen`, `accesskey.MaxBodyLen`, `codec` limits | mutations *…_LEN above the limit*; `accesskey.TestDecodeRejects` *body length above the limit* |
| 58 | Canonical CBOR | `codec.Marshal`, `codec.Unmarshal` (re-encoding comparison), `codec.Valid` | `codec.TestUnmarshalRejectsNonCanonical`, `TestValid`, `TestRoundTripProperty`, `FuzzValid` |
| 58.1 | Absent optional fields are omitted | `extension.Encode` (nil for empty), re-encoding check, `accesskey` verification map | `codec` *empty optional array present*; `accesskey` *empty extension array*, *empty verification map*, *null verification* |
| 57 | Parser limits, MUST for encoders and decoders; frame lengths and objects above their frame → `ERR_INTEGRITY` on encode and decode, CDDL violations → `ERR_NON_CANONICAL_CBOR`, Provider Profile names and public key → `ERR_UNKNOWN_PROFILE`; implementation limits not normative | `capsule.MaxPublicHeaderLen` (`ParsePrelude`, `EncodeHeader`, `DecodeHeader`), `MaxSealedControlLen` (`ParsePrelude`, `Encrypt`), `accesskey.MaxBodyLen` (`Decode`, `DecodeBody`, `MarshalBody`), `extension.MaxExtensions`, `MaxDataLen`, `codec` limits; `profile.Validate` | mutations *…_LEN above the limit*, *65 extensions in one array*; `capsule.TestHeaderLimit`, `TestHugeExtensionArraysAreRejected`; `accesskey.TestDecodeRejects` *body length above the limit*, `TestBodyLimit`; `profile.TestValidateRejectsTamperedProfiles`, `TestIntegerRanges` |
| 58 | Canonical CBOR and the protocol's CBOR profile (major types 0, 2, 3, 4, 5; unsigned integer keys; integers ≤ 2^53−1) | `codec.Marshal` (nil containers as empty, never `null`), `codec.Unmarshal` (re-encoding comparison) into typed schemas; `codec.MaxSafeUint`; the profile covers the head of extension data only | `codec.TestUnmarshalRejectsNonCanonical` (negative integer, float, `true`, `null`, text key), `TestRoundTripProperty`, `FuzzUnmarshal`; `extension.TestWireData` |
| 58.1 | Absent optional fields are omitted; `h''` and `null` never stand for absence | `extension.Encode` (nil for empty), `extension.Wire.UnmarshalCBOR` and `Decode` (empty data), re-encoding check, `accesskey` verification map | `codec` *empty optional array present*; `accesskey` *empty extension array*, *empty verification map*, *null verification*, *empty data*, *null data*; mutation *empty extension data (h'')* |
| 59 | Supply-chain security | pinned `go.mod`/`go.sum`, `.gitea/workflows`, `scripts/check.sh`, `.goreleaser.yaml`, `SECURITY.md` | CI jobs `vuln`, `sbom`, `verify` |
| 60 | Conceptual Go interfaces | `provider.ReleaseSource`, `provider.Verify`, `datekey.Resolve`, `datekey.RoundTime` | — |
| 61 | `time_only` encryption flow | `capsule.Encrypt` (steps numbered in comments) | `capsule.TestEncryptRoundTripBothPolicies` |
| 62 | `time_and_key` encryption flow | `capsule.Encrypt` | `capsule.TestEncryptRoundTripBothPolicies`, `TestPortableKeysAreNeverReused` |
| 63 | Decryption flow | `capsule.Inspect` (steps 1–8), `capsule.Open` (steps 9–18), MUST rules inside `agewrap` identities | `capsule.TestConformanceFixtures` (stage by stage), `TestMutationCorpus` |
| 64 | Mandatory mutation tests | `capsule/mutation_test.go` | `capsule.TestMutationCorpus`: the 20 listed mutations plus 25 more |
| 63 | Decryption flow; steps 4 and 14 validate critical extensions (unknown, invalid data) | `capsule.Inspect` (steps 1–8), `capsule.Open` (steps 9–18), MUST rules inside `agewrap` identities | `capsule.TestConformanceFixtures` (stage by stage), `TestMutationCorpus` |
| 64 | Mandatory mutation tests | `capsule/mutation_test.go` | `capsule.TestMutationCorpus`: the 23 listed mutations plus 30 more |
| 65 | Quicknet vectors | `internal/testkit.RoundVectors` | `datekey.TestGoldenRoundVectors` |
| 66 | `dk1_` vectors | `internal/testkit.DK1Vectors` | `datekey.TestGoldenDK1Vectors` |
| 67 | `.dkc` vectors | `testdata/fixtures/*.dkc` + `*.json`, `internal/testkit/genfixtures` | `capsule.TestConformanceFixtures` |
| 68 | `.dkk` vectors | `testdata/fixtures/*.dkk` + `*.dkk.json` | `accesskey.TestFixtures` |
| 69 | Normative errors | `errors.go` | `datekeys.TestCatalogueMatchesSpec`, `TestCode` |
| 68 | `.dkk` vectors, with the exact extension data; one carries an extension with data | `testdata/fixtures/*.dkk` + `*.dkk.json`; `time_and_key_portable_extension.dkk` derived by `genfixtures` | `accesskey.TestFixtures`, `TestFixtureWithExtension`; `capsule.TestAccessKeyFixtureWithExtension` |
| 69 | Normative errors, including `ERR_EXTENSION_DATA_INVALID` | `errors.go` | `datekeys.TestCatalogueMatchesSpec`, `TestCode` |
| 70 | Compatibility | magic and version checks, `codec.CheckSchema` | mutations; `codec.TestCheckSchema` |
| 71 | Profile registry | `profile.Decode` + `profile.NewRegistry` with pinned hashes | `profile.TestRegistry` |
| 72 | Extension registry | `extension.Registry`, `extension.Set` | `capsule.TestKnownCriticalExtensions` |
| 72 | Extension registry and registration rules; the encoder decodes its own output before sealing | `extension.Registry`, `extension.Set`, `extension.DataValidator`; self-checks in `capsule.Encrypt` and `accesskey.MarshalBody` | `capsule.TestKnownCriticalExtensions`, `TestUnusableNoncriticalExtensions`, `TestNestedDataSealsAndOpens`, `FuzzEncodeImpliesDecode` |
| 75 | Blocking requirements before v1.0 | items 1–9 above; item 10 (external review) pending | — |
| 76 | Change policy; the v0.8.2 extension change and its reproducible cases | `extension`, `codec`, fixture `time_only_extensions` regenerated | case 2: `extension.TestNew`; case 3: `capsule.TestNaNKeyedDataHasOneVerdict`; case 4: `capsule.TestExtensionFixtureData`; case 5: `capsule.TestNestedDataSealsAndOpens`; case 6: `capsule.TestHugeExtensionArraysAreRejected`, `extension.TestCheckDisjointIsLinear` |
## Error mapping
@ -93,9 +94,9 @@ under the change policy of §76.
| Failure | Error |
|---|---|
| Bytes that are not the deterministic encoding of a valid schema instance: malformed CBOR, non-canonical encoding, unknown key, missing key, wrong type, wrong type tag (key 0), wrong field length, undefined `access_policy`, empty optional array or map, extension rules | `ERR_NON_CANONICAL_CBOR` |
| Bytes that are not the deterministic encoding of a valid schema instance: malformed CBOR, non-canonical encoding, unknown key, missing key, wrong type, `null`, wrong type tag (key 0), wrong field length, undefined `access_policy`, empty optional array or map, extension rules (named by §54 and §57 since v0.8.2) | `ERR_NON_CANONICAL_CBOR` |
| Schema version (key 1) other than 1 | `ERR_UNSUPPORTED_VERSION` |
| Truncated framing, length fields beyond the §57 limits, data after BODY_CBOR, malformed or unauthenticated age data, truncated or modified STREAM, trailing data after PAYLOAD_AGE, a PAYLOAD_AGE that I_PAYLOAD cannot open | `ERR_INTEGRITY` |
| Truncated framing, length fields beyond the §57 limits, an object above its §57 frame on encode or decode, data after BODY_CBOR, malformed or unauthenticated age data, truncated or modified STREAM, trailing data after PAYLOAD_AGE, a PAYLOAD_AGE that I_PAYLOAD cannot open | `ERR_INTEGRITY` |
| Stanza count or type violations in OUTER_TIME_AGE, PAYLOAD_AGE or INNER_ACCESS_AGE, including two stanzas for one recipient | `ERR_POLICY_STRUCTURE_MISMATCH` |
| tlock stanza round argument not exactly the canonical decimal DateKey round | `ERR_ROUND_MISMATCH` |
| tlock stanza chain hash not exactly the lowercase hex chain hash of the pinned profile; profile whose parameters do not hash to its chain hash | `ERR_PROFILE_MISMATCH` |
@ -105,7 +106,7 @@ under the change policy of §76.
## Implementation decisions to confirm in the specification
These are choices the reference implementation had to make where v0.8.1 is
These are choices the reference implementation had to make where v0.8.2 is
silent or provisional (§74). None changes the protocol semantics; each is a
candidate clarification under §76.
@ -124,9 +125,14 @@ candidate clarification under §76.
non-canonical.
5. **Closed maps.** Unknown keys in core maps are rejected; applications use
extensions (§1, §54).
6. **Extension data.** Key 2 is optional and omitted when absent; data must be
deterministic CBOR without tags. `extension_id` is 1 to 256 bytes of UTF-8.
No V1 schema allows repeating an `extension_id`.
6. **Extension data.** v0.8.2 settles the format (§54, §76): key 2 is omitted
when absent and otherwise a non-empty byte string that the base protocol
never decodes. What remains an implementation choice: `extension_id` is 1 to
256 bytes of UTF-8; `extension.MaxDataLen` is 64 MiB, the largest frame, the
container frame being the effective bound; an application validates the
data of the extensions it knows through the optional
`extension.DataValidator` of its `Registry`, and an unknown critical
extension is reported before a known one with invalid data.
7. **One stanza per recipient.** Enforced as far as a recipient can observe it:
no repeated X25519 ephemeral share, and no identity that unwraps more than
one stanza.

@ -1,5 +1,5 @@
// Package datekeys is the reference Go implementation of the DateKeys Protocol
// Specification v0.8.1 (spec/DateKeys_Protocol_Specification_v0.8.1.md).
// Specification v0.8.2 (spec/DateKeys_Protocol_Specification_v0.8.2.md).
//
// The protocol objects live in subpackages:
//
@ -67,6 +67,10 @@ var (
ErrIntegrity = &Error{"ERR_INTEGRITY"}
// ErrExtensionCriticalUnknown: a critical extension this implementation does not know (spec §54).
ErrExtensionCriticalUnknown = &Error{"ERR_EXTENSION_CRITICAL_UNKNOWN"}
// ErrExtensionDataInvalid: a known extension whose data does not follow its
// registered schema. It rejects the object only for a critical extension; a
// noncritical one is reported as unusable (spec §54, §72).
ErrExtensionDataInvalid = &Error{"ERR_EXTENSION_DATA_INVALID"}
)
// All returns every normative error in the order of spec §69.
@ -76,7 +80,7 @@ func All() []*Error {
ErrUnknownProfile, ErrProfileMismatch, ErrDateKeyInvalid, ErrDateKeyNonCanonical,
ErrRoundMismatch, ErrReleaseUnavailable, ErrReleaseInvalid, ErrAccessRequired,
ErrAccessInvalid, ErrPolicyStructureMismatch, ErrHeaderBinding, ErrIntegrity,
ErrExtensionCriticalUnknown,
ErrExtensionCriticalUnknown, ErrExtensionDataInvalid,
}
}

@ -12,7 +12,7 @@ import (
// The catalogue matches spec §69 exactly, in order.
func TestCatalogueMatchesSpec(t *testing.T) {
spec, err := os.ReadFile("spec/DateKeys_Protocol_Specification_v0.8.1.md")
spec, err := os.ReadFile("spec/DateKeys_Protocol_Specification_v0.8.2.md")
if err != nil {
t.Fatal(err)
}
@ -52,4 +52,8 @@ func TestCode(t *testing.T) {
if datekeys.ErrIntegrity.Error() != "ERR_INTEGRITY" {
t.Fatal("message")
}
data := fmt.Errorf("capsule: step 4: %w", datekeys.ErrExtensionDataInvalid)
if datekeys.Code(data) != "ERR_EXTENSION_DATA_INVALID" || errors.Is(data, datekeys.ErrExtensionCriticalUnknown) {
t.Fatal("ERR_EXTENSION_DATA_INVALID")
}
}

@ -1,19 +1,26 @@
// Package extension implements the single generic extension mechanism shared
// by PUBLIC_HEADER, CONTROL_CBOR and .dkk (spec §31, §44, §54, §72).
//
// The base protocol does not interpret extension data. It enforces the
// structural rules only: valid UTF-8 identifiers, no identifier repeated
// within an object (V1 registers no schema that allows multiplicity), no
// identifier in both the critical and the noncritical array, canonical order
// by the UTF-8 bytes of extension_id and then by version, rejection of unknown
// critical extensions, and omission of empty arrays (spec §58.1).
// Extension data is opaque bytes: the base protocol never decodes or
// validates its content, and the validity of the containing object never
// depends on it. The package enforces the structural rules only: valid UTF-8
// identifiers, extension_version at most 2^32-1, data that is absent or a
// non-empty byte string, 1 to 64 extensions per array, no identifier repeated
// within an object, no identifier in both the critical and the noncritical
// array, canonical order by the UTF-8 bytes of extension_id, rejection of
// unknown critical extensions, and omission of empty arrays (spec §58.1).
//
// Only an application that knows an extension interprets its data. A
// Registry that also implements DataValidator checks the data of the
// extensions it knows: invalid data rejects a critical extension with
// ErrExtensionDataInvalid and makes a noncritical one Unusable (spec §54).
package extension
import (
"bytes"
"cmp"
"fmt"
"slices"
"strings"
"unicode/utf8"
"github.com/fxamacker/cbor/v2"
@ -22,72 +29,131 @@ import (
"g.activething.com/go/DateKeys/codec"
)
// MaxIDLen bounds extension_id. It is an implementation limit (spec §74).
const MaxIDLen = 256
// Limits of one extension array and of one extension (spec §31, §54, §57).
const (
// MaxIDLen bounds extension_id. It is an implementation limit (spec §74).
MaxIDLen = 256
// MaxExtensions is the largest number of extensions in one array.
MaxExtensions = 64
// MaxVersion is the largest extension_version, 2^32-1.
MaxVersion = 1<<32 - 1
// MaxDataLen is the largest data: the largest frame of spec §57,
// SEALED_CONTROL. The frame of the containing object is the effective
// bound.
MaxDataLen = 64 << 20
)
// Extension is one entry of an extension array.
type Extension struct {
ID string // key 0, extension_id
Version uint64 // key 1, extension_version
// Data is the Deterministic CBOR encoding of the data item (key 2), or
// nil when the extension carries no data and the key is omitted.
// Data is the opaque content of key 2, at least one byte, or nil when the
// extension carries no data and key 2 is omitted. An empty non-nil slice
// is invalid: an empty byte string never stands for absence (spec §58.1).
Data []byte
}
// New builds an extension whose data is the deterministic encoding of value.
func New(id string, version uint64, value any) (Extension, error) {
b, err := codec.Marshal(value)
if err != nil {
// New returns an extension that carries data, of which it keeps a copy. data
// must hold at least one byte. An extension without data has no constructor:
// it is the literal Extension{ID: id, Version: version}, which omits key 2.
func New(id string, version uint64, data []byte) (Extension, error) {
if data == nil {
return Extension{}, fmt.Errorf("extension %q: New needs data; an extension without data is Extension{ID, Version}: %w", id, datekeys.ErrNonCanonicalCBOR)
}
e := Extension{ID: id, Version: version, Data: bytes.Clone(data)}
if err := validate(e); err != nil {
return Extension{}, err
}
return Extension{ID: id, Version: version, Data: b}, nil
return e, nil
}
// Wire is the CBOR map of one extension (spec §54).
// Wire is the CBOR map of one extension (spec §54). Data is the content of the
// byte string at key 2; nil omits the key.
type Wire struct {
ID string `cbor:"0,keyasint"`
Version uint64 `cbor:"1,keyasint"`
Data cbor.RawMessage `cbor:"2,keyasint,omitempty"`
ID string `cbor:"0,keyasint"`
Version uint64 `cbor:"1,keyasint"`
Data []byte `cbor:"2,keyasint,omitempty"`
}
// UnmarshalCBOR decodes one extension map. Key 2, when present, must be a
// byte string of at least one byte: the empty byte string and every other
// CBOR type are rejected here, explicitly, and not left to the re-encoding
// check of the containing object (spec §54, §58.1).
func (w *Wire) UnmarshalCBOR(b []byte) error {
var raw struct {
ID string `cbor:"0,keyasint"`
Version uint64 `cbor:"1,keyasint"`
Data cbor.RawMessage `cbor:"2,keyasint,omitempty"`
}
if err := codec.Unmarshal(b, &raw); err != nil {
return err
}
*w = Wire{ID: raw.ID, Version: raw.Version}
if len(raw.Data) == 0 {
return nil
}
const majorByteString = 2
if major := raw.Data[0] >> 5; major != majorByteString {
return fmt.Errorf("extension %q: data is CBOR major type %d, not a byte string: %w", raw.ID, major, datekeys.ErrNonCanonicalCBOR)
}
var data []byte
if err := codec.Unmarshal(raw.Data, &data); err != nil {
return fmt.Errorf("extension %q: data: %w", raw.ID, err)
}
if len(data) == 0 {
return fmt.Errorf("extension %q: data is present but empty; an extension without data omits key 2: %w", raw.ID, datekeys.ErrNonCanonicalCBOR)
}
w.Data = data
return nil
}
// Registry tells which critical extensions the application implements. A nil
// Registry knows none, which is the state of the base protocol V1.
// Registry tells which extensions the application implements. A nil Registry
// knows none, which is the state of the base protocol V1.
type Registry interface {
Known(id string, version uint64) bool
}
// Set is a simple Registry.
// DataValidator is an optional interface of a Registry. ValidateData reports
// whether the data of e (nil when e carries none) follows the registered
// schema of (e.ID, e.Version) (spec §72). It is called only for extensions
// the Registry knows.
type DataValidator interface {
ValidateData(e Extension) error
}
// Set is a simple Registry. It does not validate data.
type Set map[string][]uint64
// Known reports whether (id, version) is in the set.
func (s Set) Known(id string, version uint64) bool { return slices.Contains(s[id], version) }
func compare(a, b Extension) int {
if c := bytes.Compare([]byte(a.ID), []byte(b.ID)); c != 0 {
return c
}
return cmp.Compare(a.Version, b.Version)
}
// compare orders extensions by the UTF-8 bytes of extension_id, the canonical
// order; within one object an identifier appears at most once.
func compare(a, b Extension) int { return strings.Compare(a.ID, b.ID) }
func validate(e Extension) error {
if e.ID == "" || len(e.ID) > MaxIDLen || !utf8.ValidString(e.ID) {
return fmt.Errorf("extension: invalid extension_id %q: %w", e.ID, datekeys.ErrNonCanonicalCBOR)
}
if e.Data != nil {
if err := codec.Valid(e.Data); err != nil {
return fmt.Errorf("extension %s: data: %w", e.ID, err)
}
if e.Version > MaxVersion {
return fmt.Errorf("extension %s: extension_version %d exceeds %d: %w", e.ID, e.Version, uint64(MaxVersion), datekeys.ErrNonCanonicalCBOR)
}
if e.Data != nil && (len(e.Data) == 0 || len(e.Data) > MaxDataLen) {
return fmt.Errorf("extension %s: data of %d bytes outside 1..%d: %w", e.ID, len(e.Data), MaxDataLen, datekeys.ErrNonCanonicalCBOR)
}
return nil
}
// Encode validates one extension array and returns its canonical wire form,
// sorted by extension_id bytes and then version. An empty input yields nil,
// so that the array key is omitted (spec §58.1).
// sorted by the UTF-8 bytes of extension_id. An empty input yields nil, so
// that the array key is omitted (spec §58.1).
func Encode(exts []Extension) ([]Wire, error) {
if len(exts) == 0 {
return nil, nil
}
if len(exts) > MaxExtensions {
return nil, fmt.Errorf("extension: %d extensions in one array, at most %d: %w", len(exts), MaxExtensions, datekeys.ErrNonCanonicalCBOR)
}
sorted := slices.Clone(exts)
slices.SortFunc(sorted, compare)
out := make([]Wire, 0, len(sorted))
@ -103,27 +169,33 @@ func Encode(exts []Extension) ([]Wire, error) {
return out, nil
}
// Decode validates one decoded extension array: canonical order, no repeated
// identifier and canonical data.
// Decode validates one decoded extension array: at most 64 entries, each one
// valid, in canonical order and with no repeated identifier. The data is
// copied, never decoded.
func Decode(ws []Wire) ([]Extension, error) {
if len(ws) == 0 {
return nil, nil
}
if len(ws) > MaxExtensions {
return nil, fmt.Errorf("extension: %d extensions in one array, at most %d: %w", len(ws), MaxExtensions, datekeys.ErrNonCanonicalCBOR)
}
out := make([]Extension, 0, len(ws))
for i, w := range ws {
e := Extension{ID: w.ID, Version: w.Version}
if w.Data != nil {
if len(w.Data) == 0 {
return nil, fmt.Errorf("extension %q: data is present but empty: %w", w.ID, datekeys.ErrNonCanonicalCBOR)
}
e.Data = bytes.Clone(w.Data)
}
if err := validate(e); err != nil {
return nil, err
}
if i > 0 {
prev := out[i-1]
if prev.ID == e.ID {
switch c := compare(out[i-1], e); {
case c == 0:
return nil, fmt.Errorf("extension %s: appears more than once: %w", e.ID, datekeys.ErrNonCanonicalCBOR)
}
if compare(prev, e) > 0 {
case c > 0:
return nil, fmt.Errorf("extension %s: array is not in canonical order: %w", e.ID, datekeys.ErrNonCanonicalCBOR)
}
}
@ -134,25 +206,88 @@ func Decode(ws []Wire) ([]Extension, error) {
// CheckDisjoint applies the cross-array rule of one object: an extension_id
// must not appear in both critical_extensions and noncritical_extensions
// (spec §31, §54).
// (spec §31, §54). Arrays in canonical order, as Decode returns them, are
// merged in one linear pass; other input is sorted first.
func CheckDisjoint(critical, noncritical []Extension) error {
for _, c := range critical {
for _, n := range noncritical {
if c.ID == n.ID {
return fmt.Errorf("extension %s: both critical and noncritical: %w", c.ID, datekeys.ErrNonCanonicalCBOR)
}
critical, noncritical = canonical(critical), canonical(noncritical)
for i, j := 0, 0; i < len(critical) && j < len(noncritical); {
switch c := compare(critical[i], noncritical[j]); {
case c == 0:
return fmt.Errorf("extension %s: both critical and noncritical: %w", critical[i].ID, datekeys.ErrNonCanonicalCBOR)
case c < 0:
i++
default:
j++
}
}
return nil
}
// CheckCritical rejects every critical extension unknown to reg (spec §54,
// §70). Unknown noncritical extensions may be ignored and are not checked.
// canonical returns exts itself when it is in canonical order, and a sorted
// copy otherwise.
func canonical(exts []Extension) []Extension {
if slices.IsSortedFunc(exts, compare) {
return exts
}
sorted := slices.Clone(exts)
slices.SortFunc(sorted, compare)
return sorted
}
// CheckCritical rejects every critical extension unknown to reg with
// ErrExtensionCriticalUnknown and, when reg is a DataValidator, every known
// one whose data it rejects with ErrExtensionDataInvalid (spec §54, §70).
// An unknown extension takes precedence over invalid data.
func CheckCritical(critical []Extension, reg Registry) error {
for _, c := range critical {
if reg == nil || !reg.Known(c.ID, c.Version) {
return fmt.Errorf("extension %s v%d: %w", c.ID, c.Version, datekeys.ErrExtensionCriticalUnknown)
}
}
for _, c := range critical {
if err := validateData(c, reg); err != nil {
return err
}
}
return nil
}
// Unusable is a known noncritical extension whose data does not follow its
// registered schema. The object that carries it stays valid; the application
// must not use the extension, and the caller is told (spec §54).
type Unusable struct {
ID string
Version uint64
Err error // wraps datekeys.ErrExtensionDataInvalid
}
// CheckNoncritical returns the noncritical extensions that reg knows and whose
// data it rejects. It never fails the object: unknown noncritical extensions
// are ignored, and a Registry that is not a DataValidator rejects no data
// (spec §54).
func CheckNoncritical(noncritical []Extension, reg Registry) []Unusable {
var out []Unusable
for _, n := range noncritical {
if reg == nil || !reg.Known(n.ID, n.Version) {
continue
}
if err := validateData(n, reg); err != nil {
out = append(out, Unusable{ID: n.ID, Version: n.Version, Err: err})
}
}
return out
}
// validateData applies the optional DataValidator of reg to a known
// extension. The validator's own error is kept as text only, so that the
// result carries exactly one normative code.
func validateData(e Extension, reg Registry) error {
v, ok := reg.(DataValidator)
if !ok {
return nil
}
if err := v.ValidateData(e); err != nil {
return fmt.Errorf("extension %s v%d: data: %v: %w", e.ID, e.Version, err, datekeys.ErrExtensionDataInvalid)
}
return nil
}

@ -1,14 +1,21 @@
package extension_test
import (
"bytes"
"encoding/hex"
"errors"
"fmt"
"slices"
"strings"
"testing"
"time"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/codec"
"g.activething.com/go/DateKeys/extension"
)
func ext(t *testing.T, id string, v uint64, data any) extension.Extension {
func ext(t *testing.T, id string, v uint64, data []byte) extension.Extension {
t.Helper()
if data == nil {
return extension.Extension{ID: id, Version: v}
@ -20,8 +27,41 @@ func ext(t *testing.T, id string, v uint64, data any) extension.Extension {
return e
}
func TestNew(t *testing.T) {
data := []byte("public label")
e, err := extension.New("org.example.label", 1, data)
if err != nil || e.ID != "org.example.label" || e.Version != 1 || !bytes.Equal(e.Data, data) {
t.Fatalf("%+v %v", e, err)
}
data[0] = 'P'
if e.Data[0] != 'p' {
t.Fatal("New does not copy data")
}
for name, tc := range map[string]struct {
id string
version uint64
data []byte
}{
// Spec §54, §58.1: an extension without data omits key 2; it is built
// as a literal, never through New.
"nil data": {"org.a", 1, nil},
"empty data": {"org.a", 1, []byte{}},
"empty id": {"", 1, []byte{1}},
"invalid UTF-8 id": {"org.\xff", 1, []byte{1}},
"id too long": {strings.Repeat("a", extension.MaxIDLen+1), 1, []byte{1}},
"version above 2^32": {"org.a", extension.MaxVersion + 1, []byte{1}},
} {
if _, err := extension.New(tc.id, tc.version, tc.data); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Errorf("%s: %v", name, err)
}
}
if _, err := extension.New("org.a", extension.MaxVersion, []byte{0}); err != nil {
t.Fatalf("version 2^32-1 rejected: %v", err)
}
}
func TestEncodeSortsCanonically(t *testing.T) {
in := []extension.Extension{ext(t, "org.b", 1, nil), ext(t, "org.a", 2, "x"), ext(t, "Z", 9, nil), ext(t, "org.aa", 1, 7)}
in := []extension.Extension{ext(t, "org.b", 1, nil), ext(t, "org.a", 2, []byte("x")), ext(t, "Z", 9, nil), ext(t, "org.aa", 1, []byte{7})}
w, err := extension.Encode(in)
if err != nil {
t.Fatal(err)
@ -31,52 +71,190 @@ func TestEncodeSortsCanonically(t *testing.T) {
order = append(order, e.ID)
}
// Bytewise UTF-8 order: uppercase before lowercase, prefixes first.
if got := []string{"Z", "org.a", "org.aa", "org.b"}; !equal(order, got) {
if got := []string{"Z", "org.a", "org.aa", "org.b"}; !slices.Equal(order, got) {
t.Fatalf("order %v, want %v", order, got)
}
if w, _ := extension.Encode(nil); w != nil {
t.Fatal("empty array must encode to nil so that the key is omitted")
}
back, err := extension.Decode(w)
if err != nil || len(back) != 4 || back[1].ID != "org.a" || string(back[1].Data) != "\x61\x78" {
if err != nil || len(back) != 4 || back[1].ID != "org.a" || string(back[1].Data) != "x" || back[0].Data != nil {
t.Fatalf("decode: %+v %v", back, err)
}
// The wire form: data is a byte string, and key 2 is omitted without data.
b, err := codec.Marshal(w[:2])
if err != nil {
t.Fatal(err)
}
if got, want := hex.EncodeToString(b), "82"+"a200615a0109"+"a300656f72672e6101020241"+"78"; got != want {
t.Fatalf("wire %s, want %s", got, want)
}
}
func many(n int) []extension.Extension {
out := make([]extension.Extension, n)
for i := range out {
out[i] = extension.Extension{ID: fmt.Sprintf("org.example.%03d", i), Version: 1}
}
return out
}
func TestEncodeRejects(t *testing.T) {
for name, in := range map[string][]extension.Extension{
"same id twice": {ext(t, "org.a", 1, nil), ext(t, "org.a", 2, nil)},
"empty id": {ext(t, "", 1, nil)},
"invalid UTF-8 id": {{ID: "org.\xff", Version: 1}},
"non-canonical data": {{ID: "org.a", Version: 1, Data: []byte{0x18, 0x01}}},
"data with two items": {{ID: "org.a", Version: 1, Data: []byte{0x01, 0x02}}},
"data with a tag": {{ID: "org.a", Version: 1, Data: []byte{0xc1, 0x01}}},
"same id twice": {ext(t, "org.a", 1, nil), ext(t, "org.a", 2, nil)},
"empty id": {{ID: "", Version: 1}},
"invalid UTF-8 id": {{ID: "org.\xff", Version: 1}},
"present but empty": {{ID: "org.a", Version: 1, Data: []byte{}}},
"version above 2^32": {{ID: "org.a", Version: extension.MaxVersion + 1}},
"65 extensions (§64)": many(extension.MaxExtensions + 1),
"duplicate among many": append(many(3), extension.Extension{ID: "org.example.001", Version: 2}),
} {
if _, err := extension.Encode(in); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Errorf("%s: %v", name, err)
}
}
if w, err := extension.Encode(many(extension.MaxExtensions)); err != nil || len(w) != extension.MaxExtensions {
t.Fatalf("64 extensions rejected: %v", err)
}
}
func wires(exts []extension.Extension) []extension.Wire {
out := make([]extension.Wire, len(exts))
for i, e := range exts {
out[i] = extension.Wire{ID: e.ID, Version: e.Version, Data: e.Data}
}
return out
}
func TestDecodeRejects(t *testing.T) {
for name, in := range map[string][]extension.Wire{
"out of order": {{ID: "org.b", Version: 1}, {ID: "org.a", Version: 1}},
"versions out of order": {{ID: "org.a", Version: 2}, {ID: "org.a", Version: 1}},
"repeated id": {{ID: "org.a", Version: 1}, {ID: "org.a", Version: 2}},
"non-canonical data": {{ID: "org.a", Version: 1, Data: []byte{0xf9, 0x3c, 0x00, 0x00}}},
"out of order": {{ID: "org.b", Version: 1}, {ID: "org.a", Version: 1}},
"repeated id": {{ID: "org.a", Version: 1}, {ID: "org.a", Version: 2}},
"present but empty": {{ID: "org.a", Version: 1, Data: []byte{}}},
"version above 2^32": {{ID: "org.a", Version: extension.MaxVersion + 1}},
"empty id": {{ID: "", Version: 1}},
"65 extensions": wires(many(extension.MaxExtensions + 1)),
} {
if _, err := extension.Decode(in); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Errorf("%s: %v", name, err)
}
}
if got, err := extension.Decode(wires(many(extension.MaxExtensions))); err != nil || len(got) != extension.MaxExtensions {
t.Fatalf("64 extensions rejected: %v", err)
}
}
// Spec §54: key 2 is absent or a non-empty byte string, whatever it contains.
// Every other form is rejected by the extension map itself.
func TestWireData(t *testing.T) {
const head = "a3006161" + "0101" + "02" // {0: "a", 1: 1, 2: ...}
valid := []struct{ name, item, data string }{
{"one byte", "4100", "00"},
{"bytes that are not CBOR", "44ff1c00f7", "ff1c00f7"},
{"CBOR that the base protocol never decodes", "49a2f97e0000f97e0001", "a2f97e0000f97e0001"},
{"14 nested arrays", "4f" + strings.Repeat("81", 14) + "00", strings.Repeat("81", 14) + "00"},
{"24 bytes, one-byte length", "5818" + strings.Repeat("ab", 24), strings.Repeat("ab", 24)},
}
for _, tc := range valid {
var w extension.Wire
b, _ := hex.DecodeString(head + tc.item)
if err := codec.Unmarshal(b, &w); err != nil || hex.EncodeToString(w.Data) != tc.data {
t.Errorf("%s: %x %v", tc.name, w.Data, err)
}
}
var none extension.Wire
if err := codec.Unmarshal([]byte{0xa2, 0x00, 0x61, 0x61, 0x01, 0x01}, &none); err != nil || none.Data != nil || none.ID != "a" {
t.Fatalf("extension without data: %+v %v", none, err)
}
for _, tc := range []struct{ name, item string }{
{"empty byte string h''", "40"},
{"text string", "6161"},
{"unsigned integer", "01"},
{"negative integer", "20"},
{"array", "820102"},
{"empty array", "80"},
{"map", "a10001"},
{"true", "f5"},
{"null", "f6"},
{"undefined", "f7"},
{"float", "f93c00"},
{"tag", "c24101"},
{"length not in shortest form", "5801" + "00"},
{"indefinite-length byte string", "5f4100ff"},
{"truncated byte string", "42" + "00"},
} {
var w extension.Wire
b, _ := hex.DecodeString(head + tc.item)
if err := codec.Unmarshal(b, &w); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Errorf("%s: %x %v", tc.name, w.Data, err)
}
}
// The same rule inside an array, as the containing objects decode it.
var arr []extension.Wire
b, _ := hex.DecodeString("81" + head + "40")
if err := codec.Unmarshal(b, &arr); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Fatalf("h'' in an array: %v", err)
}
}
func TestCrossArrayRules(t *testing.T) {
func TestCheckDisjoint(t *testing.T) {
crit := []extension.Extension{ext(t, "org.a", 1, nil)}
non := []extension.Extension{ext(t, "org.a", 2, nil)}
if err := extension.CheckDisjoint(crit, non); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Fatalf("id in both arrays: %v", err)
}
a := []extension.Extension{{ID: "a"}, {ID: "c"}, {ID: "e"}}
b := []extension.Extension{{ID: "b"}, {ID: "d"}, {ID: "f"}}
if err := extension.CheckDisjoint(a, b); err != nil {
t.Fatal(err)
}
// Input that is not in canonical order is sorted before the merge.
unsorted := []extension.Extension{{ID: "z"}, {ID: "e"}}
if err := extension.CheckDisjoint(a, unsorted); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Fatalf("unsorted input: %v", err)
}
if !slices.IsSortedFunc(unsorted, func(x, y extension.Extension) int { return strings.Compare(y.ID, x.ID) }) {
t.Fatal("CheckDisjoint reordered its input")
}
if err := extension.CheckDisjoint(nil, b); err != nil {
t.Fatal(err)
}
}
// The merge is linear: 200 000 + 200 000 identifiers take milliseconds, where
// the former pairwise comparison took hours (spec §76, case 6).
func TestCheckDisjointIsLinear(t *testing.T) {
const n = 200_000
crit, non := make([]extension.Extension, n), make([]extension.Extension, n)
for i := range n {
crit[i].ID = fmt.Sprintf("a.%07d", i)
non[i].ID = fmt.Sprintf("b.%07d", i)
}
start := time.Now()
if err := extension.CheckDisjoint(crit, non); err != nil {
t.Fatal(err)
}
if d := time.Since(start); d > 5*time.Second {
t.Fatalf("CheckDisjoint took %s", d)
}
}
// validator knows org.a v1 and org.b v1, and accepts only the data "ok".
type validator struct{}
func (validator) Known(id string, v uint64) bool { return (id == "org.a" || id == "org.b") && v == 1 }
func (validator) ValidateData(e extension.Extension) error {
if string(e.Data) != "ok" {
// A validator may report a normative code of its own; the result
// carries ErrExtensionDataInvalid only.
return fmt.Errorf("want \"ok\": %w", datekeys.ErrNonCanonicalCBOR)
}
return nil
}
func TestCheckCritical(t *testing.T) {
crit := []extension.Extension{ext(t, "org.a", 1, nil)}
if err := extension.CheckCritical(crit, nil); !errors.Is(err, datekeys.ErrExtensionCriticalUnknown) {
t.Fatalf("unknown critical accepted by the base protocol: %v", err)
}
@ -89,16 +267,34 @@ func TestCrossArrayRules(t *testing.T) {
if err := extension.CheckCritical(nil, nil); err != nil {
t.Fatal(err)
}
// Spec §54: a known critical extension with invalid data.
good, bad := ext(t, "org.a", 1, []byte("ok")), ext(t, "org.b", 1, []byte("ko"))
if err := extension.CheckCritical([]extension.Extension{good}, validator{}); err != nil {
t.Fatal(err)
}
err := extension.CheckCritical([]extension.Extension{good, bad}, validator{})
if datekeys.Code(err) != "ERR_EXTENSION_DATA_INVALID" || errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Fatalf("invalid data: %v", err)
}
// An unknown critical extension takes precedence over invalid data.
unknown := ext(t, "org.c", 1, nil)
if err := extension.CheckCritical([]extension.Extension{bad, unknown}, validator{}); !errors.Is(err, datekeys.ErrExtensionCriticalUnknown) {
t.Fatalf("unknown and invalid: %v", err)
}
}
func equal(a, b []string) bool {
if len(a) != len(b) {
return false
func TestCheckNoncritical(t *testing.T) {
good, bad, unknown := ext(t, "org.a", 1, []byte("ok")), ext(t, "org.b", 1, nil), ext(t, "org.c", 1, []byte("ko"))
all := []extension.Extension{good, bad, unknown}
if u := extension.CheckNoncritical(all, nil); u != nil {
t.Fatalf("base protocol: %v", u)
}
for i := range a {
if a[i] != b[i] {
return false
}
if u := extension.CheckNoncritical(all, extension.Set{"org.b": {1}}); u != nil {
t.Fatalf("a Set does not validate data: %v", u)
}
u := extension.CheckNoncritical(all, validator{})
if len(u) != 1 || u[0].ID != "org.b" || u[0].Version != 1 || !errors.Is(u[0].Err, datekeys.ErrExtensionDataInvalid) {
t.Fatalf("unusable: %+v", u)
}
return true
}

@ -60,7 +60,7 @@ type FixtureExt struct {
Critical bool `json:"critical"`
ID string `json:"id"`
Version uint64 `json:"version"`
Data string `json:"data,omitempty"` // hex of the CBOR data item
Data string `json:"data,omitempty"` // hex of the exact data bytes; absent without data
}
// DKKFixture holds the expected values of an official .dkk fixture (spec §68).

@ -5,10 +5,15 @@
// then committed: age randomness cannot be injected through its public API,
// so they are decryption and validation fixtures, not byte-reproducible
// encryption outputs (spec §67). Existing fixtures are never overwritten
// unless -force is given; vectors are always regenerated, and the tests fail
// if the implementation stops reproducing the committed ones.
// unless -force (every fixture) or -only (the named ones) is given; vectors
// are always regenerated, and the tests fail if the implementation stops
// reproducing the committed ones.
//
// The .dkk with an extension (spec §68) is derived from the portable .dkk of
// time_and_key_portable, so it is regenerated whenever its source is.
//
// go run ./internal/testkit/genfixtures -out testdata
// go run ./internal/testkit/genfixtures -out testdata -only time_only_extensions
package main
import (
@ -18,6 +23,7 @@ import (
"encoding/hex"
"flag"
"fmt"
"io"
"log"
"os"
"path/filepath"
@ -37,16 +43,55 @@ import (
func main() {
out := flag.String("out", "testdata", "output directory")
force := flag.Bool("force", false, "overwrite existing fixtures")
force := flag.Bool("force", false, "overwrite every existing fixture")
only := flag.String("only", "", "comma-separated fixture names to regenerate, overwriting them; every other fixture is left untouched")
flag.Parse()
sel, err := selection(*force, *only)
if err != nil {
log.Fatal(err)
}
if err := vectors(filepath.Join(*out, "vectors")); err != nil {
log.Fatal(err)
}
if err := fixtures(filepath.Join(*out, "fixtures"), *force); err != nil {
if err := fixtures(filepath.Join(*out, "fixtures"), sel); err != nil {
log.Fatal(err)
}
}
// selector decides which fixtures are (re)generated.
type selector struct {
force bool // overwrite every fixture
only map[string]bool // when non-empty, regenerate exactly these
}
func selection(force bool, only string) (selector, error) {
sel := selector{force: force, only: map[string]bool{}}
if only == "" {
return sel, nil
}
known := map[string]bool{extDKK: true}
for _, s := range specs() {
known[s.name] = true
}
for _, name := range strings.Split(only, ",") {
if !known[name] {
return sel, fmt.Errorf("-only: unknown fixture %q", name)
}
sel.only[name] = true
}
return sel, nil
}
// generate reports whether the fixture name, whose main file is path, is
// written.
func (s selector) generate(name, path string) bool {
if len(s.only) > 0 {
return s.only[name]
}
_, err := os.Stat(path)
return s.force || err != nil
}
func vectors(dir string) error {
pv, err := testkit.QuicknetProfileVector()
if err != nil {
@ -72,40 +117,140 @@ type spec struct {
controlExt []extension.Extension
}
func fixtures(dir string, force bool) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
large := []byte(strings.Repeat("DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk.\n", 1000))
hExt, err := extension.New("org.example.label", 1, "public label")
// Extension data of the fixtures (spec §54, §72): the header carries the raw
// UTF-8 bytes of a label, which are not CBOR; the control carries
// {0: 7, 1: "sealed"} in the CBOR profile of spec §58; the .dkk carries
// {0: "hand"}. The base protocol decodes none of them.
var (
headerExtData = []byte("public label")
controlExtData = mustHex("a2000701667365616c6564")
dkkExtData = mustHex("a1006468616e64")
)
func mustHex(s string) []byte {
b, err := hex.DecodeString(s)
if err != nil {
return err
panic(err)
}
cExt, err := extension.New("org.example.note", 2, map[string]any{"sealed": true, "n": 7})
return b
}
func mustExt(id string, version uint64, data []byte) extension.Extension {
e, err := extension.New(id, version, data)
if err != nil {
return err
panic(err)
}
specs := []spec{
return e
}
func specs() []spec {
large := []byte(strings.Repeat("DateKeys fixture: this plaintext spans more than one 64 KiB age STREAM chunk.\n", 1000))
hExt := mustExt("org.example.label", 1, headerExtData)
cExt := mustExt("org.example.note", 2, controlExtData)
return []spec{
{name: "time_only", description: "time_only capsule, two STREAM chunks, no extensions", round: 1000, policy: capsule.TimeOnly, plaintext: large},
{name: "time_only_extensions", description: "time_only capsule with a noncritical PUBLIC_HEADER extension and a noncritical CONTROL_CBOR extension", round: 2000, policy: capsule.TimeOnly, plaintext: []byte("DateKeys fixture with extensions.\n"), headerExt: []extension.Extension{hExt}, controlExt: []extension.Extension{cExt}},
{name: "time_and_key_portable", description: "time_and_key capsule whose only recipient is a portable .dkk", round: 1000, policy: capsule.TimeAndKey, portable: true, plaintext: []byte("DateKeys fixture opened with a portable .dkk.\n")},
{name: "time_and_key_recipients", description: "time_and_key capsule for two known X25519 recipients and a portable .dkk", round: 1001, policy: capsule.TimeAndKey, recipients: 2, portable: true, plaintext: []byte("DateKeys fixture for several recipients.\n")},
{name: "empty_payload", description: "time_only capsule with an empty payload", round: 1001, policy: capsule.TimeOnly, plaintext: []byte{}},
}
for _, s := range specs {
}
func fixtures(dir string, sel selector) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
regenerated := map[string]bool{}
for _, s := range specs() {
path := filepath.Join(dir, s.name+".dkc")
if _, err := os.Stat(path); err == nil && !force {
log.Printf("keeping existing %s", path)
if !sel.generate(s.name, path) {
log.Printf("leaving %s untouched", path)
continue
}
if err := generate(dir, s); err != nil {
return fmt.Errorf("%s: %w", s.name, err)
}
regenerated[s.name] = true
log.Printf("generated %s", path)
}
path := filepath.Join(dir, extDKK+".dkk")
if !sel.generate(extDKK, path) && !regenerated[extDKKSource] {
log.Printf("leaving %s untouched", path)
return nil
}
if err := deriveDKK(dir); err != nil {
return fmt.Errorf("%s: %w", extDKK, err)
}
log.Printf("generated %s", path)
return nil
}
// extDKK is the .dkk vector with an extension (spec §68): the portable
// credential of extDKKSource re-issued with a noncritical extension. It keeps
// the credential_id, the key and the capsule_digest, so its bytes are a
// function of the source .dkk.
const (
extDKK = "time_and_key_portable_extension"
extDKKSource = "time_and_key_portable"
)
func deriveDKK(dir string) error {
src, err := os.ReadFile(filepath.Join(dir, extDKKSource+".dkk"))
if err != nil {
return err
}
k, err := accesskey.Decode(bytes.NewReader(src))
if err != nil {
return err
}
k.Noncritical = []extension.Extension{mustExt("org.example.delivery", 1, dkkExtData)}
var kb bytes.Buffer
if err := accesskey.Encode(&kb, k); err != nil {
return err
}
back, err := accesskey.Decode(bytes.NewReader(kb.Bytes()))
if err != nil {
return err
}
// The credential must open its capsule through the public API.
dkcFile := extDKKSource + ".dkc"
dkc, err := os.ReadFile(filepath.Join(dir, dkcFile))
if err != nil {
return err
}
reg := testkit.Registry()
in, err := capsule.Inspect(bytes.NewReader(dkc), capsule.InspectOptions{Registry: reg})
if err != nil {
return err
}
oo := capsule.OpenOptions{Registry: reg, Source: testkit.NewSource(testkit.Release(in.Header.DateKey.Round)),
AccessKey: back, Now: testkit.Fixed(in.UnlockAt)}
if _, err := capsule.Open(context.Background(), io.Discard, bytes.NewReader(dkc), oo); err != nil {
return fmt.Errorf("the .dkk does not open %s: %w", dkcFile, err)
}
ksum := sha256.Sum256(kb.Bytes())
kf := testkit.DKKFixture{
Description: "portable X25519 .dkk of " + dkcFile + " with a noncritical extension: the credential of " + extDKKSource + ".dkk re-issued with org.example.delivery",
Spec: testkit.SpecVersion,
File: extDKK + ".dkk",
SHA256: hex.EncodeToString(ksum[:]),
CredentialID: hex.EncodeToString(back.CredentialID[:]),
CapsuleID: hex.EncodeToString(back.CapsuleID[:]),
AccessType: back.Type,
Material: hex.EncodeToString(back.Material),
CapsuleDigest: hex.EncodeToString(back.Verification.CapsuleDigest),
Extensions: exts(false, back.Noncritical),
Capsule: dkcFile,
ExpectedResult: "opens INNER_ACCESS_AGE of " + dkcFile + " and yields its CONTROL_CBOR",
}
if err := os.WriteFile(filepath.Join(dir, kf.File), kb.Bytes(), 0o644); err != nil {
return err
}
return testkit.WriteJSON(filepath.Join(dir, extDKK+".dkk.json"), kf)
}
func generate(dir string, s spec) error {
p := profile.Quicknet()
reg := testkit.Registry()

@ -13,7 +13,7 @@ import (
)
// SpecVersion is the specification the vectors and fixtures implement.
const SpecVersion = "0.8.1"
const SpecVersion = "0.8.2"
// RoundVector is one Quicknet resolution vector (spec §65).
type RoundVector struct {

@ -63,8 +63,8 @@ type wire struct {
Network string `cbor:"4,keyasint"`
ChainHash []byte `cbor:"5,keyasint"`
PublicKey []byte `cbor:"6,keyasint"`
Period uint64 `cbor:"7,keyasint"`
GenesisTime int64 `cbor:"8,keyasint"`
Period uint64 `cbor:"7,keyasint"` // 1..2^53-1
GenesisTime uint64 `cbor:"8,keyasint"` // 0..2^53-1
Scheme string `cbor:"9,keyasint"`
GenesisSeed []byte `cbor:"10,keyasint"`
}
@ -79,7 +79,12 @@ func (p *Profile) Clone() *Profile {
// CanonicalCBOR returns the exact Deterministic CBOR bytes of spec §11.
func (p *Profile) CanonicalCBOR() ([]byte, error) {
if p.Period <= 0 || p.Period%time.Second != 0 {
return nil, fmt.Errorf("profile: period %s is not a positive whole number of seconds", p.Period)
return nil, fmt.Errorf("profile: period %s is not a positive whole number of seconds: %w", p.Period, datekeys.ErrNonCanonicalCBOR)
}
// Spec §11: genesis_time in 0..2^53-1. The period needs no such check:
// a time.Duration holds at most about 9.2e9 seconds.
if p.GenesisTime < 0 || p.GenesisTime > codec.MaxSafeUint {
return nil, fmt.Errorf("profile: genesis time %d outside 0..%d: %w", p.GenesisTime, int64(codec.MaxSafeUint), datekeys.ErrNonCanonicalCBOR)
}
return codec.Marshal(wire{
Type: TypeTag,
@ -90,7 +95,7 @@ func (p *Profile) CanonicalCBOR() ([]byte, error) {
ChainHash: p.ChainHash[:],
PublicKey: p.PublicKey,
Period: uint64(p.Period / time.Second),
GenesisTime: p.GenesisTime,
GenesisTime: uint64(p.GenesisTime),
Scheme: p.Scheme,
GenesisSeed: p.GenesisSeed[:],
})
@ -122,7 +127,12 @@ func Decode(b []byte) (*Profile, error) {
if len(w.ChainHash) != 32 || len(w.GenesisSeed) != 32 {
return nil, fmt.Errorf("profile: chain hash and genesis seed must be 32 bytes: %w", datekeys.ErrNonCanonicalCBOR)
}
if w.Period == 0 || w.Period > uint64(maxPeriod/time.Second) {
// Spec §11: period in 1..2^53-1 and genesis_time in 0..2^53-1. A
// negative genesis_time already failed to decode.
if w.Period == 0 || w.Period > codec.MaxSafeUint || w.GenesisTime > codec.MaxSafeUint {
return nil, fmt.Errorf("profile: period %d or genesis time %d outside the schema: %w", w.Period, w.GenesisTime, datekeys.ErrNonCanonicalCBOR)
}
if w.Period > uint64(maxPeriod/time.Second) {
return nil, fmt.Errorf("profile: period %d s out of range: %w", w.Period, datekeys.ErrNonCanonicalCBOR)
}
p := &Profile{
@ -131,7 +141,7 @@ func Decode(b []byte) (*Profile, error) {
Network: w.Network,
PublicKey: w.PublicKey,
Period: time.Duration(w.Period) * time.Second,
GenesisTime: w.GenesisTime,
GenesisTime: int64(w.GenesisTime),
Scheme: w.Scheme,
}
copy(p.ChainHash[:], w.ChainHash)

@ -93,6 +93,48 @@ func TestDecodeRoundTrip(t *testing.T) {
}
}
// Spec §11, §58: period in 1..2^53-1 and genesis_time in 0..2^53-1, both
// unsigned. Out of the schema is ErrNonCanonicalCBOR, whatever Validate would
// say of the value.
func TestIntegerRanges(t *testing.T) {
b, _ := profile.Quicknet().CanonicalCBOR()
var m map[uint64]any
if err := codec.Unmarshal(b, &m); err != nil {
t.Fatal(err)
}
for name, v := range map[string]struct {
key uint64
val any
}{
"negative genesis_time": {8, int64(-1)},
"genesis_time 2^53": {8, uint64(codec.MaxSafeUint + 1)},
"period 2^53": {7, uint64(codec.MaxSafeUint + 1)},
"period 0": {7, uint64(0)},
"period above one day": {7, uint64(86401)},
} {
c := map[uint64]any{}
for k, x := range m {
c[k] = x
}
c[v.key] = v.val
in, err := codec.Marshal(c)
if err != nil {
t.Fatal(err)
}
if _, err := profile.Decode(in); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Errorf("%s: %v", name, err)
}
}
// The encoder refuses the same values with the same code.
for _, g := range []int64{-1, codec.MaxSafeUint + 1} {
p := profile.Quicknet()
p.GenesisTime = g
if _, err := p.CanonicalCBOR(); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Errorf("genesis time %d encoded: %v", g, err)
}
}
}
func TestValidateRejectsTamperedProfiles(t *testing.T) {
for _, tc := range []struct {
name string
@ -112,6 +154,11 @@ func TestValidateRejectsTamperedProfiles(t *testing.T) {
{"sub-second period", func(p *profile.Profile) { p.Period = 1500 * time.Millisecond }, datekeys.ErrUnknownProfile},
{"uppercase profile id", func(p *profile.Profile) { p.ID = "DateKeys:quicknet:v1" }, datekeys.ErrUnknownProfile},
{"profile id with quote", func(p *profile.Profile) { p.ID = `datekeys:"quicknet` }, datekeys.ErrUnknownProfile},
// Spec §57: the Provider Profile names and public key outside their
// CDDL rules are ERR_UNKNOWN_PROFILE.
{"uppercase provider", func(p *profile.Profile) { p.Provider = "Drand" }, datekeys.ErrUnknownProfile},
{"empty public key", func(p *profile.Profile) { p.PublicKey = []byte{} }, datekeys.ErrUnknownProfile},
{"public key above 1024 bytes", func(p *profile.Profile) { p.PublicKey = make([]byte, 1025) }, datekeys.ErrUnknownProfile},
} {
t.Run(tc.name, func(t *testing.T) {
p := profile.Quicknet()

@ -12,7 +12,7 @@ if [[ -n "${FUZZ_PARALLEL:-}" ]]; then
parallel+=(-parallel "$FUZZ_PARALLEL")
fi
targets=(
"./codec FuzzValid"
"./codec FuzzUnmarshal"
"./profile FuzzDecode"
"./datekey FuzzParse"
"./agewrap FuzzStanzas"
@ -21,6 +21,7 @@ targets=(
"./capsule FuzzDecodeHeader"
"./capsule FuzzDecodeControl"
"./capsule FuzzInspect"
"./capsule FuzzEncodeImpliesDecode"
)
for t in "${targets[@]}"; do
read -r pkg name <<<"$t"

@ -1,6 +1,6 @@
{
"description": "time_only capsule with an empty payload",
"spec": "0.8.1",
"spec": "0.8.2",
"file": "empty_payload.dkc",
"sha256": "871e9bf05b52bbae17f3adfbbf97b46e7f0e53aa8f57bcaa506e43f36f53a9d4",
"release": {

@ -1,6 +1,6 @@
{
"description": "portable X25519 .dkk of time_and_key_portable.dkc",
"spec": "0.8.1",
"spec": "0.8.2",
"file": "time_and_key_portable.dkk",
"sha256": "e528fa2c832c91119f0684bb9d6fb3c4c2d0d55183482890e7c4fe92f668426a",
"credential_id": "3955e944a3c60cfa1fd6485e9693c77d",

@ -1,6 +1,6 @@
{
"description": "time_and_key capsule whose only recipient is a portable .dkk",
"spec": "0.8.1",
"spec": "0.8.2",
"file": "time_and_key_portable.dkc",
"sha256": "2e97878078bae6358037a9c264f379a3cbe839f767d69836b0343f35657b2972",
"release": {

@ -0,0 +1,21 @@
{
"description": "portable X25519 .dkk of time_and_key_portable.dkc with a noncritical extension: the credential of time_and_key_portable.dkk re-issued with org.example.delivery",
"spec": "0.8.2",
"file": "time_and_key_portable_extension.dkk",
"sha256": "0bf463a7c65627b7dda2234d728df89ec5b835816a2a37b91497d8fecc5ea548",
"credential_id": "3955e944a3c60cfa1fd6485e9693c77d",
"capsule_id": "448e134a13457c319cab7fceaf7ffa1f",
"access_type": "x25519",
"access_material": "3d5e4d51f21236ecff40d89c0adcf9aa325a24b91b46fd9d1afa482f54b2d2c6",
"capsule_digest": "2e97878078bae6358037a9c264f379a3cbe839f767d69836b0343f35657b2972",
"extensions": [
{
"critical": false,
"id": "org.example.delivery",
"version": 1,
"data": "a1006468616e64"
}
],
"capsule": "time_and_key_portable.dkc",
"expected_result": "opens INNER_ACCESS_AGE of time_and_key_portable.dkc and yields its CONTROL_CBOR"
}

@ -1,6 +1,6 @@
{
"description": "portable X25519 .dkk of time_and_key_recipients.dkc",
"spec": "0.8.1",
"spec": "0.8.2",
"file": "time_and_key_recipients.dkk",
"sha256": "19f6c47150c3194712d454f43c7392b7344e6b4e7b074d83e9ca5f563a8e072f",
"credential_id": "b89292aedf6d05d584cec9a871ce8735",

@ -1,6 +1,6 @@
{
"description": "time_and_key capsule for two known X25519 recipients and a portable .dkk",
"spec": "0.8.1",
"spec": "0.8.2",
"file": "time_and_key_recipients.dkc",
"sha256": "69ac110380f5d768b5b6afaa157a50ed17d8ceccfbd4604ffa5b6da38539b635",
"release": {

@ -1,6 +1,6 @@
{
"description": "time_only capsule, two STREAM chunks, no extensions",
"spec": "0.8.1",
"spec": "0.8.2",
"file": "time_only.dkc",
"sha256": "99e915810d595f1092700b728f5e5081d78efe83f5343e76325b1bcc2c33ccf2",
"release": {

Binary file not shown.

@ -1,20 +1,20 @@
{
"description": "time_only capsule with a noncritical PUBLIC_HEADER extension and a noncritical CONTROL_CBOR extension",
"spec": "0.8.1",
"spec": "0.8.2",
"file": "time_only_extensions.dkc",
"sha256": "1e7effd58016d9447f9a2e7c9645c658604979c4e35af675d1dc6c4ae12ac444",
"sha256": "0446c9b73e267adcb24e5cc89afba2544a386ec9a050016e06517a4a57aa2085",
"release": {
"round": 2000,
"signature": "b6cb8f482a0b15d45936a4c4ea08e98a087e71787caee3f4d07a8a9843b1bc5423c6b3c22f446488b3137eaca799c77e"
},
"prelude": "444b4331010000000000009f000001e2",
"public_header": "a6006a646174656b657963617001010250d2fd9af55dc0253132fb36b8dc0d016b037853646b315f65794a325a584a7a61573975496a6f784c434a755a58523362334a72496a6f695a4746305a57746c65584d3663585670593274755a585136646a45694c434a79623356755a4349364d6a41774d483004000681a300716f72672e6578616d706c652e6c6162656c0101026c7075626c6963206c6162656c",
"public_header": "a6006a646174656b6579636170010102504286085c21ca34d1a71e649326a4a0f6037853646b315f65794a325a584a7a61573975496a6f784c434a755a58523362334a72496a6f695a4746305a57746c65584d3663585670593274755a585136646a45694c434a79623356755a4349364d6a41774d483004000681a300716f72672e6578616d706c652e6c6162656c0101024c7075626c6963206c6162656c",
"datekey": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MjAwMH0",
"capsule_id": "d2fd9af55dc0253132fb36b8dc0d016b",
"capsule_id": "4286085c21ca34d1a71e649326a4a0f6",
"access_policy": "time_only",
"structure": "time_only",
"unlock_at": "2023-08-23T16:49:24Z",
"header_binding": "a4fa1e3c5a47a01313173cdf23df157ff8c949011df95dde8f092ff6749dc154",
"header_binding": "cf647fac3ba7849b217d9bb358d66cb5b6f6722e2e383a32057fc725ba028070",
"outer_stanzas": [
{
"type": "tlock",
@ -28,12 +28,12 @@
{
"type": "X25519",
"args": [
"pUUIuCTHAZ3+kpMwJQQ9dc3EJO1zPagucu+VdFovSy0"
"o8TAxNxKDyeVxYiXN3BoUSmC2VffiIAdma9L58u13zs"
]
}
],
"control_cbor": "a50070646174656b6579732d636f6e74726f6c0101025820a4fa1e3c5a47a01313173cdf23df157ff8c949011df95dde8f092ff6749dc1540358201b27d7029c50dc20ef1f9269a1a944a1384f526f9ea76cdc9c76a6456bb1fbb80581a300706f72672e6578616d706c652e6e6f7465010202a2616e07667365616c6564f5",
"payload_identity": "1b27d7029c50dc20ef1f9269a1a944a1384f526f9ea76cdc9c76a6456bb1fbb8",
"control_cbor": "a50070646174656b6579732d636f6e74726f6c0101025820cf647fac3ba7849b217d9bb358d66cb5b6f6722e2e383a32057fc725ba02807003582094edebc71acf1c5743d80ec5d14922253a1325edda65d56d7e33642edb92ab6d0581a300706f72672e6578616d706c652e6e6f74650102024ba2000701667365616c6564",
"payload_identity": "94edebc71acf1c5743d80ec5d14922253a1325edda65d56d7e33642edb92ab6d",
"plaintext_file": "time_only_extensions.plaintext",
"plaintext_sha256": "1129768e195e2f1e50b7a6f926b6eebef120212c29b5642c8a662c503b2a9131",
"header_extensions": [
@ -41,7 +41,7 @@
"critical": false,
"id": "org.example.label",
"version": 1,
"data": "6c7075626c6963206c6162656c"
"data": "7075626c6963206c6162656c"
}
],
"control_extensions": [
@ -49,7 +49,7 @@
"critical": false,
"id": "org.example.note",
"version": 2,
"data": "a2616e07667365616c6564f5"
"data": "a2000701667365616c6564"
}
],
"stages": [

@ -1,5 +1,5 @@
{
"spec": "0.8.1",
"spec": "0.8.2",
"description": "Canonical dk1_ strings and rejected encodings (spec §18, §19, §66), generated by the reference implementation.",
"vectors": [
{

@ -1,5 +1,5 @@
{
"spec": "0.8.1",
"spec": "0.8.2",
"description": "Quicknet Provider Profile V1: exact Deterministic CBOR and profile_hash (spec §11, §12, §75 item 2), generated by the reference implementation.",
"profile_id": "datekeys:quicknet:v1",
"provider": "drand",

@ -1,5 +1,5 @@
{
"spec": "0.8.1",
"spec": "0.8.2",
"profile": "datekeys:quicknet:v1",
"description": "Quicknet date to round resolution (spec §15, §16, §65), generated by the reference implementation.",
"vectors": [

Loading…
Cancel
Save

Powered by TurnKey Linux.