Phase 2, step 5b: open from a Blob, streaming to an output

open(dkc, opts) now takes a Uint8Array or a Blob, such as a File.
- Of a Blob it reads only the prefix that steps 1 to 8 need.
  inspectedLength and readCapsule move from src/lib/inspector/load.ts to
  src/lib/dkc/prefix.ts, and the page imports them from the library.
- The .dkk capsule_digest is computed over the Blob's stream with the
  new src/lib/dkc/digest.ts, an incremental SHA-256 on @noble/hashes,
  since Web Crypto hashes whole buffers only. digest.ts joins the noble
  allowlist of the guards.
- PAYLOAD_AGE is decrypted in streaming.

The plaintext goes to memory, as before, or to opts.output, a
WritableStream. The output is written as age authenticates each chunk,
closed only after step 18, and aborted after any failure at any step,
even before step 17 (spec §56). A failure of the output is ERR_INTEGRITY
with its text, as Go keeps the error of the writer of the plaintext.

Tests:
- the fixtures from Blobs, to memory and to an output;
- a truncated two-chunk payload whose first chunk reached the output
  before the abort;
- early failures that never write;
- write and close failures, and an abort that fails;
- a .dkk without capsule_digest;
- the whole mutation corpus again as Blobs into an output, aborted in
  every case;
- digest.ts against Web Crypto.
Coverage of digest.ts is 100 % and a threshold.

Checked in the browser (dev server, real OPFS). time_only.dkc, two
STREAM chunks, opened from a Blob into FileSystemFileHandle.createWritable
gives 78,000 bytes with the SHA-256 of its sidecar. The same file
truncated fails at step 17, and the OPFS file keeps its previous
content. The quota check, the temporary file and the download belong to
the page, in step 8.

npm run verify is green: 2,560 tests. The site does not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
dev 1 week ago
parent b0814ab260
commit a89bee562b

