Inspector page: static SvelteKit site with /inspect (plan step 5)

A prerendered static site (adapter-static) with a landing page and
/inspect, which runs spec §63 steps 1 to 8 on a .dkc chosen with the file
picker, dropped anywhere on the page, or taken from the official fixtures
bundled at build time. It shows every step, the decoded header, the unlock
date in UTC and local time, and each extension's id, version, criticality,
length and hex, with a text view and an informative CBOR diagnostic view,
all escaped and labelled as unauthenticated before step 15. Copiar JSON
copies the exact "datekeys inspect -json" view.

No network: a hash-mode Content-Security-Policy with connect-src 'self'
is the first element of every page, and scripts/check-build.mjs verifies
it, the fixtures and the absence of external URLs after every build.
Large files are read only up to what steps 1 to 8 need.

Reviewed for design and accessibility (WCAG AA contrast, keyboard,
focus, live status, 360 px), security and correctness; 262 tests pass,
svelte-check has no warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
dev 2 weeks ago
parent 46630133d3
commit 5f8c8c8031

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

@ -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 `<meta http-equiv="content-security-policy">`:
```
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.

@ -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')`).

456
package-lock.json generated

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

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

@ -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 <meta>, 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(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&#x27;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/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(/<meta http-equiv="content-security-policy" content="([^"]*)">/gi)];
if (metas.length !== 1) {
fail(`${name}: ${metas.length} Content-Security-Policy <meta> 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 <meta>`);
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(/<script(\s[^>]*)?>([\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 <script src> has a body`);
continue;
}
const h = `'sha256-${createHash('sha256').update(body, 'utf8').digest('base64')}'`;
used.add(h);
if (!hashes.includes(h)) fail(`${name}: an inline script is not allowed by script-src (${h})`);
}
for (const h of hashes) if (!used.has(h)) fail(`${name}: script-src hash ${h} matches no inline script`);
// Styles only from files, no event handler attributes, no other origins.
if (/<style[\s>]/i.test(html)) fail(`${name}: inline <style> element`);
if (/\sstyle=/i.test(html)) fail(`${name}: inline style attribute`);
if (/\son[a-z]+=/i.test(html)) fail(`${name}: inline event handler attribute`);
for (const [, attr, url] of html.matchAll(/\s(src|href|action|srcset|poster|data)="([^"]*)"/gi)) {
if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(url.trim())) fail(`${name}: ${attr}="${url}" points outside the site`);
}
return policy;
}
const policies = htmlFiles.filter(existsSync).map(checkPage);
// ---------------------------------------------------------------------------
// Stylesheets never reach another origin (no web fonts, no remote images).
for (const f of files.filter((p) => p.endsWith('.css'))) {
const css = readFileSync(f, 'utf8');
if (/@import/i.test(css)) fail(`${rel(f)}: @import`);
for (const [, url] of css.matchAll(/url\(\s*['"]?([^'")]*)/gi)) {
if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(url)) fail(`${rel(f)}: url(${url}) points outside the site`);
}
}
// ---------------------------------------------------------------------------
// The fixtures: every .dkc byte for byte, no secret anywhere.
const fixtureFiles = readdirSync(FIXTURES).sort();
const byHash = new Map();
for (const f of files) {
const h = sha256(readFileSync(f));
byHash.set(h, [...(byHash.get(h) ?? []), f]);
}
for (const name of fixtureFiles.filter((n) => n.endsWith('.dkc'))) {
const stem = name.slice(0, -'.dkc'.length);
const copies = byHash.get(sha256(readFileSync(join(FIXTURES, name)))) ?? [];
const shipped = copies.filter((p) => inBuild(p).startsWith('_app/immutable/assets/') && basename(p).startsWith(`${stem}.`));
if (shipped.length !== 1) fail(`fixture ${name}: ${shipped.length} byte-exact copies under build/_app/immutable/assets, want 1`);
}
// Secret values that must never be shipped.
const secrets = [];
const addSecret = (label, value) => {
if (typeof value === 'string' && value.length >= 16) secrets.push([label, value]);
};
for (const name of fixtureFiles) {
const p = join(FIXTURES, name);
const bytes = readFileSync(p);
if (name.endsWith('.dkk') || name.endsWith('.plaintext')) {
const copies = byHash.get(sha256(bytes)) ?? [];
if (bytes.length > 0 && copies.length > 0) fail(`${name} is shipped as ${copies.map(rel).join(', ')}`);
addSecret(`${name} (hex)`, bytes.toString('hex'));
addSecret(`${name} (base64)`, bytes.toString('base64').replace(/=+$/, ''));
if (name.endsWith('.plaintext')) addSecret(`${name} (text)`, bytes.toString('utf8').slice(0, 64));
}
if (name.endsWith('.json')) {
const record = JSON.parse(bytes.toString('utf8'));
for (const id of record.identities ?? []) addSecret(`${name} identities`, id);
addSecret(`${name} payload_identity`, record.payload_identity);
addSecret(`${name} access_material`, record.access_material);
addSecret(`${name} control_cbor`, record.control_cbor);
}
}
if (secrets.length < 10) fail(`only ${secrets.length} fixture secrets collected; the fixture records changed shape`);
const TEXT = /\.(html|js|css|json|svg|txt|map|webmanifest)$/;
for (const f of files.filter((p) => TEXT.test(p))) {
const text = readFileSync(f, 'utf8');
for (const [label, value] of secrets) if (text.includes(value)) fail(`${rel(f)} contains the fixture secret ${label}`);
}
for (const f of files) {
if (/\.(dkk|plaintext)$/.test(f) || /fixtures?\/.*\.json$/.test(inBuild(f))) fail(`${rel(f)}: fixture file that must not be shipped`);
}
// ---------------------------------------------------------------------------
if (problems.length > 0) {
console.error(`build check failed, ${problems.length} problems:`);
for (const p of problems) console.error(` - ${p}`);
process.exit(1);
}
const size = files.reduce((n, f) => n + statSync(f).size, 0);
console.log(`build check passed: ${htmlFiles.length} prerendered pages, ${files.length} files, ${size} bytes`);
for (const [i, f] of htmlFiles.entries()) {
const p = policies[i];
console.log(` ${rel(f)}: CSP ${[...p].map(([d, s]) => `${d} ${s.join(' ')}`).join('; ')}`);
}

@ -0,0 +1,321 @@
/* DateKeys inspector: the whole stylesheet.
The palette is datekeys.com's: white paper, near-black ink and the brand
orange. Here colour always means something: green is a passed step, red a
rejection, orange a trust caveat (public data, informative views). Human
words are set in the system sans; values read from a capsule (hex,
identifiers, codes, CLI details) in the system monospace, never labels.
No web fonts: the Content-Security-Policy allows nothing but this origin. */
:root {
color-scheme: light dark;
--paper: #ffffff;
--paper-2: #f4f4f3;
--ink: #0e0e0e;
--ink-muted: #545454;
--ink-faint: #6b6b6b;
--rule: #e2e2e0;
--rule-strong: #8f8f8c;
--orange: #f18225;
/* The logo orange is a fill; this deeper orange carries text, at least
4.5:1 on the paper and on the caution tint. */
--orange-text: #a34a08;
--caution-bg: #fdf3e9;
--pass: #17683a;
--pass-bg: #e7f3ec;
--fail: #b3261e;
--fail-bg: #fbecea;
--sans: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', 'Noto Sans', 'Liberation Sans', Arial, sans-serif;
--mono: ui-monospace, 'Cascadia Mono', 'Segoe UI Mono', 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
/* Type ramp, x1.2 from 16 px. */
--t-small: 0.875rem;
--t-body: 1rem;
--t-lead: 1.2rem;
--t-h3: 1.2rem;
--t-h2: 1.44rem;
--t-date: clamp(1.6rem, 1.2rem + 1.6vw, 2.2rem);
--t-h1: clamp(2rem, 1.4rem + 2.4vw, 3rem);
--gut: 16px;
--measure: 68ch;
--radius: 4px;
}
@media (prefers-color-scheme: dark) {
:root {
--paper: #0c0c0b;
--paper-2: #161615;
--ink: #f2f1ef;
--ink-muted: #b3b0ab;
--ink-faint: #9a9791;
--rule: #2a2a28;
--rule-strong: #6d6a66;
--orange-text: #f39a4e;
--caution-bg: #26190c;
--pass: #74d49c;
--pass-bg: #0f2418;
--fail: #ff8f85;
--fail-bg: #2c1311;
}
}
@media (min-width: 720px) {
:root {
--gut: 32px;
}
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: var(--sans);
font-size: var(--t-body);
line-height: 1.55;
overflow-wrap: break-word;
}
.app-root {
display: contents;
}
h1,
h2,
h3,
h4 {
line-height: 1.2;
margin: 0;
font-weight: 650;
letter-spacing: -0.01em;
}
h1 {
font-size: var(--t-h1);
letter-spacing: -0.025em;
}
h2 {
font-size: var(--t-h2);
}
h3 {
font-size: var(--t-h3);
}
p,
ul,
ol,
dl,
pre {
margin: 0;
}
a {
color: inherit;
text-decoration-thickness: 1px;
text-underline-offset: 0.2em;
}
a:hover {
text-decoration-thickness: 2px;
}
code,
kbd,
samp,
pre,
.mono {
font-family: var(--mono);
font-size: 0.92em;
}
/* .mono only holds values read from a capsule: hex and identifiers of up to
128 characters with no break points. They must wrap anywhere, or one long
value widens every grid track above it and the page scrolls sideways. */
.mono {
overflow-wrap: anywhere;
}
:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 3px;
border-radius: 2px;
}
[tabindex='-1']:focus {
outline: none;
}
button {
font: inherit;
color: inherit;
}
.visually-hidden {
position: absolute !important;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.skip-link {
position: absolute;
left: var(--gut);
top: -4rem;
z-index: 10;
padding: 0.5rem 0.75rem;
background: var(--ink);
color: var(--paper);
border-radius: var(--radius);
}
.skip-link:focus {
top: 0.5rem;
}
.wrap {
width: 100%;
max-width: 76rem;
margin-inline: auto;
padding-inline: var(--gut);
}
.prose {
max-width: var(--measure);
}
.lead {
font-size: var(--t-lead);
line-height: 1.5;
color: var(--ink-muted);
max-width: var(--measure);
}
.muted {
color: var(--ink-muted);
}
/* Buttons: one solid (the page's main action) and one quiet. */
.button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
min-height: 2.75rem;
padding: 0.55rem 1.1rem;
border: 1px solid var(--ink);
border-radius: var(--radius);
background: var(--ink);
color: var(--paper);
font-weight: 600;
text-decoration: none;
cursor: pointer;
}
.button:hover {
background: color-mix(in srgb, var(--ink) 86%, var(--paper));
}
.button.quiet {
background: transparent;
color: var(--ink);
border-color: var(--rule-strong);
}
.button.quiet:hover {
border-color: var(--ink);
background: var(--paper-2);
}
/* The step rail, shared by the landing and the report: the steps of §63
are a real sequence, so they are numbered. */
.rail {
list-style: none;
padding: 0;
counter-reset: none;
}
.rail > li {
position: relative;
display: grid;
grid-template-columns: 2rem 1fr;
column-gap: 0.9rem;
padding-bottom: 1.1rem;
}
.rail > li::before {
content: '';
position: absolute;
left: calc(1rem - 1px);
top: 2rem;
bottom: 0;
width: 2px;
background: var(--rule);
}
.rail > li:last-child {
padding-bottom: 0;
}
.rail > li:last-child::before {
display: none;
}
.rail .dot {
position: relative;
z-index: 1;
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
border: 2px solid var(--rule-strong);
background: var(--paper);
font-size: var(--t-small);
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--ink-muted);
}
@media (prefers-reduced-motion: no-preference) {
.button,
a {
transition:
background-color 120ms ease,
border-color 120ms ease,
text-decoration-thickness 120ms ease;
}
}
/* SvelteKit's route announcer. The policy allows its inline style attribute
by hash (style-src-attr); this rule keeps it out of sight in browsers
that do not know that directive and block the attribute. */
#svelte-announcer {
position: absolute;
left: 0;
top: 0;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}

6
src/app.d.ts vendored

@ -0,0 +1,6 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
declare global {
namespace App {}
}
export {};

@ -0,0 +1,14 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
<meta name="color-scheme" content="light dark" />
<meta name="referrer" content="no-referrer" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
</head>
<body data-sveltekit-preload-data="hover">
<div class="app-root">%sveltekit.body%</div>
</body>
</html>

@ -0,0 +1,59 @@
<script lang="ts">
// One view of extension data (hexadecimal, text or informative CBOR) as a
// disclosure. Long data starts collapsed, and its text is only put in the
// DOM once the view is opened: a PUBLIC_HEADER may hold up to 1 MiB of
// extension data. The content is Svelte text interpolation, so it is always
// escaped.
import type { Snippet } from 'svelte';
let {
content,
openInitially,
summary,
kind,
}: {
content: string;
openInitially: boolean;
/** The text of the summary line. */
summary: Snippet;
/** hex breaks anywhere; text and cbor keep their lines. */
kind: 'hex' | 'text' | 'cbor';
} = $props();
// undefined until the reader toggles the view.
let toggled: boolean | undefined = $state();
const open = $derived(toggled ?? openInitially);
</script>
<details class="view" {open} ontoggle={(e) => (toggled = e.currentTarget.open)}>
<summary>{@render summary()}</summary>
{#if open}
<pre class={kind}>{content}</pre>
{/if}
</details>
<style>
.view {
margin-top: 0.9rem;
}
summary {
cursor: pointer;
width: fit-content;
padding-block: 0.2rem;
font-size: var(--t-small);
font-weight: 600;
}
pre {
margin-top: 0.35rem;
font-size: 0.82rem;
line-height: 1.55;
padding: 0.6rem 0.75rem;
background: var(--paper-2);
border-radius: var(--radius);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.hex {
word-break: break-all;
}
</style>

@ -0,0 +1,177 @@
<script lang="ts">
// Every PUBLIC_HEADER extension, as the extensions contract of plan §8 asks:
// id, version, critical or not, known or not, length and hex always; text
// when the bytes are printable UTF-8; an informative CBOR view when they are
// one item of the §58 profile. Everything is Svelte text interpolation, so
// every byte read from the capsule is escaped, and the rows are a list in
// header order: nothing is keyed by an identifier read from the capsule.
import type { ExtensionRow } from '$lib/inspector/report.ts';
import { escapeInvisible, formatByteCount } from '$lib/inspector/format.ts';
import DataView from './DataView.svelte';
let { extensions }: { extensions: readonly ExtensionRow[] } = $props();
/** Data up to this many bytes starts with its hexadecimal open. */
const OPEN_HEX_BYTES = 64;
/** Text and CBOR views up to this many characters start open. */
const OPEN_VIEW_CHARS = 2048;
</script>
<div class="caveat" role="note">
<p class="caveat-title">Datos públicos, sin autenticar hasta la apertura</p>
<p>
Cualquiera que tenga el fichero puede leer estas extensiones, y nada las autentica antes de abrir la cápsula: el
paso 15 (<span lang="en">header binding</span>) 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.
</p>
</div>
{#if extensions.length === 0}
<p class="none">La cabecera no trae extensiones.</p>
{:else}
<ol class="exts">
{#each extensions as e, i (i)}
<li>
<article aria-labelledby="ext-{i}">
<h4 id="ext-{i}" class="id">{e.id}</h4>
<dl class="facts">
<div>
<dt>Versión</dt>
<dd>{e.version}</dd>
</div>
<div>
<dt>Tipo</dt>
<dd class:critical={e.critical}>{e.critical ? 'crítica' : 'no crítica'}</dd>
</div>
<div>
<dt>Este lector</dt>
<dd class:unknown={e.critical && !e.known}>{e.known ? 'la conoce' : 'no la conoce'}</dd>
</div>
<div>
<dt>Datos</dt>
<dd>{e.length === 0 ? 'ninguno' : formatByteCount(e.length)}</dd>
</div>
</dl>
{#if e.critical && !e.known}
<p class="note bad">
Crítica y desconocida para este lector: basta para rechazar la cápsula en el paso 4
(ERR_EXTENSION_CRITICAL_UNKNOWN).
</p>
{/if}
{#if e.idEscaped}
<p class="note">
El identificador contiene caracteres invisibles o de control; se muestra entre comillas y escapado, como en
la CLI.
</p>
{/if}
{#if e.unusable !== undefined}
<p class="note bad">Este lector no puede usarla: <span class="mono">{escapeInvisible(e.unusable)}</span></p>
{/if}
{#if e.length > 0}
<DataView kind="hex" content={e.hex} openInitially={e.length <= OPEN_HEX_BYTES}>
{#snippet summary()}
Hexadecimal <span class="muted">({formatByteCount(e.length)})</span>
{/snippet}
</DataView>
{#if e.text !== undefined}
<DataView kind="text" content={e.text} openInitially={e.text.length <= OPEN_VIEW_CHARS}>
{#snippet summary()}
Texto <span class="muted">(UTF-8 imprimible)</span>
{/snippet}
</DataView>
{/if}
{#if e.cbor !== undefined}
<DataView
kind="cbor"
content={e.cbor.truncated ? `${e.cbor.text}\n… (recortado)` : e.cbor.text}
openInitially={e.cbor.text.length <= OPEN_VIEW_CHARS}
>
{#snippet summary()}
CBOR decodificado <span class="informative">informativo, no validado por el protocolo</span>
{/snippet}
</DataView>
{/if}
{/if}
</article>
</li>
{/each}
</ol>
{/if}
<style>
.caveat {
border-left: 4px solid var(--orange);
background: var(--caution-bg);
padding: 0.85rem 1rem;
border-radius: 0 var(--radius) var(--radius) 0;
max-width: var(--measure);
font-size: var(--t-small);
}
.caveat-title {
font-weight: 700;
color: var(--orange-text);
margin-bottom: 0.25rem;
}
.none {
margin-top: 1rem;
color: var(--ink-muted);
}
.exts {
list-style: none;
padding: 0;
margin-top: 1.25rem;
display: grid;
gap: 1rem;
}
article {
border: 1px solid var(--rule);
border-radius: var(--radius);
padding: 1rem;
}
.id {
font-family: var(--mono);
font-size: 1rem;
font-weight: 650;
overflow-wrap: anywhere;
}
.facts {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 2.5rem;
margin-top: 0.75rem;
font-size: var(--t-small);
}
.facts dt {
color: var(--ink-muted);
font-weight: 600;
}
.facts dd {
margin: 0;
}
.facts dd.critical {
font-weight: 700;
}
.facts dd.unknown {
color: var(--fail);
font-weight: 700;
}
.note {
margin-top: 0.75rem;
font-size: var(--t-small);
color: var(--ink-muted);
max-width: var(--measure);
}
.note.bad {
color: var(--fail);
}
.informative {
display: inline-block;
margin-left: 0.35rem;
padding: 0 0.5rem;
border-radius: var(--radius);
background: var(--caution-bg);
color: var(--orange-text);
font-weight: 600;
}
</style>

@ -0,0 +1,545 @@
<script lang="ts">
import type { Report, Comparison } from '$lib/inspector/report.ts';
import {
displayText,
errorGloss,
escapeInvisible,
formatByteCount,
formatDateTime,
formatInteger,
formatRelative,
policyGloss,
} from '$lib/inspector/format.ts';
import StepList from './StepList.svelte';
import ExtensionList from './ExtensionList.svelte';
let {
report,
nowMs,
timeZone,
}: {
report: Report;
/** When the report was made, for the distance to the unlock date. */
nowMs: number;
/** The viewer's time zone, for example "Europe/Madrid". */
timeZone: string | undefined;
} = $props();
let copyStatus = $state('');
let jsonOpen = $state(false);
let copyButton: HTMLButtonElement | undefined = $state();
const unlockMs = $derived(report.unlock?.epochMs);
const fileName = $derived(displayText(report.fileName));
const shownJSON = $derived(escapeInvisible(report.json));
async function copyJSON(): Promise<void> {
copyStatus = '';
try {
await navigator.clipboard.writeText(report.json);
copyStatus = 'JSON copiado.';
return;
} catch {
// No Clipboard API (an insecure context, a denied permission): fall
// back to a selected textarea and the legacy copy command.
}
if (legacyCopy(report.json)) {
copyStatus = 'JSON copiado.';
} else {
jsonOpen = true;
copyStatus = 'El navegador no deja copiar desde la página: selecciona el JSON de abajo y cópialo.';
}
copyButton?.focus();
}
function legacyCopy(text: string): boolean {
const area = document.createElement('textarea');
area.value = text;
area.readOnly = true;
area.className = 'visually-hidden';
document.body.append(area);
area.select();
let ok = false;
try {
ok = document.execCommand('copy');
} catch {
ok = false;
}
area.remove();
return ok;
}
function stanzaTypes(list: readonly { type: string }[]): string {
return list.length === 0 ? 'ninguno' : list.map((s) => s.type).join(', ');
}
function comparisonState(c: Comparison): string {
return c.match ? 'coincide' : 'no coincide';
}
</script>
<section class="report" aria-labelledby="report-title">
<header class="report-head">
<div class="file">
<h2 id="report-title" tabindex="-1">{fileName.text}</h2>
<p class="muted">
{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}.
</p>
</div>
<div class="copy">
<button class="button quiet" type="button" onclick={copyJSON} bind:this={copyButton}>Copiar JSON</button>
<p class="copy-status" role="status">{copyStatus}</p>
</div>
</header>
<div class={['verdict', report.valid ? 'valid' : 'invalid']}>
<div class="state">
{#if report.valid}
<p class="state-word">Estructura válida</p>
<p>
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).
</p>
{:else if report.failure}
<p class="state-word">Rechazada en el paso {report.failure.step}</p>
<p class="code">{report.failure.code}</p>
<p>{errorGloss(report.failure.code)}</p>
{/if}
</div>
<div class="when">
<p class="when-label">Fecha de apertura</p>
{#if report.unlock && unlockMs !== undefined}
<p class="date"><time datetime={report.unlock.rfc3339}>{formatDateTime(unlockMs, 'UTC')}</time></p>
<p>
En tu hora local{timeZone ? ` (${timeZone})` : ''}: {formatDateTime(unlockMs)}
</p>
<p class="muted">
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)}`}.
</p>
{:else if report.unlock}
<p class="date"><time datetime={report.unlock.rfc3339}>{report.unlock.rfc3339}</time></p>
{:else}
<p class="muted">
Sin calcular: sale de la DateKey en el paso 7, {report.steps[6]?.state === 'failed' ? 'que falló' : 'que no llegó a ejecutarse'}.
</p>
{/if}
</div>
</div>
<section class="block" aria-labelledby="steps-title">
<h3 id="steps-title">Pasos 1 a 8</h3>
<StepList steps={report.steps} />
</section>
<div class="grid">
<section class="block" aria-labelledby="header-title">
<h3 id="header-title">Cabecera pública</h3>
{#if report.capsule}
{@const c = report.capsule}
<dl>
<div>
<dt>capsule_id</dt>
<dd class="mono">{c.capsuleId}</dd>
</div>
<div>
<dt>access_policy</dt>
<dd><span class="mono">{c.accessPolicy}</span> <span class="muted">{policyGloss(c.accessPolicy)}</span></dd>
</div>
<div>
<dt>DateKey</dt>
<dd class="mono">{c.dateKey}</dd>
</div>
<div>
<dt>DateKey decodificada</dt>
<dd class="mono">{c.dateKeyJSON}</dd>
</div>
<div>
<dt>Red (network)</dt>
<dd class="mono">{c.network}</dd>
</div>
<div>
<dt>Ronda</dt>
<dd class="mono">{c.round}</dd>
</div>
<div>
<dt>unlock_at</dt>
<dd class="mono">{report.unlock?.rfc3339 ?? 'sin calcular'}</dd>
</div>
</dl>
{:else}
<p class="muted">PUBLIC_HEADER no llegó a decodificarse.</p>
{/if}
</section>
<section class="block" aria-labelledby="profile-title">
<h3 id="profile-title">Perfil fijado</h3>
{#if report.profile}
{@const p = report.profile}
<dl>
<div>
<dt>profile_id</dt>
<dd class="mono">{p.id}</dd>
</div>
<div>
<dt>Proveedor y red</dt>
<dd><span class="mono">{p.provider}</span>, <span class="mono">{p.network}</span></dd>
</div>
<div>
<dt>Cadena (chain hash)</dt>
<dd class="mono">{p.chainHash}</dd>
</div>
<div>
<dt>Periodo</dt>
<dd>{p.period} s por ronda</dd>
</div>
<div>
<dt>Génesis</dt>
<dd class="mono">{p.genesis}</dd>
</div>
<div>
<dt>Esquema</dt>
<dd class="mono">{p.scheme}</dd>
</div>
</dl>
{:else if report.capsule}
<p class="muted">
La DateKey nombra el perfil <span class="mono">{report.capsule.network}</span>, que este lector no tiene fijado.
</p>
{:else}
<p class="muted">Se elige con la DateKey de PUBLIC_HEADER, que no llegó a decodificarse.</p>
{/if}
</section>
<section class="block" aria-labelledby="prelude-title">
<h3 id="prelude-title">Prelude</h3>
{#if report.prelude}
{@const p = report.prelude}
<dl>
<div>
<dt>Bytes</dt>
<dd class="mono">{p.hex}</dd>
</div>
<div>
<dt>Marca y versión</dt>
<dd><span class="mono">{p.magic}</span>, versión {p.version}</dd>
</div>
<div>
<dt>FLAGS y RESERVED</dt>
<dd><span class="mono">{p.flags}</span>, <span class="mono">{p.reserved}</span></dd>
</div>
<div>
<dt>PUBLIC_HEADER_LEN</dt>
<dd>{formatByteCount(p.publicHeaderLen)}</dd>
</div>
<div>
<dt>SEALED_CONTROL_LEN</dt>
<dd>{formatByteCount(p.sealedControlLen)}</dd>
</div>
<div>
<dt>PAYLOAD_AGE</dt>
<dd>
desde el byte {formatInteger(p.payloadOffset)}{#if p.payloadLength !== undefined}, {formatByteCount(
p.payloadLength,
)}{:else}, fuera del fichero{/if}
</dd>
</div>
</dl>
{:else}
<p class="muted">El prelude no se superó.</p>
{/if}
</section>
<section class="block" aria-labelledby="stanzas-title">
<h3 id="stanzas-title">Stanzas age</h3>
<dl>
<div>
<dt>OUTER_TIME_AGE</dt>
<dd>
{#if report.outerStanzas}
{report.outerStanzas.length}
{report.outerStanzas.length === 1 ? 'stanza' : 'stanzas'}: <span class="mono">{stanzaTypes(report.outerStanzas)}</span>
<span class="muted">(se exige exactamente uno, tlock)</span>
{:else}
<span class="muted">sin leer</span>
{/if}
</dd>
</div>
<div>
<dt>PAYLOAD_AGE</dt>
<dd>
{#if report.payloadStanzas}
{report.payloadStanzas.length}
{report.payloadStanzas.length === 1 ? 'stanza' : 'stanzas'}: <span class="mono">{stanzaTypes(report.payloadStanzas)}</span>
<span class="muted">(se exige exactamente uno, X25519)</span>
{:else}
<span class="muted">sin leer</span>
{/if}
</dd>
</div>
</dl>
<h4 class="sub">Stanza tlock frente al perfil fijado</h4>
{#if report.tlock}
<ul class="compare">
{#each report.tlock as c (c.label)}
<li class={c.match ? 'match' : 'mismatch'}>
<p class="compare-head">
<span>{c.label}</span>
<span class="verdict-word">{comparisonState(c)}</span>
</p>
<p><span class="muted">En el stanza:</span> <span class="mono">{c.found ?? 'falta'}</span></p>
<p><span class="muted">Esperado:</span> <span class="mono">{c.expected}</span></p>
</li>
{/each}
</ul>
{:else if report.outerStanzas}
<p class="muted">OUTER_TIME_AGE no trae ningún stanza tlock.</p>
{:else}
<p class="muted">Se compara cuando se leen la cabecera, el perfil y OUTER_TIME_AGE.</p>
{/if}
</section>
</div>
<section class="block" aria-labelledby="ext-title">
<h3 id="ext-title">Extensiones de PUBLIC_HEADER</h3>
<p class="prose muted">
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.
</p>
{#if report.extensions}
<ExtensionList extensions={report.extensions} />
{:else}
<p class="muted">PUBLIC_HEADER no llegó a decodificarse.</p>
{/if}
</section>
<section class="block" aria-labelledby="json-title">
<h3 id="json-title">Salida de la CLI</h3>
<details bind:open={jsonOpen}>
<summary>JSON de <code>datekeys inspect -json</code></summary>
{#if shownJSON !== report.json}
<p class="json-note">
Los caracteres invisibles o de control se muestran escapados (<code>&#92;uXXXX</code>, el mismo JSON).
<strong>Copiar JSON</strong> copia la salida exacta de la CLI.
</p>
{/if}
<pre class="json">{shownJSON}</pre>
</details>
</section>
</section>
<style>
/* minmax(0, 1fr): a child's unbreakable content must not widen the track
beyond the screen. */
.report {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 2.5rem;
}
.report-head {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 1rem 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--rule);
}
.file h2 {
font-family: var(--mono);
font-size: var(--t-h2);
font-weight: 650;
letter-spacing: 0;
overflow-wrap: anywhere;
}
.file p {
margin-top: 0.25rem;
font-size: var(--t-small);
}
.copy {
display: grid;
justify-items: end;
gap: 0.25rem;
}
.copy-status {
font-size: var(--t-small);
color: var(--ink-muted);
min-height: 1.3em;
text-align: right;
max-width: 28rem;
}
/* The verdict: the one loud moment of the page. */
.verdict {
display: grid;
border: 1px solid var(--rule);
border-top: 6px solid var(--state);
border-radius: var(--radius);
background: var(--paper-2);
}
.verdict.valid {
--state: var(--pass);
}
.verdict.invalid {
--state: var(--fail);
}
@media (min-width: 820px) {
.verdict {
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
}
.when {
border-top: 0;
border-left: 1px solid var(--rule);
}
}
.state,
.when {
padding: 1.25rem 1.25rem 1.5rem;
display: grid;
align-content: start;
gap: 0.5rem;
}
.when {
border-top: 1px solid var(--rule);
}
.state-word {
font-size: var(--t-h2);
font-weight: 700;
letter-spacing: -0.015em;
line-height: 1.2;
color: var(--state);
}
.code {
font-family: var(--mono);
font-weight: 600;
overflow-wrap: anywhere;
}
.when-label {
font-size: var(--t-small);
font-weight: 600;
color: var(--ink-muted);
}
.date {
font-size: var(--t-date);
font-weight: 650;
line-height: 1.15;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
text-wrap: balance;
}
.block {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1rem;
align-content: start;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 2.5rem 3rem;
}
@media (min-width: 900px) {
.grid {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
}
dl {
display: grid;
border-top: 1px solid var(--rule);
}
dl > div {
display: grid;
gap: 0.1rem 1rem;
padding-block: 0.55rem;
border-bottom: 1px solid var(--rule);
}
@media (min-width: 560px) {
dl > div {
grid-template-columns: 11rem minmax(0, 1fr);
}
}
dt {
font-size: var(--t-small);
font-weight: 600;
color: var(--ink-muted);
overflow-wrap: anywhere;
}
dd {
margin: 0;
overflow-wrap: anywhere;
}
dd.mono,
dd .mono {
font-size: 0.85rem;
}
.sub {
font-size: var(--t-body);
margin-top: 0.5rem;
}
.compare {
list-style: none;
padding: 0;
display: grid;
gap: 0.6rem;
}
.compare li {
padding: 0.6rem 0.75rem;
border-left: 3px solid var(--pass);
background: var(--paper-2);
border-radius: 0 var(--radius) var(--radius) 0;
overflow-wrap: anywhere;
font-size: var(--t-small);
}
.compare li.mismatch {
border-left-color: var(--fail);
background: var(--fail-bg);
}
.compare-head {
display: flex;
justify-content: space-between;
gap: 1rem;
font-weight: 600;
font-size: var(--t-body);
}
.compare .verdict-word {
font-size: var(--t-small);
color: var(--pass);
}
.compare li.mismatch .verdict-word {
color: var(--fail);
}
.compare .mono {
font-size: 0.8rem;
}
details summary {
cursor: pointer;
font-weight: 600;
width: fit-content;
padding-block: 0.25rem;
}
.json-note {
margin-top: 0.75rem;
font-size: var(--t-small);
color: var(--ink-muted);
max-width: var(--measure);
}
.json {
margin-top: 0.75rem;
padding: 0.85rem 1rem;
background: var(--paper-2);
border: 1px solid var(--rule);
border-radius: var(--radius);
font-size: 0.8rem;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
</style>

@ -0,0 +1,29 @@
<script lang="ts">
// The DateKeys mark of datekeys.com's favicon: a ring and the orange seal.
// Decorative: the wordmark next to it carries the name.
</script>
<svg class="mark" viewBox="0 0 32 32" aria-hidden="true" focusable="false">
<rect class="ground" width="32" height="32" rx="3" />
<circle class="ring" cx="16" cy="16" r="9" />
<circle class="seal" cx="16" cy="16" r="3" />
</svg>
<style>
.mark {
width: 1.75rem;
height: 1.75rem;
flex: none;
}
.ground {
fill: var(--ink);
}
.ring {
fill: none;
stroke: var(--paper);
stroke-width: 1.6;
}
.seal {
fill: var(--orange);
}
</style>

@ -0,0 +1,119 @@
<script lang="ts">
import type { StepRow } from '$lib/inspector/report.ts';
import { errorGloss, escapeInvisible } from '$lib/inspector/format.ts';
let { steps }: { steps: readonly StepRow[] } = $props();
</script>
<ol class="rail steps">
{#each steps as s (s.step)}
<li class={s.state}>
<span class="dot" aria-hidden="true">{s.step}</span>
<div class="body">
<h4 class="head">
<span class="visually-hidden">Paso {s.step}:</span>
<span class="name" lang="en">{s.name}</span>
<span class="state">
{#if s.state === 'ok'}
superado
{:else if s.state === 'failed'}
{s.code ?? 'fallo'}
{:else}
no se ejecutó
{/if}
</span>
</h4>
<p class="gloss">{s.gloss}</p>
{#if s.state === 'failed' && s.code !== undefined}
<p class="why">{errorGloss(s.code)}</p>
{/if}
{#if s.detail !== undefined}
<p class="detail">{escapeInvisible(s.detail)}</p>
{/if}
</div>
</li>
{/each}
</ol>
<style>
.steps > li {
padding-bottom: 1.4rem;
}
li.ok .dot {
background: var(--pass);
border-color: var(--pass);
color: var(--paper);
}
li.ok::before {
background: var(--pass);
opacity: 0.45;
}
li.failed .dot {
background: var(--fail-bg);
border-color: var(--fail);
color: var(--fail);
}
li.not-run .dot {
border-style: dashed;
color: var(--ink-faint);
}
.head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
column-gap: 0.75rem;
row-gap: 0.1rem;
min-height: 2rem;
padding-top: 0.2rem;
font-size: var(--t-body);
font-weight: 600;
}
.name {
font-family: var(--mono);
font-size: 0.95rem;
}
.state {
font-size: var(--t-small);
font-weight: 600;
}
li.ok .state {
color: var(--pass);
}
li.failed .state {
color: var(--fail);
font-family: var(--mono);
overflow-wrap: anywhere;
}
li.not-run .state {
color: var(--ink-faint);
font-weight: 500;
}
li.not-run .name {
color: var(--ink-muted);
}
.gloss {
font-size: var(--t-small);
color: var(--ink-muted);
max-width: var(--measure);
}
.why {
margin-top: 0.4rem;
font-weight: 550;
color: var(--fail);
max-width: var(--measure);
}
.detail {
margin-top: 0.4rem;
font-family: var(--mono);
font-size: 0.82rem;
line-height: 1.5;
color: var(--ink);
overflow-wrap: anywhere;
white-space: pre-wrap;
}
li.failed .detail {
padding: 0.5rem 0.65rem;
background: var(--fail-bg);
border-radius: var(--radius);
}
</style>

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

@ -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();
});
});

@ -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);
}

@ -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<ArrayBuffer>));
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');
});
});

@ -0,0 +1,52 @@
/// <reference types="vite/client" />
// 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<string>('/testdata/fixtures/*.dkc', { query: '?url', import: 'default', eager: true });
const descriptions = import.meta.glob<unknown>(['/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<Record<string, string>>, jsonDescriptions: Readonly<Record<string, unknown>>): 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<Uint8Array> {
const res = await fetch(f.url);
if (!res.ok) throw new Error(`${f.name}: HTTP ${res.status}`);
return new Uint8Array(await res.arrayBuffer());
}

@ -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 <a>&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]/);
});
});

