Merge the alignment with DateKeys v0.8.2 (datekeys-go 692cf87)

TypeScript aligned with the Go reference at 692cf87: layered error
precedence, the refined spec rules and the §19 UTF-8 rule; every shared
Go vector file runs in the suite (2,375 tests, 24 phase-2 cases skipped
and counted). Two differentials against Go, 483,527 and 407,208 inputs
from independent generators, found no disagreement on verdict, code or
step; an independent review approved the merge.

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

@ -18,38 +18,38 @@ Sin dependencias de ejecución. Funciona en navegadores y en Node 20+: solo usa
`crypto.subtle` solo existe en contextos seguros: `https`, o `http` en `localhost`. La página del paso 5 servida por `http` desde una IP de la red local (por ejemplo `vite --host` para probar en un móvil) no lo tiene, y `inspect` rechaza entonces con un `Error` que lo dice (`SHA-256 needs Web Crypto (crypto.subtle), …`) en vez de dar un veredicto. El registro por defecto no memoriza ese fallo: la siguiente llamada lo vuelve a intentar.
| Fichero | Contenido | Equivale en Go (`afb44a3`) |
| Fichero | Contenido | Equivale en Go (`692cf87`) |
|---|---|---|
| `errors.ts` | `DateKeysError` con el código normativo de §69 (`ERR_*`); mensajes con la forma `contexto: CÓDIGO` de Go | `errors.go` |
| `bytes.ts` | Hex, UTF-8 estricto, `goQuote` (el `%q` de Go, con la tabla de `strconv.IsPrint` de Go 1.26 fijada en el código) y `sha256` (Web Crypto) | `strconv`, `unicode/utf8` |
| `cbor.ts` | Perfil de §58: `Encoder` con error persistente, `Decoder` estricto, `unmarshal` (decodifica, reencodifica y compara; `onReject` para borrar secretos), `peek`/`checkSchema` (tipo y versión antes del resto), `wideUint` (los dos campos que Go lee como `uint64`) y `walk` (lector genérico acotado) | `codec` |
| `extension.ts` | Mapas de extensión, reglas del array (1 a 64, orden por bytes UTF-8 de `extension_id`, sin repetidos ni solapes), registros, críticas y no críticas | `extension` |
| `profile.ts` | Provider Profile: CBOR exacto, `profile_hash`, validación completa, registro pinneado; Quicknet fijado por su CBOR y su hash | `profile` |
| `cbor.ts` | El codec propio de la referencia, con las mismas lecturas, las mismas comprobaciones en el mismo orden y los mismos textos de error: `Encoder` con error persistente; `Decoder`, cursor estricto (`map`/`key`/`endMap`, `array`, `uint`/`uint64`, `bstr`, `text`, `done`); `unmarshal` (decodifica, reencodifica y compara; `onReject` para borrar secretos); `peek`/`checkSchema` (capa 2 de §69.1: tipo y versión antes que nada) y `walk` (lector genérico acotado en profundidad y longitud) | `codec` |
| `schema.ts` | Lo que comparten los decodificadores de PUBLIC_HEADER, CONTROL_CBOR y el cuerpo de la `.dkk`: `key N: ` en los errores, claves obligatorias y arrays de extensiones | `capsule/framing.go`, `accesskey` |
| `extension.ts` | Arrays de extensiones, leídos con su objeto (capa 3 de §69.1): de 1 a 64, en orden estrictamente ascendente de los bytes UTF-8 de `extension_id` (nunca por unidades UTF-16), `extension_version` hasta 2³² − 1, `data` ausente o `bstr` no vacío, ningún id en los dos arrays; registros, críticas y no críticas (capa 4) | `extension` |
| `profile.ts` | Provider Profile: CBOR exacto, `profile_hash`, reglas 1 a 4 de §12.1 en su orden (el límite de `period` de §74 en la capa del esquema; alfabetos, clave pública del grupo del scheme y fórmula de `chain_hash`), registro pinneado; Quicknet fijado por su CBOR y su hash | `profile` |
| `bls12381.ts` | Pertenencia de claves públicas BLS12-381 comprimidas (G1 y G2) al subgrupo, como `FromCompressed` de kilic | `kyber-bls12381` |
| `datekey.ts` | `dk1_` canónico, ronda desde una fecha con precisión de nanosegundos, parser RFC 3339 equivalente a `time.Parse(time.RFC3339Nano, …)` | `datekey` |
| `header.ts`, `control.ts`, `accesskey.ts` | PUBLIC_HEADER, CONTROL_CBOR y `.dkk` (cuerpo y trama), decodificar y codificar | `capsule`, `accesskey` |
| `framing.ts` | Prelude DKC1 (16 bytes) y DKK1 (12 bytes), longitudes, límites de §57 y troceo de secciones | `capsule/framing.go` |
| `age.ts` | Parser estricto de la cabecera `age` v1 sobre los ficheros binarios, con los textos de error de `age`; reglas de stanzas; `MAX_AGE_HEADER_LEN` (2 MiB), el límite que usa la página para leer solo el prefijo de un `.dkc` grande | `agewrap`, `filippo.io/age/internal/format` |
| `inspect.ts` | Pasos 1 a 8 de §63 y la vista JSON de `datekeys inspect -json` | `capsule/inspect.go`, `cmd/datekeys` |
| `datekey.ts` | `dk1_` canónico con las reglas de lectura de §19 (CR, LF y todo carácter fuera del alfabeto fallan el paso 1; números JSON por su valor decimal exacto), ronda desde una fecha con precisión de nanosegundos y cota de 9999-12-31T23:59:59Z (§15), parser RFC 3339 equivalente a `time.Parse(time.RFC3339Nano, …)` | `datekey` |
| `header.ts`, `control.ts`, `accesskey.ts` | PUBLIC_HEADER, CONTROL_CBOR y `.dkk` (cuerpo y trama), decodificar y codificar, con las capas de §69.1 | `capsule`, `accesskey` |
| `framing.ts` | Prelude DKC1 (16 bytes) y DKK1 (12 bytes) en el orden de §23 y §40, longitudes de 1 byte hasta los límites de §57, y troceo de secciones | `capsule/framing.go` |
| `age.ts` | Parser estricto de la cabecera `age` v1 (§28.1) sobre los ficheros binarios, con los textos de error de `age`; reglas de stanzas; `MAX_AGE_HEADER_LEN` (2 MiB), el límite que usa la página para leer solo el prefijo de un `.dkc` grande | `agewrap`, `filippo.io/age/internal/format` |
| `inspect.ts` | Pasos 1 a 8 de §63 y la vista JSON de `datekeys inspect -json` (`inspectView`, `inspectJSON`) | `capsule/inspect.go`, `internal/inspectview` |
| `index.ts` | Reexporta todo | |
| `testing/` | Solo para tests: lectura de `testdata/`, constructores de CBOR en hex, cirugía de cápsulas | |
| `testing/` | Solo para tests: lectura de `testdata/` y de sus formatos (`vectors.ts`: ediciones, vectores), constructores de CBOR en hex, cirugía de cápsulas | |
Los tests (`*.test.ts`) están junto a cada fichero.
### Equivalencia con la referencia Go
El comportamiento se contrastó con la librería Go en `afb44a3` mediante un oráculo diferencial fuera del repositorio: unos 310 000 casos (cápsulas mutadas byte a byte y estructuralmente, cabeceras, controles, `.dkk`, perfiles, cabeceras `age`, `dk1_`, fechas y cabeceras de schema). Coinciden el veredicto, el código, el paso y los campos, los textos de todos los pasos superados y los textos de fallo de framing, `age`, DateKey, perfiles y extensiones. La vista de `inspect` de cada fixture es idéntica byte a byte a la salida de `datekeys inspect -json`.
El comportamiento se contrastó con la librería Go en `3820066` (`692cf87` solo cambia la regla de UTF-8 de `dk1_` descrita abajo) mediante un oráculo diferencial fuera del repositorio: 483 527 entradas de tres semillas. Son mutaciones de los cinco `.dkc` oficiales de todas las clases (bits, bytes, truncados, inserciones, borrados, longitudes y campos del prelude, cabeceras reescritas con cambios de CBOR, DateKeys, arrays de extensiones con y sin registro de extensiones, cabeceras `age` de SEALED_CONTROL y de PAYLOAD_AGE, argumentos del stanza tlock, rondas, secciones cambiadas de sitio y combinaciones de varios defectos); CBOR de cada esquema (PUBLIC_HEADER, CONTROL_CBOR, cuerpo y fichero `.dkk`, Provider Profile, arrays de extensiones); `walk`, `peek` y `checkSchema`; cabeceras `age`, preludes, cadenas `dk1_`, rondas y fechas. En todas coinciden el veredicto, el código y el paso, y también el texto del error, el valor decodificado y la salida entera de `datekeys inspect -json`, byte a byte.
Enteros: el decodificador rechaza al leerlo todo entero por encima de 2⁵³ − 1 (plan §5), salvo en `access_policy` de PUBLIC_HEADER y en `extension_version`. Go lee esos dos campos como `uint64` y comprueba su rango después de la DateKey y de los demás campos, con otro código; `Decoder.wideUint` los lee igual (un valor por encima de 2⁵³ − 1 queda como `Infinity` con sus 8 bytes, para reencodificarlo y escribirlo en decimal), y el orden, el código y el texto coinciden con Go.
Precedencia de errores (§69.1): primero la trama; después el tipo y la versión de esquema (`checkSchema`); después el perfil CBOR y el CDDL, con los límites de implementación de §74, en una sola decodificación (`unmarshal` con el decodificador del esquema, que ya comprueba tipos, tamaños, rangos, `access_policy`, `extension_version`, el máximo de 64 extensiones y su orden); y solo entonces los campos con código propio, en orden de clave: DateKey, perfil fijado y extensiones críticas en PUBLIC_HEADER; `access_type` y `access_material` en la `.dkk`; los campos y la autocomprobación de `chain_hash` en el Provider Profile. Entre pasos decide el orden de §63. Un PUBLIC_HEADER que rompe a la vez el CDDL y la DateKey da `ERR_NON_CANONICAL_CBOR`, como en la referencia de `3820066`; la de `afb44a3` leía `access_policy` y `extension_version` como `uint64` y los acotaba después de la DateKey, y la vía `wideUint` que lo imitaba ya no existe.
Diferencias conocidas:
Enteros: el decodificador lee cada entero hasta 2⁶⁴ − 1 (un `bigint` por encima de 2⁵³ − 1) y lo compara con el máximo de su campo, con el texto de la referencia (`unsigned integer 9007199254740992 above 9007199254740991`). Ningún campo de un objeto del protocolo admite más de 2⁵³ − 1; `walk` admite cualquier `uint64`, como `codec.Walk`.
- **Enteros por encima de 2⁵³ − 1 en los demás sitios.** En `walk` y en los campos de perfil, control y `.dkk`, TypeScript los rechaza al decodificar y Go los acepta como `uint64` y los rechaza después. El código es siempre `ERR_NON_CANONICAL_CBOR` en los dos y el paso el mismo; cambia el texto. Un caso `accept` de `cbor.json` con un entero así (el genérico de Go) se espera rechazado aquí con `ERR_NON_CANONICAL_CBOR`.
- **Textos de errores de decodificación CBOR.** Los de Go vienen de `fxamacker/cbor` (`codec: decode: …`); los de TypeScript describen el mismo fallo con otras palabras. El código y el paso coinciden.
`peek` lee la capa 2 de §69.1 como `codec.Peek`: una cabecera de mapa de longitud definida que anuncia al menos dos entradas y no más de la mitad de los bytes que la siguen, la clave 0 con un texto de hasta 64 bytes y la clave 1 con un entero de hasta 2⁵³ − 1, todo en su forma más corta. No lee nada más: una versión 2 es `ERR_UNSUPPORTED_VERSION` sea lo que sea lo que venga detrás, y cualquier otra cosa en esas posiciones, `ERR_NON_CANONICAL_CBOR`.
`goQuote` escribe como Go 1.26 (Unicode 15.0.0) los textos entre comillas de los detalles de fallo: usa una tabla de `strconv.IsPrint` generada con Go y no `\p{…}`, porque cada motor JavaScript trae su propia versión de Unicode (V8 en Node 24 ya imprime runas de Unicode 16 que Go escapa, como U+31E4). `bytes.test.ts` fija el SHA-256 del conjunto completo de runas imprimibles. Si el toolchain de la referencia cambia de versión de Unicode, se regenera con `go run scripts/go-isprint-table.go` y se actualizan la tabla y el hash.
UTF-8 inválido en el JSON de un `dk1_` hace fallar el paso 2 de §19 (`ERR_DATEKEY_INVALID`) en las dos implementaciones, también en un miembro que un nombre repetido reemplaza después. Hasta `692cf87` la referencia Go lo sustituía por U+FFFD (`encoding/json`) y en ese caso daba `ERR_DATEKEY_NON_CANONICAL`; el diferencial de esta implementación lo encontró y la referencia se corrigió para seguir al spec. `datekey.test.ts` y el vector *invalid UTF-8 in a member a repeated name overwrites* de `dk1.json` lo fijan.
El lector de schema (`peek`) reproduce a propósito lo que acepta `codec.Peek` de `afb44a3`, incluidas sus rarezas (una versión `null` cuenta como 0, un valor simple `e2` como 2), para que el código `ERR_UNSUPPORTED_VERSION` frente a `ERR_NON_CANONICAL_CBOR` sea el mismo. Si el codec propio de Go (paso 2b) cambia ese comportamiento, habrá que seguirlo.
`goQuote` escribe como Go 1.26 (Unicode 15.0.0) los textos entre comillas de los detalles de fallo: usa una tabla de `strconv.IsPrint` generada con Go y no `\p{…}`, porque cada motor JavaScript trae su propia versión de Unicode (V8 en Node 24 ya imprime runas de Unicode 16 que Go escapa, como U+31E4). `bytes.test.ts` fija el SHA-256 del conjunto completo de runas imprimibles. Si el toolchain de la referencia cambia de versión de Unicode, se regenera con `go run scripts/go-isprint-table.go` y se actualizan la tabla y el hash.
Secretos: `access_material` de un `.dkk` e `I_PAYLOAD` de CONTROL_CBOR se borran en todos los caminos, también cuando la decodificación falla a medias o `unmarshal` rechaza el valor, como el `clear` diferido de Go.
@ -66,7 +66,7 @@ Sitio SvelteKit estático (`@sveltejs/adapter-static`, `strict`): las dos págin
- cada paso con su número, su nombre de la CLI, `superado` o el código normativo, y el detalle (los caracteres invisibles o de control se escriben como `\uXXXX`);
- el veredicto, `capsule_id`, la DateKey compacta y decodificada (red y ronda), el perfil fijado, la fecha de apertura en UTC y en la hora local del navegador, `access_policy`, los campos del prelude, los argumentos del stanza `tlock` frente al perfil fijado y el número y tipo de stanzas de OUTER_TIME_AGE y PAYLOAD_AGE;
- las extensiones de PUBLIC_HEADER según el contrato del plan §8: id (entre comillas y escapado si tiene caracteres no imprimibles), versión, crítica o no, conocida o no, longitud y hex (plegado si pasa de 64 bytes); texto si los bytes son UTF-8 imprimible; vista CBOR con `walk` si son un ítem del perfil de §58, marcada "informativo, no validado por el protocolo"; y el aviso de que son públicas y no están autenticadas hasta el paso 15;
- las extensiones de PUBLIC_HEADER según el contrato del plan §8: id (entre comillas y escapado si tiene caracteres no imprimibles), versión, crítica o no, conocida o no, longitud y hex (plegado si pasa de 64 bytes); texto si los bytes son UTF-8 imprimible; vista CBOR con `walk` si son un ítem del perfil de §58, marcada "informativo, no validado por el protocolo"; y el aviso de que son públicas, de que nada las vincula al resto de la cápsula antes del paso 15 y de que ni entonces prueban autoría (§55.1);
- **Copiar JSON**, que copia exactamente la salida de `datekeys inspect -json` (`cliJSON`: el `json.Encoder` de Go con sangría de dos espacios, `<`, `>`, `&`, U+2028 y U+2029 escapados y salto de línea final). `file` es el nombre del fichero.
No pide ni acepta secretos. Todo el texto leído de la cápsula pasa por interpolación de texto de Svelte (nunca `{@html}`), y ni las entradas de mapas CBOR ni los identificadores de extensión se usan nunca como claves de objetos o `Map` de JavaScript.
@ -133,13 +133,12 @@ Umbrales de cobertura (`vitest.config.ts`): `cbor.ts` al 100 % en líneas, ramas
### Vectores y fixtures
- `testdata/fixtures/*.json`: cada `.dkc` pasa los pasos 1 a 8 con los valores registrados (prelude, PUBLIC_HEADER, DateKey, `capsule_id`, política, `unlock_at`, extensiones con sus bytes exactos, stanzas, `header_binding`, CONTROL_CBOR); cada `.dkk` se decodifica campo a campo y se reencodifica byte a byte. Los fixtures nuevos se recogen solos.
- `testdata/vectors/dk1.json`, `quicknet_rounds.json`, `profile_quicknet.json`: se ejecutan todos.
- Ficheros que Go producirá (plan §6 y §7), ya previstos en `vectors.test.ts`; se saltan mientras no existan y, cuando existen, cada caso se revisa antes de ejecutarlo: falla el fichero cuya estructura no se reconoce y el caso al que le falta un campo, nunca se salta en silencio.
- `vectors/cbor.json`: `accept`/`reject` con `walk` y bloques por schema (`public_header`, `control`, `access_key`, `profile`, `extension`…), cada caso con bytes y `error` o `value`. Un bloque sin decodificador, vacío o con un caso sin bytes falla.
- `vectors/mutations.json`: casos con bytes, código normativo (`error`, `code` o `want`) y `step` numérico; se ejecutan los de los pasos 1 a 8 (al menos 23, plan §7.4) y los demás se muestran como saltados.
- `vectors/inspect_differential.json`: casos con bytes y el veredicto de Go (`valid`, `error` o `checks`, en el caso o bajo `view`, `inspect`, `result`, `go`, `verdict`, `expected` o `want`); un caso sin veredicto falla.
- `**/*.inspect.json`: salida de `datekeys inspect -json` junto a su `.dkc`; se compara la vista entera salvo `file`, y el texto de los pasos superados.
- Bytes de un caso: `hex` es hex estricto; `base64`/`b64`, base64 canónico; `file`/`path`, un fichero bajo `testdata/`; `dkc`, `bytes`, `input` y `data`, hex estricto, si no un fichero bajo `testdata/`, si no base64 canónico. Cualquier otro texto hace fallar el caso.
- `testdata/fixtures/*.inspect.json`: la vista de `inspect` de cada `.dkc`, escrita con `inspectJSON` como la imprime la CLI (`file` incluido), es idéntica byte a byte al fichero, también el texto de cada paso.
- `testdata/vectors/dk1.json` (con los tres vectores de los refinamientos de §19: LF dentro del Base64, CR y LF después, y la versión `1.0000000000000001`), `quicknet_rounds.json` y `profile_quicknet.json`: se ejecutan todos.
- `testdata/vectors/cbor.json`: cada vector genérico (`accept` y `reject`) pasa por `walk` con los `max_depth` y `max_len` del fichero; los enteros aceptados comparan su `value` (número o, por encima de 2⁵³ − 1, `bigint`), y los rechazados «above max_len» o «above max_depth» se aceptan sin ese límite. Cada vector de `schemas` pasa por el decodificador de su esquema (`decodeProfile`, `decodeHeader`, `decodeControl`, `decodeAccessKeyBody`) con el código exacto, y un objeto aceptado se reescribe a los mismos bytes.
- `testdata/vectors/mutations.json`: se leen los 55 casos enteros (ediciones sobre un fixture o hex congelado, release, reloj, registro, extensiones, `.dkk` e identidades). Los 31 de los pasos 1 a 8 pasan por `inspect` con su registro y sus extensiones, y dan el mismo código y el mismo paso; los 24 de los pasos 9 a 18 necesitan `open` (fase 2) y se saltan uno a uno con ese motivo, y un test fija los dos recuentos. Las `.dkk` ofrecidas se decodifican.
- `testdata/vectors/inspect_differential.json`: las 1 825 mutaciones dan el mismo veredicto, código y paso que Go; los `bases` se comprueban por su SHA-256.
- Todo se lee con los formatos de `testdata/README.md` (`testing/vectors.ts`): una clave desconocida o que falta, un valor de otro tipo, un código que no es de §69 o una edición fuera de su base hacen fallar el fichero con su motivo; nada se salta en silencio.
- Todo fichero de `testdata/` tiene que ejecutarlo algún test: un nombre nuevo exportado por Go (otro `vectors/*.json`, un fichero de fixture que ningún JSON nombra) hace fallar `testdata/ holds no file that no test runs` hasta que se le añade su bloque.
## Tooling de desarrollo
@ -176,4 +175,4 @@ Comprueba que los ficheros coinciden con `SOURCE.json`, sin faltantes ni sobrant
`.gitattributes` marca `testdata/**` como binario para que git no altere ningún byte.
Copia actual: la de `testdata/SOURCE.json` (commit `afb44a3`, rama `v0.8.2`).
Copia actual: la de `testdata/SOURCE.json` (commit `692cf87`, rama `v0.8.2`).

