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 }, }, }, },