WIP: align with DateKeys v0.8.2 at 3820066 (interrupted, tests failing)

testdata synced from datekeys-go 3820066 (verified with sync-testdata
check --against): cbor.json, mutations.json, inspect_differential.json,
the inspect goldens, testdata/README.md and the three new dk1.json
vectors. The alignment of src/lib/dkc and of the vector harness was cut
off by a usage limit halfway through a refactor: tests and typecheck
fail. Kept on this branch so that main stays green; it is finished here
and merged when every check passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
dev 2 weeks ago
parent ee2dba93cc
commit 3305bbb1a8

@ -1,22 +1,15 @@
// The DateKeys Access Key, the portable .dkk credential (spec §38, §40-§44),
// as the Go package accesskey at afb44a3.
// as the Go package accesskey at 3820066.
//
// A .dkk is a sensitive capability (spec §7.4): its X25519 identity is stored
// as 32 raw bytes in `material`. Nothing here prints it.
import { checkSchema, type Decoder, Encoder, MAX_SAFE_UINT, cborError, unmarshal } from './cbor.ts';
import { concatBytes, copyBytes, goQuote, utf8Length } from './bytes.ts';
import { checkSchema, type Decoder, Encoder, unmarshal } from './cbor.ts';
import { DateKeysError, withContext } from './errors.ts';
import {
checkDisjoint,
decodeExtensions,
decodeWireArray,
encodeExtensions,
encodeWireArray,
type Extension,
type ExtensionWire,
} from './extension.ts';
import { concatBytes, copyBytes, goQuote } from './bytes.ts';
import { canonicalExtensions, checkDisjoint, decodeArray, type Extension } from './extension.ts';
import { dkkPreludeBytes, MAX_DKK_BODY_LEN, splitAccessKey } from './framing.ts';
import { encodeExtensionArrays, fieldOf, presence, requireKeys } from './schema.ts';
export const ACCESS_KEY_TYPE_TAG = 'datekeys-access-key';
export const ACCESS_KEY_VERSION = 1;
@ -65,74 +58,101 @@ function validateMaterial(type: string, material: Uint8Array): void {
}
}
// BODY_CBOR as it is encoded: keys 2 to 8, keys 0 and 1 being the constants
// ACCESS_KEY_TYPE_TAG and ACCESS_KEY_VERSION.
interface BodyWire {
typeTag: string;
version: number;
credentialId: Uint8Array;
capsuleId: Uint8Array;
accessType: string;
/** SECRET. */
material: Uint8Array;
/** undefined when absent; an empty digest for the empty map. */
capsuleDigest: Uint8Array | undefined;
critical: ExtensionWire[] | undefined;
noncritical: ExtensionWire[] | undefined;
/** capsule_digest, the only key of verification_metadata (key 6); undefined when key 6 is omitted. */
digest: Uint8Array | undefined;
critical: Extension[] | undefined;
noncritical: Extension[] | undefined;
}
// Reads BODY_CBOR with every CDDL rule whose violation is
// ERR_NON_CANONICAL_CBOR (layer 3 of spec §69.1); access_type and
// access_material, which have a code of their own (spec §57), are checked
// afterwards. On failure it wipes the copy of the material it read.
function decodeWire(d: Decoder): BodyWire {
d.map(9);
d.expectKey(0);
const typeTag = d.text(MAX_SAFE_UINT);
d.expectKey(1);
const version = d.uint();
d.expectKey(2);
const credentialId = d.bstr(0, MAX_SAFE_UINT);
d.expectKey(3);
const capsuleId = d.bstr(0, MAX_SAFE_UINT);
d.expectKey(4);
const accessType = d.text(MAX_SAFE_UINT);
d.expectKey(5);
const material = d.bstr(0, MAX_SAFE_UINT);
// From here on, a failure wipes the copy of the secret it read.
const w: BodyWire = {
credentialId: new Uint8Array(0),
capsuleId: new Uint8Array(0),
accessType: '',
material: new Uint8Array(0),
digest: undefined,
critical: undefined,
noncritical: undefined,
};
try {
let capsuleDigest: Uint8Array | undefined;
let critical: ExtensionWire[] | undefined;
let noncritical: ExtensionWire[] | undefined;
while (d.pairsLeft() > 0) {
const pairs = d.map(9);
const seen = new Set<number>();
for (let i = 0; i < pairs; i++) {
const k = d.key();
if (k === 6) {
// An empty map decodes, as in the reference, and is rejected
// afterwards for its missing digest; h'' is not the canonical form of
// anything.
const n = d.map(1);
if (n === 1) {
d.expectKey(0);
capsuleDigest = d.bstr(1, MAX_SAFE_UINT);
} else {
capsuleDigest = new Uint8Array(0);
}
d.endMap();
} else if (k === 7) {
critical = decodeWireArray(d);
} else if (k === 8) {
noncritical = decodeWireArray(d);
} else {
throw cborError(`unknown .dkk BODY_CBOR key ${k}`);
const field = fieldOf(k);
switch (k) {
case 0:
field(() => d.text(utf8Length(ACCESS_KEY_TYPE_TAG)));
break;
case 1:
field(() => d.uint(ACCESS_KEY_VERSION));
break;
case 2:
w.credentialId = field(() => d.bstr(ID_SIZE, ID_SIZE));
break;
case 3:
w.capsuleId = field(() => d.bstr(ID_SIZE, ID_SIZE));
break;
case 4:
w.accessType = field(() => d.text(MAX_DKK_BODY_LEN));
break;
case 5:
w.material = field(() => d.bstr(0, MAX_DKK_BODY_LEN));
break;
case 6:
w.digest = field(() => decodeVerification(d));
break;
case 7:
w.critical = field(() => decodeArray(d));
break;
case 8:
w.noncritical = field(() => decodeArray(d));
break;
default:
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `key ${k} is not defined`);
}
seen.add(Number(k));
}
requireKeys(seen, 6);
d.endMap();
return { typeTag, version, credentialId, capsuleId, accessType, material, capsuleDigest, critical, noncritical };
return w;
} catch (err) {
material.fill(0);
w.material.fill(0);
throw err;
}
}
// 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).
function decodeVerification(d: Decoder): Uint8Array {
const pairs = d.map(1);
if (pairs === 0) throw new DateKeysError('ERR_NON_CANONICAL_CBOR', 'empty verification_metadata; an absent one omits key 6');
const k = d.key();
if (k !== 0) throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `verification_metadata key ${k} is not defined`);
const digest = withContext('capsule_digest', () => d.bstr(DIGEST_SIZE, DIGEST_SIZE));
d.endMap();
return digest;
}
function encodeWire(e: Encoder, w: BodyWire): void {
e.map(6 + (w.capsuleDigest ? 1 : 0) + (w.critical ? 1 : 0) + (w.noncritical ? 1 : 0));
e.map(6 + (w.digest === undefined ? 0 : 1) + presence(w.critical) + presence(w.noncritical));
e.uint(0);
e.text(w.typeTag);
e.text(ACCESS_KEY_TYPE_TAG);
e.uint(1);
e.uint(w.version);
e.uint(ACCESS_KEY_VERSION);
e.uint(2);
e.bstr(w.credentialId);
e.uint(3);
@ -141,30 +161,23 @@ function encodeWire(e: Encoder, w: BodyWire): void {
e.text(w.accessType);
e.uint(5);
e.bstr(w.material);
if (w.capsuleDigest) {
if (w.digest !== undefined) {
e.uint(6);
const has = w.capsuleDigest.length > 0;
e.map(has ? 1 : 0);
if (has) {
e.uint(0);
e.bstr(w.capsuleDigest);
}
}
if (w.critical) {
e.uint(7);
encodeWireArray(e, w.critical);
}
if (w.noncritical) {
e.uint(8);
encodeWireArray(e, w.noncritical);
e.map(1);
e.uint(0);
e.bstr(w.digest);
}
encodeExtensionArrays(e, 7, w.critical, w.noncritical);
}
/**
* Validates and decodes BODY_CBOR, bounded by the DKK BODY limit of spec §57
* whatever it was read from. The checks run in the order of the reference:
* limit, schema head, canonical CBOR, identifier sizes, verification
* metadata, extension arrays and, last, the access material.
* whatever it was read from, in the layers of spec §69.1: the limit
* (ERR_INTEGRITY); the type tag and the schema version; canonical CBOR and
* the CDDL, verification metadata and extension arrays included, and no
* identifier in both arrays (ERR_NON_CANONICAL_CBOR); and only then
* access_type and access_material (ERR_ACCESS_INVALID). The critical
* extensions are checked by the consumer (spec §63 step 9.a).
*/
export function decodeAccessKeyBody(body: Uint8Array): AccessKey {
if (body.length > MAX_DKK_BODY_LEN) {
@ -176,17 +189,8 @@ export function decodeAccessKeyBody(body: Uint8Array): AccessKey {
// decoded value is rejected, and by the finally block below otherwise.
const w = withContext('accesskey', () => unmarshal(body, decodeWire, encodeWire, (v) => v.material.fill(0)));
try {
if (w.credentialId.length !== ID_SIZE || w.capsuleId.length !== ID_SIZE) {
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `accesskey: credential_id and capsule_id must be ${ID_SIZE} bytes`);
}
if (w.capsuleDigest !== undefined && w.capsuleDigest.length !== DIGEST_SIZE) {
throw new DateKeysError(
'ERR_NON_CANONICAL_CBOR',
`accesskey: verification_metadata must hold a ${DIGEST_SIZE}-byte capsule_digest`,
);
}
const critical = withContext('accesskey: critical_extensions', () => decodeExtensions(w.critical ?? []));
const noncritical = withContext('accesskey: noncritical_extensions', () => decodeExtensions(w.noncritical ?? []));
const critical = w.critical ?? [];
const noncritical = w.noncritical ?? [];
withContext('accesskey', () => checkDisjoint(critical, noncritical));
validateMaterial(w.accessType, w.material);
return {
@ -194,7 +198,7 @@ export function decodeAccessKeyBody(body: Uint8Array): AccessKey {
capsuleId: w.capsuleId,
type: w.accessType,
material: copyBytes(w.material),
verification: w.capsuleDigest === undefined ? undefined : { capsuleDigest: w.capsuleDigest },
verification: w.digest === undefined ? undefined : { capsuleDigest: w.digest },
critical,
noncritical,
};
@ -224,18 +228,16 @@ export function marshalAccessKeyBody(k: AccessKey): Uint8Array {
// An empty map is not a canonical representation of absence (spec §43).
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `accesskey: capsule_digest must be ${DIGEST_SIZE} bytes`);
}
const critical = encodeExtensions(k.critical);
const noncritical = encodeExtensions(k.noncritical);
const critical = canonicalExtensions(k.critical);
const noncritical = canonicalExtensions(k.noncritical);
checkDisjoint(k.critical, k.noncritical);
const e = new Encoder();
encodeWire(e, {
typeTag: ACCESS_KEY_TYPE_TAG,
version: ACCESS_KEY_VERSION,
credentialId: k.credentialId,
capsuleId: k.capsuleId,
accessType: k.type,
material: k.material,
capsuleDigest: k.verification?.capsuleDigest,
digest: k.verification?.capsuleDigest,
critical,
noncritical,
});

@ -2,9 +2,10 @@
// 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, shortest-form integers and lengths, definite lengths only
// and valid UTF-8 text.
// and valid UTF-8 text. It is the hand-written codec of the Go reference
// (package codec at 3820066), with the same reads, the same checks in the
// same order and the same error texts.
//
// The design is the one of plan §4 and §5:
// - Encoder accumulates bytes; the first error is sticky and out() throws it.
// - Decoder is a strict cursor: every form outside the profile is rejected
// while reading, and lengths are checked against the remaining input
@ -12,79 +13,74 @@
// - unmarshal decodes, re-encodes the decoded value and requires the exact
// input bytes back, an independent defense of canonicality.
// - peek and checkSchema read the type tag (key 0) and the schema version
// (key 1) before strict decoding (spec §70).
// (key 1) from the start of an object, before its strict decoding: layer
// 2 of the error precedence of spec §69.1.
// - walk is a bounded generic reader for vectors and informative views; it
// never decides the validity of a protocol object.
//
// Integers are JavaScript numbers. Every unsigned integer of the protocol is
// at most 2^53-1 (spec §58), so the decoder rejects anything larger, with one
// exception: Decoder.wideUint reads the two fields where the reference
// implementation decodes any uint64 and checks the range only after a check
// with another error code (PUBLIC_HEADER access_policy and extension_version,
// see WideUint).
//
// Every failure is ERR_NON_CANONICAL_CBOR, except the schema version check of
// checkSchema, which is ERR_UNSUPPORTED_VERSION.
import { copyBytes, decodeUtf8, equalBytes, goQuote, readUint32BE, toHex, utf8Bytes } from './bytes.ts';
import { copyBytes, decodeUtf8, equalBytes, goQuote, readUint32BE, utf8Bytes } from './bytes.ts';
import { DateKeysError } from './errors.ts';
/** The largest unsigned integer of the protocol, 2^53-1 (spec §58). */
/** The largest unsigned integer of every schema of the protocol, 2^53-1 (spec §58). */
export const MAX_SAFE_UINT = Number.MAX_SAFE_INTEGER;
/** The largest unsigned integer of CBOR, 2^64-1, which walk accepts. */
export const MAX_UINT64 = 2n ** 64n - 1n;
/**
* Limits of the decoder used by the reference implementation at afb44a3
* (codec.MaxNestedLevels, MaxArrayElements, MaxMapPairs). peek applies them
* as the reference does; the schema decoders bound extension arrays by
* MAX_ARRAY_ELEMENTS before the 64-extension rule of spec §54.
* The largest type tag peek reads, in bytes. Every type tag of V1 is at most
* 25 bytes; an implementation limit of the reference (spec §74).
*/
export const MAX_NESTED_LEVELS = 16;
export const MAX_ARRAY_ELEMENTS = 65536;
export const MAX_MAP_PAIRS = 65536;
export const MAX_TYPE_TAG_LEN = 64;
/**
* An unsigned integer as the decoder reads it: a number up to 2^53-1, a
* bigint above. The two compare exactly with <, <=, > and >=.
*/
export type Uint = number | bigint;
// Major types of the profile (spec §58).
const MAJOR_UINT = 0;
const MAJOR_BYTES = 2;
const MAJOR_TEXT = 3;
const MAJOR_ARRAY = 4;
const MAJOR_MAP = 5;
const MAJOR_NAMES = [
'unsigned integer',
'negative integer',
'byte string',
'text string',
'array',
'map',
'tag',
'float or simple value',
'an unsigned integer',
'a negative integer',
'a byte string',
'a text string',
'an array',
'a map',
'a tag',
'a float or simple value',
] as const;
const TWO_POW_32 = 2 ** 32;
/** The largest high 32-bit half of an integer at most 2^53-1. */
const MAX_HI32 = 0x1fffff;
/**
* An unsigned integer of up to 64 bits, read by Decoder.wideUint. `value` is
* the integer, or Infinity above 2^53-1; `arg` then holds the 8 bytes of the
* argument, so that the value re-encodes to the same bytes and an error
* message can show it in decimal. Never a protocol value by itself: the
* schema check rejects anything above its own bound.
*/
export interface WideUint {
readonly value: number;
readonly arg?: Uint8Array;
}
/** The exact decimal text of `w`, as Go's %d of a uint64. */
export function wideUintText(w: WideUint): string {
return w.arg === undefined ? String(w.value) : BigInt(`0x${toHex(w.arg)}`).toString();
}
/** Returns the ERR_NON_CANONICAL_CBOR error with the given detail. */
/** Returns the ERR_NON_CANONICAL_CBOR error "codec: <detail>". */
export function cborError(detail: string): DateKeysError {
return new DateKeysError('ERR_NON_CANONICAL_CBOR', `cbor: ${detail}`);
return new DateKeysError('ERR_NON_CANONICAL_CBOR', `codec: ${detail}`);
}
// Go's %#02x of an initial byte, which is never below 0x10 in a message.
const initialByte = (b: number): string => `0x${b.toString(16).padStart(2, '0')}`;
// ---------------------------------------------------------------------------
// Encoder
/**
* Encoder writes the canonical encoding of the profile. The first invalid
* call fixes an error; later calls do nothing and out() throws that error.
* Encoder writes the deterministic encoding of data items of the profile.
* The first invalid call fixes an error; later calls do nothing and out()
* throws that error. It does not know the schema: the caller writes the map
* keys in ascending order and as many entries as it announced, and the
* decoder of the schema checks the output.
*/
export class Encoder {
#buf = new Uint8Array(256);
@ -96,41 +92,33 @@ export class Encoder {
return this.#err;
}
/**
* Records `err` as the error of the encoding unless one is recorded
* already. 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.
*/
fail(err: DateKeysError): void {
this.#err ??= err;
}
/** Map header of `pairs` entries; the caller writes the keys in order. */
map(pairs: number): void {
this.#head(5, pairs);
this.#head(MAJOR_MAP, pairs);
}
/** Array header of `items` elements. */
array(items: number): void {
this.#head(4, items);
this.#head(MAJOR_ARRAY, items);
}
/** Unsigned integer 0..2^53-1. */
uint(v: number): void {
this.#head(0, v);
}
/**
* An integer read by Decoder.wideUint: its value when at most 2^53-1,
* otherwise its original 8-byte argument.
*/
wideUint(w: WideUint): void {
if (w.arg === undefined) {
this.uint(w.value);
return;
}
if (w.value !== Infinity || w.arg.length !== 8 || readUint32BE(w.arg, 0) <= MAX_HI32) {
this.#fail('malformed integer above 2^53-1');
return;
}
this.#write(Uint8Array.of(27));
this.#write(w.arg);
this.#head(MAJOR_UINT, v);
}
/** Byte string. */
bstr(b: Uint8Array): void {
this.#head(2, b.length);
this.#head(MAJOR_BYTES, b.length);
this.#write(b);
}
@ -140,11 +128,11 @@ export class Encoder {
*/
text(s: string): void {
if (!s.isWellFormed()) {
this.#fail('text is not well-formed Unicode (lone surrogate)');
this.fail(cborError('text string is not well-formed Unicode (lone surrogate)'));
return;
}
const b = utf8Bytes(s);
this.#head(3, b.length);
this.#head(MAJOR_TEXT, b.length);
this.#write(b);
}
@ -160,14 +148,10 @@ export class Encoder {
this.#len = 0;
}
#fail(detail: string): void {
this.#err ??= cborError(detail);
}
#head(major: number, v: number): void {
if (this.#err !== undefined) return;
if (!Number.isSafeInteger(v) || v < 0) {
this.#fail(`${v} is not an unsigned integer in 0..2^53-1`);
this.fail(cborError(`${v} is not an unsigned integer in 0..2^53-1`));
return;
}
const mt = major << 5;
@ -214,186 +198,221 @@ export class Encoder {
// ---------------------------------------------------------------------------
// Decoder
interface MapFrame {
remaining: number;
lastKey: number;
interface OpenMap {
/** Entries not read yet. */
left: number;
/** Last key read. */
last: Uint;
/** At least one key was read. */
started: boolean;
}
/**
* Decoder is a strict cursor over one encoded item of the profile. Each map
* opened with map() records its last key and requires strictly ascending
* unsigned integer keys.
* Decoder is a strict cursor over the encoding of one data item of the
* profile, as the Go codec.Decoder. Each method reads one data item, or one
* head, and throws ERR_NON_CANONICAL_CBOR ("codec: offset N: …") for 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.
*/
export class Decoder {
readonly #buf: Uint8Array;
#pos = 0;
readonly #maps: MapFrame[] = [];
readonly #in: Uint8Array;
#off = 0;
readonly #maps: OpenMap[] = [];
constructor(input: Uint8Array) {
this.#buf = input;
this.#in = input;
}
/** Bytes not read yet. */
get remaining(): number {
return this.#buf.length - this.#pos;
return this.#in.length - this.#off;
}
/** Major type of the next item, without consuming it. */
peekMajor(): number {
if (this.#pos >= this.#buf.length) throw cborError('unexpected end of input');
return this.#buf[this.#pos]! >> 5;
/**
* Major type of the next data item, or 0 (unsigned integer) at the end of
* the input, where reading it reports the truncation.
*/
next(): number {
return this.#off >= this.#in.length ? MAJOR_UINT : this.#in[this.#off]! >> 5;
}
/** Map header of at most `max` pairs; returns the number of pairs. */
/**
* 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().
*/
map(max: number): number {
const n = this.#head(5);
if (n > max) throw cborError(`map of ${n} pairs exceeds ${max}`);
this.#maps.push({ remaining: n, lastKey: -1 });
return n;
}
/** Pairs of the innermost open map not read yet. */
pairsLeft(): number {
return this.#frame().remaining;
const n = this.#expect(MAJOR_MAP);
if (n > max) throw this.fail(`map of ${n} entries, at most ${max}`);
if (n > Math.floor(this.remaining / 2)) throw this.fail(`truncated input: map of ${n} entries`);
this.#maps.push({ left: Number(n), last: 0, started: false });
return Number(n);
}
/** Next key of the innermost map, strictly greater than the previous one. */
key(): number {
const f = this.#frame();
if (f.remaining === 0) throw cborError('no map entry left');
const k = this.#head(0, 'unsigned integer map key');
if (k <= f.lastKey) throw cborError(k === f.lastKey ? `duplicate map key ${k}` : `map key ${k} out of order`);
f.lastKey = k;
f.remaining--;
/**
* Reads the key of the next entry of the innermost open map: an unsigned
* integer greater than the previous key of that map.
*/
key(): Uint {
const m = this.#maps.at(-1);
if (m === undefined) throw this.fail('map key outside a map');
if (m.left === 0) throw this.fail('map key after the last entry');
const start = this.#off;
const k = this.#expect(MAJOR_UINT);
if (m.started && k <= m.last) {
this.#off = start;
throw this.fail(`map key ${k} after key ${m.last}: keys must be strictly ascending`);
}
m.left--;
m.last = k;
m.started = true;
return k;
}
/** Reads the next key and requires it to be `want`. */
expectKey(want: number): void {
const k = this.key();
if (k !== want) throw cborError(`map key ${k} where key ${want} was expected`);
}
/** Closes the innermost map, which must have no entries left. */
/** Closes the innermost open map, all of whose entries must have been read. */
endMap(): void {
const f = this.#frame();
if (f.remaining !== 0) throw cborError(`${f.remaining} unexpected map entries`);
const m = this.#maps.at(-1);
if (m === undefined) throw this.fail('end of a map outside a map');
if (m.left !== 0) throw this.fail(`${m.left} map entries not read`);
this.#maps.pop();
}
/** Array header of at most `max` items; returns the number of items. */
/** Reads the head of an array of at most `max` items and returns the number of items. */
array(max: number): number {
const n = this.#head(4);
if (n > max) throw cborError(`array of ${n} items exceeds ${max}`);
return n;
const n = this.#expect(MAJOR_ARRAY);
if (n > max) throw this.fail(`array of ${n} items, at most ${max}`);
if (n > this.remaining) throw this.fail(`truncated input: array of ${n} items`);
return Number(n);
}
/** Unsigned integer at most `max` (itself at most 2^53-1). */
/** Reads an unsigned integer of at most `max`, itself at most 2^53-1. */
uint(max: number = MAX_SAFE_UINT): number {
const v = this.#head(0);
if (v > max) throw cborError(`integer ${v} exceeds ${max}`);
return v;
return this.uint64(max) as number;
}
/**
* Unsigned integer of up to 64 bits, for the fields where the reference
* implementation decodes a uint64 and checks its range only later, after a
* check with another error code: a value above 2^53-1 is returned as
* Infinity with its argument bytes instead of failing here. The caller must
* apply its own bound. Every other rule is the one of uint().
*/
wideUint(): WideUint {
const p = this.#pos;
const value = this.#head(0, MAJOR_NAMES[0], true);
return value === Infinity ? { value, arg: copyBytes(this.#buf.subarray(p + 1, p + 9)) } : { value };
/** Reads an unsigned integer of at most `max` (2^64-1 by default). */
uint64(max: Uint = MAX_UINT64): Uint {
const v = this.#expect(MAJOR_UINT);
if (v > max) throw this.fail(`unsigned integer ${v} above ${max}`);
return v;
}
/** Byte string of `min` to `max` bytes, copied. */
/** Reads a byte string of `min` to `max` bytes and returns a copy of its content. */
bstr(min: number, max: number): Uint8Array {
const n = this.#head(2);
this.#checkLength(n, min, max, 'byte string');
const out = copyBytes(this.#buf.subarray(this.#pos, this.#pos + n));
this.#pos += n;
return out;
return copyBytes(this.#content(MAJOR_BYTES, min, max));
}
/** Text string of at most `max` UTF-8 bytes. */
/** Reads a text string of at most `max` bytes of valid UTF-8. */
text(max: number): string {
return this.textUtf8(max).text;
}
/** Text string of at most `max` UTF-8 bytes, with its exact UTF-8 bytes. */
/** text(), with a copy of the exact UTF-8 bytes of the string. */
textUtf8(max: number): { text: string; utf8: Uint8Array } {
const n = this.#head(3);
this.#checkLength(n, 0, max, 'text string');
const utf8 = copyBytes(this.#buf.subarray(this.#pos, this.#pos + n));
const text = decodeUtf8(utf8);
if (text === undefined) throw cborError('text string is not valid UTF-8');
this.#pos += n;
return { text, utf8 };
const start = this.#off;
const b = this.#content(MAJOR_TEXT, 0, max);
const text = decodeUtf8(b);
if (text === undefined) {
this.#off = start;
throw this.fail('text string is not valid UTF-8');
}
return { text, utf8: copyBytes(b) };
}
/** Requires that every map is closed and that no byte is left. */
/** Checks that every map was closed and that no byte follows the data item. */
done(): void {
if (this.#maps.length !== 0) throw cborError('unclosed map');
if (this.#pos !== this.#buf.length) throw cborError(`${this.#buf.length - this.#pos} trailing bytes`);
}
#frame(): MapFrame {
const f = this.#maps.at(-1);
if (f === undefined) throw cborError('no open map');
return f;
if (this.#maps.length !== 0) throw this.fail(`${this.#maps.length} maps not closed`);
if (this.#off !== this.#in.length) throw this.fail(`${this.#in.length - this.#off} trailing bytes`);
}
#checkLength(n: number, min: number, max: number, what: string): void {
if (n > this.remaining) throw cborError(`${what} of ${n} bytes exceeds the remaining input`);
if (n < min || n > max) throw cborError(`${what} of ${n} bytes outside ${min}..${max}`);
}
// Reads the head of the next item, which must have major type `major`, and
// returns its argument: shortest form, definite length, at most 2^53-1, or
// Infinity for a larger argument when `wide` is set.
#head(major: number, what: string = MAJOR_NAMES[major]!, wide = false): number {
const b = this.#buf;
const p = this.#pos;
if (p >= b.length) throw cborError(`unexpected end of input, expected ${what}`);
/**
* The ERR_NON_CANONICAL_CBOR error "codec: offset N: detail" at the current
* offset, for the checks a caller makes on what it read (Go's d.fail).
*/
fail(detail: string): DateKeysError {
return cborError(`offset ${this.#off}: ${detail}`);
}
// 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.
#head(): [number, Uint] {
const b = this.#in;
const p = this.#off;
if (p >= b.length) throw this.fail('truncated input');
const ib = b[p]!;
if (ib >> 5 !== major) throw cborError(`expected ${what}, found ${MAJOR_NAMES[ib >> 5]!}`);
const ai = ib & 0x1f;
if (ai < 24) {
this.#pos = p + 1;
return ai;
const major = ib >> 5;
const info = ib & 0x1f;
if (major === 1 || major === 6 || major === 7) {
throw this.fail(`${MAJOR_NAMES[major]} (initial byte ${initialByte(ib)}) is outside the CBOR profile`);
}
if (ai > 27) throw cborError(ai === 31 ? 'indefinite length' : `reserved additional information ${ai}`);
const n = 1 << (ai - 24);
if (b.length - p - 1 < n) throw cborError(`truncated ${what}`);
let v: number;
if (info < 24) {
this.#off++;
return [major, info];
}
if (info === 31) throw this.fail(`indefinite length (initial byte ${initialByte(ib)})`);
if (info > 27) throw this.fail(`reserved additional information (initial byte ${initialByte(ib)})`);
const n = 1 << (info - 24);
if (this.remaining < 1 + n) throw this.fail('truncated input');
let arg: Uint;
let min: number;
if (n === 1) {
v = b[p + 1]!;
arg = b[p + 1]!;
min = 24;
} else if (n === 2) {
v = (b[p + 1]! << 8) | b[p + 2]!;
arg = (b[p + 1]! << 8) | b[p + 2]!;
min = 0x100;
} else if (n === 4) {
v = readUint32BE(b, p + 1);
arg = readUint32BE(b, p + 1);
min = 0x10000;
} else {
const hi = readUint32BE(b, p + 1);
if (hi <= MAX_HI32) v = hi * TWO_POW_32 + readUint32BE(b, p + 5);
else if (wide) v = Infinity;
else throw cborError(`${what} above 2^53-1`);
const lo = readUint32BE(b, p + 5);
arg = hi <= MAX_HI32 ? hi * TWO_POW_32 + lo : (BigInt(hi) << 32n) | BigInt(lo);
min = TWO_POW_32;
}
if (v < (n === 1 ? 24 : 2 ** (4 * n))) throw cborError(`${what} ${v} not in its shortest form`);
this.#pos = p + 1 + n;
return v;
if (arg < min) throw this.fail(`${arg} is not in its shortest form (initial byte ${initialByte(ib)})`);
this.#off = p + 1 + n;
return [major, arg];
}
// Reads the head of a data item of major type `want`.
#expect(want: number): Uint {
const start = this.#off;
const [major, arg] = this.#head();
if (major !== want) {
this.#off = start;
throw this.fail(`${MAJOR_NAMES[major]!} where ${MAJOR_NAMES[want]!} was expected`);
}
return arg;
}
// Reads a string of major type `want` and returns its content, a view of
// the input. The length is checked against the remaining input and then
// against min and max.
#content(want: number, min: number, max: number): Uint8Array {
const n = this.#expect(want);
if (n > this.remaining) throw this.fail(`truncated input: ${MAJOR_NAMES[want]!} of ${n} bytes`);
if (n < min || n > max) throw this.fail(`${MAJOR_NAMES[want]!} of ${n} bytes outside ${min}..${max}`);
const len = Number(n);
const b = this.#in.subarray(this.#off, this.#off + len);
this.#off += len;
return b;
}
}
/**
* Decodes exactly one item of `input` with `decode`, re-encodes the result
* with `encode` and requires the re-encoding to equal the input byte for
* byte. The re-encoding is wiped on every path, since it may hold secrets.
* When the decoded value is rejected (trailing bytes, or a re-encoding that
* differs), `onReject` runs on it before the error is thrown, so that a value
* holding secrets can be wiped; `decode` wipes what it read when it fails
* itself.
* Decodes exactly one item of `input` with `decode`, requires that the whole
* input was read, re-encodes the result with `encode` and requires the
* re-encoding to equal the input byte for byte. The re-encoding is wiped on
* every path, since it may hold secrets. When the decoded value is rejected
* (trailing bytes, or a re-encoding that differs), `onReject` runs on it
* before the error is thrown, so that a value holding secrets can be wiped;
* `decode` wipes what it read when it fails itself.
*/
export function unmarshal<T>(
input: Uint8Array,
@ -420,250 +439,128 @@ export function unmarshal<T>(
}
// ---------------------------------------------------------------------------
// Schema peek (spec §70)
/**
* Head of an item as read by the reference decoder: `val` is the argument, or
* Infinity above 2^53-1; `key` is the exact argument in decimal.
*/
interface RefHead {
major: number;
ai: number;
val: number;
key: string;
aboveInt64: boolean;
indefinite: boolean;
}
// RefScanner reproduces the checks the reference implementation applies
// before its strict decoding (fxamacker/cbor v2.9.4 with the options of
// codec.CheckSchema at afb44a3): well-formedness with no tags, no indefinite
// lengths, at most 16 nested levels, 65536 array elements and 65536 map pairs,
// and no extraneous data. Text is not validated here.
class RefScanner {
off = 0;
readonly #b: Uint8Array;
constructor(b: Uint8Array) {
this.#b = b;
}
head(): RefHead {
const b = this.#b;
if (this.off >= b.length) throw cborError('unexpected end of input');
const ib = b[this.off++]!;
const major = ib >> 5;
const ai = ib & 0x1f;
const h: RefHead = { major, ai, val: ai, key: String(ai), aboveInt64: false, indefinite: false };
if (ai < 24) return h;
if (ai === 31) {
if (major === 0 || major === 1 || major === 6) throw cborError(`invalid additional information 31 for ${MAJOR_NAMES[major]!}`);
if (major === 7) throw cborError('unexpected break code');
h.indefinite = true;
return h;
}
if (ai > 27) throw cborError(`invalid additional information ${ai}`);
const n = 1 << (ai - 24);
if (b.length - this.off < n) throw cborError('unexpected end of input');
if (n === 8) {
const hi = readUint32BE(b, this.off);
h.aboveInt64 = hi >= 0x80000000;
h.val = hi > MAX_HI32 ? Infinity : hi * TWO_POW_32 + readUint32BE(b, this.off + 4);
// Only a key and a message need the exact value above 2^53-1.
h.key = h.val === Infinity ? BigInt(`0x${toHex(b.subarray(this.off, this.off + 8))}`).toString() : String(h.val);
} else {
h.val = n === 1 ? b[this.off]! : n === 2 ? (b[this.off]! << 8) | b[this.off + 1]! : readUint32BE(b, this.off);
h.key = String(h.val);
if (n === 1 && major === 7 && h.val < 32) throw cborError(`invalid simple value ${h.val}`);
}
this.off += n;
return h;
}
// Checks one well-formed item; `depth` counts the enclosing containers.
item(depth: number): void {
const h = this.head();
switch (h.major) {
case 2:
case 3:
if (h.indefinite) throw cborError(`indefinite-length ${MAJOR_NAMES[h.major]!}`);
this.take(h.val);
return;
case 4:
case 5: {
if (depth + 1 > MAX_NESTED_LEVELS) throw cborError(`exceeded max nested level ${MAX_NESTED_LEVELS}`);
if (h.indefinite) throw cborError(`indefinite-length ${MAJOR_NAMES[h.major]!}`);
if (h.val > (h.major === 4 ? MAX_ARRAY_ELEMENTS : MAX_MAP_PAIRS)) throw cborError(`${MAJOR_NAMES[h.major]!} of ${h.val} entries exceeds the limit`);
const n = h.major === 5 ? 2 * h.val : h.val;
for (let i = 0; i < n; i++) this.item(depth + 1);
return;
}
case 6:
throw cborError('tags are not allowed');
default:
return;
}
}
take(n: number): Uint8Array {
if (n > this.#b.length - this.off) throw cborError('unexpected end of input');
const out = this.#b.subarray(this.off, this.off + n);
this.off += n;
return out;
}
// A text value decoded into a Go string: text, or null/undefined (no-op).
string(): string {
const h = this.head();
if (h.major === 3) {
const s = decodeUtf8(this.take(h.val));
if (s === undefined) throw cborError('type tag is not valid UTF-8');
return s;
}
if (h.major === 7 && (h.ai === 22 || h.ai === 23)) return '';
throw cborError(`type tag is a ${MAJOR_NAMES[h.major]!}`);
}
// A value decoded into a Go uint64, with its decimal text: an unsigned
// integer, a simple value other than false and true (its number), or
// null/undefined (no-op).
uint64(): [number, string] {
const h = this.head();
if (h.major === 0) return [h.val, h.key];
if (h.major === 7) {
if (h.ai === 22 || h.ai === 23) return [0, '0'];
if (h.ai < 20 || h.ai === 24) return [h.val, h.key];
}
throw cborError(`schema version is a ${MAJOR_NAMES[h.major]!}`);
}
}
// Schema head (spec §69.1 layer 2, §70)
/** Type tag (key 0) and schema version (key 1) of an encoded object. */
export interface SchemaHead {
typeTag: string;
/** The version; Infinity stands for any value above 2^53-1. */
version: number;
}
/**
* Reads the type tag and the schema version of a map before its strict
* decoding, so that an unknown version is reported as such (spec §70). It
* accepts exactly what the reference implementation's codec.Peek accepts at
* afb44a3: the whole item must be well-formed within the limits above, every
* other key is skipped, and only duplicate keys, keys that are not integers
* or valid text, and ill-typed values of keys 0 and 1 are rejected. Its
* result is never the decoded object.
* Reads the type tag (key 0, a text string of at most MAX_TYPE_TAG_LEN
* bytes) and the schema version (key 1, an unsigned integer of at most
* 2^53-1) of the map at the start of `input`, before its strict decoding, so
* that an unknown schema version is reported as such (spec §69.1, §70). The
* map must announce at least two entries and no more than half the bytes
* after its head, and start with keys 0 and 1 in the profile; nothing after
* them is read. The result is never the decoded object.
*/
export function peek(input: Uint8Array): SchemaHead {
const { typeTag, version } = peekHead(input);
return { typeTag, version };
}
function peekHead(input: Uint8Array): SchemaHead & { versionText: string } {
const s = new RefScanner(input);
s.item(0);
if (s.off !== input.length) throw cborError(`${input.length - s.off} bytes of extraneous data`);
s.off = 0;
const top = s.head();
if (top.major !== 5) throw cborError(`cannot read the schema of a ${MAJOR_NAMES[top.major]!}`);
const head = { typeTag: '', version: 0, versionText: '0' };
const found = [false, false];
const unmatched = new Set<string>();
for (let i = 0; i < top.val; i++) {
const k = s.head();
let id: string;
if (k.major === 3) {
const t = decodeUtf8(s.take(k.val));
if (t === undefined) throw cborError('map key is not valid UTF-8');
id = `t${t}`;
} else if (k.major === 0 || k.major === 1) {
if (k.aboveInt64) throw cborError('map key overflows int64');
if (k.major === 0 && k.val <= 1) {
if (found[k.val]) throw cborError(`duplicate map key ${k.val}`);
found[k.val] = true;
if (k.val === 0) head.typeTag = s.string();
else [head.version, head.versionText] = s.uint64();
continue;
}
id = `${k.major}:${k.key}`;
} else {
throw cborError(`map key of type ${MAJOR_NAMES[k.major]!}`);
}
if (unmatched.has(id)) throw cborError('duplicate map key');
unmatched.add(id);
s.item(1);
const d = new Decoder(input);
const pairs = d.map(Number.MAX_SAFE_INTEGER);
if (pairs < 2) throw d.fail('map without a type tag and a schema version');
let typeTag = '';
let version = 0;
for (const want of [0, 1]) {
const k = d.key();
if (k !== want) throw d.fail(`map key ${k} where key ${want} was expected`);
if (want === 0) typeTag = d.text(MAX_TYPE_TAG_LEN);
else version = d.uint(MAX_SAFE_UINT);
}
return head;
return { typeTag, version };
}
/**
* Requires key 0 to be `typeTag` (ERR_NON_CANONICAL_CBOR otherwise) and key
* 1 to be `version` (ERR_UNSUPPORTED_VERSION otherwise), as the reference
* codec.CheckSchema.
* Reads the schema head with peek and requires the expected values: another
* type tag is ERR_NON_CANONICAL_CBOR whatever the version, and only then
* another version is ERR_UNSUPPORTED_VERSION, whatever follows it (spec
* §69.1 layer 2).
*/
export function checkSchema(input: Uint8Array, typeTag: string, version: number): void {
const h = peekHead(input);
if (h.typeTag !== typeTag) {
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `codec: type ${goQuote(h.typeTag)}, want ${goQuote(typeTag)}`);
}
const h = peek(input);
if (h.typeTag !== typeTag) throw cborError(`type ${goQuote(h.typeTag)}, want ${goQuote(typeTag)}`);
if (h.version !== version) {
throw new DateKeysError('ERR_UNSUPPORTED_VERSION', `codec: ${typeTag} schema version ${h.versionText}, want ${version}`);
throw new DateKeysError('ERR_UNSUPPORTED_VERSION', `codec: ${typeTag} schema version ${h.version}, want ${version}`);
}
}
// ---------------------------------------------------------------------------
// Walk
/** A decoded item of the profile. Map entries keep their order. */
/**
* A decoded item of the profile. Map entries keep their order. Unsigned
* integers and map keys are numbers up to 2^53-1 and bigints above.
*/
export type Item =
| { kind: 'uint'; value: number }
| { kind: 'uint'; value: Uint }
| { kind: 'bytes'; value: Uint8Array }
| { kind: 'text'; value: string }
| { kind: 'array'; items: Item[] }
| { kind: 'map'; entries: { key: number; value: Item }[] };
| { kind: 'map'; entries: { key: Uint; value: Item }[] };
/** The depth walk allows by default, and the one of the inspector's informative view. */
export const MAX_NESTED_LEVELS = 16;
interface WalkLevel {
/** Entries of a map or items of an array not read yet. */
left: number;
item: Extract<Item, { kind: 'array' | 'map' }>;
/** The key of the map entry whose value is read next. */
key: Uint;
}
/**
* Decodes any item of the profile with at most `maxDepth` nested containers
* and at most `maxLen` input bytes, applying the same strict rules as
* Decoder. It is a helper for shared vectors and for informative views of
* extension data; it never decides the validity of a protocol object.
* Decodes `input` as exactly one data item of the profile, as the Go
* codec.Walk: containers nested at most `maxDepth` deep (a scalar has depth
* 0, `81818100` has depth 3) and every byte string and text string at most
* `maxLen` bytes, every array at most `maxLen` items and every map at most
* `maxLen` entries. Unsigned integers take any value up to 2^64-1. It reads
* iteratively, so deep input cannot exhaust the stack.
*
* A helper for shared vectors and for informative views of extension data;
* it never decides the validity of a protocol object.
*/
export function walk(input: Uint8Array, maxDepth: number = MAX_NESTED_LEVELS, maxLen: number = input.length): Item {
if (input.length > maxLen) throw cborError(`input of ${input.length} bytes exceeds ${maxLen}`);
const d = new Decoder(input);
const item = walkItem(d, maxDepth, 0);
d.done();
return item;
}
function walkItem(d: Decoder, maxDepth: number, depth: number): Item {
const major = d.peekMajor();
switch (major) {
case 0:
return { kind: 'uint', value: d.uint() };
case 2:
return { kind: 'bytes', value: d.bstr(0, d.remaining) };
case 3:
return { kind: 'text', value: d.text(d.remaining) };
case 4:
case 5: {
if (depth >= maxDepth) throw cborError(`more than ${maxDepth} nested containers`);
if (major === 4) {
const n = d.array(d.remaining);
const items: Item[] = [];
for (let i = 0; i < n; i++) items.push(walkItem(d, maxDepth, depth + 1));
return { kind: 'array', items };
}
const n = d.map(d.remaining);
const entries: { key: number; value: Item }[] = [];
for (let i = 0; i < n; i++) {
const key = d.key();
entries.push({ key, value: walkItem(d, maxDepth, depth + 1) });
}
d.endMap();
return { kind: 'map', entries };
const open: WalkLevel[] = [];
let root: Item | undefined;
const attach = (it: Item): void => {
const parent = open.at(-1);
if (parent === undefined) root = it;
else if (parent.item.kind === 'array') parent.item.items.push(it);
else parent.item.entries.push({ key: parent.key, value: it });
};
for (let first = true; first || open.length > 0; first = false) {
const top = open.at(-1);
if (top !== undefined && top.left === 0) {
// The innermost container is complete.
if (top.item.kind === 'map') d.endMap();
open.pop();
continue;
}
if (top !== undefined) {
top.left--;
if (top.item.kind === 'map') top.key = d.key();
}
const major = d.next();
if (major === MAJOR_MAP || major === MAJOR_ARRAY) {
if (open.length >= maxDepth) throw d.fail(`containers nested deeper than ${maxDepth}`);
const level: WalkLevel =
major === MAJOR_MAP
? { left: d.map(maxLen), item: { kind: 'map', entries: [] }, key: 0 }
: { left: d.array(maxLen), item: { kind: 'array', items: [] }, key: 0 };
attach(level.item);
open.push(level);
} else if (major === MAJOR_BYTES) {
attach({ kind: 'bytes', value: d.bstr(0, maxLen) });
} else if (major === MAJOR_TEXT) {
attach({ kind: 'text', value: d.text(maxLen) });
} else {
// An unsigned integer, or the error of whatever is there.
attach({ kind: 'uint', value: d.uint64() });
}
default:
throw cborError(`${MAJOR_NAMES[major]!} is outside the protocol profile`);
}
d.done();
return root!;
}

@ -1,18 +1,11 @@
// CONTROL_CBOR (spec §31, §63 step 14), as DecodeControl and EncodeControl of
// the Go package capsule at afb44a3. payloadIdentity is I_PAYLOAD, a secret.
// the Go package capsule at 3820066. payloadIdentity is I_PAYLOAD, a secret.
import { copyBytes } from './bytes.ts';
import { checkSchema, type Decoder, Encoder, MAX_SAFE_UINT, cborError, unmarshal } from './cbor.ts';
import { copyBytes, utf8Length } from './bytes.ts';
import { checkSchema, type Decoder, Encoder, unmarshal } from './cbor.ts';
import { DateKeysError, withContext } from './errors.ts';
import {
checkDisjoint,
decodeExtensions,
decodeWireArray,
encodeExtensions,
encodeWireArray,
type Extension,
type ExtensionWire,
} from './extension.ts';
import { canonicalExtensions, checkDisjoint, decodeArray, type Extension } from './extension.ts';
import { encodeExtensionArrays, fieldOf, presence, requireKeys } from './schema.ts';
export const CONTROL_TYPE_TAG = 'datekeys-control';
export const CONTROL_VERSION = 1;
@ -29,84 +22,95 @@ export interface Control {
readonly noncritical: readonly Extension[];
}
// CONTROL_CBOR as it is encoded: keys 2 to 5, keys 0 and 1 being the
// constants CONTROL_TYPE_TAG and CONTROL_VERSION.
interface ControlWire {
typeTag: string;
version: number;
headerBinding: Uint8Array;
/** SECRET. */
payloadIdentity: Uint8Array;
critical: ExtensionWire[] | undefined;
noncritical: ExtensionWire[] | undefined;
critical: Extension[] | undefined;
noncritical: Extension[] | undefined;
}
// Reads CONTROL_CBOR with every CDDL rule. On failure it wipes the copy of
// I_PAYLOAD it read.
function decodeWire(d: Decoder): ControlWire {
d.map(6);
d.expectKey(0);
const typeTag = d.text(MAX_SAFE_UINT);
d.expectKey(1);
const version = d.uint();
d.expectKey(2);
const headerBinding = d.bstr(0, MAX_SAFE_UINT);
d.expectKey(3);
const payloadIdentity = d.bstr(0, MAX_SAFE_UINT);
// From here on, a failure wipes the copy of the secret it read.
const w: ControlWire = { headerBinding: new Uint8Array(0), payloadIdentity: new Uint8Array(0), critical: undefined, noncritical: undefined };
try {
let critical: ExtensionWire[] | undefined;
let noncritical: ExtensionWire[] | undefined;
while (d.pairsLeft() > 0) {
const pairs = d.map(6);
const seen = new Set<number>();
for (let i = 0; i < pairs; i++) {
const k = d.key();
if (k === 4) critical = decodeWireArray(d);
else if (k === 5) noncritical = decodeWireArray(d);
else throw cborError(`unknown CONTROL_CBOR key ${k}`);
const field = fieldOf(k);
switch (k) {
case 0:
field(() => d.text(utf8Length(CONTROL_TYPE_TAG)));
break;
case 1:
field(() => d.uint(CONTROL_VERSION));
break;
case 2:
w.headerBinding = field(() => d.bstr(32, 32));
break;
case 3:
w.payloadIdentity = field(() => d.bstr(32, 32));
break;
case 4:
w.critical = field(() => decodeArray(d));
break;
case 5:
w.noncritical = field(() => decodeArray(d));
break;
default:
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `key ${k} is not defined`);
}
seen.add(Number(k));
}
requireKeys(seen, 4);
d.endMap();
return { typeTag, version, headerBinding, payloadIdentity, critical, noncritical };
return w;
} catch (err) {
payloadIdentity.fill(0);
w.payloadIdentity.fill(0);
throw err;
}
}
function encodeWire(e: Encoder, w: ControlWire): void {
e.map(4 + (w.critical ? 1 : 0) + (w.noncritical ? 1 : 0));
e.map(4 + presence(w.critical) + presence(w.noncritical));
e.uint(0);
e.text(w.typeTag);
e.text(CONTROL_TYPE_TAG);
e.uint(1);
e.uint(w.version);
e.uint(CONTROL_VERSION);
e.uint(2);
e.bstr(w.headerBinding);
e.uint(3);
e.bstr(w.payloadIdentity);
if (w.critical) {
e.uint(4);
encodeWireArray(e, w.critical);
}
if (w.noncritical) {
e.uint(5);
encodeWireArray(e, w.noncritical);
}
encodeExtensionArrays(e, 4, w.critical, w.noncritical);
}
/**
* Validates and decodes CONTROL_CBOR. A non-canonical encoding is rejected
* even though CONTROL_CBOR is not hashed.
* Validates and decodes CONTROL_CBOR in the layers of spec §69.1: the type
* tag and the schema version, then canonical CBOR and the CDDL, then no
* identifier in both extension arrays. A non-canonical encoding is rejected
* even though CONTROL_CBOR is not hashed. Its critical extensions (layer 4)
* are checked by the caller.
*/
export function decodeControl(b: Uint8Array): Control {
withContext('capsule: CONTROL_CBOR', () => checkSchema(b, CONTROL_TYPE_TAG, CONTROL_VERSION));
// I_PAYLOAD is wiped on every path: by decodeWire when decoding fails, by
// unmarshal's onReject when the decoded value is rejected, and by the
// finally block below otherwise.
const w = withContext('capsule: CONTROL_CBOR', () => unmarshal(b, decodeWire, encodeWire, (v) => v.payloadIdentity.fill(0)));
try {
if (w.headerBinding.length !== 32 || w.payloadIdentity.length !== 32) {
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', 'capsule: header_binding and payload_identity must be 32 bytes');
return withContext('capsule: CONTROL_CBOR', () => {
checkSchema(b, CONTROL_TYPE_TAG, CONTROL_VERSION);
// I_PAYLOAD is wiped on every path: by decodeWire when decoding fails, by
// unmarshal's onReject when the decoded value is rejected, and by the
// finally block below otherwise.
const w = unmarshal(b, decodeWire, encodeWire, (v) => v.payloadIdentity.fill(0));
try {
const critical = w.critical ?? [];
const noncritical = w.noncritical ?? [];
checkDisjoint(critical, noncritical);
return { headerBinding: w.headerBinding, payloadIdentity: copyBytes(w.payloadIdentity), critical, noncritical };
} finally {
w.payloadIdentity.fill(0);
}
const critical = withContext('capsule: CONTROL_CBOR critical_extensions', () => decodeExtensions(w.critical ?? []));
const noncritical = withContext('capsule: CONTROL_CBOR noncritical_extensions', () => decodeExtensions(w.noncritical ?? []));
withContext('capsule: CONTROL_CBOR', () => checkDisjoint(critical, noncritical));
return { headerBinding: w.headerBinding, payloadIdentity: copyBytes(w.payloadIdentity), critical, noncritical };
} finally {
w.payloadIdentity.fill(0);
}
});
}
/** The Deterministic CBOR bytes of `c`. The caller must wipe them. */
@ -114,18 +118,11 @@ export function encodeControl(c: Control): Uint8Array {
if (c.headerBinding.length !== 32 || c.payloadIdentity.length !== 32) {
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', 'capsule: header_binding and payload_identity must be 32 bytes');
}
const critical = encodeExtensions(c.critical);
const noncritical = encodeExtensions(c.noncritical);
const critical = canonicalExtensions(c.critical);
const noncritical = canonicalExtensions(c.noncritical);
checkDisjoint(c.critical, c.noncritical);
const e = new Encoder();
encodeWire(e, {
typeTag: CONTROL_TYPE_TAG,
version: CONTROL_VERSION,
headerBinding: c.headerBinding,
payloadIdentity: c.payloadIdentity,
critical,
noncritical,
});
encodeWire(e, { headerBinding: c.headerBinding, payloadIdentity: c.payloadIdentity, critical, noncritical });
const out = e.out();
e.wipe();
return out;

@ -1,4 +1,4 @@
// DateKeys (spec §14-§19), as the Go package datekey at afb44a3: local
// DateKeys (spec §14-§19), as the Go package datekey at 3820066: local
// resolution of an instant to a round and the canonical dk1_ representation.
//
// Instants carry full nanosecond precision: they are parsed by an RFC 3339
@ -174,10 +174,14 @@ export function goBase64Decode(src: Uint8Array, url: boolean, padded: boolean, s
return typeof r === 'number' ? undefined : r;
}
// Go's datekey.decodeBase64: unpadded Base64URL, and also the padded and
// standard-alphabet variants so that they are reported as non-canonical
// rather than invalid; the final comparison rejects them.
// Go's datekey.decodeBase64, step 1 of spec §19: unpadded Base64URL, and
// also the padded and standard-alphabet variants and non-zero trailing bits,
// so that they are reported as non-canonical rather than invalid; the final
// comparison rejects them. CR and LF are rejected first: Go's decoders, and
// goBase64 with them, would skip them, and spec §19 allows no character
// outside the alphabet.
function decodeBase64Lenient(src: Uint8Array): Uint8Array | undefined {
if (src.includes(0x0a) || src.includes(0x0d)) return undefined;
return (
goBase64Decode(src, true, false, false) ??
goBase64Decode(src, true, true, false) ??

@ -1,23 +1,23 @@
// The generic extension mechanism shared by PUBLIC_HEADER, CONTROL_CBOR and
// .dkk (spec §31, §44, §54, §72), as the Go package extension at afb44a3.
// .dkk (spec §31, §44, §54, §72), as the Go package extension at 3820066.
//
// Extension data is opaque bytes: the base protocol never decodes or
// validates its content. This module enforces the structural rules only:
// valid UTF-8 identifiers of 1 to 256 bytes, extension_version at most
// 2^32-1, data that is absent or a non-empty byte string, 1 to 64 extensions
// per array, no identifier repeated within an object or present in both
// arrays, canonical order by the UTF-8 bytes of extension_id (never by
// JavaScript string comparison, which orders UTF-16 code units), rejection
// of unknown critical extensions and omission of empty arrays (spec §58.1).
// per array, in strictly ascending order of the UTF-8 bytes of extension_id
// (never JavaScript string order, which compares UTF-16 code units), no
// identifier in both arrays of an object, rejection of unknown critical
// extensions and omission of empty arrays (spec §58.1).
//
// The checks run in two phases, as in the reference: the CBOR decoding of
// each extension map (decodeWire, part of the strict decoding of the
// containing object) and the array rules (decodeExtensions), which the
// containing object applies after its own field checks.
// Every rule of an array is checked while the array is decoded, with the
// rest of the CDDL of the containing object (layer 3 of spec §69.1); the
// critical extensions are checked against a registry afterwards, with the
// fields of the object (layer 4).
import { compareBytes, copyBytes, equalBytes, goQuote, utf8Bytes } from './bytes.ts';
import { cborError, type Decoder, type Encoder, MAX_ARRAY_ELEMENTS, MAX_SAFE_UINT, type WideUint, wideUintText } from './cbor.ts';
import { DateKeysError } from './errors.ts';
import { compareBytes, copyBytes, goQuote, utf8Bytes } from './bytes.ts';
import type { Decoder, Encoder } from './cbor.ts';
import { DateKeysError, withContext } from './errors.ts';
/** Largest extension_id in UTF-8 bytes; an implementation limit (spec §74). */
export const MAX_ID_LEN = 256;
@ -41,18 +41,7 @@ export interface Extension {
readonly data: Uint8Array | undefined;
}
/**
* One extension map as decoded from CBOR, before the array rules. `idUtf8`
* holds the exact UTF-8 bytes of the identifier, the ordering key. `version`
* is any uint64, as in the reference: its bound is an array rule, checked by
* decodeExtensions after the field checks of the containing object.
*/
export interface ExtensionWire {
readonly id: string;
readonly idUtf8: Uint8Array;
readonly version: WideUint;
readonly data: Uint8Array | undefined;
}
const nonCanonical = (context: string): DateKeysError => new DateKeysError('ERR_NON_CANONICAL_CBOR', context);
/**
* Returns an extension that carries data, of which it keeps a copy. `data`
@ -61,132 +50,169 @@ export interface ExtensionWire {
*/
export function newExtension(id: string, version: number, data: Uint8Array): Extension {
if (!(data instanceof Uint8Array)) {
throw new DateKeysError(
'ERR_NON_CANONICAL_CBOR',
`extension ${goQuote(id)}: newExtension needs data; an extension without data is { id, version, data: undefined }`,
);
throw nonCanonical(`extension ${goQuote(id)}: newExtension needs data; an extension without data is { id, version, data: undefined }`);
}
const e: Extension = { id, version, data: copyBytes(data) };
validate(e.id, utf8Bytes(id), { value: e.version }, e.data);
validate(e, idBytes(e));
return e;
}
function validate(id: string, idUtf8: Uint8Array, version: WideUint, data: Uint8Array | undefined): void {
if (idUtf8.length === 0 || idUtf8.length > MAX_ID_LEN || !id.isWellFormed()) {
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `extension: invalid extension_id ${goQuote(id)}`);
// The UTF-8 bytes of an identifier, or undefined when it is not well-formed
// Unicode (a lone surrogate), which no valid UTF-8 encodes.
function idBytes(e: Extension): Uint8Array | undefined {
return e.id.isWellFormed() ? utf8Bytes(e.id) : undefined;
}
function validate(e: Extension, id: Uint8Array | undefined): void {
if (id === undefined || id.length === 0 || id.length > MAX_ID_LEN) {
throw nonCanonical(`extension: invalid extension_id ${goQuote(e.id)}`);
}
if (!Number.isInteger(version.value) || version.value < 0 || version.value > MAX_VERSION) {
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `extension ${id}: extension_version ${wideUintText(version)} exceeds ${MAX_VERSION}`);
if (!Number.isInteger(e.version) || e.version < 0 || e.version > MAX_VERSION) {
throw nonCanonical(`extension ${e.id}: extension_version ${e.version} exceeds ${MAX_VERSION}`);
}
if (data !== undefined && (data.length === 0 || data.length > MAX_DATA_LEN)) {
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `extension ${id}: data of ${data.length} bytes outside 1..${MAX_DATA_LEN}`);
if (e.data !== undefined && (e.data.length === 0 || e.data.length > MAX_DATA_LEN)) {
throw nonCanonical(`extension ${e.id}: data of ${e.data.length} bytes outside 1..${MAX_DATA_LEN}`);
}
}
// The order of spec §54 between two consecutive entries: an identifier
// equal to the previous one is repeated, a smaller one is out of order.
function checkOrder(prev: Uint8Array, id: Uint8Array, e: Extension): void {
const c = compareBytes(prev, id);
if (c === 0) throw nonCanonical(`extension ${e.id}: appears more than once`);
if (c > 0) throw nonCanonical(`extension ${e.id}: array is not in canonical order`);
}
// ---------------------------------------------------------------------------
// CBOR
// Encoding
/**
* Decodes one extension map: key 0 a text string, key 1 an unsigned integer
* of up to 64 bits and, when present, key 2 a byte string of at least one
* byte. The empty byte string and every other CBOR type at key 2 are rejected
* here (spec §54, §58.1). The array rules, the bound of extension_version
* among them, are applied later by decodeExtensions.
* 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 undefined, so that the array
* key is omitted (spec §58.1).
*/
export function decodeWire(d: Decoder): ExtensionWire {
const n = d.map(3);
d.expectKey(0);
const { text: id, utf8: idUtf8 } = d.textUtf8(MAX_SAFE_UINT);
d.expectKey(1);
const version = d.wideUint();
let data: Uint8Array | undefined;
if (n === 3) {
d.expectKey(2);
data = d.bstr(1, MAX_DATA_LEN);
}
d.endMap();
return { id, idUtf8, version, data };
export function canonicalExtensions(exts: readonly Extension[]): Extension[] | undefined {
if (exts.length === 0) return undefined;
if (exts.length > MAX_EXTENSIONS) throw errTooMany(exts.length);
const sorted = exts.map((e) => ({ e, id: idBytes(e) }));
for (const { e, id } of sorted) validate(e, id);
sorted.sort((a, b) => compareBytes(a.id!, b.id!));
for (let i = 1; i < sorted.length; i++) checkOrder(sorted[i - 1]!.id!, sorted[i]!.id!, sorted[i]!.e);
return sorted.map(({ e }) => ({ id: e.id, version: e.version, data: e.data === undefined ? undefined : copyBytes(e.data) }));
}
/**
* Decodes the value of an extension-array key: an array of 1 to 65536
* extension maps (the element limit of the reference decoder). The empty
* array is rejected: an absent array omits its key (spec §58.1).
*/
export function decodeWireArray(d: Decoder): ExtensionWire[] {
const n = d.array(MAX_ARRAY_ELEMENTS);
if (n === 0) throw cborError('empty extension array; an array without extensions omits its key');
const out: ExtensionWire[] = [];
for (let i = 0; i < n; i++) out.push(decodeWire(d));
return out;
function errTooMany(n: number): DateKeysError {
return nonCanonical(`extension: ${n} extensions in one array, at most ${MAX_EXTENSIONS}`);
}
/** Encodes one extension map; data omits key 2 when undefined or empty. */
export function encodeWire(e: Encoder, w: ExtensionWire): void {
const hasData = w.data !== undefined && w.data.length > 0;
e.map(hasData ? 3 : 2);
e.uint(0);
e.text(w.id);
e.uint(1);
e.wideUint(w.version);
if (hasData) {
e.uint(2);
e.bstr(w.data!);
/**
* Writes a non-empty extension array as canonicalExtensions returns it: each
* extension is the map {0: extension_id, 1: extension_version} with key 2,
* the data as a byte string, only when it carries data (spec §54). An array
* that decodeArray would reject is not written: its error is recorded in
* `e`, whose out() throws it.
*/
export function encodeArray(e: Encoder, exts: readonly Extension[]): void {
try {
checkArray(exts);
} catch (err) {
e.fail(err as DateKeysError);
return;
}
e.array(exts.length);
for (const x of exts) {
e.map(x.data === undefined ? 2 : 3);
e.uint(0);
e.text(x.id);
e.uint(1);
e.uint(x.version);
if (x.data !== undefined) {
e.uint(2);
e.bstr(x.data);
}
}
}
/** Encodes an extension array. */
export function encodeWireArray(e: Encoder, ws: readonly ExtensionWire[]): void {
e.array(ws.length);
for (const w of ws) encodeWire(e, w);
// The rules of decodeArray applied to an array to be written.
function checkArray(exts: readonly Extension[]): void {
if (exts.length === 0) throw nonCanonical('extension: empty array; an absent array omits its key');
if (exts.length > MAX_EXTENSIONS) throw errTooMany(exts.length);
let prev: Uint8Array | undefined;
for (const x of exts) {
const id = idBytes(x);
validate(x, id);
if (prev !== undefined) checkOrder(prev, id!, x);
prev = id;
}
}
// ---------------------------------------------------------------------------
// Array rules
// Decoding
/**
* Validates one extension array and returns its canonical wire form, sorted
* by the UTF-8 bytes of extension_id. An empty input yields undefined, so
* that the array key is omitted (spec §58.1).
* Reads one extension array, as the Go extension.DecodeArray. 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
* MAX_ID_LEN bytes, key 1, an extension_version of at most MAX_VERSION, 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 strictly
* ascending order of the UTF-8 bytes of extension_id. Every failure is
* ERR_NON_CANONICAL_CBOR.
*/
export function encodeExtensions(exts: readonly Extension[]): ExtensionWire[] | undefined {
if (exts.length === 0) return undefined;
if (exts.length > MAX_EXTENSIONS) {
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `extension: ${exts.length} extensions in one array, at most ${MAX_EXTENSIONS}`);
}
const sorted = exts.map((e) => ({ e, idUtf8: utf8Bytes(e.id) })).sort((a, b) => compareBytes(a.idUtf8, b.idUtf8));
const out: ExtensionWire[] = [];
for (const [i, { e, idUtf8 }] of sorted.entries()) {
const version: WideUint = { value: e.version };
validate(e.id, idUtf8, version, e.data);
if (i > 0 && equalBytes(sorted[i - 1]!.idUtf8, idUtf8)) {
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `extension ${e.id}: appears more than once`);
}
out.push({ id: e.id, idUtf8, version, data: e.data === undefined ? undefined : copyBytes(e.data) });
export function decodeArray(d: Decoder): Extension[] {
const n = withContext('extension', () => d.array(MAX_EXTENSIONS));
if (n === 0) throw nonCanonical('extension: empty array; an absent array omits its key');
const out: Extension[] = [];
let prev: Uint8Array | undefined;
for (let i = 0; i < n; i++) {
const { e, id } = decodeOne(d);
if (prev !== undefined) checkOrder(prev, id, e);
prev = id;
out.push(e);
}
return out;
}
/**
* 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.
*/
export function decodeExtensions(ws: readonly ExtensionWire[]): Extension[] {
if (ws.length > MAX_EXTENSIONS) {
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `extension: ${ws.length} extensions in one array, at most ${MAX_EXTENSIONS}`);
}
const out: Extension[] = [];
for (const [i, w] of ws.entries()) {
validate(w.id, w.idUtf8, w.version, w.data);
if (i > 0) {
const c = compareBytes(ws[i - 1]!.idUtf8, w.idUtf8);
if (c === 0) throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `extension ${w.id}: appears more than once`);
if (c > 0) throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `extension ${w.id}: array is not in canonical order`);
// 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).
function decodeOne(d: Decoder): { e: Extension; id: Uint8Array } {
let id = '';
let idUtf8: Uint8Array = new Uint8Array(0);
let version = 0;
let data: Uint8Array | undefined;
let seenId = false;
let seenVersion = false;
const pairs = withContext('extension', () => d.map(3));
for (let i = 0; i < pairs; i++) {
const k = withContext('extension', () => d.key());
// A read that fails names the extension and the key.
const field = <T>(read: () => T): T => withContext(`extension ${goQuote(id)}: key ${k}`, read);
switch (k) {
case 0:
({ text: id, utf8: idUtf8 } = field(() => d.textUtf8(MAX_ID_LEN)));
seenId = true;
break;
case 1:
version = field(() => d.uint(MAX_VERSION));
seenVersion = true;
break;
case 2:
data = field(() => d.bstr(0, MAX_DATA_LEN));
if (data.length === 0) {
throw nonCanonical(`extension ${goQuote(id)}: data is present but empty; an extension without data omits key 2`);
}
break;
default:
throw nonCanonical(`extension ${goQuote(id)}: unknown key ${k}`);
}
out.push({ id: w.id, version: w.version.value, data: w.data === undefined ? undefined : copyBytes(w.data) });
}
return out;
if (!seenId || !seenVersion) throw nonCanonical(`extension ${goQuote(id)}: extension_id and extension_version are required`);
const e: Extension = { id, version, data };
validate(e, idUtf8);
d.endMap();
return { e, id: idUtf8 };
}
/**
@ -199,7 +225,7 @@ export function checkDisjoint(critical: readonly Extension[], noncritical: reado
const b = sortedIds(noncritical);
for (let i = 0, j = 0; i < a.length && j < b.length; ) {
const c = compareBytes(a[i]!.utf8, b[j]!.utf8);
if (c === 0) throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `extension ${a[i]!.id}: both critical and noncritical`);
if (c === 0) throw nonCanonical(`extension ${a[i]!.id}: both critical and noncritical`);
if (c < 0) i++;
else j++;
}
@ -240,7 +266,8 @@ export class ExtensionSet implements ExtensionRegistry {
* Rejects every critical extension unknown to `reg` with
* ERR_EXTENSION_CRITICAL_UNKNOWN and, when `reg` validates data, every known
* one whose data it rejects with ERR_EXTENSION_DATA_INVALID (spec §54, §70).
* An unknown extension takes precedence over invalid data.
* An unknown extension anywhere in the array takes precedence over invalid
* data (spec §69.1).
*/
export function checkCritical(critical: readonly Extension[], reg?: ExtensionRegistry): void {
for (const c of critical) {

@ -1,5 +1,5 @@
// DKC1 and DKK1 framing (spec §22, §23, §40, §57), as the Go packages capsule
// (framing.go, inspect.go) and accesskey at afb44a3.
// (framing.go, inspect.go) and accesskey at 3820066.
//
// A .dkc is PRELUDE || PUBLIC_HEADER || SEALED_CONTROL || PAYLOAD_AGE; the
// payload runs to EOF. A .dkk is a 12-byte prelude and BODY_CBOR.
@ -36,9 +36,10 @@ const hx = (v: number): string => v.toString(16);
const hx2 = (v: number): string => v.toString(16).padStart(2, '0');
/**
* Validates the prelude (spec §22, §63 step 2): magic, version, FLAGS == 0,
* RESERVED == 0 and the length limits of spec §57. `b` holds the bytes read,
* at most 16.
* Validates the prelude (spec §22, §23, §63 steps 1 and 2), in the order of
* spec §23: magic, a complete prelude, version, FLAGS == 0 and RESERVED == 0,
* and each length from 1 up to its limit of spec §57. `b` holds the bytes
* read, at most 16.
*/
export function parsePrelude(b: Uint8Array): Prelude {
if (!hasMagic(b, DKC_MAGIC)) throw new DateKeysError('ERR_INVALID_MAGIC', 'capsule');
@ -154,9 +155,10 @@ export function dkkPreludeBytes(bodyLen: number): Uint8Array {
}
/**
* Validates the DKK1 framing of a whole .dkk and returns BODY_CBOR: magic,
* version, FLAGS and RESERVED, the §57 limit, the declared length and no
* data after the body (spec §40).
* Validates the DKK1 framing of a whole .dkk and returns BODY_CBOR, in the
* order of spec §40: magic, a complete prelude, version, FLAGS and RESERVED,
* BODY_LEN in 1..16 MiB (spec §57), the declared length and no data after
* the body.
*/
export function splitAccessKey(dkk: Uint8Array): Uint8Array {
const pre = dkk.subarray(0, DKK_PRELUDE_SIZE);
@ -166,9 +168,11 @@ export function splitAccessKey(dkk: Uint8Array): Uint8Array {
if (pre[5] !== 0 || pre[6] !== 0 || pre[7] !== 0) {
throw new DateKeysError('ERR_INVALID_FLAGS', `accesskey: flags 0x${hx(pre[5]!)}, reserved 0x${hx(pre[6]!)}${hx2(pre[7]!)}`);
}
// Spec §40, §57: BODY_LEN in 1..16 MiB. No empty frame holds a valid body,
// so 0 is a framing error, like a PUBLIC_HEADER_LEN of 0 (§22).
const bodyLen = readUint32BE(pre, 8);
if (bodyLen > MAX_DKK_BODY_LEN) {
throw new DateKeysError('ERR_INTEGRITY', `accesskey: BODY_LEN ${bodyLen} exceeds the ${MAX_DKK_BODY_LEN}-byte limit`);
if (bodyLen === 0 || bodyLen > MAX_DKK_BODY_LEN) {
throw new DateKeysError('ERR_INTEGRITY', `accesskey: BODY_LEN ${bodyLen} outside 1..${MAX_DKK_BODY_LEN}`);
}
const end = DKK_PRELUDE_SIZE + bodyLen;
if (dkk.length < end) throw new DateKeysError('ERR_INTEGRITY', 'accesskey: truncated body');

@ -1,20 +1,13 @@
// PUBLIC_HEADER (spec §24, §27), as DecodeHeader and EncodeHeader of the Go
// package capsule at afb44a3.
// package capsule at 3820066.
import { goQuote, toHex } from './bytes.ts';
import { checkSchema, type Decoder, Encoder, MAX_SAFE_UINT, cborError, unmarshal, type WideUint, wideUintText } from './cbor.ts';
import { goQuote, toHex, utf8Length } from './bytes.ts';
import { checkSchema, type Decoder, Encoder, MAX_SAFE_UINT, unmarshal } from './cbor.ts';
import { compactDateKey, type DateKey, parseDateKey } from './datekey.ts';
import { DateKeysError, withContext } from './errors.ts';
import { MAX_PUBLIC_HEADER_LEN } from './framing.ts';
import {
checkDisjoint,
decodeExtensions,
decodeWireArray,
encodeExtensions,
encodeWireArray,
type Extension,
type ExtensionWire,
} from './extension.ts';
import { canonicalExtensions, checkDisjoint, decodeArray, type Extension } from './extension.ts';
import { encodeExtensionArrays, fieldOf, presence, requireKeys } from './schema.ts';
export const HEADER_TYPE_TAG = 'datekeycap';
export const HEADER_VERSION = 1;
@ -58,87 +51,100 @@ export function capsuleIdHex(h: Header): string {
return toHex(h.capsuleId);
}
// PUBLIC_HEADER as it is encoded: keys 2 to 6, keys 0 and 1 being the
// constants HEADER_TYPE_TAG and HEADER_VERSION.
interface HeaderWire {
typeTag: string;
version: number;
capsuleId: Uint8Array;
dateKey: string;
/** Any uint64, as in the reference: its range is checked after the DateKey. */
policy: WideUint;
critical: ExtensionWire[] | undefined;
noncritical: ExtensionWire[] | undefined;
policy: number;
critical: Extension[] | undefined;
noncritical: Extension[] | undefined;
}
// Reads PUBLIC_HEADER with every CDDL rule whose violation is
// ERR_NON_CANONICAL_CBOR, the extension arrays included (layer 3 of spec
// §69.1); the DateKey, which has codes of its own (spec §57), is parsed
// afterwards.
function decodeWire(d: Decoder): HeaderWire {
d.map(7);
d.expectKey(0);
const typeTag = d.text(MAX_SAFE_UINT);
d.expectKey(1);
const version = d.uint();
d.expectKey(2);
const capsuleId = d.bstr(0, MAX_SAFE_UINT);
d.expectKey(3);
const dateKey = d.text(MAX_SAFE_UINT);
d.expectKey(4);
const policy = d.wideUint();
let critical: ExtensionWire[] | undefined;
let noncritical: ExtensionWire[] | undefined;
while (d.pairsLeft() > 0) {
const w: HeaderWire = { capsuleId: new Uint8Array(0), dateKey: '', policy: 0, critical: undefined, noncritical: undefined };
const pairs = d.map(7);
const seen = new Set<number>();
for (let i = 0; i < pairs; i++) {
const k = d.key();
if (k === 5) critical = decodeWireArray(d);
else if (k === 6) noncritical = decodeWireArray(d);
else throw cborError(`unknown PUBLIC_HEADER key ${k}`);
const field = fieldOf(k);
switch (k) {
case 0:
field(() => d.text(utf8Length(HEADER_TYPE_TAG)));
break;
case 1:
field(() => d.uint(HEADER_VERSION));
break;
case 2:
w.capsuleId = field(() => d.bstr(CAPSULE_ID_SIZE, CAPSULE_ID_SIZE));
break;
case 3:
w.dateKey = field(() => d.text(MAX_PUBLIC_HEADER_LEN));
break;
case 4:
// Compared as read: 256, 257 or 2^32 are not a V1 policy.
w.policy = field(() => {
const p = d.uint(MAX_SAFE_UINT);
if (p > TIME_AND_KEY) throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `access_policy ${p} is not defined in V1`);
return p;
});
break;
case 5:
w.critical = field(() => decodeArray(d));
break;
case 6:
w.noncritical = field(() => decodeArray(d));
break;
default:
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `key ${k} is not defined`);
}
seen.add(Number(k));
}
requireKeys(seen, 5);
d.endMap();
return { typeTag, version, capsuleId, dateKey, policy, critical, noncritical };
return w;
}
function encodeWire(e: Encoder, w: HeaderWire): void {
e.map(5 + (w.critical ? 1 : 0) + (w.noncritical ? 1 : 0));
e.map(5 + presence(w.critical) + presence(w.noncritical));
e.uint(0);
e.text(w.typeTag);
e.text(HEADER_TYPE_TAG);
e.uint(1);
e.uint(w.version);
e.uint(HEADER_VERSION);
e.uint(2);
e.bstr(w.capsuleId);
e.uint(3);
e.text(w.dateKey);
e.uint(4);
e.wideUint(w.policy);
if (w.critical) {
e.uint(5);
encodeWireArray(e, w.critical);
}
if (w.noncritical) {
e.uint(6);
encodeWireArray(e, w.noncritical);
}
e.uint(w.policy);
encodeExtensionArrays(e, 5, w.critical, w.noncritical);
}
/**
* Validates and decodes PUBLIC_HEADER bytes (spec §24, §27, §63 step 4): the
* §57 limit, the schema head, canonical CBOR, a 16-byte capsule_id, a
* canonical DateKey, a V1 access policy and well-formed extension arrays, in
* that order. Whether the profile is pinned and the critical extensions known
* is decided by the caller.
* Validates and decodes PUBLIC_HEADER bytes (spec §24, §27, §63 step 4) in
* the layers of spec §69.1: the §57 limit (layer 1, ERR_INTEGRITY); the type
* tag and the schema version (layer 2); canonical CBOR and the CDDL, with a
* 16-byte capsule_id, a V1 access policy and well-formed extension arrays in
* strictly ascending order, and no identifier in both arrays (layer 3,
* ERR_NON_CANONICAL_CBOR); and only then a canonical DateKey (layer 4).
* Whether the profile is pinned and the critical extensions known is decided
* by the caller, still in layer 4.
*/
export function decodeHeader(b: Uint8Array): Header {
if (b.length > MAX_PUBLIC_HEADER_LEN) {
throw new DateKeysError('ERR_INTEGRITY', `capsule: PUBLIC_HEADER of ${b.length} bytes exceeds ${MAX_PUBLIC_HEADER_LEN}`);
}
withContext('capsule: PUBLIC_HEADER', () => checkSchema(b, HEADER_TYPE_TAG, HEADER_VERSION));
const w = withContext('capsule: PUBLIC_HEADER', () => unmarshal(b, decodeWire, encodeWire));
if (w.capsuleId.length !== CAPSULE_ID_SIZE) {
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `capsule: capsule_id is ${w.capsuleId.length} bytes, want ${CAPSULE_ID_SIZE}`);
}
const dateKey = withContext('capsule: PUBLIC_HEADER', () => parseDateKey(w.dateKey));
if (w.policy.value > 1) {
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `capsule: access_policy ${wideUintText(w.policy)} is not defined in V1`);
}
const critical = withContext('capsule: PUBLIC_HEADER critical_extensions', () => decodeExtensions(w.critical ?? []));
const noncritical = withContext('capsule: PUBLIC_HEADER noncritical_extensions', () => decodeExtensions(w.noncritical ?? []));
withContext('capsule: PUBLIC_HEADER', () => checkDisjoint(critical, noncritical));
return { capsuleId: w.capsuleId, dateKey, policy: w.policy.value, critical, noncritical };
return withContext('capsule: PUBLIC_HEADER', () => {
checkSchema(b, HEADER_TYPE_TAG, HEADER_VERSION);
const w = unmarshal(b, decodeWire, encodeWire);
checkDisjoint(w.critical ?? [], w.noncritical ?? []);
const dateKey = parseDateKey(w.dateKey);
return { capsuleId: w.capsuleId, dateKey, policy: w.policy, critical: w.critical ?? [], noncritical: w.noncritical ?? [] };
});
}
/** The Deterministic CBOR bytes of `h`, at most MAX_PUBLIC_HEADER_LEN. */
@ -149,22 +155,15 @@ export function encodeHeader(h: Header): Uint8Array {
if (h.capsuleId.length !== CAPSULE_ID_SIZE) {
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `capsule: capsule_id is ${h.capsuleId.length} bytes, want ${CAPSULE_ID_SIZE}`);
}
const critical = encodeExtensions(h.critical);
const noncritical = encodeExtensions(h.noncritical);
const critical = canonicalExtensions(h.critical);
const noncritical = canonicalExtensions(h.noncritical);
checkDisjoint(h.critical, h.noncritical);
const e = new Encoder();
encodeWire(e, {
typeTag: HEADER_TYPE_TAG,
version: HEADER_VERSION,
capsuleId: h.capsuleId,
dateKey: compact,
policy: { value: h.policy },
critical,
noncritical,
});
encodeWire(e, { capsuleId: h.capsuleId, dateKey: compact, policy: h.policy, critical, noncritical });
const b = e.out();
if (b.length > MAX_PUBLIC_HEADER_LEN) {
throw new DateKeysError('ERR_INTEGRITY', `capsule: PUBLIC_HEADER of ${b.length} bytes exceeds ${MAX_PUBLIC_HEADER_LEN}`);
}
return b;
}

@ -1,4 +1,4 @@
// Steps 1 to 8 of spec §63, as Inspect of the Go package capsule at afb44a3,
// Steps 1 to 8 of spec §63, as Inspect of the Go package capsule at 3820066,
// and the JSON view of `datekeys inspect -json`.
//
// The inspection never contacts a release source and never uses a secret, so
@ -280,3 +280,16 @@ function orderView(v: InspectView): InspectView {
for (const k of keys) if (v[k] !== undefined) o[k] = v[k];
return o as unknown as InspectView;
}
/**
* The exact output of `datekeys inspect -json` for `view` (Go's
* inspectview.WriteJSON): json.Encoder with SetIndent("", " "), which
* escapes <, > and & (SetEscapeHTML is on by default) and U+2028 and
* U+2029, and ends with a newline. Keys and non-string values never hold
* those characters, so escaping the whole text only touches strings.
* JSON.stringify writes every other escape as Go 1.22 and later do.
*/
export function inspectJSON(view: InspectView): string {
const text = JSON.stringify(view, null, 2).replace(/[<>&\u{2028}\u{2029}]/gu, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
return `${text}\n`;
}

@ -1,9 +1,9 @@
// Provider Profiles (spec §10-§13), as the Go package profile at afb44a3:
// Provider Profiles (spec §10-§13), as the Go package profile at 3820066:
// their Deterministic CBOR, profile_hash, validation and the locally pinned
// registry that forms the root of trust.
import { checkCompressedPoint, type Group } from './bls12381.ts';
import { concatBytes, copyBytes, equalBytes, goQuote, fromHex, sha256, toHex, utf8Bytes } from './bytes.ts';
import { concatBytes, copyBytes, equalBytes, goQuote, fromHex, sha256, toHex, utf8Bytes, utf8Length } from './bytes.ts';
import { checkSchema, type Decoder, Encoder, MAX_SAFE_UINT, unmarshal } from './cbor.ts';
import { DateKeysError, withContext } from './errors.ts';
@ -92,9 +92,9 @@ export function cloneProfile(p: Profile): Profile {
// ---------------------------------------------------------------------------
// CBOR
// The CBOR map of spec §11, keys 2 to 10; keys 0 and 1 are the constants
// PROFILE_TYPE_TAG and PROFILE_SCHEMA_VERSION. Every key is required.
interface ProfileWire {
typeTag: string;
version: number;
id: string;
provider: string;
network: string;
@ -106,40 +106,82 @@ interface ProfileWire {
genesisSeed: Uint8Array;
}
const WIRE_KEYS = 11;
// Bounds a field only by the input: its rule carries its own error code
// (spec §57) and is checked with the fields, after decoding.
const UNBOUNDED = Number.MAX_SAFE_INTEGER;
// Reads the map with every CDDL rule whose violation is
// ERR_NON_CANONICAL_CBOR (rule 1 of spec §12.1); the names and the public
// key are left to the field rules.
function decodeWire(d: Decoder): ProfileWire {
d.map(11);
d.expectKey(0);
const typeTag = d.text(MAX_SAFE_UINT);
d.expectKey(1);
const version = d.uint();
d.expectKey(2);
const id = d.text(MAX_SAFE_UINT);
d.expectKey(3);
const provider = d.text(MAX_SAFE_UINT);
d.expectKey(4);
const network = d.text(MAX_SAFE_UINT);
d.expectKey(5);
const chainHash = d.bstr(0, MAX_SAFE_UINT);
d.expectKey(6);
const publicKey = d.bstr(0, MAX_SAFE_UINT);
d.expectKey(7);
const period = d.uint();
d.expectKey(8);
const genesisTime = d.uint();
d.expectKey(9);
const scheme = d.text(MAX_SAFE_UINT);
d.expectKey(10);
const genesisSeed = d.bstr(0, MAX_SAFE_UINT);
const w: ProfileWire = {
id: '',
provider: '',
network: '',
chainHash: new Uint8Array(0),
publicKey: new Uint8Array(0),
period: 0,
genesisTime: 0,
scheme: '',
genesisSeed: new Uint8Array(0),
};
const pairs = d.map(WIRE_KEYS);
if (pairs !== WIRE_KEYS) throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `${pairs} keys, want all ${WIRE_KEYS}`);
for (let want = 0; want < WIRE_KEYS; want++) {
const k = d.key();
if (k !== want) throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `key ${k} where key ${want} was expected`);
withContext(`key ${k}`, () => {
switch (want) {
case 0:
d.text(utf8Length(PROFILE_TYPE_TAG));
break;
case 1:
d.uint(PROFILE_SCHEMA_VERSION);
break;
case 2:
w.id = d.text(UNBOUNDED);
break;
case 3:
w.provider = d.text(UNBOUNDED);
break;
case 4:
w.network = d.text(UNBOUNDED);
break;
case 5:
w.chainHash = d.bstr(32, 32);
break;
case 6:
w.publicKey = d.bstr(0, UNBOUNDED);
break;
case 7:
// Spec §11: period in 1..2^53-1.
w.period = d.uint(MAX_SAFE_UINT);
if (w.period === 0) throw new DateKeysError('ERR_NON_CANONICAL_CBOR', 'period 0');
break;
case 8:
// Spec §11: genesis_time in 0..2^53-1, unsigned.
w.genesisTime = d.uint(MAX_SAFE_UINT);
break;
case 9:
w.scheme = d.text(UNBOUNDED);
break;
default:
w.genesisSeed = d.bstr(32, 32);
}
});
}
d.endMap();
return { typeTag, version, id, provider, network, chainHash, publicKey, period, genesisTime, scheme, genesisSeed };
return w;
}
function encodeWire(e: Encoder, w: ProfileWire): void {
e.map(11);
e.map(WIRE_KEYS);
e.uint(0);
e.text(w.typeTag);
e.text(PROFILE_TYPE_TAG);
e.uint(1);
e.uint(w.version);
e.uint(PROFILE_SCHEMA_VERSION);
e.uint(2);
e.text(w.id);
e.uint(3);
@ -172,7 +214,7 @@ export function canonicalCBOR(p: Profile): Uint8Array {
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', 'profile: chain hash and genesis seed must be 32 bytes');
}
const e = new Encoder();
encodeWire(e, { typeTag: PROFILE_TYPE_TAG, version: PROFILE_SCHEMA_VERSION, ...p });
encodeWire(e, p);
return e.out();
}
@ -182,46 +224,65 @@ export async function profileHash(p: Profile): Promise<Uint8Array> {
}
/**
* Parses the Deterministic CBOR of a Provider Profile and validates it. It
* does not make the profile trusted: only a registry built by the caller
* does (spec §13).
* Parses the Deterministic CBOR of a Provider Profile and validates it with
* rules 1 to 3 of spec §12.1, in their order: the type tag, the schema
* version and the CDDL with the period limit (ERR_NON_CANONICAL_CBOR or
* ERR_UNSUPPORTED_VERSION), the field rules (ERR_UNKNOWN_PROFILE) and the
* chain-hash self-check (ERR_PROFILE_MISMATCH). It does not make the profile
* trusted: only a registry built by the caller does (spec §13).
*/
export async function decodeProfile(b: Uint8Array): Promise<Profile> {
withContext('profile', () => checkSchema(b, PROFILE_TYPE_TAG, PROFILE_SCHEMA_VERSION));
const w = withContext('profile', () => unmarshal(b, decodeWire, encodeWire));
if (w.chainHash.length !== 32 || w.genesisSeed.length !== 32) {
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', 'profile: chain hash and genesis seed must be 32 bytes');
}
// Spec §11: period in 1..2^53-1 and genesis_time in 0..2^53-1; the decoder
// already rejected every integer above 2^53-1.
if (w.period === 0) {
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `profile: period ${w.period} or genesis time ${w.genesisTime} outside the schema`);
}
const w = withContext('profile', () => {
checkSchema(b, PROFILE_TYPE_TAG, PROFILE_SCHEMA_VERSION);
return unmarshal(b, decodeWire, encodeWire);
});
if (w.period > MAX_PERIOD) throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `profile: period ${w.period} s out of range`);
const p: Profile = {
id: w.id,
provider: w.provider,
network: w.network,
chainHash: w.chainHash,
publicKey: w.publicKey,
period: w.period,
genesisTime: w.genesisTime,
scheme: w.scheme,
genesisSeed: w.genesisSeed,
};
const p: Profile = { ...w };
await validateProfile(p);
return p;
}
// ---------------------------------------------------------------------------
// Validation
// Validation (spec §12.1)
/**
* Checks the syntax of every field and, for drand profiles, that the scheme
* is supported by tlock, that the public key is a valid group element and
* that the chain hash is the drand chain-info hash of the other parameters.
* Applies rules 1 to 3 of spec §12.1 to a Profile value, in their order, so
* that it reports the code decodeProfile reports for the encoding of the
* value (spec §69.1):
*
* 1. the schema rules a value can break (ERR_NON_CANONICAL_CBOR): a period
* that is not a whole number of seconds in 1..86400, the implementation
* limit of spec §74; a genesis time outside 0..2^53-1; a name that is not
* well-formed Unicode; a chain hash or a genesis seed that is not 32
* bytes;
* 2. the rules of each field (ERR_UNKNOWN_PROFILE): the name alphabets and
* their length limits, the public key length limit, genesis_time in
* 1..253402300798, the provider drand, a scheme tlock supports, and a
* public key in the prime-order subgroup of the key group of the scheme,
* not the identity; period at most 2^32-1 is also a rule of this point,
* which the limit of point 1 makes unreachable here (chainInfoHash still
* enforces it);
* 3. the chain-hash self-check (ERR_PROFILE_MISMATCH): chain_hash is the
* drand chain-info hash of the other parameters (chainInfoHash).
*/
export async function validateProfile(p: Profile): Promise<void> {
if (!Number.isSafeInteger(p.period) || p.period <= 0 || p.period > MAX_PERIOD) {
throw new DateKeysError(
'ERR_NON_CANONICAL_CBOR',
`profile ${goQuote(p.id)}: period ${p.period}s is not a whole number of seconds in 1..${MAX_PERIOD}`,
);
}
if (!Number.isSafeInteger(p.genesisTime) || p.genesisTime < 0) {
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `profile ${goQuote(p.id)}: genesis time ${p.genesisTime} outside 0..${MAX_SAFE_UINT}`);
}
for (const s of [p.id, p.provider, p.network, p.scheme]) {
if (!s.isWellFormed()) {
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `profile ${goQuote(p.id)}: name ${goQuote(s)} is not valid UTF-8`);
}
}
if (p.chainHash.length !== 32 || p.genesisSeed.length !== 32) {
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `profile ${goQuote(p.id)}: chain hash and genesis seed must be 32 bytes`);
}
if (!validID(p.id)) throw new DateKeysError('ERR_UNKNOWN_PROFILE', `profile: invalid profile_id ${goQuote(p.id)}`);
if (!validName(p.provider) || !validName(p.network) || !validName(p.scheme)) {
throw new DateKeysError('ERR_UNKNOWN_PROFILE', `profile ${p.id}: invalid provider, network or scheme name`);
@ -229,10 +290,7 @@ export async function validateProfile(p: Profile): Promise<void> {
if (p.publicKey.length === 0 || p.publicKey.length > MAX_PUBLIC_KEY_LEN) {
throw new DateKeysError('ERR_UNKNOWN_PROFILE', `profile ${p.id}: invalid public key length ${p.publicKey.length}`);
}
if (!Number.isSafeInteger(p.period) || p.period <= 0 || p.period > MAX_PERIOD) {
throw new DateKeysError('ERR_UNKNOWN_PROFILE', `profile ${p.id}: invalid period ${p.period}s`);
}
if (!Number.isSafeInteger(p.genesisTime) || p.genesisTime <= 0 || p.genesisTime >= MAX_UNIX_TIME) {
if (p.genesisTime <= 0 || p.genesisTime >= MAX_UNIX_TIME) {
throw new DateKeysError('ERR_UNKNOWN_PROFILE', `profile ${p.id}: invalid genesis time ${p.genesisTime}`);
}
if (p.provider !== PROVIDER_DRAND) {
@ -272,12 +330,24 @@ async function validateDrand(p: Profile): Promise<void> {
}
}
/** The largest period the chain-info hash can encode, 2^32-1 seconds (spec §12.1). */
export const MAX_CHAIN_HASH_PERIOD = 2 ** 32 - 1;
/**
* The drand chain-info hash: SHA-256 of the period (uint32), the genesis
* time (int64), the public key, the genesis seed and, for a network other
* than "default", its name.
* The drand chain-info hash of spec §12.1, rule 3:
*
* SHA-256(uint32_be(period) || int64_be(genesis_time) || public_key ||
* genesis_seed || network)
*
* with network the UTF-8 bytes of key 4, left out when it is "default";
* profile_id, provider and scheme are not hashed. A period outside
* 1..2^32-1 has no chain hash: rule 2 rejects it with ERR_UNKNOWN_PROFILE,
* and so does this function rather than truncate it to 32 bits.
*/
async function chainInfoHash(p: Profile): Promise<Uint8Array> {
export async function chainInfoHash(p: Profile): Promise<Uint8Array> {
if (!Number.isSafeInteger(p.period) || p.period < 1 || p.period > MAX_CHAIN_HASH_PERIOD) {
throw new DateKeysError('ERR_UNKNOWN_PROFILE', `profile ${p.id}: period ${p.period}s does not fit the 32 bits of the chain hash`);
}
const fixed = new Uint8Array(12);
const view = new DataView(fixed.buffer);
view.setUint32(0, p.period);
@ -330,14 +400,19 @@ export interface Pin {
/**
* Validates every profile, checks it against its expected profile_hash and
* returns an immutable registry holding private copies.
* returns an immutable registry holding private copies. Each profile goes
* through the rules of spec §12.1 in their order, as its encoding would: it
* is encoded and decoded again (rules 1 to 3, with the codes decodeProfile
* reports), and only then compared with its pinned profile_hash (rule 4).
*/
export async function newRegistry(...pins: Pin[]): Promise<ProfileRegistry> {
const m = new Map<string, Profile>();
for (const pin of pins) {
const p = cloneProfile(pin.profile);
await validateProfile(p);
const h = await profileHash(p);
// Rules 1 to 3 of spec §12.1 as the encoding of the profile would get
// them, then rule 4, the pinned profile_hash.
const b = canonicalCBOR(pin.profile);
const p = await decodeProfile(b);
const h = await sha256(b);
if (!equalBytes(h, pin.hash)) {
throw new DateKeysError(
'ERR_PROFILE_MISMATCH',

@ -0,0 +1,43 @@
// Helpers shared by the decoders of the protocol objects (PUBLIC_HEADER,
// CONTROL_CBOR, .dkk BODY_CBOR), as the Go reference writes each of them: a
// loop over the keys of the map, one read per key, "key N: " before the
// error of a read, and the required keys checked after the loop.
import type { Encoder, Uint } from './cbor.ts';
import { DateKeysError, withContext } from './errors.ts';
import { encodeArray, type Extension } from './extension.ts';
/** Runs the read of the value of key `k`, prefixing its error with "key k". */
export function fieldOf(k: Uint): <T>(read: () => T) => T {
return (read) => withContext(`key ${k}`, read);
}
/** Requires the keys 0 to n-1 in `seen` (ERR_NON_CANONICAL_CBOR otherwise). */
export function requireKeys(seen: ReadonlySet<number>, n: number): void {
for (let k = 0; k < n; k++) {
if (!seen.has(k)) throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `key ${k} is missing`);
}
}
/** 1 for an extension array that is written, 0 for one that is omitted (spec §58.1). */
export function presence(exts: readonly Extension[] | undefined): number {
return exts !== undefined && exts.length > 0 ? 1 : 0;
}
/**
* Writes the critical and noncritical extension arrays at keys `key` and
* `key + 1`, each only when it is present (spec §58.1).
*/
export function encodeExtensionArrays(
e: Encoder,
key: number,
critical: readonly Extension[] | undefined,
noncritical: readonly Extension[] | undefined,
): void {
for (const [i, exts] of [critical, noncritical].entries()) {
if (presence(exts) === 1) {
e.uint(key + i);
encodeArray(e, exts!);
}
}
}

@ -0,0 +1,131 @@
// Readers of the shared vector formats of the Go reference, as
// testdata/README.md documents them. Every reader checks the structure it
// reads and throws on anything else: an unknown key, a missing one or a value
// of another type, so that a format change fails the harness instead of
// being skipped. Tests only.
import { fromHex } from '../bytes.ts';
import { isErrorCode } from '../errors.ts';
export type Json = Record<string, unknown>;
/** The version of the specification every vector file must name. */
export const SPEC_VERSION = '0.8.2';
export class FormatError extends Error {
constructor(where: string, what: string) {
super(`${where}: ${what}`);
this.name = 'FormatError';
}
}
/** Requires a plain JSON object. */
export function object(v: unknown, where: string): Json {
if (v === null || typeof v !== 'object' || Array.isArray(v)) throw new FormatError(where, 'not an object');
return v as Json;
}
/** Requires an array. */
export function array(v: unknown, where: string): unknown[] {
if (!Array.isArray(v)) throw new FormatError(where, 'not an array');
return v;
}
/**
* Requires exactly the keys of `required` and at most those of `optional`:
* an unknown key fails, and so does a missing one.
*/
export function keys(o: Json, where: string, required: readonly string[], optional: readonly string[] = []): void {
for (const k of required) if (!Object.hasOwn(o, k)) throw new FormatError(where, `missing "${k}"`);
for (const k of Object.keys(o)) {
if (!required.includes(k) && !optional.includes(k)) throw new FormatError(where, `unknown key "${k}"`);
}
}
export function str(v: unknown, where: string): string {
if (typeof v !== 'string') throw new FormatError(where, 'not a string');
return v;
}
export function bool(v: unknown, where: string): boolean {
if (typeof v !== 'boolean') throw new FormatError(where, 'not a boolean');
return v;
}
/** A non-negative safe integer. */
export function int(v: unknown, where: string): number {
if (!Number.isSafeInteger(v) || (v as number) < 0) throw new FormatError(where, 'not a non-negative integer');
return v as number;
}
/** Lowercase hexadecimal, as every binary value of the files (README conventions). */
export function hexBytes(v: unknown, where: string): Uint8Array {
const s = str(v, where);
if (!/^(?:[0-9a-f]{2})*$/.test(s)) throw new FormatError(where, 'not lowercase hex');
return fromHex(s);
}
/** A normative code of spec §69. */
export function code(v: unknown, where: string): string {
const s = str(v, where);
if (!isErrorCode(s)) throw new FormatError(where, `"${s}" is not a normative code`);
return s;
}
/** "ok" or a normative code. */
export function result(v: unknown, where: string): string {
const s = str(v, where);
return s === 'ok' ? s : code(s, where);
}
/** A step of the reading flow of spec §63, 1 to 18. */
export function step(v: unknown, where: string): number {
const n = int(v, where);
if (n < 1 || n > 18) throw new FormatError(where, `step ${n} outside 1..18`);
return n;
}
/** The file-level "spec" field. */
export function checkSpec(o: Json, where: string): void {
if (str(o.spec, `${where}.spec`) !== SPEC_VERSION) throw new FormatError(where, `spec ${String(o.spec)}, want ${SPEC_VERSION}`);
}
/** One edit [at, delete, insert] of README "Edited files". */
export interface Edit {
readonly at: number;
readonly delete: number;
readonly insert: Uint8Array;
}
export function edits(v: unknown, where: string): Edit[] {
return array(v, where).map((e, i) => {
const w = `${where}[${i}]`;
const a = array(e, w);
if (a.length !== 3) throw new FormatError(w, 'an edit is [at, delete, insert]');
return { at: int(a[0], `${w}.at`), delete: int(a[1], `${w}.delete`), insert: hexBytes(a[2], `${w}.insert`) };
});
}
/**
* Applies edits to `base` in one pass, as README "Edited files" states: the
* edits refer to offsets of the unmodified base, are sorted by offset, do not
* overlap and stay within the base. Anything else throws.
*/
export function applyEdits(base: Uint8Array, list: readonly Edit[]): Uint8Array {
const parts: Uint8Array[] = [];
let pos = 0;
for (const [i, e] of list.entries()) {
if (e.at < pos) throw new Error(`edit ${i} at ${e.at} is out of order or overlaps the previous one`);
if (e.at + e.delete > base.length) throw new Error(`edit ${i} goes beyond the base of ${base.length} bytes`);
parts.push(base.subarray(pos, e.at), e.insert);
pos = e.at + e.delete;
}
parts.push(base.subarray(pos));
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let o = 0;
for (const p of parts) {
out.set(p, o);
o += p.length;
}
return out;
}

File diff suppressed because it is too large Load Diff

@ -2,7 +2,7 @@
// keyed by input: every lookup below is a switch over a closed set of values
// that the library produces (step numbers, normative codes, policies).
import { decodeUtf8, type ErrorCode, goIsPrintNonASCII, goQuote, type Instant, type InspectView } from '../dkc/index.ts';
import { decodeUtf8, type ErrorCode, goIsPrintNonASCII, goQuote, type Instant, inspectJSON, type InspectView } from '../dkc/index.ts';
/** The eight steps of spec §63 run before unlock, in order. */
export const INSPECT_STEPS = [1, 2, 3, 4, 5, 6, 7, 8] as const;
@ -241,16 +241,10 @@ export function formatRelative(epochMs: number, nowMs: number): string {
// JSON of the CLI
/**
* The exact output of `datekeys inspect -json`: Go's json.Encoder with
* SetIndent("", " "), which escapes <, > and & (SetEscapeHTML is on by
* default) and U+2028 and U+2029, and ends with a newline. Keys and non-string
* values never hold those characters, so escaping the whole text only touches
* strings. JSON.stringify writes every other escape as Go 1.22 and later do.
* The exact output of `datekeys inspect -json`, the library's inspectJSON:
* Go's json.Encoder with SetIndent("", " "), <, >, &, U+2028 and U+2029
* escaped, and a final newline.
*/
export function cliJSON(view: InspectView): string {
const text = JSON.stringify(view, null, 2).replace(
/[<>&\u2028\u2029]/g,
(c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`,
);
return `${text}\n`;
return inspectJSON(view);
}

326
testdata/README.md vendored

@ -0,0 +1,326 @@
# 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. The rules that decide each verdict are in the specification;
this file points to them, and states only what belongs to the files
themselves.
```
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, by rules 1 to 3 of spec §12.1 in their order: the
CDDL with the `period` limit of the reference, the field rules
(`ERR_UNKNOWN_PROFILE`) and the chain-hash self-check
(`ERR_PROFILE_MISMATCH`), whose formula §12.1 gives. No `profile_hash` is
expected (rule 4). 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 the one of the first failing
layer of spec §69.1: the type tag and the schema version, read first from keys
0 and 1 (layer 2), then the CDDL, the implementation limits included, and the
re-encoding (layer 3), and only then the fields with codes of their own in
ascending key order (layer 4). The objects of these vectors have no frame, so
layer 1 does not apply, and their critical extensions are not checked (see
above).
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
The vectors apply the implementation limits of the reference that spec §74
lists: the maximum `extension_id` length, the Provider Profile `period` of one
day, the maximum name lengths and the `public_key` length. 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 name alphabets, the
`genesis_time` range and the drand rules of the `provider_profile`
validation are not limits: they are rules of spec §12.1. Neither is the
minimum of one byte of `extension_id` (spec §31).
## `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` is checked
before any release request (spec §63 step 9.a).
- `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 and of step 9 before the request (9.a to 9.c).
- `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 summarised in "The
checks of steps 1 to 8" below.
### Credentials and the release: step 9
What happens between step 8 and the release request is spec §63 step 9: for
`time_and_key` only, an offered `.dkk` is checked as an object and then bound
to the capsule (9.a), at least one credential must be offered (9.b), and for
either policy a `now` before the round time of the DateKey fails without a
request (9.c); only then is the release requested. For `time_only` the
credentials play no part: 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. The codes after the
request, for the release (step 10) and for the identities that open each age
file (steps 11, 13 and 17), are those of spec §63 as well. In this corpus:
- identities are not examined before the release (spec §63 step 13);
- a `release` of `null` is `ERR_RELEASE_UNAVAILABLE` at step 9;
- every `.dkk` offered decodes: the corpus checks step 9.a, not the decoding
of a `.dkk`, whose errors spec §63 also places at step 9.a.
## The checks of steps 1 to 8
`mutations.json` and `inspect_differential.json` follow the rules of the
specification for steps 1 to 8, with the `default` registry (Quicknet pinned),
no extension known unless a case lists some, and no secret. The first failure
ends the flow, and within one object the first failing layer decides the code
(spec §69.1):
| Step | Rules | Spec |
|---|---|---|
| 1, 2 | magic, truncated prelude, framing version, FLAGS and RESERVED, `PUBLIC_HEADER_LEN` in 1 to 1048576 and `SEALED_CONTROL_LEN` in 1 to 67108864 | §22, §23 |
| 3 | the PUBLIC_HEADER bytes are present | §23 |
| 4 | PUBLIC_HEADER: layers 2 to 4, the `public_header` decoder of the schema vectors, then the pinned profile of the DateKey and the critical extensions; with no extension known, every `critical_extensions` array fails | §63, §69.1 |
| 5 | the SEALED_CONTROL bytes are present, then its age header, parsed within `SEALED_CONTROL_LEN` bytes: malformed is `ERR_INTEGRITY`, one tlock stanza is required | §28.1 |
| 6 | the age header of PAYLOAD_AGE, from its offset to the end of the file: malformed is `ERR_INTEGRITY`, one X25519 stanza is required | §22, §28.1 |
| 7 | the round time of the DateKey is at most 9999-12-31T23:59:59Z (Quicknet: round 83903165811 at most); a `dk1_` round above it passes step 4 and fails here | §15 |
| 8 | the tlock stanza has exactly two arguments, the canonical decimal round and the lowercase hex chain hash, compared as strings | §63 step 8 |
The reference parses age headers with `filippo.io/age` v1.3.2, whose parser
limits (1024 stanzas, 128 arguments after the type, 2 MiB) spec §74 lists as
implementation limits. No case of these corpora depends on them.
## `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 that "The checks of
steps 1 to 8" above points to. 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.

@ -1,13 +1,16 @@
{
"module": "g.activething.com/go/DateKeys",
"commit": "afb44a396ea23db34ca3495130a2414198cfe5db",
"commit": "382006649fc950ffc41c6adb1346055860280cf5",
"files": {
"README.md": "f8f41632ee3e7cd3f7a3d3fa4459bc7bdbd201d905109ba8a9f9c6a5defcda78",
"fixtures/empty_payload.dkc": "871e9bf05b52bbae17f3adfbbf97b46e7f0e53aa8f57bcaa506e43f36f53a9d4",
"fixtures/empty_payload.inspect.json": "dd2b21faefdfe3aa59b5eeef9130a6896070ae30a2b16933f3aefd146828207e",
"fixtures/empty_payload.json": "490a6e7f7acc882bcb956b297ad8c817d7b21da29e8d4c4a2cd31c852707eee0",
"fixtures/empty_payload.plaintext": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"fixtures/time_and_key_portable.dkc": "2e97878078bae6358037a9c264f379a3cbe839f767d69836b0343f35657b2972",
"fixtures/time_and_key_portable.dkk": "e528fa2c832c91119f0684bb9d6fb3c4c2d0d55183482890e7c4fe92f668426a",
"fixtures/time_and_key_portable.dkk.json": "24eab4758e4abdb09e2316e0330ac16872dd021612eb32c40fac607f80f10a18",
"fixtures/time_and_key_portable.inspect.json": "a80724f68facb94be3d64ec2587dd547be38b9e492e9c30149b1c6cf2753ba01",
"fixtures/time_and_key_portable.json": "258fb17f95a0dbf074d017bffac6415b2982dffc7895a3b31a1ccd9132d6a5fc",
"fixtures/time_and_key_portable.plaintext": "937492203d207d6fe36161b8696bf1f05b8b4cc56d855c44853f4b76aad3a05b",
"fixtures/time_and_key_portable_extension.dkk": "0bf463a7c65627b7dda2234d728df89ec5b835816a2a37b91497d8fecc5ea548",
@ -15,15 +18,21 @@
"fixtures/time_and_key_recipients.dkc": "69ac110380f5d768b5b6afaa157a50ed17d8ceccfbd4604ffa5b6da38539b635",
"fixtures/time_and_key_recipients.dkk": "19f6c47150c3194712d454f43c7392b7344e6b4e7b074d83e9ca5f563a8e072f",
"fixtures/time_and_key_recipients.dkk.json": "d5a0c241a1fbef52bcb848b2531bda4020d9a7882bfc4f49755c61ae97a4d6bc",
"fixtures/time_and_key_recipients.inspect.json": "12c7f2200474c383ad93de7b2835dc60b4d7486926ce02955ac3ae97fc23952d",
"fixtures/time_and_key_recipients.json": "ebc77cd00ff52e08743976558605159cc0cc3747399c3ce05dc46f7cafb35b2d",
"fixtures/time_and_key_recipients.plaintext": "0e9fd50e98a85953aa9cf07a11ee3c62bb3d7622f344f1c6ce744d1ed111659f",
"fixtures/time_only.dkc": "99e915810d595f1092700b728f5e5081d78efe83f5343e76325b1bcc2c33ccf2",
"fixtures/time_only.inspect.json": "67a95a7ff711ec830fcd53b9328d5cd7c70bb34a205bc72ae99610c22ed8a6f4",
"fixtures/time_only.json": "9ea5576bfa237066b92ad07e5f1c26d6d782e975edd071a8728b21bc7998afaa",
"fixtures/time_only.plaintext": "53b8ee821fb7b678e89d4f93da1812339f6cc1ab83aac6ed1432db99df784be5",
"fixtures/time_only_extensions.dkc": "0446c9b73e267adcb24e5cc89afba2544a386ec9a050016e06517a4a57aa2085",
"fixtures/time_only_extensions.inspect.json": "168d61d22e41e55a94d5f057afd2c4dd2ceef2a5bd4c5abaedea76c6faa29ab9",
"fixtures/time_only_extensions.json": "117636691ab04d0a6274d09de7c7763fe99595a46dae9c42d066f118f816e85b",
"fixtures/time_only_extensions.plaintext": "1129768e195e2f1e50b7a6f926b6eebef120212c29b5642c8a662c503b2a9131",
"vectors/dk1.json": "618ff753996f6967eddf9ebd781dff5943b9784c99c782aa323ce4f861d632e5",
"vectors/cbor.json": "444fe6104476fbcda91e1873c12752186dbf7e55ad0769e1eb0678ed9673c9d6",
"vectors/dk1.json": "3c461ffea754a80017f257767807330307d5b73a02d9e5b946e1fb82013271af",
"vectors/inspect_differential.json": "fe207b67d307e3ae7a78295b7a882d98669adbf498b725443ab4757eb9da03f7",
"vectors/mutations.json": "0ab1f3dd30f4aef86c39a69b314a2843d0819193153a97fd3b9959199d0dbd65",
"vectors/profile_quicknet.json": "4f9de60475d0807aa580f59cf3e6df38cba2a55a62f590aefa2a55753ddb0055",
"vectors/quicknet_rounds.json": "22c764aac454ce320daf7e65b9494ff2d207cac974c68d07e84f4ec18c3ed920"
}

@ -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

@ -148,6 +148,21 @@
"name": "uppercase prefix",
"input": "DK1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6NjY4ODQyMTJ9",
"error": "ERR_DATEKEY_INVALID"
},
{
"name": "line feed inside the Base64",
"input": "dk1_eyJ2ZXJz\naW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6NjY4ODQyMTJ9",
"error": "ERR_DATEKEY_INVALID"
},
{
"name": "carriage return and line feed after the Base64",
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6NjY4ODQyMTJ9\r\n",
"error": "ERR_DATEKEY_INVALID"
},
{
"name": "version 1.0000000000000001: its exact value, not a double",
"input": "dk1_eyJ2ZXJzaW9uIjoxLjAwMDAwMDAwMDAwMDAwMDEsIm5ldHdvcmsiOiJkYXRla2V5czpxdWlja25ldDp2MSIsInJvdW5kIjo2Njg4NDIxMn0",
"error": "ERR_DATEKEY_INVALID"
}
]
}

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.