From 5f8c8c80319403d58f834d55b1f1a8446c2d95fd Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 26 Sep 2026 01:00:59 +0200 Subject: [PATCH] Inspector page: static SvelteKit site with /inspect (plan step 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A prerendered static site (adapter-static) with a landing page and /inspect, which runs spec §63 steps 1 to 8 on a .dkc chosen with the file picker, dropped anywhere on the page, or taken from the official fixtures bundled at build time. It shows every step, the decoded header, the unlock date in UTC and local time, and each extension's id, version, criticality, length and hex, with a text view and an informative CBOR diagnostic view, all escaped and labelled as unauthenticated before step 15. Copiar JSON copies the exact "datekeys inspect -json" view. No network: a hash-mode Content-Security-Policy with connect-src 'self' is the first element of every page, and scripts/check-build.mjs verifies it, the fixtures and the absence of external URLs after every build. Large files are read only up to what steps 1 to 8 need. Reviewed for design and accessibility (WCAG AA contrast, keyboard, focus, live status, 360 px), security and correctness; 262 tests pass, svelte-check has no warnings. Co-Authored-By: Claude Opus 5.5 --- .claude/launch.json | 17 + README.md | 75 ++- docs/PLAN_codec_cbor_y_pagina_svelte.md | 6 +- package-lock.json | 456 +++++++++++++++++ package.json | 17 +- scripts/check-build.mjs | 236 +++++++++ src/app.css | 321 ++++++++++++ src/app.d.ts | 6 + src/app.html | 14 + src/lib/components/DataView.svelte | 59 +++ src/lib/components/ExtensionList.svelte | 177 +++++++ src/lib/components/InspectionReport.svelte | 545 +++++++++++++++++++++ src/lib/components/Mark.svelte | 29 ++ src/lib/components/StepList.svelte | 119 +++++ src/lib/dkc/age.ts | 9 +- src/lib/inspector/diagnostic.test.ts | 77 +++ src/lib/inspector/diagnostic.ts | 126 +++++ src/lib/inspector/fixtures.test.ts | 56 +++ src/lib/inspector/fixtures.ts | 52 ++ src/lib/inspector/format.test.ts | 166 +++++++ src/lib/inspector/format.ts | 256 ++++++++++ src/lib/inspector/load.test.ts | 138 ++++++ src/lib/inspector/load.ts | 54 ++ src/lib/inspector/report.test.ts | 198 ++++++++ src/lib/inspector/report.ts | 267 ++++++++++ src/routes/+layout.svelte | 89 ++++ src/routes/+layout.ts | 2 + src/routes/+page.svelte | 147 ++++++ src/routes/inspect/+page.svelte | 359 ++++++++++++++ static/favicon.svg | 5 + svelte.config.js | 64 +++ tsconfig.json | 9 +- vite.config.ts | 21 + vitest.config.ts | 10 +- 34 files changed, 4164 insertions(+), 18 deletions(-) create mode 100644 .claude/launch.json create mode 100644 scripts/check-build.mjs create mode 100644 src/app.css create mode 100644 src/app.d.ts create mode 100644 src/app.html create mode 100644 src/lib/components/DataView.svelte create mode 100644 src/lib/components/ExtensionList.svelte create mode 100644 src/lib/components/InspectionReport.svelte create mode 100644 src/lib/components/Mark.svelte create mode 100644 src/lib/components/StepList.svelte create mode 100644 src/lib/inspector/diagnostic.test.ts create mode 100644 src/lib/inspector/diagnostic.ts create mode 100644 src/lib/inspector/fixtures.test.ts create mode 100644 src/lib/inspector/fixtures.ts create mode 100644 src/lib/inspector/format.test.ts create mode 100644 src/lib/inspector/format.ts create mode 100644 src/lib/inspector/load.test.ts create mode 100644 src/lib/inspector/load.ts create mode 100644 src/lib/inspector/report.test.ts create mode 100644 src/lib/inspector/report.ts create mode 100644 src/routes/+layout.svelte create mode 100644 src/routes/+layout.ts create mode 100644 src/routes/+page.svelte create mode 100644 src/routes/inspect/+page.svelte create mode 100644 static/favicon.svg create mode 100644 svelte.config.js create mode 100644 vite.config.ts diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..5fd8c39 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,17 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "inspector-preview", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "preview", "--", "--port", "4173", "--strictPort"], + "port": 4173 + }, + { + "name": "inspector-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev", "--", "--port", "5188", "--strictPort"], + "port": 5188 + } + ] +} diff --git a/README.md b/README.md index a91298d..107173f 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ La implementación de referencia es la librería Go `g.activething.com/go/DateKe | Parte | Ubicación | Paso del plan | Estado | |---|---|---|---| | Codec CBOR del subconjunto, parsers de schema, DateKey, `inspect` | `src/lib/dkc/` | 4 | hecho | -| Página inspector, sin red | SvelteKit, ruta `/inspect` | 5 | pendiente | +| 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 | ## `src/lib/dkc` @@ -29,7 +29,7 @@ Sin dependencias de ejecución. Funciona en navegadores y en Node 20+: solo usa | `datekey.ts` | `dk1_` canónico, ronda desde una fecha con precisión de nanosegundos, parser RFC 3339 equivalente a `time.Parse(time.RFC3339Nano, …)` | `datekey` | | `header.ts`, `control.ts`, `accesskey.ts` | PUBLIC_HEADER, CONTROL_CBOR y `.dkk` (cuerpo y trama), decodificar y codificar | `capsule`, `accesskey` | | `framing.ts` | Prelude DKC1 (16 bytes) y DKK1 (12 bytes), longitudes, límites de §57 y troceo de secciones | `capsule/framing.go` | -| `age.ts` | Parser estricto de la cabecera `age` v1 sobre los ficheros binarios, con los textos de error de `age`; reglas de stanzas | `agewrap`, `filippo.io/age/internal/format` | +| `age.ts` | Parser estricto de la cabecera `age` v1 sobre los ficheros binarios, con los textos de error de `age`; reglas de stanzas; `MAX_AGE_HEADER_LEN` (2 MiB), el límite que usa la página para leer solo el prefijo de un `.dkc` grande | `agewrap`, `filippo.io/age/internal/format` | | `inspect.ts` | Pasos 1 a 8 de §63 y la vista JSON de `datekeys inspect -json` | `capsule/inspect.go`, `cmd/datekeys` | | `index.ts` | Reexporta todo | | | `testing/` | Solo para tests: lectura de `testdata/`, constructores de CBOR en hex, cirugía de cápsulas | | @@ -53,10 +53,61 @@ El lector de schema (`peek`) reproduce a propósito lo que acepta `codec.Peek` d Secretos: `access_material` de un `.dkk` e `I_PAYLOAD` de CONTROL_CBOR se borran en todos los caminos, también cuando la decodificación falla a medias o `unmarshal` rechaza el valor, como el `clear` diferido de Go. +## Página inspector + +Sitio SvelteKit estático (`@sveltejs/adapter-static`, `strict`): las dos páginas se prerenderizan a HTML y no hay código de servidor. + +| Ruta | Contenido | +|---|---| +| `/` | Qué es el inspector y qué garantiza; enlaza con `/inspect` | +| `/inspect` | El inspector | + +`/inspect` carga un `.dkc` con el selector de ficheros, soltándolo en cualquier parte de la página o desde la lista de fixtures oficiales, y ejecuta `inspect` (pasos 1 a 8 de §63) sin registro de extensiones, como `datekeys inspect`. Muestra: + +- cada paso con su número, su nombre de la CLI, `superado` o el código normativo, y el detalle (los caracteres invisibles o de control se escriben como `\uXXXX`); +- el veredicto, `capsule_id`, la DateKey compacta y decodificada (red y ronda), el perfil fijado, la fecha de apertura en UTC y en la hora local del navegador, `access_policy`, los campos del prelude, los argumentos del stanza `tlock` frente al perfil fijado y el número y tipo de stanzas de OUTER_TIME_AGE y PAYLOAD_AGE; +- las extensiones de PUBLIC_HEADER según el contrato del plan §8: id (entre comillas y escapado si tiene caracteres no imprimibles), versión, crítica o no, conocida o no, longitud y hex (plegado si pasa de 64 bytes); texto si los bytes son UTF-8 imprimible; vista CBOR con `walk` si son un ítem del perfil de §58, marcada "informativo, no validado por el protocolo"; y el aviso de que son públicas y no están autenticadas hasta el paso 15; +- **Copiar JSON**, que copia exactamente la salida de `datekeys inspect -json` (`cliJSON`: el `json.Encoder` de Go con sangría de dos espacios, `<`, `>`, `&`, U+2028 y U+2029 escapados y salto de línea final). `file` es el nombre del fichero. + +No pide ni acepta secretos. Todo el texto leído de la cápsula pasa por interpolación de texto de Svelte (nunca `{@html}`), y ni las entradas de mapas CBOR ni los identificadores de extensión se usan nunca como claves de objetos o `Map` de JavaScript. + +| Fichero | Contenido | +|---|---| +| `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/inspector/fixtures.ts` | Los fixtures oficiales, empaquetados desde `testdata/fixtures` | +| `src/lib/components/` | `InspectionReport`, `StepList`, `ExtensionList`, `DataView`, `Mark` | +| `src/routes/` | Layout, portada e inspector | + +### Fixtures + +`fixtures.ts` importa con `import.meta.glob` los `.dkc` de `testdata/fixtures` como URL (`?url`) y, de cada registro JSON, solo el campo `description`. `testdata/` sigue siendo la única fuente: Vite copia cada `.dkc` como fichero con hash en `_app/immutable/assets/` y nunca lo incrusta como `data:` (`assetsInlineLimit: 0`), y no se copia nada más. Los `.dkk`, los textos en claro y los demás campos de los registros (`payload_identity`, `control_cbor`…) no llegan al sitio; `check-build.mjs` lo comprueba. En desarrollo, `server.fs.allow` deja que Vite sirva `testdata/fixtures`. + +### Sin red: la Content-Security-Policy + +`kit.csp` (`svelte.config.js`, modo `hash`) pone en cada página prerenderizada, como primer elemento que carga algo, un ``: + +``` +default-src 'self'; frame-src 'none'; worker-src 'none'; connect-src 'self'; font-src 'self'; +img-src 'self'; manifest-src 'self'; object-src 'none'; script-src 'self' 'sha256-…'; +style-src 'self'; style-src-attr 'unsafe-hashes' 'sha256-…'; base-uri 'none'; form-action 'none' +``` + +- `connect-src 'self'`: `fetch` solo llega al propio origen, y solo se usa para los fixtures. +- `script-src`: los módulos del sitio y el hash SHA-256 del único script en línea, el arranque de SvelteKit (los nonces no sirven en HTML prerenderizado). +- `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`). + +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. +- 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). - Tooling de desarrollo: solo el de la lista siguiente. Cualquier otra dependencia se propone por escrito y no se instala sin aprobación. ## Comandos @@ -64,11 +115,18 @@ Secretos: `access_material` de un `.dkk` e `I_PAYLOAD` de CONTROL_CBOR se borran ```bash npm test # vitest, todos los tests npm run coverage # tests con cobertura v8; falla por debajo de los umbrales -npm run typecheck # tsc sobre todo y sobre la librería sin tipos de Node +npm run typecheck # svelte-kit sync y tsc sobre todo y sobre la librería sin tipos de Node +npm run check # svelte-kit sync y svelte-check (componentes y rutas), falla con avisos +npm run dev # servidor de desarrollo: http://localhost:5173/inspect +npm run build # sitio estático en build/ y, después, scripts/check-build.mjs +npm run preview # sirve build/: http://localhost:4173/inspect +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` 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` al 100 % en líneas, ramas, funciones y sentencias; el conjunto de `src/lib/dkc` al 95/90/95/95. +`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`). `npm run typecheck` pasa dos veces: `tsconfig.json` (todo, con tipos de Node para los tests) y `tsconfig.lib.json` (solo la librería y sin tipos de Node, para que no se cuele ninguna API que no exista en el navegador). @@ -93,8 +151,11 @@ Umbrales de cobertura (`vitest.config.ts`): `cbor.ts` al 100 % en líneas, ramas | `@vitest/coverage-v8` | 5.0.1 | en `package.json`; cobertura del 100 % del codec | | `@types/node` | 24.13.6 | en `package.json`; tests que leen `testdata/` desde disco | | `vite` | 8.3.0 | en `package.json`; dependencia peer obligatoria de `vitest` 5.0.1 y base del paso 5; la versión del prototipo | -| `svelte`, `@sveltejs/kit`, `@sveltejs/vite-plugin-svelte`, `svelte-check` | las del prototipo | paso 5 | -| `@sveltejs/adapter-static` | por fijar | paso 5, pendiente de aprobación: la página es estática y no necesita servidor | +| `svelte` | 5.57.1 | en `package.json`; paso 5 | +| `@sveltejs/kit` | 2.70.3 | en `package.json`; paso 5 | +| `@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 | 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. diff --git a/docs/PLAN_codec_cbor_y_pagina_svelte.md b/docs/PLAN_codec_cbor_y_pagina_svelte.md index 996ca88..3b61c3a 100644 --- a/docs/PLAN_codec_cbor_y_pagina_svelte.md +++ b/docs/PLAN_codec_cbor_y_pagina_svelte.md @@ -309,7 +309,7 @@ Todos en un único cambio normativo, con la justificación §76 de la sección 3 | 2b | Codec propio en Go y baja de fxamacker | cero cambios en `testdata/`; regresión completa de la sección 7; `govulncheck` sin cambios; una noche de fuzzing limpia; commit en Gitea | | 3 | Vectores CBOR, corpus de mutaciones exportado y salidas de `inspect -json` congeladas | Go los genera y los pasa; ficheros congelados | | 4 | En `App`: `sync-testdata`, codec y parsers TypeScript, `inspect.ts` | `vitest` reproduce fixtures, vectores y corpus exportado; cobertura del codec al 100 % | -| 5 | Ruta `/inspect` | abre los fixtures y un `.dkc` arrastrado; la CSP bloquea cualquier otro origen | +| 5 | Ruta `/inspect` | abre los fixtures y un `.dkc` arrastrado; la CSP bloquea cualquier otro origen. `npm run verify`: `svelte-check`, `tsc`, cobertura de `src/lib/dkc` y `src/lib/inspector`, y `build` con `scripts/check-build.mjs` (CSP de cada página prerenderizada, fixtures byte a byte, ningún secreto en el sitio) | | 6 | Fase 2 | `age-encryption` aprobada; un fixture `time_and_key` se descifra en el navegador | --- @@ -341,7 +341,7 @@ Todos en un único cambio normativo, con la justificación §76 de la sección 3 | `vitest` | 5.0.1 | en `package.json`; ya usado en el prototipo | | `@vitest/coverage-v8` | 5.0.1 | en `package.json` (sección 7) | | `@types/node` | 24.13.6 | en `package.json`; tests que leen `testdata/` | -| `svelte`, `@sveltejs/kit`, `@sveltejs/vite-plugin-svelte`, `vite`, `svelte-check` | las del prototipo | paso 5 | -| `@sveltejs/adapter-static` | por fijar | pendiente de aprobación; paso 5, sitio estático | +| `svelte`, `@sveltejs/kit`, `@sveltejs/vite-plugin-svelte`, `vite`, `svelte-check` | 5.57.1, 2.70.3, 7.3.0, 8.3.0, 4.7.6 | en `package.json`; paso 5 | +| `@sveltejs/adapter-static` | 3.0.10 | en `package.json`; paso 5, sitio estático | 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')`). diff --git a/package-lock.json b/package-lock.json index b7810e0..794aa06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,13 @@ "name": "datekeys-app", "version": "0.0.0", "devDependencies": { + "@sveltejs/adapter-static": "3.0.10", + "@sveltejs/kit": "2.70.3", + "@sveltejs/vite-plugin-svelte": "7.3.0", "@types/node": "24.13.6", "@vitest/coverage-v8": "5.0.1", + "svelte": "5.57.1", + "svelte-check": "4.7.6", "typescript": "5.9.3", "vite": "8.3.0", "vitest": "5.0.1" @@ -78,6 +83,28 @@ "node": ">=18" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -116,6 +143,13 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.11", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", @@ -378,6 +412,115 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.70.3", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.3.tgz", + "integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/kit/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz", + "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.0.tgz", + "integrity": "sha512-QbRoJyD92e9R0ufeQIWRHrCC0ObcqSv/aBDdrQMoU+sypav3cDx5wytdQ6GLdXjEMO6xjrXGzfkUygng8JMv0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^1.0.0", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -389,6 +532,13 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -503,6 +653,29 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -525,6 +698,16 @@ "js-tokens": "^10.0.0" } }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -535,6 +718,52 @@ "node": ">=18" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -545,6 +774,13 @@ "node": ">=8" } }, + "node_modules/devalue": { + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", + "dev": true, + "license": "MIT" + }, "node_modules/es-module-lexer": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", @@ -552,6 +788,31 @@ "dev": true, "license": "MIT" }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.13.tgz", + "integrity": "sha512-Dc8aMY0tqJNIN3ahtccEm/UWma05Mt/N7NBGdTMtJ1bR5SyctNEFVaOUfmkzVXe6Psk8MPfiiqUafXIoMbWCsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -605,6 +866,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -612,6 +883,16 @@ "dev": true, "license": "MIT" }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -873,6 +1154,13 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, "node_modules/magic-string": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", @@ -895,6 +1183,26 @@ "source-map-js": "^1.2.1" } }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/nanoid": { "version": "3.3.19", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", @@ -977,6 +1285,20 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/rolldown": { "version": "1.2.11", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", @@ -1011,6 +1333,26 @@ "@rolldown/binding-win32-x64-msvc": "1.2.11" } }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "dev": true, + "license": "MIT" + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1018,6 +1360,21 @@ "dev": true, "license": "ISC" }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1042,6 +1399,68 @@ "dev": true, "license": "MIT" }, + "node_modules/svelte": { + "version": "5.57.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.1.tgz", + "integrity": "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.6.0", + "@sveltejs/acorn-typescript": "^1.0.13", + "@types/estree": "^1.0.9", + "acorn": "^8.18.0", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.9.2", + "esm-env": "^1.2.1", + "esrap": "^2.3.6", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz", + "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.3", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/svelte/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/tinybench": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", @@ -1089,6 +1508,16 @@ "node": ">=14.0.0" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1188,6 +1617,26 @@ } } }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, "node_modules/vitest": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz", @@ -1287,6 +1736,13 @@ "engines": { "node": ">=8" } + }, + "node_modules/zimmerframe": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index 7242671..739320d 100644 --- a/package.json +++ b/package.json @@ -2,21 +2,34 @@ "name": "datekeys-app", "version": "0.0.0", "private": true, - "description": "DateKeys in TypeScript: canonical CBOR codec, DKC1/DKK1 parsers, capsule inspector and, later, browser encryption and decryption.", + "description": "DateKeys in TypeScript: canonical CBOR codec, DKC1/DKK1 parsers, capsule inspector library and its static inspector page; later, browser encryption and decryption.", "type": "module", "engines": { "node": ">=20" }, "scripts": { + "dev": "vite dev", + "build": "vite build", + "postbuild": "node scripts/check-build.mjs", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo \"\"", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --fail-on-warnings", "test": "vitest run", "coverage": "vitest run --coverage", - "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.lib.json", + "typecheck": "svelte-kit sync && tsc --noEmit && tsc --noEmit -p tsconfig.lib.json", + "build:check": "node scripts/check-build.mjs", + "verify": "npm run check && npm run typecheck && npm run coverage && npm run build", "testdata:sync": "node scripts/sync-testdata.mjs sync", "testdata:check": "node scripts/sync-testdata.mjs check --against ../datekeys-go" }, "devDependencies": { + "@sveltejs/adapter-static": "3.0.10", + "@sveltejs/kit": "2.70.3", + "@sveltejs/vite-plugin-svelte": "7.3.0", "@types/node": "24.13.6", "@vitest/coverage-v8": "5.0.1", + "svelte": "5.57.1", + "svelte-check": "4.7.6", "typescript": "5.9.3", "vite": "8.3.0", "vitest": "5.0.1" diff --git a/scripts/check-build.mjs b/scripts/check-build.mjs new file mode 100644 index 0000000..75ddb83 --- /dev/null +++ b/scripts/check-build.mjs @@ -0,0 +1,236 @@ +#!/usr/bin/env node +// Checks the static site in build/ after `npm run build` (plan §8, phase 1). +// Node only, no dependencies. It fails with a list of problems when: +// +// - a route of src/routes has no prerendered HTML page; +// - a page lacks the Content-Security-Policy , or the policy is not +// the one promised (default-src 'self', connect-src 'self', object-src +// 'none', base-uri 'none', form-action 'none', scripts and styles from the +// origin only), allows another origin, a scheme or 'unsafe-*', or comes +// after anything the browser could fetch; +// - an inline script is missing from script-src, or script-src holds a hash +// of no inline script; +// - style-src-attr does not list exactly the hashes of the inline style +// attributes that the client bundle writes (SvelteKit's route announcer), +// with 'unsafe-hashes' and nothing else; +// - a page has an inline style, an event handler attribute or a URL to +// 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. + +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'; + +const ROOT = fileURLToPath(new URL('..', import.meta.url)); +// The site directory: build/, or the first argument. +const BUILD = process.argv[2] === undefined ? join(ROOT, 'build') : resolve(process.argv[2]); +const ROUTES = join(ROOT, 'src', 'routes'); +const FIXTURES = join(ROOT, 'testdata', 'fixtures'); + +const problems = []; +const fail = (msg) => problems.push(msg); +const rel = (p) => relative(ROOT, p).split(sep).join('/'); +const inBuild = (p) => relative(BUILD, p).split(sep).join('/'); +const sha256 = (b) => createHash('sha256').update(b).digest('hex'); + +function walk(dir) { + const out = []; + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name); + if (e.isDirectory()) out.push(...walk(p)); + else out.push(p); + } + return out.sort(); +} + +if (!existsSync(BUILD)) { + console.error(`${BUILD} does not exist: run "npm run build" first.`); + process.exit(1); +} +const files = walk(BUILD); + +// --------------------------------------------------------------------------- +// Every route is prerendered. + +const pages = walk(ROUTES) + .filter((p) => basename(p) === '+page.svelte') + .map((p) => relative(ROUTES, p).split(sep).slice(0, -1).join('/')); +const htmlFiles = pages.map((route) => join(BUILD, route === '' ? 'index.html' : `${route}.html`)); +for (const [i, f] of htmlFiles.entries()) { + if (!existsSync(f)) fail(`route /${pages[i]} has no prerendered page ${rel(f)}`); +} + +// --------------------------------------------------------------------------- +// The Content-Security-Policy of each page. + +const unescapeHtml = (s) => + s.replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); + +const REQUIRED = { + 'default-src': ["'self'"], + 'connect-src': ["'self'"], + 'style-src': ["'self'"], + 'img-src': ["'self'"], + 'font-src': ["'self'"], + 'manifest-src': ["'self'"], + 'frame-src': ["'none'"], + 'worker-src': ["'none'"], + 'object-src': ["'none'"], + 'base-uri': ["'none'"], + 'form-action': ["'none'"], +}; +const HASH = /^'sha256-[A-Za-z0-9+/]{43}='$/; +const b64sha256 = (text) => `'sha256-${createHash('sha256').update(text, 'utf8').digest('base64')}'`; + +// The inline style attributes that the client bundle writes into the DOM +// (Svelte templates are HTML strings in the JavaScript): the only ones the +// policy may allow, by hash, in style-src-attr. +const bundleStyles = new Set(); +for (const f of files.filter((p) => p.endsWith('.js'))) { + for (const [, value] of readFileSync(f, 'utf8').matchAll(/\sstyle="([^"]*)"/g)) bundleStyles.add(b64sha256(value)); +} +if (bundleStyles.size !== 1) fail(`the client bundle writes ${bundleStyles.size} distinct inline style attributes, want 1 (the route announcer)`); + +function checkPage(file) { + const name = rel(file); + const html = readFileSync(file, 'utf8'); + const metas = [...html.matchAll(//gi)]; + if (metas.length !== 1) { + fail(`${name}: ${metas.length} Content-Security-Policy elements, want 1`); + return; + } + const meta = metas[0]; + // Nothing that loads a resource may come before the policy. + const head = html.slice(0, meta.index); + if (/<(script|link|style|img|iframe|object|embed|base)\b/i.test(head)) fail(`${name}: an element that loads resources precedes the CSP `); + + const policy = new Map(); + for (const part of unescapeHtml(meta[1]).split(';')) { + const [directive, ...sources] = part.trim().split(/\s+/); + if (!directive) continue; + if (policy.has(directive)) fail(`${name}: CSP directive ${directive} appears twice`); + policy.set(directive, sources); + } + for (const [directive, want] of Object.entries(REQUIRED)) { + const got = policy.get(directive); + if (got === undefined) fail(`${name}: CSP lacks ${directive}`); + else if (got.join(' ') !== want.join(' ')) fail(`${name}: CSP ${directive} is "${got.join(' ')}", want "${want.join(' ')}"`); + } + const scriptSrc = policy.get('script-src') ?? []; + if (scriptSrc[0] !== "'self'") fail(`${name}: CSP script-src must start with 'self'`); + const hashes = scriptSrc.slice(1); + for (const h of hashes) if (!HASH.test(h)) fail(`${name}: CSP script-src allows ${h}; only 'self' and SHA-256 hashes are allowed`); + const styleAttr = policy.get('style-src-attr') ?? []; + if (styleAttr[0] !== "'unsafe-hashes'") fail(`${name}: CSP style-src-attr must start with 'unsafe-hashes'`); + const attrHashes = styleAttr.slice(1); + for (const h of attrHashes) { + if (!HASH.test(h)) fail(`${name}: CSP style-src-attr allows ${h}; only SHA-256 hashes are allowed`); + else if (!bundleStyles.has(h)) fail(`${name}: style-src-attr hash ${h} matches no inline style of the bundle`); + } + for (const h of bundleStyles) if (!attrHashes.includes(h)) fail(`${name}: the bundle's inline style ${h} is not in style-src-attr`); + const known = new Set([...Object.keys(REQUIRED), 'script-src', 'style-src-attr']); + for (const d of policy.keys()) if (!known.has(d)) fail(`${name}: unexpected CSP directive ${d}`); + + // Every inline script is allowed by its hash, and every hash is used. + const inline = [...html.matchAll(/]*)?>([\s\S]*?)<\/script>/gi)]; + const used = new Set(); + for (const [, attrs = '', body] of inline) { + if (/\ssrc=/i.test(attrs)) { + if (body.trim() !== '') fail(`${name}: a + +
(toggled = e.currentTarget.open)}> + {@render summary()} + {#if open} +
{content}
+ {/if} +
+ + diff --git a/src/lib/components/ExtensionList.svelte b/src/lib/components/ExtensionList.svelte new file mode 100644 index 0000000..750c073 --- /dev/null +++ b/src/lib/components/ExtensionList.svelte @@ -0,0 +1,177 @@ + + +
+

Datos públicos, sin autenticar hasta la apertura

+

+ Cualquiera que tenga el fichero puede leer estas extensiones, y nada las autentica antes de abrir la cápsula: el + paso 15 (header binding) compara la cabecera con la que se selló solo después de la fecha de apertura. No te fíes + de su contenido hasta entonces. +

+
+ +{#if extensions.length === 0} +

La cabecera no trae extensiones.

+{:else} +
    + {#each extensions as e, i (i)} +
  1. +
    +

    {e.id}

    +
    +
    +
    Versión
    +
    {e.version}
    +
    +
    +
    Tipo
    +
    {e.critical ? 'crítica' : 'no crítica'}
    +
    +
    +
    Este lector
    +
    {e.known ? 'la conoce' : 'no la conoce'}
    +
    +
    +
    Datos
    +
    {e.length === 0 ? 'ninguno' : formatByteCount(e.length)}
    +
    +
    + {#if e.critical && !e.known} +

    + Crítica y desconocida para este lector: basta para rechazar la cápsula en el paso 4 + (ERR_EXTENSION_CRITICAL_UNKNOWN). +

    + {/if} + {#if e.idEscaped} +

    + El identificador contiene caracteres invisibles o de control; se muestra entre comillas y escapado, como en + la CLI. +

    + {/if} + {#if e.unusable !== undefined} +

    Este lector no puede usarla: {escapeInvisible(e.unusable)}

    + {/if} + + {#if e.length > 0} + + {#snippet summary()} + Hexadecimal ({formatByteCount(e.length)}) + {/snippet} + + {#if e.text !== undefined} + + {#snippet summary()} + Texto (UTF-8 imprimible) + {/snippet} + + {/if} + {#if e.cbor !== undefined} + + {#snippet summary()} + CBOR decodificado informativo, no validado por el protocolo + {/snippet} + + {/if} + {/if} +
    +
  2. + {/each} +
+{/if} + + diff --git a/src/lib/components/InspectionReport.svelte b/src/lib/components/InspectionReport.svelte new file mode 100644 index 0000000..7566ef9 --- /dev/null +++ b/src/lib/components/InspectionReport.svelte @@ -0,0 +1,545 @@ + + +
+
+
+

{fileName.text}

+

+ {formatByteCount(report.size)}{#if report.readLength < report.size}. Se leyeron los primeros {formatByteCount( + report.readLength, + )}: el resto {report.prelude ? 'de PAYLOAD_AGE' : 'del fichero'} no interviene en los pasos 1 a 8{/if}. +

+
+
+ +

{copyStatus}

+
+
+ +
+
+ {#if report.valid} +

Estructura válida

+

+ Supera los pasos 1 a 8 de §63 sin red y sin secretos. La cabecera pública solo queda autenticada al abrir la + cápsula (paso 15). +

+ {:else if report.failure} +

Rechazada en el paso {report.failure.step}

+

{report.failure.code}

+

{errorGloss(report.failure.code)}

+ {/if} +
+
+

Fecha de apertura

+ {#if report.unlock && unlockMs !== undefined} +

+

+ En tu hora local{timeZone ? ` (${timeZone})` : ''}: {formatDateTime(unlockMs)} +

+

+ Ronda {report.capsule?.round} de {report.profile?.network ?? report.capsule?.network}; {unlockMs <= nowMs + ? `la fecha ya pasó, ${formatRelative(unlockMs, nowMs)}` + : `la fecha todavía no ha llegado: ${formatRelative(unlockMs, nowMs)}`}. +

+ {:else if report.unlock} +

+ {:else} +

+ Sin calcular: sale de la DateKey en el paso 7, {report.steps[6]?.state === 'failed' ? 'que falló' : 'que no llegó a ejecutarse'}. +

+ {/if} +
+
+ +
+

Pasos 1 a 8

+ +
+ +
+
+

Cabecera pública

+ {#if report.capsule} + {@const c = report.capsule} +
+
+
capsule_id
+
{c.capsuleId}
+
+
+
access_policy
+
{c.accessPolicy} {policyGloss(c.accessPolicy)}
+
+
+
DateKey
+
{c.dateKey}
+
+
+
DateKey decodificada
+
{c.dateKeyJSON}
+
+
+
Red (network)
+
{c.network}
+
+
+
Ronda
+
{c.round}
+
+
+
unlock_at
+
{report.unlock?.rfc3339 ?? 'sin calcular'}
+
+
+ {:else} +

PUBLIC_HEADER no llegó a decodificarse.

+ {/if} +
+ +
+

Perfil fijado

+ {#if report.profile} + {@const p = report.profile} +
+
+
profile_id
+
{p.id}
+
+
+
Proveedor y red
+
{p.provider}, {p.network}
+
+
+
Cadena (chain hash)
+
{p.chainHash}
+
+
+
Periodo
+
{p.period} s por ronda
+
+
+
Génesis
+
{p.genesis}
+
+
+
Esquema
+
{p.scheme}
+
+
+ {:else if report.capsule} +

+ La DateKey nombra el perfil {report.capsule.network}, que este lector no tiene fijado. +

+ {:else} +

Se elige con la DateKey de PUBLIC_HEADER, que no llegó a decodificarse.

+ {/if} +
+ +
+

Prelude

+ {#if report.prelude} + {@const p = report.prelude} +
+
+
Bytes
+
{p.hex}
+
+
+
Marca y versión
+
{p.magic}, versión {p.version}
+
+
+
FLAGS y RESERVED
+
{p.flags}, {p.reserved}
+
+
+
PUBLIC_HEADER_LEN
+
{formatByteCount(p.publicHeaderLen)}
+
+
+
SEALED_CONTROL_LEN
+
{formatByteCount(p.sealedControlLen)}
+
+
+
PAYLOAD_AGE
+
+ desde el byte {formatInteger(p.payloadOffset)}{#if p.payloadLength !== undefined}, {formatByteCount( + p.payloadLength, + )}{:else}, fuera del fichero{/if} +
+
+
+ {:else} +

El prelude no se superó.

+ {/if} +
+ +
+

Stanzas age

+
+
+
OUTER_TIME_AGE
+
+ {#if report.outerStanzas} + {report.outerStanzas.length} + {report.outerStanzas.length === 1 ? 'stanza' : 'stanzas'}: {stanzaTypes(report.outerStanzas)} + (se exige exactamente uno, tlock) + {:else} + sin leer + {/if} +
+
+
+
PAYLOAD_AGE
+
+ {#if report.payloadStanzas} + {report.payloadStanzas.length} + {report.payloadStanzas.length === 1 ? 'stanza' : 'stanzas'}: {stanzaTypes(report.payloadStanzas)} + (se exige exactamente uno, X25519) + {:else} + sin leer + {/if} +
+
+
+ +

Stanza tlock frente al perfil fijado

+ {#if report.tlock} +
    + {#each report.tlock as c (c.label)} +
  • +

    + {c.label} + {comparisonState(c)} +

    +

    En el stanza: {c.found ?? 'falta'}

    +

    Esperado: {c.expected}

    +
  • + {/each} +
+ {:else if report.outerStanzas} +

OUTER_TIME_AGE no trae ningún stanza tlock.

+ {:else} +

Se compara cuando se leen la cabecera, el perfil y OUTER_TIME_AGE.

+ {/if} +
+
+ +
+

Extensiones de PUBLIC_HEADER

+

+ Este inspector implementa el protocolo base V1 y no conoce ninguna extensión: una crítica hace fallar el paso 4 y + las no críticas se ignoran. Sus datos son bytes opacos para el protocolo. +

+ {#if report.extensions} + + {:else} +

PUBLIC_HEADER no llegó a decodificarse.

+ {/if} +
+ +
+

Salida de la CLI

+
+ JSON de datekeys inspect -json + {#if shownJSON !== report.json} +

+ Los caracteres invisibles o de control se muestran escapados (\uXXXX, el mismo JSON). + Copiar JSON copia la salida exacta de la CLI. +

+ {/if} +
{shownJSON}
+
+
+
+ + diff --git a/src/lib/components/Mark.svelte b/src/lib/components/Mark.svelte new file mode 100644 index 0000000..9f332ed --- /dev/null +++ b/src/lib/components/Mark.svelte @@ -0,0 +1,29 @@ + + + + + diff --git a/src/lib/components/StepList.svelte b/src/lib/components/StepList.svelte new file mode 100644 index 0000000..33666aa --- /dev/null +++ b/src/lib/components/StepList.svelte @@ -0,0 +1,119 @@ + + +
    + {#each steps as s (s.step)} +
  1. + +
    +

    + Paso {s.step}: + {s.name} + + {#if s.state === 'ok'} + superado + {:else if s.state === 'failed'} + {s.code ?? 'fallo'} + {:else} + no se ejecutó + {/if} + +

    +

    {s.gloss}

    + {#if s.state === 'failed' && s.code !== undefined} +

    {errorGloss(s.code)}

    + {/if} + {#if s.detail !== undefined} +

    {escapeInvisible(s.detail)}

    + {/if} +
    +
  2. + {/each} +
+ + diff --git a/src/lib/dkc/age.ts b/src/lib/dkc/age.ts index 25f5a87..5bb7957 100644 --- a/src/lib/dkc/age.ts +++ b/src/lib/dkc/age.ts @@ -24,7 +24,14 @@ export const STANZA_TLOCK = 'tlock'; export const STANZA_X25519 = 'X25519'; const INTRO = 'age-encryption.org/v1\n'; -const MAX_HEADER_BYTES = 2 << 20; + +/** + * The largest age header, 2 MiB. The parser never looks past it: of the + * bytes after the first MAX_AGE_HEADER_LEN of a file, it only uses the fact + * that there are some. + */ +export const MAX_AGE_HEADER_LEN = 2 << 20; +const MAX_HEADER_BYTES = MAX_AGE_HEADER_LEN; const MAX_RECIPIENT_STANZAS = 1024; const MAX_STANZA_ARGS = 128; const BYTES_PER_LINE = 48; diff --git a/src/lib/inspector/diagnostic.test.ts b/src/lib/inspector/diagnostic.test.ts new file mode 100644 index 0000000..e3397b2 --- /dev/null +++ b/src/lib/inspector/diagnostic.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { walk } from '../dkc/index.ts'; +import { arr, b, map, t, u } from '../dkc/testing/cborhex.ts'; +import { h } from '../dkc/testing/testdata.ts'; +import { cborView, diagnostic, MAX_DIAGNOSTIC_CHARS, quoteText } from './diagnostic.ts'; + +describe('diagnostic', () => { + it('writes every item of the profile', () => { + expect(diagnostic(walk(h(u(0)))).text).toBe('0'); + expect(diagnostic(walk(h(u(2 ** 53 - 1)))).text).toBe('9007199254740991'); + expect(diagnostic(walk(h(b('00ff')))).text).toBe("h'00ff'"); + expect(diagnostic(walk(h(b('')))).text).toBe("h''"); + expect(diagnostic(walk(h(t('sealed')))).text).toBe('"sealed"'); + expect(diagnostic(walk(h(arr()))).text).toBe('[]'); + expect(diagnostic(walk(h(map()))).text).toBe('{}'); + expect(diagnostic(walk(h(map([0, u(7)], [1, t('sealed')])))).text).toBe('{\n 0: 7,\n 1: "sealed"\n}'); + expect(diagnostic(walk(h(arr(u(1), arr(u(2), map([5, b('ab')])), t('x'))))).text).toBe( + '[\n 1,\n [\n 2,\n {\n 5: h\'ab\'\n }\n ],\n "x"\n]', + ); + }); + + it('keeps map entries in order, whatever the key', () => { + // Keys that would be special as JavaScript object keys stay plain text. + const d = diagnostic({ kind: 'map', entries: [{ key: 0, value: { kind: 'text', value: '__proto__' } }] }); + expect(d.text).toBe('{\n 0: "__proto__"\n}'); + }); + + it('escapes text as JSON, with every non-printable character as \\u', () => { + expect(quoteText('a"b\\c')).toBe('"a\\"b\\\\c"'); + expect(quoteText('\t\n\r')).toBe('"\\t\\n\\r"'); + expect(quoteText('\u0000\u001f\u007f\u0085')).toBe('"\\u0000\\u001f\\u007f\\u0085"'); + expect(quoteText('ok \u202e \u200d \ufeff')).toBe('"ok \\u202e \\u200d \\ufeff"'); + expect(quoteText('ñ ✓ 😀')).toBe('"ñ ✓ 😀"'); + expect(quoteText('\u{e0001}')).toBe('"\\udb40\\udc01"'); + }); + + it('cuts long output at the limit', () => { + const long = h(arr(...Array.from({ length: 200 }, (_, i) => u(i)))); + const full = diagnostic(walk(long)); + expect(full.truncated).toBe(false); + const cut = diagnostic(walk(long), 50); + expect(cut).toEqual({ text: full.text.slice(0, 50), truncated: true }); + expect(diagnostic(walk(h(t('x'.repeat(100)))), 10)).toEqual({ text: '"xxxxxxxxx', truncated: true }); + expect(MAX_DIAGNOSTIC_CHARS).toBe(16384); + }); + + it('never cuts a surrogate pair in half', () => { + const emoji = walk(h(t('😀'.repeat(10)))); + expect(diagnostic(emoji, 4)).toEqual({ text: '"😀', truncated: true }); + expect(diagnostic(emoji, 5)).toEqual({ text: '"😀😀', truncated: true }); + expect(diagnostic(emoji, 1)).toEqual({ text: '"', truncated: true }); + const cut = diagnostic(walk(h(arr(t('a😀'), t('😀')))), 12); + expect(cut.truncated).toBe(true); + expect(cut.text.isWellFormed()).toBe(true); + }); +}); + +describe('cborView', () => { + it('decodes data that is one item of the §58 profile', () => { + expect(cborView(h('a2000701667365616c6564'))).toEqual({ text: '{\n 0: 7,\n 1: "sealed"\n}', truncated: false }); + expect(cborView(h('05'))).toEqual({ text: '5', truncated: false }); + }); + + it('shows nothing for bytes outside the profile', () => { + // "public label": a text header whose length runs past the end. + expect(cborView(new TextEncoder().encode('public label'))).toBeUndefined(); + for (const x of ['f5', 'f6', '20', 'c101', '9f01ff', 'a1616100', 'a201000001', '0100', '1817', '61ff']) { + expect(cborView(h(x)), x).toBeUndefined(); + } + }); + + it('bounds the nesting depth', () => { + const nest = (n: number): string => (n === 0 ? u(0) : arr(nest(n - 1))); + expect(cborView(h(nest(16)))).toBeDefined(); + expect(cborView(h(nest(17)))).toBeUndefined(); + }); +}); diff --git a/src/lib/inspector/diagnostic.ts b/src/lib/inspector/diagnostic.ts new file mode 100644 index 0000000..63cf0e9 --- /dev/null +++ b/src/lib/inspector/diagnostic.ts @@ -0,0 +1,126 @@ +// Informative CBOR diagnostic notation (RFC 8949 §8) of extension data. +// +// The base protocol never decodes extension data (spec §54): it is opaque +// bytes. When the bytes happen to be one item of the §58 profile, the page +// shows them decoded with the library's bounded reader `walk`, labelled as +// informative and not validated by the protocol. Nothing here decides the +// validity of anything, and map entries stay an ordered list: no JavaScript +// object or Map is ever keyed by input. + +import { DateKeysError, goIsPrintNonASCII, type Item, MAX_NESTED_LEVELS, toHex, walk } from '../dkc/index.ts'; + +/** Largest diagnostic text shown, in UTF-16 code units. */ +export const MAX_DIAGNOSTIC_CHARS = 16_384; + +/** A diagnostic text, cut at the limit when `truncated`. */ +export interface Diagnostic { + readonly text: string; + readonly truncated: boolean; +} + +class Budget extends Error {} + +/** + * Writes `item` in diagnostic notation: unsigned integers in decimal, byte + * strings as h'…', text strings JSON-quoted with every non-printable + * character escaped, and non-empty containers one entry per line with + * two-space indentation. + */ +export function diagnostic(item: Item, maxChars: number = MAX_DIAGNOSTIC_CHARS): Diagnostic { + const parts: string[] = []; + let length = 0; + const put = (s: string): void => { + if (length + s.length > maxChars) { + // Never cut between the two halves of a surrogate pair. + let end = maxChars - length; + if (end > 0 && isHighSurrogate(s.charCodeAt(end - 1))) end--; + parts.push(s.slice(0, end)); + length = maxChars; + throw new Budget(); + } + parts.push(s); + length += s.length; + }; + const write = (it: Item, indent: string): void => { + switch (it.kind) { + case 'uint': + put(String(it.value)); + return; + case 'bytes': + put(`h'${toHex(it.value)}'`); + return; + case 'text': + put(quoteText(it.value)); + return; + case 'array': { + if (it.items.length === 0) return put('[]'); + const inner = `${indent} `; + put('['); + it.items.forEach((child, i) => { + put(`${i === 0 ? '' : ','}\n${inner}`); + write(child, inner); + }); + put(`\n${indent}]`); + return; + } + case 'map': { + if (it.entries.length === 0) return put('{}'); + const inner = `${indent} `; + put('{'); + it.entries.forEach((entry, i) => { + put(`${i === 0 ? '' : ','}\n${inner}${entry.key}: `); + write(entry.value, inner); + }); + put(`\n${indent}}`); + return; + } + } + }; + try { + write(item, ''); + return { text: parts.join(''), truncated: false }; + } catch (err) { + /* v8 ignore next -- @preserve */ + if (!(err instanceof Budget)) throw err; + return { text: parts.join(''), truncated: true }; + } +} + +const isHighSurrogate = (c: number): boolean => c >= 0xd800 && c <= 0xdbff; + +/** + * A text string as in JSON: quotes and backslashes escaped, tab, line feed + * and carriage return by name, every other character that Go's + * strconv.IsPrint rejects as \uXXXX (a surrogate pair above U+FFFF). + */ +export function quoteText(s: string): string { + let out = '"'; + for (const ch of s) { + const r = ch.codePointAt(0)!; + if (r === 0x22) out += '\\"'; + else if (r === 0x5c) out += '\\\\'; + else if (r === 0x09) out += '\\t'; + else if (r === 0x0a) out += '\\n'; + else if (r === 0x0d) out += '\\r'; + else if (r < 0x80 ? r >= 0x20 && r < 0x7f : goIsPrintNonASCII(r)) out += ch; + else for (let i = 0; i < ch.length; i++) out += `\\u${ch.charCodeAt(i).toString(16).padStart(4, '0')}`; + } + return `${out}"`; +} + +/** + * The informative decoded view of extension data: its diagnostic notation + * when the bytes are exactly one item of the §58 profile with at most + * MAX_NESTED_LEVELS nested containers, undefined otherwise. + */ +export function cborView(data: Uint8Array, maxChars: number = MAX_DIAGNOSTIC_CHARS): Diagnostic | undefined { + let item: Item; + try { + item = walk(data, MAX_NESTED_LEVELS, data.length); + } catch (err) { + /* v8 ignore next -- @preserve */ + if (!(err instanceof DateKeysError)) throw err; + return undefined; + } + return diagnostic(item, maxChars); +} diff --git a/src/lib/inspector/fixtures.test.ts b/src/lib/inspector/fixtures.test.ts new file mode 100644 index 0000000..a1b0b58 --- /dev/null +++ b/src/lib/inspector/fixtures.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { equalBytes } from '../dkc/index.ts'; +import { FIXTURES, fetchFixture, fixtureList } from './fixtures.ts'; +import { listTestdata, readBytes, readJSON } from '../dkc/testing/testdata.ts'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('fixtures', () => { + it('ships every official .dkc of testdata/fixtures and nothing else', () => { + const names = listTestdata('fixtures', '.dkc').map((p) => p.slice('fixtures/'.length)); + expect(names.length).toBeGreaterThanOrEqual(5); + expect(FIXTURES.map((f) => f.name)).toEqual(names); + for (const f of FIXTURES) { + expect(f.url).toMatch(/\.dkc(\?|$)/); + expect(f.url).not.toMatch(/^data:/); + } + }); + + it('takes the description of the JSON record of each capsule', () => { + for (const f of FIXTURES) { + const record = readJSON<{ description: string }>(`fixtures/${f.name.replace(/\.dkc$/, '.json')}`); + expect(f.description).toBe(record.description); + } + }); + + it('pairs names, sorts them and ignores descriptions that are missing or not text', () => { + const list = fixtureList( + { '/t/b.dkc': '/u/b', '/t/a.dkc': '/u/a', '/t/c.dkc': '/u/c', '/t/__proto__.dkc': '/u/p' }, + { '/t/a.json': 'first', '/t/b.json': 42, '/t/c.dkk.json': 'not mine', '/t/__proto__.json': '' }, + ); + expect(list).toEqual([ + { name: '__proto__.dkc', url: '/u/p', description: undefined }, + { name: 'a.dkc', url: '/u/a', description: 'first' }, + { name: 'b.dkc', url: '/u/b', description: undefined }, + { name: 'c.dkc', url: '/u/c', description: undefined }, + ]); + }); +}); + +describe('fetchFixture', () => { + it('fetches the bytes from the URL of the fixture', async () => { + const f = FIXTURES.find((x) => x.name === 'time_only_extensions.dkc')!; + const bytes = readBytes('fixtures/time_only_extensions.dkc'); + const fetch = vi.fn(async () => new Response(bytes as Uint8Array)); + vi.stubGlobal('fetch', fetch); + expect(equalBytes(await fetchFixture(f), bytes)).toBe(true); + expect(fetch).toHaveBeenCalledWith(f.url); + }); + + it('fails on an HTTP error, naming the fixture', async () => { + vi.stubGlobal('fetch', async () => new Response('', { status: 404 })); + await expect(fetchFixture({ name: 'x.dkc', url: '/x.dkc', description: undefined })).rejects.toThrow('x.dkc: HTTP 404'); + }); +}); diff --git a/src/lib/inspector/fixtures.ts b/src/lib/inspector/fixtures.ts new file mode 100644 index 0000000..c4900aa --- /dev/null +++ b/src/lib/inspector/fixtures.ts @@ -0,0 +1,52 @@ +/// +// The official fixtures shipped with the site. Vite bundles them straight +// from testdata/fixtures at build time, so testdata/ stays the single source +// of truth: every .dkc becomes a hashed same-origin file (vite.config.ts never +// inlines assets), and only the `description` field of its JSON record is +// imported. Neither the .dkk access keys, nor the plaintexts, nor the rest of +// the JSON records (payload_identity, control_cbor, …) reach the bundle. + +const urls = import.meta.glob('/testdata/fixtures/*.dkc', { query: '?url', import: 'default', eager: true }); +const descriptions = import.meta.glob(['/testdata/fixtures/*.json', '!/testdata/fixtures/*.dkk.json'], { + import: 'description', + eager: true, +}); + +/** One official fixture capsule. */ +export interface Fixture { + /** The file name, for example "time_only.dkc". */ + readonly name: string; + /** The same-origin URL of its bytes. */ + readonly url: string; + /** The description of its JSON record (in English, from the Go reference). */ + readonly description: string | undefined; +} + +/** + * Pairs each .dkc with the description of the JSON record of the same name, + * sorted by file name. Both arguments come from import.meta.glob, keyed by + * build-time paths, never by input. + */ +export function fixtureList(dkcUrls: Readonly>, jsonDescriptions: Readonly>): Fixture[] { + return Object.keys(dkcUrls) + .sort() + .map((path) => { + const json = path.replace(/\.dkc$/, '.json'); + const d = Object.hasOwn(jsonDescriptions, json) ? jsonDescriptions[json] : undefined; + return { + name: path.slice(path.lastIndexOf('/') + 1), + url: dkcUrls[path]!, + description: typeof d === 'string' && d !== '' ? d : undefined, + }; + }); +} + +/** The official fixtures, sorted by file name. */ +export const FIXTURES: readonly Fixture[] = fixtureList(urls, descriptions); + +/** Fetches the bytes of a fixture from the site's own origin. */ +export async function fetchFixture(f: Fixture): Promise { + const res = await fetch(f.url); + if (!res.ok) throw new Error(`${f.name}: HTTP ${res.status}`); + return new Uint8Array(await res.arrayBuffer()); +} diff --git a/src/lib/inspector/format.test.ts b/src/lib/inspector/format.test.ts new file mode 100644 index 0000000..488a5c1 --- /dev/null +++ b/src/lib/inspector/format.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; +import { ERROR_CODES, type InspectView } from '../dkc/index.ts'; +import { + cliJSON, + displayText, + errorGloss, + escapeInvisible, + formatByteCount, + formatDateTime, + formatInteger, + formatRelative, + INSPECT_STEPS, + instantToEpochMs, + policyGloss, + printableText, + stepGloss, + stepName, + viewerTimeZone, +} from './format.ts'; + +const te = new TextEncoder(); + +describe('steps and codes', () => { + it('names the eight steps as the CLI and explains each one', () => { + expect(INSPECT_STEPS.map(stepName)).toEqual([ + 'parse DKC1', + 'prelude', + 'public header', + 'header validation', + 'sealed control structure', + 'payload structure', + 'condition', + 'tlock stanza', + ]); + for (const s of INSPECT_STEPS) expect(stepGloss(s)).toMatch(/^\p{Lu}.*\.$/u); + expect(stepName(9)).toBe('step 9'); + expect(stepGloss(9)).toBe(''); + }); + + it('explains every normative code in Spanish', () => { + const glosses = ERROR_CODES.map(errorGloss); + for (const g of glosses) expect(g).toMatch(/^\p{Lu}.*\.$/u); + expect(new Set(glosses).size).toBe(ERROR_CODES.length); + }); + + it('explains the access policies', () => { + expect(policyGloss('time_only')).toMatch(/^Solo tiempo/); + expect(policyGloss('time_and_key')).toMatch(/\.dkk/); + expect(policyGloss('policy(7)')).toBe('Política no definida en V1.'); + }); +}); + +describe('text', () => { + it('shows printable UTF-8 as text', () => { + expect(printableText(te.encode('public label'))).toBe('public label'); + expect(printableText(te.encode('línea 1\n\tlínea 2 — ok ✓'))).toBe('línea 1\n\tlínea 2 — ok ✓'); + }); + + it('refuses invalid UTF-8 and every invisible or control character', () => { + expect(printableText(new Uint8Array([0xa2, 0x00]))).toBeUndefined(); + expect(printableText(new Uint8Array([0xc0, 0x80]))).toBeUndefined(); + for (const s of ['a\rb', 'a\u0000b', 'a\u007fb', 'a\u0085b', 'a\u00a0b', 'abc\u202edcba', 'a\u200db', '\ufeffabc', 'a\u2028b', 'a\ue000b']) { + expect(printableText(te.encode(s)), JSON.stringify(s)).toBeUndefined(); + } + }); + + it('escapes identifiers that hold non-printable characters', () => { + expect(displayText('org.example.label')).toEqual({ text: 'org.example.label', escaped: false }); + expect(displayText('ñandú.example')).toEqual({ text: 'ñandú.example', escaped: false }); + expect(displayText('org.example\u202e.label')).toEqual({ text: '"org.example\\u202e.label"', escaped: true }); + expect(displayText('a\u200bb')).toEqual({ text: '"a\\u200bb"', escaped: true }); + expect(displayText('tab\there')).toEqual({ text: '"tab\\there"', escaped: true }); + }); +}); + +describe('escapeInvisible', () => { + const c = (...codes: number[]): string => String.fromCodePoint(...codes); + const bs = c(0x5c); + + it('leaves printable text, line feeds and tabs alone', () => { + const s = 'capsule: profile "x" is not pinned\n\tnote: ñ ✓ 😀'; + expect(escapeInvisible(s)).toBe(s); + }); + + it('writes bidi overrides, format and control characters as JSON escapes', () => { + expect(escapeInvisible(`extension org.example${c(0x202e)}.must v1`)).toBe(`extension org.example${bs}u202e.must v1`); + expect(escapeInvisible(`a${c(0x200d)}b${c(0xfeff)}${c(0x0d)}${c(0x85)}${c(0xa0)}`)).toBe( + `a${bs}u200db${bs}ufeff${bs}u000d${bs}u0085${bs}u00a0`, + ); + expect(escapeInvisible(c(0xe0001))).toBe(`${bs}udb40${bs}udc01`); + }); + + it('keeps the CLI JSON valid and equivalent', () => { + const view = { file: `x${c(0x202e)}.dkc`, valid: true, checks: [] } as unknown as InspectView; + const shown = escapeInvisible(cliJSON(view)); + expect(shown).not.toBe(cliJSON(view)); + expect(JSON.parse(shown)).toEqual(view); + }); +}); + +describe('numbers and dates', () => { + it('formats integers and byte counts in Spanish', () => { + expect(formatInteger(891)).toBe('891'); + expect(formatInteger(78799)).toBe('78.799'); + expect(formatInteger(1048576)).toBe('1.048.576'); + expect(formatByteCount(1)).toBe('1 byte'); + expect(formatByteCount(0)).toBe('0 bytes'); + expect(formatByteCount(1234)).toBe('1.234 bytes'); + }); + + it('converts instants to milliseconds within the range of Date', () => { + expect(instantToEpochMs({ seconds: 1692809364, nanos: 0 })).toBe(1692809364000); + expect(instantToEpochMs({ seconds: 1, nanos: 999_999_999 })).toBe(1999); + expect(instantToEpochMs({ seconds: 253402300799, nanos: 0 })).toBe(253402300799000); + expect(instantToEpochMs({ seconds: 8.64e12 + 1, nanos: 0 })).toBeUndefined(); + }); + + it('writes long dates in UTC and in a given zone', () => { + const t = Date.UTC(2023, 7, 23, 16, 49, 24); + // The joiner of date and time depends on the ICU version: ", " or " a las ". + expect(formatDateTime(t, 'UTC')).toMatch(/^23 de agosto de 2023(, | a las )16:49:24 UTC$/); + expect(formatDateTime(t, 'Europe/Madrid')).toMatch(/^23 de agosto de 2023(, | a las )18:49:24 CEST$/); + expect(formatDateTime(Date.UTC(2023, 0, 5, 1, 2, 3), 'Europe/Madrid')).toMatch(/^5 de enero de 2023(, | a las )02:02:03 CET$/); + expect(formatDateTime(t)).toMatch(/^23 de agosto de 2023(, | a las )\d\d:\d\d:24 /); + expect(typeof viewerTimeZone()).toBe('string'); + }); + + it('says how far a date is from now', () => { + const now = Date.UTC(2026, 8, 25, 12, 0, 0); + expect(formatRelative(Date.UTC(2023, 7, 23), now)).toBe('hace 3 años'); + expect(formatRelative(Date.UTC(2027, 0, 1), now)).toBe('dentro de 3 meses'); + expect(formatRelative(now + 86400_000, now)).toBe('mañana'); + expect(formatRelative(now - 2 * 3600_000, now)).toBe('hace 2 horas'); + expect(formatRelative(now + 30_000, now)).toBe('dentro de 30 segundos'); + expect(formatRelative(now + 400, now)).toBe('ahora'); + }); +}); + +describe('cliJSON', () => { + const view = { + file: 'x.dkc', + capsule_id: '00', + valid: false, + error: 'ERR_EXTENSION_CRITICAL_UNKNOWN', + checks: [ + { step: 1, name: 'parse DKC1', ok: true, detail: 'magic DKC1' }, + { step: 4, name: 'header validation', ok: false, detail: 'extension &b\u2028\u2029 "q" \\ \n\t\b\f\u0001: X', error: 'X' }, + ], + } as InspectView; + + it('writes the JSON of the Go encoder with two-space indentation and a final newline', () => { + const out = cliJSON(view); + expect(out.endsWith('}\n')).toBe(true); + expect(out.split('\n').slice(0, 4)).toEqual(['{', ' "file": "x.dkc",', ' "capsule_id": "00",', ' "valid": false,']); + expect(out).toContain('\n "checks": [\n {\n "step": 1,\n'); + expect(JSON.parse(out)).toEqual(view); + }); + + it('escapes HTML characters and line separators as Go does', () => { + const out = cliJSON(view); + expect(out).toContain( + '"detail": "extension \\u003ca\\u003e\\u0026b\\u2028\\u2029 \\"q\\" \\\\ \\n\\t\\b\\f\\u0001: X"', + ); + expect(out).not.toMatch(/[<>&\u2028\u2029]/); + }); +}); diff --git a/src/lib/inspector/format.ts b/src/lib/inspector/format.ts new file mode 100644 index 0000000..bb09ef7 --- /dev/null +++ b/src/lib/inspector/format.ts @@ -0,0 +1,256 @@ +// Formatting helpers of the inspector page. They never build objects or Maps +// keyed by input: every lookup below is a switch over a closed set of values +// that the library produces (step numbers, normative codes, policies). + +import { decodeUtf8, type ErrorCode, goIsPrintNonASCII, goQuote, type Instant, type InspectView } from '../dkc/index.ts'; + +/** The eight steps of spec §63 run before unlock, in order. */ +export const INSPECT_STEPS = [1, 2, 3, 4, 5, 6, 7, 8] as const; + +/** The CLI name of each step (the Go CheckResult name). */ +export function stepName(step: number): string { + switch (step) { + case 1: + return 'parse DKC1'; + case 2: + return 'prelude'; + case 3: + return 'public header'; + case 4: + return 'header validation'; + case 5: + return 'sealed control structure'; + case 6: + return 'payload structure'; + case 7: + return 'condition'; + case 8: + return 'tlock stanza'; + default: + return `step ${step}`; + } +} + +/** What each step checks, in Spanish. */ +export function stepGloss(step: number): string { + switch (step) { + case 1: + return 'El fichero empieza por la marca DKC1 y trae un prelude completo.'; + case 2: + return 'Versión de trama 1, FLAGS y RESERVED a cero y longitudes dentro de los límites de §57.'; + case 3: + return 'Se leen los bytes exactos de PUBLIC_HEADER.'; + case 4: + return 'CBOR canónico, DateKey canónica, perfil fijado en este lector y ninguna extensión crítica desconocida.'; + case 5: + return 'OUTER_TIME_AGE, dentro de SEALED_CONTROL, tiene exactamente un stanza tlock.'; + case 6: + return 'La cabecera age de PAYLOAD_AGE tiene exactamente un stanza X25519.'; + case 7: + return 'La ronda de la DateKey se convierte en fecha de apertura con el perfil fijado, sin red.'; + case 8: + return 'El stanza tlock nombra la ronda de la DateKey y la cadena del perfil fijado.'; + default: + return ''; + } +} + +/** What a normative code of spec §69 means, in Spanish. */ +export function errorGloss(code: ErrorCode): string { + switch (code) { + case 'ERR_INVALID_MAGIC': + return 'El fichero no empieza por DKC1: no es una cápsula DateKeys.'; + case 'ERR_UNSUPPORTED_VERSION': + return 'La versión de la trama o del esquema no es una que este lector implemente.'; + case 'ERR_INVALID_FLAGS': + return 'FLAGS o RESERVED del prelude no valen cero.'; + case 'ERR_NON_CANONICAL_CBOR': + return 'El CBOR no está en la forma canónica del perfil del protocolo (§58) o no sigue su esquema.'; + case 'ERR_UNKNOWN_PROFILE': + return 'La DateKey nombra un perfil que este lector no tiene fijado.'; + case 'ERR_PROFILE_MISMATCH': + return 'El stanza tlock usa una cadena distinta de la del perfil fijado.'; + case 'ERR_DATEKEY_INVALID': + return 'La DateKey no es válida para su perfil.'; + case 'ERR_DATEKEY_NON_CANONICAL': + return 'La DateKey no está en su forma canónica dk1_.'; + case 'ERR_ROUND_MISMATCH': + return 'La ronda del stanza tlock no es la de la DateKey.'; + case 'ERR_RELEASE_UNAVAILABLE': + return 'La firma de la ronda todavía no se puede obtener.'; + case 'ERR_RELEASE_INVALID': + return 'La firma de la ronda no verifica con el perfil fijado.'; + case 'ERR_ACCESS_REQUIRED': + return 'La política exige una clave de acceso .dkk.'; + case 'ERR_ACCESS_INVALID': + return 'La clave de acceso no abre esta cápsula.'; + case 'ERR_POLICY_STRUCTURE_MISMATCH': + return 'El número o el tipo de stanzas age no es el que exige el protocolo.'; + case 'ERR_HEADER_BINDING': + return 'La cabecera pública no es la que se selló.'; + case 'ERR_INTEGRITY': + return 'La trama o una cabecera age está truncada, mal formada o fuera de límites.'; + case 'ERR_EXTENSION_CRITICAL_UNKNOWN': + return 'La cápsula exige una extensión crítica que este lector no conoce, así que la rechaza.'; + case 'ERR_EXTENSION_DATA_INVALID': + return 'Una extensión conocida trae datos que no siguen su esquema registrado.'; + } +} + +/** The access policy of the CLI view, explained in Spanish. */ +export function policyGloss(policy: string): string { + switch (policy) { + case 'time_only': + return 'Solo tiempo: se abre con la firma pública de la ronda.'; + case 'time_and_key': + return 'Tiempo y clave: además de la ronda hace falta una clave de acceso .dkk.'; + default: + return 'Política no definida en V1.'; + } +} + +// --------------------------------------------------------------------------- +// Text + +/** + * Printable as Go's strconv.IsPrint (letters, marks, numbers, punctuation, + * symbols and the ASCII space), with the table the library pins, so that the + * answer does not depend on the Unicode version of the browser. Format + * characters such as bidi overrides or zero-width joiners are not printable. + */ +function isPrintableRune(r: number): boolean { + return r < 0x80 ? r >= 0x20 && r < 0x7f : goIsPrintNonASCII(r); +} + +/** + * The text of `b` when it is valid UTF-8 made only of printable characters, + * tabs and line feeds; undefined otherwise. A leading BOM is kept and makes + * the bytes not printable. + */ +export function printableText(b: Uint8Array): string | undefined { + const s = decodeUtf8(b); + if (s === undefined) return undefined; + for (const ch of s) { + const r = ch.codePointAt(0)!; + if (r !== 0x09 && r !== 0x0a && !isPrintableRune(r)) return undefined; + } + return s; +} + +/** + * How to show a string read from the capsule, such as an extension_id: as it + * is when every character is printable, otherwise quoted and escaped as the + * CLI does (Go's %q), so that no invisible or reordering character can make + * one identifier look like another. + */ +export function displayText(s: string): { text: string; escaped: boolean } { + for (const ch of s) { + if (!isPrintableRune(ch.codePointAt(0)!)) return { text: goQuote(s), escaped: true }; + } + return { text: s, escaped: false }; +} + +/** + * A text for display in which every character that is not printable, except + * line feeds and tabs, is written as a JSON escape (\uXXXX, a surrogate pair + * above U+FFFF). The library's messages quote most input with Go's %q, but + * some carry it raw (an extension_id in "extension v1"), and a bidi + * override there would reorder the rest of the line on screen. The escapes + * keep JSON valid and equivalent, so the CLI JSON can be shown this way too; + * copies always take the exact text. + */ +export function escapeInvisible(s: string): string { + let out = ''; + for (const ch of s) { + const r = ch.codePointAt(0)!; + if (r === 0x09 || r === 0x0a || isPrintableRune(r)) out += ch; + else for (let i = 0; i < ch.length; i++) out += `\\u${ch.charCodeAt(i).toString(16).padStart(4, '0')}`; + } + return out; +} + +// --------------------------------------------------------------------------- +// Numbers and dates + +const integer = new Intl.NumberFormat('es-ES', { maximumFractionDigits: 0, useGrouping: 'always' }); + +/** An integer in Spanish notation, always grouped: 78.799, 1.048.576. */ +export function formatInteger(n: number): string { + return integer.format(n); +} + +/** "1 byte", "78.799 bytes". */ +export function formatByteCount(n: number): string { + return n === 1 ? '1 byte' : `${formatInteger(n)} bytes`; +} + +/** Milliseconds since the Unix epoch of an instant, or undefined past the range of Date. */ +export function instantToEpochMs(t: Instant): number | undefined { + const ms = t.seconds * 1000 + Math.floor(t.nanos / 1e6); + return Math.abs(ms) <= 8.64e15 ? ms : undefined; +} + +/** + * A long Spanish date and time, for example "23 de agosto de 2023, 16:49:24 + * UTC". `timeZone` undefined means the viewer's zone, whose name is shown. + */ +export function formatDateTime(epochMs: number, timeZone?: string): string { + const opts: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + timeZoneName: 'short', + }; + if (timeZone !== undefined) opts.timeZone = timeZone; + return new Intl.DateTimeFormat('es-ES', opts).format(epochMs); +} + +/** The viewer's time zone, for example "Europe/Madrid", or undefined. */ +export function viewerTimeZone(): string | undefined { + return new Intl.DateTimeFormat().resolvedOptions().timeZone || undefined; +} + +const UNITS: readonly [Intl.RelativeTimeFormatUnit, number][] = [ + ['year', 365.2425 * 86400_000], + ['month', 30.436875 * 86400_000], + ['day', 86400_000], + ['hour', 3600_000], + ['minute', 60_000], + ['second', 1000], +]; + +/** + * How far `epochMs` is from `nowMs`, in the largest whole unit, in Spanish: + * "hace 3 años", "dentro de 2 meses", "ahora". + */ +export function formatRelative(epochMs: number, nowMs: number): string { + const diff = epochMs - nowMs; + const rtf = new Intl.RelativeTimeFormat('es-ES', { numeric: 'auto' }); + for (const [unit, ms] of UNITS) { + const n = Math.trunc(diff / ms); + if (n !== 0) return rtf.format(n, unit); + } + return rtf.format(0, 'second'); +} + +// --------------------------------------------------------------------------- +// JSON of the CLI + +/** + * The exact output of `datekeys inspect -json`: Go's json.Encoder with + * SetIndent("", " "), which escapes <, > and & (SetEscapeHTML is on by + * default) and U+2028 and U+2029, and ends with a newline. Keys and non-string + * values never hold those characters, so escaping the whole text only touches + * strings. JSON.stringify writes every other escape as Go 1.22 and later do. + */ +export function cliJSON(view: InspectView): string { + const text = JSON.stringify(view, null, 2).replace( + /[<>&\u2028\u2029]/g, + (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`, + ); + return `${text}\n`; +} diff --git a/src/lib/inspector/load.test.ts b/src/lib/inspector/load.test.ts new file mode 100644 index 0000000..4d2b3e6 --- /dev/null +++ b/src/lib/inspector/load.test.ts @@ -0,0 +1,138 @@ +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'; + +const registry = await defaultRegistry(); +const timeOnly = readBytes('fixtures/time_only.dkc'); +const parts = split(timeOnly); +const payloadStart = 16 + parts.header.length + parts.sealed.length; +const te = new TextEncoder(); + +// Everything the page shows of an inspection. +function summary(r: Inspection): unknown { + return { view: inspectView(r), outer: r.outerStanzas, payload: r.payloadStanzas, offset: r.payloadOffset, unusable: r.unusableExtensions }; +} + +function concat(...xs: Uint8Array[]): Uint8Array { + const out = new Uint8Array(xs.reduce((n, x) => n + x.length, 0)); + let o = 0; + for (const x of xs) { + out.set(x, o); + o += x.length; + } + return out; +} + +// Reads `dkc` as the page does and checks that the prefix inspects exactly as +// the whole file. Returns the number of bytes read. +async function samePrefixVerdict(dkc: Uint8Array): Promise { + const { bytes, size } = await readCapsule(new Blob([dkc as Uint8Array])); + expect(size).toBe(dkc.length); + expect(equalBytes(bytes, dkc.subarray(0, bytes.length))).toBe(true); + expect(summary(inspectWith(bytes, registry))).toEqual(summary(inspectWith(dkc, registry))); + return bytes.length; +} + +// A DKC1 prelude with the given fields. +function preludeWith(f: { version?: number; flags?: number; reserved?: number; ph: number; sc: number }): Uint8Array { + const b = new Uint8Array(16); + b.set(te.encode('DKC1')); + b[4] = f.version ?? 1; + b[5] = f.flags ?? 0; + b[7] = f.reserved ?? 0; + const v = new DataView(b.buffer); + v.setUint32(8, f.ph); + v.setUint32(12, f.sc); + return b; +} + +// Preludes that steps 1 and 2 reject on their 16 bytes. +const rejected: [string, Uint8Array][] = [ + ['no DKC1 magic', new Uint8Array(16).fill(0xff)], + ['the magic of a .dkk', readBytes('fixtures/time_and_key_portable.dkk').subarray(0, 16)], + ['framing version 2', preludeWith({ version: 2, ph: 10, sc: 10 })], + ['FLAGS set', preludeWith({ flags: 1, ph: 10, sc: 10 })], + ['RESERVED set', preludeWith({ reserved: 1, ph: 10, sc: 10 })], + ['PUBLIC_HEADER_LEN 0', preludeWith({ ph: 0, sc: 10 })], + ['SEALED_CONTROL_LEN 0', preludeWith({ ph: 10, sc: 0 })], + ['PUBLIC_HEADER_LEN above 1 MiB', preludeWith({ ph: (1 << 20) + 1, sc: 10 })], + ['SEALED_CONTROL_LEN above 64 MiB', preludeWith({ ph: 10, sc: (64 << 20) + 1 })], + ['both lengths 0xffffffff', preludeWith({ ph: 0xffffffff, sc: 0xffffffff })], +]; + +describe('inspectedLength', () => { + it('stops one byte after the largest age header of PAYLOAD_AGE', () => { + expect(inspectedLength(10 ** 9, timeOnly.subarray(0, 16))).toBe(payloadStart + MAX_AGE_HEADER_LEN + 1); + expect(inspectedLength(timeOnly.length, timeOnly.subarray(0, 16))).toBe(timeOnly.length); + }); + + it('reads short files whole', () => { + expect(inspectedLength(7, timeOnly.subarray(0, 7))).toBe(7); + expect(inspectedLength(0, new Uint8Array(0))).toBe(0); + }); + + it('reads 16 bytes of a prelude that steps 1 and 2 reject', () => { + for (const [what, head] of rejected) { + expect(inspectedLength(2 ** 40, head), what).toBe(16); + expect(inspectedLength(16, head), what).toBe(16); + } + }); + + it('reaches at most the largest valid sections plus one age header', () => { + const largest = preludeWith({ ph: 1 << 20, sc: 64 << 20 }); + expect(inspectedLength(2 ** 40, largest)).toBe(16 + (1 << 20) + (64 << 20) + MAX_AGE_HEADER_LEN + 1); + }); +}); + +describe('readCapsule', () => { + it('reads every official fixture whole', async () => { + for (const f of listTestdata('fixtures', '.dkc')) { + const dkc = readBytes(f); + expect(await samePrefixVerdict(dkc), f).toBe(dkc.length); + } + }); + + it('reads truncated and malformed files whole', async () => { + for (const n of [0, 3, 15, 16, 100, payloadStart - 1, payloadStart, payloadStart + 10]) { + expect(await samePrefixVerdict(timeOnly.subarray(0, n))).toBe(n); + } + }); + + it('reads 16 bytes of large files that steps 1 and 2 reject, with the same verdict', async () => { + const tail = new Uint8Array(3 << 20).fill(0xff); + for (const [what, head] of rejected) { + expect(await samePrefixVerdict(concat(head, tail)), what).toBe(16); + } + }); + + it('reads 16 bytes of a .dkk access key, rejected at step 1', async () => { + const dkk = readBytes('fixtures/time_and_key_portable.dkk'); + expect(dkk.length).toBeGreaterThan(16); + expect(await samePrefixVerdict(dkk)).toBe(16); + const r = inspectWith(dkk, registry); + expect(r.checks.at(-1)).toMatchObject({ step: 1, ok: false, error: 'ERR_INVALID_MAGIC' }); + }); + + it('skips the payload after its age header, with the same verdict', async () => { + const big = concat(timeOnly, new Uint8Array(3 << 20).fill(0x61)); + expect(await samePrefixVerdict(big)).toBe(payloadStart + MAX_AGE_HEADER_LEN + 1); + }); + + it('keeps the verdict of an age header at and around the 2 MiB limit', async () => { + const intro = te.encode('age-encryption.org/v1\n-> X25519 '); + // The first stanza line ends at offsets around the limit, then more data. + for (const end of [MAX_AGE_HEADER_LEN - 2, MAX_AGE_HEADER_LEN - 1, MAX_AGE_HEADER_LEN, MAX_AGE_HEADER_LEN + 1]) { + const line = new Uint8Array(end - intro.length).fill(0x41); + const payload = concat(intro, line, te.encode('\n\n--- AAAA\n'), new Uint8Array(1 << 20)); + const r = await samePrefixVerdict(frame({ ...parts, payload })); + expect(r).toBe(payloadStart + MAX_AGE_HEADER_LEN + 1); + } + // No newline at all: the header exceeds the limit in both readings. + const endless = concat(intro, new Uint8Array(3 << 20).fill(0x41)); + const dkc = frame({ ...parts, payload: endless }); + await samePrefixVerdict(dkc); + expect(inspectWith(dkc, registry).checks.at(-1)!.detail).toMatch(/header exceeds 2 MiB/); + }); +}); diff --git a/src/lib/inspector/load.ts b/src/lib/inspector/load.ts new file mode 100644 index 0000000..c62c2f1 --- /dev/null +++ b/src/lib/inspector/load.ts @@ -0,0 +1,54 @@ +// Reading a .dkc for the inspection without holding a large payload in memory. + +import { + DKC_PRELUDE_SIZE, + MAX_AGE_HEADER_LEN, + type Prelude, + parsePrelude, + payloadOffset, +} from '../dkc/index.ts'; + +/** + * How many leading bytes of a .dkc of `size` bytes steps 1 to 8 need, given + * its first min(size, 16) bytes. Inspecting that prefix gives exactly the + * result of inspecting the whole file: + * + * - a file shorter than 16 bytes is read whole; + * - a prelude that steps 1 and 2 reject (not DKC1, another version, FLAGS or + * RESERVED set, lengths outside 1..the limits of §57) fails on its 16 bytes + * alone, so nothing more is read: a stray video or a .dkk costs 16 bytes; + * - otherwise step 3 compares the file length with the end of PUBLIC_HEADER + * and of SEALED_CONTROL, whose lengths are now within the limits; the prefix + * is the whole file whenever it ends before PAYLOAD_AGE plus the bytes below; + * - steps 5 and 6 parse the age headers of SEALED_CONTROL (complete in the + * prefix) and of PAYLOAD_AGE, whose parser reads at most MAX_AGE_HEADER_LEN + * bytes and otherwise only asks whether the file goes further, which one + * more byte answers. The rest of the payload is never read. + */ +export function inspectedLength(size: number, head: Uint8Array): number { + if (head.length < DKC_PRELUDE_SIZE) return size; + let prelude: Prelude; + try { + prelude = parsePrelude(head); + } catch { + // Steps 1 and 2 run this same check on these same 16 bytes. + return head.length; + } + return Math.min(size, payloadOffset(prelude) + MAX_AGE_HEADER_LEN + 1); +} + +/** The bytes of a .dkc that the inspection reads. */ +export interface CapsuleBytes { + /** The first `bytes.length` bytes of the file. */ + readonly bytes: Uint8Array; + /** The size of the whole file. */ + readonly size: number; +} + +/** Reads the prefix of `blob` that the inspection needs (see inspectedLength). */ +export async function readCapsule(blob: Blob): Promise { + const head = new Uint8Array(await blob.slice(0, DKC_PRELUDE_SIZE).arrayBuffer()); + const n = inspectedLength(blob.size, head); + const bytes = n === head.length ? head : new Uint8Array(await blob.slice(0, n).arrayBuffer()); + return { bytes, size: blob.size }; +} diff --git a/src/lib/inspector/report.test.ts b/src/lib/inspector/report.test.ts new file mode 100644 index 0000000..f6dbc15 --- /dev/null +++ b/src/lib/inspector/report.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from 'vitest'; +import { defaultRegistry, type ExtensionRegistry, ExtensionSet, inspectView, inspectWith } from '../dkc/index.ts'; +import { frame, replaceText, split } from '../dkc/testing/capsule.ts'; +import { arr, b, dkRound, ext, map, t, u, bn } from '../dkc/testing/cborhex.ts'; +import { h, listTestdata, readBytes, readJSON } from '../dkc/testing/testdata.ts'; +import { cliJSON } from './format.ts'; +import { buildReport, type Report } from './report.ts'; + +const registry = await defaultRegistry(); +const timeOnly = readBytes('fixtures/time_only_extensions.dkc'); +const parts = split(timeOnly); + +function report(dkc: Uint8Array, extensions?: ExtensionRegistry, fileName = 'x.dkc'): Report { + const inspection = inspectWith(dkc, registry, extensions); + return buildReport(extensions === undefined ? { fileName, bytes: dkc, size: dkc.length, inspection } : { fileName, bytes: dkc, size: dkc.length, inspection, extensions }); +} + +// A PUBLIC_HEADER like the one of time_only_extensions, with other extensions. +function header(o: { crit?: string; non?: string; dk?: string }): Uint8Array { + const entries: [number, string][] = [ + [0, t('datekeycap')], + [1, u(1)], + [2, bn(16, 7)], + [3, o.dk ?? t(dkRound(2000))], + [4, u(0)], + ]; + if (o.crit) entries.push([5, o.crit]); + if (o.non) entries.push([6, o.non]); + return h(map(...entries)); +} + +interface FixtureRecord { + capsule_id: string; + datekey: string; + access_policy: string; + unlock_at: string; + prelude: string; + outer_stanzas: { type: string; args: string[] }[]; + payload_stanzas: { type: string; args: string[] }[]; + header_extensions?: { critical: boolean; id: string; version: number; data?: string }[]; +} + +describe('buildReport', () => { + it('shows every official fixture as its JSON record', () => { + for (const f of listTestdata('fixtures', '.dkc')) { + const dkc = readBytes(f); + const want = readJSON(f.replace(/\.dkc$/, '.json')); + const name = f.slice('fixtures/'.length); + const r = report(dkc, undefined, name); + expect(r.valid, f).toBe(true); + expect(r.failure).toBeUndefined(); + expect(r.steps.map((s) => [s.step, s.state])).toEqual([1, 2, 3, 4, 5, 6, 7, 8].map((s) => [s, 'ok'])); + expect(r.capsule).toMatchObject({ capsuleId: want.capsule_id, dateKey: want.datekey, accessPolicy: want.access_policy, network: 'datekeys:quicknet:v1' }); + expect(JSON.parse(r.capsule!.dateKeyJSON)).toEqual({ version: 1, network: 'datekeys:quicknet:v1', round: r.capsule!.round }); + expect(r.unlock?.rfc3339).toBe(want.unlock_at); + expect(r.unlock?.epochMs).toBe(Date.parse(want.unlock_at)); + expect(r.prelude?.hex).toBe(want.prelude); + expect(r.prelude).toMatchObject({ magic: 'DKC1', version: 1, flags: '0x00', reserved: '0x0000' }); + expect(r.prelude!.payloadOffset + r.prelude!.payloadLength!).toBe(dkc.length); + expect(r.outerStanzas).toEqual(want.outer_stanzas); + expect(r.payloadStanzas).toEqual(want.payload_stanzas); + expect(r.tlock?.every((c) => c.match)).toBe(true); + expect(r.profile).toMatchObject({ id: 'datekeys:quicknet:v1', network: 'quicknet', provider: 'drand', period: 3, genesis: '2023-08-23T15:09:27Z' }); + expect(r.extensions?.map((e) => ({ critical: e.critical, id: e.id, version: e.version, data: e.hex }))).toEqual( + (want.header_extensions ?? []).map((e) => ({ critical: e.critical, id: e.id, version: e.version, data: e.data ?? '' })), + ); + expect(r.view).toEqual(inspectView(inspectWith(dkc, registry), name)); + expect(r.json).toBe(cliJSON(r.view)); + expect(r.view.file).toBe(name); + expect(r.readLength).toBe(dkc.length); + } + }); + + it('shows header extension data as text, never as CBOR when it is not', () => { + const r = report(timeOnly); + expect(r.extensions).toEqual([ + { + critical: false, + id: 'org.example.label', + idEscaped: false, + version: 1, + known: false, + length: 12, + hex: '7075626c6963206c6162656c', + text: 'public label', + }, + ]); + }); + + it('decodes CBOR data informatively and escapes identifiers', () => { + const hb = header({ non: arr(ext('a\u202eb', 3, b('a2000701667365616c6564')), ext('z', 1)) }); + const r = report(frame({ ...parts, header: hb })); + expect(r.valid).toBe(true); + expect(r.extensions).toEqual([ + { + critical: false, + id: '"a\\u202eb"', + idEscaped: true, + version: 3, + known: false, + length: 11, + hex: 'a2000701667365616c6564', + cbor: { text: '{\n 0: 7,\n 1: "sealed"\n}', truncated: false }, + }, + { critical: false, id: 'z', idEscaped: false, version: 1, known: false, length: 0, hex: '' }, + ]); + }); + + it('reports an unknown critical extension at step 4 and still lists it', () => { + const hb = header({ crit: arr(ext('org.example.must-understand', 1, b('01'))) }); + const r = report(frame({ ...parts, header: hb })); + expect(r.valid).toBe(false); + expect(r.failure).toEqual({ + step: 4, + code: 'ERR_EXTENSION_CRITICAL_UNKNOWN', + message: 'capsule: PUBLIC_HEADER: extension org.example.must-understand v1: ERR_EXTENSION_CRITICAL_UNKNOWN', + }); + expect(r.steps.map((s) => s.state)).toEqual(['ok', 'ok', 'ok', 'failed', 'not-run', 'not-run', 'not-run', 'not-run']); + expect(r.steps[3]).toMatchObject({ code: 'ERR_EXTENSION_CRITICAL_UNKNOWN', name: 'header validation' }); + expect(r.steps[4]!.detail).toBeUndefined(); + expect(r.extensions).toEqual([ + { critical: true, id: 'org.example.must-understand', idEscaped: false, version: 1, known: false, length: 1, hex: '01', cbor: { text: '1', truncated: false } }, + ]); + expect(r.capsule?.capsuleId).toBe('07'.repeat(16)); + expect(r.unlock).toBeUndefined(); + expect(r.outerStanzas).toBeUndefined(); + expect(r.tlock).toBeUndefined(); + expect(JSON.parse(r.json)).toMatchObject({ valid: false, error: 'ERR_EXTENSION_CRITICAL_UNKNOWN' }); + }); + + it('marks known extensions and the unusable ones', () => { + const reg: ExtensionRegistry = { + known: (id) => id === 'a' || id === 'b', + validateData: (e) => (e.id === 'b' ? new Error('bad') : undefined), + }; + const r = report(frame({ ...parts, header: header({ crit: arr(ext('a', 1, b('01'))), non: arr(ext('b', 1, b('02')), ext('c', 1)) }) }), reg); + expect(r.valid).toBe(true); + expect(r.extensions?.map((e) => [e.id, e.critical, e.known, e.unusable])).toEqual([ + ['a', true, true, undefined], + ['b', false, true, 'extension b v1: data: bad: ERR_EXTENSION_DATA_INVALID'], + ['c', false, false, undefined], + ]); + const known = report(frame({ ...parts, header: header({ crit: arr(ext('a', 1)) }) }), new ExtensionSet([['a', [1]]])); + expect(known.extensions?.[0]?.known).toBe(true); + }); + + it('compares the tlock stanza with the pinned profile', () => { + const round = report(replaceText(timeOnly, '-> tlock 2000 ', '-> tlock 2001 ')); + expect(round.failure?.code).toBe('ERR_ROUND_MISMATCH'); + expect(round.unlock?.rfc3339).toBe('2023-08-23T16:49:24Z'); + expect(round.tlock).toEqual([ + { label: 'Argumentos', found: '2', expected: '2', match: true }, + { label: 'Ronda', found: '2001', expected: '2000', match: false }, + { + label: 'Cadena (chain hash)', + found: '52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971', + expected: '52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971', + match: true, + }, + ]); + const noChain = report(frame({ ...parts, sealed: replaceText(parts.sealed, ' 52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971', '') })); + expect(noChain.tlock?.map((c) => [c.found, c.match])).toEqual([ + ['1', false], + ['2000', true], + [undefined, false], + ]); + const noTlock = report(frame({ ...parts, sealed: replaceText(parts.sealed, '-> tlock', '-> TLOCK') })); + expect(noTlock.failure?.step).toBe(5); + expect(noTlock.outerStanzas?.map((s) => s.type)).toEqual(['TLOCK']); + expect(noTlock.tlock).toBeUndefined(); + }); + + it('keeps what the framing steps found', () => { + const r = report(timeOnly.subarray(0, 100)); + expect(r.failure).toMatchObject({ step: 3, code: 'ERR_INTEGRITY' }); + expect(r.prelude).toMatchObject({ publicHeaderLen: 159, sealedControlLen: 482, payloadOffset: 657 }); + expect(r.prelude?.payloadLength).toBeUndefined(); + expect(r.capsule).toBeUndefined(); + expect(r.extensions).toBeUndefined(); + const empty = report(new Uint8Array(0)); + expect(empty.failure).toMatchObject({ step: 1, code: 'ERR_INVALID_MAGIC' }); + expect(empty.prelude).toBeUndefined(); + expect(empty.steps.filter((s) => s.state === 'not-run')).toHaveLength(7); + // An unknown profile: the header decoded, no profile, no comparison. + const other = report(frame({ ...parts, header: header({ dk: t(dkRound(2000, 'datekeys:evmnet:v1')) }) })); + expect(other.failure?.code).toBe('ERR_UNKNOWN_PROFILE'); + expect(other.capsule?.network).toBe('datekeys:evmnet:v1'); + expect(other.profile).toBeUndefined(); + }); + + it('records the prefix that was read', () => { + const inspection = inspectWith(timeOnly, registry); + const r = buildReport({ fileName: 'big.dkc', bytes: timeOnly, size: timeOnly.length + 5_000_000, inspection }); + expect(r.readLength).toBe(timeOnly.length); + expect(r.size).toBe(timeOnly.length + 5_000_000); + expect(r.prelude!.payloadLength).toBe(timeOnly.length + 5_000_000 - 657); + }); +}); diff --git a/src/lib/inspector/report.ts b/src/lib/inspector/report.ts new file mode 100644 index 0000000..3f7fc1d --- /dev/null +++ b/src/lib/inspector/report.ts @@ -0,0 +1,267 @@ +// The page model of one inspection: every value the inspector shows, built +// from the library's Inspection with no DOM and no clock, so that it can be +// tested without a browser. + +import { + canonicalJSON, + chainHashHex, + compactDateKey, + DKC_PRELUDE_SIZE, + type ErrorCode, + type Extension, + type ExtensionRegistry, + formatRFC3339, + type Inspection, + inspectView, + type InspectView, + policyName, + STANZA_TLOCK, + type StanzaInfo, + toHex, +} from '../dkc/index.ts'; +import { cborView, type Diagnostic } from './diagnostic.ts'; +import { cliJSON, displayText, instantToEpochMs, INSPECT_STEPS, printableText, stepGloss, stepName } from './format.ts'; + +export type StepState = 'ok' | 'failed' | 'not-run'; + +/** One of the eight steps, as the CLI reports it, or not run. */ +export interface StepRow { + readonly step: number; + /** The CLI name, for example "header validation". */ + readonly name: string; + /** What the step checks, in Spanish. */ + readonly gloss: string; + readonly state: StepState; + /** The pass detail or the error message; undefined when not run. */ + readonly detail?: string; + /** The normative code of a failed step. */ + readonly code?: ErrorCode; +} + +/** A value read from the capsule next to the one the pinned profile expects. */ +export interface Comparison { + readonly label: string; + readonly found: string | undefined; + readonly expected: string; + readonly match: boolean; +} + +/** One PUBLIC_HEADER extension, with every view of its data. */ +export interface ExtensionRow { + readonly critical: boolean; + /** extension_id as shown: as it is, or quoted and escaped. */ + readonly id: string; + /** The identifier holds non-printable characters and is shown escaped. */ + readonly idEscaped: boolean; + readonly version: number; + /** Whether this reader implements the extension. */ + readonly known: boolean; + /** The data error of a known noncritical extension the reader cannot use. */ + readonly unusable?: string; + /** Data length in bytes; 0 when the extension carries no data. */ + readonly length: number; + /** Data in lowercase hexadecimal. */ + readonly hex: string; + /** The data as text when it is valid printable UTF-8. */ + readonly text?: string; + /** Informative diagnostic notation when the data is CBOR of the §58 profile. */ + readonly cbor?: Diagnostic; +} + +export interface CapsuleFacts { + readonly capsuleId: string; + readonly accessPolicy: string; + /** The compact dk1_ DateKey. */ + readonly dateKey: string; + /** Its decoded payload, the canonical JSON of spec §18. */ + readonly dateKeyJSON: string; + /** The network field of the DateKey: the profile_id. */ + readonly network: string; + readonly round: number; +} + +export interface ProfileFacts { + readonly id: string; + readonly provider: string; + readonly network: string; + readonly chainHash: string; + readonly period: number; + readonly genesis: string; + readonly scheme: string; +} + +export interface PreludeFacts { + /** The 16 prelude bytes in hexadecimal. */ + readonly hex: string; + readonly magic: string; + readonly version: number; + readonly flags: string; + readonly reserved: string; + readonly publicHeaderLen: number; + readonly sealedControlLen: number; + /** Where PAYLOAD_AGE starts. */ + readonly payloadOffset: number; + /** Bytes of PAYLOAD_AGE, when the file reaches its start. */ + readonly payloadLength?: number; +} + +export interface Report { + readonly fileName: string; + /** Size of the whole file. */ + readonly size: number; + /** Bytes read for the inspection (see inspectedLength). */ + readonly readLength: number; + readonly valid: boolean; + readonly failure?: { readonly step: number; readonly code: ErrorCode; readonly message: string }; + /** Always the eight steps, in order. */ + readonly steps: readonly StepRow[]; + /** The view of `datekeys inspect -json`. */ + readonly view: InspectView; + /** Its exact JSON text. */ + readonly json: string; + readonly capsule?: CapsuleFacts; + readonly profile?: ProfileFacts; + /** The effective round time, once step 7 passed. */ + readonly unlock?: { readonly rfc3339: string; readonly epochMs: number | undefined }; + readonly prelude?: PreludeFacts; + readonly outerStanzas?: readonly StanzaInfo[]; + readonly payloadStanzas?: readonly StanzaInfo[]; + /** The tlock stanza arguments against the pinned profile. */ + readonly tlock?: readonly Comparison[]; + /** Critical extensions first, then noncritical; undefined until PUBLIC_HEADER decoded. */ + readonly extensions?: readonly ExtensionRow[]; +} + +export interface ReportInput { + readonly fileName: string; + /** The bytes that were inspected: the file or its inspected prefix. */ + readonly bytes: Uint8Array; + /** Size of the whole file. */ + readonly size: number; + readonly inspection: Inspection; + /** The extension registry given to the inspection, if any. */ + readonly extensions?: ExtensionRegistry; +} + +const hex8 = (v: number): string => `0x${v.toString(16).padStart(2, '0')}`; + +export function buildReport(input: ReportInput): Report { + const { inspection: r, bytes, size } = input; + const view = inspectView(r, input.fileName); + const out: { + -readonly [K in keyof Report]: Report[K]; + } = { + fileName: input.fileName, + size, + readLength: bytes.length, + valid: r.error === undefined, + steps: steps(r), + view, + json: cliJSON(view), + }; + if (r.error !== undefined) { + const last = r.checks.at(-1)!; + out.failure = { step: last.step, code: r.error.code, message: r.error.message }; + } + + if (r.prelude !== undefined && r.payloadOffset !== undefined) { + const p: { -readonly [K in keyof PreludeFacts]: PreludeFacts[K] } = { + hex: toHex(bytes.subarray(0, DKC_PRELUDE_SIZE)), + magic: 'DKC1', + version: bytes[4]!, + flags: hex8(bytes[5]!), + reserved: `0x${toHex(bytes.subarray(6, 8))}`, + publicHeaderLen: r.prelude.publicHeaderLen, + sealedControlLen: r.prelude.sealedControlLen, + payloadOffset: r.payloadOffset, + }; + if (size >= r.payloadOffset) p.payloadLength = size - r.payloadOffset; + out.prelude = p; + } + + const h = r.header; + if (h !== undefined) { + out.capsule = { + capsuleId: toHex(h.capsuleId), + accessPolicy: policyName(h.policy), + dateKey: compactDateKey(h.dateKey), + dateKeyJSON: canonicalJSON(h.dateKey) ?? '', + network: h.dateKey.profileId, + round: h.dateKey.round, + }; + out.extensions = [ + ...h.critical.map((e) => extensionRow(e, true, r, input.extensions)), + ...h.noncritical.map((e) => extensionRow(e, false, r, input.extensions)), + ]; + } + + const p = r.profile; + if (p !== undefined) { + out.profile = { + id: p.id, + provider: p.provider, + network: p.network, + chainHash: chainHashHex(p), + period: p.period, + genesis: formatRFC3339({ seconds: p.genesisTime, nanos: 0 }), + scheme: p.scheme, + }; + } + + if (r.unlockAt !== undefined) { + out.unlock = { rfc3339: formatRFC3339(r.unlockAt), epochMs: instantToEpochMs(r.unlockAt) }; + } + if (r.outerStanzas !== undefined) out.outerStanzas = r.outerStanzas; + if (r.payloadStanzas !== undefined) out.payloadStanzas = r.payloadStanzas; + + if (r.outerStanzas !== undefined && h !== undefined && p !== undefined) { + const t = r.outerStanzas.find((s) => s.type === STANZA_TLOCK); + if (t !== undefined) { + out.tlock = [ + compare('Argumentos', String(t.args.length), '2'), + compare('Ronda', t.args[0], String(h.dateKey.round)), + compare('Cadena (chain hash)', t.args[1], chainHashHex(p)), + ]; + } + } + return out; +} + +function compare(label: string, found: string | undefined, expected: string): Comparison { + return { label, found, expected, match: found === expected }; +} + +function steps(r: Inspection): StepRow[] { + return INSPECT_STEPS.map((step): StepRow => { + const c = r.checks.find((x) => x.step === step); + const base = { step, name: stepName(step), gloss: stepGloss(step) }; + if (c === undefined) return { ...base, state: 'not-run' }; + if (c.ok) return c.detail === undefined ? { ...base, state: 'ok' } : { ...base, state: 'ok', detail: c.detail }; + const failed: { -readonly [K in keyof StepRow]: StepRow[K] } = { ...base, state: 'failed' }; + if (c.detail !== undefined) failed.detail = c.detail; + if (r.error !== undefined) failed.code = r.error.code; + return failed; + }); +} + +function extensionRow(e: Extension, critical: boolean, r: Inspection, reg: ExtensionRegistry | undefined): ExtensionRow { + const id = displayText(e.id); + const row: { -readonly [K in keyof ExtensionRow]: ExtensionRow[K] } = { + critical, + id: id.text, + idEscaped: id.escaped, + version: e.version, + known: reg?.known(e.id, e.version) ?? false, + length: e.data?.length ?? 0, + hex: e.data === undefined ? '' : toHex(e.data), + }; + const unusable = critical ? undefined : r.unusableExtensions.find((u) => u.id === e.id && u.version === e.version); + if (unusable !== undefined) row.unusable = unusable.error.message; + if (e.data !== undefined) { + const text = printableText(e.data); + if (text !== undefined) row.text = text; + const cbor = cborView(e.data); + if (cbor !== undefined) row.cbor = cbor; + } + return row; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..870777e --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,89 @@ + + + + + + +
+ {@render children()} +
+ +
+
+

+ Protocolo DateKeys 0.8.2. La página se ejecuta entera en este navegador y su política de seguridad no le permite + conectarse a ningún otro sitio. +

+
+
+ + diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts new file mode 100644 index 0000000..664fa18 --- /dev/null +++ b/src/routes/+layout.ts @@ -0,0 +1,2 @@ +// Every page is prerendered to static HTML; there is no server code. +export const prerender = true; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte new file mode 100644 index 0000000..bfbb446 --- /dev/null +++ b/src/routes/+page.svelte @@ -0,0 +1,147 @@ + + + + DateKeys: comprueba una cápsula sin abrirla + + + +
+
+

Mira una cápsula DateKeys sin abrirla

+

+ Una cápsula .dkc es un fichero cifrado que nadie puede abrir antes de una fecha: la clave depende de + una firma que todavía no existe. La publicará en esa fecha drand, una red pública que emite una firma nueva cada + pocos segundos. El inspector lee la parte pública de la cápsula y comprueba que está bien formada, con los mismos + pasos que la herramienta datekeys inspect. +

+

+ Abrir el inspector +

+
+ +
+
Los ocho pasos que se comprueban antes de la fecha de apertura (§63)
+
    + {#each INSPECT_STEPS as step (step)} +
  1. + +
    +

    {stepName(step)}

    +

    {stepGloss(step)}

    +
    +
  2. + {/each} +
+
+
+ +
+

Qué garantiza la página

+
    +
  • +

    El fichero no sale del navegador

    +

    + Se lee en local. La política de seguridad de la página (CSP) solo permite conexiones a su propio origen, y solo + se usan para descargar las cápsulas de prueba que vienen con el sitio. +

    +
  • +
  • +

    Sin secretos

    +

    No pide ni acepta claves de acceso .dkk, identidades ni contraseñas. Solo lee lo que es público.

    +
  • +
  • +

    El veredicto de la referencia

    +

    + Pasos, códigos de error y detalles son los de la implementación de referencia en Go. Copiar JSON + da exactamente la salida de datekeys inspect -json. +

    +
  • +
  • +

    Lo que queda para la apertura

    +

    + Superar los pasos 1 a 8 no prueba que la cápsula se pueda abrir. La cabecera pública solo queda autenticada en el + paso 15, al abrirla después de la fecha. +

    +
  • +
+
+ + diff --git a/src/routes/inspect/+page.svelte b/src/routes/inspect/+page.svelte new file mode 100644 index 0000000..032c072 --- /dev/null +++ b/src/routes/inspect/+page.svelte @@ -0,0 +1,359 @@ + + + + Inspector de cápsulas DateKeys + + + + + +
+
+

Inspector de cápsulas

+

+ Comprueba la parte pública de un fichero .dkc antes de su fecha de apertura: los pasos 1 a 8 de la + especificación (§63), con el mismo resultado que datekeys inspect. El fichero no sale de este navegador + y no se pide ninguna clave. +

+
+ +
+

Elegir una cápsula

+ + +
+

Cápsulas de prueba oficiales

+

+ Cápsulas de ejemplo de la implementación de referencia en Go, incluidas en el sitio (descripciones en inglés). +

+
    + {#each FIXTURES as f (f.name)} +
  • + +
  • + {/each} +
+
+
+ +

{announcement}

+ + {#if busy} + + {/if} + {#if notice} +

{notice}

+ {/if} + {#if problem} +
+

{problem}

+
+ {/if} + + {#if report} + {#key report} + + {/key} + {/if} +
+ +{#if dragging} + +{/if} + + diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..2c02eec --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/svelte.config.js b/svelte.config.js new file mode 100644 index 0000000..d588bed --- /dev/null +++ b/svelte.config.js @@ -0,0 +1,64 @@ +// SvelteKit configuration of the inspector page (plan §8, phase 1): a static +// site, every page prerendered, no server code, and a Content-Security-Policy +// that keeps the page on its own origin. +import adapter from '@sveltejs/adapter-static'; + +// The only inline style of the site: the style attribute of SvelteKit's route +// announcer (
, written by `svelte-kit sync` into +// .svelte-kit/generated/root.svelte), allowed by its SHA-256 and nothing else. +// It is the hash of the attribute value for @sveltejs/kit 2.70.3; the build +// check (scripts/check-build.mjs) fails if the bundle holds any other inline +// style, and src/app.css hides the announcer anyway in browsers without +// style-src-attr. +const ANNOUNCER_STYLE_HASH = 'sha256-S8qMpvofolR8Mpjy4kQvEm7m1q8clzU4dfDH0AmvZjo='; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + compilerOptions: { + runes: true, + }, + kit: { + // strict: the build fails if any route is not prerendered. + adapter: adapter({ strict: true }), + // Prerendered pages get the policy as , the first element + // of . In 'hash' mode SvelteKit adds the SHA-256 of each inline + // script it writes (the hydration bootstrap) to script-src; the styles are + // external files (inlineStyleThreshold stays 0), so style-src needs no + // hash, and style-src-attr allows the announcer's style attribute alone. + // Nonces cannot work in prerendered HTML. The fixtures are fetched + // from the same origin, so connect-src 'self' is enough: no other origin + // can be reached from the page. + csp: { + mode: 'hash', + directives: { + 'default-src': ['self'], + 'script-src': ['self'], + 'style-src': ['self'], + 'style-src-attr': ['unsafe-hashes', ANNOUNCER_STYLE_HASH], + 'img-src': ['self'], + 'font-src': ['self'], + 'connect-src': ['self'], + 'manifest-src': ['self'], + 'frame-src': ['none'], + 'worker-src': ['none'], + 'object-src': ['none'], + 'base-uri': ['none'], + 'form-action': ['none'], + }, + }, + prerender: { + handleHttpError: 'fail', + handleMissingId: 'fail', + handleUnseenRoutes: 'fail', + }, + typescript: { + // The generated tsconfig covers src/ and vite.config.ts; the vitest + // configuration is type-checked as well. + config(tsconfig) { + tsconfig.include.push('../vitest.config.ts'); + }, + }, + }, +}; + +export default config; diff --git a/tsconfig.json b/tsconfig.json index 55a9863..a3af387 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { + // Extends the configuration that `svelte-kit sync` generates (the $lib and + // $app paths, and the include list: src/, vite.config.ts and, through + // svelte.config.js, vitest.config.ts). The options below make it strict. + "extends": "./.svelte-kit/tsconfig.json", "compilerOptions": { "target": "ES2022", - "lib": ["ES2024", "DOM"], + "lib": ["ES2024", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "Bundler", "strict": true, @@ -16,6 +20,5 @@ "allowImportingTsExtensions": true, "erasableSyntaxOnly": true, "types": ["node"] - }, - "include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"] + } } diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..f29c536 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,21 @@ +// 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'; + +export default defineConfig({ + plugins: [sveltekit()], + 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) + // must be separate same-origin files, whatever their size. + assetsInlineLimit: 0, + }, + server: { + fs: { + // The dev server serves the official fixtures straight from testdata/, + // the single source of truth; the build copies them as hashed assets. + allow: ['testdata/fixtures'], + }, + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts index 5ae7c20..f6caf2b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,3 +1,7 @@ +// The tests of src/lib. Vitest prefers this file to vite.config.ts, the +// SvelteKit configuration of the page, so the library tests run without the +// SvelteKit plugin; the page helpers in src/lib/inspector import the library +// by relative paths and need no $lib alias. import { defineConfig } from 'vitest/config'; export default defineConfig({ @@ -7,13 +11,15 @@ export default defineConfig({ testTimeout: 30_000, coverage: { provider: 'v8', - include: ['src/lib/dkc/**/*.ts'], - exclude: ['src/lib/dkc/**/*.test.ts', 'src/lib/dkc/testing/**', 'src/lib/dkc/index.ts'], + include: ['src/lib/dkc/**/*.ts', 'src/lib/inspector/**/*.ts'], + exclude: ['src/lib/**/*.test.ts', 'src/lib/dkc/testing/**', 'src/lib/dkc/index.ts'], reporter: ['text', 'html', 'json-summary'], thresholds: { // The codec is covered completely (plan §7). 'src/lib/dkc/cbor.ts': { 100: true }, 'src/lib/dkc/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 }, + // The page model and helpers of the inspector (plan §8, phase 1). + 'src/lib/inspector/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 }, }, }, },