@ -18,11 +18,11 @@ Implementa la especificación DateKeys 0.8.2 (tag `spec-v0.8.2` de `datekeys-go`
- dependencias de ejecución (`age-encryption` 0.3.1, `@noble/curves` y `@noble/hashes` 2.4.0) con sus guardas; - dependencias de ejecución (`age-encryption` 0.3.1, `@noble/curves` y `@noble/hashes` 2.4.0) con sus guardas;
- el IBE de tlock (`ibe.ts`) y la verificación local de releases (`release.ts`), contrastados con la referencia Go; - el IBE de tlock (`ibe.ts`) y la verificación local de releases (`release.ts`), contrastados con la referencia Go;
- la apertura, pasos 9 a 18 de §63 (`open.ts`), con el texto en claro en memoria. Las identidades estrictas de `agewrap` se apoyan en `x25519.ts`, que abre cada stanza X25519 por separado, y en `bech32.ts`. Los 65 casos del corpus de mutaciones pasan por `open` con el código y el paso de Go, y los cinco fixtures oficiales se abren a su texto en claro; - la apertura, pasos 9 a 18 de §63 (`open.ts`), con el texto en claro en memoria. Las identidades estrictas de `agewrap` se apoyan en `x25519.ts`, que abre cada stanza X25519 por separado, y en `bech32.ts`. Los 65 casos del corpus de mutaciones pasan por `open` con el código y el paso de Go, y los cinco fixtures oficiales se abren a su texto en claro;
- `@noble/ciphers` 2.4.0 como dependencia directa, aprobada el 28-09-2026: la copia que ya trae `age-encryption`. - `@noble/ciphers` 2.4.0 como dependencia directa, aprobada el 28-09-2026: la copia que ya trae `age-encryption`;
- la apertura en streaming. La entrada puede ser un `Blob`, del que se lee solo el prefijo de los pasos 1 a 8 (`prefix.ts`, antes en la página) y se descifra `PAYLOAD_AGE` en streaming. La salida puede ser un `WritableStream`, que se cierra solo tras el paso 18 y se aborta ante cualquier fallo. En el navegador, con un fichero OPFS, un fallo de STREAM deja intacto su contenido anterior.
- `VERSION` y `SPEC_VERSION`, también en el pie de la página. - `VERSION` y `SPEC_VERSION`, también en el pie de la página.
### Pendiente para 0.1.0 ### Pendiente para 0.1.0
- El descifrado de `PAYLOAD_AGE` en streaming a OPFS (fase 2, paso 5b).
- El cifrado a nivel de stanza y de fichero `age`, contrastado con Go (paso 7). - El cifrado a nivel de stanza y de fichero `age`, contrastado con Go (paso 7).
- La acción "abrir" en `/inspect`, cargada bajo demanda (paso 8). - La acción "abrir" en `/inspect`, cargada bajo demanda, con el fichero temporal de OPFS, la cuota libre y la descarga (paso 8).

@ -27,7 +27,7 @@ Hay tres números de versión, cada uno con su significado, como en la referenci
La versión actual es `0.1.0-dev`. Será `0.1.0` cuando la fase 2 añada la apertura de cápsulas. Cubre: La versión actual es `0.1.0-dev`. Será `0.1.0` cuando la fase 2 añada la apertura de cápsulas. Cubre:
- la especificación 0.8.2, con versiones de formato 1; - la especificación 0.8.2, con versiones de formato 1;
- solo el scheme de Quicknet (`bls-unchained-g1-rfc9380`): un perfil de otro scheme se inspecciona, pero su release no se verifica (decisión 3 del plan de la fase 2); - solo el scheme de Quicknet (`bls-unchained-g1-rfc9380`): un perfil de otro scheme se inspecciona, pero su release no se verifica (decisión 3 del plan de la fase 2);
- la inspección de los pasos 1 a 8 y la apertura de los pasos 9 a 18 (`open.ts`), con el texto en claro en memoria. El descifrado en streaming a OPFS completa la fase 2, y el cifrado llega en la fase 3; - la inspección de los pasos 1 a 8 y la apertura de los pasos 9 a 18 (`open.ts`), desde un `Uint8Array` o un `Blob` y hacia memoria o hacia un stream de salida, como el de un fichero OPFS. El cifrado llega en la fase 3;
- todos los vectores y fixtures compartidos de `datekeys-go` en `9ac9cd9` (`spec-v0.8.2`); - todos los vectores y fixtures compartidos de `datekeys-go` en `9ac9cd9` (`spec-v0.8.2`);
- navegadores con Web Crypto y Node 20 o posterior. - navegadores con Web Crypto y Node 20 o posterior.
@ -50,7 +50,8 @@ Lo que hay hoy (pasos 1 a 8) no importa ninguna dependencia. Funciona en navegad
| `bls12381.ts` | Pertenencia de claves públicas BLS12-381 comprimidas (G1 y G2) al subgrupo, como `FromCompressed` de kilic | `kyber-bls12381` | | `bls12381.ts` | Pertenencia de claves públicas BLS12-381 comprimidas (G1 y G2) al subgrupo, como `FromCompressed` de kilic | `kyber-bls12381` |
| `ibe.ts` | IBE-CCA de tlock sobre G2 para Quicknet (§63 paso 11): `decryptOnG2`, con la puerta de codificación canónica de `bls12381.ts` sobre la firma y U; H2 sobre GT serializado en el orden de kilic (nunca `Fp12.toBytes` de noble), H3 y H4; `roundIdentity`; el cuerpo `U ‖ V ‖ W` de 128 bytes del stanza. Errores `IbeError` con motivo (`length`, `encoding`, `identity`, `proof`) y texto fijos, sin ningún valor del cálculo; borra sigma y los hashes derivados. Sobre `@noble/curves` 2.4.0; lleva el aviso MIT de `tlock-js`, cuya estructura sigue. Lo usa la apertura (`open.ts`) | `encrypt/ibe` de drand/kyber (`DecryptCCAonG2`), `tlock.BytesToCiphertext` y `TimeUnlock` | | `ibe.ts` | IBE-CCA de tlock sobre G2 para Quicknet (§63 paso 11): `decryptOnG2`, con la puerta de codificación canónica de `bls12381.ts` sobre la firma y U; H2 sobre GT serializado en el orden de kilic (nunca `Fp12.toBytes` de noble), H3 y H4; `roundIdentity`; el cuerpo `U ‖ V ‖ W` de 128 bytes del stanza. Errores `IbeError` con motivo (`length`, `encoding`, `identity`, `proof`) y texto fijos, sin ningún valor del cálculo; borra sigma y los hashes derivados. Sobre `@noble/curves` 2.4.0; lleva el aviso MIT de `tlock-js`, cuya estructura sigue. Lo usa la apertura (`open.ts`) | `encrypt/ibe` de drand/kyber (`DecryptCCAonG2`), `tlock.BytesToCiphertext` y `TimeUnlock` |
| `release.ts` | Verificación local del release (§17, §51, §63 paso 10), en el orden y con los textos de `provider.Verify`:<br>1. el rango de la ronda (`ERR_DATEKEY_INVALID`);<br>2. la ronda del release antes que la firma (`ERR_ROUND_MISMATCH`);<br>3. la longitud de la firma;<br>4. la clave pinneada (`ERR_UNKNOWN_PROFILE`);<br>5. la firma: codificación canónica de un punto de G1 que no sea el infinito, y firma BLS válida de la ronda sobre `@noble/curves` 2.4.0, con el DST de RFC 9380 para G1 (`ERR_RELEASE_INVALID`).<br>Nada de noble se copia a los errores. Solo verifica el scheme de Quicknet: un perfil de otro scheme falla con `ERR_UNKNOWN_PROFILE` tras las comprobaciones de ronda, donde la referencia sí lo verificaría (decisión 3 del plan de la fase 2). También define `ReleaseSource`, con su contrato de fuentes de red y de la corrección 6, y `suppliedRelease`, el release que entrega quien llama | `provider` (`Verify`, `ReleaseSource`) | | `release.ts` | Verificación local del release (§17, §51, §63 paso 10), en el orden y con los textos de `provider.Verify`:<br>1. el rango de la ronda (`ERR_DATEKEY_INVALID`);<br>2. la ronda del release antes que la firma (`ERR_ROUND_MISMATCH`);<br>3. la longitud de la firma;<br>4. la clave pinneada (`ERR_UNKNOWN_PROFILE`);<br>5. la firma: codificación canónica de un punto de G1 que no sea el infinito, y firma BLS válida de la ronda sobre `@noble/curves` 2.4.0, con el DST de RFC 9380 para G1 (`ERR_RELEASE_INVALID`).<br>Nada de noble se copia a los errores. Solo verifica el scheme de Quicknet: un perfil de otro scheme falla con `ERR_UNKNOWN_PROFILE` tras las comprobaciones de ronda, donde la referencia sí lo verificaría (decisión 3 del plan de la fase 2). También define `ReleaseSource`, con su contrato de fuentes de red y de la corrección 6, y `suppliedRelease`, el release que entrega quien llama | `provider` (`Verify`, `ReleaseSource`) |
| `open.ts` | Los pasos 9 a 18 de §63 sobre los pasos 1 a 8 de `inspectWith`, con los checks, códigos y textos de `capsule.Open`:<br>- las credenciales y el release (paso 9), que cualquier fallo de la fuente convierte en `ERR_RELEASE_UNAVAILABLE` (corrección 6);<br>- la verificación del release (10);<br>- `OUTER_TIME_AGE` (11), la estructura frente a `access_policy` (12) e `INNER_ACCESS_AGE` (13);<br>- `CONTROL_CBOR` (14), `header_binding` (15), `I_PAYLOAD` (16), `PAYLOAD_AGE` (17) y el commit (18).<br>Abre los tres ficheros `age` con el `Decrypter` de `age-encryption` y con identidades propias que aplican las reglas de `agewrap`: la de tiempo, sobre `ibe.ts`; las de acceso y payload, sobre `x25519.ts`, stanza a stanza. Los fallos de `age` que no informa una identidad son `ERR_INTEGRITY` con el motivo fijo de su fase, cabecera o STREAM, sin copiar el texto de `age-encryption`. Por ahora el texto en claro se descifra en memoria y solo se entrega tras el paso 18 | `capsule.Open`, `agewrap` (`TimeIdentity`, `AccessIdentity`, `PayloadIdentity`) | | `open.ts` | Los pasos 9 a 18 de §63 sobre los pasos 1 a 8 de `inspectWith`, con los checks, códigos y textos de `capsule.Open`:<br>- las credenciales y el release (paso 9), que cualquier fallo de la fuente convierte en `ERR_RELEASE_UNAVAILABLE` (corrección 6);<br>- la verificación del release (10);<br>- `OUTER_TIME_AGE` (11), la estructura frente a `access_policy` (12) e `INNER_ACCESS_AGE` (13);<br>- `CONTROL_CBOR` (14), `header_binding` (15), `I_PAYLOAD` (16), `PAYLOAD_AGE` (17) y el commit (18).<br>Abre los tres ficheros `age` con el `Decrypter` de `age-encryption` y con identidades propias que aplican las reglas de `agewrap`: la de tiempo, sobre `ibe.ts`; las de acceso y payload, sobre `x25519.ts`, stanza a stanza. Los fallos de `age` que no informa una identidad son `ERR_INTEGRITY` con el motivo fijo de su fase, cabecera o STREAM, sin copiar el texto de `age-encryption`.<br>La entrada puede ser un `Uint8Array` o un `Blob`, como un `File`. De un `Blob` solo se lee el prefijo de los pasos 1 a 8 (`prefix.ts`), el `capsule_digest` de la `.dkk` se calcula sobre su stream (`digest.ts`) y `PAYLOAD_AGE` se descifra en streaming.<br>El texto en claro va a memoria o a `output`, un `WritableStream`. Se escribe a medida que `age` autentica cada chunk, se cierra solo tras el paso 18 y se aborta ante cualquier fallo, en cualquier paso (§56). Un fallo del stream de salida es `ERR_INTEGRITY` con su texto, como en Go. El `WritableStream` de un fichero OPFS guarda lo escrito en un fichero de intercambio hasta el cierre: comprobado en el navegador, un fallo de STREAM deja intacto el contenido anterior | `capsule.Open`, `agewrap` (`TimeIdentity`, `AccessIdentity`, `PayloadIdentity`) |
| `digest.ts` | SHA-256 incremental de un stream, con `@noble/hashes`, para el `capsule_digest` de un `.dkc` que no está en memoria (Web Crypto solo calcula el hash de buffers enteros) | |
| `x25519.ts` | El stanza X25519 de `age`, abierto de uno en uno como `X25519Identity.Unwrap` de `age`: argumentos, share, acuerdo de claves, longitud del cuerpo y autenticación, en ese orden, con las primitivas que usa `age-encryption` (X25519 de `@noble/curves`, HKDF-SHA-256 de `@noble/hashes` y ChaCha20-Poly1305 de `@noble/ciphers`). También lee identidades `AGE-SECRET-KEY-1…` | `filippo.io/age` (`X25519Identity`), `agewrap` | | `x25519.ts` | El stanza X25519 de `age`, abierto de uno en uno como `X25519Identity.Unwrap` de `age`: argumentos, share, acuerdo de claves, longitud del cuerpo y autenticación, en ese orden, con las primitivas que usa `age-encryption` (X25519 de `@noble/curves`, HKDF-SHA-256 de `@noble/hashes` y ChaCha20-Poly1305 de `@noble/ciphers`). También lee identidades `AGE-SECRET-KEY-1…` | `filippo.io/age` (`X25519Identity`), `agewrap` |
| `bech32.ts` | Bech32 (BIP 173) tal como `internal/bech32` de `age`, que la referencia copia como `codec/bech32`; conserva su aviso MIT | `codec/bech32` | | `bech32.ts` | Bech32 (BIP 173) tal como `internal/bech32` de `age`, que la referencia copia como `codec/bech32`; conserva su aviso MIT | `codec/bech32` |
| `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` | | `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` |
@ -58,7 +59,7 @@ Lo que hay hoy (pasos 1 a 8) no importa ninguna dependencia. Funciona en navegad
| `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` | | `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` | | `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` | | `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 salvo la fase 2 (`ibe.ts`, `release.ts`, `open.ts`, `x25519.ts` y `bech32.ts`). La página importa `index.ts`, y reexportarlos metería noble en `/inspect` (de 58,7 a 84,9 KB con gzip) aunque no los use, porque noble ejecuta código al cargarse. El paso 8 cargará la apertura bajo demanda | | | `index.ts` | Reexporta todo salvo la fase 2 (`ibe.ts`, `release.ts`, `open.ts`, `x25519.ts`, `bech32.ts` y `digest.ts`). La página importa `index.ts`, y reexportarlos metería noble en `/inspect` (de 58,7 a 84,9 KB con gzip) aunque no los use, porque noble ejecuta código al cargarse. El paso 8 cargará la apertura bajo demanda | |
| `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 | | | `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. Los tests (`*.test.ts`) están junto a cada fichero.
@ -104,7 +105,7 @@ No pide ni acepta secretos. Todo el texto leído de la cápsula pasa por interpo
| `src/lib/inspector/report.ts` | `buildReport`: el modelo de la página a partir de `Inspection`, sin DOM ni reloj | | `src/lib/inspector/report.ts` | `buildReport`: el modelo de la página a partir de `Inspection`, sin DOM ni reloj |
| `src/lib/inspector/format.ts` | Nombres y glosas de pasos, códigos y políticas; texto imprimible y escapado; números y fechas en español; `cliJSON` | | `src/lib/inspector/format.ts` | Nombres y glosas de pasos, códigos y políticas; texto imprimible y escapado; números y fechas en español; `cliJSON` |
| `src/lib/inspector/diagnostic.ts` | Notación de diagnóstico CBOR (RFC 8949 §8) de `walk`, acotada a 16 384 caracteres | | `src/lib/inspector/diagnostic.ts` | Notación de diagnóstico CBOR (RFC 8949 §8) de `walk`, acotada a 16 384 caracteres |
| `src/lib/inspector/load.ts` | Lectura por prefijo: de un `.dkc` grande solo se leen 16 + PUBLIC_HEADER_LEN + SEALED_CONTROL_LEN + 2 MiB + 1 bytes, y solo 16 si los pasos 1 y 2 rechazan el prelude (otro tipo de fichero, un `.dkk`, longitudes fuera de §57), siempre con el mismo resultado que el fichero entero (lo comprueba `load.test.ts`) | | `src/lib/dkc/prefix.ts` | Lectura por prefijo, que también usa la apertura: de un `.dkc` grande solo se leen 16 + PUBLIC_HEADER_LEN + SEALED_CONTROL_LEN + 2 MiB + 1 bytes, y solo 16 si los pasos 1 y 2 rechazan el prelude (otro tipo de fichero, un `.dkk`, longitudes fuera de §57), siempre con el mismo resultado que el fichero entero (lo comprueba `prefix.test.ts`) |
| `src/lib/inspector/fixtures.ts` | Los fixtures oficiales, empaquetados desde `testdata/fixtures` | | `src/lib/inspector/fixtures.ts` | Los fixtures oficiales, empaquetados desde `testdata/fixtures` |
| `src/lib/components/` | `InspectionReport`, `StepList`, `ExtensionList`, `DataView`, `Mark` | | `src/lib/components/` | `InspectionReport`, `StepList`, `ExtensionList`, `DataView`, `Mark` |
| `src/routes/` | Layout, portada e inspector | | `src/routes/` | Layout, portada e inspector |
@ -152,7 +153,7 @@ npm run build:check # solo la comprobación del sitio ya construido
npm run verify # check, typecheck, coverage y build (con su comprobación) npm run verify # check, typecheck, coverage y build (con su comprobación)
``` ```
Umbrales de cobertura (`vitest.config.ts`): `cbor.ts`, `ibe.ts`, `release.ts`, `x25519.ts` y `bech32.ts` al 100 % en líneas, ramas, funciones y sentencias; el conjunto de `src/lib/dkc` al 95/90/95/95, y el de `src/lib/inspector` también. Umbrales de cobertura (`vitest.config.ts`): `cbor.ts`, `ibe.ts`, `release.ts`, `x25519.ts`, `bech32.ts` y `digest.ts` al 100 % en líneas, ramas, funciones y sentencias; el conjunto de `src/lib/dkc` al 95/90/95/95, y el de `src/lib/inspector` también.
`vitest.config.ts` es la configuración de los tests; `vite.config.ts`, la del sitio con el plugin de SvelteKit. Vitest prefiere la primera, así que los tests de `src/lib` corren sin SvelteKit, y `src/lib/inspector` importa la librería por rutas relativas, sin el alias `$lib`. `tsconfig.json` extiende el que genera `svelte-kit sync` (por eso `typecheck` y `check` lo ejecutan antes, y `npm install` también, con `prepare`). `vitest.config.ts` es la configuración de los tests; `vite.config.ts`, la del sitio con el plugin de SvelteKit. Vitest prefiere la primera, así que los tests de `src/lib` corren sin SvelteKit, y `src/lib/inspector` importa la librería por rutas relativas, sin el alias `$lib`. `tsconfig.json` extiende el que genera `svelte-kit sync` (por eso `typecheck` y `check` lo ejecutan antes, y `npm install` también, con `prepare`).
@ -165,7 +166,7 @@ Umbrales de cobertura (`vitest.config.ts`): `cbor.ts`, `ibe.ts`, `release.ts`, `
- `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/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/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/tlock_ibe.json`: el vector de H2 del IBE de tlock (§63 paso 11). Hasta que llegue `ibe.ts` (fase 2), el test lo recalcula con `@noble/curves` 2.4.0: los puntos son canónicos para `bls12381.ts`, el pairing serializado en el orden de kilic es el GT del vector y su H2 coincide; el orden propio de noble (`Fp12.toBytes`) da otro hash. - `testdata/vectors/tlock_ibe.json`: el vector de H2 del IBE de tlock (§63 paso 11). Hasta que llegue `ibe.ts` (fase 2), el test lo recalcula con `@noble/curves` 2.4.0: los puntos son canónicos para `bls12381.ts`, el pairing serializado en el orden de kilic es el GT del vector y su H2 coincide; el orden propio de noble (`Fp12.toBytes`) da otro hash.
- `testdata/vectors/mutations.json`: se leen los 65 casos enteros (ediciones sobre un fixture o hex congelado, release, reloj, registro, extensiones, `.dkk` e identidades). Los 65 pasan por `open` con su release, su reloj, su registro, sus extensiones, su `.dkk` y sus identidades, y dan el mismo código en el mismo paso que Go. Entre ellos están los 34 de los pasos 9 a 18, con las 10 mutaciones de la enmienda de canonicidad de puntos en los pasos 10 y 11. Ninguno de los que fallan sin red pide un release. Los 31 de los pasos 1 a 8 pasan además por `inspect`, y un test fija los dos recuentos. Las `.dkk` ofrecidas se decodifican. - `testdata/vectors/mutations.json`: se leen los 65 casos enteros (ediciones sobre un fixture o hex congelado, release, reloj, registro, extensiones, `.dkk` e identidades). Los 65 pasan por `open` con su release, su reloj, su registro, sus extensiones, su `.dkk` y sus identidades, y dan el mismo código en el mismo paso que Go. También pasan como `Blob` con un stream de salida, que termina abortado en los 65. Entre ellos están los 34 de los pasos 9 a 18, con las 10 mutaciones de la enmienda de canonicidad de puntos en los pasos 10 y 11. Ninguno de los que fallan sin red pide un release. Los 31 de los pasos 1 a 8 pasan además por `inspect`, 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. - `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.
- `src/lib/dkc/testing/ibe-vectors.json`: los valores de referencia de `ibe.ts`. Los escribe `scripts/ibe-go-vectors.go` con kyber, tlock y `age`, las librerías de la referencia Go, y `ibe.test.ts` los comprueba todos: - `src/lib/dkc/testing/ibe-vectors.json`: los valores de referencia de `ibe.ts`. Los escribe `scripts/ibe-go-vectors.go` con kyber, tlock y `age`, las librerías de la referencia Go, y `ibe.test.ts` los comprueba todos:
- el GT de e(G1, G2) y de su cuadrado, con H2 de 16 y 32 bytes; - el GT de e(G1, G2) y de su cuadrado, con H2 de 16 y 32 bytes;
@ -196,7 +197,7 @@ Guardas de `src/lib/dependencies.test.ts`, en cada `npm test`:
- `package.json` declara exactamente estas cuatro dependencias, con versión exacta; - `package.json` declara exactamente estas cuatro dependencias, con versión exacta;
- `package-lock.json` no contiene `tlock-js` ni `drand-client`, ningún noble 1.x, ni más copias 2.x de `@noble/curves` o `@noble/hashes` que la 2.4.0 de la raíz y la 2.0.1 bajo `@noble/post-quantum`; - `package-lock.json` no contiene `tlock-js` ni `drand-client`, ningún noble 1.x, ni más copias 2.x de `@noble/curves` o `@noble/hashes` que la 2.4.0 de la raíz y la 2.0.1 bajo `@noble/post-quantum`;
- ningún fichero de `src/` importa `tlock-js` ni `drand-client`; - ningún fichero de `src/` importa `tlock-js` ni `drand-client`;
- solo `ibe.ts`, `release.ts`, `x25519.ts` y los tests nombran `@noble/`, siempre con subrutas de `@noble/curves`, `@noble/hashes` y `@noble/ciphers` que resuelven a la copia 2.4.0 de la raíz; - solo `digest.ts`, `ibe.ts`, `release.ts`, `x25519.ts` y los tests nombran `@noble/`, siempre con subrutas de `@noble/curves`, `@noble/hashes` y `@noble/ciphers` que resuelven a la copia 2.4.0 de la raíz;
- cada comprobación se ejecuta también sobre entradas malas, así que una guarda que dejara de detectar algo fallaría. - cada comprobación se ejecuta también sobre entradas malas, así que una guarda que dejara de detectar algo fallaría.
`check-build.mjs` hace la misma comprobación sobre el bundle del cliente. `check-build.mjs` hace la misma comprobación sobre el bundle del cliente.

@ -161,7 +161,7 @@ La ruta `/inspect` gana una acción "abrir": con un fixture o un `.dkc` arrastra
| 2 | Dependencias y guardas | instaladas con versiones exactas; `npm audit --omit=dev` sin avisos; guardas en verde; en el lockfile, un solo noble 2.4.0 en la raíz, la copia 2.0.1 solo bajo `@noble/post-quantum` y ningún 1.x. Hecho el 28-09-2026: el README de `App` recoge las guardas, la medida del bundle y el resultado de `npm audit` | | 2 | Dependencias y guardas | instaladas con versiones exactas; `npm audit --omit=dev` sin avisos; guardas en verde; en el lockfile, un solo noble 2.4.0 en la raíz, la copia 2.0.1 solo bajo `@noble/post-quantum` y ningún 1.x. Hecho el 28-09-2026: el README de `App` recoge las guardas, la medida del bundle y el resultado de `npm audit` |
| 3 | `ibe.ts` de descifrado desde la semilla, `roundIdentity`, escritura del stanza en `age.ts`, `ibe.test.ts` sin la parte de cifrado | los cinco fixtures dan la file key correcta; U no canónico e identidad rechazados; cobertura 100 %. Hecho el 28-09-2026: vectores de `scripts/ibe-go-vectors.go` en `src/lib/dkc/testing/ibe-vectors.json`; `ibe.ts` al 100 %, fijado como umbral. `ibe.ts` también pasa el cuerpo `U ‖ V ‖ W` a bytes; los argumentos del stanza y su paso al `Stanza` de `age-encryption`, que guarda el tipo en `args[0]`, van al paso 7, con el `Recipient` que los usa | | 3 | `ibe.ts` de descifrado desde la semilla, `roundIdentity`, escritura del stanza en `age.ts`, `ibe.test.ts` sin la parte de cifrado | los cinco fixtures dan la file key correcta; U no canónico e identidad rechazados; cobertura 100 %. Hecho el 28-09-2026: vectores de `scripts/ibe-go-vectors.go` en `src/lib/dkc/testing/ibe-vectors.json`; `ibe.ts` al 100 %, fijado como umbral. `ibe.ts` también pasa el cuerpo `U ‖ V ‖ W` a bytes; los argumentos del stanza y su paso al `Stanza` de `age-encryption`, que guarda el tipo en `args[0]`, van al paso 7, con el `Recipient` que los usa |
| 4 | `release.ts` y `release.test.ts` | ronda real válida, alias rechazados. Hecho el 28-09-2026:<br>- `verifyRelease` con el orden y los textos de `provider.Verify`;<br>- `ReleaseSource`, con el contrato de la sección 5, y `suppliedRelease`;<br>- los casos de `TestVerifyRejects` de Go y los 7 del corpus de mutaciones que fallan en el paso 10;<br>- las firmas publicadas de las rondas 1000, 1001, 2000 y 1004, esta última obtenida restando p a la codificación x + p del corpus;<br>- cobertura del 100 %, fijada como umbral | | 4 | `release.ts` y `release.test.ts` | ronda real válida, alias rechazados. Hecho el 28-09-2026:<br>- `verifyRelease` con el orden y los textos de `provider.Verify`;<br>- `ReleaseSource`, con el contrato de la sección 5, y `suppliedRelease`;<br>- los casos de `TestVerifyRejects` de Go y los 7 del corpus de mutaciones que fallan en el paso 10;<br>- las firmas publicadas de las rondas 1000, 1001, 2000 y 1004, esta última obtenida restando p a la codificación x + p del corpus;<br>- cobertura del 100 %, fijada como umbral |
| 5 | `open.ts` con la `Identity` propia, pasos 9 a 18, `open.test.ts` | los cinco fixtures se abren y el plaintext coincide con el sidecar; el corpus de mutaciones existente reproduce código y paso.<br>5a hecho el 28-09-2026, con el texto en claro en memoria:<br>- los 65 casos del corpus pasan por `open` con el código y el paso de Go;<br>- los cinco fixtures se abren con cada credencial;<br>- los textos siguen a `capsule.Open` y `agewrap`.<br>El paso 13 exige probar cada identity contra cada stanza X25519, y `age-encryption` no expone su `X25519Identity`. Por eso `x25519.ts` abre los stanzas de uno en uno, con ChaCha20-Poly1305 de `@noble/ciphers` 2.4.0, dependencia aprobada el 28-09-2026 porque es la copia que ya usa `age-encryption`. `bech32.ts` lee las identidades `AGE-SECRET-KEY-1…`.<br>`index.ts` aún no reexporta la apertura, que metería noble en `/inspect`: el paso 8 la cargará bajo demanda.<br>5b, pendiente: `PAYLOAD_AGE` en streaming a OPFS | | 5 | `open.ts` con la `Identity` propia, pasos 9 a 18, `open.test.ts` | los cinco fixtures se abren y el plaintext coincide con el sidecar; el corpus de mutaciones existente reproduce código y paso.<br>5a hecho el 28-09-2026, con el texto en claro en memoria:<br>- los 65 casos del corpus pasan por `open` con el código y el paso de Go;<br>- los cinco fixtures se abren con cada credencial;<br>- los textos siguen a `capsule.Open` y `agewrap`.<br>El paso 13 exige probar cada identity contra cada stanza X25519, y `age-encryption` no expone su `X25519Identity`. Por eso `x25519.ts` abre los stanzas de uno en uno, con ChaCha20-Poly1305 de `@noble/ciphers` 2.4.0, dependencia aprobada el 28-09-2026 porque es la copia que ya usa `age-encryption`. `bech32.ts` lee las identidades `AGE-SECRET-KEY-1…`.<br>`index.ts` aún no reexporta la apertura, que metería noble en `/inspect`: el paso 8 la cargará bajo demanda.<br>5b hecho el 28-09-2026: `open` acepta un `Blob`, del que lee solo el prefijo de los pasos 1 a 8 (`prefix.ts`, movido de la página a la librería), calcula el `capsule_digest` en streaming (`digest.ts`) y descifra `PAYLOAD_AGE` en streaming. La salida puede ser un `WritableStream`, que se cierra tras el paso 18 y se aborta ante cualquier fallo. Los 65 casos del corpus pasan también así.<br>Comprobado en el navegador con un fichero OPFS (`createWritable`): `time_only` se abre con el SHA-256 del sidecar, y un fallo de STREAM deja intacto el contenido anterior del fichero.<br>La consulta de cuota (`navigator.storage.estimate()`), el fichero temporal y la descarga van con la página, en el paso 8 |
| 6 | Spec, mutaciones en Go, `testdata:sync`, reproducción en TypeScript (sección 7) | texto aprobado; vectores congelados en ambos repositorios; commits en Gitea | | 6 | Spec, mutaciones en Go, `testdata:sync`, reproducción en TypeScript (sección 7) | texto aprobado; vectores congelados en ambos repositorios; commits en Gitea |
| 7 | `encryptOnG2RFC9380`, `Recipient` propio, ida y vuelta, interoperabilidad TS → Go a nivel IBE y de fichero `age` (sección 8, puntos 4 y 5) | Go abre lo que TypeScript cifra; vectores congelados | | 7 | `encryptOnG2RFC9380`, `Recipient` propio, ida y vuelta, interoperabilidad TS → Go a nivel IBE y de fichero `age` (sección 8, puntos 4 y 5) | Go abre lo que TypeScript cifra; vectores congelados |
| 8 | Página (sección 9) | un fixture `time_and_key` se abre en el navegador sin red; `check-build` en verde; tamaño del bundle anotado en el README | | 8 | Página (sección 9) | un fixture `time_and_key` se abre en el navegador sin red; `check-build` en verde; tamaño del bundle anotado en el README |

@ -15,8 +15,8 @@
// another 2.x copy anywhere but under @noble/post-quantum, which pins // another 2.x copy anywhere but under @noble/post-quantum, which pins
// ~2.0.0 and uses its copy for ML-KEM only (plan decision 5); // ~2.0.0 and uses its copy for ML-KEM only (plan decision 5);
// - a file of src/ imports tlock-js or drand-client; // - a file of src/ imports tlock-js or drand-client;
// - a file of src/ other than ibe.ts, release.ts, x25519.ts and the tests // - a file of src/ other than digest.ts, ibe.ts, release.ts, x25519.ts and
// names @noble/, or a noble import is not a subpath of @noble/curves, // the tests names @noble/, or a noble import is not a subpath of @noble/curves,
// @noble/hashes or @noble/ciphers, the root copies that those files // @noble/hashes or @noble/ciphers, the root copies that those files
// resolve to. // resolve to.
// //
@ -46,7 +46,7 @@ const NOBLE = ['@noble/ciphers', '@noble/curves', '@noble/hashes'];
// and releases are verified locally (plan decisions 1 and 4). // and releases are verified locally (plan decisions 1 and 4).
const FORBIDDEN = ['tlock-js', 'drand-client']; const FORBIDDEN = ['tlock-js', 'drand-client'];
// The only files besides the tests that may import noble. // The only files besides the tests that may import noble.
const NOBLE_IMPORTERS = ['src/lib/dkc/ibe.ts', 'src/lib/dkc/release.ts', 'src/lib/dkc/x25519.ts']; const NOBLE_IMPORTERS = ['src/lib/dkc/digest.ts', 'src/lib/dkc/ibe.ts', 'src/lib/dkc/release.ts', 'src/lib/dkc/x25519.ts'];
type LockEntry = { version?: string; dev?: boolean; dependencies?: Record<string, string> }; type LockEntry = { version?: string; dev?: boolean; dependencies?: Record<string, string> };
type Source = { name: string; text: string }; type Source = { name: string; text: string };
@ -160,7 +160,7 @@ describe('runtime dependencies', () => {
} }
}); });
it('only ibe.ts, release.ts, x25519.ts and the tests import noble, only from @noble/curves, @noble/hashes and @noble/ciphers, and nothing imports tlock-js or drand-client', () => { it('only digest.ts, ibe.ts, release.ts, x25519.ts and the tests import noble, only from @noble/curves, @noble/hashes and @noble/ciphers, and nothing imports tlock-js or drand-client', () => {
expect(importProblems(sources())).toEqual([]); expect(importProblems(sources())).toEqual([]);
}); });

@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { sha256 } from './bytes.ts';
import { sha256Stream } from './digest.ts';
import { readBytes } from './testing/testdata.ts';
describe('sha256Stream', () => {
it('hashes a stream as Web Crypto hashes the whole buffer', async () => {
const dkc = readBytes('fixtures/time_only.dkc');
expect(await sha256Stream(new Blob([dkc as Uint8Array<ArrayBuffer>]).stream())).toEqual(await sha256(dkc));
const chunks = [dkc.subarray(0, 1), dkc.subarray(1, 70000), dkc.subarray(70000)];
const stream = new ReadableStream<Uint8Array>({
start(c) {
for (const x of chunks) c.enqueue(x);
c.close();
},
});
expect(await sha256Stream(stream)).toEqual(await sha256(dkc));
expect(await sha256Stream(new Blob([]).stream())).toEqual(await sha256(new Uint8Array(0)));
});
});

@ -0,0 +1,16 @@
// SHA-256 of a stream, for the capsule_digest of a .dkk over a .dkc that is
// not held in memory (spec §43, §63 step 9.a). Web Crypto digests only whole
// buffers; @noble/hashes 2.4.0 hashes incrementally.
import { sha256 } from '@noble/hashes/sha2.js';
/** SHA-256 of everything `stream` yields. */
export async function sha256Stream(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
const h = sha256.create();
const reader = stream.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) return h.digest();
h.update(value);
}
}

@ -15,5 +15,6 @@ export * from './extension.ts';
export * from './framing.ts'; export * from './framing.ts';
export * from './header.ts'; export * from './header.ts';
export * from './inspect.ts'; export * from './inspect.ts';
export * from './prefix.ts';
export * from './profile.ts'; export * from './profile.ts';
export * from './version.ts'; export * from './version.ts';

@ -58,6 +58,27 @@ const NAMES = ['empty_payload', 'time_and_key_portable', 'time_and_key_recipient
const options = (f: Fixture, extra: Partial<OpenOptions> = {}): OpenOptions => ({ source: suppliedRelease(f.release), now: () => f.now, ...extra }); const options = (f: Fixture, extra: Partial<OpenOptions> = {}): OpenOptions => ({ source: suppliedRelease(f.release), now: () => f.now, ...extra });
const credentials = (f: Fixture): Partial<OpenOptions> => const credentials = (f: Fixture): Partial<OpenOptions> =>
f.side.access_key_file === undefined ? {} : { accessKeyFile: readBytes(`fixtures/${f.side.access_key_file}`) }; f.side.access_key_file === undefined ? {} : { accessKeyFile: readBytes(`fixtures/${f.side.access_key_file}`) };
const later = (t: Instant, seconds: number): Instant => ({ seconds: t.seconds + seconds, nanos: t.nanos });
// An output that records what it receives and whether it was closed or
// aborted, and with what.
function memorySink(): { stream: WritableStream<Uint8Array>; chunks: Uint8Array[]; state: { closed: boolean; aborted: unknown }; bytes: () => Uint8Array } {
const chunks: Uint8Array[] = [];
const state: { closed: boolean; aborted: unknown } = { closed: false, aborted: undefined };
const stream = new WritableStream<Uint8Array>({
write(c) {
chunks.push(c.slice());
},
close() {
state.closed = true;
},
abort(reason) {
state.aborted = reason;
},
});
return { stream, chunks, state, bytes: () => new Uint8Array(chunks.flatMap((c) => [...c])) };
}
// FK_TIME of a fixture, as the Go reference unwraps it (ibe-vectors.json). // FK_TIME of a fixture, as the Go reference unwraps it (ibe-vectors.json).
const IBE = JSON.parse(readFileSync(new URL('./testing/ibe-vectors.json', import.meta.url), 'utf8')) as { const IBE = JSON.parse(readFileSync(new URL('./testing/ibe-vectors.json', import.meta.url), 'utf8')) as {
fixtures: { name: string; file_key: string }[]; fixtures: { name: string; file_key: string }[];
@ -255,6 +276,68 @@ describe('open', () => {
]); ]);
}); });
it('opens a Blob, reading only its prefix and streaming PAYLOAD_AGE, to memory or to an output closed at the end', async () => {
for (const name of NAMES) {
const f = fixture(name);
const blob = new Blob([f.dkc as Uint8Array<ArrayBuffer>]);
const r = await open(blob, options(f, credentials(f)));
expect([r.error, r.plaintext], name).toEqual([undefined, f.plaintext]);
const sink = memorySink();
const s = await open(blob, options(f, { ...credentials(f), output: sink.stream }));
expect([s.error, s.plaintext, sink.state], name).toEqual([undefined, undefined, { closed: true, aborted: undefined }]);
expect(sink.bytes(), name).toEqual(f.plaintext);
expect(s.inspection.checks.map((c) => [c.step, c.ok]), name).toEqual(r.inspection.checks.map((c) => [c.step, c.ok]));
}
// time_only has two STREAM chunks: the first reaches the output before
// the truncated second fails, and the output is aborted, never closed.
const f = fixture('time_only');
const sink = memorySink();
const r = await open(new Blob([f.dkc.subarray(0, -1) as Uint8Array<ArrayBuffer>]), options(f, { output: sink.stream }));
expect([r.error?.code, r.inspection.checks.at(-1)?.step, sink.state.closed]).toEqual(['ERR_INTEGRITY', 17, false]);
expect(sink.state.aborted).toBe(r.error);
expect(sink.chunks.length).toBeGreaterThan(0);
});
it('aborts the output after any failure, even before step 17, and never writes to it', async () => {
const f = fixture('time_only');
const sink = memorySink();
const r = await open(f.dkc, { source: suppliedRelease(f.release), now: () => later(f.now, -1), output: sink.stream });
expect([r.error?.code, sink.chunks.length, sink.state.closed]).toEqual(['ERR_RELEASE_UNAVAILABLE', 0, false]);
expect(sink.state.aborted).toBe(r.error);
const bad = fixture('time_and_key_portable');
const other = memorySink();
await expect(open(bad.dkc, options(bad, { identities: [new Uint8Array(31)], output: other.stream }))).rejects.toThrow(TypeError);
expect(other.state.aborted).toBeInstanceOf(Error);
// An output whose abort fails does not change the verdict.
const stubborn = new WritableStream<Uint8Array>({ abort: () => Promise.reject(new Error('cannot abort')) });
const s = await open(f.dkc, { source: suppliedRelease(f.release), now: () => later(f.now, -1), output: stubborn });
expect(s.error?.code).toBe('ERR_RELEASE_UNAVAILABLE');
});
it('opens with a .dkk that carries no capsule_digest, which is optional (spec §43)', async () => {
const f = fixture('time_and_key_portable');
const dkk = (await import('./accesskey.ts')).decodeAccessKey(readBytes(`fixtures/${f.side.access_key_file!}`));
expect(dkk.verification).toBeDefined();
const r = await open(new Blob([f.dkc as Uint8Array<ArrayBuffer>]), options(f, { accessKey: { ...dkk, verification: undefined } }));
expect([r.error, r.plaintext]).toEqual([undefined, f.plaintext]);
});
it('reports a failure of the output with its text, as Go keeps the error of the writer of the plaintext', async () => {
const f = fixture('time_only');
const full = new Error('disk full');
for (const [label, sink, message] of [
['write', new WritableStream<Uint8Array>({ write: () => Promise.reject(full) }), 'disk full'],
['close', new WritableStream<Uint8Array>({ close: () => Promise.reject(full) }), 'disk full'],
['write, not an Error', new WritableStream<Uint8Array>({ write: () => Promise.reject('quota') }), 'quota'],
] as const) {
const r = await open(f.dkc, options(f, { output: sink }));
expect([r.error?.message, r.inspection.checks.at(-1)?.step], label).toEqual([
`capsule: PAYLOAD_AGE: writing the plaintext: ${message}: ERR_INTEGRITY`,
17,
]);
}
});
it('rejects a caller error before anything else', async () => { it('rejects a caller error before anything else', async () => {
const f = fixture('time_and_key_portable'); const f = fixture('time_and_key_portable');
const dkk = readBytes(`fixtures/${f.side.access_key_file!}`); const dkk = readBytes(`fixtures/${f.side.access_key_file!}`);

@ -13,8 +13,12 @@
// identity reports is ERR_INTEGRITY with the fixed reason of its phase, the // identity reports is ERR_INTEGRITY with the fixed reason of its phase, the
// header or the STREAM. // header or the STREAM.
// //
// The plaintext is decrypted in memory and returned only once age has // The .dkc is a Uint8Array or a Blob, such as a File: of a Blob only the
// authenticated all of it (step 18, spec §56). // prefix that steps 1 to 8 need is read (prefix.ts), the .dkk capsule_digest
// is computed over its stream, and PAYLOAD_AGE is streamed from it. The
// plaintext goes to memory, returned only once age has authenticated all of
// it, or to an output stream that is closed only then and aborted on any
// failure, so that nothing written is ever published (step 18, spec §56).
import { Decrypter, type Identity, type Stanza as AgeStanza } from 'age-encryption'; import { Decrypter, type Identity, type Stanza as AgeStanza } from 'age-encryption';
import { type AccessKey, checkAccessKeyMaterial, decodeAccessKey, wipeAccessKey } from './accesskey.ts'; import { type AccessKey, checkAccessKeyMaterial, decodeAccessKey, wipeAccessKey } from './accesskey.ts';
@ -22,12 +26,14 @@ import { ageStanzas, checkAccessStanzas, checkPayloadStanzas, checkTimeStanzas,
import { equalBytes, sha256, toHex } from './bytes.ts'; import { equalBytes, sha256, toHex } from './bytes.ts';
import { type Control, decodeControl } from './control.ts'; import { type Control, decodeControl } from './control.ts';
import { formatRFC3339, type Instant } from './datekey.ts'; import { formatRFC3339, type Instant } from './datekey.ts';
import { sha256Stream } from './digest.ts';
import { DateKeysError } from './errors.ts'; import { DateKeysError } from './errors.ts';
import { checkCritical, checkNoncritical, type Extension, type ExtensionRegistry, type Unusable } from './extension.ts'; import { checkCritical, checkNoncritical, type Extension, type ExtensionRegistry, type Unusable } from './extension.ts';
import { DKC_PRELUDE_SIZE, headerBinding, splitCapsule } from './framing.ts'; import { DKC_PRELUDE_SIZE, headerBinding, splitCapsule } from './framing.ts';
import { type Header, TIME_AND_KEY, TIME_ONLY } from './header.ts'; import { type Header, TIME_AND_KEY, TIME_ONLY } from './header.ts';
import { ciphertextFromBody, decryptOnG2, IbeError, TLOCK_BODY_LEN } from './ibe.ts'; import { ciphertextFromBody, decryptOnG2, IbeError, TLOCK_BODY_LEN } from './ibe.ts';
import { type CheckResult, type Inspection, inspectWith } from './inspect.ts'; import { type CheckResult, type Inspection, inspectWith } from './inspect.ts';
import { readCapsule } from './prefix.ts';
import { defaultRegistry, type Profile, type ProfileRegistry } from './profile.ts'; import { defaultRegistry, type Profile, type ProfileRegistry } from './profile.ts';
import { type Release, type ReleaseSource, verifyRelease } from './release.ts'; import { type Release, type ReleaseSource, verifyRelease } from './release.ts';
import { MalformedX25519Stanza, unwrapX25519 } from './x25519.ts'; import { MalformedX25519Stanza, unwrapX25519 } from './x25519.ts';
@ -59,6 +65,15 @@ export interface OpenOptions {
readonly accessKeyFile?: Uint8Array; readonly accessKeyFile?: Uint8Array;
/** Passed to the release source. */ /** Passed to the release source. */
readonly signal?: AbortSignal; readonly signal?: AbortSignal;
/**
* Where the plaintext goes instead of Opened.plaintext. It is written as
* age authenticates each chunk of PAYLOAD_AGE, closed only after step 18,
* and aborted on any failure at any step (spec §56): a sink must not show
* what it received before it is closed. The writable stream of an OPFS file
* (FileSystemFileHandle.createWritable) keeps the writes in a swap file
* until then.
*/
readonly output?: WritableStream<Uint8Array>;
} }
/** The result of open. */ /** The result of open. */
@ -70,7 +85,7 @@ export interface Opened {
readonly inspection: Inspection; readonly inspection: Inspection;
/** The failure at any step, or undefined when the capsule opened. */ /** The failure at any step, or undefined when the capsule opened. */
readonly error?: DateKeysError; readonly error?: DateKeysError;
/** The plaintext, only when the capsule opened. */ /** The plaintext, only when the capsule opened without an output stream. */
readonly plaintext?: Uint8Array; readonly plaintext?: Uint8Array;
/** The release, once verified at step 10. */ /** The release, once verified at step 10. */
readonly release?: Release; readonly release?: Release;
@ -99,20 +114,48 @@ const unusable = (u: readonly Unusable[]): string => (u.length === 0 ? '' : `, $
const before = (a: Instant, b: Instant): boolean => a.seconds < b.seconds || (a.seconds === b.seconds && a.nanos < b.nanos); const before = (a: Instant, b: Instant): boolean => a.seconds < b.seconds || (a.seconds === b.seconds && a.nanos < b.nanos);
/** /**
* Runs the whole flow of spec §63 on a .dkc and returns the plaintext. * Runs the whole flow of spec §63 on a .dkc, in memory or a Blob, and
* Everything verifiable locally is checked before a release is requested or * delivers the plaintext. Everything verifiable locally is checked before a
* a secret is used. Never rejects for an invalid capsule: the failure is in * release is requested or a secret is used. Never rejects for an invalid
* `error`, also recorded as the last check. It rejects only for a missing * capsule: the failure is in `error`, also recorded as the last check. It
* option or when the default registry cannot be built (see inspect). * rejects only for a missing option, when the default registry cannot be
* built (see inspect) or when the Blob cannot be read.
*/ */
export async function open(dkc: Uint8Array, opts: OpenOptions): Promise<Opened> { export async function open(dkc: Uint8Array | Blob, opts: OpenOptions): Promise<Opened> {
if (opts.source === undefined) throw new TypeError('open: OpenOptions.source is required'); if (opts.source === undefined) throw new TypeError('open: OpenOptions.source is required');
if (opts.now === undefined) throw new TypeError('open: OpenOptions.now is required'); if (opts.now === undefined) throw new TypeError('open: OpenOptions.now is required');
if (opts.accessKey !== undefined && opts.accessKeyFile !== undefined) { if (opts.accessKey !== undefined && opts.accessKeyFile !== undefined) {
throw new TypeError('open: set OpenOptions.accessKey or accessKeyFile, not both'); throw new TypeError('open: set OpenOptions.accessKey or accessKeyFile, not both');
} }
const registry = opts.registry ?? (await defaultRegistry()); const registry = opts.registry ?? (await defaultRegistry());
const inspection = inspectWith(dkc, registry, opts.extensions); let writer: WritableStreamDefaultWriter<Uint8Array> | undefined;
let committed = false;
let failure: DateKeysError | undefined;
try {
const r = await openCapsule(dkc, opts, registry, (w) => (writer = w));
failure = r.error;
committed = r.error === undefined;
return r;
} finally {
// Nothing written is published unless the capsule opened: the output is
// aborted after any failure, even one before step 17.
if (opts.output !== undefined && !committed) {
const reason = failure ?? new Error('open: failed');
await (writer === undefined ? opts.output.abort(reason) : writer.abort(reason)).catch(() => undefined);
}
}
}
async function openCapsule(
dkc: Uint8Array | Blob,
opts: OpenOptions,
registry: ProfileRegistry,
onWriter: (w: WritableStreamDefaultWriter<Uint8Array>) => void,
): Promise<Opened> {
// Of a Blob, only the prefix that steps 1 to 8 need; it holds the prelude,
// PUBLIC_HEADER and SEALED_CONTROL whole.
const bytes = dkc instanceof Uint8Array ? dkc : (await readCapsule(dkc)).bytes;
const inspection = inspectWith(bytes, registry, opts.extensions);
const checks: CheckResult[] = [...inspection.checks]; const checks: CheckResult[] = [...inspection.checks];
const out: Mutable<Opened> = { const out: Mutable<Opened> = {
inspection: { ...inspection, checks }, inspection: { ...inspection, checks },
@ -136,7 +179,7 @@ export async function open(dkc: Uint8Array, opts: OpenOptions): Promise<Opened>
}; };
const h = inspection.header!; const h = inspection.header!;
const p = inspection.profile!; const p = inspection.profile!;
const sections = splitCapsule(dkc); const sections = splitCapsule(bytes);
const wipe: Uint8Array[] = []; const wipe: Uint8Array[] = [];
let decoded: AccessKey | undefined; let decoded: AccessKey | undefined;
try { try {
@ -270,7 +313,7 @@ export async function open(dkc: Uint8Array, opts: OpenOptions): Promise<Opened>
pass(14, 'control', `canonical CONTROL_CBOR${unusable(out.unusableControlExtensions)}`); pass(14, 'control', `canonical CONTROL_CBOR${unusable(out.unusableControlExtensions)}`);
// Step 15: header_binding over the exact stored bytes. // Step 15: header_binding over the exact stored bytes.
const binding = await headerBinding(dkc.subarray(0, DKC_PRELUDE_SIZE), sections.publicHeader); const binding = await headerBinding(bytes.subarray(0, DKC_PRELUDE_SIZE), sections.publicHeader);
if (!equalBytes(binding, control.headerBinding)) { if (!equalBytes(binding, control.headerBinding)) {
return fail( return fail(
15, 15,
@ -280,18 +323,29 @@ export async function open(dkc: Uint8Array, opts: OpenOptions): Promise<Opened>
} }
pass(15, 'header binding', 'matches'); pass(15, 'header binding', 'matches');
// Steps 16 and 17: I_PAYLOAD, then PAYLOAD_AGE with it. // Steps 16 and 17: I_PAYLOAD, then PAYLOAD_AGE with it, streamed from
// the Blob or from memory, to the output or to memory.
pass(16, 'payload identity', 'I_PAYLOAD recovered'); pass(16, 'payload identity', 'I_PAYLOAD recovered');
let plaintext: Uint8Array; const offset = inspection.payloadOffset!;
const [payload, payloadLength] =
dkc instanceof Uint8Array ? [streamOf(sections.payload), sections.payload.length] : [dkc.slice(offset).stream(), dkc.size - offset];
let plaintext: Uint8Array | undefined;
try { try {
plaintext = await decryptAll(sections.payload, payloadIdentity(control.payloadIdentity), 'PAYLOAD_AGE'); const plain = await decrypt(payload, payloadIdentity(control.payloadIdentity), 'PAYLOAD_AGE');
if (opts.output === undefined) {
plaintext = await readAll(plain, payloadLength, 'PAYLOAD_AGE');
} else {
const w = opts.output.getWriter();
onWriter(w);
await writeAll(plain, w, 'PAYLOAD_AGE');
}
} catch (err) { } catch (err) {
return fail(17, 'open payload', err); return fail(17, 'open payload', err);
} }
// Step 18: age completed without error. // Step 18: age completed without error.
pass(18, 'commit', 'payload authenticated completely'); pass(18, 'commit', 'payload authenticated completely');
out.plaintext = plaintext; if (plaintext !== undefined) out.plaintext = plaintext;
return out; return out;
} finally { } finally {
for (const b of wipe) b.fill(0); for (const b of wipe) b.fill(0);
@ -303,7 +357,7 @@ export async function open(dkc: Uint8Array, opts: OpenOptions): Promise<Opened>
// access_type and access_material, then its critical extensions; then its // access_type and access_material, then its critical extensions; then its
// bindings to this capsule, the capsule_id and, when present, the // bindings to this capsule, the capsule_id and, when present, the
// capsule_digest (spec §43, §69.1). // capsule_digest (spec §43, §69.1).
async function checkAccessKey(k: AccessKey, h: Header, dkc: Uint8Array, reg?: ExtensionRegistry): Promise<void> { async function checkAccessKey(k: AccessKey, h: Header, dkc: Uint8Array | Blob, reg?: ExtensionRegistry): Promise<void> {
checkAccessKeyMaterial(k); checkAccessKeyMaterial(k);
try { try {
checkCritical(k.critical, reg, '.dkk'); checkCritical(k.critical, reg, '.dkk');
@ -313,7 +367,9 @@ async function checkAccessKey(k: AccessKey, h: Header, dkc: Uint8Array, reg?: Ex
if (!equalBytes(k.capsuleId, h.capsuleId)) { if (!equalBytes(k.capsuleId, h.capsuleId)) {
throw new DateKeysError('ERR_ACCESS_INVALID', `capsule: the .dkk is for capsule ${toHex(k.capsuleId)}, this is ${toHex(h.capsuleId)}`); throw new DateKeysError('ERR_ACCESS_INVALID', `capsule: the .dkk is for capsule ${toHex(k.capsuleId)}, this is ${toHex(h.capsuleId)}`);
} }
if (k.verification !== undefined && !equalBytes(await sha256(dkc), k.verification.capsuleDigest)) { if (k.verification === undefined) return;
const digest = dkc instanceof Uint8Array ? await sha256(dkc) : await sha256Stream(dkc.stream());
if (!equalBytes(digest, k.verification.capsuleDigest)) {
throw new DateKeysError('ERR_ACCESS_INVALID', 'capsule: the .dkk capsule_digest does not match this .dkc'); throw new DateKeysError('ERR_ACCESS_INVALID', 'capsule: the .dkk capsule_digest does not match this .dkc');
} }
} }
@ -332,29 +388,40 @@ function looksLikeAge(b: Uint8Array): boolean {
return b.length >= intro.length && [...intro].every((c, i) => b[i] === c.charCodeAt(0)); return b.length >= intro.length && [...intro].every((c, i) => b[i] === c.charCodeAt(0));
} }
// Opens a bounded age file in memory with one identity. A failure that the const streamOf = (b: Uint8Array): ReadableStream<Uint8Array> =>
// identity reports keeps its normative error; any other failure of age is
// ERR_INTEGRITY with the fixed reason of its phase, never the text of
// age-encryption. The plaintext is never longer than the ciphertext.
async function decryptAll(file: Uint8Array, identity: Identity, what: string): Promise<Uint8Array> {
const d = new Decrypter();
d.addIdentity(identity);
let stream: ReadableStream<Uint8Array>;
try {
stream = await d.decrypt(
new ReadableStream<Uint8Array>({ new ReadableStream<Uint8Array>({
start(c) { start(c) {
c.enqueue(file); c.enqueue(b);
c.close(); c.close();
}, },
}), });
);
// The plaintext of an age file with one identity, as a stream, once its
// header opened. A failure that the identity reports keeps its normative
// error; any other failure of age is ERR_INTEGRITY with the fixed reason of
// its phase, never the text of age-encryption.
async function decrypt(file: ReadableStream<Uint8Array>, identity: Identity, what: string): Promise<ReadableStream<Uint8Array>> {
const d = new Decrypter();
d.addIdentity(identity);
try {
return await d.decrypt(file);
} catch (err) { } catch (err) {
throw classify(what, HEADER_FAILURE, err); throw classify(what, HEADER_FAILURE, err);
} }
const out = new Uint8Array(file.length); }
// Opens a bounded age file in memory with one identity.
async function decryptAll(file: Uint8Array, identity: Identity, what: string): Promise<Uint8Array> {
return readAll(await decrypt(streamOf(file), identity, what), file.length, what);
}
// Reads a plaintext of at most `max` bytes, the length of its ciphertext,
// into one buffer, wiping every chunk it copies; on a failure of the STREAM
// the buffer is wiped too.
async function readAll(plain: ReadableStream<Uint8Array>, max: number, what: string): Promise<Uint8Array> {
const out = new Uint8Array(max);
let n = 0; let n = 0;
const reader = stream.getReader(); const reader = plain.getReader();
try { try {
for (;;) { for (;;) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
@ -370,6 +437,36 @@ async function decryptAll(file: Uint8Array, identity: Identity, what: string): P
return out.subarray(0, n); return out.subarray(0, n);
} }
// Writes a plaintext to the output and closes it once age has authenticated
// the last chunk. A failure of the output keeps its text, as Go keeps the
// error of the writer of the plaintext.
async function writeAll(plain: ReadableStream<Uint8Array>, w: WritableStreamDefaultWriter<Uint8Array>, what: string): Promise<void> {
const reader = plain.getReader();
for (;;) {
let r: ReadableStreamReadResult<Uint8Array>;
try {
r = await reader.read();
} catch (err) {
throw classify(what, STREAM_FAILURE, err);
}
if (r.done) break;
try {
await w.write(r.value);
} catch (err) {
await reader.cancel(err);
throw writing(what, err);
}
}
try {
await w.close();
} catch (err) {
throw writing(what, err);
}
}
const writing = (what: string, err: unknown): DateKeysError =>
new DateKeysError('ERR_INTEGRITY', `capsule: ${what}: writing the plaintext: ${err instanceof Error ? err.message : String(err)}`, err);
function classify(what: string, reason: string, err: unknown): DateKeysError { function classify(what: string, reason: string, err: unknown): DateKeysError {
if (err instanceof DateKeysError) return err.wrap(`capsule: ${what}`); if (err instanceof DateKeysError) return err.wrap(`capsule: ${what}`);
return new DateKeysError('ERR_INTEGRITY', `capsule: ${what}: ${reason}`, err); return new DateKeysError('ERR_INTEGRITY', `capsule: ${what}: ${reason}`, err);

@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { defaultRegistry, equalBytes, inspectView, inspectWith, MAX_AGE_HEADER_LEN, type Inspection } from '../dkc/index.ts'; import { defaultRegistry, equalBytes, inspectView, inspectWith, MAX_AGE_HEADER_LEN, type Inspection } from './index.ts';
import { frame, split } from '../dkc/testing/capsule.ts'; import { inspectedLength, readCapsule } from './prefix.ts';
import { listTestdata, readBytes } from '../dkc/testing/testdata.ts'; import { frame, split } from './testing/capsule.ts';
import { inspectedLength, readCapsule } from './load.ts'; import { listTestdata, readBytes } from './testing/testdata.ts';
const registry = await defaultRegistry(); const registry = await defaultRegistry();
const timeOnly = readBytes('fixtures/time_only.dkc'); const timeOnly = readBytes('fixtures/time_only.dkc');

@ -1,12 +1,9 @@
// Reading a .dkc for the inspection without holding a large payload in memory. // Reading a .dkc for the inspection without holding a large payload in
// memory: the page reads that prefix for steps 1 to 8, and open reads it too
// and streams PAYLOAD_AGE from the rest of the file.
import { import { MAX_AGE_HEADER_LEN } from './age.ts';
DKC_PRELUDE_SIZE, import { DKC_PRELUDE_SIZE, type Prelude, parsePrelude, payloadOffset } from './framing.ts';
MAX_AGE_HEADER_LEN,
type Prelude,
parsePrelude,
payloadOffset,
} from '../dkc/index.ts';
/** /**
* How many leading bytes of a .dkc of `size` bytes steps 1 to 8 need, given * How many leading bytes of a .dkc of `size` bytes steps 1 to 8 need, given

@ -583,6 +583,36 @@ describe('vectors/mutations.json', () => {
if (!c.network) expect(calls, 'release requests').toBe(0); if (!c.network) expect(calls, 'release requests').toBe(0);
if (accessKey !== undefined) wipeAccessKey(accessKey); if (accessKey !== undefined) wipeAccessKey(accessKey);
}); });
// The same through a Blob, of which open reads only the prefix of steps 1
// to 8 and streams PAYLOAD_AGE, into an output that must end aborted.
it.each(f.map((c) => [c.index, c.name, c.step, c] as const))('open a Blob into an output: #%i %s (step %i)', async (_i, _n, _s, c) => {
const registry: ProfileRegistry = c.registry === 'empty' ? await newRegistry() : await defaultRegistry();
const extensions = caseExtensions(c.extensions);
const source: ReleaseSource = {
fetch: () => (c.release === undefined ? Promise.reject(new DateKeysError('ERR_RELEASE_UNAVAILABLE', 'testkit: no release')) : Promise.resolve(c.release)),
};
let closed = false;
let aborted = false;
const output = new WritableStream<Uint8Array>({
close: () => void (closed = true),
abort: () => void (aborted = true),
});
const accessKey = c.dkk === undefined ? undefined : decodeAccessKey(c.dkk);
const r = await open(new Blob([capsuleOf(c.dkc) as Uint8Array<ArrayBuffer>]), {
registry,
source,
now: () => c.now,
output,
...(extensions === undefined ? {} : { extensions }),
...(accessKey === undefined ? {} : { accessKey }),
...(c.identities === undefined ? {} : { identities: c.identities.map(parseX25519Identity) }),
});
const last = r.inspection.checks.at(-1)!;
expect({ step: last.step, ok: last.ok, error: last.error }).toEqual({ step: c.step, ok: false, error: c.error });
expect([closed, aborted]).toEqual([false, true]);
if (accessKey !== undefined) wipeAccessKey(accessKey);
});
it('opens every case of steps 9 to 18 (the phase 2 cases)', () => { it('opens every case of steps 9 to 18 (the phase 2 cases)', () => {
expect(opened.every((c) => c.step >= 9 && c.step <= 18)).toBe(true); expect(opened.every((c) => c.step >= 9 && c.step <= 18)).toBe(true);
}); });

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { tick } from 'svelte'; import { tick } from 'svelte';
import { inspect } from '$lib/dkc/index.ts'; import { type CapsuleBytes, inspect, readCapsule } from '$lib/dkc/index.ts';
import { type CapsuleBytes, readCapsule } from '$lib/inspector/load.ts';
import { FIXTURES, type Fixture, fetchFixture } from '$lib/inspector/fixtures.ts'; import { FIXTURES, type Fixture, fetchFixture } from '$lib/inspector/fixtures.ts';
import { buildReport, type Report } from '$lib/inspector/report.ts'; import { buildReport, type Report } from '$lib/inspector/report.ts';
import { displayText, escapeInvisible, viewerTimeZone } from '$lib/inspector/format.ts'; import { displayText, escapeInvisible, viewerTimeZone } from '$lib/inspector/format.ts';

@ -24,6 +24,7 @@ export default defineConfig({
// The stanza X25519 and its Bech32 identities (step 5). // The stanza X25519 and its Bech32 identities (step 5).
'src/lib/dkc/x25519.ts': { 100: true }, 'src/lib/dkc/x25519.ts': { 100: true },
'src/lib/dkc/bech32.ts': { 100: true }, 'src/lib/dkc/bech32.ts': { 100: true },
'src/lib/dkc/digest.ts': { 100: true },
'src/lib/dkc/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 }, 'src/lib/dkc/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 },
// The page model and helpers of the inspector (plan §8, phase 1). // The page model and helpers of the inspector (plan §8, phase 1).
'src/lib/inspector/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 }, 'src/lib/inspector/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 },

Loading…
Cancel
Save

Powered by TurnKey Linux.