@ -18,11 +18,12 @@
</script>
<div class="caveat" role="note">
<p class="caveat-title">Datos públicos, sin autenticar hasta la apertura</p>
<p class="caveat-title">Datos públicos, que no prueban autoría</p>
<p>
Cualquiera que tenga el fichero puede leer estas extensiones, y nada las autentica antes de abrir la cápsula: el
paso 15 (<span lang="en">header binding</span>) compara la cabecera con la que se selló solo después de la fecha de apertura. No te fíes
de su contenido hasta entonces.
Cualquiera que tenga el fichero puede leer y reescribir estas extensiones. Nada las vincula al resto de la cápsula
antes del paso 15 (<span lang="en">header binding</span>), después de la fecha de apertura, y ni siquiera entonces
prueban quién las escribió ni cuándo: solo una extensión de firma podría hacerlo (§55.1, §72). No bases en ellas
ninguna decisión de seguridad.
</p>
</div>

@ -99,8 +99,9 @@
{#if report.valid}
<p class="state-word">Estructura válida</p>
<p>
Supera los pasos 1 a 8 de §63 sin red y sin secretos. La cabecera pública solo queda autenticada al abrir la
cápsula (paso 15).
Supera los pasos 1 a 8 de §63 sin red y sin secretos. Es una comprobación de estructura: la cabecera pública solo
queda vinculada al control al abrir la cápsula (paso 15), y ni siquiera entonces prueba quién la escribió ni
cuándo (§55.1).
</p>
{:else if report.failure}
<p class="state-word">Rechazada en el paso {report.failure.step}</p>

@ -95,7 +95,14 @@ describe('DKK1 framing', () => {
expectCode(() => decodeAccessKey(with_(7, 1)), 'ERR_INVALID_FLAGS', /reserved 0x001/);
const big = ok.slice();
new DataView(big.buffer).setUint32(8, MAX_DKK_BODY_LEN + 1);
expectCode(() => decodeAccessKey(big), 'ERR_INTEGRITY', /BODY_LEN 16777217 exceeds/);
expectCode(() => decodeAccessKey(big), 'ERR_INTEGRITY', /^accesskey: BODY_LEN 16777217 outside 1..16777216: ERR_INTEGRITY$/);
// Spec §40, §57: no empty frame holds a valid body, so BODY_LEN 0 is a
// framing error, after FLAGS and RESERVED.
const empty = ok.slice(0, 12);
new DataView(empty.buffer).setUint32(8, 0);
expectCode(() => decodeAccessKey(empty), 'ERR_INTEGRITY', /^accesskey: BODY_LEN 0 outside 1..16777216: ERR_INTEGRITY$/);
empty[5] = 1;
expectCode(() => decodeAccessKey(empty), 'ERR_INVALID_FLAGS', /^accesskey: flags 0x1, reserved 0x000: ERR_INVALID_FLAGS$/);
expectCode(() => decodeAccessKey(ok.subarray(0, ok.length - 1)), 'ERR_INTEGRITY', /truncated body/);
expectCode(() => decodeAccessKey(new Uint8Array([...ok, 0])), 'ERR_INTEGRITY', /data after BODY_CBOR/);
});
@ -111,23 +118,30 @@ describe('BODY_CBOR', () => {
]);
});
it('checks the fields in the order of the reference, the material last', () => {
it('checks the layers of spec §69.1 in order: schema head, CDDL, then access_type and access_material', () => {
expectCode(() => decodeAccessKeyBody(new Uint8Array(MAX_DKK_BODY_LEN + 1)), 'ERR_INTEGRITY');
expectCode(() => decodeAccessKeyBody(body({ version: u(2) })), 'ERR_UNSUPPORTED_VERSION', /^accesskey: codec: /);
expectCode(() => decodeAccessKeyBody(body({ extra: [[9, u(0)]] })), NC, /unknown \.dkk BODY_CBOR key 9/);
expectCode(() => decodeAccessKeyBody(body({ cred: bn(15) })), NC, /must be 16 bytes/);
expectCode(() => decodeAccessKeyBody(body({ cred: bn(15), type: t('y') })), NC);
expectCode(() => decodeAccessKeyBody(body({ ver: map() })), NC, /32-byte capsule_digest/);
expectCode(() => decodeAccessKeyBody(body({ ver: map(), type: t('y') })), NC);
expectCode(() => decodeAccessKeyBody(body({ ver: map([0, b('')]) })), NC);
expectCode(() => decodeAccessKeyBody(body({ ver: map([0, bn(31)]) })), NC, /32-byte/);
expectCode(() => decodeAccessKeyBody(body({ ver: map([1, bn(32)]) })), NC);
expectCode(() => decodeAccessKeyBody(body({ ver: 'f6' })), NC);
expectCode(() => decodeAccessKeyBody(body({ type: t('y'), non: arr(ext('b'), ext('a')) })), NC, /noncritical_extensions/);
expectCode(() => decodeAccessKeyBody(body({ crit: arr(ext('')) })), NC, /critical_extensions/);
expectCode(() => decodeAccessKeyBody(body({ crit: arr(ext('a')), non: arr(ext('a')) })), NC, /both critical/);
expectCode(() => decodeAccessKeyBody(body({ type: t('X25519') })), 'ERR_ACCESS_INVALID', /access_type "X25519" is not supported/);
expectCode(() => decodeAccessKeyBody(body({ material: bn(31) })), 'ERR_ACCESS_INVALID', /is 31 bytes, want 32/);
const cases: [string, Uint8Array, string, RegExp][] = [
['schema version 2', body({ version: u(2) }), 'ERR_UNSUPPORTED_VERSION', /^accesskey: codec: datekeys-access-key schema version 2, want 1: ERR_UNSUPPORTED_VERSION$/],
['unknown key 9', body({ extra: [[9, u(0)]] }), NC, /^accesskey: key 9 is not defined: ERR/],
['credential_id of 15 bytes', body({ cred: bn(15) }), NC, /^accesskey: key 2: codec: offset 26: a byte string of 15 bytes outside 16\.\.16: ERR/],
['credential_id of 15 bytes, access_type y', body({ cred: bn(15), type: t('y') }), NC, /key 2: codec: offset 26/],
['access_type as a byte string', body({ type: b('783235353139') }), NC, /^accesskey: key 4: codec: offset 61: a byte string where a text string was expected: ERR/],
['empty verification_metadata', body({ ver: map() }), NC, /^accesskey: key 6: empty verification_metadata; an absent one omits key 6: ERR/],
['empty verification_metadata, access_type y', body({ ver: map(), type: t('y') }), NC, /empty verification_metadata/],
['empty capsule_digest', body({ ver: map([0, b('')]) }), NC, /^accesskey: key 6: capsule_digest: codec: offset 107: a byte string of 0 bytes outside 32\.\.32: ERR/],
['capsule_digest of 31 bytes', body({ ver: map([0, bn(31)]) }), NC, /capsule_digest: codec: offset 108: a byte string of 31 bytes outside 32\.\.32/],
['verification_metadata key 1', body({ ver: map([1, bn(32)]) }), NC, /^accesskey: key 6: verification_metadata key 1 is not defined: ERR/],
['verification_metadata of two keys', body({ ver: map([0, bn(32)], [1, u(0)]) }), NC, /key 6: codec: offset 105: map of 2 entries, at most 1/],
['null verification_metadata', body({ ver: 'f6' }), NC, /key 6: codec: offset 104: a float or simple value \(initial byte 0xf6\) is outside the CBOR profile/],
['out of order, access_type y', body({ type: t('y'), non: arr(ext('b'), ext('a')) }), NC, /^accesskey: key 8: extension a: array is not in canonical order: ERR/],
['empty extension_id', body({ crit: arr(ext('')) }), NC, /^accesskey: key 7: extension: invalid extension_id "": ERR/],
['id in both arrays', body({ crit: arr(ext('a')), non: arr(ext('a')) }), NC, /^accesskey: extension a: both critical and noncritical: ERR/],
['access_type X25519', body({ type: t('X25519') }), 'ERR_ACCESS_INVALID', /^accesskey: access_type "X25519" is not supported by V1: ERR_ACCESS_INVALID$/],
['access_material of 31 bytes', body({ material: bn(31) }), 'ERR_ACCESS_INVALID', /^accesskey: x25519 access_material is 31 bytes, want 32: ERR_ACCESS_INVALID$/],
// access_type (key 4) before access_material (key 5).
['access_type y, material of 31 bytes', body({ type: t('y'), material: bn(31) }), 'ERR_ACCESS_INVALID', /access_type "y" is not supported/],
];
for (const [name, bytes, code, msg] of cases) expectCode(() => decodeAccessKeyBody(bytes), code, msg, name);
});
});
@ -208,10 +222,10 @@ describe('BODY_CBOR size limit', () => {
expect(decodeAccessKeyBody(exact).noncritical[0]!.data!.length).toBe(n);
expect(decodeAccessKey(framed(exact)).noncritical[0]!.data!.length).toBe(n);
expectCode(() => marshalAccessKeyBody(key(n + 1)), 'ERR_INTEGRITY', /BODY_CBOR of 16777217 bytes exceeds 16777216/);
expectCode(() => decodeAccessKeyBody(new Uint8Array(MAX_DKK_BODY_LEN)), NC, /extraneous data/);
expectCode(() => decodeAccessKeyBody(new Uint8Array(MAX_DKK_BODY_LEN)), NC, /offset 0: an unsigned integer where a map was expected/);
expectCode(() => decodeAccessKeyBody(new Uint8Array(MAX_DKK_BODY_LEN + 1)), 'ERR_INTEGRITY', /BODY_CBOR of 16777217 bytes exceeds/);
expectCode(() => decodeAccessKey(framed(new Uint8Array(MAX_DKK_BODY_LEN))), NC, /extraneous data/);
expectCode(() => decodeAccessKey(framed(new Uint8Array(MAX_DKK_BODY_LEN + 1))), 'ERR_INTEGRITY', /BODY_LEN 16777217 exceeds the 16777216-byte limit/);
expectCode(() => decodeAccessKey(framed(new Uint8Array(MAX_DKK_BODY_LEN))), NC, /offset 0: an unsigned integer where a map was expected/);
expectCode(() => decodeAccessKey(framed(new Uint8Array(MAX_DKK_BODY_LEN + 1))), 'ERR_INTEGRITY', /BODY_LEN 16777217 outside 1..16777216/);
});
});

@ -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.map(1);
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.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,
});

