diff --git a/CHANGELOG.md b/CHANGELOG.md
index 38141e9..4ae63f5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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;
- 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;
- - `@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.
### 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).
-- 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).
diff --git a/README.md b/README.md
index dd3c973..90bad40 100644
--- a/README.md
+++ b/README.md
@@ -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 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);
-- 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`);
- 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` |
| `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`:
1. el rango de la ronda (`ERR_DATEKEY_INVALID`);
2. la ronda del release antes que la firma (`ERR_ROUND_MISMATCH`);
3. la longitud de la firma;
4. la clave pinneada (`ERR_UNKNOWN_PROFILE`);
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`).
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`:
- las credenciales y el release (paso 9), que cualquier fallo de la fuente convierte en `ERR_RELEASE_UNAVAILABLE` (corrección 6);
- la verificación del release (10);
- `OUTER_TIME_AGE` (11), la estructura frente a `access_policy` (12) e `INNER_ACCESS_AGE` (13);
- `CONTROL_CBOR` (14), `header_binding` (15), `I_PAYLOAD` (16), `PAYLOAD_AGE` (17) y el commit (18).
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`:
- las credenciales y el release (paso 9), que cualquier fallo de la fuente convierte en `ERR_RELEASE_UNAVAILABLE` (corrección 6);
- la verificación del release (10);
- `OUTER_TIME_AGE` (11), la estructura frente a `access_policy` (12) e `INNER_ACCESS_AGE` (13);
- `CONTROL_CBOR` (14), `header_binding` (15), `I_PAYLOAD` (16), `PAYLOAD_AGE` (17) y el commit (18).
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`.
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.
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` |
| `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` |
@@ -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` |
| `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 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 | |
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/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/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/components/` | `InspectionReport`, `StepList`, `ExtensionList`, `DataView`, `Mark` |
| `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)
```
-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`).
@@ -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/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/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.
- `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;
@@ -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-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`;
-- 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.
`check-build.mjs` hace la misma comprobación sobre el bundle del cliente.
diff --git a/docs/PLAN_fase2_ibe_noble2.md b/docs/PLAN_fase2_ibe_noble2.md
index 9f4b433..546ac83 100644
--- a/docs/PLAN_fase2_ibe_noble2.md
+++ b/docs/PLAN_fase2_ibe_noble2.md
@@ -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` |
| 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:
- `verifyRelease` con el orden y los textos de `provider.Verify`;
- `ReleaseSource`, con el contrato de la sección 5, y `suppliedRelease`;
- los casos de `TestVerifyRejects` de Go y los 7 del corpus de mutaciones que fallan en el paso 10;
- las firmas publicadas de las rondas 1000, 1001, 2000 y 1004, esta última obtenida restando p a la codificación x + p del corpus;
- 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.
5a hecho el 28-09-2026, con el texto en claro en memoria:
- los 65 casos del corpus pasan por `open` con el código y el paso de Go;
- los cinco fixtures se abren con cada credencial;
- los textos siguen a `capsule.Open` y `agewrap`.
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…`.
`index.ts` aún no reexporta la apertura, que metería noble en `/inspect`: el paso 8 la cargará bajo demanda.
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.
5a hecho el 28-09-2026, con el texto en claro en memoria:
- los 65 casos del corpus pasan por `open` con el código y el paso de Go;
- los cinco fixtures se abren con cada credencial;
- los textos siguen a `capsule.Open` y `agewrap`.
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…`.
`index.ts` aún no reexporta la apertura, que metería noble en `/inspect`: el paso 8 la cargará bajo demanda.
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í.
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.
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 |
| 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 |
diff --git a/src/lib/dependencies.test.ts b/src/lib/dependencies.test.ts
index a431c64..15477b1 100644
--- a/src/lib/dependencies.test.ts
+++ b/src/lib/dependencies.test.ts
@@ -15,8 +15,8 @@
// 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);
// - 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
-// names @noble/, or a noble import is not a subpath of @noble/curves,
+// - a file of src/ other than digest.ts, ibe.ts, release.ts, x25519.ts and
+// 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
// resolve to.
//
@@ -46,7 +46,7 @@ const NOBLE = ['@noble/ciphers', '@noble/curves', '@noble/hashes'];
// and releases are verified locally (plan decisions 1 and 4).
const FORBIDDEN = ['tlock-js', 'drand-client'];
// 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 };
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([]);
});
diff --git a/src/lib/dkc/digest.test.ts b/src/lib/dkc/digest.test.ts
new file mode 100644
index 0000000..ffb198c
--- /dev/null
+++ b/src/lib/dkc/digest.test.ts
@@ -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]).stream())).toEqual(await sha256(dkc));
+ const chunks = [dkc.subarray(0, 1), dkc.subarray(1, 70000), dkc.subarray(70000)];
+ const stream = new ReadableStream({
+ 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)));
+ });
+});
diff --git a/src/lib/dkc/digest.ts b/src/lib/dkc/digest.ts
new file mode 100644
index 0000000..63e879d
--- /dev/null
+++ b/src/lib/dkc/digest.ts
@@ -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): Promise {
+ const h = sha256.create();
+ const reader = stream.getReader();
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) return h.digest();
+ h.update(value);
+ }
+}
diff --git a/src/lib/dkc/index.ts b/src/lib/dkc/index.ts
index 6414dc3..b02cc2c 100644
--- a/src/lib/dkc/index.ts
+++ b/src/lib/dkc/index.ts
@@ -15,5 +15,6 @@ export * from './extension.ts';
export * from './framing.ts';
export * from './header.ts';
export * from './inspect.ts';
+export * from './prefix.ts';
export * from './profile.ts';
export * from './version.ts';
diff --git a/src/lib/dkc/open.test.ts b/src/lib/dkc/open.test.ts
index dbe3336..f4b5009 100644
--- a/src/lib/dkc/open.test.ts
+++ b/src/lib/dkc/open.test.ts
@@ -58,6 +58,27 @@ const NAMES = ['empty_payload', 'time_and_key_portable', 'time_and_key_recipient
const options = (f: Fixture, extra: Partial = {}): OpenOptions => ({ source: suppliedRelease(f.release), now: () => f.now, ...extra });
const credentials = (f: Fixture): Partial =>
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; 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({
+ 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).
const IBE = JSON.parse(readFileSync(new URL('./testing/ibe-vectors.json', import.meta.url), 'utf8')) as {
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]);
+ 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]), 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({ 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]), 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({ write: () => Promise.reject(full) }), 'disk full'],
+ ['close', new WritableStream({ close: () => Promise.reject(full) }), 'disk full'],
+ ['write, not an Error', new WritableStream({ 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 () => {
const f = fixture('time_and_key_portable');
const dkk = readBytes(`fixtures/${f.side.access_key_file!}`);
diff --git a/src/lib/dkc/open.ts b/src/lib/dkc/open.ts
index e30125d..2ad10a7 100644
--- a/src/lib/dkc/open.ts
+++ b/src/lib/dkc/open.ts
@@ -13,8 +13,12 @@
// identity reports is ERR_INTEGRITY with the fixed reason of its phase, the
// header or the STREAM.
//
-// The plaintext is decrypted in memory and returned only once age has
-// authenticated all of it (step 18, spec §56).
+// The .dkc is a Uint8Array or a Blob, such as a File: of a Blob only the
+// 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 { 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 { type Control, decodeControl } from './control.ts';
import { formatRFC3339, type Instant } from './datekey.ts';
+import { sha256Stream } from './digest.ts';
import { DateKeysError } from './errors.ts';
import { checkCritical, checkNoncritical, type Extension, type ExtensionRegistry, type Unusable } from './extension.ts';
import { DKC_PRELUDE_SIZE, headerBinding, splitCapsule } from './framing.ts';
import { type Header, TIME_AND_KEY, TIME_ONLY } from './header.ts';
import { ciphertextFromBody, decryptOnG2, IbeError, TLOCK_BODY_LEN } from './ibe.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 { type Release, type ReleaseSource, verifyRelease } from './release.ts';
import { MalformedX25519Stanza, unwrapX25519 } from './x25519.ts';
@@ -59,6 +65,15 @@ export interface OpenOptions {
readonly accessKeyFile?: Uint8Array;
/** Passed to the release source. */
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;
}
/** The result of open. */
@@ -70,7 +85,7 @@ export interface Opened {
readonly inspection: Inspection;
/** The failure at any step, or undefined when the capsule opened. */
readonly error?: DateKeysError;
- /** The plaintext, only when the capsule opened. */
+ /** The plaintext, only when the capsule opened without an output stream. */
readonly plaintext?: Uint8Array;
/** The release, once verified at step 10. */
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);
/**
- * Runs the whole flow of spec §63 on a .dkc and returns the plaintext.
- * Everything verifiable locally is checked before a release is requested or
- * a secret is used. Never rejects for an invalid capsule: the failure is in
- * `error`, also recorded as the last check. It rejects only for a missing
- * option or when the default registry cannot be built (see inspect).
+ * Runs the whole flow of spec §63 on a .dkc, in memory or a Blob, and
+ * delivers the plaintext. Everything verifiable locally is checked before a
+ * release is requested or a secret is used. Never rejects for an invalid
+ * capsule: the failure is in `error`, also recorded as the last check. It
+ * 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 {
+export async function open(dkc: Uint8Array | Blob, opts: OpenOptions): Promise {
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.accessKey !== undefined && opts.accessKeyFile !== undefined) {
throw new TypeError('open: set OpenOptions.accessKey or accessKeyFile, not both');
}
const registry = opts.registry ?? (await defaultRegistry());
- const inspection = inspectWith(dkc, registry, opts.extensions);
+ let writer: WritableStreamDefaultWriter | 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) => void,
+): Promise {
+ // 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 out: Mutable = {
inspection: { ...inspection, checks },
@@ -136,7 +179,7 @@ export async function open(dkc: Uint8Array, opts: OpenOptions): Promise
};
const h = inspection.header!;
const p = inspection.profile!;
- const sections = splitCapsule(dkc);
+ const sections = splitCapsule(bytes);
const wipe: Uint8Array[] = [];
let decoded: AccessKey | undefined;
try {
@@ -270,7 +313,7 @@ export async function open(dkc: Uint8Array, opts: OpenOptions): Promise
pass(14, 'control', `canonical CONTROL_CBOR${unusable(out.unusableControlExtensions)}`);
// 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)) {
return fail(
15,
@@ -280,18 +323,29 @@ export async function open(dkc: Uint8Array, opts: OpenOptions): Promise
}
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');
- 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 {
- 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) {
return fail(17, 'open payload', err);
}
// Step 18: age completed without error.
pass(18, 'commit', 'payload authenticated completely');
- out.plaintext = plaintext;
+ if (plaintext !== undefined) out.plaintext = plaintext;
return out;
} finally {
for (const b of wipe) b.fill(0);
@@ -303,7 +357,7 @@ export async function open(dkc: Uint8Array, opts: OpenOptions): Promise
// access_type and access_material, then its critical extensions; then its
// bindings to this capsule, the capsule_id and, when present, the
// capsule_digest (spec §43, §69.1).
-async function checkAccessKey(k: AccessKey, h: Header, dkc: Uint8Array, reg?: ExtensionRegistry): Promise {
+async function checkAccessKey(k: AccessKey, h: Header, dkc: Uint8Array | Blob, reg?: ExtensionRegistry): Promise {
checkAccessKeyMaterial(k);
try {
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)) {
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');
}
}
@@ -332,29 +388,40 @@ function looksLikeAge(b: Uint8Array): boolean {
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
-// 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 {
+const streamOf = (b: Uint8Array): ReadableStream =>
+ new ReadableStream({
+ start(c) {
+ c.enqueue(b);
+ 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, identity: Identity, what: string): Promise> {
const d = new Decrypter();
d.addIdentity(identity);
- let stream: ReadableStream;
try {
- stream = await d.decrypt(
- new ReadableStream({
- start(c) {
- c.enqueue(file);
- c.close();
- },
- }),
- );
+ return await d.decrypt(file);
} catch (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 {
+ 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, max: number, what: string): Promise {
+ const out = new Uint8Array(max);
let n = 0;
- const reader = stream.getReader();
+ const reader = plain.getReader();
try {
for (;;) {
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);
}
+// 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, w: WritableStreamDefaultWriter, what: string): Promise {
+ const reader = plain.getReader();
+ for (;;) {
+ let r: ReadableStreamReadResult;
+ 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 {
if (err instanceof DateKeysError) return err.wrap(`capsule: ${what}`);
return new DateKeysError('ERR_INTEGRITY', `capsule: ${what}: ${reason}`, err);
diff --git a/src/lib/inspector/load.test.ts b/src/lib/dkc/prefix.test.ts
similarity index 96%
rename from src/lib/inspector/load.test.ts
rename to src/lib/dkc/prefix.test.ts
index 749b202..e8289ec 100644
--- a/src/lib/inspector/load.test.ts
+++ b/src/lib/dkc/prefix.test.ts
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest';
-import { defaultRegistry, equalBytes, inspectView, inspectWith, MAX_AGE_HEADER_LEN, type Inspection } from '../dkc/index.ts';
-import { frame, split } from '../dkc/testing/capsule.ts';
-import { listTestdata, readBytes } from '../dkc/testing/testdata.ts';
-import { inspectedLength, readCapsule } from './load.ts';
+import { defaultRegistry, equalBytes, inspectView, inspectWith, MAX_AGE_HEADER_LEN, type Inspection } from './index.ts';
+import { inspectedLength, readCapsule } from './prefix.ts';
+import { frame, split } from './testing/capsule.ts';
+import { listTestdata, readBytes } from './testing/testdata.ts';
const registry = await defaultRegistry();
const timeOnly = readBytes('fixtures/time_only.dkc');
diff --git a/src/lib/inspector/load.ts b/src/lib/dkc/prefix.ts
similarity index 88%
rename from src/lib/inspector/load.ts
rename to src/lib/dkc/prefix.ts
index c62c2f1..03827cc 100644
--- a/src/lib/inspector/load.ts
+++ b/src/lib/dkc/prefix.ts
@@ -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 {
- DKC_PRELUDE_SIZE,
- MAX_AGE_HEADER_LEN,
- type Prelude,
- parsePrelude,
- payloadOffset,
-} from '../dkc/index.ts';
+import { MAX_AGE_HEADER_LEN } from './age.ts';
+import { DKC_PRELUDE_SIZE, type Prelude, parsePrelude, payloadOffset } from './framing.ts';
/**
* How many leading bytes of a .dkc of `size` bytes steps 1 to 8 need, given
diff --git a/src/lib/dkc/vectors.test.ts b/src/lib/dkc/vectors.test.ts
index e4f71e2..bfe59b1 100644
--- a/src/lib/dkc/vectors.test.ts
+++ b/src/lib/dkc/vectors.test.ts
@@ -583,6 +583,36 @@ describe('vectors/mutations.json', () => {
if (!c.network) expect(calls, 'release requests').toBe(0);
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({
+ 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]), {
+ 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)', () => {
expect(opened.every((c) => c.step >= 9 && c.step <= 18)).toBe(true);
});
diff --git a/src/routes/inspect/+page.svelte b/src/routes/inspect/+page.svelte
index 032c072..f5fab18 100644
--- a/src/routes/inspect/+page.svelte
+++ b/src/routes/inspect/+page.svelte
@@ -1,7 +1,6 @@