@ -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 <id> 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`;
}

@ -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<number> {
const { bytes, size } = await readCapsule(new Blob([dkc as Uint8Array<ArrayBuffer>]));
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/);
});
});

@ -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<CapsuleBytes> {
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 };
}

@ -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<FixtureRecord>(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);
});
});

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

@ -0,0 +1,89 @@
<script lang="ts">
import '../app.css';
import type { Snippet } from 'svelte';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import Mark from '$lib/components/Mark.svelte';
let { children }: { children: Snippet } = $props();
const home = resolve('/');
const inspector = resolve('/inspect');
</script>
<a class="skip-link" href="#main">Saltar al contenido</a>
<header class="site-header">
<div class="wrap bar">
<a class="brand" href={home} aria-current={page.route.id === '/' ? 'page' : undefined}>
<Mark />
<span class="brand-name">DateKeys</span>
</a>
<nav aria-label="Principal">
<a href={inspector} aria-current={page.route.id === '/inspect' ? 'page' : undefined}>Inspector</a>
</nav>
</div>
</header>
<main id="main" tabindex="-1">
{@render children()}
</main>
<footer class="site-footer">
<div class="wrap">
<p>
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.
</p>
</div>
</footer>
<style>
.site-header {
border-bottom: 1px solid var(--rule);
}
.bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
min-height: 4rem;
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.6rem;
text-decoration: none;
font-weight: 700;
font-size: 1.1rem;
letter-spacing: -0.01em;
}
nav a {
display: inline-block;
padding: 0.5rem 0.25rem;
text-decoration: none;
font-weight: 550;
color: var(--ink-muted);
}
nav a:hover,
nav a[aria-current='page'] {
color: var(--ink);
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 0.35em;
}
main {
display: block;
min-height: 70vh;
}
.site-footer {
margin-top: 5rem;
border-top: 1px solid var(--rule);
padding-block: 2rem 3rem;
font-size: var(--t-small);
color: var(--ink-muted);
}
.site-footer p {
max-width: var(--measure);
}
</style>

@ -0,0 +1,2 @@
// Every page is prerendered to static HTML; there is no server code.
export const prerender = true;

@ -0,0 +1,147 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { INSPECT_STEPS, stepGloss, stepName } from '$lib/inspector/format.ts';
</script>
<svelte:head>
<title>DateKeys: comprueba una cápsula sin abrirla</title>
<meta
name="description"
content="Comprueba en el navegador, sin red y sin claves, la parte pública de una cápsula DateKeys (.dkc) antes de su fecha de apertura."
/>
</svelte:head>
<div class="wrap hero">
<div class="pitch">
<h1>Mira una cápsula DateKeys sin abrirla</h1>
<p class="lead">
Una cápsula <code>.dkc</code> 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 <code>datekeys inspect</code>.
</p>
<p class="actions">
<a class="button" href={resolve('/inspect')}>Abrir el inspector</a>
</p>
</div>
<figure class="preview" aria-labelledby="preview-caption">
<figcaption id="preview-caption">Los ocho pasos que se comprueban antes de la fecha de apertura (§63)</figcaption>
<ol class="rail">
{#each INSPECT_STEPS as step (step)}
<li>
<span class="dot" aria-hidden="true">{step}</span>
<div>
<p class="step-name" lang="en">{stepName(step)}</p>
<p class="step-gloss">{stepGloss(step)}</p>
</div>
</li>
{/each}
</ol>
</figure>
</div>
<section class="wrap guarantees" aria-labelledby="guarantees-title">
<h2 id="guarantees-title">Qué garantiza la página</h2>
<ul>
<li>
<h3>El fichero no sale del navegador</h3>
<p>
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.
</p>
</li>
<li>
<h3>Sin secretos</h3>
<p>No pide ni acepta claves de acceso <code>.dkk</code>, identidades ni contraseñas. Solo lee lo que es público.</p>
</li>
<li>
<h3>El veredicto de la referencia</h3>
<p>
Pasos, códigos de error y detalles son los de la implementación de referencia en Go. <strong>Copiar JSON</strong>
da exactamente la salida de <code>datekeys inspect -json</code>.
</p>
</li>
<li>
<h3>Lo que queda para la apertura</h3>
<p>
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.
</p>
</li>
</ul>
</section>
<style>
.hero {
display: grid;
gap: 3rem;
padding-block: clamp(2.5rem, 6vw, 5rem) 3rem;
}
@media (min-width: 900px) {
.hero {
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
align-items: start;
gap: 4rem;
}
}
.pitch h1 {
max-width: 14em;
text-wrap: balance;
}
.pitch .lead {
margin-top: 1.25rem;
}
.actions {
margin-top: 2rem;
}
.preview {
margin: 0;
padding: 1.5rem;
border: 1px solid var(--rule);
border-radius: var(--radius);
background: var(--paper-2);
}
.preview figcaption {
font-size: var(--t-small);
color: var(--ink-muted);
margin-bottom: 1.25rem;
}
.step-name {
font-family: var(--mono);
font-size: 0.92rem;
font-weight: 600;
line-height: 2rem;
}
.step-gloss {
font-size: var(--t-small);
color: var(--ink-muted);
}
.guarantees {
padding-top: 2rem;
}
.guarantees ul {
list-style: none;
padding: 0;
margin-top: 1.5rem;
display: grid;
gap: 0 3rem;
}
@media (min-width: 720px) {
.guarantees ul {
grid-template-columns: 1fr 1fr;
}
}
.guarantees li {
border-top: 1px solid var(--rule);
padding-block: 1.25rem 1.5rem;
}
.guarantees h3 {
font-size: var(--t-body);
margin-bottom: 0.4rem;
}
.guarantees p {
color: var(--ink-muted);
max-width: 52ch;
}
</style>

@ -0,0 +1,359 @@
<script lang="ts">
import { tick } from 'svelte';
import { inspect } from '$lib/dkc/index.ts';
import { type CapsuleBytes, readCapsule } from '$lib/inspector/load.ts';
import { FIXTURES, type Fixture, fetchFixture } from '$lib/inspector/fixtures.ts';
import { buildReport, type Report } from '$lib/inspector/report.ts';
import { displayText, escapeInvisible, viewerTimeZone } from '$lib/inspector/format.ts';
import InspectionReport from '$lib/components/InspectionReport.svelte';
let report: Report | undefined = $state();
let problem: string | undefined = $state();
let notice: string | undefined = $state();
let busy = $state(false);
let dragging = $state(false);
let announcement = $state('');
let nowMs = $state(0);
let timeZone: string | undefined = $state();
// Only the latest load may show its result.
let loadId = 0;
// dragenter and dragleave fire for every element crossed.
let dragDepth = 0;
async function run(name: string, read: () => Promise<CapsuleBytes>, readNotice?: string): Promise<void> {
const id = ++loadId;
const shown = displayText(name).text;
busy = true;
problem = undefined;
notice = readNotice;
announcement = `Inspeccionando ${shown}.`;
try {
const { bytes, size } = await read();
// The base protocol V1: no extension registry, so every extension is
// unknown to this reader, as in `datekeys inspect`.
const inspection = await inspect(bytes);
if (id !== loadId) return;
report = buildReport({ fileName: name, bytes, size, inspection });
nowMs = Date.now();
timeZone = viewerTimeZone();
const verdict = report.failure
? `${shown}: rechazada en el paso ${report.failure.step}, ${report.failure.code}.`
: `${shown}: estructura válida en los pasos 1 a 8.`;
// The notice (only the first dropped file is inspected) is a plain
// paragraph that focus skips, so the status message carries it too.
announcement = readNotice ? `${readNotice} ${verdict}` : verdict;
await tick();
document.getElementById('report-title')?.focus();
} catch (err) {
if (id !== loadId) return;
report = undefined;
problem = describeProblem(shown, err);
announcement = readNotice ? `${readNotice} ${problem}` : problem;
} finally {
if (id === loadId) busy = false;
}
}
function describeProblem(name: string, err: unknown): string {
const message = err instanceof Error ? err.message : String(err);
if (/Web Crypto/.test(message)) {
return 'Este navegador solo ofrece Web Crypto (crypto.subtle) en contextos seguros, y el inspector lo necesita para comprobar el perfil fijado. Abre la página por https o en localhost.';
}
return `No se pudo leer ${name}: ${escapeInvisible(message)}`;
}
function inspectFile(file: File, readNotice?: string): void {
void run(file.name, () => readCapsule(file), readNotice);
}
function inspectFixture(f: Fixture): void {
void run(f.name, async () => {
const bytes = await fetchFixture(f);
return { bytes, size: bytes.length };
});
}
function onPick(event: Event & { currentTarget: HTMLInputElement }): void {
const input = event.currentTarget;
const file = input.files?.[0];
if (file !== undefined) inspectFile(file);
// Choosing the same file again must inspect it again.
input.value = '';
}
function carriesFiles(e: DragEvent): boolean {
return e.dataTransfer?.types.includes('Files') ?? false;
}
function onDragEnter(e: DragEvent): void {
if (!carriesFiles(e)) return;
e.preventDefault();
dragDepth++;
dragging = true;
}
function onDragOver(e: DragEvent): void {
if (!carriesFiles(e)) return;
e.preventDefault();
e.dataTransfer!.dropEffect = 'copy';
}
function onDragLeave(e: DragEvent): void {
if (!carriesFiles(e)) return;
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) dragging = false;
}
function onDrop(e: DragEvent): void {
if (!carriesFiles(e)) return;
// Without this, the browser would navigate to the dropped file.
e.preventDefault();
dragDepth = 0;
dragging = false;
const files = e.dataTransfer!.files;
const file = files[0];
if (file === undefined) return;
inspectFile(
file,
files.length > 1 ? `Has soltado ${files.length} ficheros; se inspecciona solo el primero, ${displayText(file.name).text}.` : undefined,
);
}
</script>
<svelte:head>
<title>Inspector de cápsulas DateKeys</title>
<meta
name="description"
content="Carga un fichero .dkc y comprueba los pasos 1 a 8 de DateKeys en este navegador, sin red y sin claves."
/>
</svelte:head>
<svelte:window ondragenter={onDragEnter} ondragover={onDragOver} ondragleave={onDragLeave} ondrop={onDrop} />
<div class="wrap page">
<div class="intro">
<h1>Inspector de cápsulas</h1>
<p class="lead">
Comprueba la parte pública de un fichero <code>.dkc</code> antes de su fecha de apertura: los pasos 1 a 8 de la
especificación (§63), con el mismo resultado que <code>datekeys inspect</code>. El fichero no sale de este navegador
y no se pide ninguna clave.
</p>
</div>
<section class="loader" aria-labelledby="loader-title">
<h2 id="loader-title" class="visually-hidden">Elegir una cápsula</h2>
<label class={['drop', dragging && 'dragging']}>
<input class="visually-hidden" type="file" accept=".dkc" onchange={onPick} />
<svg class="drop-icon" viewBox="0 0 48 48" aria-hidden="true" focusable="false">
<rect x="9" y="5" width="30" height="38" rx="2" />
<circle cx="24" cy="24" r="7" />
<circle class="seal" cx="24" cy="24" r="2.2" />
</svg>
<span class="drop-title">Elige un fichero .dkc</span>
<span class="drop-hint">o suéltalo en cualquier parte de la página</span>
</label>
<div class="fixtures">
<h3 id="fixtures-title">Cápsulas de prueba oficiales</h3>
<p class="muted">
Cápsulas de ejemplo de la implementación de referencia en Go, incluidas en el sitio (descripciones en inglés).
</p>
<ul aria-labelledby="fixtures-title">
{#each FIXTURES as f (f.name)}
<li>
<button type="button" class="fixture" onclick={() => inspectFixture(f)}>
<span class="fixture-name">{f.name}</span>
{#if f.description}
<span class="fixture-desc" lang="en">{f.description}</span>
{/if}
</button>
</li>
{/each}
</ul>
</div>
</section>
<p class="visually-hidden" role="status">{announcement}</p>
{#if busy}
<p class="busy" aria-hidden="true">Inspeccionando…</p>
{/if}
{#if notice}
<p class="notice">{notice}</p>
{/if}
{#if problem}
<div class="problem">
<p>{problem}</p>
</div>
{/if}
{#if report}
{#key report}
<InspectionReport {report} {nowMs} {timeZone} />
{/key}
{/if}
</div>
{#if dragging}
<div class="drop-overlay" aria-hidden="true">
<p>Suelta el fichero para inspeccionarlo</p>
</div>
{/if}
<style>
/* minmax(0, 1fr): a child's unbreakable content must not widen the track
beyond the screen. */
.page {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 2rem;
padding-top: clamp(2rem, 5vw, 3.5rem);
}
.intro .lead {
margin-top: 1rem;
}
.loader {
display: grid;
gap: 1.5rem;
margin-bottom: 1rem;
}
@media (min-width: 900px) {
.loader {
grid-template-columns: minmax(0, 1fr) minmax(0, 1.25fr);
align-items: stretch;
}
}
.drop {
display: grid;
place-items: center;
align-content: center;
gap: 0.35rem;
min-height: 13rem;
padding: 1.5rem;
border: 2px dashed var(--rule-strong);
border-radius: var(--radius);
background: var(--paper-2);
text-align: center;
cursor: pointer;
}
.drop:hover,
.drop.dragging {
border-color: var(--ink);
}
.drop:has(input:focus-visible) {
outline: 2px solid var(--ink);
outline-offset: 3px;
border-color: var(--ink);
}
.drop-icon {
width: 3rem;
height: 3rem;
margin-bottom: 0.5rem;
fill: none;
stroke: var(--ink);
stroke-width: 2;
}
.drop-icon .seal {
fill: var(--orange);
stroke: none;
}
.drop-title {
font-size: var(--t-h3);
font-weight: 650;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 0.2em;
}
.drop-hint {
color: var(--ink-muted);
}
.fixtures h3 {
font-size: var(--t-body);
}
.fixtures > p {
margin-top: 0.25rem;
font-size: var(--t-small);
}
.fixtures ul {
list-style: none;
padding: 0;
margin-top: 0.75rem;
border-top: 1px solid var(--rule);
}
.fixtures li {
border-bottom: 1px solid var(--rule);
}
.fixture {
display: grid;
gap: 0.1rem;
width: 100%;
padding: 0.65rem 0.5rem;
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
}
.fixture:hover {
background: var(--paper-2);
}
.fixture-name {
font-family: var(--mono);
font-size: 0.92rem;
font-weight: 600;
text-decoration: underline;
text-decoration-color: var(--rule-strong);
text-underline-offset: 0.2em;
overflow-wrap: anywhere;
}
.fixture:hover .fixture-name {
text-decoration-color: currentColor;
}
.fixture-desc {
font-size: var(--t-small);
color: var(--ink-muted);
}
.busy,
.notice {
color: var(--ink-muted);
}
/* Both can quote a file name, which may have no break points. */
.notice,
.problem {
overflow-wrap: anywhere;
}
.problem {
border-left: 4px solid var(--fail);
background: var(--fail-bg);
padding: 0.85rem 1rem;
border-radius: 0 var(--radius) var(--radius) 0;
max-width: var(--measure);
}
.drop-overlay {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 1rem;
pointer-events: none;
background: color-mix(in srgb, var(--paper) 88%, transparent);
}
.drop-overlay::before {
content: '';
position: absolute;
inset: 12px;
border: 3px dashed var(--ink);
border-radius: 8px;
}
.drop-overlay p {
font-size: var(--t-h2);
font-weight: 700;
text-align: center;
}
</style>

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#0e0e0e"/>
<circle cx="16" cy="16" r="9" fill="none" stroke="#f2f1ef" stroke-width="1.6"/>
<circle cx="16" cy="16" r="3" fill="#f18225"/>
</svg>

After

Width:  |  Height:  |  Size: 247 B

@ -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 (<div id="svelte-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 <meta http-equiv>, the first element
// of <head>. 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;

@ -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"]
}
}

@ -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'],
},
},
});

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

Loading…
Cancel
Save

Powered by TurnKey Linux.