@ -111,6 +111,24 @@ describe('parseAgeHeader', () => {
expect(parseAgeHeader(age(INTRO, ...many, MAC)).stanzas).toHaveLength(1024);
});
it('accepts a header of exactly 2 MiB and rejects one of 2 MiB + 1 byte, as filippo.io/age', () => {
// A header of exactly n bytes, MAC line included: the intro, "-> t <arg>",
// k full body lines of 64 columns, the final empty body line and the MAC
// line. The argument length absorbs what the full lines leave.
const header = (n: number): Uint8Array => {
const fixed = INTRO.length + 1 + '-> t '.length + 1 + 1 + MAC.length + 1;
const k = Math.floor((n - fixed - 1) / 65);
return age(INTRO, `-> t ${'a'.repeat(n - fixed - 65 * k)}`, ...Array<string>(k).fill('A'.repeat(64)), '', MAC);
};
const limit = 2 << 20;
const at = header(limit);
expect(at.length).toBe(limit);
expect(parseAgeHeader(at).length).toBe(limit);
const over = header(limit + 1);
expect(over.length).toBe(limit + 1);
expect(() => parseAgeHeader(over)).toThrow(/header exceeds 2 MiB/);
});
it('rejects everything age rejects', () => {
const cases: Uint8Array[] = [
new Uint8Array(0),

File diff suppressed because it is too large Load Diff

@ -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,80 @@
// - 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;
/**
* The bound peek gives the map count, Go's math.MaxInt: the input bounds the
* map, and only a count above 2^63-1 fails on this bound, as in the reference.
*/
const MAX_INT64 = 2n ** 63n - 1n;
/**
* 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 +98,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 +134,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 +154,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 +204,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;
}
/** 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;
return this.#in.length - this.#off;
}
/** Map header of at most `max` pairs; returns the number of pairs. */
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;
/**
* 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;
}
/** Pairs of the innermost open map not read yet. */
pairsLeft(): number {
return this.#frame().remaining;
/**
* 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: Uint): number {
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. */
array(max: number): number {
const n = this.#head(4);
if (n > max) throw cborError(`array of ${n} items exceeds ${max}`);
return n;
/** Reads the head of an array of at most `max` items and returns the number of items. */
array(max: Uint): number {
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');
}
/** Requires that every map is closed and that no byte is left. */
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;
return { text, utf8: copyBytes(b) };
}
#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}`);
/** Checks that every map was closed and that no byte follows the data item. */
done(): void {
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`);
}
// 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;
}
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;
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 (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 +445,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(MAX_INT64);
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 };
}
default:
throw cborError(`${MAJOR_NAMES[major]!} is outside the protocol profile`);
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() });
}
}
d.done();
return root!;
}

@ -61,15 +61,20 @@ describe('CONTROL_CBOR', () => {
expect(hx(input)).toBe(fx.control_cbor);
});
it('rejects wrong sizes, schemas and extensions', () => {
expectCode(() => decodeControl(control({ hb: bn(31) })), NC, /must be 32 bytes/);
expectCode(() => decodeControl(control({ pi: bn(33) })), NC, /must be 32 bytes/);
expectCode(() => decodeControl(control({ version: u(2) })), 'ERR_UNSUPPORTED_VERSION', /^capsule: CONTROL_CBOR: /);
expectCode(() => decodeControl(control({ extra: [[6, u(0)]] })), NC, /unknown CONTROL_CBOR key 6/);
expectCode(() => decodeControl(control({ crit: arr() })), NC);
expectCode(() => decodeControl(control({ crit: arr(ext('b'), ext('a')) })), NC, /critical_extensions: extension a/);
expectCode(() => decodeControl(control({ non: arr(ext('')) })), NC, /noncritical_extensions/);
expectCode(() => decodeControl(control({ crit: arr(ext('a')), non: arr(ext('a')) })), NC, /both critical/);
it('rejects wrong sizes, schemas and extensions, with the texts of the reference', () => {
const cases: [string, Uint8Array, string, RegExp][] = [
['header_binding of 31 bytes', control({ hb: bn(31) }), NC, /^capsule: CONTROL_CBOR: key 2: codec: offset 24: a byte string of 31 bytes outside 32\.\.32: ERR/],
['payload_identity of 33 bytes', control({ pi: bn(33) }), NC, /^capsule: CONTROL_CBOR: key 3: codec: offset 59: a byte string of 33 bytes outside 32\.\.32: ERR/],
['payload_identity as text', control({ pi: t('x'.repeat(32)) }), NC, /key 3: codec: offset 57: a text string where a byte string was expected/],
['schema version 2', control({ version: u(2) }), 'ERR_UNSUPPORTED_VERSION', /^capsule: CONTROL_CBOR: codec: datekeys-control schema version 2, want 1: ERR_UNSUPPORTED_VERSION$/],
['unknown key 6', control({ extra: [[6, u(0)]] }), NC, /^capsule: CONTROL_CBOR: key 6 is not defined: ERR/],
['missing key 3', h(map([0, t('datekeys-control')], [1, u(1)], [2, bn(32, 1)])), NC, /^capsule: CONTROL_CBOR: key 3 is missing: ERR/],
['empty critical_extensions', control({ crit: arr() }), NC, /^capsule: CONTROL_CBOR: key 4: extension: empty array; an absent array omits its key: ERR/],
['out of order', control({ crit: arr(ext('b'), ext('a')) }), NC, /^capsule: CONTROL_CBOR: key 4: extension a: array is not in canonical order: ERR/],
['empty extension_id', control({ non: arr(ext('')) }), NC, /^capsule: CONTROL_CBOR: key 5: extension: invalid extension_id "": ERR/],
['id in both arrays', control({ crit: arr(ext('a')), non: arr(ext('a')) }), NC, /^capsule: CONTROL_CBOR: extension a: both critical and noncritical: ERR/],
];
for (const [name, bytes, code, msg] of cases) expectCode(() => decodeControl(bytes), code, msg, name);
expect(decodeControl(control({ crit: arr(ext('z', 3, b('01'))) })).critical).toEqual([{ id: 'z', version: 3, data: h('01') }]);
});
@ -91,7 +96,14 @@ describe('CONTROL_CBOR', () => {
const identities = (copies: Uint8Array[]): Uint8Array[] => copies.filter((c) => c.length === 32 && c.every((x) => x === 2 || x === 0));
it('wipes I_PAYLOAD when decoding fails after key 3, and on every other path', () => {
for (const input of [control({ crit: arr() }), control({ extra: [[6, u(0)]] }), control({ non: arr(ext('')) }), control({ hb: bn(31) }), control()]) {
const afterKey3 = [
control({ crit: arr() }),
control({ extra: [[6, u(0)]] }),
control({ non: arr(ext('')) }),
control({ crit: arr(ext('a')), non: arr(ext('a')) }),
control(),
];
for (const input of afterKey3) {
const copies = spyBstr();
try {
decodeControl(input).payloadIdentity.fill(0);
@ -103,6 +115,10 @@ describe('CONTROL_CBOR', () => {
expect(ids.every((c) => c.every((x) => x === 0))).toBe(true);
vi.restoreAllMocks();
}
// A failure before key 3 never copies I_PAYLOAD at all.
const copies = spyBstr();
expectCode(() => decodeControl(control({ hb: bn(31) })), NC);
expect(identities(copies)).toHaveLength(0);
});
it('wipes I_PAYLOAD when unmarshal rejects the decoded value', () => {

@ -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));
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 = withContext('capsule: CONTROL_CBOR', () => unmarshal(b, decodeWire, encodeWire, (v) => v.payloadIdentity.fill(0)));
const w = 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');
}
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));
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);
}
});
}
/** 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;

@ -45,8 +45,6 @@ describe('dk1_', () => {
for (const s of [
`dk1_${std}`,
`dk1_${url}=`,
`dk1_${url.slice(0, 8)}\n${url.slice(8)}`,
`dk1_${url}\r\n`,
dk1('{"round":1000,"version":1,"network":"datekeys:quicknet:v1"}'),
dk1('{"version":1e0,"network":"datekeys:quicknet:v1","round":1000}'),
dk1('{"version":1,"network":"datekeys:quicknet:v1","round":10e2}'),
@ -62,6 +60,49 @@ describe('dk1_', () => {
}
});
it('rejects CR, LF and every other character outside the alphabet at step 1 (spec §19)', () => {
// Go's decoders skip CR and LF: the reference at afb44a3 reported these
// as ERR_DATEKEY_NON_CANONICAL; since 3820066 they fail step 1.
const url = Buffer.from('{"version":1,"network":"datekeys:quicknet:v1","round":1000}').toString('base64url');
for (const s of [
`dk1_${url.slice(0, 8)}\n${url.slice(8)}`,
`dk1_${url.slice(0, 8)}\r${url.slice(8)}`,
`dk1_${url}\r\n`,
`dk1_${url}\n`,
`dk1_\n${url}`,
`dk1_${url} `,
`dk1_${url}\t`,
`dk1_${url.slice(0, 8)} ${url.slice(8)}`,
]) {
expectCode(() => parseDateKey(s), INVALID, /^datekey: payload is not Base64URL: ERR_DATEKEY_INVALID$/);
}
});
it('reads JSON numbers by their exact decimal value, never as doubles (spec §19)', () => {
// 1.0000000000000001 is 1 as a double, and not 1: step 3 fails.
expectCode(() => parseDateKey(dk1('{"version":1.0000000000000001,"network":"datekeys:quicknet:v1","round":1000}')), INVALID, /unsupported version 1\.0000000000000001/);
expectCode(() => parseDateKey(dk1('{"version":1,"network":"datekeys:quicknet:v1","round":1000.0000000000001}')), INVALID, /invalid round 1000\.0000000000001/);
expectCode(() => parseDateKey(dk1('{"version":1,"network":"datekeys:quicknet:v1","round":9007199254740993}')), INVALID);
// Other spellings of the exact value pass step 3 and fail step 6.
for (const v of ['1.0', '1e0', '100e-2', '0.1e1', '1.000000000000000000000000000000']) {
expectCode(() => parseDateKey(dk1(`{"version":${v},"network":"datekeys:quicknet:v1","round":1000}`)), NONCANON, undefined, v);
}
});
it('rejects invalid UTF-8 at step 2, even in a member that a repeated name replaces (spec §19)', () => {
// Spec §19 step 2 fails on invalid UTF-8 with ERR_DATEKEY_INVALID. The
// reference replaced it with U+FFFD until 692cf87 (encoding/json), so a
// repeated name that replaced the member gave ERR_DATEKEY_NON_CANONICAL;
// both implementations now check the bytes before parsing the JSON.
const raw = (...parts: (string | number[])[]): string =>
`dk1_${Buffer.concat(parts.map((p) => (typeof p === 'string' ? Buffer.from(p) : Buffer.from(p)))).toString('base64url')}`;
const notUtf8 = /^datekey: payload is not valid UTF-8: ERR_DATEKEY_INVALID$/;
expectCode(() => parseDateKey(raw('{"version":1,"network":"', [0xff], '","network":"datekeys:quicknet:v1","round":1000}')), INVALID, notUtf8);
expectCode(() => parseDateKey(raw('{"version":1,"network":"datekeys:quicknet:v1","round":"', [0xc0, 0x80], '","round":1000}')), INVALID, notUtf8);
expectCode(() => parseDateKey(raw('{"version":1,"network":"', [0xff], '","round":1000}')), INVALID, notUtf8);
expectCode(() => parseDateKey(raw('{"version":1,"netw', [0xff], 'ork":"x","network":"datekeys:quicknet:v1","round":1000}')), INVALID, notUtf8);
});
it('rejects undecodable input and invalid fields', () => {
const cases = [
'',
@ -132,8 +173,9 @@ describe('dk1_', () => {
});
it('decodes JSON strings as Go does', () => {
// Invalid UTF-8 and unpaired surrogates become U+FFFD, so they never
// form a valid profile_id.
// Invalid UTF-8 bytes fail step 2 (spec §19); valid runes and escapes,
// where an unpaired surrogate escape becomes U+FFFD as in Go, reach step 3,
// where the extra field "x" fails.
const withBytes = (bytes: number[]): string =>
'dk1_' + Buffer.from([...te.encode('{"version":1,"network":"datekeys:quicknet:v1","round":1,"x":"'), ...bytes, 0x22, 0x7d]).toString('base64url');
for (const bytes of [[0xff], [0xe2, 0x82], [0xc0, 0x80], [0xed, 0xa0, 0x80], [0xf4, 0x90, 0x80, 0x80], [0xf0, 0x9f, 0x98, 0x80], [0xc3, 0xa9], [0xe2, 0x82, 0xac]]) {

@ -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
@ -8,7 +8,7 @@
// A DateKey is public: not a symmetric key, not a private key, not a .dkk and
// not a secret (spec §14).
import { compareBytes, decodeRuneGo, goQuote, utf8Bytes, utf8Length } from './bytes.ts';
import { compareBytes, decodeRuneGo, decodeUtf8, goQuote, utf8Bytes, utf8Length } from './bytes.ts';
import { DateKeysError } from './errors.ts';
import { maxRound, MAX_UNIX_TIME, type Profile, validID } from './profile.ts';
@ -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) ??
@ -312,8 +316,9 @@ class JsonParser {
return String.fromCharCode(...b.subarray(start, this.pos));
}
// A string, unquoted as Go does: invalid UTF-8 bytes and unpaired
// surrogate escapes become U+FFFD, one per byte or escape.
// A string, unquoted as Go does: unpaired surrogate escapes become
// U+FFFD, one per escape. parseJSON has already rejected invalid UTF-8
// bytes (spec §19 step 2), so every raw sequence here is a valid rune.
string(): string {
const b = this.#b;
this.pos++; // "
@ -390,6 +395,9 @@ const SIMPLE_ESCAPES: ReadonlyMap<number, string> = new Map([
// whatever their spelling; non-canonical spellings are rejected later by the
// byte comparison, as spec §19 prescribes.
function parseJSON(raw: Uint8Array): DateKey {
// Spec §19 step 2: invalid UTF-8 fails the step, before any JSON parsing
// could replace it with U+FFFD (the reference checks utf8.Valid first).
if (decodeUtf8(raw) === undefined) throw invalid('payload is not valid UTF-8');
const p = new JsonParser(raw);
let obj: Map<string, JsonValue>;
try {

@ -1,212 +1,275 @@
// Tests of the extension mechanism (spec §31, §54), ported from the tests of
// the Go package extension at 3820066 (TestNew, TestCanonical*,
// TestEncodeArrayRejects, TestDecodeArrayRejects, TestData,
// TestOrderIsUnsignedBytewise, TestCheckDisjoint*, TestCheckCritical,
// TestCheckNoncritical), with the error texts of the reference.
import { describe, expect, it } from 'vitest';
import { Decoder, Encoder, MAX_SAFE_UINT } from './cbor.ts';
import { Decoder, Encoder, unmarshal } from './cbor.ts';
import {
canonicalExtensions,
checkCritical,
checkDisjoint,
checkNoncritical,
decodeExtensions,
decodeWire,
decodeWireArray,
encodeExtensions,
encodeWire,
encodeWireArray,
decodeArray,
encodeArray,
type Extension,
type ExtensionRegistry,
ExtensionSet,
MAX_DATA_LEN,
MAX_EXTENSIONS,
MAX_ID_LEN,
MAX_VERSION,
newExtension,
} from './extension.ts';
import { arr, b, ext as extItem, map, t, u } from './testing/cborhex.ts';
import { expectCode, h, hx } from './testing/testdata.ts';
const NC = 'ERR_NON_CANONICAL_CBOR';
const te = new TextEncoder();
const ext = (id: string, version = 1, data?: Uint8Array): Extension => ({ id, version, data });
const many = (n: number): Extension[] => Array.from({ length: n }, (_, i) => ext(`org.example.${String(i).padStart(3, '0')}`));
function wires(exts: Extension[]): Uint8Array {
const e = new Encoder();
encodeWireArray(e, encodeExtensions(exts) ?? []);
return e.out();
// Decodes one extension array as the containing objects do: decodeArray and
// then the re-encoding check.
function decodeWhole(bytes: Uint8Array): Extension[] {
return unmarshal(bytes, (d) => decodeArray(d), (e, exts) => encodeArray(e, exts));
}
function decodeArray(b: Uint8Array): Extension[] {
const d = new Decoder(b);
const ws = decodeWireArray(d);
d.done();
return decodeExtensions(ws);
function encodeWhole(exts: readonly Extension[]): Uint8Array {
const e = new Encoder();
encodeArray(e, exts);
return e.out();
}
describe('newExtension', () => {
it('copies the data and validates the extension', () => {
const data = h('0102');
const e = newExtension('org.example.a', 3, data);
data[0] = 9;
expect(e).toEqual({ id: 'org.example.a', version: 3, data: h('0102') });
expectCode(() => newExtension('a', 1, new Uint8Array(0)), NC, /data of 0 bytes outside/);
expectCode(() => newExtension('a', 1, undefined as unknown as Uint8Array), NC, /needs data/);
expectCode(() => newExtension('', 1, h('00')), NC, /invalid extension_id ""/);
expectCode(() => newExtension('\ud800', 1, h('00')), NC, /invalid extension_id/);
expectCode(() => newExtension('a', MAX_VERSION + 1, h('00')), NC, /exceeds 4294967295/);
expectCode(() => newExtension('a', -1, h('00')), NC);
expectCode(() => newExtension('a', 1.5, h('00')), NC);
expect(newExtension('x'.repeat(256), MAX_VERSION, h('00')).id.length).toBe(256);
expectCode(() => newExtension('x'.repeat(257), 1, h('00')), NC);
const data = new TextEncoder().encode('public label');
const e = newExtension('org.example.label', 1, data);
data[0] = 0x50;
expect(e).toEqual({ id: 'org.example.label', version: 1, data: new TextEncoder().encode('public label') });
expect(newExtension('org.a', MAX_VERSION, h('00')).version).toBe(MAX_VERSION);
expect(newExtension('x'.repeat(MAX_ID_LEN), 1, h('00')).id).toHaveLength(256);
// 128 two-byte characters are 256 UTF-8 bytes; 129 are too many.
expect(newExtension('é'.repeat(128), 1, h('00')).id).toHaveLength(128);
expectCode(() => newExtension('é'.repeat(129), 1, h('00')), NC);
const invalid: [string, () => unknown, RegExp][] = [
['no data', () => newExtension('org.a', 1, undefined as unknown as Uint8Array), /extension "org.a": newExtension needs data/],
['empty data', () => newExtension('org.a', 1, new Uint8Array(0)), /extension org.a: data of 0 bytes outside 1\.\.67108864/],
['empty id', () => newExtension('', 1, h('01')), /extension: invalid extension_id ""/],
['lone surrogate', () => newExtension('org.\u{d800}', 1, h('01')), /invalid extension_id "org.\\ufffd"|invalid extension_id/],
['id too long', () => newExtension('a'.repeat(MAX_ID_LEN + 1), 1, h('01')), /invalid extension_id/],
['129 two-byte characters', () => newExtension('é'.repeat(129), 1, h('01')), /invalid extension_id/],
['version above 2^32-1', () => newExtension('org.a', MAX_VERSION + 1, h('01')), /extension org.a: extension_version 4294967296 exceeds 4294967295/],
['negative version', () => newExtension('org.a', -1, h('01')), /extension_version -1 exceeds/],
['fractional version', () => newExtension('org.a', 1.5, h('01')), /extension_version 1.5 exceeds/],
];
for (const [name, fn, msg] of invalid) expectCode(fn, NC, msg, name);
});
});
describe('extension maps', () => {
it('decodes and re-encodes one extension', () => {
const cases = ['a2006161 0101', 'a3006161 0101 024101', 'a3006161 011affffffff 02 5901000' + '0'.repeat(511)];
for (const hex of cases) {
const d = new Decoder(h(hex));
const w = decodeWire(d);
d.done();
const e = new Encoder();
encodeWire(e, w);
expect(hx(e.out())).toBe(hx(h(hex)));
}
describe('canonicalExtensions and encodeArray', () => {
it('sorts by the UTF-8 bytes of extension_id and writes the wire form', () => {
const input = [ext('org.b'), ext('org.a', 2, new TextEncoder().encode('x')), ext('Z', 9), ext('org.aa', 1, h('07'))];
const w = canonicalExtensions(input)!;
// Bytewise UTF-8 order: uppercase before lowercase, prefixes first.
expect(w.map((e) => e.id)).toEqual(['Z', 'org.a', 'org.aa', 'org.b']);
expect(input[0]!.id).toBe('org.b');
// The data is copied.
input[1]!.data![0] = 0;
expect(hx(w[1]!.data!)).toBe('78');
expect(canonicalExtensions([])).toBeUndefined();
const back = decodeWhole(encodeWhole(w));
expect(back.map((e) => [e.id, e.version, e.data === undefined ? undefined : hx(e.data)])).toEqual([
['Z', 9, undefined],
['org.a', 2, '78'],
['org.aa', 1, '07'],
['org.b', 1, undefined],
]);
// Data is a byte string, and key 2 is omitted without data.
expect(hx(encodeWhole(w.slice(0, 2)))).toBe('82' + 'a200615a0109' + 'a300656f72672e6101020241' + '78');
});
it('omits empty data when encoding', () => {
const e = new Encoder();
encodeWire(e, { id: 'a', idUtf8: te.encode('a'), version: { value: 1 }, data: new Uint8Array(0) });
expect(hx(e.out())).toBe('a20061610101');
it('orders unsigned bytes, never UTF-16 code units (spec §54)', () => {
const ids = ['\u{10000}', '\u{ff61}', 'é', 'z', 'Z', 'a\u{0}', 'a'];
expect('\u{10000}' < '\u{ff61}').toBe(true); // JavaScript compares UTF-16 code units
// 5a, 61, 61 00, 7a, c3 a9, ef bd a1, f0 90 80 80.
expect(canonicalExtensions(ids.map((id) => ext(id)))!.map((e) => e.id)).toEqual(['Z', 'a', 'a\u{0}', 'z', 'é', '\u{ff61}', '\u{10000}']);
});
it('rejects data that is not a non-empty byte string (spec §54)', () => {
for (const data of ['40', '6178', 'f6', 'f7', '01', 'a0', '80', 'c24101', '5f4101ff', '580101', 'f5', '20']) {
const d = new Decoder(h(`a3 006161 0101 02${data}`));
expectCode(() => decodeWire(d), NC);
}
it('rejects what decodeArray would reject', () => {
const cases: [string, Extension[], RegExp][] = [
['same id twice', [ext('org.a', 1), ext('org.a', 2)], /extension org.a: appears more than once/],
['empty id', [ext('', 1)], /invalid extension_id ""/],
['lone surrogate id', [ext('org.\u{dc00}', 1)], /invalid extension_id/],
['present but empty', [ext('org.a', 1, new Uint8Array(0))], /data of 0 bytes outside 1\.\.67108864/],
['version above 2^32-1', [ext('org.a', MAX_VERSION + 1)], /extension_version 4294967296 exceeds 4294967295/],
['65 extensions', many(MAX_EXTENSIONS + 1), /extension: 65 extensions in one array, at most 64/],
['duplicate among many', [...many(3), ext('org.example.001', 2)], /extension org.example.001: appears more than once/],
];
for (const [name, input, msg] of cases) expectCode(() => canonicalExtensions(input), NC, msg, name);
expect(canonicalExtensions(many(MAX_EXTENSIONS))).toHaveLength(64);
// Sorted first and then checked, as the reference: the first invalid
// extension in canonical order is reported.
expectCode(() => canonicalExtensions([ext('b', MAX_VERSION + 1), ext('')]), NC, /invalid extension_id ""/);
});
it('rejects missing, unknown and misplaced keys', () => {
for (const hex of ['a0', 'a1006161', 'a2006161 0201', 'a20101 006161', 'a3006161 0101 0301', 'a4006161 0101 024101 0301', 'a2016161 0101']) {
expectCode(() => decodeWire(new Decoder(h(hex))), NC);
}
});
it('decodes any uint64 extension_version and re-encodes it exactly (the bound is an array rule)', () => {
for (const [arg, value] of [
['1b001fffffffffffff', MAX_SAFE_UINT],
['1b0020000000000000', Infinity],
['1bffffffffffffffff', Infinity],
] as const) {
const b = h(`a2 006161 01${arg}`);
const d = new Decoder(b);
const w = decodeWire(d);
d.done();
expect(w.version.value).toBe(value);
it('never writes an array that decodeArray rejects', () => {
const cases: [string, Extension[]][] = [
['empty', []],
['65 extensions', many(MAX_EXTENSIONS + 1)],
['present but empty', [ext('org.a', 1, new Uint8Array(0))]],
['out of order', [ext('org.b'), ext('org.a')]],
['same id twice', [ext('org.a', 1), ext('org.a', 2)]],
['version above 2^32-1', [ext('org.a', MAX_VERSION + 1)]],
['empty id', [ext('')]],
['id too long', [ext('a'.repeat(MAX_ID_LEN + 1))]],
['lone surrogate id', [ext('org.\u{d800}')]],
];
for (const [name, input] of cases) {
const e = new Encoder();
encodeWire(e, w);
expect(hx(e.out())).toBe(hx(b));
e.uint(0);
encodeArray(e, input);
expect(e.err?.code, name).toBe(NC);
expectCode(() => e.out(), NC, undefined, name);
}
});
it('accepts data of exactly MAX_DATA_LEN bytes and rejects one more', () => {
expect(MAX_DATA_LEN).toBe(64 * 1024 * 1024);
// a3 006161 0101 02 5a<len> <data>
const map = (len: number): Uint8Array => {
const b = new Uint8Array(12 + len);
b.set(h('a3 006161 0101 02 5a'), 0);
new DataView(b.buffer).setUint32(8, len);
b.fill(7, 12);
return b;
};
const d = new Decoder(map(MAX_DATA_LEN));
const w = decodeWire(d);
d.done();
expect(w.data!.length).toBe(MAX_DATA_LEN);
expect(decodeExtensions([w])[0]!.data!.length).toBe(MAX_DATA_LEN);
expect(encodeExtensions([{ id: 'a', version: 1, data: w.data }])![0]!.data!.length).toBe(MAX_DATA_LEN);
expect(newExtension('a', 1, w.data!).data!.length).toBe(MAX_DATA_LEN);
expectCode(() => decodeWire(new Decoder(map(MAX_DATA_LEN + 1))), NC, /byte string of 67108865 bytes outside 1\.\.67108864/);
const over = new Uint8Array(MAX_DATA_LEN + 1);
expectCode(() => newExtension('a', 1, over), NC, /data of 67108865 bytes outside 1\.\.67108864/);
expectCode(() => decodeExtensions([{ id: 'a', idUtf8: te.encode('a'), version: { value: 1 }, data: over }]), NC, /data of 67108865 bytes/);
expectCode(() => encodeExtensions([{ id: 'a', version: 1, data: over }]), NC, /data of 67108865 bytes/);
});
it('decodes arrays of 1 to 65536 extensions and rejects the empty array', () => {
expectCode(() => decodeWireArray(new Decoder(h('80'))), NC, /empty extension array/);
expect(decodeWireArray(new Decoder(h('81a20061610101')))).toHaveLength(1);
expectCode(() => decodeWireArray(new Decoder(h('9a00010001'))), NC, /array of 65537 items exceeds 65536/);
expect(hx(encodeWhole(many(MAX_EXTENSIONS)).subarray(0, 2))).toBe('9840');
});
});
describe('array rules', () => {
it('sorts by the UTF-8 bytes of extension_id and round-trips', () => {
const exts = [ext('org.b'), ext('org.a', 2, h('ff')), ext('\u{10000}'), ext('\u{ff61}')];
const b = wires(exts);
const back = decodeArray(b);
expect(back.map((e) => e.id)).toEqual(['org.a', 'org.b', '\u{ff61}', '\u{10000}']);
expect(back[0]!.data).toEqual(h('ff'));
expect(back[1]!.data).toBeUndefined();
describe('decodeArray', () => {
it('rejects every array outside the rules of spec §54, all with ERR_NON_CANONICAL_CBOR', () => {
const entry = (id: string, v: number | bigint): string => map([0, t(id)], [1, u(v)]);
const cases: [string, string, RegExp][] = [
['out of order', arr(entry('org.b', 1), entry('org.a', 1)), /^extension org.a: array is not in canonical order: ERR/],
['repeated id', arr(entry('org.a', 1), entry('org.a', 2)), /^extension org.a: appears more than once: ERR/],
['present but empty', arr(map([0, t('org.a')], [1, u(1)], [2, '40'])), /^extension "org.a": data is present but empty; an extension without data omits key 2: ERR/],
['version above 2^32-1', arr(entry('org.a', 2 ** 32)), /^extension "org.a": key 1: codec: offset 19: unsigned integer 4294967296 above 4294967295: ERR/],
['version 2^53', arr(entry('org.a', 2n ** 53n)), /unsigned integer 9007199254740992 above 4294967295/],
['version 2^64-1', arr(entry('org.a', 2n ** 64n - 1n)), /unsigned integer 18446744073709551615 above 4294967295/],
['empty id', arr(entry('', 1)), /^extension: invalid extension_id "": ERR/],
['id too long', arr(entry('a'.repeat(MAX_ID_LEN + 1), 1)), /^extension "": key 0: codec: offset 6: a text string of 257 bytes outside 0\.\.256: ERR/],
['invalid UTF-8 id', arr(map([0, '656f72672eff'], [1, u(1)])), /^extension "": key 0: codec: offset 3: text string is not valid UTF-8: ERR/],
['id not a string', arr(map([0, u(1)], [1, u(1)])), /key 0: codec: offset 3: an unsigned integer where a text string was expected/],
['65 extensions', arr(...many(65).map((e) => entry(e.id, 1))), /^extension: codec: offset 2: array of 65 items, at most 64: ERR/],
['empty array', '80', /^extension: empty array; an absent array omits its key: ERR/],
['not an array', entry('org.a', 1), /^extension: codec: offset 0: a map where an array was expected: ERR/],
['entry not a map', arr(t('org.a')), /^extension: codec: offset 1: a text string where a map was expected: ERR/],
['four keys', arr(map([0, t('org.a')], [1, u(1)], [2, b('01')], [3, u(0)])), /^extension: codec: offset 2: map of 4 entries, at most 3: ERR/],
['unknown key', arr(map([0, t('org.a')], [1, u(1)], [3, u(0)])), /^extension "org.a": unknown key 3: ERR/],
['without version', arr(map([0, t('org.a')])), /^extension "org.a": extension_id and extension_version are required: ERR/],
['without id', arr(map([1, u(1)])), /^extension "": extension_id and extension_version are required: ERR/],
['text key', '81a2616100' + '0101', /^extension: codec: offset 2: a text string where an unsigned integer was expected: ERR/],
['keys out of order', arr(map([1, u(1)], [0, t('org.a')])), /^extension: codec: offset 4: map key 0 after key 1/],
['truncated', '82a2006161' + '0101', /^extension: codec: offset 7: truncated input: ERR/],
];
for (const [name, hex, msg] of cases) expectCode(() => decodeWhole(h(hex)), NC, msg, name);
expect(decodeWhole(h(arr(...many(64).map((e) => entry(e.id, 1)))))).toHaveLength(64);
});
it('orders U+FF61 before U+10000, unlike UTF-16 string comparison', () => {
expect('\u{10000}' < '\u{ff61}').toBe(true); // UTF-16 code units
const ok = h('82 a2 0063efbda1 0101 a2 0064f0908080 0101');
expect(decodeArray(ok).map((e) => e.id)).toEqual(['\u{ff61}', '\u{10000}']);
const reversed = h('82 a2 0064f0908080 0101 a2 0063efbda1 0101');
expectCode(() => decodeArray(reversed), NC, /not in canonical order/);
it('keeps the bytes of data exactly, and only a non-empty byte string (spec §54)', () => {
const head = '81' + 'a3006161' + '0101' + '02';
const valid: [string, string, string][] = [
['one byte', '4100', '00'],
['bytes that are not CBOR', '44ff1c00f7', 'ff1c00f7'],
['CBOR that the base protocol never decodes', '49a2f97e0000f97e0001', 'a2f97e0000f97e0001'],
['24 bytes, one-byte length', `5818${'ab'.repeat(24)}`, 'ab'.repeat(24)],
];
for (const [name, item, data] of valid) expect(hx(decodeWhole(h(head + item))[0]!.data!), name).toBe(data);
const none = decodeWhole(h('81a20061610101'));
expect(none[0]).toEqual({ id: 'a', version: 1, data: undefined });
const invalid: [string, string][] = [
['empty byte string', '40'],
['text string', '6161'],
['unsigned integer', '01'],
['negative integer', '20'],
['array', '820102'],
['empty array', '80'],
['map', 'a10001'],
['true', 'f5'],
['null', 'f6'],
['undefined', 'f7'],
['float', 'f93c00'],
['tag', 'c24101'],
['length not in shortest form', '5801' + '00'],
['indefinite-length byte string', '5f4100ff'],
['truncated byte string', '42' + '00'],
];
for (const [name, item] of invalid) expectCode(() => decodeWhole(h(head + item)), NC, undefined, name);
});
it('rejects repeated identifiers, even with other versions', () => {
expectCode(() => encodeExtensions([ext('a', 1), ext('a', 2)]), NC, /appears more than once/);
expectCode(() => decodeArray(h('82 a2006161 0101 a2006161 0102')), NC, /appears more than once/);
it('accepts data of exactly MAX_DATA_LEN bytes and rejects one more', () => {
expect(MAX_DATA_LEN).toBe(64 * 1024 * 1024);
// 81 a3 006161 0101 02 5a<len> <data>
const array = (len: number): Uint8Array => {
const out = new Uint8Array(13 + len);
out.set(h('81a3006161010102 5a'.replace(' ', '')), 0);
new DataView(out.buffer).setUint32(9, len);
out.fill(7, 13);
return out;
};
const d = new Decoder(array(MAX_DATA_LEN));
const exts = decodeArray(d);
d.done();
expect(exts[0]!.data!.length).toBe(MAX_DATA_LEN);
expectCode(() => decodeArray(new Decoder(array(MAX_DATA_LEN + 1))), NC, /a byte string of 67108865 bytes outside 0\.\.67108864/);
expectCode(() => canonicalExtensions([ext('a', 1, new Uint8Array(MAX_DATA_LEN + 1))]), NC, /data of 67108865 bytes outside 1\.\.67108864/);
});
it('accepts 64 extensions and rejects 65', () => {
const exts = (n: number): Extension[] => Array.from({ length: n }, (_, i) => ext(`org.example.${String(i).padStart(3, '0')}`));
expect(decodeArray(wires(exts(MAX_EXTENSIONS)))).toHaveLength(64);
expectCode(() => encodeExtensions(exts(65)), NC, /65 extensions in one array, at most 64/);
const e = new Encoder();
encodeWireArray(
e,
exts(65).map((x) => ({ id: x.id, idUtf8: te.encode(x.id), version: { value: 1 }, data: undefined })),
);
expectCode(() => decodeArray(e.out()), NC, /65 extensions in one array/);
it('requires the strict order of the UTF-8 bytes, which also forbids repeating an id', () => {
const cases: [string, string[], boolean][] = [
['U+FF61 before U+10000, by UTF-8 bytes', ['\u{ff61}', '\u{10000}'], true],
['U+10000 before U+FF61, by UTF-16 code units', ['\u{10000}', '\u{ff61}'], false],
['a proper prefix first', ['a', 'ab'], true],
['a proper prefix last', ['ab', 'a'], false],
['byte 7a before c3, unsigned', ['z', 'é'], true],
['byte c3 before 7a, signed', ['é', 'z'], false],
['uppercase before lowercase', ['B', 'a'], true],
['case-insensitive collation', ['a', 'B'], false],
['repeated', ['a', 'a'], false],
];
for (const [name, ids, ok] of cases) {
const hex = arr(...ids.map((id) => extItem(id)));
if (ok) expect(decodeWhole(h(hex)).map((e) => e.id), name).toEqual(ids);
else expectCode(() => decodeWhole(h(hex)), NC, undefined, name);
}
// A leading BOM is part of the identifier.
expect(decodeWhole(h(arr(extItem('\u{feff}org.example.a'))))[0]!.id).toBe('\u{feff}org.example.a');
});
it('validates each decoded extension', () => {
expectCode(() => decodeArray(h('81 a20060 0101')), NC, /invalid extension_id ""/);
expect(decodeArray(h('81 a2006161 01 1affffffff'))[0]!.version).toBe(MAX_VERSION);
expectCode(() => decodeArray(h('81 a2006161 01 1b0000000100000000')), NC, /extension a: extension_version 4294967296 exceeds 4294967295/);
// 2^53-1 and above are rejected by the same rule, with their exact value,
// as the reference does with its uint64.
expectCode(() => decodeArray(h('81 a2006161 01 1b001fffffffffffff')), NC, /extension_version 9007199254740991 exceeds 4294967295/);
expectCode(() => decodeArray(h('81 a2006161 01 1b0020000000000000')), NC, /extension_version 9007199254740992 exceeds 4294967295/);
expectCode(() => decodeArray(h('81 a2006161 01 1bffffffffffffffff')), NC, /extension_version 18446744073709551615 exceeds 4294967295/);
const long = h('81 a2 00 790101' + '61'.repeat(257) + ' 0101');
expectCode(() => decodeArray(long), NC, /invalid extension_id/);
expect(decodeArray(h('81 a2 0064efbbbf61 0101'))[0]!.id).toBe('\u{feff}a');
});
it('returns no wire for no extensions', () => {
expect(encodeExtensions([])).toBeUndefined();
expect(decodeExtensions([])).toEqual([]);
describe('checkDisjoint', () => {
it('rejects an identifier in both arrays, whatever the order of its input', () => {
expectCode(() => checkDisjoint([ext('org.a', 1)], [ext('org.a', 2)]), NC, /^extension org.a: both critical and noncritical: ERR/);
const a = [ext('a'), ext('c'), ext('e')];
const bb = [ext('b'), ext('d'), ext('f')];
checkDisjoint(a, bb);
// Input that is not in canonical order is sorted before the merge.
const unsorted = [ext('z'), ext('e')];
expectCode(() => checkDisjoint(a, unsorted), NC, /extension e: both critical and noncritical/);
expect(unsorted.map((e) => e.id)).toEqual(['z', 'e']);
checkDisjoint([], bb);
checkDisjoint(a, []);
});
it('rejects an identifier in both arrays', () => {
checkDisjoint([ext('a'), ext('c')], [ext('b'), ext('d')]);
checkDisjoint([], [ext('a')]);
checkDisjoint([ext('z'), ext('a')], [ext('b')]);
expectCode(() => checkDisjoint([ext('c'), ext('a')], [ext('b'), ext('c')]), NC, /extension c: both critical and noncritical/);
it('is linear: 20 000 + 20 000 identifiers in milliseconds', () => {
const n = 20_000;
const crit = Array.from({ length: n }, (_, i) => ext(`a.${String(i).padStart(7, '0')}`));
const non = Array.from({ length: n }, (_, i) => ext(`b.${String(i).padStart(7, '0')}`));
const start = performance.now();
checkDisjoint(crit, non);
expect(performance.now() - start).toBeLessThan(5000);
});
});
describe('registries', () => {
const strict: ExtensionRegistry = {
known: (id, v) => id.startsWith('known') && v === 1,
validateData: (e) => (e.data?.[0] === 0 ? new Error('first byte is zero') : undefined),
// Knows org.a v1 and org.b v1, and accepts only the data "ok".
const validator: ExtensionRegistry = {
known: (id, v) => (id === 'org.a' || id === 'org.b') && v === 1,
validateData: (e) => (e.data !== undefined && new TextDecoder().decode(e.data) === 'ok' ? undefined : new Error('want "ok"')),
};
const ok = new TextEncoder().encode('ok');
const ko = new TextEncoder().encode('ko');
it('knows extensions by identifier and version', () => {
it('knows extensions by identifier and version, and never by prototype keys', () => {
const s = new ExtensionSet([['org.a', [1, 2]]]);
expect(s.known('org.a', 2)).toBe(true);
expect(s.known('org.a', 3)).toBe(false);
@ -214,21 +277,27 @@ describe('registries', () => {
expect(new ExtensionSet().known('org.a', 1)).toBe(false);
});
it('rejects unknown critical extensions before invalid data', () => {
it('rejects unknown critical extensions first, then known ones with invalid data', () => {
const crit = [ext('org.a', 1)];
expectCode(() => checkCritical(crit), 'ERR_EXTENSION_CRITICAL_UNKNOWN', /^extension org.a v1: ERR_EXTENSION_CRITICAL_UNKNOWN$/);
expectCode(() => checkCritical(crit, new ExtensionSet([['org.a', [2]]])), 'ERR_EXTENSION_CRITICAL_UNKNOWN');
checkCritical(crit, new ExtensionSet([['org.a', [1]]]));
checkCritical([]);
expectCode(() => checkCritical([ext('a')]), 'ERR_EXTENSION_CRITICAL_UNKNOWN', /extension a v1/);
expectCode(() => checkCritical([ext('known.a', 1, h('00')), ext('b')], strict), 'ERR_EXTENSION_CRITICAL_UNKNOWN');
expectCode(() => checkCritical([ext('known.a', 1, h('00'))], strict), 'ERR_EXTENSION_DATA_INVALID', /data: first byte is zero/);
checkCritical([ext('known.a', 1, h('01'))], strict);
checkCritical([ext('org.a', 1)], new ExtensionSet([['org.a', [1]]]));
checkCritical([ext('org.a', 1, ok)], validator);
expectCode(() => checkCritical([ext('org.a', 1, ok), ext('org.b', 1, ko)], validator), 'ERR_EXTENSION_DATA_INVALID', /^extension org.b v1: data: want "ok": ERR_EXTENSION_DATA_INVALID$/);
// An unknown critical extension anywhere takes precedence over invalid data (spec §69.1).
expectCode(() => checkCritical([ext('org.b', 1, ko), ext('org.c', 1)], validator), 'ERR_EXTENSION_CRITICAL_UNKNOWN', /org.c v1/);
});
it('reports unusable noncritical extensions without failing', () => {
expect(checkNoncritical([ext('a')])).toEqual([]);
expect(checkNoncritical([ext('a')], new ExtensionSet([['a', [1]]]))).toEqual([]);
const u = checkNoncritical([ext('known.a', 1, h('00')), ext('known.b', 1, h('01')), ext('other', 1, h('00'))], strict);
expect(u).toHaveLength(1);
expect(u[0]!.id).toBe('known.a');
expect(u[0]!.error.code).toBe('ERR_EXTENSION_DATA_INVALID');
const all = [ext('org.a', 1, ok), ext('org.b', 1), ext('org.c', 1, ko)];
expect(checkNoncritical(all)).toEqual([]);
expect(checkNoncritical(all, new ExtensionSet([['org.b', [1]]]))).toEqual([]);
const unusable = checkNoncritical(all, validator);
expect(unusable).toHaveLength(1);
expect(unusable[0]!.id).toBe('org.b');
expect(unusable[0]!.version).toBe(1);
expect(unusable[0]!.error.code).toBe('ERR_EXTENSION_DATA_INVALID');
expect(unusable[0]!.error.message).toBe('extension org.b v1: data: want "ok": ERR_EXTENSION_DATA_INVALID');
});
});

@ -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,171 @@ 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(e.version) || e.version < 0 || e.version > MAX_VERSION) {
throw nonCanonical(`extension ${e.id}: extension_version ${e.version} exceeds ${MAX_VERSION}`);
}
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 (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}`);
}
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}`);
}
// 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);
export function canonicalExtensions(exts: readonly Extension[]): Extension[] | undefined {
if (exts.length === 0) return undefined;
if (exts.length > MAX_EXTENSIONS) throw errTooMany(exts.length);
// Sorted first and then checked as an array to be written, as the
// reference: an identifier that is not well-formed sorts by its UTF-8 with
// U+FFFD, and the check rejects it.
const sorted = exts.map((e) => ({ e, key: utf8Bytes(e.id) })).sort((a, b) => compareBytes(a.key, b.key));
const out = sorted.map(({ e }) => e);
checkArray(out);
return out.map((e) => ({ id: e.id, version: e.version, data: e.data === undefined ? undefined : copyBytes(e.data) }));
}
d.endMap();
return { id, idUtf8, version, data };
function errTooMany(n: number): DateKeysError {
return nonCanonical(`extension: ${n} extensions in one array, at most ${MAX_EXTENSIONS}`);
}
/**
* 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).
* 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 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;
}
/** 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);
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(w.id);
e.text(x.id);
e.uint(1);
e.wideUint(w.version);
if (hasData) {
e.uint(x.version);
if (x.data !== undefined) {
e.uint(2);
e.bstr(w.data!);
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`);
}
out.push({ id: w.id, version: w.version.value, data: w.data === undefined ? undefined : copyBytes(w.data) });
}
return out;
// 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}`);
}
}
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 +227,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 +268,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) {

@ -60,6 +60,17 @@ describe('DKC1 prelude', () => {
expectCode(() => parsePrelude(maxes(MAX_PUBLIC_HEADER_LEN + 1, 1)), 'ERR_INTEGRITY', /PUBLIC_HEADER_LEN 1048577 outside/);
expectCode(() => parsePrelude(maxes(1, MAX_SEALED_CONTROL_LEN + 1)), 'ERR_INTEGRITY', /SEALED_CONTROL_LEN 67108865 outside/);
expectCode(() => parsePrelude(maxes(0xffffffff, 1)), 'ERR_INTEGRITY', /4294967295/);
// Lower bounds (spec §22, §23, §57): no empty frame holds a valid object.
expect(parsePrelude(maxes(1, 1))).toEqual({ publicHeaderLen: 1, sealedControlLen: 1 });
expectCode(() => parsePrelude(maxes(0, 1)), 'ERR_INTEGRITY', /^capsule: PUBLIC_HEADER_LEN 0 outside 1\.\.1048576: ERR_INTEGRITY$/);
expectCode(() => parsePrelude(maxes(1, 0)), 'ERR_INTEGRITY', /^capsule: SEALED_CONTROL_LEN 0 outside 1\.\.67108864: ERR_INTEGRITY$/);
expectCode(() => parsePrelude(maxes(0, 0)), 'ERR_INTEGRITY', /PUBLIC_HEADER_LEN 0/);
// The order of spec §23: version and FLAGS before the lengths.
const zeroFlagged = maxes(0, 0);
zeroFlagged[7] = 1;
expectCode(() => parsePrelude(zeroFlagged), 'ERR_INVALID_FLAGS', /^capsule: flags 0x0, reserved 0x0001: /);
zeroFlagged[4] = 2;
expectCode(() => parsePrelude(zeroFlagged), 'ERR_UNSUPPORTED_VERSION', /framing version 2/);
});
});
@ -104,6 +115,15 @@ describe('DKK1 framing', () => {
const framed = new Uint8Array([...dkkPreludeBytes(body.length), ...body]);
expect(hx(framed)).toBe('444b4b3101000000' + '00000001' + 'a0');
expect(hx(splitAccessKey(framed))).toBe('a0');
expect(splitAccessKey(dkkPreludeBytes(0))).toHaveLength(0);
});
it('bounds BODY_LEN in 1..16 MiB, after FLAGS and RESERVED (spec §40, §57)', () => {
// No empty frame holds a valid body, as a PUBLIC_HEADER_LEN of 0 (§22).
expectCode(() => splitAccessKey(dkkPreludeBytes(0)), 'ERR_INTEGRITY', /^accesskey: BODY_LEN 0 outside 1\.\.16777216: ERR_INTEGRITY$/);
expectCode(() => splitAccessKey(dkkPreludeBytes((16 << 20) + 1)), 'ERR_INTEGRITY', /BODY_LEN 16777217 outside 1\.\.16777216/);
const flagged = dkkPreludeBytes(0);
flagged[6] = 1;
expectCode(() => splitAccessKey(flagged), 'ERR_INVALID_FLAGS', /^accesskey: flags 0x0, reserved 0x100: ERR_INVALID_FLAGS$/);
expectCode(() => splitAccessKey(dkkPreludeBytes(1)), 'ERR_INTEGRITY', /^accesskey: truncated body: ERR_INTEGRITY$/);
});
});

