Phase 2, step 2: runtime dependencies and their guards

- age-encryption 0.3.1, @noble/curves 2.4.0 (moved from dev) and
  @noble/hashes 2.4.0 become exact runtime dependencies (plan section 3,
  decision 5). The lockfile gains six packages: age-encryption,
  @noble/ciphers 2.4.0, @scure/base 2.4.0, @noble/post-quantum 0.5.4 and
  its own @noble/curves and @noble/hashes 2.0.1. No file of src/ imports
  them yet, so the site does not change.
- src/lib/dependencies.test.ts guards them. package.json declares exactly
  these three, pinned. The lockfile has no tlock-js, drand-client or noble
  1.x, and no noble 2.x copy other than 2.4.0 at the root and 2.0.1 under
  @noble/post-quantum. No file of src/ imports tlock-js or drand-client.
  Only ibe.ts, release.ts and the tests name @noble/, always subpaths of
  @noble/curves or @noble/hashes that resolve to the root 2.4.0 copy.
  Every check also runs on bad inputs. It replaces the "only tests import
  @noble/curves" test of bls12381.contrast.test.ts.
- vite.config.ts records the modules of each client chunk in
  .svelte-kit/output/client-modules.json. check-build.mjs fails if the
  bundle holds tlock-js, drand-client or @babel/*, or a nested copy
  other than noble under @noble/post-quantum. It also reports the
  JavaScript each page loads: /inspect today loads 157 KB, 58.7 KB gzip.
- Measured with a probe build (Vite 8, minified, gzip 9): the Decrypter
  is 48 KB gzip, with the Encrypter 56 KB, noble BLS12-381 plus SHA-256
  28 KB, and all of them 73 KB. age-encryption imports its hybrid ML-KEM
  recipients statically, so post-quantum and its nested noble copy are
  about 99 KB of the Decrypter's 212 KB of rendered code.
- npm audit --omit=dev: no vulnerabilities. The full audit finds two low
  ones in the tooling: cookie < 0.7.0 through @sveltejs/kit 2.70.3,
  which is the latest version and affects only SvelteKit's server.

npm run verify is green: 2,384 tests.

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

@ -10,11 +10,11 @@ La implementación de referencia es la librería Go `g.activething.com/go/DateKe
|---|---|---|---|
| Codec CBOR del subconjunto, parsers de schema, DateKey, `inspect` | `src/lib/dkc/` | 4 | hecho |
| Página inspector, sin red | SvelteKit estático: `src/routes/`, `src/lib/inspector/`, `src/lib/components/` | 5 | hecho |
| Cifrado y descifrado en el navegador | fase 2 | 6 | pendiente |
| Cifrado y descifrado en el navegador | fase 2: [docs/PLAN_fase2_ibe_noble2.md](docs/PLAN_fase2_ibe_noble2.md) | 6 | en curso: dependencias y guardas hechas (paso 2 de la fase) |
## `src/lib/dkc`
Sin dependencias de ejecución. Funciona en navegadores y en Node 20+: solo usa `Uint8Array`, `DataView`, `TextEncoder`/`TextDecoder`, `BigInt` y `crypto.subtle` (SHA-256).
Lo que hay hoy (pasos 1 a 8) no importa ninguna dependencia. Funciona en navegadores y en Node 20+: solo usa `Uint8Array`, `DataView`, `TextEncoder`/`TextDecoder`, `BigInt` y `crypto.subtle` (SHA-256). La fase 2 añade las dependencias de ejecución de su sección, y noble solo lo importarán `ibe.ts` y `release.ts`.
`crypto.subtle` solo existe en contextos seguros: `https`, o `http` en `localhost`. La página del paso 5 servida por `http` desde una IP de la red local (por ejemplo `vite --host` para probar en un móvil) no lo tiene, y `inspect` rechaza entonces con un `Error` que lo dice (`SHA-256 needs Web Crypto (crypto.subtle), …`) en vez de dar un veredicto. El registro por defecto no memoriza ese fallo: la siguiente llamada lo vuelve a intentar.
@ -102,14 +102,14 @@ style-src 'self'; style-src-attr 'unsafe-hashes' 'sha256-…'; base-uri 'none';
- `style-src 'self'`: solo hojas de estilo del sitio; sin fuentes web ni CDN, con las fuentes del sistema.
- `style-src-attr`: solo el atributo `style` del anunciador de rutas de SvelteKit, por su hash (`ANNOUNCER_STYLE_HASH`, válido para `@sveltejs/kit` 2.70.3; `app.css` lo oculta también si el navegador bloquea el atributo).
`npm run build` ejecuta después `scripts/check-build.mjs` (`postbuild`; también `npm run build:check`), que falla si una ruta no tiene su HTML prerenderizado; si una página no tiene exactamente esa política, con la etiqueta antes de cualquier elemento que cargue recursos; si un script en línea no está en `script-src` o sobra un hash; si `style-src-attr` no coincide con los atributos `style` del bundle; si hay estilos en línea, manejadores de eventos en atributos, `@import` o URL a otro origen; si algún `.dkc` oficial no está byte a byte; o si aparece en el sitio algún secreto de los fixtures (`.dkk`, textos en claro, identidades, `payload_identity`, `access_material`, `control_cbor`).
`npm run build` ejecuta después `scripts/check-build.mjs` (`postbuild`; también `npm run build:check`), que falla si una ruta no tiene su HTML prerenderizado; si una página no tiene exactamente esa política, con la etiqueta antes de cualquier elemento que cargue recursos; si un script en línea no está en `script-src` o sobra un hash; si `style-src-attr` no coincide con los atributos `style` del bundle; si hay estilos en línea, manejadores de eventos en atributos, `@import` o URL a otro origen; si algún `.dkc` oficial no está byte a byte; si aparece en el sitio algún secreto de los fixtures (`.dkk`, textos en claro, identidades, `payload_identity`, `access_material`, `control_cbor`); o si el bundle del cliente contiene `tlock-js`, `drand-client` o helpers de Babel, o una copia anidada de un paquete que no sea la de noble bajo `@noble/post-quantum`. `vite.config.ts` registra los módulos de cada chunk en `.svelte-kit/output/client-modules.json`, fuera del sitio. Al terminar informa del JavaScript que carga cada página, en bytes y con gzip, y de los paquetes npm que lleva el bundle.
En un hosting estático basta con servir `build/`. Las directivas que solo funcionan como cabecera HTTP (`frame-ancestors`, `sandbox`, `report-to`) quedan para el servidor que la aloje. `crypto.subtle` exige contexto seguro: `https`, o `http` en `localhost`.
## Reglas
- Los fixtures y vectores del Go son la verdad. Este proyecto nunca genera fixtures propios: `testdata/` es una copia exacta de un commit de la librería Go.
- Dependencias de ejecución: solo `age`, `drand`, `tlock` y lo que ellas arrastran. Ahora mismo no hay ninguna: `package.json` solo tiene `devDependencies`. El sitio lleva compilado el runtime de cliente de Svelte y SvelteKit, el tooling que el plan elige para la página (sección 13).
- Dependencias de ejecución: solo `age`, `drand`, `tlock` y lo que ellas arrastran; hoy, las de la sección «Dependencias de ejecución». El sitio lleva compilado además el runtime de cliente de Svelte y SvelteKit, el tooling que el plan elige para la página (sección 13).
- Tooling de desarrollo: solo el de la lista siguiente. Cualquier otra dependencia se propone por escrito y no se instala sin aprobación.
## Comandos
@ -138,12 +138,46 @@ Umbrales de cobertura (`vitest.config.ts`): `cbor.ts` al 100 % en líneas, ramas
- `testdata/fixtures/*.inspect.json`: la vista de `inspect` de cada `.dkc`, escrita con `inspectJSON` como la imprime la CLI (`file` incluido), es idéntica byte a byte al fichero, también el texto de cada paso.
- `testdata/vectors/dk1.json` (con los tres vectores de los refinamientos de §19: LF dentro del Base64, CR y LF después, y la versión `1.0000000000000001`), `quicknet_rounds.json` y `profile_quicknet.json`: se ejecutan todos.
- `testdata/vectors/cbor.json`: cada vector genérico (`accept` y `reject`) pasa por `walk` con los `max_depth` y `max_len` del fichero; los enteros aceptados comparan su `value` (número o, por encima de 2⁵³ − 1, `bigint`), y los rechazados «above max_len» o «above max_depth» se aceptan sin ese límite. Cada vector de `schemas` pasa por el decodificador de su esquema (`decodeProfile`, `decodeHeader`, `decodeControl`, `decodeAccessKeyBody`) con el código exacto, y un objeto aceptado se reescribe a los mismos bytes.
- `testdata/vectors/tlock_ibe.json`: el vector de H2 del IBE de tlock (§63 paso 11). Hasta la fase 2 se recalcula con `@noble/curves` 2.4.0, de desarrollo: los puntos son canónicos para `bls12381.ts`, el pairing serializado en el orden de kilic es el GT del vector y su H2 coincide; el orden propio de noble (`Fp12.toBytes`) da otro hash.
- `testdata/vectors/tlock_ibe.json`: el vector de H2 del IBE de tlock (§63 paso 11). Hasta que llegue `ibe.ts` (fase 2), el test lo recalcula con `@noble/curves` 2.4.0: los puntos son canónicos para `bls12381.ts`, el pairing serializado en el orden de kilic es el GT del vector y su H2 coincide; el orden propio de noble (`Fp12.toBytes`) da otro hash.
- `testdata/vectors/mutations.json`: se leen los 65 casos enteros (ediciones sobre un fixture o hex congelado, release, reloj, registro, extensiones, `.dkk` e identidades). Los 31 de los pasos 1 a 8 pasan por `inspect` con su registro y sus extensiones, y dan el mismo código y el mismo paso; los 34 de los pasos 9 a 18 (entre ellos las 10 mutaciones de la enmienda de canonicidad de puntos, en los pasos 10 y 11) necesitan `open` (fase 2) y se saltan uno a uno con ese motivo, y un test fija los dos recuentos. Las `.dkk` ofrecidas se decodifican.
- `testdata/vectors/inspect_differential.json`: las 1 825 mutaciones dan el mismo veredicto, código y paso que Go; los `bases` se comprueban por su SHA-256.
- Todo se lee con los formatos de `testdata/README.md` (`testing/vectors.ts`): una clave desconocida o que falta, un valor de otro tipo, un código que no es de §69 o una edición fuera de su base hacen fallar el fichero con su motivo; nada se salta en silencio.
- Todo fichero de `testdata/` tiene que ejecutarlo algún test: un nombre nuevo exportado por Go (otro `vectors/*.json`, un fichero de fixture que ningún JSON nombra) hace fallar `testdata/ holds no file that no test runs` hasta que se le añade su bloque.
## Dependencias de ejecución
Aprobadas en el plan de la fase 2 (sección 3 y decisión 5) e instaladas con su versión exacta. Todavía ningún fichero de `src/` las importa, así que el sitio no cambia hasta que llegue el código que las usa.
| Paquete | Versión | Licencia | Uso |
|---|---|---|---|
| `age-encryption` | 0.3.1 | BSD-3-Clause | las tres envolturas `age` (§28), con `Identity` y `Recipient` propios para el stanza `tlock` |
| `@noble/curves` | 2.4.0 | MIT | BLS12-381 del núcleo IBE y de la verificación de releases; también el oráculo de `bls12381.contrast.test.ts` |
| `@noble/hashes` | 2.4.0 | MIT | los hashes del IBE; se declara porque se importa directamente |
`age-encryption` arrastra `@noble/ciphers` 2.4.0, `@scure/base` 2.4.0 y `@noble/post-quantum` 0.5.4, todos con licencia MIT. `@noble/post-quantum` fija `@noble/curves` y `@noble/hashes` a `~2.0.0` y trae su propia copia 2.0.1, que usa para el ML-KEM híbrido. La decisión 5 la acepta, sin overrides de npm.
Guardas de `src/lib/dependencies.test.ts`, en cada `npm test`:
- `package.json` declara exactamente estas tres 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` y los tests nombran `@noble/`, siempre con subrutas de `@noble/curves` y `@noble/hashes` 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.
Coste medido en el bundle el 28-09-2026, con una compilación de prueba de Vite 8 minificada (gzip de nivel 9):
| Qué se importa | Minificado | gzip |
|---|---|---|
| `Decrypter` de `age-encryption` | 153 645 B | 48 032 B |
| `Decrypter` y `Encrypter` | 183 650 B | 55 915 B |
| `bls12_381` y `sha256` de noble | 92 637 B | 28 090 B |
| Todo lo anterior | 239 454 B | 72 783 B |
`age-encryption` importa de forma estática sus recipients ML-KEM híbridos, P-256 y scrypt. Por eso el `Decrypter` arrastra `@noble/post-quantum` y la copia 2.0.1 de noble, aunque DateKeys no los use: son unos 99 KB de los 212 KB de código antes de minificar. Como referencia, `/inspect` carga hoy unos 157 KB de JavaScript (58,7 KB con gzip). La cifra exacta cambia unos bytes en cada compilación, por la versión que SvelteKit incrusta.
`npm audit --omit=dev` no encuentra vulnerabilidades. El `npm audit` completo encuentra 2 de gravedad baja en el tooling: `cookie` < 0.7.0 (GHSA-pxg6-pf52-xh8x), que llega a través de `@sveltejs/kit` 2.70.3. Esa es la última versión y sigue pidiendo `cookie` ^0.6.0. Solo afecta a la gestión de cookies del servidor de SvelteKit, que un sitio estático no usa.
## Tooling de desarrollo
| Paquete | Versión | Estado |
@ -158,7 +192,8 @@ Umbrales de cobertura (`vitest.config.ts`): `cbor.ts` al 100 % en líneas, ramas
| `@sveltejs/vite-plugin-svelte` | 7.3.0 | en `package.json`; paso 5 |
| `svelte-check` | 4.7.6 | en `package.json`; paso 5, `npm run check` |
| `@sveltejs/adapter-static` | 3.0.10 | en `package.json`; paso 5: la página es estática y no necesita servidor |
| `@noble/curves` | 2.4.0 | solo desarrollo: oráculo auditado de `bls12381.contrast.test.ts`, que compara nuestro `bls12381.ts` con la referencia Go y con noble; un test falla si algo que no sea un test la importa, así que nunca llega al sitio. Arrastra `@noble/hashes` 2.4.0 |
`@noble/curves` 2.4.0 estaba en esta lista como oráculo de `bls12381.contrast.test.ts` y ha pasado a dependencia de ejecución en la fase 2.
Todas las versiones se fijan exactas y `package-lock.json` se versiona. `.npmrc` activa `legacy-peer-deps` porque npm 11.5.2 falla al resolver los peers opcionales de `vitest` 5.0.1 (`Cannot read properties of null (reading 'edgesOut')`); con esa opción npm no instala peers, así que el peer obligatorio `vite` está declarado explícitamente.

@ -49,9 +49,9 @@ Confirmadas por el autor el 26-09-2026 (paso 0 de la sección 10).
| Paquete | Versión | Estado |
|---|---|---|
| `age-encryption` | 0.3.1 | aprobada (decisión 5). Arrastra `@noble/ciphers`, `@noble/curves` 2, `@noble/hashes` 2, `@noble/post-quantum` 0.5.4 (con su propia copia de `@noble/curves` y `@noble/hashes` 2.0.x) y `@scure/base`. |
| `@noble/curves` | 2.4.0 | ya instalada como desarrollo; pasa a ejecución. Fijada exacta. |
| `@noble/hashes` | 2.4.0 | dependencia exacta de `@noble/curves` 2.4.0; se declara explícita por importarse directamente. |
| `age-encryption` | 0.3.1 | aprobada (decisión 5) e instalada el 28-09-2026. Arrastra `@noble/ciphers` 2.4.0, `@noble/curves` 2, `@noble/hashes` 2, `@noble/post-quantum` 0.5.4 (con su propia copia de `@noble/curves` y `@noble/hashes` 2.0.1) y `@scure/base` 2.4.0. |
| `@noble/curves` | 2.4.0 | pasó de desarrollo a ejecución el 28-09-2026. Fijada exacta. |
| `@noble/hashes` | 2.4.0 | dependencia exacta de `@noble/curves` 2.4.0; declarada explícita el 28-09-2026 por importarse directamente. |
| `tlock-js`, `drand-client` | — | no se instalan. |
Guardas, todas en tests que corren en cada ejecución:
@ -101,6 +101,8 @@ Guardas, todas en tests que corren en cada ejecución:
La interfaz `ReleaseSource` del SDK solo tiene una implementación estática en esta fase: el release embebido en el sidecar del fixture o suministrado por la aplicación. Su contrato se fija ya para las fuentes con red de fases posteriores: una fuente llama a `verifyRelease` con cada respuesta y, si ninguna verifica, falla con `ERR_RELEASE_UNAVAILABLE` en el paso 9; solo un release entregado directamente por quien llama da `ERR_RELEASE_INVALID` en el paso 10. Es el comportamiento de `provider/drand/client.go` en Go.
Además, desde la corrección 6 de §76, cualquier fallo de una fuente se informa en el paso 9 con `ERR_RELEASE_UNAVAILABLE` y ningún otro código. Si el error de la fuente lleva otro código normativo, o ninguno, se conserva solo su texto. Si el contexto termina, se sigue pudiendo detectar. Así lo hacen `capsule.Open` (`sourceFailure`) y `provider/drand.Client` en Go `9ac9cd9`, y `open.ts` debe hacer lo mismo.
---
## 6. Apertura y cifrado con `age-encryption`
@ -155,8 +157,8 @@ La ruta `/inspect` gana una acción "abrir": con un fixture o un `.dkc` arrastra
| Paso | Contenido | Hecho cuando |
|---|---|---|
| 0 | Confirmar las decisiones de la sección 2 y aprobar las dependencias de la sección 3 | hecho el 26-09-2026 |
| 1 | Precondición: `main` verde con `testdata` sincronizado al último commit de `datekeys-go` | cumplida en `d5e3236` (`692cf87`); se repite al sincronizar la enmienda de la sección 7 |
| 2 | Dependencias y guardas | instaladas con versiones exactas; `npm audit` sin avisos; guardas en verde; un solo noble en el lockfile |
| 1 | Precondición: `main` verde con `testdata` sincronizado al último commit de `datekeys-go` | cumplida en `d5e3236` (`692cf87`) y de nuevo en `71ab8fb`, con `testdata` en `9ac9cd9` (`spec-v0.8.2`) |
| 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 % |
| 4 | `release.ts` y `release.test.ts` | ronda real válida, alias rechazados |
| 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 |

85
package-lock.json generated

@ -8,8 +8,12 @@
"name": "datekeys-app",
"version": "0.0.0",
"license": "Apache-2.0",
"devDependencies": {
"dependencies": {
"@noble/curves": "2.4.0",
"@noble/hashes": "2.4.0",
"age-encryption": "0.3.1"
},
"devDependencies": {
"@sveltejs/adapter-static": "3.0.10",
"@sveltejs/kit": "2.70.3",
"@sveltejs/vite-plugin-svelte": "7.3.0",
@ -135,11 +139,22 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@noble/ciphers": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz",
"integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz",
"integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==",
"dev": true,
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.4.0"
@ -155,7 +170,49 @@
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/post-quantum": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.5.4.tgz",
"integrity": "sha512-leww0zzIirrvwaYMPI9fj6aRIlA/c6Y0/lifQQ1YOOyHEr0MNH3yYpjXeiVG+tWdPps4XxGclFWX2INPO3Yo5w==",
"license": "MIT",
"dependencies": {
"@noble/curves": "~2.0.0",
"@noble/hashes": "~2.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/post-quantum/node_modules/@noble/curves": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.0.1"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/post-quantum/node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
@ -443,6 +500,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@scure/base": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.4.0.tgz",
"integrity": "sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@ -697,6 +763,19 @@
"node": ">=0.4.0"
}
},
"node_modules/age-encryption": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/age-encryption/-/age-encryption-0.3.1.tgz",
"integrity": "sha512-bYgd7lxM7tEANmb9bXf7xTFB3Qpq+JGKinVSdFh5MV+t2fJJZSTdKhWkvXTFJQGysZd+K9Dt3F+n+4DKazXlnQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@noble/ciphers": "^2.1.1",
"@noble/curves": "^2.0.1",
"@noble/hashes": "^2.0.1",
"@noble/post-quantum": "^0.5.3",
"@scure/base": "^2.0.0"
}
},
"node_modules/aria-query": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",

@ -24,7 +24,6 @@
"testdata:check": "node scripts/sync-testdata.mjs check --against ../datekeys-go"
},
"devDependencies": {
"@noble/curves": "2.4.0",
"@sveltejs/adapter-static": "3.0.10",
"@sveltejs/kit": "2.70.3",
"@sveltejs/vite-plugin-svelte": "7.3.0",
@ -35,5 +34,10 @@
"typescript": "5.9.3",
"vite": "8.3.0",
"vitest": "5.0.1"
},
"dependencies": {
"@noble/curves": "2.4.0",
"@noble/hashes": "2.4.0",
"age-encryption": "0.3.1"
}
}

@ -17,12 +17,19 @@
// another origin, or a stylesheet imports or references one;
// - the official .dkc fixtures are not shipped byte for byte, or a secret of
// the fixtures (.dkk files, plaintexts, identities, payload identities,
// access material, CONTROL_CBOR) is anywhere in the build.
// access material, CONTROL_CBOR) is anywhere in the build;
// - the client bundle holds tlock-js, drand-client or Babel's helpers, or a
// nested copy of a package other than the noble copy under
// @noble/post-quantum (plan of phase 2, section 3 and decision 5), as
// .svelte-kit/output/client-modules.json records it (vite.config.ts).
//
// It reports the JavaScript that each page loads, raw and gzip.
import { createHash } from 'node:crypto';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { basename, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { gzipSync } from 'node:zlib';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
// The site directory: build/, or the first argument.
@ -221,6 +228,80 @@ for (const f of files) {
if (/\.(dkk|plaintext)$/.test(f) || /fixtures?\/.*\.json$/.test(inBuild(f))) fail(`${rel(f)}: fixture file that must not be shipped`);
}
// ---------------------------------------------------------------------------
// What the client bundle is made of.
const MODULES = join(ROOT, '.svelte-kit', 'output', 'client-modules.json');
const FORBIDDEN_PACKAGES = [/^tlock-js$/, /^drand-client$/, /^@babel\//];
const chunks = existsSync(MODULES) ? JSON.parse(readFileSync(MODULES, 'utf8')).chunks : {};
if (!existsSync(MODULES)) fail(`${rel(MODULES)} is missing: vite.config.ts writes it during "npm run build"`);
// The npm package of a module id, and the package it is nested under, if any.
function packageOf(id) {
const i = id.lastIndexOf('/node_modules/');
if (i < 0) return null;
const parts = id.slice(i + '/node_modules/'.length).split('/');
const name = parts[0].startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0];
const outer = id.slice(0, i);
const j = outer.lastIndexOf('/node_modules/');
return { name, parent: j < 0 ? null : outer.slice(j + '/node_modules/'.length) };
}
const packages = new Set();
for (const [file, chunk] of Object.entries(chunks)) {
if (!existsSync(join(BUILD, file))) fail(`client chunk ${file} is not in the site`);
for (const id of Object.keys(chunk.modules)) {
const pkg = packageOf(id);
if (pkg === null) continue;
if (FORBIDDEN_PACKAGES.some((re) => re.test(pkg.name))) fail(`${file} bundles ${pkg.name}: ${id}`);
if (pkg.parent !== null && pkg.parent !== '@noble/post-quantum') fail(`${file} bundles a copy of ${pkg.name} nested under ${pkg.parent}`);
packages.add(pkg.parent === null ? pkg.name : `${pkg.name} (under ${pkg.parent})`);
}
}
// The JavaScript of a page: the chunks it preloads and the entries its
// inline script imports, as SvelteKit writes them, with their static
// imports; and apart, what those load on demand, other than the nodes of
// other routes.
function pageScripts(html) {
const eager = new Set();
for (const [link] of html.matchAll(/<link\b[^>]*>/gi)) {
const href = /\brel="modulepreload"/i.test(link) ? link.match(/\bhref="\.\/([^"]+)"/i) : null;
if (href) eager.add(href[1]);
}
for (const [, src] of html.matchAll(/\bimport\("\.\/([^"]+)"\)/g)) eager.add(src);
const close = (set, seeds) => {
const queue = [...seeds];
while (queue.length > 0) {
for (const f of chunks[queue.pop()]?.imports ?? []) {
if (!set.has(f) && !eager.has(f)) {
set.add(f);
queue.push(f);
}
}
}
};
close(eager, eager);
const lazy = new Set();
for (const f of eager) {
for (const d of chunks[f]?.dynamicImports ?? []) {
if (!eager.has(d) && !/^_app\/immutable\/(nodes|entry)\//.test(d)) lazy.add(d);
}
}
close(lazy, lazy);
return { eager, lazy };
}
const weight = (set) => {
let raw = 0;
let gzip = 0;
for (const f of set) {
const bytes = readFileSync(join(BUILD, f));
raw += bytes.length;
gzip += gzipSync(bytes, { level: 9 }).length;
}
return `${set.size} files, ${raw} bytes, ${gzip} gzip`;
};
// ---------------------------------------------------------------------------
if (problems.length > 0) {
@ -233,4 +314,7 @@ console.log(`build check passed: ${htmlFiles.length} prerendered pages, ${files.
for (const [i, f] of htmlFiles.entries()) {
const p = policies[i];
console.log(` ${rel(f)}: CSP ${[...p].map(([d, s]) => `${d} ${s.join(' ')}`).join('; ')}`);
const { eager, lazy } = pageScripts(readFileSync(f, 'utf8'));
console.log(` ${rel(f)}: JavaScript ${weight(eager)}; on demand ${weight(lazy)}`);
}
console.log(` npm packages in the client bundle: ${[...packages].sort().join(', ') || 'none'}`);

@ -0,0 +1,188 @@
// Guards of the runtime dependencies (plan of phase 2, section 3).
//
// The runtime dependencies are age-encryption 0.3.1, for the three age
// files, and the two noble packages that the BLS12-381 code of phase 2
// imports directly, @noble/curves and @noble/hashes 2.4.0; everything else
// is what age-encryption drags in. These tests fail when:
//
// - package.json declares another runtime dependency, or one without its
// exact version;
// - package-lock.json holds tlock-js or drand-client, a noble 1.x, a root
// copy of @noble/curves or @noble/hashes other than 2.4.0 (2.3.0 is the
// first to reject non-canonical point encodings), or 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 and the tests names
// @noble/, or a noble import is not a subpath of @noble/curves or
// @noble/hashes, the root copies that those files resolve to.
//
// Each check is also run on bad inputs, so that a guard that no longer
// catches anything fails too.
import { bls12_381 } from '@noble/curves/bls12-381.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { readdirSync, readFileSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { describe, expect, it } from 'vitest';
const ROOT = fileURLToPath(new URL('../../', import.meta.url));
const rel = (p: string): string => relative(ROOT, p).split(sep).join('/');
// The approved runtime dependencies, with their exact versions.
const RUNTIME: Record<string, string> = {
'@noble/curves': '2.4.0',
'@noble/hashes': '2.4.0',
'age-encryption': '0.3.1',
};
// Packages that never enter the project: the IBE core is our own on noble 2
// 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'];
type LockEntry = { version?: string; dev?: boolean; dependencies?: Record<string, string> };
type Source = { name: string; text: string };
const readJSON = <T>(path: string): T => JSON.parse(readFileSync(join(ROOT, path), 'utf8')) as T;
function walk(dir: string, keep: (name: string) => boolean): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) out.push(...walk(path, keep));
else if (keep(entry.name)) out.push(path);
}
return out;
}
// The files of src/, except this one, whose bad imports are test data.
const SELF = rel(fileURLToPath(import.meta.url));
const sources = (): Source[] =>
walk(join(ROOT, 'src'), (name) => /\.(ts|js|mjs|svelte)$/.test(name))
.map((p) => ({ name: rel(p), text: readFileSync(p, 'utf8') }))
.filter((s) => s.name !== SELF);
// The module specifiers of a source file: imports and re-exports, static,
// dynamic or for side effects.
function specifiers(text: string): string[] {
const out: string[] = [];
for (const re of [/\b(?:import|export)\b[^'"`;]*?\bfrom\s*['"]([^'"]+)['"]/g, /\bimport\s*\(?\s*['"]([^'"]+)['"]/g]) {
for (const [, s] of text.matchAll(re)) if (s !== undefined) out.push(s);
}
return out;
}
const packageOf = (specifier: string): string => specifier.split('/').slice(0, specifier.startsWith('@') ? 2 : 1).join('/');
// The problems of the lockfile's packages, and how many noble copies are
// nested under @noble/post-quantum.
function lockProblems(packages: Record<string, LockEntry>): { problems: string[]; nested: number } {
const problems: string[] = [];
let nested = 0;
for (const [path, entry] of Object.entries(packages)) {
if (path === '') continue;
const name = path.slice(path.lastIndexOf('node_modules/') + 'node_modules/'.length);
if (FORBIDDEN.includes(name)) problems.push(`${path} is in the lockfile`);
if (name !== '@noble/curves' && name !== '@noble/hashes') continue;
const version = entry.version ?? '';
if (!version.startsWith('2.')) problems.push(`${path} ${version}: only noble 2 is allowed`);
if (path === `node_modules/${name}`) {
if (version !== '2.4.0') problems.push(`${path} ${version}: the root copy must be 2.4.0`);
} else if (path.startsWith('node_modules/@noble/post-quantum/node_modules/')) {
nested++;
} else {
problems.push(`${path} ${version}: a second copy outside @noble/post-quantum`);
}
}
return { problems, nested };
}
// The problems of the imports of the files of src/.
function importProblems(files: Source[]): string[] {
const problems: string[] = [];
for (const { name, text } of files) {
if (!name.endsWith('.test.ts') && !NOBLE_IMPORTERS.includes(name) && text.includes('@noble/')) problems.push(`${name} names @noble/`);
for (const s of specifiers(text)) {
if (FORBIDDEN.includes(packageOf(s))) problems.push(`${name}: ${s} is forbidden`);
else if (s.includes('node_modules')) problems.push(`${name}: ${s} reaches into node_modules`);
else if (s.startsWith('@noble/') && !/^@noble\/(?:curves|hashes)\/[\w./-]+\.js$/.test(s)) problems.push(`${name}: ${s}`);
}
}
return problems;
}
describe('runtime dependencies', () => {
it('package.json declares exactly the approved runtime dependencies, pinned', () => {
const pkg = readJSON<{ dependencies?: Record<string, string>; devDependencies?: Record<string, string> }>('package.json');
expect(pkg.dependencies).toEqual(RUNTIME);
for (const name of Object.keys(RUNTIME)) expect(pkg.devDependencies?.[name], name).toBeUndefined();
const lock = readJSON<{ packages: Record<string, LockEntry> }>('package-lock.json');
expect(lock.packages['']?.dependencies).toEqual(RUNTIME);
for (const [name, version] of Object.entries(RUNTIME)) {
const entry = lock.packages[`node_modules/${name}`];
expect(entry?.version, name).toBe(version);
expect(entry?.dev, `${name} is a runtime dependency`).toBeUndefined();
}
});
it('package-lock.json holds noble 2.4.0 at the root, other 2.x copies only under @noble/post-quantum, and no tlock-js or drand-client', () => {
const { problems, nested } = lockProblems(readJSON<{ packages: Record<string, LockEntry> }>('package-lock.json').packages);
expect(problems).toEqual([]);
// post-quantum 0.5.4 brings @noble/curves and @noble/hashes 2.0.1.
expect(nested).toBe(2);
});
it('the lockfile check rejects each forbidden layout', () => {
const good: Record<string, LockEntry> = {
'': {},
'node_modules/@noble/curves': { version: '2.4.0' },
'node_modules/@noble/hashes': { version: '2.4.0' },
'node_modules/@noble/post-quantum/node_modules/@noble/curves': { version: '2.0.1' },
};
expect(lockProblems(good)).toEqual({ problems: [], nested: 1 });
const bad: [label: string, path: string, version: string][] = [
['a noble 1.x', 'node_modules/age-encryption/node_modules/@noble/hashes', '1.8.0'],
['a root copy other than 2.4.0', 'node_modules/@noble/curves', '2.3.0'],
['a second 2.x copy outside post-quantum', 'node_modules/age-encryption/node_modules/@noble/curves', '2.0.1'],
['tlock-js', 'node_modules/tlock-js', '0.9.0'],
['drand-client', 'node_modules/drand-client', '1.2.6'],
];
for (const [label, path, version] of bad) {
expect(lockProblems({ ...good, [path]: { version } }).problems, label).not.toEqual([]);
}
});
it('only ibe.ts, release.ts and the tests import noble, only from @noble/curves and @noble/hashes, and nothing imports tlock-js or drand-client', () => {
expect(importProblems(sources())).toEqual([]);
});
it('the import check rejects each forbidden import', () => {
const noble = "import { bls12_381 } from '@noble/curves/bls12-381.js';";
expect(importProblems([{ name: 'src/lib/dkc/ibe.ts', text: noble }, { name: 'src/lib/dkc/x.test.ts', text: noble }])).toEqual([]);
const bad: [label: string, name: string, text: string][] = [
['noble outside the allowlist', 'src/lib/dkc/open.ts', noble],
['noble from a page', 'src/routes/inspect/+page.svelte', noble],
['another noble package', 'src/lib/dkc/ibe.ts', "import { chacha20poly1305 } from '@noble/ciphers/chacha.js';"],
['the nested copy', 'src/lib/dkc/release.ts', "import { bls12_381 } from '../../../node_modules/@noble/post-quantum/node_modules/@noble/curves/bls12-381.js';"],
['tlock-js', 'src/lib/dkc/open.ts', "import { timelockDecrypt } from 'tlock-js';"],
['drand-client, dynamically', 'src/lib/dkc/open.test.ts', "const c = await import('drand-client');"],
['tlock-js, re-exported', 'src/lib/dkc/index.ts', "export * from 'tlock-js/drand/timelock-decrypter';"],
];
for (const [label, name, text] of bad) {
expect(importProblems([{ name, text }]), label).not.toEqual([]);
}
});
it('src/ resolves noble to the root copies, 2.4.0', async () => {
// No node_modules directory inside src/ can shadow the root ones.
expect(walk(join(ROOT, 'src'), () => true).filter((p) => rel(p).split('/').includes('node_modules'))).toEqual([]);
for (const name of ['@noble/curves', '@noble/hashes']) {
expect(readJSON<{ version: string }>(`node_modules/${name}/package.json`).version, name).toBe('2.4.0');
}
// The modules that a file of src/ imports by name are the files of those
// root copies.
const curves = (await import(pathToFileURL(join(ROOT, 'node_modules/@noble/curves/bls12-381.js')).href)) as { bls12_381: unknown };
const hashes = (await import(pathToFileURL(join(ROOT, 'node_modules/@noble/hashes/sha2.js')).href)) as { sha256: unknown };
expect(curves.bls12_381).toBe(bls12_381);
expect(hashes.sha256).toBe(sha256);
});
});

@ -5,14 +5,13 @@
// dependency. Its assurance comes from this file: on every run it is compared
// with the Go reference on 157 frozen edge cases and with @noble/curves 2.4.0
// (Cure53 2024; Trail of Bits 2026 review, whose BLS findings were fixed in
// 2.3.0) on a deterministic corpus. noble is a development dependency only:
// the last test fails if anything outside a test imports it, so it never
// reaches the site.
// 2.3.0) on a deterministic corpus. noble is a runtime dependency since phase
// 2, for the IBE core and the release verification only: the guards of
// src/lib/dependencies.test.ts fail if any other file of the library or the
// page imports it.
import { bls12_381 } from '@noble/curves/bls12-381.js';
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { checkCompressedPoint, type Group } from './bls12381.ts';
@ -158,20 +157,4 @@ describe('bls12381.ts against the Go reference and @noble/curves 2.4.0', () => {
expect(seen.point).toBeGreaterThanOrEqual(160);
expect(seen.invalid).toBeGreaterThanOrEqual(200);
});
it('only tests import @noble/curves', () => {
const root = fileURLToPath(new URL('../../', import.meta.url));
const offenders: string[] = [];
const walk = (dir: string): void => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) walk(path);
else if (/\.(ts|js|svelte)$/.test(entry.name) && !/\.test\.ts$/.test(entry.name)) {
if (readFileSync(path, 'utf8').includes('@noble/')) offenders.push(path);
}
}
};
walk(root);
expect(offenders).toEqual([]);
});
});

@ -1,10 +1,42 @@
// Vite configuration of the SvelteKit site. The library tests run with
// vitest.config.ts, which vitest prefers when both files exist.
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { defineConfig, type Plugin } from 'vite';
// Records what the client bundle is made of, for scripts/check-build.mjs:
// each chunk with the chunks it imports and the modules it contains, with
// their rendered sizes. The record goes to .svelte-kit/output, outside the
// site.
function clientModules(): Plugin {
let root = process.cwd();
return {
name: 'datekeys:client-modules',
apply: 'build',
configResolved(config) {
root = config.root;
},
generateBundle(_, bundle) {
if (this.environment.name !== 'client') return;
const chunks: Record<string, { imports: string[]; dynamicImports: string[]; modules: Record<string, number> }> = {};
for (const out of Object.values(bundle)) {
if (out.type !== 'chunk') continue;
chunks[out.fileName] = {
imports: out.imports,
dynamicImports: out.dynamicImports,
modules: Object.fromEntries(Object.entries(out.modules).map(([id, m]) => [id.replace(/\\/g, '/'), m.renderedLength])),
};
}
const dir = join(root, '.svelte-kit', 'output');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'client-modules.json'), JSON.stringify({ chunks }, null, 1));
},
};
}
export default defineConfig({
plugins: [sveltekit()],
plugins: [sveltekit(), clientModules()],
build: {
// Never inline an asset as a data: URL. The CSP allows only the page's
// own origin, so the official fixtures (src/lib/inspector/fixtures.ts)

Loading…
Cancel
Save

Powered by TurnKey Linux.