Hand-written CBOR codec and shared test vectors (plan steps 2b and 3)

Step 2b: package codec is rewritten without reflection or struct tags. A
strict Decoder accepts only the spec §58 profile, Unmarshal decodes,
re-encodes and compares, Peek reads the type tag and version, and Walk is a
bounded iterative helper for vectors and fuzzing. Every schema has its own
hand-written encoder and decoder that checks all CDDL rules before the
fields with their own error codes. github.com/fxamacker/cbor/v2 and
github.com/x448/float16 are gone; nothing replaces them. Valid objects
encode and decode exactly as before (1.34 million differential verdicts);
the invalid-input differences are documented in CHANGELOG and
traceability decision 12. A review found and fixed an access_policy check
that truncated to uint8.

Step 3: testdata gains vectors/cbor.json (generic and per-schema CBOR
vectors), vectors/mutations.json (the 55-case mutation corpus, replayable
offline), vectors/inspect_differential.json (1,825 fixed-seed mutations
with the Go verdict) and one inspect -json golden per fixture, all
regenerated byte-identically by genfixtures and documented in
testdata/README.md for second implementations.

Gate green with 90 s of fuzzing per target on all 15 targets; codec at
100 % coverage; pre-existing testdata byte-identical.

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

@ -9,6 +9,11 @@ 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.
The CBOR library is replaced by a codec of the module's own, without
reflection or dependencies. Every valid object encodes to the same bytes as
before: the official vectors and fixtures are unchanged, and every error code
and inspection step of the test suite and the mutation corpus is the same.
### Breaking changes
- `extension.New(id, version, data []byte)` takes the opaque data bytes instead
@ -19,10 +24,52 @@ versions do not change.
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`.
- `codec.Valid` and its fuzz target `codec.FuzzValid` are removed: nothing
decodes extension data any more.
- Package `codec` is rewritten without reflection, struct tags or dependencies
(§58). Removed: `Marshal`, the reflection-based `Unmarshal(data, v)` and
`Peek(data, v)`, and `MaxNestedLevels`, `MaxArrayElements` and
`MaxMapPairs`. Each schema now writes its encoding with a `codec.Encoder`
(`Map`, `Array`, `Uint`, `Bstr`, `Text`, `Out`, with a sticky first error,
and `Fail`, which records an error of the schema so that `Out` never returns
bytes its decoder rejects) and reads it with a strict `codec.Decoder`
(`NewDecoder`, `Map`, `Key`, `EndMap`, `Array`, `Uint`, `Bstr`, `Text`,
`Done`). `codec.Unmarshal(in, decode, encode)` runs the decoder of a schema
and requires that its re-encoding reproduces the input; `codec.Peek(in)`
returns the type tag, of at most `codec.MaxTypeTagLen` (64) bytes, and the
schema version; `codec.Walk(in, maxDepth, maxLen)` checks that bytes are one
item of the §58 profile, for vectors, fuzzing and diagnostics.
`CheckSchema` and `MaxSafeUint` keep their names.
- `extension.Wire` and its `UnmarshalCBOR` are removed. `extension.Encode`
becomes `extension.Canonical`, which returns the validated array in
canonical order as `[]Extension`; `extension.Decode([]Wire)` becomes
`extension.DecodeArray(*codec.Decoder)`, which reads and validates one array
and rejects more than 64 entries from the array head, before reading any;
`extension.EncodeArray(*codec.Encoder, []Extension)` writes one, and
records in the Encoder, instead of writing it, an array that `DecodeArray`
would reject.
- `codec.CheckSchema` reads keys 0 and 1 only, and nothing after them (§70):
the map head, key 0, a type tag of at most 64 bytes, key 1 and the version
must be in the profile, each head in its shortest form, and the version at
most 2^53−1. A schema version other than the expected one read that way is
`ERR_UNSUPPORTED_VERSION` whatever follows it; it was
`ERR_NON_CANONICAL_CBOR` when the rest of the object was malformed. Every
other form of the version is now `ERR_NON_CANONICAL_CBOR`, where it was
`ERR_UNSUPPORTED_VERSION` whenever the value read was not the expected one:
a missing version, `null` or `undefined`, a version not in its shortest
form, a version above 2^53−1 (up to 2^64−1), a version that is not the
second key (placed before key 0 or after another key), and a version
behind a map head or a type tag head not in its shortest form. `true` and
`false` were already `ERR_NON_CANONICAL_CBOR`.
- `capsule.DecodeHeader` checks every CDDL rule of PUBLIC_HEADER, including
`access_policy`, the extension arrays and the cross-array rule, before it
parses the DateKey (§57, §63 step 4). A header that breaks both reports
`ERR_NON_CANONICAL_CBOR` where it reported `ERR_DATEKEY_INVALID` or
`ERR_DATEKEY_NON_CANONICAL`; a header with one fault keeps its code.
- `profile.Profile.CanonicalCBOR` and `Hash` refuse a `profile_id`,
`provider`, `network` or `scheme` that is not valid UTF-8, with
`ERR_NON_CANONICAL_CBOR`: they wrote it as an invalid text string. Every
encoder refuses such text.
- 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.
@ -56,7 +103,8 @@ versions do not change.
- 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`.
- `extension.MaxExtensions`, `MaxVersion`, `MaxDataLen`, `codec.MaxSafeUint`
and `codec.MaxTypeTagLen`.
- 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.
@ -64,6 +112,71 @@ versions do not change.
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`).
- The mutation corpus moves from `capsule/mutation_test.go` to
`internal/testkit.Mutations`, shared by the test and by `genfixtures`. Its
third-party X25519 identity is now fixed (`testkit.Stranger`), and every
release source answers with one recorded release, as the exported corpus
describes it. The CLI's inspect view moves to `internal/inspectview`, which
`genfixtures` uses to freeze the outputs.
- `internal/cbortest`, an encoder and decoder of generic CBOR values written
independently of `codec`: tests build with it inputs outside the profile
and check `codec` against it.
- Tests of the map structure of every schema (key order, required and unknown
keys, entry counts) and of the codec, whose statement coverage is 100 %.
- `access_policy` values whose low byte is 0 or 1 (256, 257, 65536, 2^32,
2^53−256…) are tested as undefined, as `FuzzDecodeHeader` seeds and as two
mutations with a consistent `header_binding` (`testkit.Build.RawPolicy`).
- Tests `extension.TestEncodeArrayRejects`,
`accesskey.TestEncodeAndDecodeLeaveNoStaleMaterial`,
`accesskey.TestDecodeShortBodyAllocatesLittle` and `capsule.TestDecryptAll`.
- Shared test data for a second implementation, generated by `genfixtures`,
regenerated by the gate and documented in `testdata/README.md`:
- `testdata/vectors/cbor.json`: 36 accepted and 67 rejected items of the
§58 profile, walked with `codec.Walk` (integers above 2^53−1 as decimal
strings), and 135 schema vectors: minimal valid object, unknown key,
missing key, wrong type, size and range for the Provider Profile,
PUBLIC_HEADER, CONTROL_CBOR, the `.dkk` body, `verification_metadata` and
extensions (data `40` and `5801xx`, data of every other type, 64 and 65
extensions, a leading BOM, the U+FF61/U+10000 order, `extension_version`
2^32−1 and 2^32); schema versions 2^53 and 2^64−1 and the order of type
tag and version; and the Provider Profile validation (names, public key,
`genesis_time`, drand scheme, the chain-hash self-check with its formula,
the `period` limit), each vector keeping the chain hash consistent unless
it tests the self-check.
- `testdata/vectors/mutations.json`: the mutation corpus as frozen data, 55
cases (the 23 of §64 first), each a `.dkc` given as edits of a fixture and
what the reader is given (`.dkk`, identities, the recorded release, clock,
registry, known extensions), with the expected error and step. The 16
capsules built with age randomness are kept from the committed file;
`genfixtures -only mutations` rebuilds them.
- `testdata/vectors/inspect_differential.json`: 1825 deterministic mutations
of the five `.dkc` fixtures (bit flips, byte changes, truncations,
insertions, deletions, length fields, CBOR-aware header edits, DateKey
edits, age header edits) with the verdict of steps 1 to 8.
- `testdata/fixtures/<name>.inspect.json`: the exact output of
`datekeys inspect -json -in <name>.dkc` for each official capsule.
- `testdata/README.md` also states the rules of the reference that the spec
leaves open and the corpora depend on: the order of codes within an
object, the checks of steps 1 to 8 (frame lengths of at least 1, the age
header grammar and parser limits, the round-time ceiling of
9999-12-31T23:59:59Z, the exact tlock stanza arguments), the access
pre-checks and the implementation limits.
- `spec/datekeys.cddl` marks the one-day `period` limit of the Provider
Profile, which `profile.Decode` already applied, as an implementation limit
of the reference (§57, §74); spec §11 allows up to 2^53−1.
- Tests that replay them: `codec.TestSharedVectors`,
`internal/testkit.TestSchemaVectors`, `capsule.TestExportedMutationCorpus`,
`capsule.TestInspectDifferentialCorpus` and
`cmd/datekeys.TestInspectJSONGoldens`.
- Fuzz targets `codec.FuzzDecoder` (the Decoder primitives),
`codec.FuzzWalk` (against the independent decoder), `codec.FuzzPeek`,
`codec.FuzzEncodeImpliesWalk` and `extension.FuzzDecodeArray`, run by
`scripts/fuzz.sh`; `codec.FuzzUnmarshal` now fuzzes a hand-written schema.
### Removed
- The dependencies `github.com/fxamacker/cbor/v2` and
`github.com/x448/float16`. `go.mod` requires nothing new.
### Fixed
@ -76,7 +189,17 @@ versions do not change.
- `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.
wipes the material on its error paths. The `codec.Encoder` also wipes every
buffer it outgrows, and `codec.Unmarshal` sizes its re-encoding for the
input; the former library's internal buffers could keep a copy.
- `accesskey.Encode` wipes the body it wrote, and `accesskey.Decode` reads the
body into a buffer that grows with the data read, wiping every buffer it
outgrows, and wipes the body once decoded or on error: both left copies of
`access_material` behind. The in-memory age decryption of SEALED_CONTROL and
INNER_ACCESS_AGE in `capsule.Open` reads the plaintext into one buffer of
the ciphertext's size instead of a growing one, so that no outgrown buffer
keeps a copy of I_PAYLOAD. Buffers internal to `filippo.io/age` and copies
made by the Go runtime stay out of reach (SECURITY.md).
## Unreleased — v0.1.0

@ -61,11 +61,15 @@ Coverage must stay at or above 90 % for `codec`, `capsule`, `accesskey`,
## Fixtures
`go run ./internal/testkit/genfixtures -out testdata` regenerates the vectors
and creates missing fixtures. It never overwrites existing fixtures unless
`-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.
`go run ./internal/testkit/genfixtures -out testdata` regenerates the vectors,
the corpora and the frozen inspect outputs, and creates missing fixtures. It
never overwrites existing fixtures unless `-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. The mutations built with age randomness keep the capsules recorded
in `testdata/vectors/mutations.json`; `-only mutations` rebuilds them, which a
change to one of those mutations needs. `testdata/README.md` documents every
format and is updated with any change to one.
## Commits and releases

@ -136,13 +136,18 @@ go test -tags interop ./capsule # las CLI oficiales age y tle abren
go test -tags integration ./capsule ./provider/drand # Quicknet en vivo
```
- `testdata/vectors`: vectores de `profile_hash`, fecha→ronda y `dk1_` (spec §65, §66).
- `testdata/vectors`: vectores de `profile_hash`, fecha→ronda y `dk1_` (spec
§65, §66), vectores del perfil CBOR y de cada schema, el corpus de mutaciones
exportado y un corpus diferencial de las comprobaciones previas al
desbloqueo; formatos en [`testdata/README.md`](testdata/README.md).
- `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 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.
§67, §68); se descifran sin red. Cada `.dkc` tiene congelada su salida de
`datekeys inspect -json`.
- `internal/testkit.Mutations`: las 23 mutaciones del §64 y 32 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; exportadas a
`testdata/vectors/mutations.json`.
- [`docs/traceability.md`](docs/traceability.md): sección del spec → código → test.
- [`spec/datekeys.cddl`](spec/datekeys.cddl): schemas CBOR.

@ -135,13 +135,17 @@ go test -tags interop ./capsule # official age and tle CLIs open our
go test -tags integration ./capsule ./provider/drand # live Quicknet
```
- `testdata/vectors`: profile hash, date→round and `dk1_` vectors (spec §65, §66).
- `testdata/vectors`: profile hash, date→round and `dk1_` vectors (spec §65, §66),
CBOR profile and schema vectors, the exported mutation corpus and a
differential corpus of the pre-unlock checks; formats in
[`testdata/README.md`](testdata/README.md).
- `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 23 mutations of spec §64 and 30 more, each
they decrypt offline. Each `.dkc` has its frozen `datekeys inspect -json`
output.
- `internal/testkit.Mutations`: the 23 mutations of spec §64 and 32 more, each
with its exact error and step, and a check that pre-unlock failures never
cause a release request.
cause a release request; exported to `testdata/vectors/mutations.json`.
- [`docs/traceability.md`](docs/traceability.md): spec section → code → test.
- [`spec/datekeys.cddl`](spec/datekeys.cddl): CBOR schemas.

@ -45,7 +45,9 @@ No cryptography is implemented in this module. It depends on:
| `github.com/drand/tlock` v1.2.0 | `TimeLock`, `TimeUnlock`, ciphertext encoding |
| `github.com/drand/drand/v2` v2.1.7 | BLS verification (`crypto.Scheme`), chain-info hash |
| `github.com/drand/kyber`, `github.com/drand/kyber-bls12381` | BLS12-381 pairing |
| `github.com/fxamacker/cbor/v2` v2.9.4 | Deterministic CBOR |
Deterministic CBOR (spec §58) is implemented by the module itself, in package
`codec`, without dependencies.
All versions are pinned in `go.mod` and verified through `go.sum`. Changes to
`age`, `tlock`, `drand` or `kyber` are reviewed manually.

@ -56,20 +56,129 @@ type Verification struct {
CapsuleDigest []byte // key 0, SHA-256 of the exact .dkc bytes
}
// bodyWire is BODY_CBOR as it is encoded: keys 2 to 8, keys 0 and 1 being
// the constants TypeTag and SchemaVersion.
type bodyWire struct {
Type string `cbor:"0,keyasint"`
Version uint64 `cbor:"1,keyasint"`
CredentialID []byte `cbor:"2,keyasint"`
CapsuleID []byte `cbor:"3,keyasint"`
AccessType string `cbor:"4,keyasint"`
Material []byte `cbor:"5,keyasint"`
Verification *verificationWire `cbor:"6,keyasint,omitempty"`
Critical []extension.Wire `cbor:"7,keyasint,omitempty"`
Noncritical []extension.Wire `cbor:"8,keyasint,omitempty"`
CredentialID []byte // key 2
CapsuleID []byte // key 3
AccessType string // key 4
Material []byte // key 5, SECRET
// Digest is capsule_digest, the only key of verification_metadata
// (key 6); nil when key 6 is omitted.
Digest []byte
Critical []extension.Extension // key 7, omitted when empty
Noncritical []extension.Extension // key 8, omitted when empty
}
type verificationWire struct {
CapsuleDigest []byte `cbor:"0,keyasint,omitempty"`
func (w *bodyWire) encode(e *codec.Encoder) {
pairs := 6
for _, present := range []bool{w.Digest != nil, len(w.Critical) > 0, len(w.Noncritical) > 0} {
if present {
pairs++
}
}
e.Map(pairs)
e.Uint(0)
e.Text(TypeTag)
e.Uint(1)
e.Uint(SchemaVersion)
e.Uint(2)
e.Bstr(w.CredentialID)
e.Uint(3)
e.Bstr(w.CapsuleID)
e.Uint(4)
e.Text(w.AccessType)
e.Uint(5)
e.Bstr(w.Material)
if w.Digest != nil {
e.Uint(6)
e.Map(1)
e.Uint(0)
e.Bstr(w.Digest)
}
if len(w.Critical) > 0 {
e.Uint(7)
extension.EncodeArray(e, w.Critical)
}
if len(w.Noncritical) > 0 {
e.Uint(8)
extension.EncodeArray(e, w.Noncritical)
}
}
// decode reads BODY_CBOR with every CDDL rule whose violation is
// ErrNonCanonicalCBOR; access_type and access_material, which have a code of
// their own (spec §57), are checked afterwards. The caller wipes Material,
// whatever the result.
func (w *bodyWire) decode(d *codec.Decoder) error {
pairs, err := d.Map(9)
if err != nil {
return err
}
var seen uint
for range pairs {
k, err := d.Key()
if err != nil {
return err
}
switch k {
case 0:
_, err = d.Text(len(TypeTag))
case 1:
_, err = d.Uint(SchemaVersion)
case 2:
w.CredentialID, err = d.Bstr(idSize, idSize)
case 3:
w.CapsuleID, err = d.Bstr(idSize, idSize)
case 4:
w.AccessType, err = d.Text(MaxBodyLen)
case 5:
w.Material, err = d.Bstr(0, MaxBodyLen)
case 6:
w.Digest, err = decodeVerification(d)
case 7:
w.Critical, err = extension.DecodeArray(d)
case 8:
w.Noncritical, err = extension.DecodeArray(d)
default:
return fmt.Errorf("key %d is not defined: %w", k, datekeys.ErrNonCanonicalCBOR)
}
if err != nil {
return fmt.Errorf("key %d: %w", k, err)
}
seen |= 1 << k
}
for k := range 6 {
if seen&(1<<k) == 0 {
return fmt.Errorf("key %d is missing: %w", k, datekeys.ErrNonCanonicalCBOR)
}
}
return d.EndMap()
}
// decodeVerification reads verification_metadata, {0: capsule_digest}. It is
// present only when it holds a digest: an empty map is not a representation
// of absence (spec §43, §58.1).
func decodeVerification(d *codec.Decoder) ([]byte, error) {
pairs, err := d.Map(1)
if err != nil {
return nil, err
}
if pairs == 0 {
return nil, fmt.Errorf("empty verification_metadata; an absent one omits key 6: %w", datekeys.ErrNonCanonicalCBOR)
}
k, err := d.Key()
if err != nil {
return nil, err
}
if k != 0 {
return nil, fmt.Errorf("verification_metadata key %d is not defined: %w", k, datekeys.ErrNonCanonicalCBOR)
}
digest, err := d.Bstr(digestSize, digestSize)
if err != nil {
return nil, fmt.Errorf("capsule_digest: %w", err)
}
return digest, d.EndMap()
}
// String describes k without its material.
@ -113,8 +222,6 @@ func (k *AccessKey) MarshalBody() ([]byte, error) {
return nil, err
}
w := bodyWire{
Type: TypeTag,
Version: SchemaVersion,
CredentialID: k.CredentialID[:],
CapsuleID: k.CapsuleID[:],
AccessType: k.Type,
@ -125,19 +232,21 @@ func (k *AccessKey) MarshalBody() ([]byte, error) {
// An empty map is not a canonical representation of absence (spec §43).
return nil, fmt.Errorf("accesskey: capsule_digest must be %d bytes: %w", digestSize, datekeys.ErrNonCanonicalCBOR)
}
w.Verification = &verificationWire{CapsuleDigest: k.Verification.CapsuleDigest}
w.Digest = k.Verification.CapsuleDigest
}
var err error
if w.Critical, err = extension.Encode(k.Critical); err != nil {
if w.Critical, err = extension.Canonical(k.Critical); err != nil {
return nil, err
}
if w.Noncritical, err = extension.Encode(k.Noncritical); err != nil {
if w.Noncritical, err = extension.Canonical(k.Noncritical); err != nil {
return nil, err
}
if err := extension.CheckDisjoint(k.Critical, k.Noncritical); err != nil {
if err := extension.CheckDisjoint(w.Critical, w.Noncritical); err != nil {
return nil, err
}
b, err := codec.Marshal(w)
var e codec.Encoder
w.encode(&e)
b, err := e.Out()
if err != nil {
return nil, err
}
@ -155,12 +264,14 @@ func (k *AccessKey) MarshalBody() ([]byte, error) {
return b, nil
}
// Encode writes k as a complete .dkk: prelude and BODY_CBOR (spec §40).
// Encode writes k as a complete .dkk: prelude and BODY_CBOR (spec §40). The
// body it encodes, which holds the material, is wiped once written.
func Encode(w io.Writer, k *AccessKey) error {
body, err := k.MarshalBody()
if err != nil {
return err
}
defer clear(body)
var pre [PreludeSize]byte
copy(pre[0:4], Magic)
pre[4] = FramingVersion
@ -196,13 +307,11 @@ func Decode(r io.Reader) (*AccessKey, error) {
if bodyLen > MaxBodyLen {
return nil, fmt.Errorf("accesskey: BODY_LEN %d exceeds the %d-byte limit: %w", bodyLen, MaxBodyLen, datekeys.ErrIntegrity)
}
// The buffer grows with the data actually read, so a short file that
// declares a large BODY_LEN does not force an allocation of that size.
var buf bytes.Buffer
if _, err := io.CopyN(&buf, r, int64(bodyLen)); err != nil {
body, err := readBody(r, int(bodyLen))
if err != nil {
return nil, fmt.Errorf("accesskey: truncated body: %w", datekeys.ErrIntegrity)
}
body := buf.Bytes()
defer clear(body)
var extra [1]byte
switch n, err := io.ReadFull(r, extra[:]); {
case n != 0:
@ -213,6 +322,30 @@ func Decode(r io.Reader) (*AccessKey, error) {
return DecodeBody(body)
}
// readBody reads the n bytes of BODY_CBOR from r. The buffer grows with the
// data actually read, so a short file that declares a large BODY_LEN does not
// force an allocation of that size. The body holds access_material: every
// buffer it outgrows is wiped, and so is the partial body on error.
func readBody(r io.Reader, n int) ([]byte, error) {
buf := make([]byte, 0, min(n, bytes.MinRead))
for len(buf) < n {
if len(buf) == cap(buf) {
grown := make([]byte, len(buf), min(n, 2*cap(buf)))
copy(grown, buf)
clear(buf)
buf = grown
}
m, err := r.Read(buf[len(buf):cap(buf)])
buf = buf[:len(buf)+m]
if err != nil && len(buf) < n {
// Read may use the whole of its argument as scratch space.
clear(buf[:cap(buf)])
return nil, err
}
}
return buf, nil
}
// 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) {
@ -224,30 +357,17 @@ func DecodeBody(body []byte) (*AccessKey, error) {
}
var w bodyWire
defer func() { clear(w.Material) }()
if err := codec.Unmarshal(body, &w); err != nil {
if err := codec.Unmarshal(body, w.decode, w.encode); 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)
if err := extension.CheckDisjoint(w.Critical, w.Noncritical); err != nil {
return nil, fmt.Errorf("accesskey: %w", err)
}
k := &AccessKey{Type: w.AccessType}
k := &AccessKey{Type: w.AccessType, Critical: w.Critical, Noncritical: w.Noncritical}
copy(k.CredentialID[:], w.CredentialID)
copy(k.CapsuleID[:], w.CapsuleID)
if w.Verification != nil {
if len(w.Verification.CapsuleDigest) != digestSize {
return nil, fmt.Errorf("accesskey: verification_metadata must hold a %d-byte capsule_digest: %w", digestSize, datekeys.ErrNonCanonicalCBOR)
}
k.Verification = &Verification{CapsuleDigest: bytes.Clone(w.Verification.CapsuleDigest)}
}
var err error
if k.Critical, err = extension.Decode(w.Critical); err != nil {
return nil, fmt.Errorf("accesskey: critical_extensions: %w", err)
}
if k.Noncritical, err = extension.Decode(w.Noncritical); err != nil {
return nil, fmt.Errorf("accesskey: noncritical_extensions: %w", err)
}
if err := extension.CheckDisjoint(k.Critical, k.Noncritical); err != nil {
return nil, fmt.Errorf("accesskey: %w", err)
if w.Digest != nil {
k.Verification = &Verification{CapsuleDigest: w.Digest}
}
k.Material = bytes.Clone(w.Material)
if err := k.validateMaterial(); err != nil {

@ -15,13 +15,12 @@ import (
"testing"
"filippo.io/age"
"github.com/fxamacker/cbor/v2"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/accesskey"
"g.activething.com/go/DateKeys/agewrap"
"g.activething.com/go/DateKeys/codec"
"g.activething.com/go/DateKeys/extension"
"g.activething.com/go/DateKeys/internal/cbortest"
"g.activething.com/go/DateKeys/internal/testkit"
"g.activething.com/go/DateKeys/profile"
)
@ -149,8 +148,8 @@ func frame(body []byte) []byte {
func TestDecodeRejects(t *testing.T) {
good, _ := loadDKK(t, "time_and_key_portable")
body := good[accesskey.PreludeSize:]
var w map[uint64]any
if err := codec.Unmarshal(body, &w); err != nil {
w, err := cbortest.UnmarshalMap(body)
if err != nil {
t.Fatal(err)
}
with := func(edit func(m map[uint64]any)) []byte {
@ -159,7 +158,7 @@ func TestDecodeRejects(t *testing.T) {
m[k] = v
}
edit(m)
b, err := codec.Marshal(m)
b, err := cbortest.Marshal(m)
if err != nil {
t.Fatal(err)
}
@ -254,6 +253,105 @@ func TestIdentityWipeAndEncodeErrors(t *testing.T) {
}
}
// retainingWriter keeps the slices it is given, which io.Writer forbids, so
// that a test can see what the writer's caller leaves in them.
type retainingWriter struct{ writes [][]byte }
func (w *retainingWriter) Write(p []byte) (int, error) {
w.writes = append(w.writes, p)
return len(p), nil
}
// retainingReader delivers data a few bytes at a time and keeps every slice
// it is given, up to its capacity, so that a test can see what the reader's
// caller leaves in them.
type retainingReader struct {
data []byte
chunk int
seen [][]byte
}
func (r *retainingReader) Read(p []byte) (int, error) {
r.seen = append(r.seen, p[:cap(p)])
if len(r.data) == 0 {
return 0, io.EOF
}
n := copy(p[:min(len(p), r.chunk)], r.data)
r.data = r.data[n:]
return n, nil
}
// Encode and Decode leave no copy of access_material in the buffers they
// allocate, including the ones Decode outgrows while it reads the body.
func TestEncodeAndDecodeLeaveNoStaleMaterial(t *testing.T) {
material := bytes.Repeat([]byte{0xab}, 32)
big, err := extension.New("org.example.big", 1, make([]byte, 4096))
if err != nil {
t.Fatal(err)
}
k := &accesskey.AccessKey{Type: accesskey.TypeX25519, Material: bytes.Clone(material), Noncritical: []extension.Extension{big}}
w := &retainingWriter{}
var dkk bytes.Buffer
if err := accesskey.Encode(io.MultiWriter(&dkk, w), k); err != nil {
t.Fatal(err)
}
for i, p := range w.writes {
if bytes.Contains(p, material) {
t.Errorf("Encode: slice %d written still holds the material", i)
}
}
if !bytes.Equal(k.Material, material) {
t.Fatal("Encode wiped the material of its argument")
}
if !bytes.Contains(dkk.Bytes(), material) {
t.Fatal("the .dkk does not hold the material")
}
r := &retainingReader{data: dkk.Bytes(), chunk: 100}
back, err := accesskey.Decode(r)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(back.Material, material) {
t.Fatal("material not decoded")
}
back.Wipe()
for i, p := range r.seen {
if bytes.Contains(p, material[:8]) {
t.Errorf("Decode: read buffer %d of %d bytes still holds the material", i, len(p))
}
}
// A truncated body is wiped too.
r = &retainingReader{data: dkk.Bytes()[:dkk.Len()-10], chunk: 100}
if _, err := accesskey.Decode(r); !errors.Is(err, datekeys.ErrIntegrity) {
t.Fatalf("truncated body: %v", err)
}
for i, p := range r.seen {
if bytes.Contains(p, material[:8]) {
t.Errorf("Decode of a truncated body: read buffer %d still holds the material", i)
}
}
}
// A short .dkk that declares the largest BODY_LEN is rejected without an
// allocation of that size: the body buffer grows with the data read.
func TestDecodeShortBodyAllocatesLittle(t *testing.T) {
var pre [accesskey.PreludeSize]byte
copy(pre[:], accesskey.Magic)
pre[4] = accesskey.FramingVersion
binary.BigEndian.PutUint32(pre[8:12], accesskey.MaxBodyLen)
r := &retainingReader{data: append(pre[:], make([]byte, 5000)...), chunk: 1000}
if _, err := accesskey.Decode(r); !errors.Is(err, datekeys.ErrIntegrity) {
t.Fatalf("short body: %v", err)
}
for _, p := range r.seen {
if len(p) > 16<<10 {
t.Fatalf("a read buffer of %d bytes for 5000 bytes of body", len(p))
}
}
}
// 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) {
@ -277,8 +375,8 @@ func TestBodyLimit(t *testing.T) {
func TestDecodeBodyExtensionRules(t *testing.T) {
good, _ := loadDKK(t, "time_and_key_portable")
var m map[uint64]any
if err := codec.Unmarshal(good[accesskey.PreludeSize:], &m); err != nil {
m, err := cbortest.UnmarshalMap(good[accesskey.PreludeSize:])
if err != nil {
t.Fatal(err)
}
ext := func(id string, v uint64) map[uint64]any { return map[uint64]any{0: id, 1: v} }
@ -289,11 +387,11 @@ func TestDecodeBodyExtensionRules(t *testing.T) {
// 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}}}
m[8] = []any{map[uint64]any{0: "a", 1: uint64(1), 2: cbortest.Raw{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}}}
m[8] = []any{map[uint64]any{0: "a", 1: uint64(1), 2: cbortest.Raw{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}} },
@ -313,7 +411,7 @@ func TestDecodeBodyExtensionRules(t *testing.T) {
c[k] = v
}
edit(c)
b, err := codec.Marshal(c)
b, err := cbortest.Marshal(c)
if err != nil {
t.Fatal(err)
}

@ -0,0 +1,66 @@
package accesskey_test
import (
"errors"
"maps"
"slices"
"testing"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/accesskey"
"g.activething.com/go/DateKeys/internal/cbortest"
)
// Spec §41, §43, §58: BODY_CBOR is a closed map with strictly ascending
// unsigned integer keys and every required key, and verification_metadata is
// the closed map {0: capsule_digest}.
func TestDecodeBodyStructure(t *testing.T) {
good, _ := loadDKK(t, "time_and_key_portable")
body, err := cbortest.UnmarshalMap(good[accesskey.PreludeSize:])
if err != nil {
t.Fatal(err)
}
encode := func(v any) []byte {
b, err := cbortest.Marshal(v)
if err != nil {
t.Fatal(err)
}
return b
}
with := func(edit func(m map[uint64]any)) []byte {
m := maps.Clone(body)
edit(m)
return encode(m)
}
// Keys 4 and 5 in each other's place.
var swapped cbortest.Pairs
for _, k := range slices.Sorted(maps.Keys(body)) {
switch k {
case 4:
k = 5
case 5:
k = 4
}
swapped = append(swapped, k, body[k])
}
a, b := []any{map[uint64]any{0: "a", 1: uint64(1)}}, []any{map[uint64]any{0: "b", 1: uint64(1)}}
for name, in := range map[string][]byte{
"missing credential_id": with(func(m map[uint64]any) { delete(m, 2) }),
"missing access_material": with(func(m map[uint64]any) { delete(m, 5) }),
"credential_id as text": with(func(m map[uint64]any) { m[2] = "x" }),
"ten entries": with(func(m map[uint64]any) { m[7], m[8], m[9], m[10] = a, b, "x", "y" }),
"keys 4 and 5 swapped": encode(swapped),
"text key": encode(cbortest.Pairs{uint64(0), accesskey.TypeTag, uint64(1), uint64(1), "2", make([]byte, 16)}),
"verification with key 1": with(func(m map[uint64]any) { m[6] = map[uint64]any{1: make([]byte, 32)} }),
"verification with two keys": with(func(m map[uint64]any) { m[6] = map[uint64]any{0: make([]byte, 32), 1: uint64(0)} }),
"verification as a byte string": with(func(m map[uint64]any) { m[6] = make([]byte, 32) }),
"verification with a text key": with(func(m map[uint64]any) { m[6] = cbortest.Pairs{"0", make([]byte, 32)} }),
"capsule_digest of type text": with(func(m map[uint64]any) { m[6] = map[uint64]any{0: "digest"} }),
"capsule_digest of 33 bytes": with(func(m map[uint64]any) { m[6] = map[uint64]any{0: make([]byte, 33)} }),
"access_type of type byte string": with(func(m map[uint64]any) { m[4] = []byte("x25519") }),
} {
if _, err := accesskey.DecodeBody(in); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Errorf("%s: %v", name, err)
}
}
}

@ -0,0 +1,55 @@
package capsule_test
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"testing"
"g.activething.com/go/DateKeys/internal/testkit"
)
// The differential corpus of the pre-unlock checks,
// testdata/vectors/inspect_differential.json, replays: every mutation of an
// official fixture gets exactly the recorded verdict of Inspect, ok or an
// error code at a step. genfixtures regenerates the corpus, so that a change
// of verdict also shows as a change of the file.
func TestInspectDifferentialCorpus(t *testing.T) {
var f testkit.DifferentialFile
if err := testkit.ReadJSON("../testdata/vectors/inspect_differential.json", &f); err != nil {
t.Fatal(err)
}
if len(f.Mutations) < 1500 {
t.Fatalf("%d mutations, want at least 1500", len(f.Mutations))
}
bases := make([][]byte, len(f.Bases))
for i, b := range f.Bases {
dkc, err := os.ReadFile(filepath.Join(fixtureDir, b.File))
if err != nil {
t.Fatal(err)
}
if sum := sha256.Sum256(dkc); hex.EncodeToString(sum[:]) != b.SHA256 {
t.Fatalf("%s changed", b.File)
}
bases[i] = dkc
}
kinds := map[string]int{}
for i, m := range f.Mutations {
dkc, err := testkit.ApplyEdits(bases[m.Base], m.Edits)
if err != nil {
t.Fatalf("mutation %d: %v", i, err)
}
result, step := testkit.InspectVerdict(dkc)
if result != m.Result || step != m.Step {
t.Errorf("mutation %d (%s of %s, edits %v): got %s at step %d, want %s at step %d",
i, m.Kind, f.Bases[m.Base].File, m.Edits, result, step, m.Result, m.Step)
}
kinds[m.Kind]++
}
for _, k := range []string{"flip", "byte", "truncate", "insert", "delete", "length", "header", "datekey", "age"} {
if kinds[k] == 0 {
t.Errorf("no mutation of kind %s", k)
}
}
}

@ -11,8 +11,8 @@ import (
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/cbortest"
"g.activething.com/go/DateKeys/internal/testkit"
)
@ -104,8 +104,8 @@ func TestNaNKeyedDataHasOneVerdict(t *testing.T) {
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 {
m, err := cbortest.UnmarshalMap(parts.Header)
if err != nil {
t.Fatal(err)
}
const n = 40_000
@ -115,7 +115,7 @@ func TestHugeExtensionArraysAreRejected(t *testing.T) {
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)
h, err := cbortest.Marshal(m)
if err != nil {
t.Fatal(err)
}

@ -149,14 +149,106 @@ type Header struct {
Noncritical []extension.Extension // key 6
}
// headerWire is PUBLIC_HEADER as it is encoded: keys 2 to 6, keys 0 and 1
// being the constants HeaderTypeTag and HeaderVersion.
type headerWire struct {
Type string `cbor:"0,keyasint"`
Version uint64 `cbor:"1,keyasint"`
CapsuleID []byte `cbor:"2,keyasint"`
DateKey string `cbor:"3,keyasint"`
Policy uint64 `cbor:"4,keyasint"`
Critical []extension.Wire `cbor:"5,keyasint,omitempty"`
Noncritical []extension.Wire `cbor:"6,keyasint,omitempty"`
CapsuleID []byte // key 2
DateKey string // key 3
Policy uint64 // key 4
Critical []extension.Extension // key 5, omitted when empty
Noncritical []extension.Extension // key 6, omitted when empty
}
func (w *headerWire) encode(e *codec.Encoder) {
e.Map(5 + nonEmpty(w.Critical) + nonEmpty(w.Noncritical))
e.Uint(0)
e.Text(HeaderTypeTag)
e.Uint(1)
e.Uint(HeaderVersion)
e.Uint(2)
e.Bstr(w.CapsuleID)
e.Uint(3)
e.Text(w.DateKey)
e.Uint(4)
e.Uint(w.Policy)
encodeExtensions(e, 5, w.Critical, w.Noncritical)
}
// decode reads PUBLIC_HEADER with every CDDL rule whose violation is
// ErrNonCanonicalCBOR, including the extension arrays; the DateKey, which
// has codes of its own (spec §57), is parsed afterwards.
func (w *headerWire) decode(d *codec.Decoder) error {
pairs, err := d.Map(7)
if err != nil {
return err
}
var seen uint
for range pairs {
k, err := d.Key()
if err != nil {
return err
}
switch k {
case 0:
_, err = d.Text(len(HeaderTypeTag))
case 1:
_, err = d.Uint(HeaderVersion)
case 2:
w.CapsuleID, err = d.Bstr(CapsuleIDSize, CapsuleIDSize)
case 3:
w.DateKey, err = d.Text(MaxPublicHeaderLen)
case 4:
// Compared as read, before any narrowing to Policy, which would
// let 256, 257, 2^32 and the like pass as a V1 policy.
if w.Policy, err = d.Uint(codec.MaxSafeUint); err == nil && w.Policy > uint64(TimeAndKey) {
err = fmt.Errorf("access_policy %d is not defined in V1: %w", w.Policy, datekeys.ErrNonCanonicalCBOR)
}
case 5:
w.Critical, err = extension.DecodeArray(d)
case 6:
w.Noncritical, err = extension.DecodeArray(d)
default:
return fmt.Errorf("key %d is not defined: %w", k, datekeys.ErrNonCanonicalCBOR)
}
if err != nil {
return fmt.Errorf("key %d: %w", k, err)
}
seen |= 1 << k
}
if err := required(seen, 5); err != nil {
return err
}
return d.EndMap()
}
// nonEmpty is 1 for an extension array that is written and 0 for one that is
// omitted (spec §58.1).
func nonEmpty(exts []extension.Extension) int {
if len(exts) == 0 {
return 0
}
return 1
}
// encodeExtensions writes the critical and noncritical arrays at keys key and
// key+1, each only when it is not empty.
func encodeExtensions(e *codec.Encoder, key uint64, critical, noncritical []extension.Extension) {
for i, exts := range [][]extension.Extension{critical, noncritical} {
if len(exts) > 0 {
e.Uint(key + uint64(i))
extension.EncodeArray(e, exts)
}
}
}
// required checks that seen holds the keys 0 to n-1, which are required.
func required(seen uint, n int) error {
for k := range n {
if seen&(1<<k) == 0 {
return fmt.Errorf("key %d is missing: %w", k, datekeys.ErrNonCanonicalCBOR)
}
}
return nil
}
// CapsuleIDHex returns the capsule_id in hexadecimal.
@ -173,23 +265,23 @@ func EncodeHeader(h *Header) ([]byte, error) {
return nil, fmt.Errorf("capsule: unknown access policy %d", h.Policy)
}
w := headerWire{
Type: HeaderTypeTag,
Version: HeaderVersion,
CapsuleID: h.CapsuleID[:],
DateKey: compact,
Policy: uint64(h.Policy),
}
var err error
if w.Critical, err = extension.Encode(h.Critical); err != nil {
if w.Critical, err = extension.Canonical(h.Critical); err != nil {
return nil, err
}
if w.Noncritical, err = extension.Encode(h.Noncritical); err != nil {
if w.Noncritical, err = extension.Canonical(h.Noncritical); err != nil {
return nil, err
}
if err := extension.CheckDisjoint(h.Critical, h.Noncritical); err != nil {
if err := extension.CheckDisjoint(w.Critical, w.Noncritical); err != nil {
return nil, err
}
b, err := codec.Marshal(w)
var e codec.Encoder
w.encode(&e)
b, err := e.Out()
if err != nil {
return nil, err
}
@ -200,10 +292,11 @@ func EncodeHeader(h *Header) ([]byte, error) {
}
// DecodeHeader validates and decodes PUBLIC_HEADER bytes (spec §24, §27,
// §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.
// §63 step 4): the §57 limit, the schema version, canonical CBOR, the schema
// with a 16-byte capsule_id, a V1 access policy and well-formed extension
// arrays, and then a canonical DateKey, so that a header that also breaks the
// CDDL reports ErrNonCanonicalCBOR. 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)
@ -212,30 +305,19 @@ func DecodeHeader(b []byte) (*Header, error) {
return nil, fmt.Errorf("capsule: PUBLIC_HEADER: %w", err)
}
var w headerWire
if err := codec.Unmarshal(b, &w); err != nil {
if err := codec.Unmarshal(b, w.decode, w.encode); err != nil {
return nil, fmt.Errorf("capsule: PUBLIC_HEADER: %w", err)
}
if len(w.CapsuleID) != CapsuleIDSize {
return nil, fmt.Errorf("capsule: capsule_id is %d bytes, want %d: %w", len(w.CapsuleID), CapsuleIDSize, datekeys.ErrNonCanonicalCBOR)
if err := extension.CheckDisjoint(w.Critical, w.Noncritical); err != nil {
return nil, fmt.Errorf("capsule: PUBLIC_HEADER: %w", err)
}
dk, err := datekey.Parse(w.DateKey)
if err != nil {
return nil, fmt.Errorf("capsule: PUBLIC_HEADER: %w", err)
}
if w.Policy > 1 {
return nil, fmt.Errorf("capsule: access_policy %d is not defined in V1: %w", w.Policy, datekeys.ErrNonCanonicalCBOR)
}
h := &Header{DateKey: dk, Policy: Policy(w.Policy)}
// decode bounds w.Policy to 0 or 1, so the conversion is exact.
h := &Header{DateKey: dk, Policy: Policy(w.Policy), Critical: w.Critical, Noncritical: w.Noncritical}
copy(h.CapsuleID[:], w.CapsuleID)
if h.Critical, err = extension.Decode(w.Critical); err != nil {
return nil, fmt.Errorf("capsule: PUBLIC_HEADER critical_extensions: %w", err)
}
if h.Noncritical, err = extension.Decode(w.Noncritical); err != nil {
return nil, fmt.Errorf("capsule: PUBLIC_HEADER noncritical_extensions: %w", err)
}
if err := extension.CheckDisjoint(h.Critical, h.Noncritical); err != nil {
return nil, fmt.Errorf("capsule: PUBLIC_HEADER: %w", err)
}
return h, nil
}
@ -250,13 +332,66 @@ type Control struct {
Noncritical []extension.Extension // key 5
}
// controlWire is CONTROL_CBOR as it is encoded: keys 2 to 5, keys 0 and 1
// being the constants ControlTypeTag and ControlVersion.
type controlWire struct {
Type string `cbor:"0,keyasint"`
Version uint64 `cbor:"1,keyasint"`
HeaderBinding []byte `cbor:"2,keyasint"`
PayloadIdentity []byte `cbor:"3,keyasint"`
Critical []extension.Wire `cbor:"4,keyasint,omitempty"`
Noncritical []extension.Wire `cbor:"5,keyasint,omitempty"`
HeaderBinding []byte // key 2
PayloadIdentity []byte // key 3, SECRET
Critical []extension.Extension // key 4, omitted when empty
Noncritical []extension.Extension // key 5, omitted when empty
}
func (w *controlWire) encode(e *codec.Encoder) {
e.Map(4 + nonEmpty(w.Critical) + nonEmpty(w.Noncritical))
e.Uint(0)
e.Text(ControlTypeTag)
e.Uint(1)
e.Uint(ControlVersion)
e.Uint(2)
e.Bstr(w.HeaderBinding)
e.Uint(3)
e.Bstr(w.PayloadIdentity)
encodeExtensions(e, 4, w.Critical, w.Noncritical)
}
// decode reads CONTROL_CBOR with every CDDL rule. The caller wipes
// PayloadIdentity, whatever the result.
func (w *controlWire) decode(d *codec.Decoder) error {
pairs, err := d.Map(6)
if err != nil {
return err
}
var seen uint
for range pairs {
k, err := d.Key()
if err != nil {
return err
}
switch k {
case 0:
_, err = d.Text(len(ControlTypeTag))
case 1:
_, err = d.Uint(ControlVersion)
case 2:
w.HeaderBinding, err = d.Bstr(32, 32)
case 3:
w.PayloadIdentity, err = d.Bstr(32, 32)
case 4:
w.Critical, err = extension.DecodeArray(d)
case 5:
w.Noncritical, err = extension.DecodeArray(d)
default:
return fmt.Errorf("key %d is not defined: %w", k, datekeys.ErrNonCanonicalCBOR)
}
if err != nil {
return fmt.Errorf("key %d: %w", k, err)
}
seen |= 1 << k
}
if err := required(seen, 4); err != nil {
return err
}
return d.EndMap()
}
// String describes c without I_PAYLOAD.
@ -271,22 +406,22 @@ func (c Control) GoString() string { return c.String() }
// wipe the result: it contains I_PAYLOAD.
func EncodeControl(c *Control) ([]byte, error) {
w := controlWire{
Type: ControlTypeTag,
Version: ControlVersion,
HeaderBinding: c.HeaderBinding[:],
PayloadIdentity: c.PayloadIdentity[:],
}
var err error
if w.Critical, err = extension.Encode(c.Critical); err != nil {
if w.Critical, err = extension.Canonical(c.Critical); err != nil {
return nil, err
}
if w.Noncritical, err = extension.Encode(c.Noncritical); err != nil {
if w.Noncritical, err = extension.Canonical(c.Noncritical); err != nil {
return nil, err
}
if err := extension.CheckDisjoint(c.Critical, c.Noncritical); err != nil {
if err := extension.CheckDisjoint(w.Critical, w.Noncritical); err != nil {
return nil, err
}
return codec.Marshal(w)
var e codec.Encoder
w.encode(&e)
return e.Out()
}
// DecodeControl validates and decodes CONTROL_CBOR (spec §31, §63 step 14).
@ -296,26 +431,16 @@ func DecodeControl(b []byte) (*Control, error) {
return nil, fmt.Errorf("capsule: CONTROL_CBOR: %w", err)
}
var w controlWire
if err := codec.Unmarshal(b, &w); err != nil {
defer func() { clear(w.PayloadIdentity) }()
if err := codec.Unmarshal(b, w.decode, w.encode); err != nil {
return nil, fmt.Errorf("capsule: CONTROL_CBOR: %w", err)
}
defer clear(w.PayloadIdentity)
if len(w.HeaderBinding) != 32 || len(w.PayloadIdentity) != 32 {
return nil, fmt.Errorf("capsule: header_binding and payload_identity must be 32 bytes: %w", datekeys.ErrNonCanonicalCBOR)
if err := extension.CheckDisjoint(w.Critical, w.Noncritical); err != nil {
return nil, fmt.Errorf("capsule: CONTROL_CBOR: %w", err)
}
c := &Control{}
c := &Control{Critical: w.Critical, Noncritical: w.Noncritical}
copy(c.HeaderBinding[:], w.HeaderBinding)
copy(c.PayloadIdentity[:], w.PayloadIdentity)
var err error
if c.Critical, err = extension.Decode(w.Critical); err != nil {
return nil, fmt.Errorf("capsule: CONTROL_CBOR critical_extensions: %w", err)
}
if c.Noncritical, err = extension.Decode(w.Noncritical); err != nil {
return nil, fmt.Errorf("capsule: CONTROL_CBOR noncritical_extensions: %w", err)
}
if err := extension.CheckDisjoint(c.Critical, c.Noncritical); err != nil {
return nil, fmt.Errorf("capsule: CONTROL_CBOR: %w", err)
}
return c, nil
}

@ -11,9 +11,9 @@ 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/cbortest"
"g.activething.com/go/DateKeys/internal/testkit"
"g.activething.com/go/DateKeys/profile"
)
@ -72,7 +72,7 @@ func TestEncodeHeaderAndControlReject(t *testing.T) {
func marshal(t *testing.T, m map[uint64]any) []byte {
t.Helper()
b, err := codec.Marshal(m)
b, err := cbortest.Marshal(m)
if err != nil {
t.Fatal(err)
}

@ -63,6 +63,16 @@ func FuzzParsePrelude(f *testing.F) {
func FuzzDecodeHeader(f *testing.F) {
seedFixtures(f, func(p testkit.Parts) []byte { return p.Header })
// access_policy in a multi-byte head whose low byte is a V1 policy: a
// narrowing before the check accepted them (spec §25).
dk := datekey.DateKey{ProfileID: profile.QuicknetID, Round: 1000}.Compact()
for _, p := range []uint64{256, 257, 1 << 32} {
h, err := testkit.RawHeader([capsule.CapsuleIDSize]byte{1}, dk, p)
if err != nil {
f.Fatal(err)
}
f.Add(h)
}
f.Fuzz(func(t *testing.T, b []byte) {
h, err := capsule.DecodeHeader(b)
if err != nil {

@ -0,0 +1,49 @@
package capsule
import (
"bytes"
"errors"
"testing"
"filippo.io/age"
datekeys "g.activething.com/go/DateKeys"
)
// decryptAll reads the plaintext into one buffer of the ciphertext's size, so
// that no outgrown buffer keeps a copy of I_PAYLOAD, and still reports a
// truncated STREAM, which the age reader signals with io.ErrUnexpectedEOF.
func TestDecryptAll(t *testing.T) {
id, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
const chunk = 64 << 10
for _, size := range []int{0, 1, 511, 512, 513, chunk, chunk + 1, 2*chunk + 100} {
plaintext := bytes.Repeat([]byte{0xab}, size)
ct, err := encryptAll(plaintext, id.Recipient())
if err != nil {
t.Fatal(err)
}
out, err := decryptAll(ct, id)
if err != nil || !bytes.Equal(out, plaintext) {
t.Fatalf("%d bytes: %v", size, err)
}
if cap(out) != len(ct) {
t.Errorf("%d bytes: a buffer of %d bytes for a ciphertext of %d", size, cap(out), len(ct))
}
if size == 0 {
continue
}
// The last chunk removed: a truncation at a chunk boundary.
last := size % chunk
if last == 0 {
last = chunk
}
for _, cut := range []int{len(ct) - 1, len(ct) - (last + 16)} {
if _, err := decryptAll(ct[:cut], id); !errors.Is(err, datekeys.ErrIntegrity) {
t.Errorf("%d bytes cut to %d of %d: %v", size, cut, len(ct), err)
}
}
}
}

@ -3,85 +3,14 @@ package capsule_test
import (
"bytes"
"context"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"io"
"strings"
"testing"
"filippo.io/age"
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"
"g.activething.com/go/DateKeys/profile"
"g.activething.com/go/DateKeys/provider"
)
// mutation is one entry of the mutation corpus (spec §64): a function over a
// valid fixture that must fail with one exact normative error at one step.
type mutation struct {
name string
// 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
step int
// network reports whether the failure may happen after a release was
// requested. Failures of steps 1 to 8 and of the access pre-checks must
// not cause any request (spec §27, §63).
network bool
}
type env struct {
to, tk *fixture // time_only and time_and_key_portable fixtures
toParts testkit.Parts
tkParts testkit.Parts
sibling []byte // another time_only capsule for the same round
stranger *age.X25519Identity
}
func newEnv(t *testing.T) *env {
e := &env{to: loadFixture(t, "time_only"), tk: loadFixture(t, "time_and_key_portable")}
var err error
if e.toParts, err = testkit.Split(e.to.dkc); err != nil {
t.Fatal(err)
}
if e.tkParts, err = testkit.Split(e.tk.dkc); err != nil {
t.Fatal(err)
}
var b bytes.Buffer
p := profile.Quicknet()
unlock, _ := datekey.RoundTime(p, 1000)
if _, err := capsule.Encrypt(&b, strings.NewReader("sibling"), capsule.EncryptOptions{Profile: p, UnlockAt: unlock, Now: testkit.Fixed(testkit.Genesis())}); err != nil {
t.Fatal(err)
}
e.sibling = b.Bytes()
e.stranger, _ = age.GenerateX25519Identity()
return e
}
func withSource(o capsule.OpenOptions, s provider.ReleaseSource) capsule.OpenOptions {
o.Source = s
return o
}
func set(b []byte, i int, v byte) []byte {
c := bytes.Clone(b)
c[i] = v
return c
}
func xorLast(b []byte) []byte {
c := bytes.Clone(b)
c[len(c)-1] ^= 0x01
return c
}
const mutationsFile = "../testdata/vectors/mutations.json"
// build returns a capsule made by testkit.Build and the options to open it.
func build(t *testing.T, b testkit.Build) ([]byte, capsule.OpenOptions) {
@ -96,392 +25,79 @@ func build(t *testing.T, b testkit.Build) ([]byte, capsule.OpenOptions) {
return out.DKC, capsule.OpenOptions{Registry: testkit.Registry(), Source: testkit.NewSource(testkit.Release(1000)), Now: testkit.Fixed(testkit.Genesis().AddDate(1, 0, 0))}
}
func headerWithDateKey(t *testing.T, e *env, dk string) []byte {
t.Helper()
h, err := capsule.DecodeHeader(e.toParts.Header)
// Spec §64: every mutation of the corpus (testkit.Mutations), built afresh,
// fails with its exact normative error at its exact step, and a failure
// before the release request causes no request.
func TestMutationCorpus(t *testing.T) {
e, err := testkit.NewMutationEnv(fixtureDir)
if err != nil {
t.Fatal(err)
}
raw, err := testkit.RawHeader(h.CapsuleID, dk, 0)
if err != nil {
t.Fatal(err)
n := 0
for _, m := range testkit.Mutations() {
if m.Spec {
n++
}
t.Run(m.Name, func(t *testing.T) {
in, err := m.Make(e)
if err != nil {
t.Fatal(err)
}
if err := m.Check(in); err != nil {
t.Fatal(err)
}
})
}
if n != 23 {
t.Fatalf("spec §64 lists 23 mutations, the corpus has %d", n)
}
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 {
// The exported corpus, testdata/vectors/mutations.json, holds every mutation
// of the corpus in order, and replaying each case from the file alone gives
// exactly the recorded error and step. The capsules that are not built with
// randomness are the ones the corpus derives from the fixtures.
func TestExportedMutationCorpus(t *testing.T) {
var f testkit.MutationFile
if err := testkit.ReadJSON(mutationsFile, &f); err != nil {
t.Fatal(err)
}
m[6] = exts
h, err := codec.Marshal(m)
e, err := testkit.NewMutationEnv(fixtureDir)
if err != nil {
t.Fatal(err)
}
return testkit.Reframe(e.toParts.Prelude, h, e.toParts.Sealed, e.toParts.Payload)
}
func policyByte(t *testing.T, header []byte) int {
// The access_policy entry is the last one of a header without extensions: 0x04 <value>.
i := len(header) - 2
if header[i] != 0x04 {
t.Fatalf("unexpected header layout %x", header[i:])
muts := testkit.Mutations()
if len(f.Cases) != len(muts) {
t.Fatalf("%d exported cases, the corpus has %d mutations", len(f.Cases), len(muts))
}
return i + 1
}
var mutations = []mutation{
// ---- 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)
return testkit.Reframe(e.toParts.Prelude, e.toParts.Header, b.Sealed, b.Payload), e.to.openOptions(t)
}},
{name: "SEALED_CONTROL_A + PAYLOAD_AGE_B", spec: true, want: datekeys.ErrIntegrity, step: 17, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
b, _ := testkit.Split(e.sibling)
return testkit.Join(e.toParts.Prelude, e.toParts.Header, e.toParts.Sealed, b.Payload), e.to.openOptions(t)
}},
{name: "DateKey A + release of round B", spec: true, want: datekeys.ErrRoundMismatch, step: 10, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
src := provider.ReleaseSourceFunc(func(context.Context, *profile.Profile, provider.Condition) (provider.Release, error) {
return testkit.Release(1001), nil
})
return e.to.dkc, withSource(e.to.openOptions(t), src)
}},
{name: "chain hash changed", spec: true, want: datekeys.ErrProfileMismatch, step: 8,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
p := profile.Quicknet()
other := strings.Repeat("ab", 32)
return bytes.Replace(e.to.dkc, []byte(p.ChainHashHex()), []byte(other), 1), e.to.openOptions(t)
}},
{name: "version changed", spec: true, want: datekeys.ErrUnsupportedVersion, step: 2,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return set(e.to.dkc, 4, 2), e.to.openOptions(t)
}},
{name: "flags != 0", spec: true, want: datekeys.ErrInvalidFlags, step: 2,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return set(e.to.dkc, 5, 0x80), e.to.openOptions(t)
}},
{name: "reserved != 0", spec: true, want: datekeys.ErrInvalidFlags, step: 2,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return set(e.to.dkc, 7, 1), e.to.openOptions(t)
}},
{name: "payload truncated", spec: true, want: datekeys.ErrIntegrity, step: 17, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return e.to.dkc[:len(e.to.dkc)-1], e.to.openOptions(t)
}},
{name: "payload age modified", spec: true, want: datekeys.ErrIntegrity, step: 17, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return xorLast(e.to.dkc), e.to.openOptions(t)
}},
{name: "control modified", spec: true, want: datekeys.ErrIntegrity, step: 11, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return testkit.Join(e.toParts.Prelude, e.toParts.Header, xorLast(e.toParts.Sealed), e.toParts.Payload), e.to.openOptions(t)
}},
{name: "non-canonical dk1_ JSON", spec: true, want: datekeys.ErrDateKeyNonCanonical, step: 4,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
dk := datekey.Prefix + b64(`{"version":1, "network":"datekeys:quicknet:v1", "round":1000}`)
return headerWithDateKey(t, e, dk), e.to.openOptions(t)
}},
{name: "unknown profile", spec: true, want: datekeys.ErrUnknownProfile, step: 4,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
dk := datekey.DateKey{ProfileID: "datekeys:evmnet:v1", Round: 1000}
return headerWithDateKey(t, e, dk.Compact()), e.to.openOptions(t)
}},
{name: "release of another round", spec: true, want: datekeys.ErrReleaseInvalid, step: 10, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
forged := provider.Release{Round: 1000, Signature: testkit.Release(1001).Signature}
return e.to.dkc, withSource(e.to.openOptions(t), testkit.NewSource(forged))
}},
{name: "access_policy=time_only with time_and_key structure", spec: true, want: datekeys.ErrPolicyStructureMismatch, step: 12, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
h := set(e.tkParts.Header, policyByte(t, e.tkParts.Header), 0)
return testkit.Join(e.tkParts.Prelude, h, e.tkParts.Sealed, e.tkParts.Payload), e.tk.openOptions(t)
}},
{name: "access_policy=time_and_key with time_only structure", spec: true, want: datekeys.ErrPolicyStructureMismatch, step: 12, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
h := set(e.toParts.Header, policyByte(t, e.toParts.Header), 1)
o := e.to.openOptions(t)
o.Identities = []age.Identity{e.stranger}
return testkit.Join(e.toParts.Prelude, h, e.toParts.Sealed, e.toParts.Payload), o
}},
{name: "extra stanza in OUTER_TIME_AGE", spec: true, want: datekeys.ErrPolicyStructureMismatch, step: 5,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return build(t, testkit.Build{EditOuter: func(fk []byte, s []*age.Stanza) []*age.Stanza {
extra, _, _ := testkit.X25519Stanza(fk)
return append(s, extra)
}})
}},
{name: "extra stanza in PAYLOAD_AGE", spec: true, want: datekeys.ErrPolicyStructureMismatch, step: 6,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return build(t, testkit.Build{EditPayload: func(fk []byte, s []*age.Stanza) []*age.Stanza {
extra, _, _ := testkit.X25519Stanza(fk)
return append(s, extra)
}})
}},
{name: "non-X25519 stanza in INNER_ACCESS_AGE", spec: true, want: datekeys.ErrPolicyStructureMismatch, step: 12, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
dkc, o := build(t, testkit.Build{Declared: capsule.TimeAndKey, Structure: capsule.TimeAndKey,
AccessRecipients: []age.Recipient{e.stranger.Recipient()},
EditInner: func(fk []byte, s []*age.Stanza) []*age.Stanza {
return append(s, &age.Stanza{Type: "scrypt", Args: []string{"c2FsdHNhbHRzYWx0c2FsdA", "10"}, Body: make([]byte, 32)})
}})
o.Identities = []age.Identity{e.stranger}
return dkc, o
}},
{name: "tlock stanza round differs from DateKey.round", spec: true, want: datekeys.ErrRoundMismatch, step: 8,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return build(t, testkit.Build{EditOuter: func(_ []byte, s []*age.Stanza) []*age.Stanza { s[0].Args[0] = "1001"; return s }})
}},
{name: "tlock stanza chain hash differs from the pinned profile", spec: true, want: datekeys.ErrProfileMismatch, step: 8,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return build(t, testkit.Build{EditOuter: func(_ []byte, s []*age.Stanza) []*age.Stanza {
s[0].Args[1] = "dbd506d6ef76e5f386f41c651dcb808c5bcbd75471cc4eafa3f4df7ad4e4c493" // drand default chain
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)}
for i, c := range f.Cases {
m := muts[i]
t.Run(c.Name, func(t *testing.T) {
if c.Name != m.Name || c.Spec != m.Spec || c.Error != m.Want.Code() || c.Step != m.Step || c.Network != m.Network || c.Frozen != m.Random {
t.Fatalf("exported case %+v does not match mutation %q", c, m.Name)
}
return headerWithExtensions(t, e, exts), e.to.openOptions(t)
}},
// ---- Further cases ----------------------------------------------------
{name: "magic", want: datekeys.ErrInvalidMagic, step: 1,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return set(e.to.dkc, 0, 'X'), e.to.openOptions(t)
}},
{name: "a .dkk offered as a .dkc", want: datekeys.ErrInvalidMagic, step: 1,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return append([]byte("DKK1"), e.to.dkc[4:]...), e.to.openOptions(t)
}},
{name: "empty file", want: datekeys.ErrInvalidMagic, step: 1,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) { return nil, e.to.openOptions(t) }},
{name: "truncated prelude", want: datekeys.ErrIntegrity, step: 1,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) { return e.to.dkc[:10], e.to.openOptions(t) }},
{name: "PUBLIC_HEADER_LEN above the limit", want: datekeys.ErrIntegrity, step: 2,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
c := bytes.Clone(e.to.dkc)
binary.BigEndian.PutUint32(c[8:12], capsule.MaxPublicHeaderLen+1)
return c, e.to.openOptions(t)
}},
{name: "SEALED_CONTROL_LEN above the limit", want: datekeys.ErrIntegrity, step: 2,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
c := bytes.Clone(e.to.dkc)
binary.BigEndian.PutUint32(c[12:16], capsule.MaxSealedControlLen+1)
return c, e.to.openOptions(t)
}},
{name: "truncated inside SEALED_CONTROL", want: datekeys.ErrIntegrity, step: 5,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return e.to.dkc[:len(e.toParts.Prelude)+len(e.toParts.Header)+10], e.to.openOptions(t)
}},
{name: "header schema version changed", want: datekeys.ErrUnsupportedVersion, step: 4,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
// a5 00 6a "datekeycap" 01 <version>
return set(e.to.dkc, capsule.PreludeSize+14, 2), e.to.openOptions(t)
}},
{name: "unknown key in PUBLIC_HEADER", want: datekeys.ErrNonCanonicalCBOR, step: 4,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
h := append(bytes.Clone(e.toParts.Header), 0x07, 0x00)
h[0]++ // one more map entry
return testkit.Reframe(e.toParts.Prelude, h, e.toParts.Sealed, e.toParts.Payload), e.to.openOptions(t)
}},
{name: "undefined access_policy", want: datekeys.ErrNonCanonicalCBOR, step: 4,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return testkit.Join(e.toParts.Prelude, set(e.toParts.Header, policyByte(t, e.toParts.Header), 2), e.toParts.Sealed, e.toParts.Payload), e.to.openOptions(t)
}},
{name: "unknown critical PUBLIC_HEADER extension", want: datekeys.ErrExtensionCriticalUnknown, step: 4,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return build(t, testkit.Build{HeaderCritical: []extension.Extension{{ID: "org.example.must-understand", Version: 1}}})
}},
{name: "unknown critical CONTROL_CBOR extension", want: datekeys.ErrExtensionCriticalUnknown, step: 14, network: true,
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)
o.AccessKey = nil
return e.tk.dkc, o
}},
{name: ".dkk of another capsule", want: datekeys.ErrAccessInvalid, step: 9,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
o := e.tk.openOptions(t)
other := loadFixture(t, "time_and_key_recipients")
o.AccessKey = other.dkk
return e.tk.dkc, o
}},
{name: "capsule_digest of the .dkk does not match", want: datekeys.ErrAccessInvalid, step: 9,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return xorLast(e.tk.dkc), e.tk.openOptions(t)
}},
{name: "identity that is not a recipient", want: datekeys.ErrAccessInvalid, step: 13, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
o := e.tk.openOptions(t)
o.AccessKey = nil
o.Identities = []age.Identity{e.stranger}
return e.tk.dkc, o
}},
{name: "round not reached yet", want: datekeys.ErrReleaseUnavailable, step: 9,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
o := e.to.openOptions(t)
o.Now = testkit.Fixed(e.to.unlock(t).Add(-1))
return e.to.dkc, o
}},
{name: "release source unavailable", want: datekeys.ErrReleaseUnavailable, step: 9, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return e.to.dkc, withSource(e.to.openOptions(t), testkit.NewSource())
}},
{name: "trailing data after PAYLOAD_AGE", want: datekeys.ErrIntegrity, step: 17, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return append(bytes.Clone(e.to.dkc), 0), e.to.openOptions(t)
}},
{name: "payload stanza body modified", want: datekeys.ErrIntegrity, step: 17, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
n, err := testkit.HeaderLen(e.toParts.Payload)
if err != nil {
if err := c.Check(fixtureDir); err != nil {
t.Fatal(err)
}
// Flip a byte of the wrapped file key: the last body line before "---".
i := bytes.LastIndex(e.toParts.Payload[:n], []byte("\n---")) - 10
c := byte('A')
if e.toParts.Payload[i] == 'A' {
c = 'B'
}
p := set(e.toParts.Payload, i, c)
return testkit.Join(e.toParts.Prelude, e.toParts.Header, e.toParts.Sealed, p), e.to.openOptions(t)
}},
{name: "tlock round edited by a third party", want: datekeys.ErrRoundMismatch, step: 8,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return bytes.Replace(e.to.dkc, []byte("-> tlock 1000 "), []byte("-> tlock 1001 "), 1), e.to.openOptions(t)
}},
{name: "empty registry", want: datekeys.ErrUnknownProfile, step: 4,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
o := e.to.openOptions(t)
o.Registry, _ = profile.NewRegistry()
return e.to.dkc, o
}},
{name: "time_only declared, time_and_key built by the creator", want: datekeys.ErrPolicyStructureMismatch, step: 12, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
return build(t, testkit.Build{Declared: capsule.TimeOnly, Structure: capsule.TimeAndKey, AccessRecipients: []age.Recipient{e.stranger.Recipient()}})
}},
{name: "time_and_key declared, time_only built by the creator", want: datekeys.ErrPolicyStructureMismatch, step: 12, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
dkc, o := build(t, testkit.Build{Declared: capsule.TimeAndKey, Structure: capsule.TimeOnly})
o.Identities = []age.Identity{e.stranger}
return dkc, o
}},
{name: "two INNER_ACCESS_AGE stanzas for one recipient", want: datekeys.ErrPolicyStructureMismatch, step: 13, network: true,
make: func(t *testing.T, e *env) ([]byte, capsule.OpenOptions) {
dkc, o := build(t, testkit.Build{Declared: capsule.TimeAndKey, Structure: capsule.TimeAndKey,
AccessRecipients: []age.Recipient{e.stranger.Recipient()},
EditInner: func(fk []byte, s []*age.Stanza) []*age.Stanza {
again, _ := e.stranger.Recipient().Wrap(fk)
return append(s, again[0])
}})
o.Identities = []age.Identity{e.stranger}
return dkc, o
}},
}
func TestMutationCorpus(t *testing.T) {
e := newEnv(t)
n := 0
for _, m := range mutations {
if m.spec {
n++
}
t.Run(m.name, func(t *testing.T) {
dkc, o := m.make(t, e)
counter := &countingSource{inner: o.Source}
o.Source = counter
opened, err := capsule.Open(context.Background(), io.Discard, bytes.NewReader(dkc), o)
if err == nil {
t.Fatal("mutation accepted")
if m.Random {
if c.DKC.Base != "" {
t.Fatal("a frozen capsule must not depend on a fixture")
}
return
}
if !errors.Is(err, m.want) {
t.Fatalf("got %v, want %v", err, m.want)
want, err := m.Make(e)
if err != nil {
t.Fatal(err)
}
if !m.network && counter.calls != 0 {
t.Fatalf("an invalid capsule caused %d release requests", counter.calls)
got, err := c.Input(fixtureDir)
if err != nil {
t.Fatal(err)
}
if m.step != 0 {
checks := checksOf(opened, dkc, o)
last := checks[len(checks)-1]
if last.OK || last.Step != m.step || last.Error != m.want.Code() {
t.Fatalf("failed at %+v, want step %d", last, m.step)
}
if !bytes.Equal(got.DKC, want.DKC) || !bytes.Equal(got.DKK, want.DKK) {
t.Fatal("the exported capsule or .dkk differs from the one the mutation derives")
}
})
}
if n != 23 {
t.Fatalf("spec §64 lists 23 mutations, the corpus has %d", n)
}
}
// checksOf returns the checks recorded for a failed Open; failures inside the
// inspection return no Opened, so the inspection is repeated for them.
func checksOf(opened *capsule.Opened, dkc []byte, o capsule.OpenOptions) []capsule.CheckResult {
if opened != nil {
return opened.Inspection.Checks
}
in, _ := capsule.Inspect(bytes.NewReader(dkc), capsule.InspectOptions{Registry: o.Registry, Extensions: o.Extensions})
return in.Checks
}
type countingSource struct {
inner provider.ReleaseSource
calls int
}
func (c *countingSource) Fetch(ctx context.Context, p *profile.Profile, cond provider.Condition) (provider.Release, error) {
c.calls++
return c.inner.Fetch(ctx, p, cond)
}
// Known critical extensions are accepted when the application declares them.
@ -496,5 +112,3 @@ func TestKnownCriticalExtensions(t *testing.T) {
}
}
}
func b64(s string) string { return base64.RawURLEncoding.EncodeToString([]byte(s)) }

@ -258,18 +258,31 @@ func checkCapsuleDigest(r io.ReadSeeker, start, payloadOffset int64, want []byte
}
// decryptAll opens a bounded, in-memory age file. The plaintext is never
// longer than the ciphertext.
// longer than the ciphertext, so it is read into one buffer of that size:
// the plaintext may hold I_PAYLOAD, and no outgrown buffer is left behind.
// The caller wipes the result; on error it is wiped here.
func decryptAll(ciphertext []byte, id age.Identity) ([]byte, error) {
r, err := age.Decrypt(bytes.NewReader(ciphertext), id)
if err != nil {
return nil, classify("age", err)
}
out, err := io.ReadAll(io.LimitReader(r, int64(len(ciphertext))))
if err != nil {
clear(out)
return nil, classify("age", err)
// Not io.ReadFull: it would turn the age reader's io.ErrUnexpectedEOF,
// a truncated STREAM, into the end of a short read.
out := make([]byte, len(ciphertext))
for n := 0; ; {
m, err := r.Read(out[n:])
n += m
switch {
case err == io.EOF:
clear(out[n:]) // Read may use the whole of its argument as scratch space.
return out[:n], nil
case err != nil:
clear(out)
return nil, classify("age", err)
case n == len(out):
return out, nil
}
}
return out, nil
}
// classify keeps the normative error an identity returned from Unwrap, and

@ -0,0 +1,127 @@
package capsule_test
import (
"errors"
"maps"
"slices"
"testing"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/capsule"
"g.activething.com/go/DateKeys/datekey"
"g.activething.com/go/DateKeys/internal/cbortest"
"g.activething.com/go/DateKeys/profile"
)
// swapped encodes m with the entries of keys a and b in each other's place.
func swapped(t *testing.T, m map[uint64]any, a, b uint64) []byte {
t.Helper()
var p cbortest.Pairs
for _, k := range slices.Sorted(maps.Keys(m)) {
switch k {
case a:
k = b
case b:
k = a
}
p = append(p, k, m[k])
}
out, err := cbortest.Marshal(p)
if err != nil {
t.Fatal(err)
}
return out
}
func with(m map[uint64]any, edit func(m map[uint64]any)) map[uint64]any {
c := maps.Clone(m)
edit(c)
return c
}
// Spec §58: the maps of PUBLIC_HEADER and CONTROL_CBOR are closed, their keys
// strictly ascending unsigned integers, and every required key is present.
func TestDecodeMapStructure(t *testing.T) {
dk := datekey.DateKey{ProfileID: profile.QuicknetID, Round: 1000}.Compact()
h := map[uint64]any{0: capsule.HeaderTypeTag, 1: uint64(1), 2: make([]byte, 16), 3: dk, 4: uint64(0)}
c := map[uint64]any{0: capsule.ControlTypeTag, 1: uint64(1), 2: make([]byte, 32), 3: make([]byte, 32)}
exts := []any{ext("a", 1)}
for name, in := range map[string][]byte{
"missing capsule_id": marshal(t, with(h, func(m map[uint64]any) { delete(m, 2) })),
"missing access_policy": marshal(t, with(h, func(m map[uint64]any) { delete(m, 4) })),
"capsule_id as text": marshal(t, with(h, func(m map[uint64]any) { m[2] = "x" })),
"access_policy 2^53": marshal(t, with(h, func(m map[uint64]any) { m[4] = uint64(1) << 53 })),
"unknown key 7": marshal(t, with(h, func(m map[uint64]any) { m[7] = uint64(0) })),
"eight entries": marshal(t, with(h, func(m map[uint64]any) { m[5], m[6], m[7] = exts, []any{ext("b", 1)}, uint64(0) })),
"keys 3 and 4 swapped": swapped(t, h, 3, 4),
"text key": mustMarshal(t, cbortest.Pairs{uint64(0), capsule.HeaderTypeTag, uint64(1), uint64(1), "2", make([]byte, 16)}),
} {
if _, err := capsule.DecodeHeader(in); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Errorf("PUBLIC_HEADER %s: %v", name, err)
}
}
// access_policy is 0 or 1 (spec §25): a value whose low byte is 0 or 1
// is not a V1 policy.
for _, p := range []uint64{2, 255, 256, 257, 512, 65536, 65537, 1 << 32, 1<<32 + 1, 1<<53 - 256, 1<<53 - 255, 1<<53 - 1} {
if _, err := capsule.DecodeHeader(marshal(t, with(h, func(m map[uint64]any) { m[4] = p }))); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Errorf("PUBLIC_HEADER access_policy %d: %v", p, err)
}
}
for name, in := range map[string][]byte{
"missing payload_identity": marshal(t, with(c, func(m map[uint64]any) { delete(m, 3) })),
"seven entries": marshal(t, with(c, func(m map[uint64]any) { m[4], m[5], m[6] = exts, []any{ext("b", 1)}, uint64(0) })),
"keys 2 and 3 swapped": swapped(t, c, 2, 3),
"text key": mustMarshal(t, cbortest.Pairs{uint64(0), capsule.ControlTypeTag, uint64(1), uint64(1), "2", make([]byte, 32)}),
} {
if _, err := capsule.DecodeControl(in); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Errorf("CONTROL_CBOR %s: %v", name, err)
}
}
}
func mustMarshal(t *testing.T, v any) []byte {
t.Helper()
b, err := cbortest.Marshal(v)
if err != nil {
t.Fatal(err)
}
return b
}
// Spec §70: the schema version is reported as such whatever follows it, and
// a version that is missing or outside the profile is not a version.
func TestDecodeSchemaVersion(t *testing.T) {
for name, tc := range map[string]struct {
in cbortest.Pairs
want error
}{
"version 2, rest unreadable": {cbortest.Pairs{uint64(0), capsule.HeaderTypeTag, uint64(1), uint64(2), uint64(2), cbortest.Raw{0xff}}, datekeys.ErrUnsupportedVersion},
"version missing": {cbortest.Pairs{uint64(0), capsule.HeaderTypeTag, uint64(2), make([]byte, 16)}, datekeys.ErrNonCanonicalCBOR},
"version null": {cbortest.Pairs{uint64(0), capsule.HeaderTypeTag, uint64(1), nil}, datekeys.ErrNonCanonicalCBOR},
"version before type": {cbortest.Pairs{uint64(1), uint64(1), uint64(0), capsule.HeaderTypeTag}, datekeys.ErrNonCanonicalCBOR},
} {
if _, err := capsule.DecodeHeader(mustMarshal(t, tc.in)); !errors.Is(err, tc.want) {
t.Errorf("%s: got %v, want %v", name, err, tc.want)
}
}
}
// A PUBLIC_HEADER that breaks the CDDL and holds an invalid DateKey reports
// the CDDL violation: the DateKey is parsed after the map (spec §57, §63
// step 4).
func TestDecodeHeaderReportsTheCDDLFirst(t *testing.T) {
h := map[uint64]any{0: capsule.HeaderTypeTag, 1: uint64(1), 2: make([]byte, 16), 3: "dk1_x", 4: uint64(0)}
if _, err := capsule.DecodeHeader(marshal(t, h)); !errors.Is(err, datekeys.ErrDateKeyInvalid) {
t.Fatalf("invalid DateKey alone: %v", err)
}
for name, edit := range map[string]func(m map[uint64]any){
"undefined access_policy": func(m map[uint64]any) { m[4] = uint64(2) },
"access_policy 256": func(m map[uint64]any) { m[4] = uint64(256) },
"extensions out of order": func(m map[uint64]any) { m[6] = []any{ext("b", 1), ext("a", 1)} },
"id in both arrays": func(m map[uint64]any) { m[5], m[6] = []any{ext("a", 1)}, []any{ext("a", 1)} },
} {
if _, err := capsule.DecodeHeader(marshal(t, with(h, edit))); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Errorf("%s and an invalid DateKey: %v", name, err)
}
}
}

@ -31,6 +31,7 @@ import (
"g.activething.com/go/DateKeys/accesskey"
"g.activething.com/go/DateKeys/capsule"
"g.activething.com/go/DateKeys/datekey"
"g.activething.com/go/DateKeys/internal/inspectview"
"g.activething.com/go/DateKeys/profile"
"g.activething.com/go/DateKeys/provider/drand"
)
@ -264,19 +265,6 @@ func readIdentities(path string) ([]age.Identity, error) {
return ids, nil
}
type inspectView struct {
File string `json:"file"`
CapsuleID string `json:"capsule_id,omitempty"`
DateKey string `json:"datekey,omitempty"`
Profile string `json:"profile,omitempty"`
Round uint64 `json:"round,omitempty"`
UnlockAt string `json:"unlock_at,omitempty"`
AccessPolicy string `json:"access_policy,omitempty"`
Valid bool `json:"valid"`
Error string `json:"error,omitempty"`
Checks []capsule.CheckResult `json:"checks"`
}
// inspect runs steps 1 to 8 only: it never requests a release and never uses
// a secret.
func inspect(args []string, stdout io.Writer) error {
@ -299,31 +287,13 @@ func inspect(args []string, stdout io.Writer) error {
}
defer f.Close()
result, inspectErr := capsule.Inspect(f, capsule.InspectOptions{Registry: reg})
v := inspectView{File: *in, Valid: inspectErr == nil, Error: datekeys.Code(inspectErr), Checks: result.Checks}
if h := result.Header; h != nil {
v.CapsuleID, v.DateKey, v.Profile, v.Round, v.AccessPolicy = h.CapsuleIDHex(), h.DateKey.Compact(), h.DateKey.ProfileID, h.DateKey.Round, h.Policy.String()
}
if !result.UnlockAt.IsZero() {
v.UnlockAt = result.UnlockAt.Format(time.RFC3339)
}
v := inspectview.New(*in, result, inspectErr)
if *asJSON {
enc := json.NewEncoder(stdout)
enc.SetIndent("", " ")
if err := enc.Encode(v); err != nil {
if err := v.WriteJSON(stdout); err != nil {
return err
}
} else {
fmt.Fprintf(stdout, "%s\n", v.File)
for _, c := range v.Checks {
mark := "ok "
if !c.OK {
mark = "FAIL"
}
fmt.Fprintf(stdout, " [%s] step %2d %-26s %s\n", mark, c.Step, c.Name, c.Detail)
}
if v.Valid {
fmt.Fprintf(stdout, " valid before unlock; opens at %s (round %d, %s)\n", v.UnlockAt, v.Round, v.AccessPolicy)
}
v.WriteText(stdout)
}
return inspectErr
}

@ -18,6 +18,7 @@ import (
"filippo.io/age"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/internal/inspectview"
"g.activething.com/go/DateKeys/internal/testkit"
"g.activething.com/go/DateKeys/profile"
)
@ -169,7 +170,7 @@ func TestEncryptDecryptRoundTrip(t *testing.T) {
if err != nil {
t.Fatal(err)
}
var v inspectView
var v inspectview.View
if err := json.Unmarshal([]byte(stdout), &v); err != nil || !v.Valid || v.Round != 1000 || v.AccessPolicy != "time_and_key" {
t.Fatalf("inspect: %+v %v", v, err)
}
@ -200,6 +201,36 @@ func TestInspectReportsFailures(t *testing.T) {
}
}
// The frozen inspect outputs (testdata/fixtures/<name>.inspect.json) are
// exactly what "datekeys inspect -json -in <name>.dkc" prints in the fixture
// directory.
func TestInspectJSONGoldens(t *testing.T) {
names, err := filepath.Glob(filepath.Join(fixtures, "*.inspect.json"))
if err != nil {
t.Fatal(err)
}
if len(names) != 5 {
t.Fatalf("%d frozen inspect outputs, want one per official .dkc (5)", len(names))
}
t.Chdir(fixtures)
for _, path := range names {
name := strings.TrimSuffix(filepath.Base(path), ".inspect.json")
t.Run(name, func(t *testing.T) {
want, err := os.ReadFile(name + ".inspect.json")
if err != nil {
t.Fatal(err)
}
stdout, _, err := cli(t, later, "inspect", "-json", "-in", name+".dkc")
if err != nil {
t.Fatal(err)
}
if stdout != string(want) {
t.Fatalf("output differs from %s.inspect.json:\n%s", name, stdout)
}
})
}
}
func TestResolveAndProfile(t *testing.T) {
stdout, _, err := cli(t, later, "datekey", "resolve", "-at", "2030-01-01T00:00:00Z")
if err != nil || !strings.Contains(stdout, `"round":66884212`) || !strings.Contains(stdout, `"unlock_at":"2030-01-01T00:00:00Z"`) {

@ -1,144 +1,605 @@
// Package codec implements the Deterministic CBOR rules of spec §58 and §58.1.
// Package codec implements the CBOR profile of the DateKeys protocol (spec
// §58, §58.1) without reflection and without dependencies.
//
// Encoding uses RFC 8949 §4.2.1 Core Deterministic Encoding. Decoding is
// strict (no indefinite lengths, no tags, no duplicate keys, bounded depth and
// sizes, valid UTF-8, no unknown struct fields) and is always followed by a
// re-encoding that must reproduce the input byte for byte. Any difference is
// ErrNonCanonicalCBOR. The same principle as dk1_ canonicality (spec §19):
// canonicality does not depend on a library promising to reject every
// non-canonical form.
// The profile is Deterministic CBOR (RFC 8949 §4.2.1) restricted to major
// types 0 (unsigned integer), 2 (byte string), 3 (text string), 4 (array) and
// 5 (map), with unsigned integer map keys in strictly ascending order,
// integers and lengths in their shortest form, definite lengths only and
// valid UTF-8 text. Negative integers, tags, floats, simple values (false,
// true, null, undefined), indefinite lengths and every other map key are
// rejected with ErrNonCanonicalCBOR.
//
// 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.
// Each schema writes its own encoding with an Encoder and reads it with a
// Decoder, a strict cursor that reads exactly what the schema asks for.
// Unmarshal runs the decoder of a schema and then re-encodes what it decoded:
// the input must be reproduced byte for byte, or it is ErrNonCanonicalCBOR.
// The same principle as dk1_ canonicality (spec §19): canonicality does not
// depend on the decoder rejecting every non-canonical form.
//
// Peek reads the type tag and the schema version of an object before strict
// decoding (spec §70). Walk checks that bytes are one data item of the
// profile; it is a helper for vectors, fuzzing and diagnostics, and never
// decides whether an object of the protocol is valid.
package codec
import (
"bytes"
"encoding/binary"
"fmt"
"github.com/fxamacker/cbor/v2"
"math"
"unicode/utf8"
datekeys "g.activething.com/go/DateKeys"
)
// Decoding limits. Structural sizes are additionally bounded by the framing
// limits of spec §57 before any CBOR is decoded.
const (
MaxNestedLevels = 16
MaxArrayElements = 65536
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(encOptions().EncMode())
decMode = must(decOptions(true).DecMode())
peekMode = must(decOptions(false).DecMode())
// MaxTypeTagLen bounds the type tag that Peek reads. Every type tag of V1 is
// at most 25 bytes, so a longer one is of no known schema; the bound also
// keeps an input-sized tag out of the errors. It is an implementation limit
// (spec §74).
const MaxTypeTagLen = 64
// Major types of the profile (spec §58).
const (
majorUint = 0
majorBytes = 2
majorText = 3
majorArray = 4
majorMap = 5
)
// 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 {
o := cbor.DecOptions{
DupMapKey: cbor.DupMapKeyEnforcedAPF,
IndefLength: cbor.IndefLengthForbidden,
TagsMd: cbor.TagsForbidden,
MaxNestedLevels: MaxNestedLevels,
MaxArrayElements: MaxArrayElements,
MaxMapPairs: MaxMapPairs,
UTF8: cbor.UTF8RejectInvalid,
MapKeyByteString: cbor.MapKeyByteStringAllowed,
}
if strict {
o.ExtraReturnErrors = cbor.ExtraDecErrorUnknownField
}
return o
}
// must accepts only the static options above, which cannot be invalid.
func must[T any](m T, err error) T {
var majorNames = [8]string{
"an unsigned integer", "a negative integer", "a byte string", "a text string",
"an array", "a map", "a tag", "a float or simple value",
}
// errorf returns an error that wraps ErrNonCanonicalCBOR.
func errorf(format string, args ...any) error {
return fmt.Errorf("codec: "+format+": %w", append(args, datekeys.ErrNonCanonicalCBOR)...)
}
// ---------------------------------------------------------------------------
// Encoder
// Encoder writes the deterministic encoding of data items of the profile:
// every integer and length in its shortest form, definite lengths only. The
// first error is kept and later calls do nothing; Out returns it. The zero
// value is ready to use.
//
// An Encoder does not know the schema: the caller writes the map keys, as
// unsigned integers in ascending order, and as many entries and items as it
// announced. The decoder of the schema checks both on the output (spec §72).
//
// An encoding may hold secrets, such as I_PAYLOAD or access_material: the
// Encoder wipes every buffer it outgrows, so that the output is the only
// copy, and the caller wipes the output.
type Encoder struct {
buf []byte
err error
}
// grow makes room for n more bytes, wiping the buffer it outgrows.
func (e *Encoder) grow(n int) {
if cap(e.buf)-len(e.buf) >= n {
return
}
b := make([]byte, len(e.buf), 2*cap(e.buf)+n)
copy(b, e.buf)
clear(e.buf)
e.buf = b
}
// head appends the head of a data item: its major type and argument.
func (e *Encoder) head(major byte, arg uint64) {
if e.err != nil {
return
}
var h [9]byte
h[0] = major << 5
n := 1
switch {
case arg < 24:
h[0] |= byte(arg)
case arg <= math.MaxUint8:
h[0] |= 24
h[1] = byte(arg)
n = 2
case arg <= math.MaxUint16:
h[0] |= 25
binary.BigEndian.PutUint16(h[1:], uint16(arg))
n = 3
case arg <= math.MaxUint32:
h[0] |= 26
binary.BigEndian.PutUint32(h[1:], uint32(arg))
n = 5
default:
h[0] |= 27
binary.BigEndian.PutUint64(h[1:], arg)
n = 9
}
e.grow(n)
e.buf = append(e.buf, h[:n]...)
}
// Fail records err as the error of the encoding unless one is recorded
// already; a nil err is ignored. Every later call does nothing, and Out
// returns the first error. The encoder of a schema calls it when its value
// breaks a rule of the schema, so that bytes the decoder rejects are never
// returned.
func (e *Encoder) Fail(err error) {
if e.err == nil {
e.err = err
}
}
// Map writes the head of a map of the given number of entries. The caller
// then writes each key, with Uint, followed by its value.
func (e *Encoder) Map(pairs int) {
if pairs < 0 {
e.Fail(errorf("map of %d entries", pairs))
return
}
e.head(majorMap, uint64(pairs))
}
// Array writes the head of an array of the given number of items. The caller
// then writes each item.
func (e *Encoder) Array(items int) {
if items < 0 {
e.Fail(errorf("array of %d items", items))
return
}
e.head(majorArray, uint64(items))
}
// Uint writes an unsigned integer.
func (e *Encoder) Uint(v uint64) { e.head(majorUint, v) }
// Bstr writes a byte string. A nil slice is the empty byte string.
func (e *Encoder) Bstr(b []byte) {
e.head(majorBytes, uint64(len(b)))
if e.err == nil {
e.grow(len(b))
e.buf = append(e.buf, b...)
}
}
// Text writes a text string, which must be valid UTF-8.
func (e *Encoder) Text(s string) {
if !utf8.ValidString(s) {
e.Fail(errorf("text string %q is not valid UTF-8", s))
return
}
e.head(majorText, uint64(len(s)))
if e.err == nil {
e.grow(len(s))
e.buf = append(e.buf, s...)
}
}
// Out returns the encoding, or the first error. On error the partial output
// is wiped.
func (e *Encoder) Out() ([]byte, error) {
if e.err != nil {
clear(e.buf)
e.buf = nil
return nil, e.err
}
return e.buf, nil
}
// ---------------------------------------------------------------------------
// Decoder
// Decoder is a strict cursor over the encoding of one data item of the
// profile. Each method reads one data item, or one head, and rejects with
// ErrNonCanonicalCBOR a major type outside the profile, a major type other
// than the one asked for, an indefinite length, an integer or length not in
// its shortest form, a length beyond the remaining input and a value outside
// the bounds the caller gives. Within each open map the keys are unsigned
// integers in strictly ascending order.
//
// The first error is kept: every later call returns it.
type Decoder struct {
in []byte
off int
maps []openMap
err error
}
// openMap is the state of a map between Map and EndMap.
type openMap struct {
left uint64 // entries not read yet
last uint64 // last key read
started bool // at least one key was read
}
// NewDecoder returns a Decoder positioned at the start of in.
func NewDecoder(in []byte) *Decoder { return &Decoder{in: in} }
// fail records the first error, with the current offset, and returns it.
func (d *Decoder) fail(format string, args ...any) error {
if d.err == nil {
d.err = errorf("offset %d: "+format, append([]any{d.off}, args...)...)
}
return d.err
}
func (d *Decoder) remaining() int { return len(d.in) - d.off }
// head reads the head of the next data item and returns its major type and
// argument. It rejects the major types outside the profile, reserved values,
// indefinite lengths, arguments not in their shortest form and truncation.
func (d *Decoder) head() (byte, uint64, error) {
if d.err != nil {
return 0, 0, d.err
}
if d.off >= len(d.in) {
return 0, 0, d.fail("truncated input")
}
b := d.in[d.off]
major, info := b>>5, b&0x1f
switch major {
case 1, 6, 7:
return 0, 0, d.fail("%s (initial byte %#02x) is outside the CBOR profile", majorNames[major], b)
}
switch {
case info < 24:
d.off++
return major, uint64(info), nil
case info == 31:
return 0, 0, d.fail("indefinite length (initial byte %#02x)", b)
case info > 27:
return 0, 0, d.fail("reserved additional information (initial byte %#02x)", b)
}
n := 1 << (info - 24)
if d.remaining() < 1+n {
return 0, 0, d.fail("truncated input")
}
var arg, min uint64
p := d.in[d.off+1 : d.off+1+n]
switch n {
case 1:
arg, min = uint64(p[0]), 24
case 2:
arg, min = uint64(binary.BigEndian.Uint16(p)), math.MaxUint8+1
case 4:
arg, min = uint64(binary.BigEndian.Uint32(p)), math.MaxUint16+1
default:
arg, min = binary.BigEndian.Uint64(p), math.MaxUint32+1
}
if arg < min {
return 0, 0, d.fail("%d is not in its shortest form (initial byte %#02x)", arg, b)
}
d.off += 1 + n
return major, arg, nil
}
// expect reads the head of a data item of major type want.
func (d *Decoder) expect(want byte) (uint64, error) {
start := d.off
major, arg, err := d.head()
if err != nil {
panic("codec: invalid static options: " + err.Error())
return 0, err
}
if major != want {
d.off = start
return 0, d.fail("%s where %s was expected", majorNames[major], majorNames[want])
}
return m
return arg, nil
}
// Marshal returns the core deterministic CBOR encoding of v.
func Marshal(v any) ([]byte, error) {
b, err := encMode.Marshal(v)
// Map reads the head of a map of at most max entries and returns the number
// of entries. The caller reads each entry with Key and a value, then calls
// EndMap.
func (d *Decoder) Map(max int) (int, error) {
n, err := d.expect(majorMap)
if err != nil {
return nil, fmt.Errorf("codec: encode: %w", err)
return 0, err
}
if max < 0 || n > uint64(max) {
return 0, d.fail("map of %d entries, at most %d", n, max)
}
if n > uint64(d.remaining())/2 {
return 0, d.fail("truncated input: map of %d entries", n)
}
d.maps = append(d.maps, openMap{left: n})
return int(n), nil
}
// Key reads the key of the next entry of the innermost open map: an unsigned
// integer greater than the previous key of that map.
func (d *Decoder) Key() (uint64, error) {
if d.err != nil {
return 0, d.err
}
if len(d.maps) == 0 {
return 0, d.fail("map key outside a map")
}
m := &d.maps[len(d.maps)-1]
if m.left == 0 {
return 0, d.fail("map key after the last entry")
}
start := d.off
k, err := d.expect(majorUint)
if err != nil {
return 0, err
}
if m.started && k <= m.last {
d.off = start
return 0, d.fail("map key %d after key %d: keys must be strictly ascending", k, m.last)
}
m.left--
m.last, m.started = k, true
return k, nil
}
// EndMap closes the innermost open map, all of whose entries must have been
// read.
func (d *Decoder) EndMap() error {
if d.err != nil {
return d.err
}
if len(d.maps) == 0 {
return d.fail("end of a map outside a map")
}
if left := d.maps[len(d.maps)-1].left; left != 0 {
return d.fail("%d map entries not read", left)
}
d.maps = d.maps[:len(d.maps)-1]
return nil
}
// Array reads the head of an array of at most max items and returns the
// number of items, which the caller then reads.
func (d *Decoder) Array(max int) (int, error) {
n, err := d.expect(majorArray)
if err != nil {
return 0, err
}
if max < 0 || n > uint64(max) {
return 0, d.fail("array of %d items, at most %d", n, max)
}
if n > uint64(d.remaining()) {
return 0, d.fail("truncated input: array of %d items", n)
}
return int(n), nil
}
// Uint reads an unsigned integer of at most max.
func (d *Decoder) Uint(max uint64) (uint64, error) {
v, err := d.expect(majorUint)
if err != nil {
return 0, err
}
if v > max {
return 0, d.fail("unsigned integer %d above %d", v, max)
}
return v, nil
}
// content reads a string of major type want and returns its content, a
// subslice of the input. The length is checked against the remaining input
// and then against min and max.
func (d *Decoder) content(want byte, min, max int) ([]byte, error) {
n, err := d.expect(want)
if err != nil {
return nil, err
}
if n > uint64(d.remaining()) {
return nil, d.fail("truncated input: %s of %d bytes", majorNames[want], n)
}
if int(n) < min || int(n) > max {
return nil, d.fail("%s of %d bytes outside %d..%d", majorNames[want], n, min, max)
}
b := d.in[d.off : d.off+int(n)]
d.off += int(n)
return b, nil
}
// Unmarshal decodes exactly one CBOR data item from data into v, which must be
// a pointer, and then requires that re-encoding v reproduces data exactly.
// Every failure wraps datekeys.ErrNonCanonicalCBOR.
// Bstr reads a byte string of min to max bytes and returns a copy of its
// content. The length is checked before anything is copied.
func (d *Decoder) Bstr(min, max int) ([]byte, error) {
b, err := d.content(majorBytes, min, max)
if err != nil {
return nil, err
}
return bytes.Clone(b), nil
}
// Text reads a text string of at most max bytes of valid UTF-8.
func (d *Decoder) Text(max int) (string, error) {
start := d.off
b, err := d.content(majorText, 0, max)
if err != nil {
return "", err
}
if !utf8.Valid(b) {
d.off = start
return "", d.fail("text string is not valid UTF-8")
}
return string(b), nil
}
// Done checks that every map was closed and that no byte follows the data
// item.
func (d *Decoder) Done() error {
if d.err != nil {
return d.err
}
if len(d.maps) != 0 {
return d.fail("%d maps not closed", len(d.maps))
}
if d.off != len(d.in) {
return d.fail("%d trailing bytes", len(d.in)-d.off)
}
return nil
}
// next returns the major type of the next data item, or majorUint at the end
// of the input, where reading it reports the truncation.
func (d *Decoder) next() byte {
if d.off >= len(d.in) {
return majorUint
}
return d.in[d.off] >> 5
}
// ---------------------------------------------------------------------------
// Objects
// Unmarshal decodes one object from in with decode, checks that the whole
// input was read, and re-encodes the decoded value with encode: the result
// must reproduce in byte for byte. decode and encode are the two halves of
// one schema and work on the same value.
//
// Fields of type cbor.RawMessage are copied verbatim and are NOT covered by the
// re-encoding check; the caller must validate them.
// Errors of the Decoder and of the re-encoding wrap ErrNonCanonicalCBOR; any
// other error of decode is returned as it is.
//
// 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)
}
re, err := encMode.Marshal(v)
// The re-encoding equals in on success, so it may hold secrets such as
// I_PAYLOAD or access_material; it is wiped on every path, and so is every
// buffer the Encoder outgrows.
func Unmarshal(in []byte, decode func(*Decoder) error, encode func(*Encoder)) error {
d := NewDecoder(in)
if err := decode(d); err != nil {
return err
}
if err := d.Done(); err != nil {
return err
}
e := Encoder{buf: make([]byte, 0, len(in))}
encode(&e)
re, err := e.Out()
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)
if err != nil || !bytes.Equal(re, in) {
return errorf("input is not the deterministic encoding of its value")
}
return nil
}
// Peek decodes selected fields of a CBOR map, ignoring every other key and
// without the canonicality check. It exists only to read a type tag and a
// schema version before strict decoding, so that an unknown major version is
// reported as such (spec §70). Its result must never be used as the decoded
// object.
func Peek(data []byte, v any) error {
if err := peekMode.Unmarshal(data, v); err != nil {
return fmt.Errorf("codec: decode: %v: %w", err, datekeys.ErrNonCanonicalCBOR)
// Peek reads the type tag (key 0, a text string of at most MaxTypeTagLen
// bytes) and the schema version (key 1, an unsigned integer) of the map at the
// start of in, before strict decoding, so that an unknown schema version is
// reported as such (spec §70). The map must start with keys 0 and 1, in the
// profile; nothing after them is read. The result must never be used as the
// decoded object.
func Peek(in []byte) (typeTag string, version uint64, err error) {
d := NewDecoder(in)
// The input bounds the map.
pairs, err := d.Map(math.MaxInt)
if err != nil {
return "", 0, err
}
return nil
if pairs < 2 {
return "", 0, d.fail("map without a type tag and a schema version")
}
for want := range uint64(2) {
k, err := d.Key()
if err != nil {
return "", 0, err
}
if k != want {
return "", 0, d.fail("map key %d where key %d was expected", k, want)
}
if want == 0 {
typeTag, err = d.Text(MaxTypeTagLen)
} else {
version, err = d.Uint(MaxSafeUint)
}
if err != nil {
return "", 0, err
}
}
return typeTag, version, nil
}
// CheckSchema reads key 0 (type tag) and key 1 (schema version) of a CBOR map
// and requires the expected values. A different type tag is
// CheckSchema reads the type tag and the schema version of an object with
// Peek and requires the expected values. A different type tag is
// ErrNonCanonicalCBOR; a different version is ErrUnsupportedVersion.
func CheckSchema(data []byte, typeTag string, version uint64) error {
var h struct {
Type string `cbor:"0,keyasint"`
Version uint64 `cbor:"1,keyasint"`
}
if err := Peek(data, &h); err != nil {
func CheckSchema(in []byte, typeTag string, version uint64) error {
tag, v, err := Peek(in)
if err != nil {
return err
}
if h.Type != typeTag {
return fmt.Errorf("codec: type %q, want %q: %w", h.Type, typeTag, datekeys.ErrNonCanonicalCBOR)
if tag != typeTag {
return errorf("type %q, want %q", tag, typeTag)
}
if h.Version != version {
return fmt.Errorf("codec: %s schema version %d, want %d: %w", typeTag, h.Version, version, datekeys.ErrUnsupportedVersion)
if v != version {
return fmt.Errorf("codec: %s schema version %d, want %d: %w", typeTag, v, version, datekeys.ErrUnsupportedVersion)
}
return nil
}
// Walk checks that in is exactly one data item of the profile, with
// containers nested at most maxDepth deep (a scalar has depth 0) and every
// string and container at most maxLen long. It reads iteratively, so deep
// input cannot exhaust the stack.
//
// Walk is a helper for vectors, fuzzing and diagnostics, and for registered
// extensions whose data is CBOR (spec §72). It never decides whether an
// object of the protocol is valid: the decoder of its schema does.
func Walk(in []byte, maxDepth, maxLen int) error {
d := NewDecoder(in)
var open []walkLevel
for first := true; first || len(open) > 0; first = false {
var err error
if n := len(open); n > 0 && open[n-1].left == 0 {
// The innermost container is complete.
if open[n-1].isMap {
err = d.EndMap()
}
open = open[:n-1]
} else {
if n > 0 {
open[n-1].left--
if open[n-1].isMap {
_, err = d.Key()
}
}
if err == nil {
open, err = d.walkItem(open, maxDepth, maxLen)
}
}
if err != nil {
return err
}
}
return d.Done()
}
// walkLevel is an open container of Walk.
type walkLevel struct {
left int // entries of a map or items of an array not read yet
isMap bool
}
// walkItem reads one data item for Walk: a scalar, or the head of a
// container, which it pushes on open.
func (d *Decoder) walkItem(open []walkLevel, maxDepth, maxLen int) ([]walkLevel, error) {
var err error
switch major := d.next(); major {
case majorMap, majorArray:
if len(open) >= maxDepth {
return open, d.fail("containers nested deeper than %d", maxDepth)
}
l := walkLevel{isMap: major == majorMap}
if l.isMap {
l.left, err = d.Map(maxLen)
} else {
l.left, err = d.Array(maxLen)
}
open = append(open, l)
case majorBytes:
_, err = d.content(majorBytes, 0, maxLen)
case majorText:
_, err = d.Text(maxLen)
default:
// An unsigned integer, or the error of whatever is there.
_, err = d.Uint(math.MaxUint64)
}
return open, err
}

@ -1,44 +1,393 @@
package codec_test
import (
"bytes"
"encoding/hex"
"errors"
"fmt"
"maps"
"math"
"math/rand/v2"
"slices"
"strconv"
"strings"
"testing"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/codec"
"g.activething.com/go/DateKeys/internal/cbortest"
)
type sample struct {
Type string `cbor:"0,keyasint"`
N uint64 `cbor:"1,keyasint"`
Bytes []byte `cbor:"2,keyasint"`
List []uint64 `cbor:"10,keyasint,omitempty"`
}
func mustHex(t *testing.T, s string) []byte {
func mustHex(t testing.TB, s string) []byte {
t.Helper()
b, err := hex.DecodeString(s)
b, err := hex.DecodeString(strings.ReplaceAll(s, " ", ""))
if err != nil {
t.Fatal(err)
}
return b
}
func TestMarshalIsCoreDeterministic(t *testing.T) {
b, err := codec.Marshal(sample{Type: "x", N: 23, Bytes: []byte{1}, List: []uint64{1, 500}})
if err != nil {
t.Fatal(err)
func isNonCanonical(err error) bool { return errors.Is(err, datekeys.ErrNonCanonicalCBOR) }
// ---------------------------------------------------------------------------
// Encoder
func TestEncoderShortestForm(t *testing.T) {
for _, tc := range []struct {
v uint64
want string
}{
{0, "00"}, {23, "17"}, {24, "1818"}, {255, "18ff"}, {256, "190100"},
{65535, "19ffff"}, {65536, "1a00010000"}, {1<<32 - 1, "1affffffff"},
{1 << 32, "1b0000000100000000"}, {math.MaxUint64, "1bffffffffffffffff"},
} {
var e codec.Encoder
e.Uint(tc.v)
if b, err := e.Out(); err != nil || hex.EncodeToString(b) != tc.want {
t.Errorf("Uint(%d) = %x %v, want %s", tc.v, b, err, tc.want)
}
}
// {0: "x", 1: 23, 2: h'01', 10: [1, 500]} with keys sorted and shortest integers.
if got, want := hex.EncodeToString(b), "a400617801170241010a82011901f4"; got != want {
t.Fatalf("got %s, want %s", got, want)
// {0: "x", 1: 23, 2: h'01', 10: [1, 500], 11: h'', 12: ""}
var e codec.Encoder
e.Map(6)
e.Uint(0)
e.Text("x")
e.Uint(1)
e.Uint(23)
e.Uint(2)
e.Bstr([]byte{1})
e.Uint(10)
e.Array(2)
e.Uint(1)
e.Uint(500)
e.Uint(11)
e.Bstr(nil)
e.Uint(12)
e.Text("")
b, err := e.Out()
if want := "a600617801170241010a82011901f40b400c60"; err != nil || hex.EncodeToString(b) != want {
t.Fatalf("got %x %v, want %s", b, err, want)
}
long := strings.Repeat("a", 24)
var l codec.Encoder
l.Text(long)
l.Bstr([]byte(long))
l.Map(24)
l.Array(256)
s := hex.EncodeToString([]byte(long))
if b, _ := l.Out(); hex.EncodeToString(b) != "7818"+s+"5818"+s+"b818"+"990100" {
t.Fatalf("long heads %x", b)
}
}
func TestEncoderErrorsAreSticky(t *testing.T) {
for name, fail := range map[string]func(e *codec.Encoder){
"negative map": func(e *codec.Encoder) { e.Map(-1) },
"negative array": func(e *codec.Encoder) { e.Array(-1) },
"invalid UTF-8": func(e *codec.Encoder) { e.Text("\xff") },
"surrogate": func(e *codec.Encoder) { e.Text("\xed\xa0\x80") },
"overlong": func(e *codec.Encoder) { e.Text("\xc0\x80") },
"Fail": func(e *codec.Encoder) { e.Fail(fmt.Errorf("schema rule: %w", datekeys.ErrNonCanonicalCBOR)) },
"Fail twice": func(e *codec.Encoder) {
e.Fail(fmt.Errorf("first: %w", datekeys.ErrNonCanonicalCBOR))
e.Fail(errors.New("second"))
},
} {
var e codec.Encoder
e.Bstr([]byte("secret"))
fail(&e)
e.Uint(1)
e.Bstr([]byte{1})
e.Text("ok")
e.Map(1)
e.Array(1)
if b, err := e.Out(); b != nil || !isNonCanonical(err) {
t.Errorf("%s: %x %v", name, b, err)
}
}
// A nil error records nothing.
var e codec.Encoder
e.Fail(nil)
e.Uint(1)
if b, err := e.Out(); err != nil || !bytes.Equal(b, []byte{0x01}) {
t.Errorf("Fail(nil): %x %v", b, err)
}
}
// ---------------------------------------------------------------------------
// Decoder
// run interprets prog, a space-separated list of Decoder calls, on in and
// returns the first error, after checking that it is sticky:
//
// m<max> Map k Key e EndMap a<max> Array u[<max>] Uint
// b<min>,<max> Bstr t<max> Text d Done
func run(in []byte, prog string) error {
d := codec.NewDecoder(in)
num := func(s string) int { n, _ := strconv.Atoi(s); return n }
var first error
for _, op := range strings.Fields(prog) {
arg := op[1:]
var err error
switch op[0] {
case 'm':
_, err = d.Map(num(arg))
case 'k':
_, err = d.Key()
case 'e':
err = d.EndMap()
case 'a':
_, err = d.Array(num(arg))
case 'u':
max := uint64(math.MaxUint64)
if arg != "" {
max, _ = strconv.ParseUint(arg, 10, 64)
}
_, err = d.Uint(max)
case 'b':
lo, hi, _ := strings.Cut(arg, ",")
_, err = d.Bstr(num(lo), num(hi))
case 't':
_, err = d.Text(num(arg))
case 'd':
err = d.Done()
default:
panic("bad op " + op)
}
if first == nil {
first = err
} else if err != first {
return fmt.Errorf("error not sticky: %v then %v", first, err)
}
}
var s sample
if err := codec.Unmarshal(b, &s); err != nil {
return first
}
func TestDecoderAccepts(t *testing.T) {
for _, tc := range []struct{ name, hex, prog string }{
{"uint 23 inline", "17", "u23 d"},
{"uint 24 one byte", "1818", "u24 d"},
{"uint 256 two bytes", "190100", "u d"},
{"uint 65536 four bytes", "1a00010000", "u d"},
{"uint 2^32 eight bytes", "1b0000000100000000", "u d"},
{"uint 2^64-1", "1bffffffffffffffff", "u d"},
{"empty bstr", "40", "b0,0 d"},
{"bstr at its bounds", "420102", "b2,2 d"},
{"bstr 24 bytes", "5818" + strings.Repeat("00", 24), "b0,24 d"},
{"empty text", "60", "t0 d"},
{"text with leading BOM", "64efbbbf61", "t4 d"},
{"text U+10FFFF", "64f48fbfbf", "t4 d"},
{"empty map", "a0", "m0 e d"},
{"map two sorted keys", "a200010101", "m2 k u k u e d"},
{"map keys 0 and 2^64-1", "a200001bffffffffffffffff00", "m2 k u k u e d"},
{"empty array", "80", "a0 d"},
{"array of maps", "82a10000a10101", "a2 m1 k u e m1 k u e d"},
{"nested maps", "a100a10000", "m1 k m1 k u e e d"},
} {
if err := run(mustHex(t, tc.hex), tc.prog); err != nil {
t.Errorf("%s: %v", tc.name, err)
}
}
}
func TestDecoderRejects(t *testing.T) {
for _, tc := range []struct{ name, hex, prog string }{
// Outside the profile of spec §58.
{"negative int", "20", "u"},
{"tag", "c101", "u"},
{"tag on a byte string", "c24101", "b0,9"},
{"half float", "f97e00", "u"},
{"single float", "fa3f800000", "u"},
{"double float", "fb3ff0000000000000", "u"},
{"false", "f4", "u"},
{"true", "f5", "u"},
{"null", "f6", "b0,9"},
{"undefined", "f7", "t9"},
{"break", "ff", "u"},
{"indefinite array", "9f01ff", "a9"},
{"indefinite map", "bf0001ff", "m9"},
{"indefinite byte string", "5f4101ff", "b0,9"},
{"indefinite text", "7f6161ff", "t9"},
{"reserved 28", "1c", "u"},
{"reserved 29", "1d", "u"},
{"reserved 30", "1e", "u"},
// Shortest form.
{"uint 23 with one extra byte", "1817", "u"},
{"uint 255 in two bytes", "1900ff", "u"},
{"uint 65535 in four bytes", "1a0000ffff", "u"},
{"uint 2^32-1 in eight bytes", "1b00000000ffffffff", "u"},
{"bstr length not shortest", "5800", "b0,9"},
{"map length not shortest", "b800", "m9"},
{"key not shortest", "a1180000", "m1 k"},
// Truncation.
{"empty input", "", "u"},
{"truncated uint", "1901", "u"},
{"truncated uint 8", "1b00000000000000", "u"},
{"length beyond input", "5affffffff", "b0,9"},
{"bstr shorter than its length", "4300", "b0,9"},
{"text shorter than its length", "6361", "t9"},
{"map beyond input", "a300", "m9"},
{"huge map", "bbffffffffffffffff", "m100"},
{"array beyond input", "8300", "a9"},
{"huge array", "9b7fffffffffffffff", "a100"},
{"truncated inside a map", "a200", "m2 k u"},
// Types and bounds.
{"bstr where uint", "4100", "u"},
{"uint where bstr", "00", "b0,9"},
{"text where bstr", "6161", "b0,9"},
{"bstr where text", "4161", "t9"},
{"array where map", "80", "m9"},
{"map where array", "a0", "a9"},
{"uint above max", "1818", "u23"},
{"bstr below min", "4101", "b2,9"},
{"bstr above max", "420102", "b0,1"},
{"text above max", "626161", "t1"},
{"map above max", "a200010101", "m1"},
{"negative map max", "a0", "m-1"},
{"array above max", "820101", "a1"},
{"negative array max", "80", "a-1"},
// Keys.
{"keys out of order", "a201000001", "m2 k u k"},
{"duplicate key", "a200000001", "m2 k u k"},
{"text key", "a1616100", "m1 k"},
{"bstr key", "a1416100", "m1 k"},
{"negative key", "a12000", "m1 k"},
{"key outside a map", "00", "k"},
{"key after the last entry", "a10000", "m1 k u k"},
{"key after the map closed", "a0", "m0 e k"},
{"end outside a map", "00", "e"},
{"end with entries left", "a10000", "m1 e"},
{"done with a map open", "a0", "m0 d"},
// Trailing bytes and UTF-8.
{"trailing byte", "0100", "u d"},
{"invalid UTF-8", "61ff", "t9"},
{"overlong UTF-8", "62c080", "t9"},
{"UTF-8 surrogate", "63eda080", "t9"},
{"above U+10FFFF", "64f4908080", "t9"},
{"truncated UTF-8", "62e282", "t9"},
// The first error stays.
{"sticky after a failed read", "f600", "u u d"},
} {
if err := run(mustHex(t, tc.hex), tc.prog); !isNonCanonical(err) {
t.Errorf("%s: %v", tc.name, err)
}
}
}
func TestDecoderCopiesByteStrings(t *testing.T) {
in := mustHex(t, "43010203")
d := codec.NewDecoder(in)
b, err := d.Bstr(3, 3)
if err != nil || d.Done() != nil {
t.Fatal(err)
}
in[1] = 9
if !bytes.Equal(b, []byte{1, 2, 3}) {
t.Fatal("Bstr returned a view of its input")
}
d = codec.NewDecoder([]byte{0x40})
if b, err := d.Bstr(0, 0); err != nil || b == nil || len(b) != 0 {
t.Fatalf("empty byte string: %v %v", b, err)
}
}
func TestDecoderErrorsNameTheOffset(t *testing.T) {
err := run(mustHex(t, "a2 00 01 00 02"), "m2 k u k")
if err == nil || !strings.Contains(err.Error(), "offset 3") || !strings.Contains(err.Error(), "key 0 after key 0") {
t.Fatalf("got %v", err)
}
}
// ---------------------------------------------------------------------------
// Unmarshal
// sample is a schema of this test: {0: tstr, 1: uint, 2: bstr, ? 10: [* uint]}.
// Its decoder accepts an empty list at key 10 and its encoder omits an empty
// list, so an empty list that is present is left to the re-encoding check.
type sample struct {
Type string
N uint64
Bytes []byte
List []uint64
}
func (s *sample) decode(d *codec.Decoder) error {
pairs, err := d.Map(4)
if err != nil {
return err
}
for range pairs {
k, err := d.Key()
if err != nil {
return err
}
switch k {
case 0:
s.Type, err = d.Text(16)
case 1:
s.N, err = d.Uint(math.MaxUint64)
case 2:
s.Bytes, err = d.Bstr(0, 64)
case 10:
var n int
if n, err = d.Array(8); err == nil {
s.List = make([]uint64, n)
for i := range s.List {
if s.List[i], err = d.Uint(math.MaxUint64); err != nil {
break
}
}
}
default:
return fmt.Errorf("unknown key %d: %w", k, datekeys.ErrNonCanonicalCBOR)
}
if err != nil {
return err
}
}
if s.Type == "" || s.Bytes == nil {
return fmt.Errorf("missing key: %w", datekeys.ErrNonCanonicalCBOR)
}
return d.EndMap()
}
func (s *sample) encode(e *codec.Encoder) {
pairs := 3
if len(s.List) > 0 {
pairs++
}
e.Map(pairs)
e.Uint(0)
e.Text(s.Type)
e.Uint(1)
e.Uint(s.N)
e.Uint(2)
e.Bstr(s.Bytes)
if len(s.List) > 0 {
e.Uint(10)
e.Array(len(s.List))
for _, v := range s.List {
e.Uint(v)
}
}
}
func (s *sample) marshal() ([]byte, error) {
var e codec.Encoder
s.encode(&e)
return e.Out()
}
func unmarshal(in []byte) (*sample, error) {
s := &sample{}
return s, codec.Unmarshal(in, s.decode, s.encode)
}
func TestUnmarshal(t *testing.T) {
s, err := unmarshal(mustHex(t, "a400617801170241010a82011901f4"))
if err != nil || s.Type != "x" || s.N != 23 || !bytes.Equal(s.Bytes, []byte{1}) || !slices.Equal(s.List, []uint64{1, 500}) {
t.Fatalf("%+v %v", s, err)
}
}
func TestUnmarshalRejectsNonCanonical(t *testing.T) {
@ -67,90 +416,482 @@ func TestUnmarshalRejectsNonCanonical(t *testing.T) {
{"text map key", "a300617801176162" + "4101"},
} {
t.Run(tc.name, func(t *testing.T) {
var s sample
err := codec.Unmarshal(mustHex(t, tc.hex), &s)
if !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
if _, err := unmarshal(mustHex(t, tc.hex)); !isNonCanonical(err) {
t.Fatalf("got %v, want ErrNonCanonicalCBOR", err)
}
})
}
}
func TestUnmarshalChecksTheReEncoding(t *testing.T) {
in := mustHex(t, "a30061780117024101")
// An error of the schema is returned as it is.
own := errors.New("schema error")
if err := codec.Unmarshal(in, func(*codec.Decoder) error { return own }, func(*codec.Encoder) {}); err != own {
t.Fatalf("decode error: %v", err)
}
// A decoder that stops early leaves bytes behind.
stop := func(d *codec.Decoder) error { _, err := d.Map(3); return err }
if err := codec.Unmarshal(in, stop, func(*codec.Encoder) {}); !isNonCanonical(err) {
t.Fatalf("partial decode: %v", err)
}
// A decoder that ignores an error of the Decoder fails anyway.
ignore := func(d *codec.Decoder) error { _, _ = d.Uint(0); return nil }
if err := codec.Unmarshal(in, ignore, func(*codec.Encoder) {}); !isNonCanonical(err) {
t.Fatalf("ignored error: %v", err)
}
// An encoder that fails, and one that writes something else.
s := &sample{}
if err := codec.Unmarshal(in, s.decode, func(e *codec.Encoder) { e.Map(-1) }); !isNonCanonical(err) {
t.Fatalf("encode error: %v", err)
}
if err := codec.Unmarshal(in, s.decode, func(e *codec.Encoder) { s.encode(e); e.Uint(0) }); !isNonCanonical(err) {
t.Fatalf("different re-encoding: %v", err)
}
}
// ---------------------------------------------------------------------------
// Peek and CheckSchema
func TestPeek(t *testing.T) {
// Nothing after key 1 is read: a future version may use anything there.
future, _ := cbortest.Marshal(map[uint64]any{0: "datekeycap", 1: uint64(2), 99: "new", 100: cbortest.Raw{0xf9, 0x7e, 0x00}})
tag, v, err := codec.Peek(future)
if err != nil || tag != "datekeycap" || v != 2 {
t.Fatalf("%q %d %v", tag, v, err)
}
long := strings.Repeat("a", codec.MaxTypeTagLen)
if tag, _, err := codec.Peek(mustHex(t, "a200"+"7840"+hex.EncodeToString([]byte(long))+"0101")); err != nil || tag != long {
t.Fatalf("type tag of MaxTypeTagLen bytes: %q %v", tag, err)
}
for _, tc := range []struct{ name, hex string }{
{"empty", ""},
{"not a map", "8200"},
{"one entry", "a1006161"},
{"first key not 0", "a2016161" + "0201"},
{"second key not 1", "a2006161" + "0201"},
{"text key", "a2616100" + "0101"},
{"type tag not text", "a2004161" + "0101"},
{"version not uint", "a2006161" + "0120"},
{"version above 2^53-1", "a2006161" + "011b0020000000000000"},
{"keys swapped", "a2010100" + "6161"},
{"truncated type tag", "a2006361"},
{"map beyond input", "a5006161" + "0101"},
{"type tag above MaxTypeTagLen", "a200" + "7841" + strings.Repeat("61", codec.MaxTypeTagLen+1) + "0101"},
} {
if _, _, err := codec.Peek(mustHex(t, tc.hex)); !isNonCanonical(err) {
t.Errorf("%s: %v", tc.name, err)
}
}
}
func TestCheckSchema(t *testing.T) {
b, _ := codec.Marshal(sample{Type: "datekeycap", N: 1, Bytes: []byte{}})
b, _ := (&sample{Type: "datekeycap", N: 1, Bytes: []byte{}}).marshal()
if err := codec.CheckSchema(b, "datekeycap", 1); err != nil {
t.Fatal(err)
}
if err := codec.CheckSchema(b, "datekeys-control", 1); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
if err := codec.CheckSchema(b, "datekeys-control", 1); !isNonCanonical(err) {
t.Fatalf("type confusion: %v", err)
}
if err := codec.CheckSchema(b, "datekeycap", 2); !errors.Is(err, datekeys.ErrUnsupportedVersion) {
t.Fatalf("version: %v", err)
}
// A future version with unknown keys still reports the version.
future, _ := codec.Marshal(map[uint64]any{0: "datekeycap", 1: uint64(2), 99: "new"})
future, _ := cbortest.Marshal(map[uint64]any{0: "datekeycap", 1: uint64(2), 99: int64(-7)})
if err := codec.CheckSchema(future, "datekeycap", 1); !errors.Is(err, datekeys.ErrUnsupportedVersion) {
t.Fatalf("future version: %v", err)
}
for _, in := range [][]byte{nil, {0xff}, {0x83, 0x01}, mustHex(t, "a10061")} {
if err := codec.CheckSchema(in, "datekeycap", 1); !isNonCanonical(err) {
t.Fatalf("%x: %v", in, err)
}
}
}
func TestRoundTripProperty(t *testing.T) {
// Spec §70: a version is read only as the second key, after a type tag, both
// in the profile; every other form of the version is ErrNonCanonicalCBOR,
// whatever its value.
func TestCheckSchemaVersionForms(t *testing.T) {
text := hex.EncodeToString([]byte("datekeycap"))
tag := "6a" + text
for _, tc := range []struct {
name, hex string
want error
}{
{"version 2", "a200" + tag + "0102", datekeys.ErrUnsupportedVersion},
{"version 2, rest malformed", "a300" + tag + "0102" + "02ff", datekeys.ErrUnsupportedVersion},
{"version 2^53-1", "a200" + tag + "011b001fffffffffffff", datekeys.ErrUnsupportedVersion},
{"version 2 not in shortest form", "a200" + tag + "011802", datekeys.ErrNonCanonicalCBOR},
{"version 2^53", "a200" + tag + "011b0020000000000000", datekeys.ErrNonCanonicalCBOR},
{"version 2^64-1", "a200" + tag + "011bffffffffffffffff", datekeys.ErrNonCanonicalCBOR},
{"version before key 0", "a2" + "0102" + "00" + tag, datekeys.ErrNonCanonicalCBOR},
{"version after key 2", "a3" + "00" + tag + "0200" + "0102", datekeys.ErrNonCanonicalCBOR},
{"map head not in shortest form", "b802" + "00" + tag + "0102", datekeys.ErrNonCanonicalCBOR},
{"type tag head not in shortest form", "a200" + "780a" + text + "0102", datekeys.ErrNonCanonicalCBOR},
{"version missing", "a100" + tag, datekeys.ErrNonCanonicalCBOR},
{"version null", "a200" + tag + "01f6", datekeys.ErrNonCanonicalCBOR},
{"version undefined", "a200" + tag + "01f7", datekeys.ErrNonCanonicalCBOR},
{"version true", "a200" + tag + "01f5", datekeys.ErrNonCanonicalCBOR},
{"version false", "a200" + tag + "01f4", datekeys.ErrNonCanonicalCBOR},
} {
if err := codec.CheckSchema(mustHex(t, tc.hex), "datekeycap", 1); !errors.Is(err, tc.want) {
t.Errorf("%s: got %v, want %v", tc.name, err, tc.want)
}
}
}
// ---------------------------------------------------------------------------
// Walk
func TestWalk(t *testing.T) {
for _, tc := range []struct {
name, hex string
maxDepth, max int
ok bool
}{
{"uint", "17", 0, 0, true},
{"empty bstr", "40", 0, 0, true},
{"text", "626161", 0, 2, true},
{"text above max", "626161", 0, 1, false},
{"bstr above max", "420000", 0, 1, false},
{"map two sorted keys", "a200010101", 1, 2, true},
{"map at depth 0", "a0", 0, 0, false},
{"map above max", "a200010101", 1, 1, false},
{"array above max", "83010203", 1, 2, false},
{"four levels", "a1008181a10040", 4, 1, true},
{"four levels, depth 3", "a1008181a10040", 3, 1, false},
{"empty containers", "82a080", 2, 2, true},
{"keys out of order", "a201000001", 1, 2, false},
{"text key", "a1616100", 1, 1, false},
{"float inside", "8201f97e00", 1, 2, false},
{"truncated map", "a20001", 1, 2, false},
{"truncated array", "8201", 1, 2, false},
{"trailing byte", "8000", 1, 0, false},
{"empty input", "", 1, 1, false},
{"invalid UTF-8 inside", "a10061ff", 1, 1, false},
{"null", "f6", 1, 1, false},
} {
err := codec.Walk(mustHex(t, tc.hex), tc.maxDepth, tc.max)
if (err == nil) != tc.ok || (err != nil && !isNonCanonical(err)) {
t.Errorf("%s: %v", tc.name, err)
}
}
}
func TestWalkDeepInputDoesNotExhaustTheStack(t *testing.T) {
const n = 1 << 20
in := append(bytes.Repeat([]byte{0x81}, n), 0x00)
if err := codec.Walk(in, n, 1); err != nil {
t.Fatal(err)
}
if err := codec.Walk(in, n-1, 1); !isNonCanonical(err) {
t.Fatalf("depth limit: %v", err)
}
}
// ---------------------------------------------------------------------------
// Properties
// randomValue returns a value of the profile, in the types of cbortest.
func randomValue(r *rand.Rand, depth int) any {
switch k := r.IntN(6); {
case k == 0 && depth < 4:
v := make([]any, r.IntN(4))
for i := range v {
v[i] = randomValue(r, depth+1)
}
return v
case k == 1 && depth < 4:
m := map[uint64]any{}
for range r.IntN(4) {
m[r.Uint64()>>r.IntN(64)] = randomValue(r, depth+1)
}
return m
case k == 2:
return make([]byte, r.IntN(30))
case k == 3:
return strings.Repeat("é", r.IntN(20))
}
return r.Uint64() >> r.IntN(64)
}
// shape returns the nesting depth of v and the length of its longest string
// or container.
func shape(v any) (depth, length int) {
var items []any
switch v := v.(type) {
case []byte:
return 0, len(v)
case string:
return 0, len(v)
case []any:
items = v
case map[uint64]any:
items = slices.Collect(maps.Values(v))
default:
return 0, 0
}
length = len(items)
for _, x := range items {
d, l := shape(x)
depth, length = max(depth, d), max(length, l)
}
return depth + 1, length
}
// encode writes v, a value of the profile, with the Encoder.
func encode(e *codec.Encoder, v any) {
switch v := v.(type) {
case uint64:
e.Uint(v)
case []byte:
e.Bstr(v)
case string:
e.Text(v)
case []any:
e.Array(len(v))
for _, x := range v {
encode(e, x)
}
case map[uint64]any:
e.Map(len(v))
for _, k := range slices.Sorted(maps.Keys(v)) {
e.Uint(k)
encode(e, v[k])
}
default:
panic(fmt.Sprintf("%T", v))
}
}
// The Encoder writes what the independent encoder of cbortest writes, and
// Walk accepts it within its exact shape and rejects it one level or one
// byte tighter.
func TestEncoderAndWalkAgreeWithAReference(t *testing.T) {
r := rand.New(rand.NewPCG(1, 2))
for range 3000 {
v := randomValue(r, 0)
ref, err := cbortest.Marshal(v)
if err != nil {
t.Fatal(err)
}
var e codec.Encoder
encode(&e, v)
b, err := e.Out()
if err != nil || !bytes.Equal(b, ref) {
t.Fatalf("Encoder %x, reference %x: %v", b, ref, err)
}
depth, length := shape(v)
if err := codec.Walk(b, depth, length); err != nil {
t.Fatalf("%x: %v", b, err)
}
if depth > 0 && codec.Walk(b, depth-1, length) == nil {
t.Fatalf("%x accepted at depth %d", b, depth-1)
}
if length > 0 && codec.Walk(b, depth, length-1) == nil {
t.Fatalf("%x accepted with length %d", b, length-1)
}
}
}
func TestUnmarshalRoundTripProperty(t *testing.T) {
r := rand.New(rand.NewPCG(1, 2))
for range 2000 {
s := sample{Type: string(rune('a' + r.IntN(26))), N: r.Uint64() >> r.IntN(64), Bytes: make([]byte, r.IntN(40))}
for range r.IntN(4) {
s.List = append(s.List, r.Uint64()>>r.IntN(64))
}
b, err := codec.Marshal(s)
b, err := s.marshal()
if err != nil {
t.Fatal(err)
}
var got sample
if err := codec.Unmarshal(b, &got); err != nil {
got, err := unmarshal(b)
if err != nil {
t.Fatalf("%x: %v", b, err)
}
b2, _ := codec.Marshal(got)
if string(b) != string(b2) {
if b2, _ := got.marshal(); !bytes.Equal(b, b2) {
t.Fatal("encoding is not stable")
}
}
}
func TestErrorsCarryTheNormativeCode(t *testing.T) {
if _, err := codec.Marshal(make(chan int)); err == nil {
t.Fatal("encoded a channel")
}
for _, in := range [][]byte{nil, {0xff}, {0x83, 0x01}, mustHex(t, "a10061")} {
if err := codec.CheckSchema(in, "datekeycap", 1); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Fatalf("%x: %v", in, err)
}
var v struct {
A uint64 `cbor:"0,keyasint"`
}
if err := codec.Peek(in, &v); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Fatalf("peek %x: %v", in, err)
}
// ---------------------------------------------------------------------------
// Fuzzing
func addSeeds(f *testing.F) {
for _, h := range []string{
"a400617801170241010a82011901f4", "a30061780117024101", "a3006178011702f6", "9f01ff",
"a200010101", "a1008181a10040", "64efbbbf61", "1bffffffffffffffff",
"a2006a646174656b657963617001" + "01",
} {
f.Add(mustHex(f, h))
}
}
// FuzzUnmarshal: whatever Unmarshal accepts is the deterministic encoding of
// the decoded value.
// the decoded value and an item of the profile, and every error carries
// ErrNonCanonicalCBOR.
func FuzzUnmarshal(f *testing.F) {
for _, h := range []string{"a400617801170241010a82011901f4", "a30061780117024101", "a3006178011702f6", "9f01ff"} {
b, _ := hex.DecodeString(h)
f.Add(b)
}
addSeeds(f)
f.Fuzz(func(t *testing.T, b []byte) {
var s sample
if err := codec.Unmarshal(b, &s); err != nil {
if !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
s, err := unmarshal(b)
if err != nil {
if !isNonCanonical(err) {
t.Fatalf("error without ErrNonCanonicalCBOR: %v", err)
}
return
}
re, err := codec.Marshal(s)
if err != nil || string(re) != string(b) {
if re, err := s.marshal(); err != nil || !bytes.Equal(re, b) {
t.Fatalf("accepted a non-canonical item %x", b)
}
if err := codec.Walk(b, 2, 64); err != nil {
t.Fatalf("Walk rejects %x: %v", b, err)
}
})
}
// FuzzDecoder drives the Decoder primitives with a program, the first input,
// over the second input: no call panics, every error carries
// ErrNonCanonicalCBOR and is sticky, and every value is within the bounds
// asked for.
func FuzzDecoder(f *testing.F) {
f.Add([]byte{0, 2, 1, 4, 9, 1, 4, 9, 2, 7}, mustHex(f, "a200010101"))
f.Add([]byte{3, 4, 0, 1, 1, 5, 4, 2, 7}, mustHex(f, "81a10040"))
f.Add([]byte{6, 9, 7}, mustHex(f, "6461626364"))
f.Fuzz(func(t *testing.T, prog, in []byte) {
d := codec.NewDecoder(in)
var first error
for i := 0; i < len(prog); i++ {
bound := 0
if i+1 < len(prog) {
bound = int(prog[i+1])
}
var err error
switch prog[i] % 8 {
case 0:
var n int
if n, err = d.Map(bound); err == nil && n > bound {
t.Fatalf("Map(%d) = %d", bound, n)
}
i++
case 1:
_, err = d.Key()
case 2:
err = d.EndMap()
case 3:
var n int
if n, err = d.Array(bound); err == nil && n > bound {
t.Fatalf("Array(%d) = %d", bound, n)
}
i++
case 4:
var v uint64
if v, err = d.Uint(uint64(bound)); err == nil && v > uint64(bound) {
t.Fatalf("Uint(%d) = %d", bound, v)
}
i++
case 5:
var b []byte
lo := bound % 16
if b, err = d.Bstr(lo, bound); err == nil && (len(b) < lo || len(b) > bound) {
t.Fatalf("Bstr(%d, %d) = %d bytes", lo, bound, len(b))
}
i++
case 6:
var s string
if s, err = d.Text(bound); err == nil && len(s) > bound {
t.Fatalf("Text(%d) = %d bytes", bound, len(s))
}
i++
case 7:
err = d.Done()
}
if err != nil && !isNonCanonical(err) {
t.Fatalf("error without ErrNonCanonicalCBOR: %v", err)
}
if first == nil {
first = err
} else if err != first {
t.Fatalf("error not sticky: %v then %v", first, err)
}
}
})
}
// FuzzWalk compares Walk with the independent decoder of cbortest: Walk
// accepts exactly the items of the profile whose shape fits its bounds.
func FuzzWalk(f *testing.F) {
addSeeds(f)
f.Fuzz(func(t *testing.T, in []byte) {
err := codec.Walk(in, 8, 64)
if err != nil && !isNonCanonical(err) {
t.Fatalf("error without ErrNonCanonicalCBOR: %v", err)
}
v, refErr := cbortest.Unmarshal(in)
if refErr != nil {
if err == nil {
t.Fatalf("Walk accepts %x, the reference rejects it: %v", in, refErr)
}
return
}
depth, length := shape(v)
if fits := depth <= 8 && length <= 64; fits != (err == nil) {
t.Fatalf("%x of depth %d and length %d: Walk %v", in, depth, length, err)
}
})
}
// FuzzPeek: Peek never panics, its errors carry ErrNonCanonicalCBOR, and on
// an item of the profile it reads what the reference decoder reads.
func FuzzPeek(f *testing.F) {
addSeeds(f)
f.Fuzz(func(t *testing.T, in []byte) {
tag, version, err := codec.Peek(in)
if err != nil && !isNonCanonical(err) {
t.Fatalf("error without ErrNonCanonicalCBOR: %v", err)
}
m, refErr := cbortest.UnmarshalMap(in)
if refErr != nil {
return
}
// Keys ascend, so keys 0 and 1, when present, come first.
refTag, okTag := m[0].(string)
refVersion, okVersion := m[1].(uint64)
want := okTag && len(refTag) <= codec.MaxTypeTagLen && okVersion && refVersion <= codec.MaxSafeUint
if want != (err == nil) || (err == nil && (tag != refTag || version != refVersion)) {
t.Fatalf("%x: Peek %q %d %v", in, tag, version, err)
}
})
}
// FuzzEncodeImpliesWalk writes a value of the profile built from the inputs
// with the Encoder, and requires that Walk and the reference decoder accept
// it and that the reference reads the same value back.
func FuzzEncodeImpliesWalk(f *testing.F) {
f.Add(uint64(1), []byte{1, 2, 3})
f.Add(uint64(99), []byte("datekeys"))
f.Fuzz(func(t *testing.T, seed uint64, data []byte) {
r := rand.New(rand.NewPCG(seed, uint64(len(data))))
v := randomValue(r, 0)
if len(data) > 0 {
v = []any{v, data, string(bytes.ToValidUTF8(data, []byte("?")))}
}
var e codec.Encoder
encode(&e, v)
b, err := e.Out()
if err != nil {
t.Fatal(err)
}
depth, length := shape(v)
if err := codec.Walk(b, depth, length); err != nil {
t.Fatalf("Walk rejects what the Encoder wrote: %x: %v", b, err)
}
back, err := cbortest.Unmarshal(b)
if err != nil {
t.Fatalf("the reference rejects what the Encoder wrote: %x: %v", b, err)
}
if re, _ := cbortest.Marshal(back); !bytes.Equal(re, b) {
t.Fatalf("%x read back as %x", b, re)
}
})
}

@ -0,0 +1,29 @@
package codec
import (
"bytes"
"testing"
)
// The Encoder wipes every buffer it outgrows: an encoding that holds a
// secret leaves no stale copy behind.
func TestEncoderWipesOutgrownBuffers(t *testing.T) {
secret := bytes.Repeat([]byte{0xab}, 32)
var e Encoder
e.Bstr(secret)
old := e.buf[:cap(e.buf)]
e.Bstr(make([]byte, 2*cap(e.buf)))
if bytes.Contains(old, secret[:8]) {
t.Fatalf("outgrown buffer not wiped: %x", old)
}
b, err := e.Out()
if err != nil || !bytes.Contains(b, secret) {
t.Fatalf("output lost the data: %v", err)
}
// A buffer with room is not replaced.
e = Encoder{buf: make([]byte, 0, 64)}
e.Text("fits")
if cap(e.buf) != 64 {
t.Fatal("buffer replaced although it had room")
}
}

@ -0,0 +1,82 @@
package codec_test
import (
"encoding/hex"
"encoding/json"
"math"
"os"
"strconv"
"testing"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/codec"
)
// cborVectors is the generic part of testdata/vectors/cbor.json, which
// internal/testkit.CBORVectors generates.
type cborVectors struct {
Walk struct {
MaxDepth int `json:"max_depth"`
MaxLen int `json:"max_len"`
} `json:"walk"`
Accept []struct {
Name string `json:"name"`
Hex string `json:"hex"`
Value json.RawMessage `json:"value"`
} `json:"accept"`
Reject []struct {
Name string `json:"name"`
Hex string `json:"hex"`
Error string `json:"error"`
} `json:"reject"`
}
// The shared vectors of the CBOR profile (spec §58): Walk accepts exactly the
// accept list, with the value recorded for unsigned integers (a decimal string
// above 2^53-1), and rejects the reject list with the recorded code.
func TestSharedVectors(t *testing.T) {
b, err := os.ReadFile("../testdata/vectors/cbor.json")
if err != nil {
t.Fatal(err)
}
var f cborVectors
if err := json.Unmarshal(b, &f); err != nil {
t.Fatal(err)
}
if len(f.Accept) == 0 || len(f.Reject) == 0 || f.Walk.MaxDepth == 0 || f.Walk.MaxLen == 0 {
t.Fatal("incomplete vector file")
}
for _, v := range f.Accept {
in, err := hex.DecodeString(v.Hex)
if err != nil {
t.Fatal(err)
}
if err := codec.Walk(in, f.Walk.MaxDepth, f.Walk.MaxLen); err != nil {
t.Errorf("%s: %v", v.Name, err)
continue
}
if v.Value == nil {
continue
}
n, err := codec.NewDecoder(in).Uint(math.MaxUint64)
if err != nil {
t.Fatalf("%s: %v", v.Name, err)
}
want := strconv.FormatUint(n, 10)
if n > codec.MaxSafeUint {
want = strconv.Quote(want)
}
if string(v.Value) != want {
t.Errorf("%s: value %s, want %s", v.Name, v.Value, want)
}
}
for _, v := range f.Reject {
in, err := hex.DecodeString(v.Hex)
if err != nil {
t.Fatal(err)
}
if got := datekeys.Code(codec.Walk(in, f.Walk.MaxDepth, f.Walk.MaxLen)); got != v.Error {
t.Errorf("%s: got %q, want %s", v.Name, got, v.Error)
}
}
}

@ -18,9 +18,9 @@ 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`; `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` |
| 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` (hand-written `wire` encode and decode: keys 0 to 10, all required, in order; unsigned `genesis_time`, `codec.MaxSafeUint`; `period` limited to 1..86400 s, an implementation limit marked in `spec/datekeys.cddl`, `ERR_NON_CANONICAL_CBOR`) | `profile.TestQuicknetMatchesGoldenVector`, `TestQuicknetCBORLayout`, `TestDecodeRoundTrip`, `TestDecodeStructure`, `TestIntegerRanges`, `FuzzDecode`; `testdata/vectors/profile_quicknet.json`; the `provider_profile` block of `testdata/vectors/cbor.json` (*period of one day, the implementation limit*, *… above the implementation limit*) |
| 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* |
| 13 | Root of trust | `profile.NewRegistry`, `profile.Pin`, `profile.Default`, `QuicknetProfileHash`; chain-hash self-check in `Profile.Validate` (the drand chain-info hash, formula in `testdata/README.md`) | `profile.TestRegistry`; mutations *unknown profile*, *empty registry*; the `provider_profile` block of `testdata/vectors/cbor.json` |
| 14 | DateKey | `datekey.DateKey` | `datekey/*` |
| 15 | Date → round resolution | `datekey.Resolve`, `datekey.RoundTime` | `datekey.TestGoldenRoundVectors`, `TestRoundNeverOpensEarly`, `TestResolveProperty`, `TestTimezoneIndependence` |
| 16 | Normative round vector | — | `datekey.TestNormativeRoundVector`; `testdata/vectors/quicknet_rounds.json` |
@ -31,15 +31,15 @@ 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; 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* |
| 24 | PUBLIC_HEADER; keys 5 and 6 optional, 1 to 64 extensions each | `capsule.Header`, `EncodeHeader`, `DecodeHeader` (hand-written `headerWire` encode and decode; CDDL checked before the DateKey) | `capsule.TestConformanceFixtures` (exact extension data), `TestDecodeHeaderRejects`, `TestDecodeMapStructure`, `TestDecodeHeaderReportsTheCDDLFirst`, `FuzzDecodeHeader`, `FuzzEncodeImpliesDecode`; mutations *header schema version changed*, *unknown key in PUBLIC_HEADER* |
| 25 | Declared access policy | `capsule.Policy`; `capsule.DecodeHeader` (the value read, up to 2^53−1, must be 0 or 1 before any narrowing); `capsule.Open` step 12 | `capsule.TestDecodeMapStructure` (2, 255, 256, 257, 2^32, 2^53−256 and others), `FuzzDecodeHeader` (seeds 256, 257, 2^32); mutations *access_policy=… with … structure* (four cases), *undefined access_policy*, *access_policy 256 / 257 with a consistent header_binding* |
| 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` |
| 28 | Three age files | `capsule.Encrypt`, `capsule.Open` | `capsule.TestEncryptRoundTripBothPolicies` |
| 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; 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* |
| 31 | CONTROL_CBOR; keys 4 and 5 optional; extension entry rules | `capsule.Control`, `EncodeControl`, `DecodeControl` (hand-written `controlWire` encode and decode); `extension` | `capsule.TestConformanceFixtures` (exact extension data), `TestDecodeControlRejects`, `TestDecodeMapStructure`, `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` |
@ -49,10 +49,10 @@ Paths are relative to the repository root. `§` numbers refer to
| 37 | X25519 recipient V1 | `age.X25519Recipient`; `agewrap.X25519IdentityFromRaw` | `agewrap.TestRawKeys` |
| 38 | Portable Access Key | `EncryptOptions.NewPortableKey` (fresh `I_ACCESS` per capsule; no API accepts an existing one); `accesskey.AccessKey` | `capsule.TestPortableKeysAreNeverReused` |
| 39 | Multiple recipients | `capsule.Encrypt`; `agewrap.AccessIdentity` | `capsule.TestFixtureRecipients`, `TestEncryptRoundTripBothPolicies` |
| 40 | `.dkk` framing | `accesskey.Encode`, `accesskey.Decode` | `accesskey.TestDecodeRejects`, `FuzzDecode` |
| 41 | `.dkk` BODY_CBOR | `AccessKey.MarshalBody`, `accesskey.DecodeBody` | `accesskey.TestFixtures` |
| 40 | `.dkk` framing | `accesskey.Encode`, `accesskey.Decode` (the body buffer grows with the data read; every buffer holding the body is wiped) | `accesskey.TestDecodeRejects`, `TestDecodeShortBodyAllocatesLittle`, `TestEncodeAndDecodeLeaveNoStaleMaterial`, `FuzzDecode` |
| 41 | `.dkk` BODY_CBOR | `AccessKey.MarshalBody`, `accesskey.DecodeBody` (hand-written `bodyWire` encode and decode) | `accesskey.TestFixtures`, `TestDecodeBodyStructure` |
| 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* |
| 43 | `verification_metadata` | `accesskey.Verification`, `decodeVerification` (the closed map `{0: capsule_digest}`); `capsule.Open` (`checkCapsuleDigest`, seekable readers) | `accesskey.TestDecodeRejects` *empty verification map*, `TestDecodeBodyStructure`; mutation *capsule_digest of the .dkk does not match* |
| 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) | — |
@ -63,24 +63,24 @@ 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: 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* |
| 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`, `Canonical`, `EncodeArray` (refuses, through `codec.Encoder.Fail`, an array that `DecodeArray` rejects), `DecodeArray` (64 entries checked on the array head, explicit key 2 check), `CheckDisjoint` (linear merge), `CheckCritical`, `CheckNoncritical`, `Unusable` | `extension.TestNew`, `TestData`, `TestCanonicalSorts`, `TestCanonicalRejects`, `TestEncodeArrayRejects`, `TestDecodeArrayRejects`, `TestCheckDisjoint`, `TestCheckDisjointIsLinear`, `TestCheckCritical`, `TestCheckNoncritical`, `FuzzDecodeArray`; `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, 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'')* |
| 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`; the bounds each schema passes to `codec.Decoder` (`Map`, `Array`, `Uint`, `Bstr`, `Text`), with lengths checked against the remaining input before any copy; `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`; `codec.TestDecoderRejects` |
| 58 | Canonical CBOR and the protocol's CBOR profile (major types 0, 2, 3, 4, 5; unsigned integer keys; integers ≤ 2^53−1) | `codec`, without reflection or dependencies: `Encoder` (shortest heads, valid UTF-8, nil byte strings as empty, never `null`; a sticky first error, which `Fail` lets a schema encoder record), `Decoder` (strict cursor: profile major types only, shortest heads, definite lengths, strictly ascending unsigned keys per map, valid UTF-8, no trailing bytes), `Unmarshal` (re-encoding comparison), `Walk` (the profile only, for vectors, fuzzing and diagnostics); `codec.MaxSafeUint`; the profile covers the head of extension data only | `codec.TestDecoderAccepts`, `TestDecoderRejects` (negative integer, tag, float, simple values, indefinite lengths, non-shortest heads, text key, key order, UTF-8), `TestUnmarshalRejectsNonCanonical`, `TestWalk`, `TestEncoderAndWalkAgreeWithAReference` (against `internal/cbortest`), `TestSharedVectors`, `FuzzDecoder`, `FuzzUnmarshal`, `FuzzWalk`, `FuzzEncodeImpliesWalk`; `extension.TestData`; `internal/testkit.TestSchemaVectors`; `testdata/vectors/cbor.json` (generic vectors walked with `codec.Walk`, and one block per schema: Provider Profile, PUBLIC_HEADER, CONTROL_CBOR, `.dkk` body, `verification_metadata`, extension), generated by `internal/testkit.CBORVectors` |
| 58.1 | Absent optional fields are omitted; `h''` and `null` never stand for absence | `extension.Canonical` (nil for empty), `extension.DecodeArray` (empty array, 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; 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 |
| 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; `datekeys inspect -json` rendered by `internal/inspectview` | `capsule.TestConformanceFixtures` (stage by stage), `TestMutationCorpus`, `TestInspectDifferentialCorpus` (`testdata/vectors/inspect_differential.json`: 1825 deterministic mutations of the fixtures with the verdict of steps 1–8, generated by `internal/testkit.InspectDifferential`); `cmd/datekeys.TestInspectJSONGoldens` (`testdata/fixtures/*.inspect.json`) |
| 64 | Mandatory mutation tests | `internal/testkit.Mutations` (the corpus), `internal/testkit.MutationCorpus` (its export) | `capsule.TestMutationCorpus`: the 23 listed mutations plus 32 more, built afresh; `capsule.TestExportedMutationCorpus`: `testdata/vectors/mutations.json`, the same 55 cases as frozen data (capsule, `.dkk`, identities, recorded release, clock, registry, known extensions), replayed with the recorded error and step |
| 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` |
| 67 | `.dkc` vectors | `testdata/fixtures/*.dkc` + `*.json`, `internal/testkit/genfixtures`; the frozen `datekeys inspect -json` output of each, `*.inspect.json`; formats in `testdata/README.md` | `capsule.TestConformanceFixtures`; `cmd/datekeys.TestInspectJSONGoldens` |
| 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` |
| 70 | Compatibility | magic and version checks; `codec.Peek` and `codec.CheckSchema` read keys 0 and 1 only, before strict decoding, with a type tag of at most `codec.MaxTypeTagLen` bytes | mutations; `codec.TestPeek`, `TestCheckSchema`, `TestCheckSchemaVersionForms`, `FuzzPeek`; `capsule.TestDecodeSchemaVersion` |
| 71 | Profile registry | `profile.Decode` + `profile.NewRegistry` with pinned hashes | `profile.TestRegistry` |
| 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 | — |
@ -94,13 +94,14 @@ 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, `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, 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` |
| Bytes that are not the deterministic encoding of a valid schema instance: malformed CBOR, non-canonical encoding (including a map head or type tag head not in its shortest form before the schema version), unknown key, missing key, wrong type, `null`, wrong type tag (key 0, or one longer than `codec.MaxTypeTagLen` bytes), a schema version that is missing, not the second key, not an unsigned integer, not in its shortest form or above 2^53−1, wrong field length, undefined `access_policy` (any value other than 0 and 1), empty optional array or map, extension rules (named by §54 and §57 since v0.8.2) | `ERR_NON_CANONICAL_CBOR` |
| Schema version other than 1, read as the second key, after a type tag within the profile, as an unsigned integer in its shortest form of at most 2^53−1; whatever follows it | `ERR_UNSUPPORTED_VERSION` |
| Truncated framing, length fields of 0 or beyond the §57 limits, an object above its §57 frame on encode or decode, data after BODY_CBOR, malformed or unauthenticated age data (an age header without stanzas, or beyond the parser limits of `filippo.io/age`: 1024 stanzas, 128 arguments, 2 MiB), 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, in a header that parses; a tlock stanza without exactly two arguments | `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` |
| Instant before the profile genesis or after 9999-12-31T23:59:59Z; round outside the profile range | `ERR_DATEKEY_INVALID` |
| Instant before the profile genesis or after 9999-12-31T23:59:59Z; round outside the profile range, including a round time after 9999-12-31T23:59:59Z (step 7) | `ERR_DATEKEY_INVALID` |
| Provider Profile that cannot be pinned, beyond the names and public key size that §57 maps: `genesis_time` outside 1..253402300798, `provider` other than `drand`, a scheme tlock does not support, a public key that is not a point of the scheme's key group or is the identity | `ERR_UNKNOWN_PROFILE` |
| Unknown `access_type`, wrong material length, `.dkk` for another `capsule_id`, `capsule_digest` mismatch, no supplied identity is a recipient | `ERR_ACCESS_INVALID` |
| Round time not reached yet (no request is made), no source delivered the release | `ERR_RELEASE_UNAVAILABLE` |
@ -147,3 +148,27 @@ candidate clarification under §76.
11. **Clock injection.** No library package reads the wall clock; `Encrypt` and
`Open` require a `Now` function, and `Open` never requests a release for a
round whose time has not been reached.
12. **Order of checks within an object.** The type tag and the schema version
are read first, from keys 0 and 1 only (§70). The decoder of each schema
then reads the whole map with every CDDL rule whose violation is
`ERR_NON_CANONICAL_CBOR`, and only then checks the fields that have codes
of their own (§57): the DateKey of PUBLIC_HEADER, `access_type` and
`access_material` of a `.dkk`, the names and public key of a Provider
Profile. An object with faults of both kinds reports
`ERR_NON_CANONICAL_CBOR`.
13. **Profile `period`.** At most 86400 s (one day), an implementation limit
marked in `spec/datekeys.cddl` (§57, §74); §11 allows up to 2^53−1.
`genesis_time` must be in 1..253402300798 for a profile to be pinned
(`ERR_UNKNOWN_PROFILE`): a positive time before the last second that
item 2 allows, so that the profile has at least one round.
14. **Frame lengths.** `PUBLIC_HEADER_LEN` and `SEALED_CONTROL_LEN` of 0 are
out of range (`ERR_INTEGRITY`, step 2): §57 gives only upper bounds, and
no empty frame holds a valid object.
15. **Malformed age headers.** Steps 5 and 6 parse the age header with
`filippo.io/age`; a header that does not parse, including one without
stanzas or beyond the parser limits (1024 stanzas, 128 arguments, 2 MiB),
is `ERR_INTEGRITY`, and only a header that parses is judged by the stanza
rules (`ERR_POLICY_STRUCTURE_MISMATCH`).
All of them, with the other rules of steps 1 to 8 and the access pre-checks,
are written out for a second implementation in `testdata/README.md`.

@ -23,8 +23,6 @@ import (
"strings"
"unicode/utf8"
"github.com/fxamacker/cbor/v2"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/codec"
)
@ -67,46 +65,6 @@ func New(id string, version uint64, data []byte) (Extension, error) {
return e, nil
}
// 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 []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 extensions the application implements. A nil Registry
// knows none, which is the state of the base protocol V1.
type Registry interface {
@ -144,51 +102,102 @@ func validate(e Extension) error {
return nil
}
// Encode validates one extension array and returns its canonical wire form,
// 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) {
// Canonical validates one extension array and returns it in canonical order,
// sorted by the UTF-8 bytes of extension_id: 1 to 64 valid extensions, no
// identifier repeated. An empty input yields nil, so that the array key is
// omitted (spec §58.1).
func Canonical(exts []Extension) ([]Extension, 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)
return nil, errTooMany(len(exts))
}
sorted := slices.Clone(exts)
slices.SortFunc(sorted, compare)
out := make([]Wire, 0, len(sorted))
for i, e := range sorted {
out := slices.Clone(exts)
slices.SortFunc(out, compare)
if err := checkArray(out); err != nil {
return nil, err
}
return out, nil
}
func errTooMany(n int) error {
return fmt.Errorf("extension: %d extensions in one array, at most %d: %w", n, MaxExtensions, datekeys.ErrNonCanonicalCBOR)
}
// checkArray applies the rules of DecodeArray to an array to be written: 1 to
// 64 valid extensions in canonical order, no identifier repeated.
func checkArray(exts []Extension) error {
switch {
case len(exts) == 0:
return fmt.Errorf("extension: empty array; an absent array omits its key: %w", datekeys.ErrNonCanonicalCBOR)
case len(exts) > MaxExtensions:
return errTooMany(len(exts))
}
for i, e := range exts {
if err := validate(e); err != nil {
return nil, err
return err
}
if i > 0 && sorted[i-1].ID == e.ID {
return nil, fmt.Errorf("extension %s: appears more than once: %w", e.ID, datekeys.ErrNonCanonicalCBOR)
if i == 0 {
continue
}
switch c := compare(exts[i-1], e); {
case c == 0:
return fmt.Errorf("extension %s: appears more than once: %w", e.ID, datekeys.ErrNonCanonicalCBOR)
case c > 0:
return fmt.Errorf("extension %s: array is not in canonical order: %w", e.ID, datekeys.ErrNonCanonicalCBOR)
}
out = append(out, Wire{ID: e.ID, Version: e.Version, Data: bytes.Clone(e.Data)})
}
return out, nil
return nil
}
// 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
// EncodeArray writes a non-empty extension array as Canonical returns it:
// each extension is the map {0: extension_id, 1: extension_version} with
// key 2, the data as a byte string, only when the extension carries data
// (spec §54). An array that DecodeArray would reject is not written: its
// error is recorded in e, whose Out returns it.
func EncodeArray(e *codec.Encoder, exts []Extension) {
if err := checkArray(exts); err != nil {
e.Fail(err)
return
}
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)
e.Array(len(exts))
for _, x := range exts {
if x.Data == nil {
e.Map(2)
} else {
e.Map(3)
}
if err := validate(e); err != nil {
e.Uint(0)
e.Text(x.ID)
e.Uint(1)
e.Uint(x.Version)
if x.Data != nil {
e.Uint(2)
e.Bstr(x.Data)
}
}
}
// DecodeArray reads one extension array. The array holds 1 to 64 entries,
// which its head declares before any is read; each entry is a map with
// key 0, a non-empty UTF-8 extension_id of at most MaxIDLen bytes, key 1, an
// extension_version of at most MaxVersion, and optionally key 2, a byte
// string of at least one byte whose content is copied and never decoded
// (spec §54, §58.1). Entries are in canonical order with no identifier
// repeated. Every failure wraps ErrNonCanonicalCBOR.
func DecodeArray(d *codec.Decoder) ([]Extension, error) {
n, err := d.Array(MaxExtensions)
if err != nil {
return nil, fmt.Errorf("extension: %w", err)
}
if n == 0 {
return nil, fmt.Errorf("extension: empty array; an absent array omits its key: %w", datekeys.ErrNonCanonicalCBOR)
}
out := make([]Extension, 0, n)
for i := range n {
e, err := decodeOne(d)
if err != nil {
return nil, err
}
if i > 0 {
@ -204,12 +213,53 @@ func Decode(ws []Wire) ([]Extension, error) {
return out, nil
}
// decodeOne reads the map of one extension. 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 explicitly (spec §54, §58.1).
func decodeOne(d *codec.Decoder) (Extension, error) {
var e Extension
pairs, err := d.Map(3)
if err != nil {
return e, fmt.Errorf("extension: %w", err)
}
var seen [3]bool
for range pairs {
k, err := d.Key()
if err != nil {
return e, fmt.Errorf("extension: %w", err)
}
switch k {
case 0:
e.ID, err = d.Text(MaxIDLen)
case 1:
e.Version, err = d.Uint(MaxVersion)
case 2:
if e.Data, err = d.Bstr(0, MaxDataLen); err == nil && len(e.Data) == 0 {
return e, fmt.Errorf("extension %q: data is present but empty; an extension without data omits key 2: %w", e.ID, datekeys.ErrNonCanonicalCBOR)
}
default:
return e, fmt.Errorf("extension %q: unknown key %d: %w", e.ID, k, datekeys.ErrNonCanonicalCBOR)
}
if err != nil {
return e, fmt.Errorf("extension %q: key %d: %w", e.ID, k, err)
}
seen[k] = true
}
if !seen[0] || !seen[1] {
return e, fmt.Errorf("extension %q: extension_id and extension_version are required: %w", e.ID, datekeys.ErrNonCanonicalCBOR)
}
if err := validate(e); err != nil {
return e, err
}
return e, d.EndMap()
}
// 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). Arrays in canonical order, as Decode returns them, are
// merged in one linear pass; other input is sorted first.
// (spec §31, §54). Arrays in canonical order, as Canonical and DecodeArray
// return them, are merged in one linear pass; other input is sorted first.
func CheckDisjoint(critical, noncritical []Extension) error {
critical, noncritical = canonical(critical), canonical(noncritical)
critical, noncritical = sorted(critical), sorted(noncritical)
for i, j := 0, 0; i < len(critical) && j < len(noncritical); {
switch c := compare(critical[i], noncritical[j]); {
case c == 0:
@ -223,9 +273,9 @@ func CheckDisjoint(critical, noncritical []Extension) error {
return nil
}
// canonical returns exts itself when it is in canonical order, and a sorted
// copy otherwise.
func canonical(exts []Extension) []Extension {
// sorted returns exts itself when it is in canonical order, and a sorted copy
// otherwise.
func sorted(exts []Extension) []Extension {
if slices.IsSortedFunc(exts, compare) {
return exts
}

@ -13,6 +13,7 @@ import (
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/codec"
"g.activething.com/go/DateKeys/extension"
"g.activething.com/go/DateKeys/internal/cbortest"
)
func ext(t *testing.T, id string, v uint64, data []byte) extension.Extension {
@ -60,9 +61,32 @@ func TestNew(t *testing.T) {
}
}
func TestEncodeSortsCanonically(t *testing.T) {
// decodeArray decodes b, one extension array, as the containing objects do:
// DecodeArray and then the re-encoding check.
func decodeArray(b []byte) ([]extension.Extension, error) {
var exts []extension.Extension
decode := func(d *codec.Decoder) (err error) { exts, err = extension.DecodeArray(d); return err }
encode := func(e *codec.Encoder) { extension.EncodeArray(e, exts) }
if err := codec.Unmarshal(b, decode, encode); err != nil {
return nil, err
}
return exts, nil
}
func encodeArray(t *testing.T, exts []extension.Extension) []byte {
t.Helper()
var e codec.Encoder
extension.EncodeArray(&e, exts)
b, err := e.Out()
if err != nil {
t.Fatal(err)
}
return b
}
func TestCanonicalSorts(t *testing.T) {
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)
w, err := extension.Canonical(in)
if err != nil {
t.Fatal(err)
}
@ -74,19 +98,18 @@ func TestEncodeSortsCanonically(t *testing.T) {
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")
if in[0].ID != "org.b" {
t.Fatal("Canonical reordered its input")
}
if w, _ := extension.Canonical(nil); w != nil {
t.Fatal("an empty array must be nil so that the key is omitted")
}
back, err := extension.Decode(w)
back, err := decodeArray(encodeArray(t, w))
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 {
if got, want := hex.EncodeToString(encodeArray(t, w[:2])), "82"+"a200615a0109"+"a300656f72672e6101020241"+"78"; got != want {
t.Fatalf("wire %s, want %s", got, want)
}
}
@ -99,7 +122,7 @@ func many(n int) []extension.Extension {
return out
}
func TestEncodeRejects(t *testing.T) {
func TestCanonicalRejects(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": {{ID: "", Version: 1}},
@ -109,45 +132,90 @@ func TestEncodeRejects(t *testing.T) {
"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) {
if _, err := extension.Canonical(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 {
if w, err := extension.Canonical(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))
// EncodeArray never writes an array that DecodeArray rejects: the Encoder
// records the error and Out returns it.
func TestEncodeArrayRejects(t *testing.T) {
for name, in := range map[string][]extension.Extension{
"nil": nil,
"empty": {},
"65 extensions": many(extension.MaxExtensions + 1),
"present but empty": {{ID: "org.a", Version: 1, Data: []byte{}}},
"out of order": {{ID: "org.b", Version: 1}, {ID: "org.a", Version: 1}},
"same id twice": {{ID: "org.a", Version: 1}, {ID: "org.a", Version: 2}},
"version above 2^32": {{ID: "org.a", Version: extension.MaxVersion + 1}},
"empty id": {{ID: "", Version: 1}},
"id too long": {{ID: strings.Repeat("a", extension.MaxIDLen+1), Version: 1}},
"invalid UTF-8 id": {{ID: "org.\xff", Version: 1}},
} {
var e codec.Encoder
extension.EncodeArray(&e, in)
if b, err := e.Out(); b != nil || !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Errorf("%s: %x %v", name, b, err)
}
}
if b := encodeArray(t, many(extension.MaxExtensions)); b[0] != 0x98 || b[1] != extension.MaxExtensions {
t.Fatalf("64 extensions: %x", b[:2])
}
}
// entries returns the wire maps of exts, without data.
func entries(exts []extension.Extension) []any {
out := make([]any, len(exts))
for i, e := range exts {
out[i] = extension.Wire{ID: e.ID, Version: e.Version, Data: e.Data}
out[i] = map[uint64]any{0: e.ID, 1: e.Version}
}
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}},
"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)),
func TestDecodeArrayRejects(t *testing.T) {
entry := func(id any, v uint64) map[uint64]any { return map[uint64]any{0: id, 1: v} }
for name, in := range map[string]any{
"out of order": []any{entry("org.b", 1), entry("org.a", 1)},
"repeated id": []any{entry("org.a", 1), entry("org.a", 2)},
"present but empty": []any{map[uint64]any{0: "org.a", 1: uint64(1), 2: []byte{}}},
"version above 2^32": []any{entry("org.a", extension.MaxVersion+1)},
"empty id": []any{entry("", 1)},
"id too long": []any{entry(strings.Repeat("a", extension.MaxIDLen+1), 1)},
"invalid UTF-8 id": []any{entry("org.\xff", 1)},
"id not a string": []any{entry(uint64(1), 1)},
"65 extensions": entries(many(extension.MaxExtensions + 1)),
"empty array": []any{},
"not an array": entry("org.a", 1),
"entry not a map": []any{"org.a"},
"four keys": []any{map[uint64]any{0: "org.a", 1: uint64(1), 2: []byte{1}, 3: uint64(0)}},
"unknown key": []any{map[uint64]any{0: "org.a", 1: uint64(1), 3: uint64(0)}},
"without version": []any{map[uint64]any{0: "org.a"}},
"without id": []any{map[uint64]any{1: uint64(1)}},
"text key": cbortest.Raw{0x81, 0xa2, 0x61, 0x61, 0x00, 0x01, 0x01},
"truncated": cbortest.Raw{0x82, 0xa2, 0x00, 0x61, 0x61, 0x01, 0x01},
} {
if _, err := extension.Decode(in); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
b, err := cbortest.Marshal(in)
if err != nil {
t.Fatal(err)
}
if _, err := decodeArray(b); !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 {
b, _ := cbortest.Marshal(entries(many(extension.MaxExtensions)))
if got, err := decodeArray(b); 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: ...}
func TestData(t *testing.T) {
const head = "81" + "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"},
@ -156,14 +224,12 @@ func TestWireData(t *testing.T) {
{"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)
if w, err := decodeArray(b); err != nil || hex.EncodeToString(w[0].Data) != tc.data {
t.Errorf("%s: %v", tc.name, 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" {
if none, err := decodeArray([]byte{0x81, 0xa2, 0x00, 0x61, 0x61, 0x01, 0x01}); err != nil || none[0].Data != nil || none[0].ID != "a" {
t.Fatalf("extension without data: %+v %v", none, err)
}
for _, tc := range []struct{ name, item string }{
@ -183,18 +249,11 @@ func TestWireData(t *testing.T) {
{"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)
if w, err := decodeArray(b); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Errorf("%s: %+v %v", tc.name, w, 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 TestCheckDisjoint(t *testing.T) {
@ -298,3 +357,36 @@ func TestCheckNoncritical(t *testing.T) {
t.Fatalf("unusable: %+v", u)
}
}
// FuzzDecodeArray: whatever DecodeArray accepts is in canonical order and
// re-encodes to its input with EncodeArray, and every error carries
// ErrNonCanonicalCBOR.
func FuzzDecodeArray(f *testing.F) {
for _, h := range []string{
"81a2006161" + "0101",
"82a2006161" + "0101" + "a3006162" + "0102" + "02" + "4100",
"81a3006161010102" + "40",
"80",
} {
b, _ := hex.DecodeString(h)
f.Add(b)
}
same := func(a, b extension.Extension) bool {
return a.ID == b.ID && a.Version == b.Version && bytes.Equal(a.Data, b.Data) && (a.Data == nil) == (b.Data == nil)
}
f.Fuzz(func(t *testing.T, b []byte) {
exts, err := decodeArray(b)
if err != nil {
if !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Fatalf("error without ErrNonCanonicalCBOR: %v", err)
}
return
}
if c, err := extension.Canonical(exts); err != nil || !slices.EqualFunc(c, exts, same) {
t.Fatalf("decoded array is not canonical: %v", err)
}
if re := encodeArray(t, exts); !bytes.Equal(re, b) {
t.Fatalf("%x re-encodes to %x", b, re)
}
})
}

@ -7,7 +7,6 @@ require (
github.com/drand/drand/v2 v2.1.7
github.com/drand/kyber v1.3.2
github.com/drand/tlock v1.2.0
github.com/fxamacker/cbor/v2 v2.9.4
golang.org/x/crypto v0.57.0
)
@ -17,7 +16,6 @@ require (
github.com/drand/kyber-bls12381 v0.3.4 // indirect
github.com/kilic/bls12-381 v0.1.0 // indirect
github.com/nikkolasg/hexjson v0.1.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.dedis.ch/fixbuf v1.0.3 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.28.0 // indirect

@ -32,8 +32,6 @@ github.com/drand/kyber-bls12381 v0.3.4 h1:rrmYcRcXmtOAvKWVBxRQxi22qNMVcS2Jz7MAeb
github.com/drand/kyber-bls12381 v0.3.4/go.mod h1:jh3IGIAQfdLrdNKYz1HWZ3YdfJM0DWlN1TxXkh60utk=
github.com/drand/tlock v1.2.0 h1:YmbH2PXsq6UeUXljq+GMZcDicUlVnLIW9QbLqYoDp6g=
github.com/drand/tlock v1.2.0/go.mod h1:HFjdoX5v8rp4uOFaIPI8nDdWRKdvDnNgj+kQwQOOxoQ=
github.com/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo=
github.com/fxamacker/cbor/v2 v2.9.4/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@ -82,8 +80,6 @@ github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWb
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.dedis.ch/fixbuf v1.0.3 h1:hGcV9Cd/znUxlusJ64eAlExS+5cJDIyTyEG+otu5wQs=
go.dedis.ch/fixbuf v1.0.3/go.mod h1:yzJMt34Wa5xD37V5RTdmp38cz3QhMagdGoem9anUalw=
go.dedis.ch/protobuf v1.0.11 h1:FTYVIEzY/bfl37lu3pR4lIj+F9Vp1jE8oh91VmxKgLo=

@ -0,0 +1,252 @@
// Package cbortest encodes and decodes generic CBOR values for tests.
//
// It is written independently of package codec, on purpose: tests use it to
// build inputs that codec cannot write, such as null, negative integers or a
// non-canonical head inside an otherwise valid object, and as a second
// reading of the CBOR profile of spec §58 to check codec against.
//
// It is internal and exists for tests only.
package cbortest
import (
"encoding/binary"
"errors"
"fmt"
"slices"
"unicode/utf8"
)
// Raw is an encoded data item that Marshal writes verbatim.
type Raw []byte
// Pairs is a map whose entries Marshal writes in the given order, keys and
// values alternating. Its keys may be of any type Marshal accepts, so it
// builds maps with keys out of order, repeated or not unsigned integers.
type Pairs []any
// Marshal returns the deterministic encoding of v, which is one of:
// uint64, uint, int or int64 (a negative value as major type 1), string,
// []byte, bool, nil (null), []any, map[uint64]any (keys in ascending order),
// Pairs or Raw. Strings are written as they are, even if they are not valid
// UTF-8.
func Marshal(v any) ([]byte, error) { return appendValue(nil, v) }
func appendHead(b []byte, major byte, arg uint64) []byte {
m := major << 5
switch {
case arg < 24:
return append(b, m|byte(arg))
case arg < 1<<8:
return append(b, m|24, byte(arg))
case arg < 1<<16:
return binary.BigEndian.AppendUint16(append(b, m|25), uint16(arg))
case arg < 1<<32:
return binary.BigEndian.AppendUint32(append(b, m|26), uint32(arg))
}
return binary.BigEndian.AppendUint64(append(b, m|27), arg)
}
func appendInt(b []byte, v int64) []byte {
if v < 0 {
return appendHead(b, 1, uint64(-(v + 1)))
}
return appendHead(b, 0, uint64(v))
}
func appendValue(b []byte, v any) ([]byte, error) {
switch v := v.(type) {
case nil:
return append(b, 0xf6), nil
case bool:
if v {
return append(b, 0xf5), nil
}
return append(b, 0xf4), nil
case uint64:
return appendHead(b, 0, v), nil
case uint:
return appendHead(b, 0, uint64(v)), nil
case int:
return appendInt(b, int64(v)), nil
case int64:
return appendInt(b, v), nil
case []byte:
return append(appendHead(b, 2, uint64(len(v))), v...), nil
case string:
return append(appendHead(b, 3, uint64(len(v))), v...), nil
case Raw:
return append(b, v...), nil
case []any:
b = appendHead(b, 4, uint64(len(v)))
return appendAll(b, v)
case Pairs:
if len(v)%2 != 0 {
return nil, fmt.Errorf("cbortest: Pairs of odd length %d", len(v))
}
b = appendHead(b, 5, uint64(len(v)/2))
return appendAll(b, v)
case map[uint64]any:
keys := make([]uint64, 0, len(v))
for k := range v {
keys = append(keys, k)
}
slices.Sort(keys)
b = appendHead(b, 5, uint64(len(v)))
for _, k := range keys {
b = appendHead(b, 0, k)
var err error
if b, err = appendValue(b, v[k]); err != nil {
return nil, err
}
}
return b, nil
}
return nil, fmt.Errorf("cbortest: cannot encode %T", v)
}
func appendAll(b []byte, vs []any) ([]byte, error) {
for _, x := range vs {
var err error
if b, err = appendValue(b, x); err != nil {
return nil, err
}
}
return b, nil
}
// MaxDepth bounds the nesting that Unmarshal follows.
const MaxDepth = 1000
// Unmarshal decodes exactly one data item of the CBOR profile of spec §58
// into uint64, []byte, string, []any and map[uint64]any. It rejects every
// other major type, indefinite lengths, heads not in their shortest form,
// map keys that are not unsigned integers in strictly ascending order,
// invalid UTF-8, truncation, trailing bytes and nesting deeper than MaxDepth.
func Unmarshal(b []byte) (any, error) {
r := &reader{b: b}
v, err := r.value(0)
if err != nil {
return nil, err
}
if r.off != len(b) {
return nil, fmt.Errorf("cbortest: %d trailing bytes", len(b)-r.off)
}
return v, nil
}
// UnmarshalMap is Unmarshal for an input that must hold a map.
func UnmarshalMap(b []byte) (map[uint64]any, error) {
v, err := Unmarshal(b)
if err != nil {
return nil, err
}
m, ok := v.(map[uint64]any)
if !ok {
return nil, fmt.Errorf("cbortest: %T, not a map", v)
}
return m, nil
}
var errTruncated = errors.New("cbortest: truncated")
type reader struct {
b []byte
off int
}
func (r *reader) head() (major byte, arg uint64, err error) {
if r.off >= len(r.b) {
return 0, 0, errTruncated
}
ib := r.b[r.off]
r.off++
major, info := ib>>5, ib&0x1f
if major == 1 || major >= 6 {
return 0, 0, fmt.Errorf("cbortest: major type %d", major)
}
if info < 24 {
return major, uint64(info), nil
}
if info > 27 {
return 0, 0, fmt.Errorf("cbortest: additional information %d", info)
}
n := 1 << (info - 24)
if len(r.b)-r.off < n {
return 0, 0, errTruncated
}
for _, c := range r.b[r.off : r.off+n] {
arg = arg<<8 | uint64(c)
}
r.off += n
if (n == 1 && arg < 24) || (n > 1 && arg>>(4*n) == 0) {
return 0, 0, fmt.Errorf("cbortest: %d not in its shortest form", arg)
}
return major, arg, nil
}
func (r *reader) bytes(n uint64) ([]byte, error) {
if n > uint64(len(r.b)-r.off) {
return nil, errTruncated
}
s := r.b[r.off : r.off+int(n)]
r.off += int(n)
return append([]byte{}, s...), nil
}
func (r *reader) value(depth int) (any, error) {
major, arg, err := r.head()
if err != nil {
return nil, err
}
switch major {
case 0:
return arg, nil
case 2:
return r.bytes(arg)
case 3:
s, err := r.bytes(arg)
if err != nil {
return nil, err
}
if !utf8.Valid(s) {
return nil, errors.New("cbortest: invalid UTF-8")
}
return string(s), nil
}
if depth >= MaxDepth {
return nil, errors.New("cbortest: nested too deep")
}
if arg > uint64(len(r.b)-r.off) {
return nil, errTruncated
}
if major == 4 {
out := make([]any, 0, arg)
for range arg {
v, err := r.value(depth + 1)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, nil
}
out := make(map[uint64]any, arg)
var last uint64
for i := range arg {
km, k, err := r.head()
if err != nil {
return nil, err
}
if km != 0 {
return nil, fmt.Errorf("cbortest: map key of major type %d", km)
}
if i > 0 && k <= last {
return nil, fmt.Errorf("cbortest: map key %d after %d", k, last)
}
last = k
if out[k], err = r.value(depth + 1); err != nil {
return nil, err
}
}
return out, nil
}

@ -0,0 +1,60 @@
package cbortest
import (
"bytes"
"encoding/hex"
"reflect"
"strings"
"testing"
)
func TestMarshal(t *testing.T) {
for _, tc := range []struct {
v any
want string
}{
{uint64(0), "00"}, {uint(24), "1818"}, {256, "190100"}, {int64(-1), "20"}, {-500, "3901f3"},
{uint64(1) << 32, "1b0000000100000000"}, {"a", "6161"}, {[]byte{1}, "4101"},
{true, "f5"}, {false, "f4"}, {nil, "f6"}, {Raw{0xf9, 0x7e, 0x00}, "f97e00"},
{[]any{uint64(1), "x"}, "82016178"}, {map[uint64]any{10: uint64(1), 2: uint64(0)}, "a202000a01"},
{Pairs{uint64(1), uint64(0), "k", nil}, "a2010061" + "6bf6"},
{uint64(65536), "1a00010000"},
} {
b, err := Marshal(tc.v)
if err != nil || hex.EncodeToString(b) != tc.want {
t.Errorf("%#v: %x %v, want %s", tc.v, b, err, tc.want)
}
}
for _, v := range []any{Pairs{uint64(1)}, 1.5, []any{struct{}{}}, map[uint64]any{0: 1.5}, Pairs{uint64(0), 1.5}} {
if _, err := Marshal(v); err == nil {
t.Errorf("%#v encoded", v)
}
}
}
func TestUnmarshal(t *testing.T) {
v, err := Unmarshal([]byte{0xa2, 0x00, 0x82, 0x40, 0x60, 0x0a, 0x1b, 0, 0, 0, 1, 0, 0, 0, 0})
want := map[uint64]any{0: []any{[]byte{}, ""}, 10: uint64(1) << 32}
if err != nil || !reflect.DeepEqual(v, want) {
t.Fatalf("%#v %v", v, err)
}
for _, h := range []string{
"", "20", "c101", "f5", "f6", "1c", "9f00ff", "1817", "190017", "1a0000ffff", "1b00000000ffffffff",
"a1616100", "a201000001", "a200000001", "61ff", "0100", "1901", "4201", "8201", "a100",
strings.Repeat("81", MaxDepth+1) + "00",
} {
b, _ := hex.DecodeString(h)
if _, err := Unmarshal(b); err == nil {
t.Errorf("%s accepted", h)
}
}
if _, err := UnmarshalMap([]byte{0x80}); err == nil {
t.Error("an array read as a map")
}
if _, err := UnmarshalMap([]byte{0xff}); err == nil {
t.Error("garbage read as a map")
}
if m, err := UnmarshalMap([]byte{0xa1, 0x00, 0x41, 0x07}); err != nil || !bytes.Equal(m[0].([]byte), []byte{7}) {
t.Errorf("%v %v", m, err)
}
}

@ -0,0 +1,64 @@
// Package inspectview renders the result of capsule.Inspect as the
// "datekeys inspect" command prints it. The command and the generator of the
// frozen inspect outputs (testdata/fixtures/*.inspect.json) share it, so that
// the frozen files are exactly what the command prints.
package inspectview
import (
"encoding/json"
"fmt"
"io"
"time"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/capsule"
)
// View is the result of "datekeys inspect"; "-json" prints it as JSON.
type View struct {
File string `json:"file"`
CapsuleID string `json:"capsule_id,omitempty"`
DateKey string `json:"datekey,omitempty"`
Profile string `json:"profile,omitempty"`
Round uint64 `json:"round,omitempty"`
UnlockAt string `json:"unlock_at,omitempty"`
AccessPolicy string `json:"access_policy,omitempty"`
Valid bool `json:"valid"`
Error string `json:"error,omitempty"`
Checks []capsule.CheckResult `json:"checks"`
}
// New returns the view of the inspection of file: the result and the error
// of capsule.Inspect.
func New(file string, result *capsule.Inspection, err error) View {
v := View{File: file, Valid: err == nil, Error: datekeys.Code(err), Checks: result.Checks}
if h := result.Header; h != nil {
v.CapsuleID, v.DateKey, v.Profile, v.Round, v.AccessPolicy = h.CapsuleIDHex(), h.DateKey.Compact(), h.DateKey.ProfileID, h.DateKey.Round, h.Policy.String()
}
if !result.UnlockAt.IsZero() {
v.UnlockAt = result.UnlockAt.Format(time.RFC3339)
}
return v
}
// WriteJSON writes v as indented JSON followed by a newline.
func (v View) WriteJSON(w io.Writer) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(v)
}
// WriteText writes v as lines of text, one per check.
func (v View) WriteText(w io.Writer) {
fmt.Fprintf(w, "%s\n", v.File)
for _, c := range v.Checks {
mark := "ok "
if !c.OK {
mark = "FAIL"
}
fmt.Fprintf(w, " [%s] step %2d %-26s %s\n", mark, c.Step, c.Name, c.Detail)
}
if v.Valid {
fmt.Fprintf(w, " valid before unlock; opens at %s (round %d, %s)\n", v.UnlockAt, v.Round, v.AccessPolicy)
}
}

@ -29,6 +29,10 @@ type Build struct {
AccessRecipients []age.Recipient
Plaintext []byte
DateKeyString string // overrides the canonical dk1_ string in PUBLIC_HEADER
// RawPolicy, when not zero, is the access_policy value written in
// PUBLIC_HEADER instead of Declared, which may be outside V1. A header
// written with RawPolicy or DateKeyString carries no extensions.
RawPolicy uint64
HeaderCritical, HeaderNoncritical []extension.Extension
ControlCritical, ControlNoncritical []extension.Extension
@ -153,23 +157,36 @@ func (b Build) header(p *profile.Profile, round uint64, id [capsule.CapsuleIDSiz
Critical: b.HeaderCritical,
Noncritical: b.HeaderNoncritical,
}
if b.DateKeyString == "" {
if b.DateKeyString == "" && b.RawPolicy == 0 {
return capsule.EncodeHeader(h)
}
return RawHeader(id, b.DateKeyString, uint64(b.Declared))
dk, policy := b.DateKeyString, uint64(b.Declared)
if dk == "" {
dk = h.DateKey.Compact()
}
if b.RawPolicy != 0 {
policy = b.RawPolicy
}
return RawHeader(id, dk, policy)
}
// RawHeader encodes a PUBLIC_HEADER with an arbitrary DateKey string and
// policy value, bypassing the validation of capsule.EncodeHeader.
// RawHeader encodes a PUBLIC_HEADER with an arbitrary DateKey string, which
// must be valid UTF-8, and policy value, bypassing the validation of
// capsule.EncodeHeader.
func RawHeader(id [capsule.CapsuleIDSize]byte, dk string, policy uint64) ([]byte, error) {
type wire struct {
Type string `cbor:"0,keyasint"`
Version uint64 `cbor:"1,keyasint"`
CapsuleID []byte `cbor:"2,keyasint"`
DateKey string `cbor:"3,keyasint"`
Policy uint64 `cbor:"4,keyasint"`
}
return codec.Marshal(wire{capsule.HeaderTypeTag, capsule.HeaderVersion, id[:], dk, policy})
var e codec.Encoder
e.Map(5)
e.Uint(0)
e.Text(capsule.HeaderTypeTag)
e.Uint(1)
e.Uint(capsule.HeaderVersion)
e.Uint(2)
e.Bstr(id[:])
e.Uint(3)
e.Text(dk)
e.Uint(4)
e.Uint(policy)
return e.Out()
}
// Parts is a .dkc split into its four sections.

@ -0,0 +1,597 @@
package testkit
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"math"
"strconv"
"strings"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/accesskey"
"g.activething.com/go/DateKeys/capsule"
"g.activething.com/go/DateKeys/codec"
"g.activething.com/go/DateKeys/datekey"
"g.activething.com/go/DateKeys/internal/cbortest"
"g.activething.com/go/DateKeys/profile"
)
// Limits with which the generic vectors of cbor.json are walked: at most three
// nested containers, the most any object of the protocol has (object,
// extension array, extension), and at most 64 bytes in a string and 64 items
// or entries in a container, the size of the largest extension array.
const (
WalkMaxDepth = 3
WalkMaxLen = 64
)
// Schema names of the schema vectors: the object the bytes encode and the
// decoder that reads them.
const (
SchemaProfile = "provider_profile"
SchemaHeader = "public_header"
SchemaControl = "control_cbor"
SchemaDKKBody = "dkk_body"
)
// ResultOK is the result of a vector, mutation or inspection that is accepted.
const ResultOK = "ok"
// CBORVectorFile is testdata/vectors/cbor.json.
type CBORVectorFile struct {
Spec string `json:"spec"`
Description string `json:"description"`
Walk WalkLimits `json:"walk"`
Accept []CBORVector `json:"accept"`
Reject []CBORVector `json:"reject"`
Schemas []SchemaVector `json:"schemas"`
}
// WalkLimits are the maxDepth and maxLen arguments of codec.Walk.
type WalkLimits struct {
MaxDepth int `json:"max_depth"`
MaxLen int `json:"max_len"`
}
// CBORVector is one generic vector: bytes that are, or are not, exactly one
// data item of the CBOR profile of spec §58 within the walk limits.
type CBORVector struct {
Name string `json:"name"`
Hex string `json:"hex"`
// Value is the value of an accepted unsigned integer: a JSON number up
// to 2^53-1, a decimal string above.
Value any `json:"value,omitempty"`
// Error is the code of a rejected vector.
Error string `json:"error,omitempty"`
}
// SchemaVector is one encoded object and the result of its decoder.
type SchemaVector struct {
// Block names the schema the vector exercises: one of the Schema*
// names, "verification_metadata" (inside a .dkk body) or "extension"
// (inside the noncritical_extensions of a PUBLIC_HEADER).
Block string `json:"block"`
// Schema names the object the bytes encode and the decoder to run.
Schema string `json:"schema"`
Name string `json:"name"`
Hex string `json:"hex"`
// Result is ResultOK or the normative code of the rejection.
Result string `json:"result"`
}
// DecodeSchema runs the decoder of the named schema on b, as a reader does
// before any registry is consulted: profile.Decode, capsule.DecodeHeader,
// capsule.DecodeControl or accesskey.DecodeBody. Secrets it decodes are
// wiped.
func DecodeSchema(schema string, b []byte) error {
switch schema {
case SchemaProfile:
_, err := profile.Decode(b)
return err
case SchemaHeader:
_, err := capsule.DecodeHeader(b)
return err
case SchemaControl:
c, err := capsule.DecodeControl(b)
if c != nil {
clear(c.PayloadIdentity[:])
}
return err
case SchemaDKKBody:
k, err := accesskey.DecodeBody(b)
if k != nil {
k.Wipe()
}
return err
}
return fmt.Errorf("testkit: unknown schema %q", schema)
}
// Result returns ResultOK for a nil error and its normative code otherwise.
func Result(err error) string {
if err == nil {
return ResultOK
}
if c := datekeys.Code(err); c != "" {
return c
}
return "error without a normative code: " + err.Error()
}
// CBORVectors computes testdata/vectors/cbor.json with the implementation,
// and fails if any vector does not get the result it is written for.
func CBORVectors() (CBORVectorFile, error) {
f := CBORVectorFile{
Spec: SpecVersion,
Description: "CBOR profile of spec §58 and the schemas of spec/datekeys.cddl, generated by the reference implementation. " +
"accept and reject are walked as one data item of the profile with the limits of walk; " +
"schemas are decoded with the decoder of their schema. See testdata/README.md.",
Walk: WalkLimits{MaxDepth: WalkMaxDepth, MaxLen: WalkMaxLen},
}
var errs []error
for _, g := range genericVectors() {
b, err := hex.DecodeString(g.hex)
if err != nil {
return f, fmt.Errorf("vector %q: %w", g.name, err)
}
got := Result(codec.Walk(b, WalkMaxDepth, WalkMaxLen))
v := CBORVector{Name: g.name, Hex: g.hex}
if g.accept {
if got != ResultOK {
errs = append(errs, fmt.Errorf("vector %q: want accepted, got %s", g.name, got))
}
v.Value = uintValue(b)
f.Accept = append(f.Accept, v)
continue
}
if got != datekeys.ErrNonCanonicalCBOR.Code() {
errs = append(errs, fmt.Errorf("vector %q: want %s, got %s", g.name, datekeys.ErrNonCanonicalCBOR.Code(), got))
}
v.Error = got
f.Reject = append(f.Reject, v)
}
sv, err := schemaVectors()
if err != nil {
return f, err
}
for _, s := range sv {
b, err := cbortest.Marshal(s.value)
if err != nil {
return f, fmt.Errorf("schema vector %s %q: %w", s.block, s.name, err)
}
got := Result(DecodeSchema(s.schema, b))
if got != s.want {
errs = append(errs, fmt.Errorf("schema vector %s %q: want %s, got %s", s.block, s.name, s.want, got))
}
f.Schemas = append(f.Schemas, SchemaVector{Block: s.block, Schema: s.schema, Name: s.name, Hex: hex.EncodeToString(b), Result: got})
}
return f, errors.Join(errs...)
}
// uintValue returns the value of b when b is exactly one unsigned integer:
// a number up to 2^53-1, a decimal string above. It returns nil otherwise.
func uintValue(b []byte) any {
v, err := cbortest.Unmarshal(b)
if err != nil {
return nil
}
n, ok := v.(uint64)
if !ok {
return nil
}
if n > codec.MaxSafeUint {
return strconv.FormatUint(n, 10)
}
return n
}
type genericVector struct {
name string
hex string
accept bool
}
func genericVectors() []genericVector {
rep := func(s string, n int) string { return strings.Repeat(s, n) }
// 64 map entries {0: 0, ..., 63: 0}, and one more.
mapEntries := func(n int) string {
var b strings.Builder
for k := range n {
if k < 24 {
fmt.Fprintf(&b, "%02x00", k)
} else {
fmt.Fprintf(&b, "18%02x00", k)
}
}
return b.String()
}
accept := []genericVector{
{name: "uint 0", hex: "00"},
{name: "uint 23 inline", hex: "17"},
{name: "uint 24 one byte", hex: "1818"},
{name: "uint 255 one byte", hex: "18ff"},
{name: "uint 256 two bytes", hex: "190100"},
{name: "uint 65535 two bytes", hex: "19ffff"},
{name: "uint 65536 four bytes", hex: "1a00010000"},
{name: "uint 2^32-1 four bytes", hex: "1affffffff"},
{name: "uint 2^32 eight bytes", hex: "1b0000000100000000"},
{name: "uint 2^53-1 eight bytes", hex: "1b001fffffffffffff"},
{name: "uint 2^53 eight bytes", hex: "1b0020000000000000"},
{name: "uint 2^64-1 eight bytes", hex: "1bffffffffffffffff"},
{name: "empty bstr", hex: "40"},
{name: "bstr of one byte", hex: "4100"},
{name: "bstr of 23 bytes, inline length", hex: "57" + rep("ab", 23)},
{name: "bstr of 24 bytes, one-byte length", hex: "5818" + rep("ab", 24)},
{name: "bstr of 64 bytes, max_len", hex: "5840" + rep("ab", 64)},
{name: "empty text", hex: "60"},
{name: "text a", hex: "6161"},
{name: "text with NUL", hex: "6100"},
{name: "text with leading BOM", hex: "64efbbbf61"},
{name: "text U+FF61", hex: "63efbda1"},
{name: "text U+10000", hex: "64f0908080"},
{name: "text U+10FFFF", hex: "64f48fbfbf"},
{name: "text of 64 bytes, max_len", hex: "7840" + rep("61", 64)},
{name: "empty array", hex: "80"},
{name: "empty map", hex: "a0"},
{name: "map two sorted keys", hex: "a200010101"},
{name: "map keys 23 and 24", hex: "a21700181800"},
{name: "map keys 255 and 256", hex: "a218ff0019010000"},
{name: "map with text and bstr values", hex: "a20061610141" + "00"},
{name: "array of mixed items", hex: "8500406080a0"},
{name: "array of 64 items, max_len", hex: "9840" + rep("00", 64)},
{name: "map of 64 entries, max_len", hex: "b840" + mapEntries(64)},
{name: "containers nested 3 deep, max_depth", hex: "81818100"},
{name: "map in array in map", hex: "a10081a10000"},
}
reject := []genericVector{
{name: "empty input", hex: ""},
{name: "uint 23 with one extra byte", hex: "1817"},
{name: "uint 255 in two bytes", hex: "1900ff"},
{name: "uint 65535 in four bytes", hex: "1a0000ffff"},
{name: "uint 2^32-1 in eight bytes", hex: "1b00000000ffffffff"},
{name: "bstr length not shortest", hex: "5800"},
{name: "text length not shortest", hex: "7800"},
{name: "array length not shortest", hex: "9800"},
{name: "map length not shortest", hex: "b800"},
{name: "map key not shortest", hex: "a1180000"},
{name: "keys out of order", hex: "a201000001"},
{name: "duplicate key", hex: "a200000001"},
{name: "keys out of order in a nested map", hex: "a100a2010000" + "00"},
{name: "text key", hex: "a1616100"},
{name: "bstr key", hex: "a1416100"},
{name: "negative integer key", hex: "a12000"},
{name: "array key", hex: "a18000"},
{name: "float key", hex: "a1f93c0000"},
{name: "indefinite array", hex: "9f01ff"},
{name: "indefinite map", hex: "bf0000ff"},
{name: "indefinite bstr", hex: "5f4100ff"},
{name: "indefinite text", hex: "7f6161ff"},
{name: "break", hex: "ff"},
{name: "tag", hex: "c101"},
{name: "tag 24, encoded CBOR data item", hex: "d8184100"},
{name: "tag 2, bignum", hex: "c24101"},
{name: "float", hex: "f97e00"},
{name: "half-precision float 1.0", hex: "f93c00"},
{name: "single-precision float 1.0", hex: "fa3f800000"},
{name: "double-precision float 1.0", hex: "fb3ff0000000000000"},
{name: "false", hex: "f4"},
{name: "true", hex: "f5"},
{name: "null", hex: "f6"},
{name: "undefined", hex: "f7"},
{name: "simple value 16", hex: "f0"},
{name: "simple value 32", hex: "f820"},
{name: "negative int", hex: "20"},
{name: "negative int -25", hex: "3818"},
{name: "reserved additional information 28", hex: "1c"},
{name: "reserved additional information 29 in a bstr head", hex: "5d"},
{name: "reserved additional information 30 in an array head", hex: "9e"},
{name: "truncated uint", hex: "1901"},
{name: "truncated eight-byte uint", hex: "1b00000000"},
{name: "truncated bstr", hex: "4200"},
{name: "truncated text", hex: "6261"},
{name: "truncated array", hex: "8200"},
{name: "map without the value of its last key", hex: "a100"},
{name: "truncated map head", hex: "b900"},
{name: "length beyond input", hex: "5affffffff"},
{name: "array count beyond input", hex: "9affffffff"},
{name: "map count beyond input", hex: "baffffffff"},
{name: "trailing byte", hex: "0100"},
{name: "trailing byte after a map", hex: "a000"},
{name: "invalid UTF-8", hex: "61ff"},
{name: "overlong UTF-8", hex: "62c080"},
{name: "overlong three-byte UTF-8", hex: "63e08080"},
{name: "UTF-8 surrogate", hex: "63eda080"},
{name: "truncated UTF-8 sequence", hex: "62e282"},
{name: "UTF-8 above U+10FFFF", hex: "64f4908080"},
{name: "invalid UTF-8 in a map value", hex: "a10061ff"},
{name: "tag inside an array", hex: "81c101"},
{name: "float inside a map", hex: "a100f93c00"},
{name: "containers nested 4 deep, above max_depth", hex: "8181818100"},
{name: "bstr of 65 bytes, above max_len", hex: "5841" + rep("ab", 65)},
{name: "text of 65 bytes, above max_len", hex: "7841" + rep("61", 65)},
{name: "array of 65 items, above max_len", hex: "9841" + rep("00", 65)},
{name: "map of 65 entries, above max_len", hex: "b841" + mapEntries(65)},
}
for i := range accept {
accept[i].accept = true
}
return append(accept, reject...)
}
type schemaVector struct {
block, schema, name string
value any // encoded with cbortest.Marshal
want string
}
// with returns a copy of m with the given keys set; a nil value deletes the key.
func with(m map[uint64]any, kv ...any) map[uint64]any {
c := make(map[uint64]any, len(m)+len(kv)/2)
for k, v := range m {
c[k] = v
}
for i := 0; i < len(kv); i += 2 {
k := uint64(kv[i].(int))
if kv[i+1] == nil {
delete(c, k)
} else {
c[k] = kv[i+1]
}
}
return c
}
// null is CBOR null, which with cannot set because nil deletes a key.
var null = cbortest.Raw{0xf6}
// appendRaw returns the encoding of v followed by extra bytes.
func appendRaw(v any, extra ...byte) cbortest.Raw {
b, err := cbortest.Marshal(v)
if err != nil {
panic(err)
}
return cbortest.Raw(append(b, extra...))
}
func fill(b byte, n int) []byte { return bytes.Repeat([]byte{b}, n) }
func ext(id string, version uint64, data ...any) map[uint64]any {
e := map[uint64]any{0: id, 1: version}
if len(data) > 0 {
e[2] = data[0]
}
return e
}
func exts(n int) []any {
out := make([]any, n)
for i := range out {
out[i] = ext(fmt.Sprintf("org.example.%03d", i), 1)
}
return out
}
// chainHash returns the chain hash that profile.Validate requires of a
// Provider Profile map, the drand chain-info hash: SHA-256 of period (key 7)
// as a big-endian uint32, genesis_time (key 8) as a big-endian int64,
// public_key (key 6), genesis_seed (key 10) and, unless it is "default",
// network (key 4). The vectors below that change one of these keys and keep
// the chain hash consistent test that key's own rule, not the self-check.
func chainHash(m map[uint64]any) []byte {
var n [12]byte
binary.BigEndian.PutUint32(n[:4], uint32(m[7].(uint64)))
binary.BigEndian.PutUint64(n[4:], m[8].(uint64))
h := sha256.New()
h.Write(n[:])
h.Write(m[6].([]byte))
h.Write(m[10].([]byte))
if network := m[4].(string); network != "default" {
h.Write([]byte(network))
}
return h.Sum(nil)
}
func schemaVectors() ([]schemaVector, error) {
const (
nc = "ERR_NON_CANONICAL_CBOR"
unsup = "ERR_UNSUPPORTED_VERSION"
unknown = "ERR_UNKNOWN_PROFILE"
)
qb, err := profile.Quicknet().CanonicalCBOR()
if err != nil {
return nil, err
}
q, err := cbortest.UnmarshalMap(qb)
if err != nil {
return nil, err
}
pairs := func(m map[uint64]any, order ...uint64) cbortest.Pairs {
var p cbortest.Pairs
for _, k := range order {
p = append(p, k, m[k])
}
return p
}
if !bytes.Equal(chainHash(q), q[5].([]byte)) {
return nil, errors.New("testkit: chainHash does not reproduce the Quicknet chain hash")
}
var out []schemaVector
add := func(block, schema, name string, v any, want string) {
out = append(out, schemaVector{block: block, schema: schema, name: name, value: v, want: want})
}
// Provider Profile (spec §11): keys 0 to 10, all required.
pp := func(name string, v any, want string) { add(SchemaProfile, SchemaProfile, name, v, want) }
pp("Quicknet profile", q, ResultOK)
pp("unknown key 11", with(q, 11, uint64(0)), nc)
pp("missing key 10, genesis_seed", with(q, 10, nil), nc)
pp("missing key 6, public_key", with(q, 6, nil), nc)
pp("keys 2 and 3 out of order", pairs(q, 0, 1, 3, 2, 4, 5, 6, 7, 8, 9, 10), nc)
pp("period as a text string", with(q, 7, "3"), nc)
pp("chain_hash as a text string", with(q, 5, strings.Repeat("ab", 32)), nc)
pp("null genesis_seed", with(q, 10, null), nc)
pp("chain_hash of 31 bytes", with(q, 5, q[5].([]byte)[:31]), nc)
pp("genesis_seed of 33 bytes", with(q, 10, append(bytes.Clone(q[10].([]byte)), 0)), nc)
pp("period 0", with(q, 7, uint64(0)), nc)
// rehashed keeps chain_hash consistent with the changed keys.
rehashed := func(kv ...any) map[uint64]any {
m := with(q, kv...)
return with(m, 5, chainHash(m))
}
pp("network default, left out of the chain hash", rehashed(4, "default"), ResultOK)
pp("period of one day, the implementation limit", rehashed(7, uint64(86400)), ResultOK)
pp("period of one day and one second, above the implementation limit", rehashed(7, uint64(86401)), nc)
pp("period 2^53", with(q, 7, uint64(1)<<53), nc)
pp("genesis_time 2^53", with(q, 8, uint64(1)<<53), nc)
pp("negative genesis_time", with(q, 8, -1), nc)
pp("period not in shortest form", with(q, 7, cbortest.Raw{0x1a, 0, 0, 0, 3}), nc)
pp("schema version 2", with(q, 1, uint64(2)), unsup)
pp("schema version 2 and an unknown key 11: the version is read first", with(q, 1, uint64(2), 11, uint64(0)), unsup)
pp("schema version 2^53", with(q, 1, uint64(1)<<53), nc)
pp("type tag of PUBLIC_HEADER", with(q, 0, capsule.HeaderTypeTag), nc)
pp("type tag of PUBLIC_HEADER and schema version 2: the type tag is checked first", with(q, 0, capsule.HeaderTypeTag, 1, uint64(2)), nc)
pp("invalid profile_id", with(q, 2, "Datekeys:quicknet:v1"), unknown)
pp("network changed: the chain hash no longer matches", with(q, 4, "quicknet2"), "ERR_PROFILE_MISMATCH")
pp("network changed with its chain hash", rehashed(4, "quicknet2"), ResultOK)
pp("empty public_key", with(q, 6, []byte{}), unknown)
pp("public_key of 1025 bytes", rehashed(6, fill(0xaa, 1025)), unknown)
pp("public_key that is not a group element", rehashed(6, fill(0x00, 96)), unknown)
pp("public_key the identity element", rehashed(6, append([]byte{0xc0}, fill(0x00, 95)...)), unknown)
pp("provider other than drand", rehashed(3, "drand2"), unknown)
pp("unknown scheme", rehashed(9, "bls-unknown"), unknown)
pp("scheme pedersen-bls-chained, not supported by tlock", rehashed(9, "pedersen-bls-chained"), unknown)
pp("genesis_time 0", rehashed(8, uint64(0)), unknown)
pp("genesis_time 253402300798, 9999-12-31T23:59:58Z", rehashed(8, uint64(profile.MaxUnixTime-1)), ResultOK)
pp("genesis_time 253402300799, 9999-12-31T23:59:59Z", rehashed(8, uint64(profile.MaxUnixTime)), unknown)
// PUBLIC_HEADER (spec §24): keys 0 to 4, optional 5 and 6.
id := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}
dk := datekey.DateKey{ProfileID: profile.QuicknetID, Round: 1000}
h := map[uint64]any{0: capsule.HeaderTypeTag, 1: uint64(capsule.HeaderVersion), 2: id, 3: dk.Compact(), 4: uint64(capsule.TimeOnly)}
dkJSON := func(s string) string { return datekey.Prefix + base64.RawURLEncoding.EncodeToString([]byte(s)) }
ph := func(name string, v any, want string) { add(SchemaHeader, SchemaHeader, name, v, want) }
ph("minimal header", h, ResultOK)
ph("time_and_key policy", with(h, 4, uint64(capsule.TimeAndKey)), ResultOK)
ph("both extension arrays", with(h, 5, []any{ext("org.example.a", 1)}, 6, []any{ext("org.example.b", 1, []byte{0})}), ResultOK)
ph("DateKey of a profile that is not pinned: the registry decides", with(h, 3, datekey.DateKey{ProfileID: "datekeys:evmnet:v1", Round: 1000}.Compact()), ResultOK)
ph("unknown key 7", with(h, 7, uint64(0)), nc)
ph("missing key 2, capsule_id", with(h, 2, nil), nc)
ph("missing key 4, access_policy", with(h, 4, nil), nc)
ph("keys 3 and 4 out of order", pairs(h, 0, 1, 2, 4, 3), nc)
ph("capsule_id as a text string", with(h, 2, "0123456789abcdef"), nc)
ph("DateKey as a byte string", with(h, 3, []byte(dk.Compact())), nc)
ph("capsule_id of 15 bytes", with(h, 2, id[:15]), nc)
ph("capsule_id of 17 bytes", with(h, 2, append(bytes.Clone(id), 0x10)), nc)
ph("access_policy 2", with(h, 4, uint64(2)), nc)
ph("access_policy 256", with(h, 4, uint64(256)), nc)
ph("access_policy not in shortest form", with(h, 4, cbortest.Raw{0x18, 0x00}), nc)
ph("null access_policy", with(h, 4, null), nc)
ph("schema version 2", with(h, 1, uint64(2)), unsup)
ph("schema version 2 and a trailing byte: the version is read first", appendRaw(with(h, 1, uint64(2)), 0x00), unsup)
ph("schema version 2^53", with(h, 1, uint64(1)<<53), nc)
ph("schema version 2^64-1", with(h, 1, uint64(math.MaxUint64)), nc)
ph("type tag of CONTROL_CBOR", with(h, 0, capsule.ControlTypeTag), nc)
ph("type tag of CONTROL_CBOR and schema version 2: the type tag is checked first", with(h, 0, capsule.ControlTypeTag, 1, uint64(2)), nc)
ph("empty critical_extensions", with(h, 5, []any{}), nc)
ph("same extension_id in both arrays", with(h, 5, []any{ext("org.example.a", 1)}, 6, []any{ext("org.example.a", 1)}), nc)
ph("trailing byte after the map", appendRaw(h, 0x00), nc)
ph("non-canonical dk1_ JSON", with(h, 3, dkJSON(`{"version":1, "network":"datekeys:quicknet:v1", "round":1000}`)), "ERR_DATEKEY_NON_CANONICAL")
ph("DateKey that is not dk1_", with(h, 3, "hello"), "ERR_DATEKEY_INVALID")
ph("non-canonical DateKey and undefined access_policy: the CDDL is checked first",
with(h, 3, dkJSON(`{"version":1, "network":"datekeys:quicknet:v1", "round":1000}`), 4, uint64(2)), nc)
// CONTROL_CBOR (spec §31): keys 0 to 3, optional 4 and 5.
c := map[uint64]any{0: capsule.ControlTypeTag, 1: uint64(capsule.ControlVersion), 2: fill(0x11, 32), 3: fill(0x22, 32)}
pc := func(name string, v any, want string) { add(SchemaControl, SchemaControl, name, v, want) }
pc("minimal control", c, ResultOK)
pc("both extension arrays", with(c, 4, []any{ext("org.example.a", 1)}, 5, []any{ext("org.example.b", 1, []byte("x"))}), ResultOK)
pc("unknown key 6", with(c, 6, uint64(0)), nc)
pc("missing key 3, payload_identity", with(c, 3, nil), nc)
pc("missing key 2, header_binding", with(c, 2, nil), nc)
pc("header_binding as a text string", with(c, 2, strings.Repeat("a", 32)), nc)
pc("header_binding of 31 bytes", with(c, 2, fill(0x11, 31)), nc)
pc("payload_identity of 33 bytes", with(c, 3, fill(0x22, 33)), nc)
pc("null payload_identity", with(c, 3, null), nc)
pc("schema version 2", with(c, 1, uint64(2)), unsup)
pc("type tag of PUBLIC_HEADER", with(c, 0, capsule.HeaderTypeTag), nc)
pc("empty noncritical_extensions", with(c, 5, []any{}), nc)
// .dkk body (spec §41): keys 0 to 5, optional 6, 7 and 8.
k := map[uint64]any{0: accesskey.TypeTag, 1: uint64(accesskey.SchemaVersion), 2: fill(0x33, 16), 3: fill(0x44, 16), 4: accesskey.TypeX25519, 5: fill(0x55, 32)}
pk := func(name string, v any, want string) { add(SchemaDKKBody, SchemaDKKBody, name, v, want) }
pk("minimal body", k, ResultOK)
pk("verification_metadata and both extension arrays",
with(k, 6, map[uint64]any{0: fill(0x66, 32)}, 7, []any{ext("org.example.a", 1)}, 8, []any{ext("org.example.b", 1, []byte("x"))}), ResultOK)
pk("unknown key 9", with(k, 9, uint64(0)), nc)
pk("missing key 5, access_material", with(k, 5, nil), nc)
pk("missing key 3, capsule_id", with(k, 3, nil), nc)
pk("access_type as a byte string", with(k, 4, []byte(accesskey.TypeX25519)), nc)
pk("credential_id of 15 bytes", with(k, 2, fill(0x33, 15)), nc)
pk("capsule_id of 17 bytes", with(k, 3, fill(0x44, 17)), nc)
pk("null access_material", with(k, 5, null), nc)
pk("access_type x448", with(k, 4, "x448"), "ERR_ACCESS_INVALID")
pk("access_material of 31 bytes", with(k, 5, fill(0x55, 31)), "ERR_ACCESS_INVALID")
pk("access_material of 33 bytes", with(k, 5, fill(0x55, 33)), "ERR_ACCESS_INVALID")
pk("schema version 2", with(k, 1, uint64(2)), unsup)
pk("type tag of CONTROL_CBOR", with(k, 0, capsule.ControlTypeTag), nc)
pk("empty critical_extensions", with(k, 7, []any{}), nc)
// verification_metadata (spec §43), key 6 of a .dkk body.
const vm = "verification_metadata"
pv := func(name string, v any, want string) { add(vm, SchemaDKKBody, name, with(k, 6, v), want) }
pv("capsule_digest", map[uint64]any{0: fill(0x66, 32)}, ResultOK)
pv("empty map", map[uint64]any{}, nc)
pv("unknown key 1 instead of key 0", map[uint64]any{1: fill(0x66, 32)}, nc)
pv("unknown key 1 after capsule_digest", map[uint64]any{0: fill(0x66, 32), 1: uint64(0)}, nc)
pv("capsule_digest of 31 bytes", map[uint64]any{0: fill(0x66, 31)}, nc)
pv("capsule_digest of 33 bytes", map[uint64]any{0: fill(0x66, 33)}, nc)
pv("capsule_digest as a text string", map[uint64]any{0: strings.Repeat("f", 32)}, nc)
pv("null capsule_digest", map[uint64]any{0: null}, nc)
pv("verification_metadata as an array", []any{fill(0x66, 32)}, nc)
pv("null verification_metadata", null, nc)
// Extensions (spec §31, §54), in the noncritical_extensions of a
// PUBLIC_HEADER.
const ex = "extension"
pe := func(name string, v []any, want string) { add(ex, SchemaHeader, name, with(h, 6, v), want) }
pe("one extension without data", []any{ext("org.example.a", 1)}, ResultOK)
pe("data of one byte", []any{ext("org.example.a", 1, []byte{0})}, ResultOK)
pe("data that is not CBOR", []any{ext("org.example.a", 1, []byte{0xff, 0xfe})}, ResultOK)
pe("data 40, an empty byte string", []any{ext("org.example.a", 1, []byte{})}, nc)
pe("data 5801xx, a length not in its shortest form", []any{ext("org.example.a", 1, cbortest.Raw{0x58, 0x01, 0x2a})}, nc)
pe("data as a text string", []any{ext("org.example.a", 1, "public label")}, nc)
pe("null data", []any{ext("org.example.a", 1, null)}, nc)
pe("data as an unsigned integer", []any{ext("org.example.a", 1, uint64(7))}, nc)
pe("data as a map", []any{ext("org.example.a", 1, map[uint64]any{0: uint64(7)})}, nc)
pe("data as an array", []any{ext("org.example.a", 1, []any{[]byte{0}})}, nc)
pe("data as a tagged byte string", []any{ext("org.example.a", 1, cbortest.Raw{0xc1, 0x41, 0x00})}, nc)
pe("data as an indefinite-length byte string", []any{ext("org.example.a", 1, cbortest.Raw{0x5f, 0x41, 0x00, 0xff})}, nc)
pe("64 extensions", exts(64), ResultOK)
pe("65 extensions", exts(65), nc)
pe("empty array", []any{}, nc)
pe("extension_id starting with a BOM", []any{ext("\ufefforg.example.a", 1)}, ResultOK)
pe("U+FF61 before U+10000: UTF-8 byte order", []any{ext("\uff61", 1), ext("\U00010000", 1)}, ResultOK)
pe("U+10000 before U+FF61: UTF-16 order, not UTF-8 byte order", []any{ext("\U00010000", 1), ext("\uff61", 1)}, nc)
pe("extensions out of order", []any{ext("org.example.b", 1), ext("org.example.a", 1)}, nc)
pe("extension_id repeated", []any{ext("org.example.a", 1), ext("org.example.a", 2)}, nc)
pe("extension_version 2^32-1", []any{ext("org.example.a", 1<<32-1)}, ResultOK)
pe("extension_version 2^32", []any{ext("org.example.a", 1<<32)}, nc)
pe("extension_version 2^53", []any{ext("org.example.a", 1<<53)}, nc)
pe("extension_version 0", []any{ext("org.example.a", 0)}, ResultOK)
pe("unknown key 3", []any{map[uint64]any{0: "org.example.a", 1: uint64(1), 3: []byte{0}}}, nc)
pe("missing key 1, extension_version", []any{map[uint64]any{0: "org.example.a"}}, nc)
pe("missing key 0, extension_id", []any{map[uint64]any{1: uint64(1)}}, nc)
pe("keys 0 and 1 out of order", []any{cbortest.Pairs{uint64(1), uint64(1), uint64(0), "org.example.a"}}, nc)
pe("extension_id as a byte string", []any{map[uint64]any{0: []byte("org.example.a"), 1: uint64(1)}}, nc)
pe("empty extension_id", []any{ext("", 1)}, nc)
pe("extension_id of 256 bytes, the implementation limit", []any{ext(strings.Repeat("a", 256), 1)}, ResultOK)
pe("extension_id of 257 bytes, above the implementation limit", []any{ext(strings.Repeat("a", 257), 1)}, nc)
pe("extension_id that is not valid UTF-8", []any{map[uint64]any{0: "org.example.\xff", 1: uint64(1)}}, nc)
pe("extension that is not a map", []any{uint64(1)}, nc)
return out, nil
}

@ -0,0 +1,670 @@
package testkit
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"strconv"
"strings"
"g.activething.com/go/DateKeys/capsule"
"g.activething.com/go/DateKeys/datekey"
"g.activething.com/go/DateKeys/internal/cbortest"
"g.activething.com/go/DateKeys/profile"
)
// DifferentialSeed seeds the generator of the differential corpus.
const DifferentialSeed = 20260925
// DifferentialFile is testdata/vectors/inspect_differential.json.
type DifferentialFile struct {
Spec string `json:"spec"`
Description string `json:"description"`
Format string `json:"format"`
Seed uint64 `json:"seed"`
Bases []DifferentialBase `json:"bases"`
Mutations []DifferentialCase `json:"mutations"`
}
// DifferentialBase is an official .dkc fixture the mutations edit.
type DifferentialBase struct {
File string `json:"file"`
SHA256 string `json:"sha256"`
}
// DifferentialCase is one mutation and the verdict of capsule.Inspect.
type DifferentialCase struct {
Base int `json:"base"` // index into Bases
Kind string `json:"kind"`
Edits []Edit `json:"edits"`
// Result is ResultOK or the normative code of the failure.
Result string `json:"result"`
// Step is the step of spec §63 that failed; absent when Result is ok.
Step int `json:"step,omitempty"`
}
const differentialFormat = "Each mutation is bases[base].file (in testdata/fixtures) with its edits applied. " +
"An edit is [at, delete, insert]: the delete bytes at offset at of the base are replaced by the bytes of the hex string insert. " +
"The edits of one mutation refer to offsets of the unmodified base, are sorted by offset and do not overlap. " +
"result is the verdict of steps 1 to 8 of spec §63 (capsule.Inspect, the Quicknet profile pinned, no extension known, no network, no secret): " +
"ok, or the normative error code, with step the step that failed. kind names the generator of the mutation and is informative."
// Kinds of differential mutations and how many each base gets.
var differentialKinds = []struct {
kind string
count int
}{
{"flip", 60}, // one bit flipped
{"byte", 30}, // one byte replaced
{"truncate", 25}, // the file cut short
{"insert", 30}, // one to four bytes inserted
{"delete", 30}, // one to four bytes deleted
{"length", 25}, // PUBLIC_HEADER_LEN or SEALED_CONTROL_LEN edited
{"header", 80}, // PUBLIC_HEADER re-encoded with a CBOR-aware change
{"datekey", 25}, // PUBLIC_HEADER re-encoded with another DateKey string
{"age", 60}, // an age header of SEALED_CONTROL or PAYLOAD_AGE edited
}
// InspectVerdict runs capsule.Inspect on dkc with the default registry and
// no extension known, and returns ResultOK or the error code, and the step
// that failed.
func InspectVerdict(dkc []byte) (string, int) {
in, err := capsule.Inspect(bytes.NewReader(dkc), capsule.InspectOptions{Registry: Registry()})
if err == nil {
return ResultOK, 0
}
step := 0
if n := len(in.Checks); n > 0 && !in.Checks[n-1].OK {
step = in.Checks[n-1].Step
}
return Result(err), step
}
// splitmix64 is the generator of the corpus: fixed, simple and independent
// of the Go release.
type splitmix64 struct{ s uint64 }
func (r *splitmix64) next() uint64 {
r.s += 0x9e3779b97f4a7c15
z := r.s
z = (z ^ (z >> 30)) * 0xbf58476d1ce4e5b9
z = (z ^ (z >> 27)) * 0x94d049bb133111eb
return z ^ (z >> 31)
}
// intn returns a number in [0, n).
func (r *splitmix64) intn(n int) int { return int(r.next() % uint64(n)) }
func (r *splitmix64) byte() byte { return byte(r.next()) }
func (r *splitmix64) bytes(n int) []byte {
b := make([]byte, n)
for i := range b {
b[i] = r.byte()
}
return b
}
func pick[T any](r *splitmix64, s []T) T { return s[r.intn(len(s))] }
// diffBase is a base fixture and the offsets of its sections.
type diffBase struct {
file string
dkc []byte
parts Parts
header map[uint64]any
sealedAt, payloadAt int // offsets of SEALED_CONTROL and PAYLOAD_AGE
sealedHdr, payHdr int // lengths of their age headers
interesting int // the end of the bytes Inspect reads, and a little more
regions [][2]int
}
func newDiffBase(dir, name string) (*diffBase, error) {
f, err := LoadFixture(dir, name)
if err != nil {
return nil, err
}
b := &diffBase{file: f.File, dkc: f.DKC, parts: f.Parts}
if b.header, err = cbortest.UnmarshalMap(f.Parts.Header); err != nil {
return nil, err
}
b.sealedAt = capsule.PreludeSize + len(f.Parts.Header)
b.payloadAt = b.sealedAt + len(f.Parts.Sealed)
if b.sealedHdr, err = HeaderLen(f.Parts.Sealed); err != nil {
return nil, err
}
if b.payHdr, err = HeaderLen(f.Parts.Payload); err != nil {
return nil, err
}
b.interesting = min(len(b.dkc), b.payloadAt+b.payHdr+64)
b.regions = [][2]int{
{0, capsule.PreludeSize},
{capsule.PreludeSize, b.sealedAt},
{b.sealedAt, b.sealedAt + b.sealedHdr},
{b.sealedAt + b.sealedHdr, b.payloadAt},
{b.payloadAt, b.payloadAt + b.payHdr},
{b.payloadAt + b.payHdr, len(b.dkc)},
}
return b, nil
}
// position picks an offset, weighted towards the bytes steps 1 to 8 read:
// the prelude, the header, and the age headers of the two age files.
func (b *diffBase) position(r *splitmix64) int {
weights := []int{15, 30, 25, 5, 20, 5}
n := r.intn(100)
for i, w := range weights {
if n < w {
reg := b.regions[i]
if reg[1] > reg[0] {
return reg[0] + r.intn(reg[1]-reg[0])
}
break
}
n -= w
}
return r.intn(len(b.dkc))
}
// lengths returns an edit of the prelude that sets the section lengths.
func (b *diffBase) lengths(headerLen, sealedLen int) Edit {
var v [8]byte
binary.BigEndian.PutUint32(v[0:4], uint32(headerLen))
binary.BigEndian.PutUint32(v[4:8], uint32(sealedLen))
return Edit{At: 8, Delete: 8, Insert: v[:]}
}
// InspectDifferential computes testdata/vectors/inspect_differential.json:
// deterministic mutations of the official .dkc fixtures of dir, each with
// the verdict of capsule.Inspect.
func InspectDifferential(dir string, names []string) (DifferentialFile, error) {
f := DifferentialFile{
Spec: SpecVersion,
Description: "Differential corpus of the pre-unlock checks (spec §63 steps 1 to 8): deterministic mutations of the official .dkc fixtures " +
"with the verdict of the reference implementation. See testdata/README.md.",
Format: differentialFormat,
Seed: DifferentialSeed,
}
r := &splitmix64{s: DifferentialSeed}
for bi, name := range names {
b, err := newDiffBase(dir, name)
if err != nil {
return f, err
}
sum := sha256.Sum256(b.dkc)
f.Bases = append(f.Bases, DifferentialBase{File: b.file, SHA256: hex.EncodeToString(sum[:])})
seen := map[[32]byte]bool{sum: true}
for _, k := range differentialKinds {
made := 0
for attempt := 0; made < k.count; attempt++ {
if attempt > 50*k.count {
return f, fmt.Errorf("differential %s %s: only %d distinct mutations", name, k.kind, made)
}
edits, err := b.mutate(r, k.kind)
if err != nil {
return f, fmt.Errorf("differential %s %s: %w", name, k.kind, err)
}
if edits, err = normalizeEdits(b.dkc, edits); err != nil {
return f, err
}
out, err := ApplyEdits(b.dkc, edits)
if err != nil {
return f, err
}
h := sha256.Sum256(out)
if len(edits) == 0 || seen[h] {
continue
}
seen[h] = true
result, step := InspectVerdict(out)
f.Mutations = append(f.Mutations, DifferentialCase{Base: bi, Kind: k.kind, Edits: edits, Result: result, Step: step})
made++
}
}
}
return f, nil
}
func (b *diffBase) mutate(r *splitmix64, kind string) ([]Edit, error) {
switch kind {
case "flip":
p := b.position(r)
return []Edit{{At: p, Delete: 1, Insert: []byte{b.dkc[p] ^ 1<<r.intn(8)}}}, nil
case "byte":
p := b.position(r)
v := pick(r, []byte{0x00, 0xff, 0x7f, 0x80, 0x18, 0x1b, 0x40, 0x60, 0x80, 0xa0, 0xf6, '\n', ' ', '-', r.byte()})
return []Edit{{At: p, Delete: 1, Insert: []byte{v}}}, nil
case "truncate":
p := r.intn(b.interesting)
return []Edit{{At: p, Delete: len(b.dkc) - p}}, nil
case "insert":
p := b.position(r)
ins := r.bytes(1 + r.intn(4))
if r.intn(3) == 0 {
ins = []byte{pick(r, []byte{0x00, 0xff, 0xf6, 0x20, '\n', ' ', '\r'})}
}
return []Edit{{At: p, Insert: ins}}, nil
case "delete":
p := b.position(r)
return []Edit{{At: p, Delete: min(1+r.intn(4), len(b.dkc)-p)}}, nil
case "length":
return b.lengthEdit(r), nil
case "header":
return b.headerEdit(r)
case "datekey":
m := with(b.header)
m[3] = r.dateKey(b.dateKey())
h, err := cbortest.Marshal(m)
if err != nil {
return nil, err
}
return b.replaceHeader(h, false), nil
case "age":
return b.ageEdit(r)
}
return nil, fmt.Errorf("unknown kind %q", kind)
}
func (b *diffBase) lengthEdit(r *splitmix64) []Edit {
hl, sl := len(b.parts.Header), len(b.parts.Sealed)
vary := func(v int) int {
switch r.intn(8) {
case 0:
return v + 1
case 1:
return v - 1
case 2:
return v + 1 + r.intn(64)
case 3:
return max(0, v-1-r.intn(64))
case 4:
return pick(r, []int{0, 1, 16, 255, 256, 65535, 65536})
case 5:
return pick(r, []int{capsule.MaxPublicHeaderLen, capsule.MaxPublicHeaderLen + 1, capsule.MaxSealedControlLen, capsule.MaxSealedControlLen + 1, 1<<32 - 1})
case 6:
return int(r.next() >> 32)
}
return v ^ 1<<(8+r.intn(24))
}
switch r.intn(4) {
case 0:
hl = vary(hl)
case 1:
sl = vary(sl)
case 2:
hl, sl = sl, hl
default:
hl, sl = vary(hl), vary(sl)
}
return []Edit{b.lengths(hl, sl)}
}
// replaceHeader returns the edits that replace PUBLIC_HEADER with h and,
// unless keepPrelude, set PUBLIC_HEADER_LEN to its length.
func (b *diffBase) replaceHeader(h []byte, keepPrelude bool) []Edit {
edits := []Edit{{At: capsule.PreludeSize, Delete: len(b.parts.Header), Insert: h}}
if !keepPrelude {
edits = append([]Edit{b.lengths(len(h), len(b.parts.Sealed))}, edits...)
}
return edits
}
// dateKey returns the DateKey of the base header.
func (b *diffBase) dateKey() datekey.DateKey {
if s, ok := b.header[3].(string); ok {
if d, err := datekey.Parse(s); err == nil {
return d
}
}
return datekey.DateKey{ProfileID: profile.QuicknetID, Round: 1000}
}
func (b *diffBase) headerEdit(r *splitmix64) ([]Edit, error) {
m := with(b.header)
dk := b.dateKey()
var v any = m
switch r.intn(12) {
case 0: // remove a key
keys := sortedKeys(m)
delete(m, pick(r, keys))
case 1: // add a key
m[pick(r, []uint64{5, 6, 7, 8, 23, 24, 255, 65535, 1 << 32})] = r.value()
case 2: // change the type of a value
m[pick(r, sortedKeys(m))] = r.value()
case 3:
m[0] = pick(r, []string{"", "datekeyca", "datekeycapx", "DATEKEYCAP", capsule.ControlTypeTag, "datekeys-access-key", strings.Repeat("a", 65)})
case 4:
m[1] = pick(r, []any{uint64(0), uint64(2), uint64(255), uint64(256), uint64(1 << 32), uint64(1<<53 - 1), uint64(1 << 53), uint64(1<<64 - 1), "1", -1})
case 5:
m[2] = r.bytes(pick(r, []int{0, 1, 15, 17, 32}))
case 6:
m[3] = r.dateKey(dk)
case 7:
m[4] = pick(r, []any{uint64(2), uint64(3), uint64(23), uint64(24), uint64(255), uint64(256), uint64(257), uint64(65536), uint64(1 << 32), uint64(1<<53 - 1), uint64(1 << 53)})
case 8: // an extension array
key := uint64(5 + r.intn(2))
arr := r.extensions()
if arr == nil {
// The same extension in both arrays.
m[5] = []any{ext("org.example.a", 1)}
m[6] = []any{ext("org.example.a", 1)}
} else {
m[key] = arr
}
case 9: // a head not in its shortest form
enc, err := cbortest.Marshal(m)
if err != nil {
return nil, err
}
switch r.intn(4) {
case 0:
v = cbortest.Raw(append([]byte{0xb8, enc[0] & 0x1f}, enc[1:]...))
case 1:
m[4] = cbortest.Raw(pick(r, [][]byte{{0x18, 0x00}, {0x18, 0x01}, {0x19, 0x00, 0x00}, {0x1b, 0, 0, 0, 0, 0, 0, 0, 0}}))
case 2:
m[1] = cbortest.Raw{0x18, 0x01}
default:
if id, ok := m[2].([]byte); ok {
m[2] = cbortest.Raw(append([]byte{0x58, byte(len(id))}, id...))
}
}
case 10: // keys out of order or repeated
keys := sortedKeys(m)
var p cbortest.Pairs
for _, k := range keys {
p = append(p, k, m[k])
}
i := 2 * r.intn(len(keys))
j := 2 * r.intn(len(keys))
if r.intn(2) == 0 {
p[i], p[j] = p[j], p[i]
p[i+1], p[j+1] = p[j+1], p[i+1]
} else {
p = append(p, p[i], p[i+1])
}
v = p
default: // the whole item wrapped or framed differently
enc, err := cbortest.Marshal(m)
if err != nil {
return nil, err
}
switch r.intn(6) {
case 0:
v = cbortest.Raw(append([]byte{0xc1}, enc...))
case 1:
v = []any{cbortest.Raw(enc)}
case 2:
v = cbortest.Raw(append(append([]byte{0xbf}, enc[1:]...), 0xff))
case 3:
v = cbortest.Raw(append(enc, 0x00))
case 4:
v = cbortest.Raw(append(enc, enc...))
default:
v = cbortest.Raw(enc[:len(enc)-1])
}
}
h, err := cbortest.Marshal(v)
if err != nil {
return nil, err
}
return b.replaceHeader(h, r.intn(10) == 0), nil
}
func sortedKeys(m map[uint64]any) []uint64 {
var keys []uint64
for k := range m {
keys = append(keys, k)
}
for i := 1; i < len(keys); i++ {
for j := i; j > 0 && keys[j] < keys[j-1]; j-- {
keys[j], keys[j-1] = keys[j-1], keys[j]
}
}
return keys
}
// value returns a value of a random type, most of them outside the profile.
func (r *splitmix64) value() any {
return pick(r, []any{
uint64(0), uint64(1), uint64(1 << 53), "x", []byte{0}, []byte{}, []any{}, map[uint64]any{},
cbortest.Raw{0xf6}, cbortest.Raw{0xf5}, cbortest.Raw{0x20}, cbortest.Raw{0xc1, 0x00},
cbortest.Raw{0xf9, 0x3c, 0x00}, cbortest.Raw{0x5f, 0x40, 0xff},
})
}
// dateKey returns a DateKey string derived from dk, most of them invalid.
func (r *splitmix64) dateKey(dk datekey.DateKey) any {
s := dk.Compact()
enc := func(j string) string { return datekey.Prefix + b64(j) }
switch r.intn(11) {
case 0: // one character changed
i := len(datekey.Prefix) + r.intn(len(s)-len(datekey.Prefix))
c := pick(r, []byte("AZaz09-_=+/ ."))
return s[:i] + string(c) + s[i+1:]
case 1:
return s[:len(datekey.Prefix)+r.intn(len(s)-len(datekey.Prefix))]
case 2:
return s + "="
case 3:
return "DK1_" + s[len(datekey.Prefix):]
case 4: // canonical, but of a profile that is not pinned
return datekey.DateKey{ProfileID: pick(r, []string{"datekeys:evmnet:v1", "datekeys:quicknet:v2", "drand:quicknet"}), Round: dk.Round}.Compact()
case 5: // canonical, but of a round the tlock stanza does not name or the profile never reaches
return datekey.DateKey{ProfileID: dk.ProfileID, Round: pick(r, []uint64{1, dk.Round - 1, dk.Round + 1, 66884212,
profile.Quicknet().MaxRound(), profile.Quicknet().MaxRound() + 1, datekey.MaxRound})}.Compact()
case 6:
return enc(fmt.Sprintf(`{"version":1,"network":"%s","round":%s}`, dk.ProfileID,
pick(r, []string{"0", "-1", "1.0", "1e3", "9007199254740992", strconv.FormatUint(dk.Round+1, 10), `"1000"`})))
case 7:
return enc(fmt.Sprintf(`{"version":1, "network":"%s", "round":%d}`, dk.ProfileID, dk.Round))
case 8:
return datekey.Prefix
case 9:
return cbortest.Raw(append([]byte{0x62}, 0xc0, 0x80))
}
return s + strings.Repeat("A", 200+r.intn(100))
}
func (r *splitmix64) extensions() []any {
many := func(n int) []any {
out := make([]any, n)
for i := range out {
out[i] = ext(fmt.Sprintf("org.example.%03d", i), 1)
}
return out
}
switch r.intn(16) {
case 0:
return []any{}
case 1:
return []any{ext("org.example.a", 1)}
case 2:
return []any{ext("org.example.a", 1, r.bytes(1+r.intn(8)))}
case 3:
return []any{ext("org.example.a", 1, []byte{})}
case 4:
return []any{ext("org.example.a", 1, pick(r, []any{"text", uint64(7), cbortest.Raw{0xf6}, map[uint64]any{}, []any{}, cbortest.Raw{0x58, 0x01, 0x2a}, cbortest.Raw{0xc1, 0x41, 0x00}}))}
case 5:
return []any{ext("org.example.a", 1<<32)}
case 6:
return many(64)
case 7:
return many(65)
case 8:
return []any{ext("org.example.b", 1), ext("org.example.a", 1)}
case 9:
return []any{ext("org.example.a", 1), ext("org.example.a", 1)}
case 10:
return []any{map[uint64]any{0: "org.example.a", 1: uint64(1), 3: uint64(0)}}
case 11:
return []any{map[uint64]any{0: "org.example.a"}}
case 12:
return []any{ext("\ufefforg.example.a", 1)}
case 13:
return []any{ext("\U00010000", 1), ext("\uff61", 1)}
case 14:
return []any{ext("\uff61", 1), ext("\U00010000", 1)}
}
return nil
}
// ageEdit edits the age header of SEALED_CONTROL (and then, most of the
// time, SEALED_CONTROL_LEN) or of PAYLOAD_AGE.
func (b *diffBase) ageEdit(r *splitmix64) ([]Edit, error) {
sealed := r.intn(5) < 3
at, n := b.payloadAt, b.payHdr
if sealed {
at, n = b.sealedAt, b.sealedHdr
}
hdr := string(b.dkc[at : at+n])
lines := strings.SplitAfter(hdr, "\n")
lines = lines[:len(lines)-1] // SplitAfter leaves an empty string after the final newline
// lines: intro, stanza lines (argument line, body lines), MAC line.
stanzaAt := 1
mac := len(lines) - 1
switch r.intn(11) {
case 0: // intro line
lines[0] = pick(r, []string{"age-encryption.org/v2\n", "age-encryption.org/V1\n", "age-encryption.org/v1 \n", "age-encryption.org/\n", "\n"})
case 1: // stanza type
f := strings.Fields(lines[stanzaAt])
f[1] = pick(r, []string{"tlock", "TLOCK", "X25519", "x25519", "scrypt", "tlock2", "t"})
lines[stanzaAt] = strings.Join(f, " ") + "\n"
case 2: // one argument
f := strings.Fields(lines[stanzaAt])
if len(f) > 2 {
i := 2 + r.intn(len(f)-2)
f[i] = r.arg(f[i])
lines[stanzaAt] = strings.Join(f, " ") + "\n"
}
case 3: // one more argument, or one less
f := strings.Fields(lines[stanzaAt])
if r.intn(2) == 0 {
f = append(f, pick(r, []string{"extra", "1000", "AAAA"}))
} else if len(f) > 2 {
f = f[:len(f)-1]
}
lines[stanzaAt] = strings.Join(f, " ") + "\n"
case 4: // the stanza twice
stanza := lines[stanzaAt:mac]
lines = append(append(append([]string{}, lines[:mac]...), stanza...), lines[mac])
case 5: // an extra stanza
extra := pick(r, []string{
"-> X25519 " + strings.Repeat("A", 43) + "\n" + strings.Repeat("B", 43) + "\n",
"-> scrypt c2FsdHNhbHRzYWx0c2FsdA 18\n" + strings.Repeat("C", 43) + "\n",
"-> tlock 1000 " + profile.Quicknet().ChainHashHex() + "\n" + strings.Repeat("D", 64) + "\n\n",
"-> grease-x !@#\n\n",
})
lines = append(append(append([]string{}, lines[:mac]...), extra), lines[mac])
case 6: // no stanza
lines = []string{lines[0], lines[mac]}
case 7: // a body line
if mac-stanzaAt > 1 {
i := stanzaAt + 1 + r.intn(mac-stanzaAt-1)
l := strings.TrimSuffix(lines[i], "\n")
switch r.intn(4) {
case 0:
if len(l) > 0 {
j := r.intn(len(l))
l = l[:j] + string(pick(r, []byte("A/+=_-"))) + l[j+1:]
}
case 1:
l += "A"
case 2:
l += " "
default:
if len(l) > 0 {
l = l[:len(l)-1]
}
}
lines[i] = l + "\n"
}
case 8: // the MAC line
l := lines[mac]
switch r.intn(4) {
case 0:
l = "--" + l[3:]
case 1:
j := 4 + r.intn(len(l)-5)
l = l[:j] + string(pick(r, []byte("AB/+"))) + l[j+1:]
case 2:
l = "---\n"
default:
l = strings.TrimSuffix(l, "\n") + " \n"
}
lines[mac] = l
case 9: // a line ending
i := r.intn(len(lines))
lines[i] = strings.TrimSuffix(lines[i], "\n") + "\r\n"
default: // an empty line
i := 1 + r.intn(len(lines)-1)
lines = append(append(append([]string{}, lines[:i]...), "\n"), lines[i:]...)
}
edited := []byte(strings.Join(lines, ""))
edits := []Edit{{At: at, Delete: n, Insert: edited}}
if sealed && r.intn(10) != 0 {
edits = append([]Edit{b.lengths(len(b.parts.Header), len(b.parts.Sealed)-n+len(edited))}, edits...)
}
return edits, nil
}
// arg returns a variant of a stanza argument.
func (r *splitmix64) arg(a string) string {
if _, err := strconv.ParseUint(a, 10, 64); err == nil {
return pick(r, []string{"999", "1001", "0" + a, a + "0", "0", "-1", "18446744073709551616", a + "a", "2000"})
}
if len(a) == 0 {
return "A"
}
i := r.intn(len(a))
switch r.intn(4) {
case 0:
return a[:i] + string(pick(r, []byte("0aAf/+"))) + a[i+1:]
case 1:
return strings.ToUpper(a)
case 2:
return a[:len(a)-1]
}
return a + "A"
}
// MarshalDifferential writes f with one mutation per line.
func MarshalDifferential(f DifferentialFile) ([]byte, error) {
head := f
head.Mutations = nil
b, err := json.MarshalIndent(head, "", " ")
if err != nil {
return nil, err
}
// Replace the closing "\n}" and the null mutations.
b = bytes.TrimSuffix(b, []byte("\n}"))
b = bytes.TrimSuffix(b, []byte(",\n \"mutations\": null"))
var out bytes.Buffer
out.Write(b)
out.WriteString(",\n \"mutations\": [\n")
for i, m := range f.Mutations {
line, err := json.Marshal(m)
if err != nil {
return nil, err
}
out.WriteString(" ")
out.Write(line)
if i < len(f.Mutations)-1 {
out.WriteByte(',')
}
out.WriteByte('\n')
}
out.WriteString(" ]\n}\n")
return out.Bytes(), nil
}
// WriteDifferential writes f to path with MarshalDifferential.
func WriteDifferential(path string, f DifferentialFile) error {
b, err := MarshalDifferential(f)
if err != nil {
return err
}
return writeFile(path, b)
}

@ -0,0 +1,103 @@
package testkit
import (
"bytes"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"slices"
)
// Edit replaces Delete bytes at offset At of a base file with Insert. In JSON
// it is the array [at, delete, "insert in lowercase hex"].
//
// The edits of one file refer to offsets of the unmodified base, are sorted by
// At and do not overlap: the result is the base with every range
// [At, At+Delete) replaced by its Insert.
type Edit struct {
At, Delete int
Insert []byte
}
// MarshalJSON writes e as [at, delete, "hex"].
func (e Edit) MarshalJSON() ([]byte, error) {
return json.Marshal([]any{e.At, e.Delete, hex.EncodeToString(e.Insert)})
}
// UnmarshalJSON reads [at, delete, "hex"].
func (e *Edit) UnmarshalJSON(b []byte) error {
var raw []json.RawMessage
if err := json.Unmarshal(b, &raw); err != nil {
return err
}
if len(raw) != 3 {
return fmt.Errorf("testkit: edit of %d elements, want 3", len(raw))
}
var s string
if err := errors.Join(json.Unmarshal(raw[0], &e.At), json.Unmarshal(raw[1], &e.Delete), json.Unmarshal(raw[2], &s)); err != nil {
return err
}
var err error
e.Insert, err = hex.DecodeString(s)
return err
}
// ApplyEdits returns a new file: base with edits applied.
func ApplyEdits(base []byte, edits []Edit) ([]byte, error) {
var out []byte
next := 0
for _, e := range edits {
if e.At < next || e.Delete < 0 || e.At+e.Delete > len(base) {
return nil, fmt.Errorf("testkit: edit [%d, %d] outside the base or out of order", e.At, e.Delete)
}
out = append(out, base[next:e.At]...)
out = append(out, e.Insert...)
next = e.At + e.Delete
}
return append(out, base[next:]...), nil
}
// Splice returns the edits that turn base into out: one edit covering the
// bytes between their common prefix and their common suffix, or none when
// they are equal.
func Splice(base, out []byte) []Edit {
return trimEdits(base, []Edit{{At: 0, Delete: len(base), Insert: out}})
}
// trimEdits drops from each edit the leading and trailing bytes it leaves
// unchanged, and then the edits that change nothing.
func trimEdits(base []byte, edits []Edit) []Edit {
var out []Edit
for _, e := range edits {
old, ins := base[e.At:e.At+e.Delete], e.Insert
p := 0
for p < len(old) && p < len(ins) && old[p] == ins[p] {
p++
}
old, ins = old[p:], ins[p:]
s := 0
for s < len(old) && s < len(ins) && old[len(old)-1-s] == ins[len(ins)-1-s] {
s++
}
old, ins = old[:len(old)-s], ins[:len(ins)-s]
if len(old) == 0 && len(ins) == 0 {
continue
}
out = append(out, Edit{At: e.At + p, Delete: len(old), Insert: bytes.Clone(ins)})
}
return out
}
// normalizeEdits sorts edits by offset and trims them. Overlapping edits are
// an error of the caller.
func normalizeEdits(base []byte, edits []Edit) ([]Edit, error) {
edits = slices.Clone(edits)
slices.SortStableFunc(edits, func(a, b Edit) int { return a.At - b.At })
for i := 1; i < len(edits); i++ {
if edits[i].At < edits[i-1].At+edits[i-1].Delete {
return nil, fmt.Errorf("testkit: overlapping edits at %d and %d", edits[i-1].At, edits[i].At)
}
}
return trimEdits(base, edits), nil
}

@ -4,6 +4,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"regexp"
)
// FixtureStanza is the visible part of an age stanza in a fixture.
@ -95,8 +96,25 @@ func WriteJSON(path string, v any) error {
if err != nil {
return err
}
return writeFile(path, append(b, '\n'))
}
// editPattern is an Edit as json.MarshalIndent spreads it over five lines.
var editPattern = regexp.MustCompile(`\[\n\s*(\d+),\n\s*(\d+),\n\s*("[0-9a-f]*")\n\s*\]`)
// WriteJSONEdits is WriteJSON with every Edit, [at, delete, "hex"], on one
// line.
func WriteJSONEdits(path string, v any) error {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
return writeFile(path, append(editPattern.ReplaceAll(b, []byte("[$1, $2, $3]")), '\n'))
}
func writeFile(path string, b []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, append(b, '\n'), 0o644)
return os.WriteFile(path, b, 0o644)
}

@ -12,6 +12,14 @@
// 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.
//
// Derived from the fixtures and always regenerated, like the vectors: the
// frozen "datekeys inspect -json" output of each .dkc (<name>.inspect.json),
// the exported mutation corpus (vectors/mutations.json) and the differential
// corpus of the pre-unlock checks (vectors/inspect_differential.json). The
// mutations whose capsule is built with age randomness keep the bytes
// recorded in the committed mutations.json; -force, or -only mutations,
// builds them afresh.
//
// go run ./internal/testkit/genfixtures -out testdata
// go run ./internal/testkit/genfixtures -out testdata -only time_only_extensions
package main
@ -21,9 +29,11 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"flag"
"fmt"
"io"
"io/fs"
"log"
"os"
"path/filepath"
@ -37,6 +47,7 @@ import (
"g.activething.com/go/DateKeys/capsule"
"g.activething.com/go/DateKeys/datekey"
"g.activething.com/go/DateKeys/extension"
"g.activething.com/go/DateKeys/internal/inspectview"
"g.activething.com/go/DateKeys/internal/testkit"
"g.activething.com/go/DateKeys/profile"
)
@ -44,7 +55,7 @@ import (
func main() {
out := flag.String("out", "testdata", "output directory")
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")
only := flag.String("only", "", "comma-separated fixture names to regenerate, overwriting them, and \"mutations\" to rebuild the randomly built mutations; every other fixture is left untouched")
flag.Parse()
sel, err := selection(*force, *only)
if err != nil {
@ -56,6 +67,9 @@ func main() {
if err := fixtures(filepath.Join(*out, "fixtures"), sel); err != nil {
log.Fatal(err)
}
if err := derived(*out, sel); err != nil {
log.Fatal(err)
}
}
// selector decides which fixtures are (re)generated.
@ -69,7 +83,7 @@ func selection(force bool, only string) (selector, error) {
if only == "" {
return sel, nil
}
known := map[string]bool{extDKK: true}
known := map[string]bool{extDKK: true, mutationsName: true}
for _, s := range specs() {
known[s.name] = true
}
@ -103,7 +117,72 @@ func vectors(dir string) error {
if err := testkit.WriteJSON(filepath.Join(dir, "quicknet_rounds.json"), testkit.RoundVectors()); err != nil {
return err
}
return testkit.WriteJSON(filepath.Join(dir, "dk1.json"), testkit.DK1Vectors())
if err := testkit.WriteJSON(filepath.Join(dir, "dk1.json"), testkit.DK1Vectors()); err != nil {
return err
}
cv, err := testkit.CBORVectors()
if err != nil {
return err
}
return testkit.WriteJSON(filepath.Join(dir, "cbor.json"), cv)
}
// mutationsName is the -only name that rebuilds the capsules of the
// mutations built with age randomness.
const mutationsName = "mutations"
// derived writes what is computed from the fixtures: the inspect outputs,
// the mutation corpus and the differential corpus.
func derived(out string, sel selector) error {
fixtureDir := filepath.Join(out, "fixtures")
var names []string
for _, s := range specs() {
names = append(names, s.name)
if err := inspectOutput(fixtureDir, s.name); err != nil {
return fmt.Errorf("%s: %w", s.name, err)
}
}
path := filepath.Join(out, "vectors", "mutations.json")
var frozen *testkit.MutationFile
if !sel.force && !sel.only[mutationsName] {
var f testkit.MutationFile
switch err := testkit.ReadJSON(path, &f); {
case err == nil:
frozen = &f
case !errors.Is(err, fs.ErrNotExist):
return err
}
}
m, err := testkit.MutationCorpus(fixtureDir, frozen)
if err != nil {
return err
}
if err := testkit.WriteJSONEdits(path, m); err != nil {
return err
}
d, err := testkit.InspectDifferential(fixtureDir, names)
if err != nil {
return err
}
return testkit.WriteDifferential(filepath.Join(out, "vectors", "inspect_differential.json"), d)
}
// inspectOutput writes <name>.inspect.json: the output of
// "datekeys inspect -json -in <name>.dkc" run in the fixture directory.
func inspectOutput(dir, name string) error {
file := name + ".dkc"
dkc, err := os.ReadFile(filepath.Join(dir, file))
if err != nil {
return err
}
in, err := capsule.Inspect(bytes.NewReader(dkc), capsule.InspectOptions{Registry: testkit.Registry()})
var b bytes.Buffer
if err := inspectview.New(file, in, err).WriteJSON(&b); err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, name+".inspect.json"), b.Bytes(), 0o644)
}
type spec struct {

@ -0,0 +1,919 @@
package testkit
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"filippo.io/age"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/accesskey"
"g.activething.com/go/DateKeys/agewrap"
"g.activething.com/go/DateKeys/capsule"
"g.activething.com/go/DateKeys/datekey"
"g.activething.com/go/DateKeys/extension"
"g.activething.com/go/DateKeys/internal/cbortest"
"g.activething.com/go/DateKeys/profile"
"g.activething.com/go/DateKeys/provider"
)
// Mutation is one entry of the mutation corpus (spec §64): a capsule, and
// the options to open it, that must fail with one exact normative error at
// one step of spec §63.
type Mutation struct {
Name string
// Spec is true for the twenty-three mutations listed in spec §64.
Spec bool
Want *datekeys.Error
Step int
// Network reports whether the failure may happen after a release was
// requested. Failures of steps 1 to 8 and of the access pre-checks must
// not cause any request (spec §27, §63).
Network bool
// Random reports that Make builds the capsule with fresh age
// randomness, so that its bytes differ on every call. The exported
// corpus freezes the bytes of the first build (MutationCorpus).
Random bool
Make func(e *MutationEnv) (*MutationInput, error)
}
// MutationInput is a mutated capsule and what the reader is given to open
// it.
type MutationInput struct {
// Base is the file name of the official fixture the capsule derives
// from, or "" for a capsule built from nothing.
Base string
DKC []byte
// DKK is the .dkk file offered, or nil.
DKK []byte
// Identities are the age X25519 identities offered, AGE-SECRET-KEY-1...
Identities []string
// Release is the only release the source knows: it answers every request
// with it. Nil means that no release is available.
Release *provider.Release
Now time.Time
// EmptyRegistry pins no profile; otherwise profile.Default is used.
EmptyRegistry bool
// Extensions are the extensions the application implements; nil knows
// none.
Extensions KnownExtensions
}
// KnownExtension is an extension an application implements, whose data is
// valid only when it equals ValidData.
type KnownExtension struct {
ID string
Version uint64
ValidData []byte
}
// KnownExtensions is an extension.Registry and extension.DataValidator.
type KnownExtensions []KnownExtension
func (k KnownExtensions) find(id string, version uint64) *KnownExtension {
for i := range k {
if k[i].ID == id && k[i].Version == version {
return &k[i]
}
}
return nil
}
// Known implements extension.Registry.
func (k KnownExtensions) Known(id string, version uint64) bool { return k.find(id, version) != nil }
// ValidateData implements extension.DataValidator.
func (k KnownExtensions) ValidateData(e extension.Extension) error {
x := k.find(e.ID, e.Version)
if x == nil {
return fmt.Errorf("extension %s version %d is not known", e.ID, e.Version)
}
if !bytes.Equal(x.ValidData, e.Data) {
return fmt.Errorf("data %x is not %x", e.Data, x.ValidData)
}
return nil
}
// singleSource answers every request with one release, or with
// ErrReleaseUnavailable, and counts the requests.
type singleSource struct {
release *provider.Release
calls int
}
func (s *singleSource) Fetch(context.Context, *profile.Profile, provider.Condition) (provider.Release, error) {
s.calls++
if s.release == nil {
return provider.Release{}, fmt.Errorf("testkit: no release: %w", datekeys.ErrReleaseUnavailable)
}
return *s.release, nil
}
// Verdict is how a reader rejected a capsule.
type Verdict struct {
Err error
Step int // the step of spec §63 that failed, 0 on success
// Calls is the number of release requests made.
Calls int
}
// Open opens the capsule with capsule.Open, as a reader given exactly the
// input would, from a seekable reader, and returns the verdict.
func (in *MutationInput) Open() (Verdict, error) {
reg := Registry()
if in.EmptyRegistry {
var err error
if reg, err = profile.NewRegistry(); err != nil {
return Verdict{}, err
}
}
src := &singleSource{release: in.Release}
o := capsule.OpenOptions{Registry: reg, Source: src, Now: Fixed(in.Now)}
if in.Extensions != nil {
o.Extensions = in.Extensions
}
if in.DKK != nil {
k, err := accesskey.Decode(bytes.NewReader(in.DKK))
if err != nil {
return Verdict{}, fmt.Errorf("testkit: the .dkk of a mutation must decode: %w", err)
}
defer k.Wipe()
o.AccessKey = k
}
for _, s := range in.Identities {
id, err := age.ParseX25519Identity(s)
if err != nil {
return Verdict{}, err
}
o.Identities = append(o.Identities, id)
}
opened, err := capsule.Open(context.Background(), discard{}, bytes.NewReader(in.DKC), o)
v := Verdict{Err: err, Calls: src.calls}
if err == nil {
return v, nil
}
if opened == nil || len(opened.Inspection.Checks) == 0 {
return v, fmt.Errorf("testkit: Open failed without recording a step: %w", err)
}
checks := opened.Inspection.Checks
if last := checks[len(checks)-1]; !last.OK {
v.Step = last.Step
}
return v, nil
}
type discard struct{}
func (discard) Write(p []byte) (int, error) { return len(p), nil }
// LoadedFixture is an official .dkc fixture read from a fixture directory.
type LoadedFixture struct {
DKCFixture
DKC []byte
DKK []byte // the .dkk file, when the fixture has one
Parts Parts
Published provider.Release // the release the fixture opens with
Unlock time.Time
}
// LoadFixture reads the fixture name from dir.
func LoadFixture(dir, name string) (*LoadedFixture, error) {
f := &LoadedFixture{}
if err := ReadJSON(filepath.Join(dir, name+".json"), &f.DKCFixture); err != nil {
return nil, err
}
var err error
if f.DKC, err = os.ReadFile(filepath.Join(dir, f.File)); err != nil {
return nil, err
}
if f.Parts, err = Split(f.DKC); err != nil {
return nil, err
}
if f.AccessKeyFile != "" {
if f.DKK, err = os.ReadFile(filepath.Join(dir, f.AccessKeyFile)); err != nil {
return nil, err
}
}
sig, err := hex.DecodeString(f.Release.Signature)
if err != nil {
return nil, err
}
f.Published = provider.Release{Round: f.Release.Round, Signature: sig}
if f.Unlock, err = time.Parse(time.RFC3339, f.UnlockAt); err != nil {
return nil, err
}
return f, nil
}
// input returns dkc, derived from f, with the options that open f: its
// release, its unlock time and, for time_and_key, its .dkk.
func (f *LoadedFixture) input(dkc []byte) *MutationInput {
r := f.Published
in := &MutationInput{Base: f.File, DKC: dkc, Release: &r, Now: f.Unlock}
if f.AccessPolicy == capsule.TimeAndKey.String() {
in.DKK = f.DKK
}
return in
}
// MutationEnv holds what the mutations derive from.
type MutationEnv struct {
Dir string
// TimeOnly and TimeAndKey are the time_only and time_and_key_portable
// fixtures.
TimeOnly, TimeAndKey *LoadedFixture
sibling []byte
}
// NewMutationEnv loads the fixtures the mutations derive from.
func NewMutationEnv(dir string) (*MutationEnv, error) {
e := &MutationEnv{Dir: dir}
var err error
if e.TimeOnly, err = LoadFixture(dir, "time_only"); err != nil {
return nil, err
}
if e.TimeAndKey, err = LoadFixture(dir, "time_and_key_portable"); err != nil {
return nil, err
}
return e, nil
}
// Sibling returns another time_only capsule for round 1000, built once per
// environment with fresh randomness.
func (e *MutationEnv) Sibling() (Parts, error) {
if e.sibling == nil {
var b bytes.Buffer
p := profile.Quicknet()
unlock, err := datekey.RoundTime(p, 1000)
if err != nil {
return Parts{}, err
}
if _, err := capsule.Encrypt(&b, strings.NewReader("sibling"), capsule.EncryptOptions{Profile: p, UnlockAt: unlock, Now: Fixed(Genesis())}); err != nil {
return Parts{}, err
}
e.sibling = b.Bytes()
}
return Split(e.sibling)
}
// Stranger returns a fixed X25519 identity that is not a recipient of any
// fixture: the key of the tests' third party. Its scalar is
// SHA-256("DateKeys test identity: stranger").
func Stranger() *age.X25519Identity {
raw := sha256.Sum256([]byte("DateKeys test identity: stranger"))
id, err := agewrap.X25519IdentityFromRaw(raw[:])
if err != nil {
panic(err)
}
return id
}
// MustUnderstand is the critical extension of the mutations that need one.
const MustUnderstand = "org.example.must-understand"
// mustUnderstandKnown is an application that knows MustUnderstand at version
// 1 and accepts only the data "ok".
var mustUnderstandKnown = KnownExtensions{{ID: MustUnderstand, Version: 1, ValidData: []byte("ok")}}
func set(b []byte, i int, v byte) []byte {
c := bytes.Clone(b)
c[i] = v
return c
}
func xorLast(b []byte) []byte {
c := bytes.Clone(b)
c[len(c)-1] ^= 0x01
return c
}
// built returns a capsule made by Build and the options that open it.
func built(b Build) (*MutationInput, error) {
if b.Plaintext == nil {
b.Plaintext = []byte("malicious creator")
}
out, err := b.Make()
if err != nil {
return nil, err
}
r := Release(1000)
return &MutationInput{DKC: out.DKC, Release: &r, Now: Genesis().AddDate(1, 0, 0)}, nil
}
func (e *MutationEnv) headerWithDateKey(dk string) (*MutationInput, error) {
to := e.TimeOnly
h, err := capsule.DecodeHeader(to.Parts.Header)
if err != nil {
return nil, err
}
raw, err := RawHeader(h.CapsuleID, dk, 0)
if err != nil {
return nil, err
}
return to.input(Reframe(to.Parts.Prelude, raw, to.Parts.Sealed, to.Parts.Payload)), nil
}
// headerWithExtensions replaces the noncritical_extensions of the time_only
// fixture header with exts, encoded as given.
func (e *MutationEnv) headerWithExtensions(exts []any) (*MutationInput, error) {
to := e.TimeOnly
m, err := cbortest.UnmarshalMap(to.Parts.Header)
if err != nil {
return nil, err
}
m[6] = exts
h, err := cbortest.Marshal(m)
if err != nil {
return nil, err
}
return to.input(Reframe(to.Parts.Prelude, h, to.Parts.Sealed, to.Parts.Payload)), nil
}
// policyByte returns the offset of the access_policy value in a header
// without extensions, whose last entry is 0x04 <value>.
func policyByte(header []byte) (int, error) {
i := len(header) - 2
if header[i] != 0x04 {
return 0, fmt.Errorf("testkit: unexpected header layout %x", header[i:])
}
return i + 1, nil
}
func (f *LoadedFixture) withPolicy(policy byte) (*MutationInput, error) {
i, err := policyByte(f.Parts.Header)
if err != nil {
return nil, err
}
h := set(f.Parts.Header, i, policy)
return f.input(Join(f.Parts.Prelude, h, f.Parts.Sealed, f.Parts.Payload)), nil
}
func mustUnderstand(data []byte) extension.Extension {
e, err := extension.New(MustUnderstand, 1, data)
if err != nil {
panic(err)
}
return e
}
func b64(s string) string { return base64.RawURLEncoding.EncodeToString([]byte(s)) }
// Mutations returns the mutation corpus: the twenty-three mutations of spec
// §64 followed by further cases.
func Mutations() []Mutation {
ok := func(in *MutationInput) (*MutationInput, error) { return in, nil }
return []Mutation{
// ---- The twenty-three mutations of spec §64 -------------------------
{Name: "PUBLIC_HEADER_A + SEALED_CONTROL_B", Spec: true, Want: datekeys.ErrHeaderBinding, Step: 15, Network: true, Random: true,
Make: func(e *MutationEnv) (*MutationInput, error) {
b, err := e.Sibling()
if err != nil {
return nil, err
}
to := e.TimeOnly
return to.input(Reframe(to.Parts.Prelude, to.Parts.Header, b.Sealed, b.Payload)), nil
}},
{Name: "SEALED_CONTROL_A + PAYLOAD_AGE_B", Spec: true, Want: datekeys.ErrIntegrity, Step: 17, Network: true, Random: true,
Make: func(e *MutationEnv) (*MutationInput, error) {
b, err := e.Sibling()
if err != nil {
return nil, err
}
to := e.TimeOnly
return to.input(Join(to.Parts.Prelude, to.Parts.Header, to.Parts.Sealed, b.Payload)), nil
}},
{Name: "DateKey A + release of round B", Spec: true, Want: datekeys.ErrRoundMismatch, Step: 10, Network: true,
Make: func(e *MutationEnv) (*MutationInput, error) {
in := e.TimeOnly.input(e.TimeOnly.DKC)
r := Release(1001)
in.Release = &r
return in, nil
}},
{Name: "chain hash changed", Spec: true, Want: datekeys.ErrProfileMismatch, Step: 8,
Make: func(e *MutationEnv) (*MutationInput, error) {
other := strings.Repeat("ab", 32)
return ok(e.TimeOnly.input(bytes.Replace(e.TimeOnly.DKC, []byte(profile.Quicknet().ChainHashHex()), []byte(other), 1)))
}},
{Name: "version changed", Spec: true, Want: datekeys.ErrUnsupportedVersion, Step: 2,
Make: func(e *MutationEnv) (*MutationInput, error) { return ok(e.TimeOnly.input(set(e.TimeOnly.DKC, 4, 2))) }},
{Name: "flags != 0", Spec: true, Want: datekeys.ErrInvalidFlags, Step: 2,
Make: func(e *MutationEnv) (*MutationInput, error) {
return ok(e.TimeOnly.input(set(e.TimeOnly.DKC, 5, 0x80)))
}},
{Name: "reserved != 0", Spec: true, Want: datekeys.ErrInvalidFlags, Step: 2,
Make: func(e *MutationEnv) (*MutationInput, error) { return ok(e.TimeOnly.input(set(e.TimeOnly.DKC, 7, 1))) }},
{Name: "payload truncated", Spec: true, Want: datekeys.ErrIntegrity, Step: 17, Network: true,
Make: func(e *MutationEnv) (*MutationInput, error) {
return ok(e.TimeOnly.input(e.TimeOnly.DKC[:len(e.TimeOnly.DKC)-1]))
}},
{Name: "payload age modified", Spec: true, Want: datekeys.ErrIntegrity, Step: 17, Network: true,
Make: func(e *MutationEnv) (*MutationInput, error) { return ok(e.TimeOnly.input(xorLast(e.TimeOnly.DKC))) }},
{Name: "control modified", Spec: true, Want: datekeys.ErrIntegrity, Step: 11, Network: true,
Make: func(e *MutationEnv) (*MutationInput, error) {
p := e.TimeOnly.Parts
return ok(e.TimeOnly.input(Join(p.Prelude, p.Header, xorLast(p.Sealed), p.Payload)))
}},
{Name: "non-canonical dk1_ JSON", Spec: true, Want: datekeys.ErrDateKeyNonCanonical, Step: 4,
Make: func(e *MutationEnv) (*MutationInput, error) {
return e.headerWithDateKey(datekey.Prefix + b64(`{"version":1, "network":"datekeys:quicknet:v1", "round":1000}`))
}},
{Name: "unknown profile", Spec: true, Want: datekeys.ErrUnknownProfile, Step: 4,
Make: func(e *MutationEnv) (*MutationInput, error) {
return e.headerWithDateKey(datekey.DateKey{ProfileID: "datekeys:evmnet:v1", Round: 1000}.Compact())
}},
{Name: "release of another round", Spec: true, Want: datekeys.ErrReleaseInvalid, Step: 10, Network: true,
Make: func(e *MutationEnv) (*MutationInput, error) {
in := e.TimeOnly.input(e.TimeOnly.DKC)
in.Release = &provider.Release{Round: 1000, Signature: Release(1001).Signature}
return in, nil
}},
{Name: "access_policy=time_only with time_and_key structure", Spec: true, Want: datekeys.ErrPolicyStructureMismatch, Step: 12, Network: true,
Make: func(e *MutationEnv) (*MutationInput, error) { return e.TimeAndKey.withPolicy(0) }},
{Name: "access_policy=time_and_key with time_only structure", Spec: true, Want: datekeys.ErrPolicyStructureMismatch, Step: 12, Network: true,
Make: func(e *MutationEnv) (*MutationInput, error) {
in, err := e.TimeOnly.withPolicy(1)
if err != nil {
return nil, err
}
in.Identities = []string{Stranger().String()}
return in, nil
}},
{Name: "extra stanza in OUTER_TIME_AGE", Spec: true, Want: datekeys.ErrPolicyStructureMismatch, Step: 5, Random: true,
Make: func(*MutationEnv) (*MutationInput, error) {
return built(Build{EditOuter: func(fk []byte, s []*age.Stanza) []*age.Stanza {
extra, _, _ := X25519Stanza(fk)
return append(s, extra)
}})
}},
{Name: "extra stanza in PAYLOAD_AGE", Spec: true, Want: datekeys.ErrPolicyStructureMismatch, Step: 6, Random: true,
Make: func(*MutationEnv) (*MutationInput, error) {
return built(Build{EditPayload: func(fk []byte, s []*age.Stanza) []*age.Stanza {
extra, _, _ := X25519Stanza(fk)
return append(s, extra)
}})
}},
{Name: "non-X25519 stanza in INNER_ACCESS_AGE", Spec: true, Want: datekeys.ErrPolicyStructureMismatch, Step: 12, Network: true, Random: true,
Make: func(*MutationEnv) (*MutationInput, error) {
in, err := built(Build{Declared: capsule.TimeAndKey, Structure: capsule.TimeAndKey,
AccessRecipients: []age.Recipient{Stranger().Recipient()},
EditInner: func(fk []byte, s []*age.Stanza) []*age.Stanza {
return append(s, &age.Stanza{Type: "scrypt", Args: []string{"c2FsdHNhbHRzYWx0c2FsdA", "10"}, Body: make([]byte, 32)})
}})
if err != nil {
return nil, err
}
in.Identities = []string{Stranger().String()}
return in, nil
}},
{Name: "tlock stanza round differs from DateKey.round", Spec: true, Want: datekeys.ErrRoundMismatch, Step: 8, Random: true,
Make: func(*MutationEnv) (*MutationInput, error) {
return built(Build{EditOuter: func(_ []byte, s []*age.Stanza) []*age.Stanza { s[0].Args[0] = "1001"; return s }})
}},
{Name: "tlock stanza chain hash differs from the pinned profile", Spec: true, Want: datekeys.ErrProfileMismatch, Step: 8, Random: true,
Make: func(*MutationEnv) (*MutationInput, error) {
return built(Build{EditOuter: func(_ []byte, s []*age.Stanza) []*age.Stanza {
s[0].Args[1] = "dbd506d6ef76e5f386f41c651dcb808c5bcbd75471cc4eafa3f4df7ad4e4c493" // drand default chain
return s
}})
}},
{Name: "extension data of a type other than bstr", Spec: true, Want: datekeys.ErrNonCanonicalCBOR, Step: 4,
Make: func(e *MutationEnv) (*MutationInput, error) {
// The v0.8.1 form of the time_only_extensions header: data as a text string.
return e.headerWithExtensions([]any{map[uint64]any{0: "org.example.label", 1: uint64(1), 2: "public label"}})
}},
{Name: "empty extension data (h'')", Spec: true, Want: datekeys.ErrNonCanonicalCBOR, Step: 4,
Make: func(e *MutationEnv) (*MutationInput, error) {
return e.headerWithExtensions([]any{map[uint64]any{0: "org.example.label", 1: uint64(1), 2: []byte{}}})
}},
{Name: "65 extensions in one array", Spec: true, Want: datekeys.ErrNonCanonicalCBOR, Step: 4,
Make: func(e *MutationEnv) (*MutationInput, error) {
exts := make([]any, 65)
for i := range exts {
exts[i] = map[uint64]any{0: fmt.Sprintf("org.example.%03d", i), 1: uint64(1)}
}
return e.headerWithExtensions(exts)
}},
// ---- Further cases ----------------------------------------------------
{Name: "magic", Want: datekeys.ErrInvalidMagic, Step: 1,
Make: func(e *MutationEnv) (*MutationInput, error) { return ok(e.TimeOnly.input(set(e.TimeOnly.DKC, 0, 'X'))) }},
{Name: "a .dkk offered as a .dkc", Want: datekeys.ErrInvalidMagic, Step: 1,
Make: func(e *MutationEnv) (*MutationInput, error) {
return ok(e.TimeOnly.input(append([]byte("DKK1"), e.TimeOnly.DKC[4:]...)))
}},
{Name: "empty file", Want: datekeys.ErrInvalidMagic, Step: 1,
Make: func(e *MutationEnv) (*MutationInput, error) { return ok(e.TimeOnly.input([]byte{})) }},
{Name: "truncated prelude", Want: datekeys.ErrIntegrity, Step: 1,
Make: func(e *MutationEnv) (*MutationInput, error) { return ok(e.TimeOnly.input(e.TimeOnly.DKC[:10])) }},
{Name: "PUBLIC_HEADER_LEN above the limit", Want: datekeys.ErrIntegrity, Step: 2,
Make: func(e *MutationEnv) (*MutationInput, error) {
c := bytes.Clone(e.TimeOnly.DKC)
binary.BigEndian.PutUint32(c[8:12], capsule.MaxPublicHeaderLen+1)
return ok(e.TimeOnly.input(c))
}},
{Name: "SEALED_CONTROL_LEN above the limit", Want: datekeys.ErrIntegrity, Step: 2,
Make: func(e *MutationEnv) (*MutationInput, error) {
c := bytes.Clone(e.TimeOnly.DKC)
binary.BigEndian.PutUint32(c[12:16], capsule.MaxSealedControlLen+1)
return ok(e.TimeOnly.input(c))
}},
{Name: "truncated inside SEALED_CONTROL", Want: datekeys.ErrIntegrity, Step: 5,
Make: func(e *MutationEnv) (*MutationInput, error) {
p := e.TimeOnly.Parts
return ok(e.TimeOnly.input(e.TimeOnly.DKC[:len(p.Prelude)+len(p.Header)+10]))
}},
{Name: "header schema version changed", Want: datekeys.ErrUnsupportedVersion, Step: 4,
Make: func(e *MutationEnv) (*MutationInput, error) {
// a5 00 6a "datekeycap" 01 <version>
return ok(e.TimeOnly.input(set(e.TimeOnly.DKC, capsule.PreludeSize+14, 2)))
}},
{Name: "unknown key in PUBLIC_HEADER", Want: datekeys.ErrNonCanonicalCBOR, Step: 4,
Make: func(e *MutationEnv) (*MutationInput, error) {
p := e.TimeOnly.Parts
h := append(bytes.Clone(p.Header), 0x07, 0x00)
h[0]++ // one more map entry
return ok(e.TimeOnly.input(Reframe(p.Prelude, h, p.Sealed, p.Payload)))
}},
{Name: "undefined access_policy", Want: datekeys.ErrNonCanonicalCBOR, Step: 4,
Make: func(e *MutationEnv) (*MutationInput, error) { return e.TimeOnly.withPolicy(2) }},
// 256 and 257 end in the byte of a V1 policy: a check made after a
// narrowing to one byte would read them as time_only and time_and_key.
{Name: "access_policy 256 with a consistent header_binding", Want: datekeys.ErrNonCanonicalCBOR, Step: 4, Random: true,
Make: func(*MutationEnv) (*MutationInput, error) { return built(Build{RawPolicy: 256}) }},
{Name: "access_policy 257 with a consistent header_binding", Want: datekeys.ErrNonCanonicalCBOR, Step: 4, Random: true,
Make: func(*MutationEnv) (*MutationInput, error) { return built(Build{RawPolicy: 257}) }},
{Name: "unknown critical PUBLIC_HEADER extension", Want: datekeys.ErrExtensionCriticalUnknown, Step: 4, Random: true,
Make: func(*MutationEnv) (*MutationInput, error) {
return built(Build{HeaderCritical: []extension.Extension{{ID: MustUnderstand, Version: 1}}})
}},
{Name: "unknown critical CONTROL_CBOR extension", Want: datekeys.ErrExtensionCriticalUnknown, Step: 14, Network: true, Random: true,
Make: func(*MutationEnv) (*MutationInput, error) {
return built(Build{ControlCritical: []extension.Extension{{ID: MustUnderstand, Version: 1}}})
}},
{Name: "known critical PUBLIC_HEADER extension with invalid data", Want: datekeys.ErrExtensionDataInvalid, Step: 4, Random: true,
Make: func(*MutationEnv) (*MutationInput, error) {
in, err := built(Build{HeaderCritical: []extension.Extension{mustUnderstand([]byte("ko"))}})
if err != nil {
return nil, err
}
in.Extensions = mustUnderstandKnown
return in, nil
}},
{Name: "known critical CONTROL_CBOR extension with invalid data", Want: datekeys.ErrExtensionDataInvalid, Step: 14, Network: true, Random: true,
Make: func(*MutationEnv) (*MutationInput, error) {
in, err := built(Build{ControlCritical: []extension.Extension{mustUnderstand([]byte("ko"))}})
if err != nil {
return nil, err
}
in.Extensions = mustUnderstandKnown
return in, nil
}},
{Name: "known critical .dkk extension with invalid data", Want: datekeys.ErrExtensionDataInvalid, Step: 9,
Make: func(e *MutationEnv) (*MutationInput, error) {
tk := e.TimeAndKey
k, err := accesskey.Decode(bytes.NewReader(tk.DKK))
if err != nil {
return nil, err
}
defer k.Wipe()
k.Critical = []extension.Extension{mustUnderstand([]byte("ko"))}
var b bytes.Buffer
if err := accesskey.Encode(&b, k); err != nil {
return nil, err
}
in := tk.input(tk.DKC)
in.DKK, in.Extensions = b.Bytes(), mustUnderstandKnown
return in, nil
}},
{Name: "extension_version above 2^32-1", Want: datekeys.ErrNonCanonicalCBOR, Step: 4,
Make: func(e *MutationEnv) (*MutationInput, error) {
return e.headerWithExtensions([]any{map[uint64]any{0: "org.example.label", 1: uint64(1) << 32}})
}},
{Name: "null extension data", Want: datekeys.ErrNonCanonicalCBOR, Step: 4,
Make: func(e *MutationEnv) (*MutationInput, error) {
return e.headerWithExtensions([]any{map[uint64]any{0: "org.example.label", 1: uint64(1), 2: nil}})
}},
{Name: "time_and_key without credentials", Want: datekeys.ErrAccessRequired, Step: 9,
Make: func(e *MutationEnv) (*MutationInput, error) {
in := e.TimeAndKey.input(e.TimeAndKey.DKC)
in.DKK = nil
return in, nil
}},
{Name: ".dkk of another capsule", Want: datekeys.ErrAccessInvalid, Step: 9,
Make: func(e *MutationEnv) (*MutationInput, error) {
other, err := LoadFixture(e.Dir, "time_and_key_recipients")
if err != nil {
return nil, err
}
in := e.TimeAndKey.input(e.TimeAndKey.DKC)
in.DKK = other.DKK
return in, nil
}},
{Name: "capsule_digest of the .dkk does not match", Want: datekeys.ErrAccessInvalid, Step: 9,
Make: func(e *MutationEnv) (*MutationInput, error) { return ok(e.TimeAndKey.input(xorLast(e.TimeAndKey.DKC))) }},
{Name: "identity that is not a recipient", Want: datekeys.ErrAccessInvalid, Step: 13, Network: true,
Make: func(e *MutationEnv) (*MutationInput, error) {
in := e.TimeAndKey.input(e.TimeAndKey.DKC)
in.DKK, in.Identities = nil, []string{Stranger().String()}
return in, nil
}},
{Name: "round not reached yet", Want: datekeys.ErrReleaseUnavailable, Step: 9,
Make: func(e *MutationEnv) (*MutationInput, error) {
in := e.TimeOnly.input(e.TimeOnly.DKC)
in.Now = e.TimeOnly.Unlock.Add(-1)
return in, nil
}},
{Name: "release source unavailable", Want: datekeys.ErrReleaseUnavailable, Step: 9, Network: true,
Make: func(e *MutationEnv) (*MutationInput, error) {
in := e.TimeOnly.input(e.TimeOnly.DKC)
in.Release = nil
return in, nil
}},
{Name: "trailing data after PAYLOAD_AGE", Want: datekeys.ErrIntegrity, Step: 17, Network: true,
Make: func(e *MutationEnv) (*MutationInput, error) {
return ok(e.TimeOnly.input(append(bytes.Clone(e.TimeOnly.DKC), 0)))
}},
{Name: "payload stanza body modified", Want: datekeys.ErrIntegrity, Step: 17, Network: true,
Make: func(e *MutationEnv) (*MutationInput, error) {
p := e.TimeOnly.Parts
n, err := HeaderLen(p.Payload)
if err != nil {
return nil, err
}
// Flip a byte of the wrapped file key: the last body line before "---".
i := bytes.LastIndex(p.Payload[:n], []byte("\n---")) - 10
c := byte('A')
if p.Payload[i] == 'A' {
c = 'B'
}
return ok(e.TimeOnly.input(Join(p.Prelude, p.Header, p.Sealed, set(p.Payload, i, c))))
}},
{Name: "tlock round edited by a third party", Want: datekeys.ErrRoundMismatch, Step: 8,
Make: func(e *MutationEnv) (*MutationInput, error) {
return ok(e.TimeOnly.input(bytes.Replace(e.TimeOnly.DKC, []byte("-> tlock 1000 "), []byte("-> tlock 1001 "), 1)))
}},
{Name: "empty registry", Want: datekeys.ErrUnknownProfile, Step: 4,
Make: func(e *MutationEnv) (*MutationInput, error) {
in := e.TimeOnly.input(e.TimeOnly.DKC)
in.EmptyRegistry = true
return in, nil
}},
{Name: "time_only declared, time_and_key built by the creator", Want: datekeys.ErrPolicyStructureMismatch, Step: 12, Network: true, Random: true,
Make: func(*MutationEnv) (*MutationInput, error) {
return built(Build{Declared: capsule.TimeOnly, Structure: capsule.TimeAndKey, AccessRecipients: []age.Recipient{Stranger().Recipient()}})
}},
{Name: "time_and_key declared, time_only built by the creator", Want: datekeys.ErrPolicyStructureMismatch, Step: 12, Network: true, Random: true,
Make: func(*MutationEnv) (*MutationInput, error) {
in, err := built(Build{Declared: capsule.TimeAndKey, Structure: capsule.TimeOnly})
if err != nil {
return nil, err
}
in.Identities = []string{Stranger().String()}
return in, nil
}},
{Name: "two INNER_ACCESS_AGE stanzas for one recipient", Want: datekeys.ErrPolicyStructureMismatch, Step: 13, Network: true, Random: true,
Make: func(*MutationEnv) (*MutationInput, error) {
stranger := Stranger()
in, err := built(Build{Declared: capsule.TimeAndKey, Structure: capsule.TimeAndKey,
AccessRecipients: []age.Recipient{stranger.Recipient()},
EditInner: func(fk []byte, s []*age.Stanza) []*age.Stanza {
again, _ := stranger.Recipient().Wrap(fk)
return append(s, again[0])
}})
if err != nil {
return nil, err
}
in.Identities = []string{stranger.String()}
return in, nil
}},
}
}
// Check opens in and compares the verdict with the mutation's expectation.
func (m Mutation) Check(in *MutationInput) error {
v, err := in.Open()
if err != nil {
return err
}
return m.checkVerdict(v)
}
func (m Mutation) checkVerdict(v Verdict) error {
switch {
case v.Err == nil:
return errors.New("mutation accepted")
case !errors.Is(v.Err, m.Want):
return fmt.Errorf("got %v, want %v", v.Err, m.Want)
case v.Step != m.Step:
return fmt.Errorf("failed at step %d, want step %d: %v", v.Step, m.Step, v.Err)
case !m.Network && v.Calls != 0:
return fmt.Errorf("an invalid capsule caused %d release requests", v.Calls)
}
return nil
}
// ---------------------------------------------------------------------------
// Exported corpus: testdata/vectors/mutations.json
// MutationFile is testdata/vectors/mutations.json.
type MutationFile struct {
Spec string `json:"spec"`
Description string `json:"description"`
Cases []MutationCase `json:"cases"`
}
// MutationCase is one mutation as frozen data.
type MutationCase struct {
Name string `json:"name"`
// Spec is true for the twenty-three mutations of spec §64.
Spec bool `json:"spec"`
DKC EditedFile `json:"dkc"`
// DKK is the hex of the .dkk offered, absent for none.
DKK string `json:"dkk,omitempty"`
Identities []string `json:"identities,omitempty"`
// Release is the only release the source serves, for any requested
// round; null when no release is available.
Release *FixtureRelease `json:"release"`
// Now is the reader's clock, RFC 3339.
Now string `json:"now"`
// Registry is "default" (the Quicknet profile pinned) or "empty".
Registry string `json:"registry"`
Extensions []KnownExtensionRecord `json:"extensions,omitempty"`
// Network reports whether a release may be requested before the
// failure; when false the reader must fail without any request.
Network bool `json:"network"`
// Frozen reports that the capsule was built once with age randomness;
// its bytes are kept and never regenerated.
Frozen bool `json:"frozen"`
Error string `json:"error"`
Step int `json:"step"`
}
// EditedFile is a file given as edits of a base fixture.
type EditedFile struct {
// Base is the file name of an official fixture in testdata/fixtures, or
// absent for the empty file.
Base string `json:"base,omitempty"`
Edits []Edit `json:"edits"`
}
// KnownExtensionRecord is a KnownExtension in JSON.
type KnownExtensionRecord struct {
ID string `json:"id"`
Version uint64 `json:"version"`
ValidData string `json:"valid_data"`
}
func (f EditedFile) bytes(dir string) ([]byte, error) {
var base []byte
if f.Base != "" {
var err error
if base, err = os.ReadFile(filepath.Join(dir, f.Base)); err != nil {
return nil, err
}
}
return ApplyEdits(base, f.Edits)
}
// Input rebuilds the input of the case with the fixtures of dir.
func (c *MutationCase) Input(dir string) (*MutationInput, error) {
dkc, err := c.DKC.bytes(dir)
if err != nil {
return nil, err
}
in := &MutationInput{Base: c.DKC.Base, DKC: dkc, Identities: c.Identities, EmptyRegistry: c.Registry == "empty"}
if c.Registry != "default" && c.Registry != "empty" {
return nil, fmt.Errorf("testkit: unknown registry %q", c.Registry)
}
if c.DKK != "" {
if in.DKK, err = hex.DecodeString(c.DKK); err != nil {
return nil, err
}
}
if c.Release != nil {
sig, err := hex.DecodeString(c.Release.Signature)
if err != nil {
return nil, err
}
in.Release = &provider.Release{Round: c.Release.Round, Signature: sig}
}
if in.Now, err = time.Parse(time.RFC3339Nano, c.Now); err != nil {
return nil, err
}
for _, x := range c.Extensions {
data, err := hex.DecodeString(x.ValidData)
if err != nil {
return nil, err
}
in.Extensions = append(in.Extensions, KnownExtension{ID: x.ID, Version: x.Version, ValidData: data})
}
return in, nil
}
// Check opens the input of the case and compares the verdict with the
// recorded one.
func (c *MutationCase) Check(dir string) error {
in, err := c.Input(dir)
if err != nil {
return err
}
v, err := in.Open()
if err != nil {
return err
}
if got := Result(v.Err); got != c.Error || v.Step != c.Step {
return fmt.Errorf("got %s at step %d, want %s at step %d (%v)", got, v.Step, c.Error, c.Step, v.Err)
}
if !c.Network && v.Calls != 0 {
return fmt.Errorf("an invalid capsule caused %d release requests", v.Calls)
}
return nil
}
func (m Mutation) record(in *MutationInput, dir string, v Verdict) (MutationCase, error) {
c := MutationCase{
Name: m.Name, Spec: m.Spec, Identities: in.Identities, Now: in.Now.UTC().Format(time.RFC3339Nano),
Registry: "default", Network: m.Network, Frozen: m.Random, Error: Result(v.Err), Step: v.Step,
}
if in.EmptyRegistry {
c.Registry = "empty"
}
c.DKC.Edits = Splice(nil, in.DKC)
if in.Base != "" && !m.Random {
base, err := os.ReadFile(filepath.Join(dir, in.Base))
if err != nil {
return c, err
}
c.DKC = EditedFile{Base: in.Base, Edits: Splice(base, in.DKC)}
}
if c.DKC.Edits == nil {
c.DKC.Edits = []Edit{}
}
if in.DKK != nil {
c.DKK = hex.EncodeToString(in.DKK)
}
if in.Release != nil {
c.Release = &FixtureRelease{Round: in.Release.Round, Signature: hex.EncodeToString(in.Release.Signature)}
}
for _, x := range in.Extensions {
c.Extensions = append(c.Extensions, KnownExtensionRecord{ID: x.ID, Version: x.Version, ValidData: hex.EncodeToString(x.ValidData)})
}
return c, nil
}
// MutationCorpus computes testdata/vectors/mutations.json from the fixtures
// of dir. The capsules of the Random mutations are taken from frozen, the
// file as committed, when it records them, and built afresh otherwise. Every
// case is opened, and the file records the verdict; a verdict other than the
// one the mutation is written for is an error.
func MutationCorpus(dir string, frozen *MutationFile) (MutationFile, error) {
f := MutationFile{
Spec: SpecVersion,
Description: "Mutation corpus of spec §64 and further cases of capsule.TestMutationCorpus, generated by the reference implementation: " +
"each case is a .dkc and what the reader is given, with the normative error and the step of spec §63 at which capsule.Open fails. See testdata/README.md.",
}
env, err := NewMutationEnv(dir)
if err != nil {
return f, err
}
old := map[string]*MutationCase{}
if frozen != nil {
for i := range frozen.Cases {
old[frozen.Cases[i].Name] = &frozen.Cases[i]
}
}
for _, m := range Mutations() {
var in *MutationInput
if c := old[m.Name]; m.Random && c != nil && c.Frozen {
in, err = c.Input(dir)
} else {
in, err = m.Make(env)
}
if err != nil {
return f, fmt.Errorf("mutation %q: %w", m.Name, err)
}
v, err := in.Open()
if err == nil {
err = m.checkVerdict(v)
}
if err != nil {
return f, fmt.Errorf("mutation %q: %w", m.Name, err)
}
c, err := m.record(in, dir, v)
if err != nil {
return f, err
}
f.Cases = append(f.Cases, c)
}
return f, nil
}

@ -0,0 +1,108 @@
package testkit_test
import (
"bytes"
"encoding/hex"
"encoding/json"
"testing"
"g.activething.com/go/DateKeys/internal/testkit"
)
func TestEdits(t *testing.T) {
base := []byte("0123456789")
for _, tc := range []struct {
out string
want string // JSON of Splice(base, out)
}{
{"0123456789", `[]`},
{"01X3456789", `[[2,1,"58"]]`},
{"012", `[[3,7,""]]`},
{"", `[[0,10,""]]`},
{"01234567890", `[[10,0,"30"]]`},
{"0123XY456789", `[[4,0,"5859"]]`},
} {
edits := testkit.Splice(base, []byte(tc.out))
if edits == nil {
edits = []testkit.Edit{}
}
b, err := json.Marshal(edits)
if err != nil || string(b) != tc.want {
t.Errorf("Splice(%q) = %s, %v; want %s", tc.out, b, err, tc.want)
}
var back []testkit.Edit
if err := json.Unmarshal(b, &back); err != nil {
t.Fatal(err)
}
out, err := testkit.ApplyEdits(base, back)
if err != nil || string(out) != tc.out {
t.Errorf("ApplyEdits(%s) = %q, %v; want %q", b, out, err, tc.out)
}
}
// Several edits refer to offsets of the base.
out, err := testkit.ApplyEdits(base, []testkit.Edit{{At: 1, Delete: 1, Insert: []byte("ab")}, {At: 5, Delete: 2}, {At: 10, Insert: []byte("!")}})
if err != nil || string(out) != "0ab234789!" {
t.Fatalf("got %q, %v", out, err)
}
for _, bad := range [][]testkit.Edit{
{{At: 5, Delete: 1}, {At: 2, Delete: 1}}, // out of order
{{At: 2, Delete: 3}, {At: 4, Delete: 1}}, // overlapping
{{At: 9, Delete: 2}}, // beyond the base
{{At: -1}},
} {
if _, err := testkit.ApplyEdits(base, bad); err == nil {
t.Errorf("ApplyEdits accepted %v", bad)
}
}
for _, bad := range []string{`[1,2]`, `[1,2,"zz"]`, `["1",2,""]`, `{}`} {
var e testkit.Edit
if err := json.Unmarshal([]byte(bad), &e); err == nil {
t.Errorf("Edit accepted %s", bad)
}
}
}
// The schema vectors of testdata/vectors/cbor.json replay: the decoder of each
// schema gives exactly the recorded result.
func TestSchemaVectors(t *testing.T) {
var f testkit.CBORVectorFile
if err := testkit.ReadJSON("../../testdata/vectors/cbor.json", &f); err != nil {
t.Fatal(err)
}
blocks := map[string]int{}
for _, v := range f.Schemas {
b, err := hex.DecodeString(v.Hex)
if err != nil {
t.Fatal(err)
}
if got := testkit.Result(testkit.DecodeSchema(v.Schema, b)); got != v.Result {
t.Errorf("%s %q: got %s, want %s", v.Block, v.Name, got, v.Result)
}
blocks[v.Block]++
}
for _, b := range []string{testkit.SchemaProfile, testkit.SchemaHeader, testkit.SchemaControl, testkit.SchemaDKKBody, "verification_metadata", "extension"} {
if blocks[b] < 5 {
t.Errorf("block %s has %d vectors", b, blocks[b])
}
}
if err := testkit.DecodeSchema("nope", nil); err == nil {
t.Error("unknown schema accepted")
}
}
// The committed vector files are what the generators compute now.
func TestVectorFilesAreCurrent(t *testing.T) {
want, err := testkit.CBORVectors()
if err != nil {
t.Fatal(err)
}
var got testkit.CBORVectorFile
if err := testkit.ReadJSON("../../testdata/vectors/cbor.json", &got); err != nil {
t.Fatal(err)
}
a, _ := json.Marshal(want)
b, _ := json.Marshal(got)
if !bytes.Equal(a, b) {
t.Error("testdata/vectors/cbor.json is stale: run go run ./internal/testkit/genfixtures -out testdata")
}
}

@ -8,6 +8,7 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
"math"
"time"
"github.com/drand/drand/v2/common/chain"
@ -54,19 +55,104 @@ type Profile struct {
GenesisSeed [32]byte // key 10
}
// wire is the CBOR map of spec §11. Every key is required.
// wire is the CBOR map of spec §11, keys 2 to 10; keys 0 and 1 are the
// constants TypeTag and SchemaVersion. Every key is required.
type wire struct {
Type string `cbor:"0,keyasint"`
Version uint64 `cbor:"1,keyasint"`
ID string `cbor:"2,keyasint"`
Provider string `cbor:"3,keyasint"`
Network string `cbor:"4,keyasint"`
ChainHash []byte `cbor:"5,keyasint"`
PublicKey []byte `cbor:"6,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"`
ID string // key 2
Provider string // key 3
Network string // key 4
ChainHash []byte // key 5
PublicKey []byte // key 6
Period uint64 // key 7, 1..2^53-1
GenesisTime uint64 // key 8, 0..2^53-1
Scheme string // key 9
GenesisSeed []byte // key 10
}
// wireKeys is the number of keys of the map, all required.
const wireKeys = 11
// unbounded bounds a field only by the input: its rule carries its own error
// code (spec §57) and Validate checks it after decoding.
const unbounded = math.MaxInt
func (w *wire) encode(e *codec.Encoder) {
e.Map(wireKeys)
e.Uint(0)
e.Text(TypeTag)
e.Uint(1)
e.Uint(SchemaVersion)
e.Uint(2)
e.Text(w.ID)
e.Uint(3)
e.Text(w.Provider)
e.Uint(4)
e.Text(w.Network)
e.Uint(5)
e.Bstr(w.ChainHash)
e.Uint(6)
e.Bstr(w.PublicKey)
e.Uint(7)
e.Uint(w.Period)
e.Uint(8)
e.Uint(w.GenesisTime)
e.Uint(9)
e.Text(w.Scheme)
e.Uint(10)
e.Bstr(w.GenesisSeed)
}
// decode reads the map with every CDDL rule whose violation is
// ErrNonCanonicalCBOR; the names and the public key are left to Validate.
func (w *wire) decode(d *codec.Decoder) error {
pairs, err := d.Map(wireKeys)
if err != nil {
return err
}
if pairs != wireKeys {
return fmt.Errorf("%d keys, want all %d: %w", pairs, wireKeys, datekeys.ErrNonCanonicalCBOR)
}
for want := range uint64(wireKeys) {
k, err := d.Key()
if err != nil {
return err
}
if k != want {
return fmt.Errorf("key %d where key %d was expected: %w", k, want, datekeys.ErrNonCanonicalCBOR)
}
switch k {
case 0:
_, err = d.Text(len(TypeTag))
case 1:
_, err = d.Uint(SchemaVersion)
case 2:
w.ID, err = d.Text(unbounded)
case 3:
w.Provider, err = d.Text(unbounded)
case 4:
w.Network, err = d.Text(unbounded)
case 5:
w.ChainHash, err = d.Bstr(32, 32)
case 6:
w.PublicKey, err = d.Bstr(0, unbounded)
case 7:
// Spec §11: period in 1..2^53-1.
if w.Period, err = d.Uint(codec.MaxSafeUint); err == nil && w.Period == 0 {
err = fmt.Errorf("period 0: %w", datekeys.ErrNonCanonicalCBOR)
}
case 8:
// Spec §11: genesis_time in 0..2^53-1, unsigned.
w.GenesisTime, err = d.Uint(codec.MaxSafeUint)
case 9:
w.Scheme, err = d.Text(unbounded)
case 10:
w.GenesisSeed, err = d.Bstr(32, 32)
}
if err != nil {
return fmt.Errorf("key %d: %w", k, err)
}
}
return d.EndMap()
}
// Clone returns a deep copy of p.
@ -86,9 +172,7 @@ func (p *Profile) CanonicalCBOR() ([]byte, error) {
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,
Version: SchemaVersion,
w := wire{
ID: p.ID,
Provider: p.Provider,
Network: p.Network,
@ -98,7 +182,10 @@ func (p *Profile) CanonicalCBOR() ([]byte, error) {
GenesisTime: uint64(p.GenesisTime),
Scheme: p.Scheme,
GenesisSeed: p.GenesisSeed[:],
})
}
var e codec.Encoder
w.encode(&e)
return e.Out()
}
// Hash returns profile_hash = SHA-256(exact_deterministic_cbor_bytes) (spec §11).
@ -121,17 +208,9 @@ func Decode(b []byte) (*Profile, error) {
return nil, fmt.Errorf("profile: %w", err)
}
var w wire
if err := codec.Unmarshal(b, &w); err != nil {
if err := codec.Unmarshal(b, w.decode, w.encode); err != nil {
return nil, fmt.Errorf("profile: %w", err)
}
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)
}
// 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)
}

@ -9,6 +9,7 @@ import (
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/codec"
"g.activething.com/go/DateKeys/internal/cbortest"
"g.activething.com/go/DateKeys/internal/testkit"
"g.activething.com/go/DateKeys/profile"
)
@ -47,8 +48,8 @@ func TestQuicknetCBORLayout(t *testing.T) {
if b[0] != 0xab {
t.Fatalf("map header %#x", b[0])
}
var m map[uint64]any
if err := codec.Unmarshal(b, &m); err != nil {
m, err := cbortest.UnmarshalMap(b)
if err != nil {
t.Fatal(err)
}
want := map[uint64]any{0: "datekeys-provider-profile", 1: uint64(1), 2: "datekeys:quicknet:v1", 3: "drand", 4: "quicknet", 7: uint64(3), 8: uint64(1692803367), 9: "bls-unchained-g1-rfc9380"}
@ -87,7 +88,7 @@ func TestDecodeRoundTrip(t *testing.T) {
t.Errorf("%s accepted: %v", name, err)
}
}
future, _ := codec.Marshal(map[uint64]any{0: profile.TypeTag, 1: uint64(2)})
future, _ := cbortest.Marshal(map[uint64]any{0: profile.TypeTag, 1: uint64(2)})
if _, err := profile.Decode(future); !errors.Is(err, datekeys.ErrUnsupportedVersion) {
t.Fatalf("future schema: %v", err)
}
@ -98,8 +99,8 @@ func TestDecodeRoundTrip(t *testing.T) {
// 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 {
m, err := cbortest.UnmarshalMap(b)
if err != nil {
t.Fatal(err)
}
for name, v := range map[string]struct {
@ -117,7 +118,7 @@ func TestIntegerRanges(t *testing.T) {
c[k] = x
}
c[v.key] = v.val
in, err := codec.Marshal(c)
in, err := cbortest.Marshal(c)
if err != nil {
t.Fatal(err)
}

@ -0,0 +1,56 @@
package profile_test
import (
"errors"
"maps"
"testing"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/internal/cbortest"
"g.activething.com/go/DateKeys/profile"
)
// Spec §11, §58: the Provider Profile is the closed map of keys 0 to 10, all
// required, in ascending order.
func TestDecodeStructure(t *testing.T) {
b, _ := profile.Quicknet().CanonicalCBOR()
m, err := cbortest.UnmarshalMap(b)
if err != nil {
t.Fatal(err)
}
encode := func(v any) []byte {
out, err := cbortest.Marshal(v)
if err != nil {
t.Fatal(err)
}
return out
}
with := func(edit func(m map[uint64]any)) []byte {
c := maps.Clone(m)
edit(c)
return encode(c)
}
for name, in := range map[string][]byte{
"missing scheme": with(func(c map[uint64]any) { delete(c, 9) }),
"key 11 for key 10": with(func(c map[uint64]any) { c[11] = c[10]; delete(c, 10) }),
"twelve entries": with(func(c map[uint64]any) { c[11] = uint64(0) }),
"chain hash of 31": with(func(c map[uint64]any) { c[5] = make([]byte, 31) }),
"genesis seed of 33": with(func(c map[uint64]any) { c[10] = make([]byte, 33) }),
"public key as text": with(func(c map[uint64]any) { c[6] = "key" }),
"profile_id as bytes": with(func(c map[uint64]any) { c[2] = []byte("datekeys:quicknet:v1") }),
"text key after key 1": encode(cbortest.Pairs{uint64(0), profile.TypeTag, uint64(1), uint64(1), "2", "datekeys:quicknet:v1"}),
"null period": with(func(c map[uint64]any) { c[7] = nil }),
"invalid UTF-8 network": with(func(c map[uint64]any) { c[4] = "quick\xffnet" }),
} {
if _, err := profile.Decode(in); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Errorf("%s: %v", name, err)
}
}
// The encoder refuses a name that is not valid UTF-8, with the same code:
// it cannot be written as a CBOR text string.
p := profile.Quicknet()
p.Network = "quick\xffnet"
if _, err := p.CanonicalCBOR(); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Fatalf("invalid UTF-8 encoded: %v", err)
}
}

@ -12,7 +12,12 @@ if [[ -n "${FUZZ_PARALLEL:-}" ]]; then
parallel+=(-parallel "$FUZZ_PARALLEL")
fi
targets=(
"./codec FuzzDecoder"
"./codec FuzzWalk"
"./codec FuzzPeek"
"./codec FuzzUnmarshal"
"./codec FuzzEncodeImpliesWalk"
"./extension FuzzDecodeArray"
"./profile FuzzDecode"
"./datekey FuzzParse"
"./agewrap FuzzStanzas"

@ -36,7 +36,7 @@ provider-profile = {
4 => name, ; provider network identifier, "quicknet"
5 => bstr .size 32, ; chain_hash
6 => public-key, ; group public key
7 => 1..max-safe-uint, ; period in seconds
7 => period, ; seconds; spec section 11: 1..max-safe-uint
8 => 0..max-safe-uint, ; genesis_time, Unix seconds
9 => name, ; scheme, "bls-unchained-g1-rfc9380"
10 => bstr .size 32, ; genesis_seed
@ -112,6 +112,7 @@ max-safe-uint = 9007199254740991
profile-id = tstr .regexp "[a-z0-9][a-z0-9:._-]{0,127}"
name = tstr .regexp "[a-z0-9][a-z0-9._-]{0,63}"
public-key = bstr .size (1..1024)
period = 1..86400 ; at most one day
extension-id = tstr .size (1..256)
; Spec section 18 and 19: "dk1_" + unpadded Base64URL of the canonical JSON

436
testdata/README.md vendored

@ -0,0 +1,436 @@
# DateKeys test data
Official vectors, fixtures and corpora of the DateKeys Protocol Specification
v0.8.2, generated by the reference implementation. Another implementation
consumes them as they are: this file documents every format, so that no Go code
has to be read.
```
go run ./internal/testkit/genfixtures -out testdata
```
regenerates everything except the `.dkc` and `.dkk` fixtures, which are
generated once and frozen (spec §67). The local gate (`scripts/check.sh`) and CI
run it and fail if any committed file changes: every file below is exactly what
the implementation computes today.
Conventions for every file:
- JSON in UTF-8, with LF line endings. Binary values are lowercase hex strings.
- `error`, `result` and similar fields hold the normative codes of spec §69, such
as `ERR_NON_CANONICAL_CBOR`.
- `step` is a step of the reading flow of spec §63, 1 to 18. Steps 1 to 8 are the
pre-unlock checks (`datekeys inspect`, `capsule.Inspect`): no network, no
secret.
- The `spec` field names the version of the specification.
| File | Content | Spec |
|---|---|---|
| `vectors/profile_quicknet.json` | Quicknet Provider Profile: its canonical CBOR and `profile_hash` | §11, §12 |
| `vectors/quicknet_rounds.json` | date → round resolution | §15, §16, §65 |
| `vectors/dk1.json` | canonical `dk1_` strings, and rejected encodings with their code | §18, §19, §66 |
| `vectors/cbor.json` | the CBOR profile, and one block of vectors per schema | §58, CDDL |
| `vectors/mutations.json` | the mutation corpus: 23 mutations of §64 and further cases | §63, §64 |
| `vectors/inspect_differential.json` | 1825 mutations of the fixtures with the verdict of steps 1 to 8 | §63 |
| `fixtures/<name>.dkc`, `<name>.json` | official capsules and every intermediate value | §67 |
| `fixtures/<name>.dkk`, `<name>.dkk.json` | official access keys | §68 |
| `fixtures/<name>.plaintext` | the plaintext of each capsule | §67 |
| `fixtures/<name>.inspect.json` | the exact output of `datekeys inspect -json` for each `.dkc` | §63 |
The five official capsules are `time_only`, `time_only_extensions`,
`time_and_key_portable`, `time_and_key_recipients` and `empty_payload`. The
release that opens each one, a published Quicknet signature, is in its
`<name>.json`, so they all decrypt offline.
## Edited files
`mutations.json` and `inspect_differential.json` give each mutated `.dkc` as
edits of a base file, not as its full bytes:
```json
{ "base": "time_only.dkc", "edits": [[4, 1, "02"]] }
```
- `base` is a file of `testdata/fixtures`. In `mutations.json` it may be absent:
the base is then the empty file, and the single edit holds the whole capsule.
- An edit is `[at, delete, insert]`: the `delete` bytes at offset `at` of the
base are replaced by the bytes of the hex string `insert`.
- The edits of one file refer to offsets of the unmodified base, are sorted by
`at` and do not overlap. The result is therefore built in one pass: copy the
base up to `at`, append `insert`, skip `delete` bytes of the base, go on with
the next edit, and copy the rest of the base.
- `[0, 78799, ""]` on `time_only.dkc` is the empty file; `"edits": []` is the
base unchanged.
## `vectors/cbor.json`
```json
{
"spec": "0.8.2",
"walk": { "max_depth": 3, "max_len": 64 },
"accept": [ { "name": "uint 2^53 eight bytes", "hex": "1b0020000000000000", "value": "9007199254740992" } ],
"reject": [ { "name": "tag", "hex": "c101", "error": "ERR_NON_CANONICAL_CBOR" } ],
"schemas": [ { "block": "extension", "schema": "public_header", "name": "65 extensions", "hex": "a600…", "result": "ERR_NON_CANONICAL_CBOR" } ]
}
```
### Generic vectors: `accept` and `reject`
Each `hex` is checked as exactly one data item of the CBOR profile of spec §58:
major types 0, 2, 3, 4 and 5 only; integers and lengths in their shortest form;
definite lengths; map keys that are unsigned integers in strictly ascending
order; valid UTF-8 text; nothing after the item. `accept` holds the inputs that
pass, `reject` those that fail, all with `ERR_NON_CANONICAL_CBOR`.
The `walk` limits apply as well, as in the reference `codec.Walk`: containers
nest at most `max_depth` deep (a scalar has depth 0, `81818100` has depth 3),
and every byte string and text string has at most `max_len` bytes, every array
at most `max_len` items and every map at most `max_len` entries. The vectors
named "above max_len" or "above max_depth" fail on these limits only.
`value` is present for an accepted unsigned integer: a JSON number up to
2^53 − 1, and a decimal string above, so that no reader loses precision.
Among them: shortest-form boundaries, `a200010101` (the two-key map
`{0: 1, 1: 1}`), a text with a leading BOM, keys out of order or repeated,
non-integer keys, indefinite lengths, tags, floats, simple values, negative
integers, truncation, lengths beyond the input, trailing bytes, overlong UTF-8
and surrogates.
### Schema vectors: `schemas`
Each vector is one encoded object:
- `schema` names the object and the decoder to run on `hex`:
- `provider_profile`: a Provider Profile (§11), decoded and then validated
as a profile to pin (§12, §13). Decoding applies the CDDL, with the
`period` limit of the reference (see "Implementation limits"). The
validation then requires, each failure being `ERR_UNKNOWN_PROFILE`:
`profile_id`, `provider`, `network` and `scheme` that follow their CDDL
rules; a `public_key` of 1 to 1024 bytes; a `genesis_time` from 1 to
253402300798 (before 9999-12-31T23:59:59Z); `provider` `drand`; a
`scheme` that tlock supports, `pedersen-bls-unchained`,
`bls-unchained-on-g1` or `bls-unchained-g1-rfc9380`; and a `public_key`
that is the compressed encoding of a BLS12-381 point of the scheme's key
group (G1, 48 bytes, for `pedersen-bls-unchained`; G2, 96 bytes, for the
other two), other than the identity. Last, the chain-hash self-check:
`chain_hash` must be the drand chain-info hash of the other parameters,
or the result is `ERR_PROFILE_MISMATCH`:
```
SHA-256( uint32_be(period) || int64_be(genesis_time) || public_key
|| genesis_seed || network )
```
with `period` in seconds, and `network` as its UTF-8 bytes, left out when
it is `default`. The scheme and the other names are not hashed. For
Quicknet this gives `52db9ba7…`, its `chain_hash`. A vector that changes
a hashed key recomputes `chain_hash`, unless its name says that the chain
hash no longer matches.
- `public_header`: PUBLIC_HEADER (§24). No profile registry is consulted and
no extension is known: a header naming an unpinned profile is valid here
(`ERR_UNKNOWN_PROFILE` comes from the registry at step 4), and critical
extensions are not checked here.
- `control_cbor`: CONTROL_CBOR (§31).
- `dkk_body`: BODY_CBOR of a `.dkk` (§41), without the 12-byte DKK1
prelude.
- `block` names the schema the vector exercises: the same as `schema`, or
`verification_metadata` (the `.dkk` body's key 6 varies) or `extension` (the
PUBLIC_HEADER's key 6, `noncritical_extensions`, varies).
- `result` is `ok` or the error code.
When bytes break several rules, the code is decided in this order (§57, §70):
1. the type tag (key 0) and the schema version (key 1) are read first, from
the start of the input only. The input must start with, each head in its
shortest form: a map head of definite length announcing at least two
entries, and no more entries than half the number of bytes after the head;
key 0; a text string, the type tag; key 1; and an unsigned integer of at
most 2^53 − 1, the version. Anything else there is `ERR_NON_CANONICAL_CBOR`,
including a version of 2^53 or more (2^64 − 1 too): it is outside every
schema (§58), not an unsupported version. Then a type tag other than the
schema's own is `ERR_NON_CANONICAL_CBOR`, whatever the version. Only then
a version other than 1 is `ERR_UNSUPPORTED_VERSION`, whatever follows it:
unknown keys, items outside the profile, truncation or trailing bytes;
2. every rule of the CDDL, including sizes, ranges, the 64-extension maximum,
the cross-array rule and the implementation limits that the table below
maps to it, is `ERR_NON_CANONICAL_CBOR`, and so is any difference between
the input and the re-encoding of what was decoded;
3. only then the fields with codes of their own: the DateKey
(`ERR_DATEKEY_INVALID`, `ERR_DATEKEY_NON_CANONICAL`), `access_type` and
`access_material` (`ERR_ACCESS_INVALID`), and the validation of a
Provider Profile described above (`ERR_UNKNOWN_PROFILE`, then
`ERR_PROFILE_MISMATCH` for the chain hash, checked last).
Each block has a minimal valid object, an unknown key, a missing required key, a
wrong type and values out of size or range. The `extension` block has, besides:
data `40` (empty) and `5801xx` (length not in its shortest form), data of every
other type (text, `null`, integer, map, array, tag, indefinite length), 64 and
65 extensions, an `extension_id` starting with a BOM, the pair U+FF61 and
U+10000 in UTF-8 byte order (valid) and in UTF-16 order (invalid), and
`extension_version` 2^32 − 1 (valid), 2^32 and 2^53 (invalid).
#### Implementation limits
`spec/datekeys.cddl` marks some rules as limits of the reference
implementation. They are not normative while §74 leaves the field limits open,
and an implementation that applies them uses the codes of §57. The vectors
apply them:
| Limit of the reference | Normative range | Code |
|---|---|---|
| `extension_id` of 1 to 256 bytes | 1 byte or more | `ERR_NON_CANONICAL_CBOR` |
| Provider Profile `period` of 1 to 86400 seconds (one day) | 1 to 2^53 − 1 (§11) | `ERR_NON_CANONICAL_CBOR` |
| `profile_id` `[a-z0-9][a-z0-9:._-]{0,127}`; `provider`, `network` and `scheme` `[a-z0-9][a-z0-9._-]{0,63}` | a text string | `ERR_UNKNOWN_PROFILE` |
| Provider Profile `public_key` of 1 to 1024 bytes | a byte string | `ERR_UNKNOWN_PROFILE` |
The vectors named "the implementation limit" sit at a limit and are valid;
those named "above the implementation limit" go one past it and are otherwise
valid, so that an implementation without the limit accepts them. The
`genesis_time` range and the drand rules of the `provider_profile` validation
are not CDDL rules: they decide whether a profile can be pinned, and fail
with `ERR_UNKNOWN_PROFILE`.
## `vectors/mutations.json`
The mutation corpus of spec §64, as frozen data. Each case is a `.dkc`, what
the reader is given to open it, and the exact error and step at which the full
reading flow (`capsule.Open`, §63) must fail.
```json
{
"name": "version changed",
"spec": true,
"dkc": { "base": "time_only.dkc", "edits": [[4, 1, "02"]] },
"release": { "round": 1000, "signature": "b446…" },
"now": "2023-08-23T15:59:24Z",
"registry": "default",
"network": false,
"frozen": false,
"error": "ERR_UNSUPPORTED_VERSION",
"step": 2
}
```
- `name`: unique, stable.
- `spec`: true for the 23 mutations listed in spec §64 (the first 23 cases),
false for the further cases of the reference.
- `dkc`: the capsule, as edits of a fixture (see above). The reader gets it as a
seekable file, so that the `capsule_digest` of an offered `.dkk` can be
checked before any release request (see "Access pre-checks").
- `dkk`: the hex of a complete `.dkk` file (prelude and body) offered as the
access credential; absent when none is offered.
- `identities`: age X25519 identities (`AGE-SECRET-KEY-1…`) offered as access
credentials; absent when none.
- `release`: what the release source answers to every request, whatever round
is asked for. The reader must verify it (§51): a case may serve a release of
another round, or a round with the signature of another. `null` means that
no release is available (`ERR_RELEASE_UNAVAILABLE`).
- `now`: the reader's clock, RFC 3339. No release is requested before the round
time of the DateKey.
- `registry`: `default` pins exactly the Quicknet profile of
`profile_quicknet.json`; `empty` pins none.
- `extensions`: the extensions the application implements. Each entry is known
at `(id, version)`, and its data is valid only when it equals the bytes of
`valid_data`. Absent: the application knows no extension, the state of the
base protocol V1.
- `network`: whether the failure may come after a release request. When false,
the reader must fail without requesting any release (§27, §63): every failure
of steps 1 to 8, of the access pre-checks and of the round-time check.
- `frozen`: the capsule was built once with age randomness; its bytes are kept
and never regenerated. These cases have no `base`.
- `error`, `step`: the expected code and the step of §63 that fails.
Every case reproduces offline: the recorded release stands in for the network.
A reader that implements only steps 1 to 8 can replay every case whose `step` is
at most 8: 31 cases, 13 of them from §64. Steps 1 to 8 are described in "The
checks of steps 1 to 8" below.
### Access pre-checks
After step 8 and before any release request, the reference examines the
credentials offered, and only when the declared `access_policy` is
`time_and_key`. For `time_only` the offered `.dkk` and identities are ignored,
whatever they hold: the §64 case "access_policy=time_only with time_and_key
structure" offers a `.dkk` whose `capsule_digest` is that of the unmutated
capsule, and fails at step 12, not at step 9.
For `time_and_key`, in this order, each failure being at step 9:
1. when a `.dkk` is offered: its `capsule_id` must be the PUBLIC_HEADER's
(`ERR_ACCESS_INVALID`); its critical extensions are checked as those of
PUBLIC_HEADER at step 4 (`ERR_EXTENSION_CRITICAL_UNKNOWN`, then
`ERR_EXTENSION_DATA_INVALID`); and, when it has `verification_metadata`,
its `capsule_digest` must be the SHA-256 of the whole `.dkc` file
(`ERR_ACCESS_INVALID`);
2. at least one credential, a `.dkk` or an identity, must be offered
(`ERR_ACCESS_REQUIRED`).
Identities are not examined before the release: one that is not a recipient
fails at step 13 with `ERR_ACCESS_INVALID`.
Then, for either policy and still at step 9 without any request, a `now`
before the round time of the DateKey is `ERR_RELEASE_UNAVAILABLE`. Only then
is the release requested: `null` is `ERR_RELEASE_UNAVAILABLE` at step 9, and a
release that does not verify fails at step 10.
## The checks of steps 1 to 8
What the reference checks before any network request or secret, in this
order; the first failure ends the flow. `mutations.json` and
`inspect_differential.json` both follow it, with the `default` registry
(Quicknet pinned), no extension known unless a case lists some, and no
secret.
1. **parse DKC1**: fewer than 4 bytes, or bytes 0 to 3 other than `DKC1`:
`ERR_INVALID_MAGIC`. `DKC1` followed by fewer than 12 bytes, a truncated
prelude: `ERR_INTEGRITY`.
2. **prelude**, in this order: byte 4, the framing version, other than 1:
`ERR_UNSUPPORTED_VERSION`; byte 5 (FLAGS) or bytes 6 and 7 (RESERVED) not
zero: `ERR_INVALID_FLAGS`; `PUBLIC_HEADER_LEN` (bytes 8 to 11, big-endian)
outside 1 to 1048576, or `SEALED_CONTROL_LEN` (bytes 12 to 15) outside 1
to 67108864: `ERR_INTEGRITY`. A length of 0 is out of range: no empty
frame holds a valid object.
3. **public header**: fewer than `PUBLIC_HEADER_LEN` bytes after the prelude:
`ERR_INTEGRITY`.
4. **header validation**: the `public_header` decoder of the schema vectors,
with its order of codes; then the profile of the DateKey must be pinned
(`ERR_UNKNOWN_PROFILE`); then the critical extensions: any unknown one is
`ERR_EXTENSION_CRITICAL_UNKNOWN`, and only then a known one with invalid
data is `ERR_EXTENSION_DATA_INVALID`. With no extension known, every
`critical_extensions` array fails.
5. **sealed control structure**: fewer than `SEALED_CONTROL_LEN` bytes:
`ERR_INTEGRITY`. Then the age header at the start of SEALED_CONTROL,
parsed within its `SEALED_CONTROL_LEN` bytes as described below: a header
that does not parse is `ERR_INTEGRITY`; one that parses with a number of
stanzas other than one, or a stanza type other than `tlock`, is
`ERR_POLICY_STRUCTURE_MISMATCH`. The rest of SEALED_CONTROL, and the
arguments and body of the stanza, are not examined here.
6. **payload structure**: the age header of PAYLOAD_AGE, which starts at
offset 16 + `PUBLIC_HEADER_LEN` + `SEALED_CONTROL_LEN` and runs to the end
of the file, with the same rules and `X25519` as the one stanza type. Only
the header is read.
7. **condition**: the round time of the DateKey,
`genesis_time + (round − 1) × period`, must be at most 253402300799
(9999-12-31T23:59:59Z), or the result is `ERR_DATEKEY_INVALID`. For
Quicknet the last valid round is 83903165811, as in `dk1.json`; a `dk1_`
round above it, up to 2^53 − 1, passes step 4 and fails here.
8. **tlock stanza**, in this order: the one stanza of OUTER_TIME_AGE must have
exactly two arguments (`ERR_POLICY_STRUCTURE_MISMATCH`); the first must be
exactly the DateKey round in decimal, without sign or leading zeros, so
`01000`, `1000a` and `-1` are `ERR_ROUND_MISMATCH`; the second must be
exactly the pinned profile's `chain_hash` in lowercase hex
(`ERR_PROFILE_MISMATCH`). The arguments are compared as strings, never
parsed.
### Age headers at steps 5 and 6
The reference parses an age header with `filippo.io/age` v1.3.2, which
follows the age v1 format:
```
header = intro 1*stanza footer
intro = "age-encryption.org/v1" LF
stanza = "->" 1*(SP arg) LF body ; the first arg is the stanza type
arg = 1*VCHAR ; bytes 0x21 to 0x7E
body = *full-line final-line
full-line = 64base64char LF ; exactly 48 bytes
final-line = *63base64char LF ; fewer than 48 bytes, maybe none
footer = "---" SP 43base64char LF ; the 32-byte header MAC
```
- Base64 is the standard alphabet without padding, decoded strictly: `=`, a
length that no unpadded encoding has, and non-zero trailing bits are
malformed. Lines end with LF only: a CR belongs to the line and makes it
malformed.
- Arguments are separated by exactly one space: two spaces, or a space at
the end of the line, are malformed. So is a stanza line that is not `->`
followed by at least the type, and a body that meets a line starting with
`->` or `---` before its final line.
- Parser limits: at least 1 and at most 1024 stanzas; at most 128 arguments
after the type; at most 2 MiB (2097152 bytes) from the intro to the footer.
- The header MAC is not verified: steps 5 and 6 use no key.
A header that breaks any of these rules, a header without stanzas included,
is `ERR_INTEGRITY`. Only a header that parses reaches the stanza count and
types of steps 5 and 6 and the arguments of step 8, whose failures are
`ERR_POLICY_STRUCTURE_MISMATCH`, `ERR_ROUND_MISMATCH` or
`ERR_PROFILE_MISMATCH`.
## `vectors/inspect_differential.json`
A differential corpus of the pre-unlock checks: 1825 deterministic mutations of
the five official `.dkc` fixtures, with the verdict of steps 1 to 8 of §63 as
the reference computes it (`capsule.Inspect` with the `default` registry, no
extension known, no network, no secret), by the rules of "The checks of steps
1 to 8" above. The file repeats the format below in its `format` field.
```json
{
"seed": 20260925,
"bases": [ { "file": "time_only.dkc", "sha256": "99e9…" } ],
"mutations": [
{"base":0,"kind":"flip","edits":[[121,1,"b3"]],"result":"ERR_NON_CANONICAL_CBOR","step":4},
{"base":0,"kind":"flip","edits":[[725,1,"4d"]],"result":"ok"}
]
}
```
- `bases`: the fixtures, with the SHA-256 of their exact bytes. `base` in a
mutation is an index into this list.
- `edits`: see "Edited files".
- `result`: `ok` when steps 1 to 8 pass, or the error code; `step` is the step
that failed, absent when `ok`.
- `kind` names the generator and is informative: `flip` (one bit), `byte` (one
byte replaced), `truncate`, `insert` and `delete` (one to four bytes),
`length` (PUBLIC_HEADER_LEN and SEALED_CONTROL_LEN), `header` (PUBLIC_HEADER
re-encoded with one CBOR-aware change: a key removed, added or retyped, the
type tag, version, `capsule_id`, DateKey or `access_policy` changed, an
extension array added, a head not in its shortest form, keys out of order or
repeated, the whole item tagged, wrapped, made indefinite, truncated or
followed by bytes), `datekey` (the DateKey string alone), `age` (the age
header of SEALED_CONTROL or PAYLOAD_AGE edited: intro line, stanza type,
arguments, stanzas added or removed, body lines, MAC line, line endings).
Most `header`, `datekey` and SEALED_CONTROL `age` mutations also rewrite the
prelude lengths to match; some keep the old ones on purpose.
- `seed` seeds the generator of the reference and is informative too: every
mutation is stored explicitly.
## `fixtures/<name>.inspect.json`
For each official `.dkc`, the exact bytes that `datekeys inspect -json -in
<name>.dkc` prints when run in `testdata/fixtures`: JSON indented with two
spaces, fields in this order, and a final newline. The pre-unlock checks use
the `default` registry.
| Field | Content |
|---|---|
| `file` | the `-in` argument, `<name>.dkc` |
| `capsule_id` | hex, once step 4 has decoded the header |
| `datekey`, `profile`, `round` | the canonical `dk1_` string, its profile and round |
| `unlock_at` | the round time of the DateKey, RFC 3339 in UTC, once step 7 passes |
| `access_policy` | `time_only` or `time_and_key` |
| `valid` | true when steps 1 to 8 pass |
| `error` | the code of the failure, absent when valid |
| `checks` | one entry per step run: `step`, `name`, `ok`, `detail` (free text) and, for a failed step, `error` |
`detail` is informative text of the reference; a second implementation compares
at least `step`, `name`, `ok` and `error`, and every other field.
## Existing vectors and fixtures
- `vectors/profile_quicknet.json`: the Quicknet profile fields,
`canonical_cbor` (hex) and `profile_hash`.
- `vectors/quicknet_rounds.json`: `vectors` of `requested` instants (RFC 3339
with nanoseconds) and the resolved `round` and `effective` time, or `error`.
- `vectors/dk1.json`: valid `vectors` with `network`, `round`,
`canonical_json`, `base64url` and `dk1`; invalid ones with `input` and the
`error` code (`accepted` would mean the input decodes).
- `fixtures/<name>.json`: for each `.dkc`, its SHA-256, the release that opens
it, the hex of the prelude, PUBLIC_HEADER and CONTROL_CBOR, the DateKey,
`capsule_id`, `header_binding`, `payload_identity` (I_PAYLOAD, a test
secret), the visible stanzas of each age file, the identities or `.dkk` that
open it, the exact extension data, and the result of every step of §63.
- `fixtures/<name>.dkk.json`: for each `.dkk`, its SHA-256, `credential_id`,
`capsule_id`, `access_type`, `access_material` (a test secret),
`capsule_digest`, extensions and the capsule it opens.

@ -0,0 +1,60 @@
{
"file": "empty_payload.dkc",
"capsule_id": "ab10174561a9a19a6d9dc9ab1ef59c66",
"datekey": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMX0",
"profile": "datekeys:quicknet:v1",
"round": 1001,
"unlock_at": "2023-08-23T15:59:27Z",
"access_policy": "time_only",
"valid": true,
"checks": [
{
"step": 1,
"name": "parse DKC1",
"ok": true,
"detail": "magic DKC1"
},
{
"step": 2,
"name": "prelude",
"ok": true,
"detail": "DKC1 v1, PUBLIC_HEADER_LEN=121, SEALED_CONTROL_LEN=446"
},
{
"step": 3,
"name": "public header",
"ok": true,
"detail": "121 bytes"
},
{
"step": 4,
"name": "header validation",
"ok": true,
"detail": "capsule_id=ab10174561a9a19a6d9dc9ab1ef59c66 datekey=dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMX0 policy=time_only profile=datekeys:quicknet:v1"
},
{
"step": 5,
"name": "sealed control structure",
"ok": true,
"detail": "one tlock stanza"
},
{
"step": 6,
"name": "payload structure",
"ok": true,
"detail": "one X25519 stanza"
},
{
"step": 7,
"name": "condition",
"ok": true,
"detail": "round 1001, unlock at 2023-08-23T15:59:27Z"
},
{
"step": 8,
"name": "tlock stanza",
"ok": true,
"detail": "round 1001, chain 52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
}
]
}

@ -0,0 +1,60 @@
{
"file": "time_and_key_portable.dkc",
"capsule_id": "448e134a13457c319cab7fceaf7ffa1f",
"datekey": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMH0",
"profile": "datekeys:quicknet:v1",
"round": 1000,
"unlock_at": "2023-08-23T15:59:24Z",
"access_policy": "time_and_key",
"valid": true,
"checks": [
{
"step": 1,
"name": "parse DKC1",
"ok": true,
"detail": "magic DKC1"
},
{
"step": 2,
"name": "prelude",
"ok": true,
"detail": "DKC1 v1, PUBLIC_HEADER_LEN=121, SEALED_CONTROL_LEN=646"
},
{
"step": 3,
"name": "public header",
"ok": true,
"detail": "121 bytes"
},
{
"step": 4,
"name": "header validation",
"ok": true,
"detail": "capsule_id=448e134a13457c319cab7fceaf7ffa1f datekey=dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMH0 policy=time_and_key profile=datekeys:quicknet:v1"
},
{
"step": 5,
"name": "sealed control structure",
"ok": true,
"detail": "one tlock stanza"
},
{
"step": 6,
"name": "payload structure",
"ok": true,
"detail": "one X25519 stanza"
},
{
"step": 7,
"name": "condition",
"ok": true,
"detail": "round 1000, unlock at 2023-08-23T15:59:24Z"
},
{
"step": 8,
"name": "tlock stanza",
"ok": true,
"detail": "round 1000, chain 52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
}
]
}

@ -0,0 +1,60 @@
{
"file": "time_and_key_recipients.dkc",
"capsule_id": "c75dfc8e9c576d1369910664df93693a",
"datekey": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMX0",
"profile": "datekeys:quicknet:v1",
"round": 1001,
"unlock_at": "2023-08-23T15:59:27Z",
"access_policy": "time_and_key",
"valid": true,
"checks": [
{
"step": 1,
"name": "parse DKC1",
"ok": true,
"detail": "magic DKC1"
},
{
"step": 2,
"name": "prelude",
"ok": true,
"detail": "DKC1 v1, PUBLIC_HEADER_LEN=121, SEALED_CONTROL_LEN=842"
},
{
"step": 3,
"name": "public header",
"ok": true,
"detail": "121 bytes"
},
{
"step": 4,
"name": "header validation",
"ok": true,
"detail": "capsule_id=c75dfc8e9c576d1369910664df93693a datekey=dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMX0 policy=time_and_key profile=datekeys:quicknet:v1"
},
{
"step": 5,
"name": "sealed control structure",
"ok": true,
"detail": "one tlock stanza"
},
{
"step": 6,
"name": "payload structure",
"ok": true,
"detail": "one X25519 stanza"
},
{
"step": 7,
"name": "condition",
"ok": true,
"detail": "round 1001, unlock at 2023-08-23T15:59:27Z"
},
{
"step": 8,
"name": "tlock stanza",
"ok": true,
"detail": "round 1001, chain 52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
}
]
}

@ -0,0 +1,60 @@
{
"file": "time_only.dkc",
"capsule_id": "ad4d676812b134ff8a3de263f77018b4",
"datekey": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMH0",
"profile": "datekeys:quicknet:v1",
"round": 1000,
"unlock_at": "2023-08-23T15:59:24Z",
"access_policy": "time_only",
"valid": true,
"checks": [
{
"step": 1,
"name": "parse DKC1",
"ok": true,
"detail": "magic DKC1"
},
{
"step": 2,
"name": "prelude",
"ok": true,
"detail": "DKC1 v1, PUBLIC_HEADER_LEN=121, SEALED_CONTROL_LEN=446"
},
{
"step": 3,
"name": "public header",
"ok": true,
"detail": "121 bytes"
},
{
"step": 4,
"name": "header validation",
"ok": true,
"detail": "capsule_id=ad4d676812b134ff8a3de263f77018b4 datekey=dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMH0 policy=time_only profile=datekeys:quicknet:v1"
},
{
"step": 5,
"name": "sealed control structure",
"ok": true,
"detail": "one tlock stanza"
},
{
"step": 6,
"name": "payload structure",
"ok": true,
"detail": "one X25519 stanza"
},
{
"step": 7,
"name": "condition",
"ok": true,
"detail": "round 1000, unlock at 2023-08-23T15:59:24Z"
},
{
"step": 8,
"name": "tlock stanza",
"ok": true,
"detail": "round 1000, chain 52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
}
]
}

@ -0,0 +1,60 @@
{
"file": "time_only_extensions.dkc",
"capsule_id": "4286085c21ca34d1a71e649326a4a0f6",
"datekey": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MjAwMH0",
"profile": "datekeys:quicknet:v1",
"round": 2000,
"unlock_at": "2023-08-23T16:49:24Z",
"access_policy": "time_only",
"valid": true,
"checks": [
{
"step": 1,
"name": "parse DKC1",
"ok": true,
"detail": "magic DKC1"
},
{
"step": 2,
"name": "prelude",
"ok": true,
"detail": "DKC1 v1, PUBLIC_HEADER_LEN=159, SEALED_CONTROL_LEN=482"
},
{
"step": 3,
"name": "public header",
"ok": true,
"detail": "159 bytes"
},
{
"step": 4,
"name": "header validation",
"ok": true,
"detail": "capsule_id=4286085c21ca34d1a71e649326a4a0f6 datekey=dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MjAwMH0 policy=time_only profile=datekeys:quicknet:v1"
},
{
"step": 5,
"name": "sealed control structure",
"ok": true,
"detail": "one tlock stanza"
},
{
"step": 6,
"name": "payload structure",
"ok": true,
"detail": "one X25519 stanza"
},
{
"step": 7,
"name": "condition",
"ok": true,
"detail": "round 2000, unlock at 2023-08-23T16:49:24Z"
},
{
"step": 8,
"name": "tlock stanza",
"ok": true,
"detail": "round 2000, chain 52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
}
]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save

Powered by TurnKey Linux.