From afb44a396ea23db34ca3495130a2414198cfe5db Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 25 Sep 2026 19:40:44 +0200 Subject: [PATCH] Implement v0.8.2 extension data, limits and writer self-checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 75 ++++++ CONTRIBUTING.md | 4 +- README.es.md | 4 +- README.md | 4 +- SECURITY.md | 2 +- TRADEMARKS.md | 2 +- accesskey/accesskey.go | 25 +- accesskey/accesskey_test.go | 98 ++++++- capsule/conformance_test.go | 57 ++++- capsule/encrypt.go | 31 ++- capsule/encrypt_test.go | 4 +- capsule/extension_test.go | 192 ++++++++++++++ capsule/framing.go | 23 +- capsule/framing_test.go | 58 ++++- capsule/fuzz_test.go | 73 ++++++ capsule/inspect.go | 27 +- capsule/mutation_test.go | 71 ++++- capsule/open.go | 18 +- codec/codec.go | 42 ++- codec/codec_test.go | 54 ++-- docs/traceability.md | 48 ++-- errors.go | 8 +- errors_test.go | 6 +- extension/extension.go | 233 +++++++++++++---- extension/extension_test.go | 242 ++++++++++++++++-- internal/testkit/fixture.go | 2 +- internal/testkit/genfixtures/main.go | 179 +++++++++++-- internal/testkit/vectors.go | 2 +- profile/profile.go | 22 +- profile/profile_test.go | 47 ++++ scripts/fuzz.sh | 3 +- testdata/fixtures/empty_payload.json | 2 +- .../fixtures/time_and_key_portable.dkk.json | 2 +- testdata/fixtures/time_and_key_portable.json | 2 +- .../time_and_key_portable_extension.dkk | Bin 0 -> 188 bytes .../time_and_key_portable_extension.dkk.json | 21 ++ .../fixtures/time_and_key_recipients.dkk.json | 2 +- .../fixtures/time_and_key_recipients.json | 2 +- testdata/fixtures/time_only.json | 2 +- testdata/fixtures/time_only_extensions.dkc | Bin 891 -> 891 bytes testdata/fixtures/time_only_extensions.json | 20 +- testdata/vectors/dk1.json | 2 +- testdata/vectors/profile_quicknet.json | 2 +- testdata/vectors/quicknet_rounds.json | 2 +- 44 files changed, 1473 insertions(+), 242 deletions(-) create mode 100644 capsule/extension_test.go create mode 100644 testdata/fixtures/time_and_key_portable_extension.dkk create mode 100644 testdata/fixtures/time_and_key_portable_extension.dkk.json diff --git a/CHANGELOG.md b/CHANGELOG.md index ba67c1d..f2f59a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 18b0611..a76c7ac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.es.md b/README.es.md index 00f9dc7..6a78361 100644 --- a/README.es.md +++ b/README.es.md @@ -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. diff --git a/README.md b/README.md index f4a4f44..78469c5 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/SECURITY.md b/SECURITY.md index d5279da..401889d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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). diff --git a/TRADEMARKS.md b/TRADEMARKS.md index 19baccb..a204adc 100644 --- a/TRADEMARKS.md +++ b/TRADEMARKS.md @@ -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. diff --git a/accesskey/accesskey.go b/accesskey/accesskey.go index 78df01c..adadcf2 100644 --- a/accesskey/accesskey.go +++ b/accesskey/accesskey.go @@ -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 } diff --git a/accesskey/accesskey_test.go b/accesskey/accesskey_test.go index 00e162e..5d326ff 100644 --- a/accesskey/accesskey_test.go +++ b/accesskey/accesskey_test.go @@ -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) diff --git a/capsule/conformance_test.go b/capsule/conformance_test.go index e1ac99c..4d45695 100644 --- a/capsule/conformance_test.go +++ b/capsule/conformance_test.go @@ -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") diff --git a/capsule/encrypt.go b/capsule/encrypt.go index 8a28f05..8382a1d 100644 --- a/capsule/encrypt.go +++ b/capsule/encrypt.go @@ -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) { diff --git a/capsule/encrypt_test.go b/capsule/encrypt_test.go index 270f182..0288a3f 100644 --- a/capsule/encrypt_test.go +++ b/capsule/encrypt_test.go @@ -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} diff --git a/capsule/extension_test.go b/capsule/extension_test.go new file mode 100644 index 0000000..620d382 --- /dev/null +++ b/capsule/extension_test.go @@ -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) + } +} diff --git a/capsule/framing.go b/capsule/framing.go index 4270bea..d717940 100644 --- a/capsule/framing.go +++ b/capsule/framing.go @@ -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) } diff --git a/capsule/framing_test.go b/capsule/framing_test.go index e4df020..e679336 100644 --- a/capsule/framing_test.go +++ b/capsule/framing_test.go @@ -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 diff --git a/capsule/fuzz_test.go b/capsule/fuzz_test.go index d56cf48..90901f1 100644 --- a/capsule/fuzz_test.go +++ b/capsule/fuzz_test.go @@ -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<> 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) } diff --git a/capsule/inspect.go b/capsule/inspect.go index d768581..1d94b2d 100644 --- a/capsule/inspect.go +++ b/capsule/inspect.go @@ -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 { diff --git a/capsule/mutation_test.go b/capsule/mutation_test.go index 4994645..eba7f14 100644 --- a/capsule/mutation_test.go +++ b/capsule/mutation_test.go @@ -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 . 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) } } diff --git a/capsule/open.go b/capsule/open.go index fd86d9f..01b473e 100644 --- a/capsule/open.go +++ b/capsule/open.go @@ -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) diff --git a/codec/codec.go b/codec/codec.go index 318b56c..c6dbaa2 100644 --- a/codec/codec.go +++ b/codec/codec.go @@ -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) -} diff --git a/codec/codec_test.go b/codec/codec_test.go index bd63272..96fb1c3 100644 --- a/codec/codec_test.go +++ b/codec/codec_test.go @@ -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) } diff --git a/docs/traceability.md b/docs/traceability.md index dd2b048..6084bd9 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -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. diff --git a/errors.go b/errors.go index fab2dd7..ed72a5e 100644 --- a/errors.go +++ b/errors.go @@ -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, } } diff --git a/errors_test.go b/errors_test.go index 6db8a2a..dca323b 100644 --- a/errors_test.go +++ b/errors_test.go @@ -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") + } } diff --git a/extension/extension.go b/extension/extension.go index 7a7876e..0f279e4 100644 --- a/extension/extension.go +++ b/extension/extension.go @@ -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 } diff --git a/extension/extension_test.go b/extension/extension_test.go index 9942bfa..6c23ab8 100644 --- a/extension/extension_test.go +++ b/extension/extension_test.go @@ -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 } diff --git a/internal/testkit/fixture.go b/internal/testkit/fixture.go index a37a71e..0a44b37 100644 --- a/internal/testkit/fixture.go +++ b/internal/testkit/fixture.go @@ -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). diff --git a/internal/testkit/genfixtures/main.go b/internal/testkit/genfixtures/main.go index 8fe67ff..280bf77 100644 --- a/internal/testkit/genfixtures/main.go +++ b/internal/testkit/genfixtures/main.go @@ -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() diff --git a/internal/testkit/vectors.go b/internal/testkit/vectors.go index 04a1c8a..cdf5219 100644 --- a/internal/testkit/vectors.go +++ b/internal/testkit/vectors.go @@ -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 { diff --git a/profile/profile.go b/profile/profile.go index 6674475..a60ff0a 100644 --- a/profile/profile.go +++ b/profile/profile.go @@ -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) diff --git a/profile/profile_test.go b/profile/profile_test.go index d353c8b..aa789f5 100644 --- a/profile/profile_test.go +++ b/profile/profile_test.go @@ -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() diff --git a/scripts/fuzz.sh b/scripts/fuzz.sh index a4f875b..4058df1 100644 --- a/scripts/fuzz.sh +++ b/scripts/fuzz.sh @@ -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" diff --git a/testdata/fixtures/empty_payload.json b/testdata/fixtures/empty_payload.json index f119aaa..fc0daf1 100644 --- a/testdata/fixtures/empty_payload.json +++ b/testdata/fixtures/empty_payload.json @@ -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": { diff --git a/testdata/fixtures/time_and_key_portable.dkk.json b/testdata/fixtures/time_and_key_portable.dkk.json index a9d3e07..f822efa 100644 --- a/testdata/fixtures/time_and_key_portable.dkk.json +++ b/testdata/fixtures/time_and_key_portable.dkk.json @@ -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", diff --git a/testdata/fixtures/time_and_key_portable.json b/testdata/fixtures/time_and_key_portable.json index 866d858..e197650 100644 --- a/testdata/fixtures/time_and_key_portable.json +++ b/testdata/fixtures/time_and_key_portable.json @@ -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": { diff --git a/testdata/fixtures/time_and_key_portable_extension.dkk b/testdata/fixtures/time_and_key_portable_extension.dkk new file mode 100644 index 0000000000000000000000000000000000000000..3029648fcdf2813ecc95ac59bd83a3d24871c58d GIT binary patch literal 188 zcmZ?q_BLc>0D}!H7>ZL8OH#8_D~oj#lao`6i*77UZPrrKIL$mZcVfoa4TbAtfU*F9iT&wo1tW literal 0 HcmV?d00001 diff --git a/testdata/fixtures/time_and_key_portable_extension.dkk.json b/testdata/fixtures/time_and_key_portable_extension.dkk.json new file mode 100644 index 0000000..bdf1571 --- /dev/null +++ b/testdata/fixtures/time_and_key_portable_extension.dkk.json @@ -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" +} diff --git a/testdata/fixtures/time_and_key_recipients.dkk.json b/testdata/fixtures/time_and_key_recipients.dkk.json index 9067520..740694a 100644 --- a/testdata/fixtures/time_and_key_recipients.dkk.json +++ b/testdata/fixtures/time_and_key_recipients.dkk.json @@ -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", diff --git a/testdata/fixtures/time_and_key_recipients.json b/testdata/fixtures/time_and_key_recipients.json index 44fd9c1..c0bebd2 100644 --- a/testdata/fixtures/time_and_key_recipients.json +++ b/testdata/fixtures/time_and_key_recipients.json @@ -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": { diff --git a/testdata/fixtures/time_only.json b/testdata/fixtures/time_only.json index 21bf71d..7ad219a 100644 --- a/testdata/fixtures/time_only.json +++ b/testdata/fixtures/time_only.json @@ -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": { diff --git a/testdata/fixtures/time_only_extensions.dkc b/testdata/fixtures/time_only_extensions.dkc index db72ae240522f534b5e771cf082e537fe9c8c458..f1091f1002e3ffc60b59692e0f5edc851cb4fadb 100644 GIT binary patch delta 639 zcmV-_0)YMd2Kxq(ArL}_2wWk`G|{IXWRoVOp!Sh5aScpxbz*F3V<2o{Vr6WTkpdZ! z$V`88RZVDPF*0{nLpW4qcQ7+iD`7Y|XLK-WQfNbDX;F4dQ!-O#Y-UbbF?UiiMQ?Fg zbx=h$SWhr|S8FzTSY~AkM@d9+ZA)1zLql_VI8HE2P-JdYO>}QXcvVSgQc^Z%X<9>d zM00mSaCA2>RBADFIBHW`c2H$iGA3WH)1FbTwH;SWtLvK?*G`Eg)lYc1CSjQaDayNH$|aVnj-9XK!jTPGwOr zQcgxoK}JDHW@UI(L1I&NZwmQx{=4xEFG5sda?8(``~-MQqiN4j+*KlpQl3r)Uw40A zU-FqjK3i{Ob_il;Gs2GyzVK3wgwIdkNTuD=79kwc$-VFDpv!~)DjOqpFdj$kD+QI! z%P4(7cW+*UBx?PW>%$aap0n$l;VUcgr$Ww>il8KV9$zEgp9D@sWhcMVJ$jw6w+kgp z(dn7|ckx4oTJZY4IEs@;M8PNcgqo9)0w8~HI8;G+PIya1d1Y33S!q~KGeU1wQ*A>s zR%T{tNkL?7VL41SICU{IdUFbTO)z3ZXEZNodM{3JZE8_fHAYx7cy?<`H#llhH)%OF zZfr(2Ycgz8aWD!kEiE8pOiejgNib@3b6HGNb5l=gIcs83Vl{4AOKfjvc3MS2adAsm zRYF8eW=Ba1MuUyMoPG3C_I}rZ+q>2|J_Kv;irUwxvih6+>qDvdZQ`}R5AUr delta 639 zcmV-_0)YMd2Kxq(ArR92n)O}4B{4GlHn`jk0c(*laSd#6bz*F3V<2o{Vr6WTkpdZ! z$V`84crYRAE?fT6#fhby#FFa#Ut& zL2h_zVpMuCYgR{hMp|tOL~c}VaA{LsoA>O$y`|oGVkBj;B`wce8XhjDGVPF6o!a?b)S2+lMZ~2XB8d zZMG*1LwecUum{chg`*b496HS+M;sAsGkFA%%uN9hgr=4^wAYD<`P@4a3Vy5QXp>zF zl<`JCHJqLTM?J6fvO^M|1HL1>oW_VYxj1GZq-{|BJ>vgH?)e^8k>Egf(`byHeL_rO zPIV3@i;jBOd<0w8~IRaHrKLsUpXS~Dwaa7}kgQBgT$V>3lc zPceE>VP|z?bt_h6MsIdgc`yn_Gb?9JWn*<}I8rilY&BzAVsK4LLt;^4Hh5B2RB~}^ zOKn(bNl7