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