@ -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');

@ -59,81 +59,77 @@ describe('decodeHeader', () => {
expect(got.critical).toEqual([]);
});
it('checks the schema head first', () => {
expectCode(() => decodeHeader(header({ version: u(2) })), 'ERR_UNSUPPORTED_VERSION', /^capsule: PUBLIC_HEADER: codec: datekeycap schema version 2/);
expectCode(() => decodeHeader(header({ version: 'f6' })), 'ERR_UNSUPPORTED_VERSION');
expectCode(() => decodeHeader(header({ type: t('datekeys-control') })), NC);
expectCode(() => decodeHeader(header({ version: u(2), cid: bn(1), extra: [[7, 'c101']] })), NC);
it('reads the type tag and the schema version first (spec §69.1 layer 2, §70)', () => {
expectCode(() => decodeHeader(header({ version: u(2) })), 'ERR_UNSUPPORTED_VERSION', /^capsule: PUBLIC_HEADER: codec: datekeycap schema version 2, want 1: ERR_UNSUPPORTED_VERSION$/);
expectCode(() => decodeHeader(header({ type: t('datekeys-control') })), NC, /^capsule: PUBLIC_HEADER: codec: type "datekeys-control", want "datekeycap": ERR/);
expectCode(() => decodeHeader(header({ type: t('datekeys-control'), version: u(2) })), NC, /type "datekeys-control"/);
// A version that is not an unsigned integer in the profile is not a version.
expectCode(() => decodeHeader(header({ version: 'f6' })), NC, /offset 14: a float or simple value \(initial byte 0xf6\) is outside the CBOR profile/);
expectCode(() => decodeHeader(header({ version: '1801' })), NC, /offset 14: 1 is not in its shortest form \(initial byte 0x18\)/);
expectCode(() => decodeHeader(header({ version: u(2n ** 53n) })), NC, /unsigned integer 9007199254740992 above 9007199254740991/);
// Version 2 is reported whatever follows it: unknown keys, items outside
// the profile, invalid UTF-8, a DateKey that is not dk1_.
expectCode(() => decodeHeader(header({ version: u(2), cid: bn(1), extra: [[7, 'c101']] })), 'ERR_UNSUPPORTED_VERSION');
expectCode(() => decodeHeader(header({ version: u(2), extra: [[7, '61ff']] })), 'ERR_UNSUPPORTED_VERSION');
expectCode(() => decodeHeader(header({ version: u(2), dk: t('dk1_!!'), extra: [[11, u(0)]] })), 'ERR_UNSUPPORTED_VERSION');
});
it('rejects non-canonical and unknown structures', () => {
expectCode(() => decodeHeader(header({ extra: [[7, u(0)]] })), NC, /unknown PUBLIC_HEADER key 7/);
expectCode(() => decodeHeader(header({ version: '1801' })), NC);
expectCode(() => decodeHeader(header({ non: arr() })), NC, /empty extension array/);
expectCode(() => decodeHeader(header({ non: arr(ext('a', 1, b(''))) })), NC);
expectCode(() => decodeHeader(header({ non: arr(ext('a', 1, t('x'))) })), NC);
expectCode(() => decodeHeader(h(map([0, t('datekeycap')], [1, u(1)], [2, bn(16)], [3, t(dkRound(1))]))), NC);
expectCode(() => decodeHeader(new Uint8Array(MAX_PUBLIC_HEADER_LEN + 1)), 'ERR_INTEGRITY', /exceeds 1048576/);
});
it('checks capsule_id, the DateKey, the policy and the extensions in that order', () => {
const bad = t('dk1_!!');
expectCode(() => decodeHeader(header({ cid: bn(15) })), NC, /capsule_id is 15 bytes/);
expectCode(() => decodeHeader(header({ cid: bn(15), dk: bad })), NC, /capsule_id/);
expectCode(() => decodeHeader(header({ dk: bad })), 'ERR_DATEKEY_INVALID', /^capsule: PUBLIC_HEADER: datekey:/);
expectCode(() => decodeHeader(header({ dk: t(dkRound('1.0e3')) })), 'ERR_DATEKEY_NON_CANONICAL');
expectCode(() => decodeHeader(header({ policy: u(2) })), NC, /access_policy 2 is not defined/);
expectCode(() => decodeHeader(header({ policy: u(2 ** 32) })), NC);
expectCode(() => decodeHeader(header({ dk: bad, policy: u(2) })), 'ERR_DATEKEY_INVALID');
expectCode(() => decodeHeader(header({ dk: bad, non: arr(ext('b'), ext('a')) })), 'ERR_DATEKEY_INVALID');
expectCode(() => decodeHeader(header({ dk: bad, non: arr(ext('a', 2 ** 32)) })), 'ERR_DATEKEY_INVALID');
expectCode(() => decodeHeader(header({ dk: bad, crit: arr(ext('a')), non: arr(ext('a')) })), 'ERR_DATEKEY_INVALID');
// Extension maps are decoded with the header, before the DateKey.
expectCode(() => decodeHeader(header({ dk: bad, non: arr(ext('a', 1, b(''))) })), NC);
expectCode(() => decodeHeader(header({ crit: arr(ext('a', 1)), non: arr(ext('a', 2)) })), NC, /both critical and noncritical/);
expectCode(() => decodeHeader(header({ non: arr(ext('b'), ext('a')) })), NC, /^capsule: PUBLIC_HEADER noncritical_extensions: /);
expectCode(() => decodeHeader(header({ crit: arr(ext('')) })), NC, /^capsule: PUBLIC_HEADER critical_extensions: /);
it('checks the CDDL with ERR_NON_CANONICAL_CBOR (layer 3)', () => {
const cases: [string, Uint8Array, RegExp][] = [
['unknown key 7', header({ extra: [[7, u(0)]] }), /^capsule: PUBLIC_HEADER: key 7 is not defined: ERR/],
['missing key 4', h(map([0, t('datekeycap')], [1, u(1)], [2, bn(16)], [3, t(dkRound(1))])), /^capsule: PUBLIC_HEADER: key 4 is missing: ERR/],
['capsule_id of 15 bytes', header({ cid: bn(15) }), /^capsule: PUBLIC_HEADER: key 2: codec: offset 17: a byte string of 15 bytes outside 16\.\.16: ERR/],
['DateKey as a byte string', header({ dk: b(hx(new TextEncoder().encode(dkRound(1000)))) }), /key 3: codec: offset 34: a byte string where a text string was expected/],
['access_policy 2', header({ policy: u(2) }), /^capsule: PUBLIC_HEADER: key 4: access_policy 2 is not defined in V1: ERR/],
['access_policy 2^32', header({ policy: u(2 ** 32) }), /key 4: access_policy 4294967296 is not defined in V1/],
['access_policy 2^53-1', header({ policy: u(2n ** 53n - 1n) }), /key 4: access_policy 9007199254740991 is not defined in V1/],
['access_policy 2^53', header({ policy: u(2n ** 53n) }), /^capsule: PUBLIC_HEADER: key 4: codec: offset 129: unsigned integer 9007199254740992 above 9007199254740991: ERR/],
['access_policy 2^64-1', header({ policy: u(2n ** 64n - 1n) }), /key 4: codec: offset 129: unsigned integer 18446744073709551615 above 9007199254740991/],
['access_policy not shortest', header({ policy: '1b0000000000000001' }), /key 4: codec: offset 120: 1 is not in its shortest form \(initial byte 0x1b\)/],
['access_policy negative', header({ policy: '3b0020000000000000' }), /key 4: codec: offset 120: a negative integer \(initial byte 0x3b\) is outside the CBOR profile/],
['empty array', header({ non: arr() }), /^capsule: PUBLIC_HEADER: key 6: extension: empty array; an absent array omits its key: ERR/],
['empty data', header({ non: arr(ext('a', 1, b(''))) }), /key 6: extension "a": data is present but empty; an extension without data omits key 2/],
['text data', header({ non: arr(ext('a', 1, t('x'))) }), /key 6: extension "a": key 2: codec: offset 130: a text string where a byte string was expected/],
['id in both arrays', header({ crit: arr(ext('a', 1)), non: arr(ext('a', 2)) }), /^capsule: PUBLIC_HEADER: extension a: both critical and noncritical: ERR/],
['out of order', header({ non: arr(ext('b'), ext('a')) }), /^capsule: PUBLIC_HEADER: key 6: extension a: array is not in canonical order: ERR/],
['empty extension_id', header({ crit: arr(ext('')) }), /^capsule: PUBLIC_HEADER: key 5: extension: invalid extension_id "": ERR/],
['extension_version 2^53', header({ non: arr(ext('a', 2n ** 53n)) }), /key 6: extension "a": key 1: codec: offset 137: unsigned integer 9007199254740992 above 4294967295/],
['extension_version 2^53-1', header({ crit: arr(ext('a', 2n ** 53n - 1n)) }), /key 5: extension "a": key 1: codec: offset 137: unsigned integer 9007199254740991 above 4294967295/],
];
for (const [name, bytes, msg] of cases) expectCode(() => decodeHeader(bytes), NC, msg, name);
expectCode(() => decodeHeader(new Uint8Array(MAX_PUBLIC_HEADER_LEN + 1)), 'ERR_INTEGRITY', /^capsule: PUBLIC_HEADER of 1048577 bytes exceeds 1048576: ERR_INTEGRITY$/);
});
it('decodes up to 65536 extensions before the 64-extension rule, as the reference', () => {
const many = (n: number): string => {
const items = Array.from({ length: n }, (_, i) => ext(`e${String(i).padStart(5, '0')}`));
return n < 65536 ? arr(...items) : '9a' + n.toString(16).padStart(8, '0') + items.join('');
};
expectCode(() => decodeHeader(header({ non: many(65) })), NC, /65 extensions in one array/);
expectCode(() => decodeHeader(header({ non: many(65536), dk: t('dk1_!!') })), 'ERR_DATEKEY_INVALID');
expectCode(() => decodeHeader(header({ non: many(65537), dk: t('dk1_!!') })), NC, /array of 65537 entries/);
it('bounds each extension array at 64 by its head, before reading any entry', () => {
const many = (n: number): string => arr(...Array.from({ length: n }, (_, i) => ext(`e${String(i).padStart(5, '0')}`)));
expect(decodeHeader(header({ non: many(64) })).noncritical).toHaveLength(64);
expectCode(() => decodeHeader(header({ non: many(65) })), NC, /^capsule: PUBLIC_HEADER: key 6: extension: codec: offset 124: array of 65 items, at most 64: ERR/);
expectCode(() => decodeHeader(header({ non: many(65), dk: t('dk1_!!') })), NC, /array of 65 items, at most 64/);
});
it('decodes access_policy and extension_version as any uint64 and bounds them after the DateKey, as the reference', () => {
// Reproducers of the Go differential (capsule.DecodeHeader at afb44a3).
expectCode(
() => decodeHeader(h('a5006a646174656b65796361700101025000000000000000000000000000000000036178041b0020000000000000')),
'ERR_DATEKEY_INVALID',
/^capsule: PUBLIC_HEADER: datekey: missing "dk1_" prefix: ERR_DATEKEY_INVALID$/,
);
expectCode(
() => decodeHeader(h('a6006a646174656b6579636170010102500000000000000000000000000000000003617804000681a2006161011b0020000000000000')),
'ERR_DATEKEY_INVALID',
/^capsule: PUBLIC_HEADER: datekey: missing "dk1_" prefix: ERR_DATEKEY_INVALID$/,
);
expectCode(() => decodeHeader(header({ dk: t('dk1_!!'), policy: u(2n ** 53n) })), 'ERR_DATEKEY_INVALID');
expectCode(() => decodeHeader(header({ dk: t(dkRound('1.0e3')), policy: u(2n ** 64n - 1n) })), 'ERR_DATEKEY_NON_CANONICAL');
expectCode(() => decodeHeader(header({ dk: t(dkRound('1.0e3')), crit: arr(ext('a', 2n ** 64n - 1n)) })), 'ERR_DATEKEY_NON_CANONICAL');
// With a valid DateKey, the range checks fail with the reference's texts.
expectCode(() => decodeHeader(header({ policy: u(2n ** 53n) })), NC, /^capsule: access_policy 9007199254740992 is not defined in V1: ERR_NON_CANONICAL_CBOR$/);
expectCode(() => decodeHeader(header({ policy: u(2n ** 53n - 1n) })), NC, /access_policy 9007199254740991 is not defined/);
expectCode(() => decodeHeader(header({ policy: u(2n ** 64n - 1n) })), NC, /access_policy 18446744073709551615 is not defined/);
expectCode(
() => decodeHeader(header({ non: arr(ext('a', 2n ** 53n)) })),
NC,
/^capsule: PUBLIC_HEADER noncritical_extensions: extension a: extension_version 9007199254740992 exceeds 4294967295: ERR_NON_CANONICAL_CBOR$/,
);
expectCode(() => decodeHeader(header({ crit: arr(ext('a', 2n ** 53n - 1n)) })), NC, /critical_extensions: extension a: extension_version 9007199254740991 exceeds/);
// Still not in its shortest form, and still no other type.
expectCode(() => decodeHeader(header({ policy: '1b0000000000000001' })), NC, /not in its shortest form/);
expectCode(() => decodeHeader(header({ policy: '3b0020000000000000' })), NC, /found negative integer/);
it('checks the DateKey only after the CDDL (spec §69.1 layer 4)', () => {
const bad = t('dk1_!!');
expectCode(() => decodeHeader(header({ dk: bad })), 'ERR_DATEKEY_INVALID', /^capsule: PUBLIC_HEADER: datekey: payload is not Base64URL: ERR_DATEKEY_INVALID$/);
expectCode(() => decodeHeader(header({ dk: t(dkRound('1.0e3')) })), 'ERR_DATEKEY_NON_CANONICAL', /^capsule: PUBLIC_HEADER: datekey: not the canonical encoding dk1_/);
// A header that also breaks the CDDL reports ERR_NON_CANONICAL_CBOR,
// whatever its DateKey. The reference at afb44a3 read access_policy and
// extension_version as any uint64 and bounded them after the DateKey
// (ERR_DATEKEY_INVALID for the first two reproducers below); the codec of
// 3820066 reports the CDDL layer first, as spec §69.1 requires.
const cases: [string, Uint8Array, RegExp][] = [
['policy 2^53, DateKey "x"', h('a5006a646174656b65796361700101025000000000000000000000000000000000036178041b0020000000000000'), /key 4: codec: offset 46: unsigned integer 9007199254740992 above 9007199254740991/],
['extension_version 2^53, DateKey "x"', h('a6006a646174656b6579636170010102500000000000000000000000000000000003617804000681a2006161011b0020000000000000'), /key 6: extension "a": key 1: codec: offset 54: unsigned integer 9007199254740992 above 4294967295/],
['capsule_id of 15 bytes', header({ cid: bn(15), dk: bad }), /key 2: codec: offset 17: a byte string of 15 bytes/],
['access_policy 2', header({ dk: bad, policy: u(2) }), /key 4: access_policy 2 is not defined in V1/],
['access_policy 2^53', header({ dk: bad, policy: u(2n ** 53n) }), /key 4: codec: offset 51: unsigned integer 9007199254740992 above 9007199254740991/],
['access_policy 2^64-1, non-canonical DateKey', header({ dk: t(dkRound('1.0e3')), policy: u(2n ** 64n - 1n) }), /key 4: codec: offset 130: unsigned integer 18446744073709551615/],
['extensions out of order', header({ dk: bad, non: arr(ext('b'), ext('a')) }), /key 6: extension a: array is not in canonical order/],
['extension_version 2^32', header({ dk: bad, non: arr(ext('a', 2 ** 32)) }), /key 6: extension "a": key 1: codec: offset 59: unsigned integer 4294967296 above 4294967295/],
['extension_version 2^64-1, non-canonical DateKey', header({ dk: t(dkRound('1.0e3')), crit: arr(ext('a', 2n ** 64n - 1n)) }), /key 5: extension "a": key 1: codec: offset 138: unsigned integer 18446744073709551615 above 4294967295/],
['id in both arrays', header({ dk: bad, crit: arr(ext('a')), non: arr(ext('a')) }), /extension a: both critical and noncritical/],
['empty data', header({ dk: bad, non: arr(ext('a', 1, b(''))) }), /data is present but empty/],
];
for (const [name, bytes, msg] of cases) expectCode(() => decodeHeader(bytes), NC, msg, name);
});
});
@ -164,7 +160,7 @@ describe('encodeHeader', () => {
expect(decodeHeader(exact).noncritical[0]!.data!.length).toBe(n);
expectCode(() => encodeHeader(withData(n + 1)), 'ERR_INTEGRITY', /PUBLIC_HEADER of 1048577 bytes exceeds 1048576/);
// At the limit, the reader goes on to the schema checks.
expectCode(() => decodeHeader(new Uint8Array(MAX_PUBLIC_HEADER_LEN)), NC, /extraneous data/);
expectCode(() => decodeHeader(new Uint8Array(MAX_PUBLIC_HEADER_LEN)), NC, /offset 0: an unsigned integer where a map was expected/);
expectCode(() => decodeHeader(new Uint8Array(MAX_PUBLIC_HEADER_LEN + 1)), 'ERR_INTEGRITY', /PUBLIC_HEADER of 1048577 bytes exceeds 1048576/);
});

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

@ -23,9 +23,9 @@ function run(dkc: Uint8Array, extensions?: ExtensionRegistry): Inspection {
}
// A PUBLIC_HEADER like the one of time_only_extensions, with overrides.
function header(o: { dk?: string; policy?: string; crit?: string; non?: string; version?: string; extra?: [number, string][] } = {}): Uint8Array {
function header(o: { type?: string; dk?: string; policy?: string; crit?: string; non?: string; version?: string; extra?: [number, string][] } = {}): Uint8Array {
const entries: [number, string][] = [
[0, t('datekeycap')],
[0, o.type ?? t('datekeycap')],
[1, o.version ?? u(1)],
[2, bn(16, 7)],
[3, o.dk ?? t(dkRound(2000))],
@ -114,10 +114,12 @@ describe('inspect', () => {
expect(last(at4(header({ version: u(2) })))).toEqual([4, 'header validation', false, 'ERR_UNSUPPORTED_VERSION']);
expect(last(at4(header({ extra: [[7, u(0)]] })))).toEqual([4, 'header validation', false, 'ERR_NON_CANONICAL_CBOR']);
expect(last(at4(header({ policy: u(2) })))).toEqual([4, 'header validation', false, 'ERR_NON_CANONICAL_CBOR']);
// Integers of up to 64 bits are bounded after the DateKey, as the reference.
expect(last(at4(header({ dk: t('x'), policy: u(2n ** 53n) })))).toEqual([4, 'header validation', false, 'ERR_DATEKEY_INVALID']);
expect(last(at4(header({ dk: t(dkRound('2e3')), non: arr(ext('a', 2n ** 64n - 1n)) })))).toEqual([4, 'header validation', false, 'ERR_DATEKEY_NON_CANONICAL']);
expect(at4(header({ policy: u(2n ** 53n) })).checks.at(-1)!.detail).toBe('capsule: access_policy 9007199254740992 is not defined in V1: ERR_NON_CANONICAL_CBOR');
// The CDDL, integer bounds included, before the DateKey (spec §69.1).
expect(last(at4(header({ dk: t('x'), policy: u(2n ** 53n) })))).toEqual([4, 'header validation', false, 'ERR_NON_CANONICAL_CBOR']);
expect(last(at4(header({ dk: t(dkRound('2e3')), non: arr(ext('a', 2n ** 64n - 1n)) })))).toEqual([4, 'header validation', false, 'ERR_NON_CANONICAL_CBOR']);
expect(at4(header({ policy: u(2n ** 53n) })).checks.at(-1)!.detail).toBe(
'capsule: PUBLIC_HEADER: key 4: codec: offset 129: unsigned integer 9007199254740992 above 9007199254740991: ERR_NON_CANONICAL_CBOR',
);
expect(last(at4(header({ dk: t(dkRound('2e3')) })))).toEqual([4, 'header validation', false, 'ERR_DATEKEY_NON_CANONICAL']);
expect(last(at4(header({ non: arr(ext('a', 1, t('x'))) })))).toEqual([4, 'header validation', false, 'ERR_NON_CANONICAL_CBOR']);
expect(last(at4(header({ non: arr(ext('a', 1, b(''))) })))).toEqual([4, 'header validation', false, 'ERR_NON_CANONICAL_CBOR']);
@ -142,6 +144,75 @@ describe('inspect', () => {
expect(unusable.checks[3]!.detail).toMatch(/, 1 unusable noncritical extensions$/);
});
it('reports the code of the first failing layer and step (spec §69.1 examples)', () => {
const at4 = (hb: Uint8Array, ext?: ExtensionRegistry): Inspection => run(frame({ ...parts, header: hb }), ext);
// The type tag of another schema and version 2.
const tag = at4(header({ type: t('datekeys-control'), version: u(2) }));
expect(last(tag)).toEqual([4, 'header validation', false, 'ERR_NON_CANONICAL_CBOR']);
expect(tag.checks.at(-1)!.detail).toBe('capsule: PUBLIC_HEADER: codec: type "datekeys-control", want "datekeycap": ERR_NON_CANONICAL_CBOR');
// Version 2, an unknown key and an invalid DateKey.
expect(last(at4(header({ version: u(2), extra: [[11, u(0)]], dk: t('dk1_x') })))).toEqual([4, 'header validation', false, 'ERR_UNSUPPORTED_VERSION']);
// access_policy 2 and the DateKey dk1_x.
expect(last(at4(header({ policy: u(2), dk: t('dk1_x') })))).toEqual([4, 'header validation', false, 'ERR_NON_CANONICAL_CBOR']);
// The DateKey dk1_x and an unknown critical extension.
expect(last(at4(header({ dk: t('dk1_x'), crit: arr(ext('org.example.zz')) })))).toEqual([4, 'header validation', false, 'ERR_DATEKEY_INVALID']);
// A critical extension with invalid data followed, in the array, by an unknown one.
const knowsA: ExtensionRegistry = { known: (id) => id === 'a', validateData: () => new Error('ko') };
expect(last(at4(header({ crit: arr(ext('a', 1, b('01')), ext('b')) }), knowsA))).toEqual([4, 'header validation', false, 'ERR_EXTENSION_CRITICAL_UNKNOWN']);
// A round beyond the profile and a malformed PAYLOAD_AGE header: step 6 comes first.
const noStanza = (x: Uint8Array): Uint8Array => {
const s = new TextDecoder('latin1').decode(x);
return replaceText(x, s.slice(s.indexOf('\n-> ') + 1, s.indexOf('\n---') + 1), '');
};
const late = run(frame({ ...parts, header: header({ dk: t(dkRound(83903165812)) }), payload: noStanza(parts.payload) }));
expect(last(late)).toEqual([6, 'payload structure', false, 'ERR_INTEGRITY']);
expect(late.checks.at(-1)!.detail).toBe(
'capsule: PAYLOAD_AGE: agewrap: not a valid age file: failed to read header: parsing age header: no recipient stanzas: ERR_INTEGRITY',
);
});
it('rejects malformed age headers with ERR_INTEGRITY (spec §28.1)', () => {
const noStanza = (x: Uint8Array): Uint8Array => {
const s = new TextDecoder('latin1').decode(x);
return replaceText(x, s.slice(s.indexOf('\n-> ') + 1, s.indexOf('\n---') + 1), '');
};
// A header without stanzas: the grammar requires 1*stanza.
expect(last(run(frame({ ...parts, sealed: noStanza(parts.sealed) })))).toEqual([5, 'sealed control structure', false, 'ERR_INTEGRITY']);
expect(last(run(frame({ ...parts, payload: noStanza(parts.payload) })))).toEqual([6, 'payload structure', false, 'ERR_INTEGRITY']);
// A CR belongs to the line and makes it malformed.
const ch = '52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971';
const cr = run(frame({ ...parts, sealed: replaceText(parts.sealed, `${ch}\n`, `${ch}\r\n`) }));
expect(last(cr)).toEqual([5, 'sealed control structure', false, 'ERR_INTEGRITY']);
expect(cr.checks.at(-1)!.detail).toBe(
`capsule: SEALED_CONTROL: agewrap: not a valid age file: failed to read header: failed to parse header: malformed stanza: "-> tlock 2000 ${ch}\\r\\n": ERR_INTEGRITY`,
);
});
it('compares the tlock arguments as exact strings, in the order of step 8', () => {
const ch = '52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971';
const tlock = (from: string, to: string): Inspection => run(frame({ ...parts, sealed: replaceText(parts.sealed, from, to) }));
const cases: [string, Inspection, string, string][] = [
['leading zero', tlock('-> tlock 2000 ', '-> tlock 02000 '), 'ERR_ROUND_MISMATCH', 'agewrap: tlock stanza round "02000", DateKey round 2000: ERR_ROUND_MISMATCH'],
['sign', tlock('-> tlock 2000 ', '-> tlock +2000 '), 'ERR_ROUND_MISMATCH', 'agewrap: tlock stanza round "+2000", DateKey round 2000: ERR_ROUND_MISMATCH'],
[
'uppercase chain hash',
tlock(ch, ch.toUpperCase()),
'ERR_PROFILE_MISMATCH',
`agewrap: tlock stanza chain hash "${ch.toUpperCase()}", pinned profile datekeys:quicknet:v1 uses ${ch}: ERR_PROFILE_MISMATCH`,
],
['three arguments', tlock(`${ch}\n`, `${ch} x\n`), 'ERR_POLICY_STRUCTURE_MISMATCH', 'agewrap: tlock stanza has 3 arguments, want 2: ERR_POLICY_STRUCTURE_MISMATCH'],
['round first', tlock('-> tlock 2000 52db9ba70e0cc0f6', '-> tlock 2001 52db9ba70e0cc0f7'), 'ERR_ROUND_MISMATCH', 'agewrap: tlock stanza round "2001", DateKey round 2000: ERR_ROUND_MISMATCH'],
];
for (const [name, r, code, detail] of cases) {
expect(last(r), name).toEqual([8, 'tlock stanza', false, code]);
expect(r.checks.at(-1)!.detail, name).toBe(detail);
}
// The last Quicknet round opens at 9999-12-31T23:59:57Z (spec §15).
const lastRound = run(frame({ ...parts, header: header({ dk: t(dkRound(83903165811)) }), sealed: replaceText(parts.sealed, '-> tlock 2000 ', '-> tlock 83903165811 ') }));
expect(lastRound.error).toBeUndefined();
expect(inspectView(lastRound).unlock_at).toBe('9999-12-31T23:59:57Z');
});
it('applies the registry it is given', async () => {
const empty = await newRegistry();
expect(last(inspectWith(timeOnly, empty))).toEqual([4, 'header validation', false, 'ERR_UNKNOWN_PROFILE']);

@ -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`;
}

@ -3,9 +3,11 @@ import { checkCompressedPoint } from './bls12381.ts';
import {
canonicalCBOR,
chainHashHex,
chainInfoHash,
cloneProfile,
decodeProfile,
defaultRegistry,
MAX_CHAIN_HASH_PERIOD,
maxRound,
newRegistry,
type Profile,
@ -100,7 +102,10 @@ describe('Quicknet profile', () => {
expect(hx(profile())).toBe(QUICKNET_CANONICAL_CBOR);
expect(chainHashHex(QN)).toBe('52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971');
expect(maxRound(QN)).toBe(83903165811);
expect(maxRound({ ...QN, genesisTime: 253402300799 })).toBe(0);
// A round time of exactly 9999-12-31T23:59:59Z is still valid (spec §15).
expect(maxRound({ ...QN, genesisTime: 253402300799 })).toBe(1);
expect(maxRound({ ...QN, genesisTime: 253402300797, period: 2 })).toBe(2);
expect(maxRound({ ...QN, genesisTime: 253402300800 })).toBe(0);
expect(maxRound({ ...QN, period: 0 })).toBe(0);
});
@ -131,18 +136,24 @@ describe('Quicknet profile', () => {
});
describe('decodeProfile', () => {
it('checks the schema, then the sizes, then the fields', async () => {
await expectCodeAsync(() => decodeProfile(profile({ 1: u(2) })), 'ERR_UNSUPPORTED_VERSION', /^profile: codec:/);
await expectCodeAsync(() => decodeProfile(profile({ 0: t('datekeycap') })), NC);
await expectCodeAsync(() => decodeProfile(profile({ 7: '1803' })), NC);
await expectCodeAsync(() => decodeProfile(h(QUICKNET_CANONICAL_CBOR.replace(/^ab/, 'aa').slice(0, -70))), NC);
await expectCodeAsync(() => decodeProfile(profile({ 5: bn(31) })), NC, /must be 32 bytes/);
await expectCodeAsync(() => decodeProfile(profile({ 10: bn(33), 2: t('X') })), NC, /must be 32 bytes/);
await expectCodeAsync(() => decodeProfile(profile({ 7: u(0) })), NC, /period 0/);
await expectCodeAsync(() => decodeProfile(profile({ 7: u(0), 2: t('X') })), NC);
await expectCodeAsync(() => decodeProfile(profile({ 7: u(86401) })), NC, /period 86401 s out of range/);
await expectCodeAsync(() => decodeProfile(profile({ 7: u(2n ** 53n) })), NC);
await expectCodeAsync(() => decodeProfile(profile({ 8: u(2n ** 53n) })), NC);
it('checks the schema head, then the CDDL with the period limit, then the fields (spec §12.1 rules 1 and 2)', async () => {
const cases: [string, Uint8Array, string, RegExp][] = [
['schema version 2', profile({ 1: u(2) }), 'ERR_UNSUPPORTED_VERSION', /^profile: codec: datekeys-provider-profile schema version 2, want 1: ERR_UNSUPPORTED_VERSION$/],
['type tag of PUBLIC_HEADER', profile({ 0: t('datekeycap') }), NC, /^profile: codec: type "datekeycap", want "datekeys-provider-profile": ERR/],
['period not shortest', profile({ 7: '1803' }), NC, /^profile: key 7: codec: offset 205: 3 is not in its shortest form \(initial byte 0x18\): ERR/],
['ten keys', h(QUICKNET_CANONICAL_CBOR.replace(/^ab/, 'aa').slice(0, -70)), NC, /^profile: 10 keys, want all 11: ERR/],
['chain_hash of 31 bytes', profile({ 5: bn(31) }), NC, /^profile: key 5: codec: offset 73: a byte string of 31 bytes outside 32\.\.32: ERR/],
['genesis_seed of 33 bytes and an invalid profile_id', profile({ 10: bn(33), 2: t('X') }), NC, /^profile: key 10: codec: offset 223: a byte string of 33 bytes/],
['period 0', profile({ 7: u(0) }), NC, /^profile: key 7: period 0: ERR/],
['period 0 and an invalid profile_id', profile({ 7: u(0), 2: t('X') }), NC, /key 7: period 0/],
['period 86401, above the implementation limit', profile({ 7: u(86401) }), NC, /^profile: period 86401 s out of range: ERR/],
['period 86401 and an invalid profile_id', profile({ 7: u(86401), 2: t('X') }), NC, /period 86401 s out of range/],
['period 2^53', profile({ 7: u(2n ** 53n) }), NC, /^profile: key 7: codec: offset 214: unsigned integer 9007199254740992 above 9007199254740991: ERR/],
['genesis_time 2^53', profile({ 8: u(2n ** 53n) }), NC, /^profile: key 8: codec: offset 216: unsigned integer 9007199254740992 above 9007199254740991: ERR/],
['provider not valid UTF-8', profile({ 3: '62c080' }), NC, /^profile: key 3: codec: offset 54: text string is not valid UTF-8: ERR/],
['genesis_time 0', profile({ 8: u(0) }), UP, /^profile datekeys:quicknet:v1: invalid genesis time 0: ERR_UNKNOWN_PROFILE$/],
];
for (const [name, bytes, code, msg] of cases) await expectCodeAsync(() => decodeProfile(bytes), code, msg);
});
it('rejects invalid names, keys and parameters with ERR_UNKNOWN_PROFILE', async () => {
@ -183,6 +194,24 @@ describe('decodeProfile', () => {
expect((await decodeProfile(profile({ 9: t('bls-unchained-on-g1') }))).scheme).toBe('bls-unchained-on-g1');
});
it('computes chain_hash with the formula of spec §12.1 rule 3', async () => {
// SHA-256(uint32_be(period) || int64_be(genesis_time) || public_key || genesis_seed || network).
expect(hx(await chainInfoHash(QN))).toBe(hx(QN.chainHash));
// The network "default" is not hashed; "defaults" is.
const d = { ...QN, network: 'default' };
const withDefault = { ...d, chainHash: await chainInfoHash(d) };
await validateProfile(withDefault);
await expectCodeAsync(() => validateProfile({ ...withDefault, network: 'defaults' }), 'ERR_PROFILE_MISMATCH');
// A period has a chain hash only within the 32 bits the formula gives it.
expect((await chainInfoHash({ ...QN, period: MAX_CHAIN_HASH_PERIOD })).length).toBe(32);
for (const period of [0, MAX_CHAIN_HASH_PERIOD + 1, 1.5]) {
await expectCodeAsync(() => chainInfoHash({ ...QN, period }), UP, /does not fit the 32 bits of the chain hash/);
}
// Values that no encoding holds fail with ERR_NON_CANONICAL_CBOR first.
await expectCodeAsync(() => validateProfile({ ...QN, chainHash: new Uint8Array(31) }), NC, /chain hash and genesis seed must be 32 bytes/);
await expectCodeAsync(() => validateProfile({ ...QN, genesisSeed: new Uint8Array(33) }), NC, /chain hash and genesis seed must be 32 bytes/);
});
it('accepts other drand parameters that hash to their own chain hash', async () => {
// The drand default network: pedersen-bls-chained is refused, but the
// same parameters with the unchained G1-key scheme validate, since the
@ -205,16 +234,43 @@ describe('decodeProfile', () => {
await expectCodeAsync(() => validateProfile({ ...p, scheme: 'pedersen-bls-chained' }), UP, /not supported by tlock/);
});
it('validates profiles built in memory', async () => {
await expectCodeAsync(() => validateProfile({ ...QN, period: 0 }), UP, /invalid period 0s/);
await expectCodeAsync(() => validateProfile({ ...QN, period: 1.5 }), UP);
await expectCodeAsync(() => validateProfile({ ...QN, genesisTime: 0.5 }), UP, /invalid genesis time/);
it('validates profiles built in memory with the code their encoding would get (spec §12.1)', async () => {
// The texts are those of Go's Profile.Validate, which prints the period
// as a time.Duration.
const cases: [number, string][] = [
[0, '0s'],
[-1, '-1s'],
[1.5, '1.5s'],
[0.5, '500ms'],
[0.1, '100ms'],
[1.234567, '1.234567s'],
[86401, '24h0m1s'],
[90000, '25h0m0s'],
];
for (const [period, text] of cases) {
await expectCodeAsync(
() => validateProfile({ ...QN, period }),
NC,
new RegExp(`^profile "datekeys:quicknet:v1": period ${text.replace('.', '\\.')} is not a whole number of seconds in 1\\.\\.86400: ERR`),
);
}
await expectCodeAsync(() => validateProfile({ ...QN, period: 61 }), 'ERR_PROFILE_MISMATCH', /parameters hash to chain c52c0d511a3e8badf76968bd7100182c1a82089062a30270ddf3230f841de918/);
await expectCodeAsync(() => validateProfile({ ...QN, genesisTime: -1 }), NC, /^profile "datekeys:quicknet:v1": genesis time -1 outside 0\.\.9007199254740991: ERR/);
await expectCodeAsync(() => validateProfile({ ...QN, genesisTime: 0.5 }), NC, /genesis time 0\.5 outside/);
// A lone surrogate is quoted as its UTF-8 encoding, U+FFFD, which Go prints.
await expectCodeAsync(() => validateProfile({ ...QN, provider: 'a\u{d800}' }), NC, /name "a\u{fffd}" is not valid UTF-8/u);
// Pinning goes through the encoding: its errors are those of canonicalCBOR and decodeProfile.
await expectCodeAsync(() => newRegistry({ profile: { ...QN, period: 0 }, hash: new Uint8Array(32) }), NC, /^profile: period 0s is not a positive whole number of seconds: ERR/);
await expectCodeAsync(() => newRegistry({ profile: { ...QN, period: 90000 }, hash: new Uint8Array(32) }), NC, /^profile: period 90000 s out of range: ERR/);
});
it('encodes only representable profiles', () => {
expectCode(() => canonicalCBOR({ ...QN, period: 0 }), NC);
expectCode(() => canonicalCBOR({ ...QN, period: 1.5 }), NC);
expectCode(() => canonicalCBOR({ ...QN, genesisTime: -1 }), NC);
expectCode(() => canonicalCBOR({ ...QN, period: 0 }), NC, /^profile: period 0s is not a positive whole number of seconds: ERR/);
expectCode(() => canonicalCBOR({ ...QN, period: 1.5 }), NC, /period 1\.5s/);
expectCode(() => canonicalCBOR({ ...QN, period: 0.000001 }), NC, /period 1\u{b5}s/u);
expectCode(() => canonicalCBOR({ ...QN, period: 1e-9 }), NC, /period 1ns/);
expectCode(() => canonicalCBOR({ ...QN, period: Number.NaN }), NC, /period NaNs/);
expectCode(() => canonicalCBOR({ ...QN, genesisTime: -1 }), NC, /^profile: genesis time -1 outside 0\.\.9007199254740991: ERR/);
expectCode(() => canonicalCBOR({ ...QN, chainHash: new Uint8Array(31) }), NC);
});

@ -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);
@ -160,10 +202,48 @@ function encodeWire(e: Encoder, w: ProfileWire): void {
e.bstr(w.genesisSeed);
}
/**
* Go's time.Duration.String of a period of `seconds`, as the messages of the
* reference print it: 0s, 500ms, 1.5s, 24h0m1s.
*/
function goDuration(seconds: number): string {
if (!Number.isFinite(seconds)) return `${seconds}s`;
let u = BigInt(Math.round(Math.abs(seconds) * 1e9));
// The fraction of u / 10^prec without trailing zeros, the point omitted
// when it is zero; u keeps the integer part.
const frac = (prec: number): string => {
let digits = '';
for (let i = 0; i < prec; i++) {
const d = u % 10n;
if (digits !== '' || d !== 0n) digits = `${d}${digits}`;
u /= 10n;
}
return digits === '' ? '' : `.${digits}`;
};
let out: string;
if (u === 0n) {
out = '0s';
} else if (u < 1_000_000_000n) {
const [prec, unit] = u < 1000n ? [0, 'ns'] : u < 1_000_000n ? [3, '\u{b5}s'] : [6, 'ms'];
const f = frac(prec);
out = `${u}${f}${unit}`;
} else {
const f = frac(9);
out = `${u % 60n}${f}s`;
u /= 60n;
if (u > 0n) {
out = `${u % 60n}m${out}`;
u /= 60n;
if (u > 0n) out = `${u}h${out}`;
}
}
return seconds < 0 ? `-${out}` : out;
}
/** The exact Deterministic CBOR bytes of spec §11. */
export function canonicalCBOR(p: Profile): Uint8Array {
if (!Number.isSafeInteger(p.period) || p.period <= 0) {
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `profile: period ${p.period} is not a positive whole number of seconds`);
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `profile: period ${goDuration(p.period)} is not a positive whole number of seconds`);
}
if (!Number.isSafeInteger(p.genesisTime) || p.genesisTime < 0) {
throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `profile: genesis time ${p.genesisTime} outside 0..${MAX_SAFE_UINT}`);
@ -172,7 +252,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 +262,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 ${goDuration(p.period)} 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 +328,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 +368,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);
@ -291,9 +399,13 @@ export function chainHashHex(p: Profile): string {
return toHex(p.chainHash);
}
/** The last round whose round time is not after MAX_UNIX_TIME. */
/**
* The last round whose round time is not after MAX_UNIX_TIME (spec §15), or
* 0 when there is none: a round time of exactly 9999-12-31T23:59:59Z is
* still valid.
*/
export function maxRound(p: Profile): number {
if (p.period <= 0 || p.genesisTime >= MAX_UNIX_TIME) return 0;
if (p.period <= 0 || p.genesisTime > MAX_UNIX_TIME) return 0;
return Math.floor((MAX_UNIX_TIME - p.genesisTime) / p.period) + 1;
}
@ -330,14 +442,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,17 @@
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
// vectors.test.ts runs whatever testdata/ holds, so the shared Go vectors are
// only as good as their integrity: a truncated or edited file must fail here
// rather than pass with fewer cases. The check is the one of
// scripts/sync-testdata.mjs: every file matches the SHA-256 recorded in
// testdata/SOURCE.json for the pinned datekeys-go commit, with none missing
// and none extra.
describe('testdata', () => {
it('matches testdata/SOURCE.json byte for byte, with no missing or extra file', () => {
const root = fileURLToPath(new URL('../../../', import.meta.url));
const out = execFileSync(process.execPath, ['scripts/sync-testdata.mjs', 'check'], { cwd: root, encoding: 'utf8' });
expect(out).toMatch(/^testdata: \d+ files match g\.activething\.com\/go\/DateKeys at [0-9a-f]{40}/);
});
});

@ -51,17 +51,21 @@ export function hx(b: Uint8Array): string {
return Buffer.from(b).toString('hex');
}
/** Asserts that `fn` throws a DateKeysError with `code` (and a message matching `msg`). */
export function expectCode(fn: () => unknown, code: string, msg?: RegExp): void {
/**
* Asserts that `fn` throws a DateKeysError with `code` (and a message matching
* `msg`); `label` names the case in a failure.
*/
export function expectCode(fn: () => unknown, code: string, msg?: RegExp, label?: string): void {
let caught: unknown;
try {
fn();
} catch (err) {
caught = err;
}
expect(caught, 'expected a thrown error').toBeDefined();
expect(errorCode(caught), (caught as Error).message).toBe(code);
if (msg) expect((caught as Error).message).toMatch(msg);
const where = label === undefined ? '' : `${label}: `;
expect(caught, `${where}expected a thrown error`).toBeDefined();
expect(errorCode(caught), `${where}${(caught as Error).message}`).toBe(code);
if (msg) expect((caught as Error).message, label).toMatch(msg);
}
/** Async variant of expectCode. */

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

@ -7,10 +7,13 @@
// the JSON records (payload_identity, control_cbor, …) reach the bundle.
const urls = import.meta.glob<string>('/testdata/fixtures/*.dkc', { query: '?url', import: 'default', eager: true });
const descriptions = import.meta.glob<unknown>(['/testdata/fixtures/*.json', '!/testdata/fixtures/*.dkk.json'], {
import: 'description',
eager: true,
});
// The records of the capsules only: neither the .dkk records nor the frozen
// outputs of `datekeys inspect -json` (*.inspect.json), which have no
// description.
const descriptions = import.meta.glob<unknown>(
['/testdata/fixtures/*.json', '!/testdata/fixtures/*.dkk.json', '!/testdata/fixtures/*.inspect.json'],
{ import: 'description', eager: true },
);
/** One official fixture capsule. */
export interface Fixture {

@ -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;
@ -37,19 +37,19 @@ export function stepGloss(step: number): string {
case 1:
return 'El fichero empieza por la marca DKC1 y trae un prelude completo.';
case 2:
return 'Versión de trama 1, FLAGS y RESERVED a cero y longitudes dentro de los límites de §57.';
return 'Versión de trama 1, FLAGS y RESERVED a cero, y cada longitud entre 1 byte y su límite de §57 (1 MiB y 64 MiB).';
case 3:
return 'Se leen los bytes exactos de PUBLIC_HEADER.';
case 4:
return 'CBOR canónico, DateKey canónica, perfil fijado en este lector y ninguna extensión crítica desconocida.';
return 'Tipo y versión de esquema, CBOR canónico y esquema, con las extensiones en orden estricto; después, DateKey canónica, perfil fijado en este lector y ninguna extensión crítica desconocida.';
case 5:
return 'OUTER_TIME_AGE, dentro de SEALED_CONTROL, tiene exactamente un stanza tlock.';
return 'La cabecera age de OUTER_TIME_AGE, dentro de SEALED_CONTROL, está bien formada (§28.1) y tiene exactamente un stanza tlock.';
case 6:
return 'La cabecera age de PAYLOAD_AGE tiene exactamente un stanza X25519.';
return 'La cabecera age de PAYLOAD_AGE, que llega hasta el final del fichero, está bien formada (§28.1) y tiene exactamente un stanza X25519.';
case 7:
return 'La ronda de la DateKey se convierte en fecha de apertura con el perfil fijado, sin red.';
return 'La ronda de la DateKey se convierte en fecha de apertura con el perfil fijado, sin red; esa fecha no pasa de 9999-12-31T23:59:59Z (§15).';
case 8:
return 'El stanza tlock nombra la ronda de la DateKey y la cadena del perfil fijado.';
return 'El stanza tlock tiene dos argumentos, la ronda de la DateKey en decimal canónico y la cadena del perfil fijado en hexadecimal en minúsculas, comparados como texto exacto.';
default:
return '';
}
@ -65,27 +65,27 @@ export function errorGloss(code: ErrorCode): string {
case 'ERR_INVALID_FLAGS':
return 'FLAGS o RESERVED del prelude no valen cero.';
case 'ERR_NON_CANONICAL_CBOR':
return 'El CBOR no está en la forma canónica del perfil del protocolo (§58) o no sigue su esquema.';
return 'El CBOR no está en la forma canónica del perfil del protocolo (§58) o no sigue su esquema: tipos, tamaños, claves u orden de las extensiones.';
case 'ERR_UNKNOWN_PROFILE':
return 'La DateKey nombra un perfil que este lector no tiene fijado.';
case 'ERR_PROFILE_MISMATCH':
return 'El stanza tlock usa una cadena distinta de la del perfil fijado.';
case 'ERR_DATEKEY_INVALID':
return 'La DateKey no es válida para su perfil.';
return 'La DateKey no se puede leer como dk1_, o su ronda abriría después de 9999-12-31T23:59:59Z con el perfil fijado.';
case 'ERR_DATEKEY_NON_CANONICAL':
return 'La DateKey no está en su forma canónica dk1_.';
case 'ERR_ROUND_MISMATCH':
return 'La ronda del stanza tlock no es la de la DateKey.';
return 'La ronda del stanza tlock no es la de la DateKey escrita en decimal canónico.';
case 'ERR_RELEASE_UNAVAILABLE':
return 'La firma de la ronda todavía no se puede obtener.';
case 'ERR_RELEASE_INVALID':
return 'La firma de la ronda no verifica con el perfil fijado.';
case 'ERR_ACCESS_REQUIRED':
return 'La política exige una clave de acceso .dkk.';
return 'La política exige una credencial de acceso: una clave .dkk o la identidad X25519 de un destinatario.';
case 'ERR_ACCESS_INVALID':
return 'La clave de acceso no abre esta cápsula.';
case 'ERR_POLICY_STRUCTURE_MISMATCH':
return 'El número o el tipo de stanzas age no es el que exige el protocolo.';
return 'El número o el tipo de stanzas age, o el número de argumentos del stanza tlock, no es el que exige el protocolo.';
case 'ERR_HEADER_BINDING':
return 'La cabecera pública no es la que se selló.';
case 'ERR_INTEGRITY':
@ -103,7 +103,7 @@ export function policyGloss(policy: string): string {
case 'time_only':
return 'Solo tiempo: se abre con la firma pública de la ronda.';
case 'time_and_key':
return 'Tiempo y clave: además de la ronda hace falta una clave de acceso .dkk.';
return 'Tiempo y clave: además de la firma de la ronda hace falta una credencial de acceso, una clave .dkk o la identidad X25519 de un destinatario.';
default:
return 'Política no definida en V1.';
}
@ -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);
}

@ -15,9 +15,9 @@
<div class="pitch">
<h1>Mira una cápsula DateKeys sin abrirla</h1>
<p class="lead">
Una cápsula <code>.dkc</code> es un fichero cifrado que nadie puede abrir antes de una fecha: la clave depende de
Una cápsula <code>.dkc</code> es un fichero cifrado que no se puede abrir antes de una fecha: la clave depende de
una firma que todavía no existe. La publicará en esa fecha drand, una red pública que emite una firma nueva cada
pocos segundos. El inspector lee la parte pública de la cápsula y comprueba que está bien formada, con los mismos
pocos segundos; la garantía se apoya en que esa red no revele la firma antes de tiempo. El inspector lee la parte pública de la cápsula y comprueba que está bien formada, con los mismos
pasos que la herramienta <code>datekeys inspect</code>.
</p>
<p class="actions">
@ -65,8 +65,9 @@
<li>
<h3>Lo que queda para la apertura</h3>
<p>
Superar los pasos 1 a 8 no prueba que la cápsula se pueda abrir. La cabecera pública solo queda autenticada en el
paso 15, al abrirla después de la fecha.
Superar los pasos 1 a 8 no prueba que la cápsula se pueda abrir. La cabecera pública solo queda vinculada al resto
de la cápsula en el paso 15, al abrirla después de la fecha, y ni siquiera entonces prueba quién la escribió ni
cuándo (§55.1).
</p>
</li>
</ul>

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": "692cf87db1ea88a88624b59e2a95400fbc236e08",
"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": "3a345330fed244662b0a335f0a6d5581c200ceeb09bfb74c4d1c342daea72864",
"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,26 @@
"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"
},
{
"name": "invalid UTF-8 in a member a repeated name overwrites",
"input": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoi_yIsIm5ldHdvcmsiOiJkYXRla2V5czpxdWlja25ldDp2MSIsInJvdW5kIjo2Njg4NDIxMn0",
"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.