Phase 2, step 8: the open action of /inspect

After steps 1 to 8, a valid capsule whose date has passed on the device
clock can be opened in the page: steps 9 to 18 of spec section 63 with
open, loaded on demand with a dynamic import (opener.ts), so noble and
age-encryption stay out of the first load of every page.

- The release is supplied directly by the person (spec 63, step 10):
  drand's JSON answer or the bare signature, pasted after opening the
  drand URL the page links to, or the release in the record of an
  official fixture. The page never fetches it and reads only its round
  and signature (spec 11, 13). The CSP is unchanged.
- time_and_key credentials: a .dkk (readAccessKey reads at most
  12 bytes + 16 MiB + 1) or age identities, one per line.
- The plaintext of the person's own file goes to a temporary OPFS file
  (tempfile.ts), committed only after step 18 (spec 56), offered for
  download and deleted on request, with another capsule, on pagehide
  and, if left over, on the next visit. One directory and one Web Lock
  per tab keep other tabs' clean-up away from files in use. Without
  OPFS, or when the browser refuses it, capsules up to 64 MiB open in
  memory. An opening in progress stops when another capsule is loaded.
- opening.ts builds the page model of steps 9 to 18 as the reference
  records them; fixtures show their plaintext and compare its SHA-256
  with their record.
- licenses.txt: the notices of tlock-js (ibe.ts) and age (bech32.ts),
  the license of every package in the client bundle, Vite's and
  rolldown's runtime code, and the site's own license. check-build now
  fails if a notice is missing, or if a page loads noble, @scure/base
  or age-encryption with its first load.
- The home page no longer says that the page never asks for keys.

Checked in the browser on the production build: the time_only,
time_and_key_portable (with its .dkk) and time_and_key_recipients (with
a pasted identity) fixtures open with the SHA-256 of their records; a
tampered signature fails at step 10 and a tampered STREAM chunk at step
17, with no download and no file left; an own file opens to OPFS,
downloads without a CSP violation and is deleted with its lock; a left
over directory goes on the next visit; no request leaves the origin.
An adversarial review (four dimensions, each finding checked by a
refuter) confirmed 15 findings, all fixed here.

2611 tests; coverage 100 % of the new modules, now a threshold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
dev 1 week ago
parent 5f1b36945d
commit 48d6704b4b

@ -20,9 +20,11 @@ Implementa la especificación DateKeys 0.8.2 (tag `spec-v0.8.2` de `datekeys-go`
- la apertura, pasos 9 a 18 de §63 (`open.ts`), con el texto en claro en memoria. Las identidades estrictas de `agewrap` se apoyan en `x25519.ts`, que abre cada stanza X25519 por separado, y en `bech32.ts`. Los 65 casos del corpus de mutaciones pasan por `open` con el código y el paso de Go, y los cinco fixtures oficiales se abren a su texto en claro; - la apertura, pasos 9 a 18 de §63 (`open.ts`), con el texto en claro en memoria. Las identidades estrictas de `agewrap` se apoyan en `x25519.ts`, que abre cada stanza X25519 por separado, y en `bech32.ts`. Los 65 casos del corpus de mutaciones pasan por `open` con el código y el paso de Go, y los cinco fixtures oficiales se abren a su texto en claro;
- `@noble/ciphers` 2.4.0 como dependencia directa, aprobada el 28-09-2026: la copia que ya trae `age-encryption`; - `@noble/ciphers` 2.4.0 como dependencia directa, aprobada el 28-09-2026: la copia que ya trae `age-encryption`;
- la apertura en streaming. La entrada puede ser un `Blob`, del que se lee solo el prefijo de los pasos 1 a 8 (`prefix.ts`, antes en la página) y se descifra `PAYLOAD_AGE` en streaming. La salida puede ser un `WritableStream`, que se cierra solo tras el paso 18 y se aborta ante cualquier fallo. En el navegador, con un fichero OPFS, un fallo de STREAM deja intacto su contenido anterior. - la apertura en streaming. La entrada puede ser un `Blob`, del que se lee solo el prefijo de los pasos 1 a 8 (`prefix.ts`, antes en la página) y se descifra `PAYLOAD_AGE` en streaming. La salida puede ser un `WritableStream`, que se cierra solo tras el paso 18 y se aborta ante cualquier fallo. En el navegador, con un fichero OPFS, un fallo de STREAM deja intacto su contenido anterior.
- el cifrado del stanza tlock (`encryptOnG2RFC9380`) y el `Recipient` de `OUTER_TIME_AGE` (`tlock.ts`). Con sigma fijo, el cifrado reproduce byte a byte los vectores de Go. Además Go abre lo que cifra esta librería: el cuerpo IBE con `tlock.TimeUnlock` y el fichero `age` con `agewrap.NewTimeIdentity`. - el cifrado del stanza tlock (`encryptOnG2RFC9380`) y el `Recipient` de `OUTER_TIME_AGE` (`tlock.ts`). Con sigma fijo, el cifrado reproduce byte a byte los vectores de Go. Además Go abre lo que cifra esta librería: el cuerpo IBE con `tlock.TimeUnlock` y el fichero `age` con `agewrap.NewTimeIdentity`;
- la acción "abrir" de `/inspect` (paso 8), cuyo código, con noble y `age-encryption`, se carga bajo demanda:
- el release lo pega quien abre (la respuesta de drand o la firma sola), o sale del registro de un fixture. La página nunca lo pide a la red y solo lee su ronda y su firma;
- las credenciales de `time_and_key` son una `.dkk` o identidades `AGE-SECRET-KEY-1…`;
- el texto en claro de un fichero propio va a un fichero temporal de OPFS que solo se confirma tras el paso 18. Se ofrece para descargar y se borra al pedirlo, con otra cápsula, al salir o en la visita siguiente;
- `readAccessKey` en `prefix.ts`.
- `VERSION` y `SPEC_VERSION`, también en el pie de la página. - `VERSION` y `SPEC_VERSION`, también en el pie de la página.
- `licenses.txt` en el sitio, con los avisos de `tlock-js` y `age` y los de cada paquete del bundle.
### Pendiente para 0.1.0
- La acción "abrir" en `/inspect`, cargada bajo demanda, con el fichero temporal de OPFS, la cuota libre y la descarga (paso 8).

@ -10,7 +10,8 @@ La implementación de referencia es la librería Go `g.activething.com/go/DateKe
|---|---|---|---| |---|---|---|---|
| Codec CBOR del subconjunto, parsers de schema, DateKey, `inspect` | `src/lib/dkc/` | 4 | hecho | | Codec CBOR del subconjunto, parsers de schema, DateKey, `inspect` | `src/lib/dkc/` | 4 | hecho |
| Página inspector, sin red | SvelteKit estático: `src/routes/`, `src/lib/inspector/`, `src/lib/components/` | 5 | hecho | | 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: [docs/PLAN_fase2_ibe_noble2.md](docs/PLAN_fase2_ibe_noble2.md) | 6 | en curso: dependencias y guardas hechas (paso 2 de la fase) | | Apertura de cápsulas en el navegador, y la acción "abrir" de la página | fase 2: [docs/PLAN_fase2_ibe_noble2.md](docs/PLAN_fase2_ibe_noble2.md) | 6 | hecho (pasos 2 a 8 de la fase 2) |
| Escritura de cápsulas en TypeScript | fase 3 | | pendiente; el cifrado del stanza tlock ya está (`tlock.ts`) |
## Versiones ## Versiones
@ -24,10 +25,10 @@ Hay tres números de versión, cada uno con su significado, como en la referenci
`version.test.ts` comprueba que `VERSION` coincide con `package.json` y con su lockfile, y que `SPEC_VERSION` es la versión que nombran los vectores y fixtures compartidos; `vectors.test.ts` exige esa versión a cada fichero. El pie de la página muestra las dos. `version.test.ts` comprueba que `VERSION` coincide con `package.json` y con su lockfile, y que `SPEC_VERSION` es la versión que nombran los vectores y fixtures compartidos; `vectors.test.ts` exige esa versión a cada fichero. El pie de la página muestra las dos.
La versión actual es `0.1.0-dev`. Será `0.1.0` cuando la fase 2 añada la apertura de cápsulas. Cubre: La versión actual es `0.1.0-dev`. La fase 2 ya añade la apertura de cápsulas, en la librería y en la página; pasar a `0.1.0` queda a decisión del autor. Cubre:
- la especificación 0.8.2, con versiones de formato 1; - la especificación 0.8.2, con versiones de formato 1;
- solo el scheme de Quicknet (`bls-unchained-g1-rfc9380`): un perfil de otro scheme se inspecciona, pero su release no se verifica (decisión 3 del plan de la fase 2); - solo el scheme de Quicknet (`bls-unchained-g1-rfc9380`): un perfil de otro scheme se inspecciona, pero su release no se verifica (decisión 3 del plan de la fase 2);
- la inspección de los pasos 1 a 8 y la apertura de los pasos 9 a 18 (`open.ts`), desde un `Uint8Array` o un `Blob` y hacia memoria o hacia un stream de salida, como el de un fichero OPFS. El cifrado llega en la fase 3; - la inspección de los pasos 1 a 8 y la apertura de los pasos 9 a 18 (`open.ts`), desde un `Uint8Array` o un `Blob` y hacia memoria o hacia un stream de salida, como el de un fichero OPFS, también desde la página `/inspect`. Escribir cápsulas llega en la fase 3;
- todos los vectores y fixtures compartidos de `datekeys-go` en `9ac9cd9` (`spec-v0.8.2`); - todos los vectores y fixtures compartidos de `datekeys-go` en `9ac9cd9` (`spec-v0.8.2`);
- navegadores con Web Crypto y Node 20 o posterior. - navegadores con Web Crypto y Node 20 o posterior.
@ -35,7 +36,7 @@ La versión actual es `0.1.0-dev`. Será `0.1.0` cuando la fase 2 añada la aper
## `src/lib/dkc` ## `src/lib/dkc`
Lo que hay hoy (pasos 1 a 8) no importa ninguna dependencia. Funciona en navegadores y en Node 20+: solo usa `Uint8Array`, `DataView`, `TextEncoder`/`TextDecoder`, `BigInt` y `crypto.subtle` (SHA-256). La fase 2 añade las dependencias de ejecución de su sección, y noble solo lo importarán `ibe.ts` y `release.ts`. La inspección (pasos 1 a 8) no importa ninguna dependencia. Funciona en navegadores y en Node 20+: solo usa `Uint8Array`, `DataView`, `TextEncoder`/`TextDecoder`, `BigInt` y `crypto.subtle` (SHA-256). La apertura (fase 2) usa las dependencias de ejecución de su sección: noble solo lo importan `digest.ts`, `ibe.ts`, `release.ts` y `x25519.ts`, y `age-encryption` solo `open.ts` y `tlock.ts`.
`crypto.subtle` solo existe en contextos seguros: `https`, o `http` en `localhost`. La página del paso 5 servida por `http` desde una IP de la red local (por ejemplo `vite --host` para probar en un móvil) no lo tiene, y `inspect` rechaza entonces con un `Error` que lo dice (`SHA-256 needs Web Crypto (crypto.subtle), …`) en vez de dar un veredicto. El registro por defecto no memoriza ese fallo: la siguiente llamada lo vuelve a intentar. `crypto.subtle` solo existe en contextos seguros: `https`, o `http` en `localhost`. La página del paso 5 servida por `http` desde una IP de la red local (por ejemplo `vite --host` para probar en un móvil) no lo tiene, y `inspect` rechaza entonces con un `Error` que lo dice (`SHA-256 needs Web Crypto (crypto.subtle), …`) en vez de dar un veredicto. El registro por defecto no memoriza ese fallo: la siguiente llamada lo vuelve a intentar.
@ -60,7 +61,8 @@ Lo que hay hoy (pasos 1 a 8) no importa ninguna dependencia. Funciona en navegad
| `framing.ts` | Prelude DKC1 (16 bytes) y DKK1 (12 bytes) en el orden de §23 y §40, longitudes de 1 byte hasta los límites de §57, y troceo de secciones | `capsule/framing.go` | | `framing.ts` | Prelude DKC1 (16 bytes) y DKK1 (12 bytes) en el orden de §23 y §40, longitudes de 1 byte hasta los límites de §57, y troceo de secciones | `capsule/framing.go` |
| `age.ts` | Parser estricto de la cabecera `age` v1 (§28.1) sobre los ficheros binarios, con los textos de error de `age`; reglas de stanzas; `MAX_AGE_HEADER_LEN` (2 MiB), el límite que usa la página para leer solo el prefijo de un `.dkc` grande | `agewrap`, `filippo.io/age/internal/format` | | `age.ts` | Parser estricto de la cabecera `age` v1 (§28.1) sobre los ficheros binarios, con los textos de error de `age`; reglas de stanzas; `MAX_AGE_HEADER_LEN` (2 MiB), el límite que usa la página para leer solo el prefijo de un `.dkc` grande | `agewrap`, `filippo.io/age/internal/format` |
| `inspect.ts` | Pasos 1 a 8 de §63 y la vista JSON de `datekeys inspect -json` (`inspectView`, `inspectJSON`) | `capsule/inspect.go`, `internal/inspectview` | | `inspect.ts` | Pasos 1 a 8 de §63 y la vista JSON de `datekeys inspect -json` (`inspectView`, `inspectJSON`) | `capsule/inspect.go`, `internal/inspectview` |
| `index.ts` | Reexporta todo salvo la fase 2 (`ibe.ts`, `release.ts`, `open.ts`, `tlock.ts`, `x25519.ts`, `bech32.ts` y `digest.ts`). La página importa `index.ts`, y reexportarlos metería noble en `/inspect` (de 58,7 a 84,9 KB con gzip) aunque no los use, porque noble ejecuta código al cargarse. El paso 8 cargará la apertura bajo demanda | | | `prefix.ts` | Lecturas acotadas de un `Blob`: el prefijo de un `.dkc` que necesitan los pasos 1 a 8 (`readCapsule`) y, de una `.dkk`, como mucho 12 bytes + 16 MiB + 1 (`readAccessKey`), que dan el mismo resultado que el fichero entero | |
| `index.ts` | Reexporta todo salvo la fase 2 (`ibe.ts`, `release.ts`, `open.ts`, `tlock.ts`, `x25519.ts`, `bech32.ts` y `digest.ts`). La página importa `index.ts`, y reexportarlos metería noble en la primera carga de `/inspect` aunque no los use, porque noble ejecuta código al cargarse. La página carga la apertura bajo demanda (`src/lib/inspector/opener.ts`) | |
| `testing/` | Solo para tests: lectura de `testdata/` y de sus formatos (`vectors.ts`: ediciones, vectores), constructores de CBOR en hex, cirugía de cápsulas | | | `testing/` | Solo para tests: lectura de `testdata/` y de sus formatos (`vectors.ts`: ediciones, vectores), constructores de CBOR en hex, cirugía de cápsulas | |
Los tests (`*.test.ts`) están junto a cada fichero. Los tests (`*.test.ts`) están junto a cada fichero.
@ -99,7 +101,21 @@ Sitio SvelteKit estático (`@sveltejs/adapter-static`, `strict`): las dos págin
- 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, de que nada las vincula al resto de la cápsula antes del paso 15 y de que ni entonces prueban autoría (§55.1); - 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, de que nada las vincula al resto de la cápsula antes del paso 15 y de que ni entonces prueban autoría (§55.1);
- **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. - **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. 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.
### Abrir
Tras los pasos 1 a 8, si la cápsula es válida y su fecha de apertura ya pasó según el reloj del dispositivo, la página ofrece abrirla: pasos 9 a 18 de §63 con `open` (fase 2, paso 8). Antes de la fecha dice que nadie puede abrirla todavía y no pide nada.
- **El release lo da quien abre** (decisión 4 del plan de la fase 2, confirmada el 28-09-2026): la página nunca lo pide a la red. Se pega la respuesta JSON de drand o la firma sola en hexadecimal (`release-input.ts`), y solo se leen `round` y `signature`: cualquier otro campo, como una clave pública, se ignora, porque la raíz de confianza es el perfil fijado (§11, §13). La página enlaza la URL de drand de esa ronda (`https://api.drand.sh/<chain hash>/public/<ronda>`, con `rel="noopener noreferrer"`), que abre la persona en otra pestaña. Es un release que suministra quien llama: el paso 10 lo verifica y da sus códigos (`ERR_ROUND_MISMATCH`, `ERR_RELEASE_INVALID`). En los fixtures oficiales el campo viene relleno con el release de su registro, que es el que publicó drand.
- **Credenciales**, solo en `time_and_key`: una `.dkk` (se leen como mucho 16 MiB + 13 bytes, `readAccessKey`) o identidades `AGE-SECRET-KEY-1…`, una por línea, como un fichero de identidades de `age`. Un error de una línea se da por su número, nunca por su contenido, y el foco va al campo; las identidades se borran tras usarlas. En `time_only` la página no las pide y `open` no las usa.
- **El código de la apertura se carga bajo demanda**: la página importa `opener.ts` con `import()` al pulsar "Abrir", y con él `open.ts`, noble y `age-encryption`. `opening.ts`, que construye lo que se muestra, solo importa tipos de `open.ts`. `check-build.mjs` comprueba que ninguna página carga noble, `@scure/base` ni `age-encryption` en la primera carga.
- **El texto en claro** de un fixture se abre en memoria y se muestra, con su SHA-256 comparado con el del registro. El de un fichero propio va a un fichero temporal del almacenamiento privado del navegador (OPFS, `tempfile.ts`), escrito con `createWritable`. El navegador guarda lo escrito en un fichero de intercambio que solo se confirma al cerrarlo, y `open` lo cierra tras el paso 18 y lo aborta ante cualquier fallo (§56). Después se ofrece para descargar, con el nombre del `.dkc` sin la extensión, como hace `age`, y cada carácter no imprimible cambiado por `_`, para que un carácter de control bidireccional no disfrace la extensión. Antes de empezar se compara el tamaño de `PAYLOAD_AGE` con la cuota libre (`navigator.storage.estimate()`).
- **El fichero temporal se borra** al pulsar "Borrar", al abrir o cargar otra cápsula y al salir de la página (`pagehide`). Si el navegador terminó antes, se borra en la siguiente visita. Cada pestaña escribe en su propio directorio, `datekeys-open/<id>`, y tiene un Web Lock con ese nombre mientras existe. Así la limpieza de otra pestaña nunca borra un fichero en uso; sin Web Locks, solo borra lo que tiene más de un día. Si el navegador no tiene OPFS o `createWritable`, o los rechaza (una ventana privada, datos del sitio bloqueados), o la cuota no alcanza, la página abre en memoria hasta 64 MiB. Una apertura en curso se detiene si se carga otra cápsula: su salida deja de aceptar datos, `open` falla en el paso 17 y su fichero se borra. Si la página vuelve de la caché de atrás y adelante tras borrar el fichero, ya no lo ofrece.
- **La fecha** se vuelve a mirar cuando llega y cuando la pestaña vuelve a verse, así que una cápsula inspeccionada antes de su fecha se puede abrir sin cargarla otra vez. `open` comprueba de nuevo el reloj en el paso 9.
- **El resultado** muestra cada paso de 9 a 18 como muestra los de 1 a 8, con los checks que registra la referencia (el paso 17 solo aparece si falla; el 18 lo sigue), el release verificado, las extensiones de CONTROL_CBOR y el tamaño y el SHA-256 del texto en claro. También avisa de que el texto está autenticado, pero no prueba quién lo escribió ni que sea el original si otros abrieron la cápsula antes (§55.1).
Medido en Chromium (el navegador de la app de escritorio) sobre la compilación de producción: los pasos 9 a 18 tardan 0,34 s en `time_only`, la primera apertura de la página, y 0,11 s en `time_and_key_portable`, la siguiente, sin contar la descarga del código y en el hilo principal (la política no permite workers).
| Fichero | Contenido | | Fichero | Contenido |
|---|---| |---|---|
@ -108,12 +124,16 @@ No pide ni acepta secretos. Todo el texto leído de la cápsula pasa por interpo
| `src/lib/inspector/diagnostic.ts` | Notación de diagnóstico CBOR (RFC 8949 §8) de `walk`, acotada a 16 384 caracteres | | `src/lib/inspector/diagnostic.ts` | Notación de diagnóstico CBOR (RFC 8949 §8) de `walk`, acotada a 16 384 caracteres |
| `src/lib/dkc/prefix.ts` | Lectura por prefijo, que también usa la apertura: de un `.dkc` grande solo se leen 16 + PUBLIC_HEADER_LEN + SEALED_CONTROL_LEN + 2 MiB + 1 bytes, y solo 16 si los pasos 1 y 2 rechazan el prelude (otro tipo de fichero, un `.dkk`, longitudes fuera de §57), siempre con el mismo resultado que el fichero entero (lo comprueba `prefix.test.ts`) | | `src/lib/dkc/prefix.ts` | Lectura por prefijo, que también usa la apertura: de un `.dkc` grande solo se leen 16 + PUBLIC_HEADER_LEN + SEALED_CONTROL_LEN + 2 MiB + 1 bytes, y solo 16 si los pasos 1 y 2 rechazan el prelude (otro tipo de fichero, un `.dkk`, longitudes fuera de §57), siempre con el mismo resultado que el fichero entero (lo comprueba `prefix.test.ts`) |
| `src/lib/inspector/fixtures.ts` | Los fixtures oficiales, empaquetados desde `testdata/fixtures` | | `src/lib/inspector/fixtures.ts` | Los fixtures oficiales, empaquetados desde `testdata/fixtures` |
| `src/lib/components/` | `InspectionReport`, `StepList`, `ExtensionList`, `DataView`, `Mark` | | `src/lib/inspector/release-input.ts` | Lee el release pegado (respuesta de drand o firma sola) y construye la URL de drand de la ronda |
| `src/lib/inspector/opener.ts` | La apertura, cargada bajo demanda: identidades, `.dkk`, `open` con el release suministrado, SHA-256 del texto en claro |
| `src/lib/inspector/opening.ts` | `buildOpenReport`: el modelo de la apertura (pasos 9 a 18, release, extensiones de CONTROL_CBOR, texto en claro), sin DOM ni reloj; nombre del fichero descifrado |
| `src/lib/inspector/tempfile.ts` | El fichero temporal de OPFS, con un directorio y un Web Lock por pestaña, la cuota libre y la limpieza de lo que quedó |
| `src/lib/components/` | `InspectionReport`, `OpenPanel`, `StepList`, `ExtensionList`, `DataView`, `Mark` |
| `src/routes/` | Layout, portada e inspector | | `src/routes/` | Layout, portada e inspector |
### Fixtures ### 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`. `fixtures.ts` importa con `import.meta.glob` los `.dkc` de `testdata/fixtures` como URL (`?url`) y, de cada registro JSON, solo tres campos públicos: `description`, `release` (la ronda y la firma que publicó drand, con las que la página abre el fixture) y `plaintext_sha256` (para comparar con lo descifrado). `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 ### Sin red: la Content-Security-Policy
@ -125,12 +145,19 @@ img-src 'self'; manifest-src 'self'; object-src 'none'; script-src 'self' 'sha25
style-src 'self'; style-src-attr 'unsafe-hashes' 'sha256-…'; base-uri 'none'; form-action 'none' 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. - `connect-src 'self'`: `fetch` solo llega al propio origen, y solo se usa para los fixtures. Tampoco llega a URL `blob:`. La descarga del texto en claro es una navegación a una URL `blob:` y el enlace a drand lo abre la persona en otra pestaña: ninguno es una conexión de la página.
- `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). - `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 '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). - `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; si aparece en el sitio algún secreto de los fixtures (`.dkk`, textos en claro, identidades, `payload_identity`, `access_material`, `control_cbor`); o si el bundle del cliente contiene `tlock-js`, `drand-client` o helpers de Babel, o una copia anidada de un paquete que no sea la de noble bajo `@noble/post-quantum`. `vite.config.ts` registra los módulos de cada chunk en `.svelte-kit/output/client-modules.json`, fuera del sitio. Al terminar informa del JavaScript que carga cada página, en bytes y con gzip, y de los paquetes npm que lleva el bundle. `npm run build` ejecuta después `scripts/check-build.mjs` (`postbuild`; también `npm run build:check`), que falla si una ruta no tiene su HTML prerenderizado; si una página no tiene exactamente esa política, con la etiqueta antes de cualquier elemento que cargue recursos; si un script en línea no está en `script-src` o sobra un hash; si `style-src-attr` no coincide con los atributos `style` del bundle; si hay estilos en línea, manejadores de eventos en atributos, `@import` o URL a otro origen; si algún `.dkc` oficial no está byte a byte; si aparece en el sitio algún secreto de los fixtures (`.dkk`, textos en claro, identidades, `payload_identity`, `access_material`, `control_cbor`); si el bundle del cliente contiene `tlock-js`, `drand-client` o helpers de Babel, o una copia anidada de un paquete que no sea la de noble bajo `@noble/post-quantum`; si una página carga noble, `@scure/base` o `age-encryption` en su primera carga, o si `/inspect` no puede cargar bajo demanda `age-encryption`, `@noble/curves` y `@noble/ciphers`; o si a `licenses.txt` le falta el aviso de un paquete del bundle, las líneas de copyright de un módulo de `src/` derivado de otro proyecto o la licencia del sitio. `vite.config.ts` registra los módulos de cada chunk en `.svelte-kit/output/client-modules.json`, fuera del sitio. Al terminar informa del JavaScript que carga cada página, en bytes y con gzip, en la primera carga y bajo demanda, y de los paquetes npm que lleva el bundle.
### Avisos de licencia: `licenses.txt`
El JavaScript minimizado no conserva comentarios, así que el sitio publica `licenses.txt`, enlazado desde el pie de cada página. Lo escribe un plugin de `vite.config.ts` al compilar el cliente, con tres partes:
- los módulos de `src/` derivados de otros proyectos, con el aviso de su cabecera: `ibe.ts` (de `tlock-js`, MIT) y `bech32.ts` (de `age`, MIT);
- el fichero de licencia de cada paquete npm con algún módulo en el bundle;
- la licencia Apache-2.0 del sitio.
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`. 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`.
@ -189,7 +216,7 @@ Umbrales de cobertura (`vitest.config.ts`): `cbor.ts`, `ibe.ts`, `release.ts`, `
## Dependencias de ejecución ## Dependencias de ejecución
Aprobadas en el plan de la fase 2 (sección 3 y decisión 5) e instaladas con su versión exacta. `ibe.ts` importa noble desde el paso 3, pero la página todavía no lo usa, así que el sitio no cambia hasta que llegue la apertura. Aprobadas en el plan de la fase 2 (sección 3 y decisión 5) e instaladas con su versión exacta. La página solo las carga al abrir una cápsula (ver [Abrir](#abrir)).
| Paquete | Versión | Licencia | Uso | | Paquete | Versión | Licencia | Uso |
|---|---|---|---| |---|---|---|---|
@ -218,7 +245,17 @@ Coste medido en el bundle el 28-09-2026, con una compilación de prueba de Vite
| `bls12_381` y `sha256` de noble | 92 637 B | 28 090 B | | `bls12_381` y `sha256` de noble | 92 637 B | 28 090 B |
| Todo lo anterior | 239 454 B | 72 783 B | | Todo lo anterior | 239 454 B | 72 783 B |
`age-encryption` importa de forma estática sus recipients ML-KEM híbridos, P-256 y scrypt. Por eso el `Decrypter` arrastra `@noble/post-quantum` y la copia 2.0.1 de noble, aunque DateKeys no los use: son unos 99 KB de los 212 KB de código antes de minificar. Como referencia, `/inspect` carga hoy unos 157 KB de JavaScript (58,7 KB con gzip). La cifra exacta cambia unos bytes en cada compilación, por la versión que SvelteKit incrusta. `age-encryption` importa de forma estática sus recipients ML-KEM híbridos, P-256 y scrypt. Por eso el `Decrypter` arrastra `@noble/post-quantum` y la copia 2.0.1 de noble, aunque DateKeys no los use: son unos 99 KB de los 212 KB de código antes de minificar.
En el sitio, según `check-build.mjs` el 28-09-2026:
| `/inspect` | JavaScript | gzip |
|---|---|---|
| Primera carga, antes del paso 8 | unos 157 KB | 58,7 KB |
| Primera carga, con la acción "abrir" | 187 700 B | 68 197 B |
| Bajo demanda, al abrir: `opener.ts`, `open.ts`, noble y `age-encryption` | 183 747 B | 66 882 B |
Los 9,5 KB con gzip que crece la primera carga son la interfaz de la apertura (`OpenPanel.svelte`) y sus módulos sin noble. Las cifras exactas cambian unos bytes en cada compilación, por la versión que SvelteKit incrusta.
`npm audit --omit=dev` no encuentra vulnerabilidades. El `npm audit` completo encuentra 2 de gravedad baja en el tooling: `cookie` < 0.7.0 (GHSA-pxg6-pf52-xh8x), que llega a través de `@sveltejs/kit` 2.70.3. Esa es la última versión y sigue pidiendo `cookie` ^0.6.0. Solo afecta a la gestión de cookies del servidor de SvelteKit, que un sitio estático no usa. `npm audit --omit=dev` no encuentra vulnerabilidades. El `npm audit` completo encuentra 2 de gravedad baja en el tooling: `cookie` < 0.7.0 (GHSA-pxg6-pf52-xh8x), que llega a través de `@sveltejs/kit` 2.70.3. Esa es la última versión y sigue pidiendo `cookie` ^0.6.0. Solo afecta a la gestión de cookies del servidor de SvelteKit, que un sitio estático no usa.
@ -261,4 +298,4 @@ Copia actual: la de `testdata/SOURCE.json` (rama `v0.8.2`).
## Licencia ## Licencia
Apache-2.0 ([LICENSE](LICENSE)), como la librería Go de referencia. El código que se derive de terceros conserva su aviso de copyright y licencia en el propio fichero; el primero previsto es el núcleo IBE de la fase 2, derivado de `tlock-js` (Apache-2.0 OR MIT). Apache-2.0 ([LICENSE](LICENSE)), como la librería Go de referencia. El código que se derive de terceros conserva su aviso de copyright y licencia en el propio fichero: el núcleo IBE (`ibe.ts`), derivado de `tlock-js` (Apache-2.0 OR MIT, usado bajo MIT), y `bech32.ts`, portado de `age` (MIT). El sitio publica esos avisos y los de sus paquetes npm en `licenses.txt`.

@ -148,7 +148,12 @@ Todo en un único cambio normativo, con vectores congelados, según la política
## 9. Página ## 9. Página
La ruta `/inspect` gana una acción "abrir": con un fixture o un `.dkc` arrastrado, un release pegado o el del sidecar, y las identidades `.dkk` cuando la política las exige. Muestra el resultado de cada paso 9 a 18 como hoy muestra 1 a 8, y el SHA-256 del plaintext; para los fixtures, además el plaintext. Sin red: la CSP no cambia y `check-build` lo comprueba. El plaintext nunca se guarda ni se envía. La ruta `/inspect` gana una acción "abrir": con un fixture o un `.dkc` arrastrado, un release pegado o el del sidecar, y las identidades `.dkk` cuando la política las exige. Muestra el resultado de cada paso 9 a 18 como hoy muestra 1 a 8, y el SHA-256 del plaintext; para los fixtures, además el plaintext. Sin red: la CSP no cambia y `check-build` lo comprueba. El plaintext nunca se envía.
Precisión aprobada por el autor el 28-09-2026, tras revisar lo que dice el protocolo:
- **El plaintext de un fichero propio se guarda solo en un fichero temporal** del almacenamiento privado del navegador (OPFS), hasta la descarga. Es el "fichero temporal" que recomienda §56, y ninguna regla del protocolo prohíbe guardarlo en el cliente. Se borra al pedirlo, al abrir o cargar otra cápsula, al salir de la página y, si quedó, en la siguiente visita.
- **El release lo suministra quien abre** (§63 paso 10): pegado de la respuesta de drand, que la persona abre en otra pestaña desde un enlace de la página, o el del registro de un fixture. Solo se leen su ronda y su firma (§11, §13).
- **§48 y §49 siguen pendientes.** Con solo releases suministrados, la librería no cumple aún el SHOULD de §48 (varios relays) y no resuelve por sí misma el de §49 (obtener el release directamente del proveedor, sin la API DateKeys). Los cubrirá la fuente drand opcional del SDK, nunca activa por defecto en la página (decisión 4, sección 12).
--- ---
@ -164,7 +169,7 @@ La ruta `/inspect` gana una acción "abrir": con un fixture o un `.dkc` arrastra
| 5 | `open.ts` con la `Identity` propia, pasos 9 a 18, `open.test.ts` | los cinco fixtures se abren y el plaintext coincide con el sidecar; el corpus de mutaciones existente reproduce código y paso.<br>5a hecho el 28-09-2026, con el texto en claro en memoria:<br>- los 65 casos del corpus pasan por `open` con el código y el paso de Go;<br>- los cinco fixtures se abren con cada credencial;<br>- los textos siguen a `capsule.Open` y `agewrap`.<br>El paso 13 exige probar cada identity contra cada stanza X25519, y `age-encryption` no expone su `X25519Identity`. Por eso `x25519.ts` abre los stanzas de uno en uno, con ChaCha20-Poly1305 de `@noble/ciphers` 2.4.0, dependencia aprobada el 28-09-2026 porque es la copia que ya usa `age-encryption`. `bech32.ts` lee las identidades `AGE-SECRET-KEY-1…`.<br>`index.ts` aún no reexporta la apertura, que metería noble en `/inspect`: el paso 8 la cargará bajo demanda.<br>5b hecho el 28-09-2026: `open` acepta un `Blob`, del que lee solo el prefijo de los pasos 1 a 8 (`prefix.ts`, movido de la página a la librería), calcula el `capsule_digest` en streaming (`digest.ts`) y descifra `PAYLOAD_AGE` en streaming. La salida puede ser un `WritableStream`, que se cierra tras el paso 18 y se aborta ante cualquier fallo. Los 65 casos del corpus pasan también así.<br>Comprobado en el navegador con un fichero OPFS (`createWritable`): `time_only` se abre con el SHA-256 del sidecar, y un fallo de STREAM deja intacto el contenido anterior del fichero.<br>La consulta de cuota (`navigator.storage.estimate()`), el fichero temporal y la descarga van con la página, en el paso 8 | | 5 | `open.ts` con la `Identity` propia, pasos 9 a 18, `open.test.ts` | los cinco fixtures se abren y el plaintext coincide con el sidecar; el corpus de mutaciones existente reproduce código y paso.<br>5a hecho el 28-09-2026, con el texto en claro en memoria:<br>- los 65 casos del corpus pasan por `open` con el código y el paso de Go;<br>- los cinco fixtures se abren con cada credencial;<br>- los textos siguen a `capsule.Open` y `agewrap`.<br>El paso 13 exige probar cada identity contra cada stanza X25519, y `age-encryption` no expone su `X25519Identity`. Por eso `x25519.ts` abre los stanzas de uno en uno, con ChaCha20-Poly1305 de `@noble/ciphers` 2.4.0, dependencia aprobada el 28-09-2026 porque es la copia que ya usa `age-encryption`. `bech32.ts` lee las identidades `AGE-SECRET-KEY-1…`.<br>`index.ts` aún no reexporta la apertura, que metería noble en `/inspect`: el paso 8 la cargará bajo demanda.<br>5b hecho el 28-09-2026: `open` acepta un `Blob`, del que lee solo el prefijo de los pasos 1 a 8 (`prefix.ts`, movido de la página a la librería), calcula el `capsule_digest` en streaming (`digest.ts`) y descifra `PAYLOAD_AGE` en streaming. La salida puede ser un `WritableStream`, que se cierra tras el paso 18 y se aborta ante cualquier fallo. Los 65 casos del corpus pasan también así.<br>Comprobado en el navegador con un fichero OPFS (`createWritable`): `time_only` se abre con el SHA-256 del sidecar, y un fallo de STREAM deja intacto el contenido anterior del fichero.<br>La consulta de cuota (`navigator.storage.estimate()`), el fichero temporal y la descarga van con la página, en el paso 8 |
| 6 | Spec, mutaciones en Go, `testdata:sync`, reproducción en TypeScript (sección 7) | texto aprobado; vectores congelados en ambos repositorios; commits en Gitea. Hecho: la enmienda de canonicidad entró en la v0.8.2 (`f6f2e9f`, tag `spec-v0.8.2`), y sus 10 mutaciones pasan por `open` en TypeScript desde el paso 5a | | 6 | Spec, mutaciones en Go, `testdata:sync`, reproducción en TypeScript (sección 7) | texto aprobado; vectores congelados en ambos repositorios; commits en Gitea. Hecho: la enmienda de canonicidad entró en la v0.8.2 (`f6f2e9f`, tag `spec-v0.8.2`), y sus 10 mutaciones pasan por `open` en TypeScript desde el paso 5a |
| 7 | `encryptOnG2RFC9380`, `Recipient` propio, ida y vuelta, interoperabilidad TS → Go a nivel IBE y de fichero `age` (sección 8, puntos 4 y 5) | Go abre lo que TypeScript cifra; vectores congelados.<br>Hecho el 28-09-2026:<br>- `ibe.ts` gana `encryptOnG2RFC9380` y `encryptOnG2WithSigma` (solo para tests);<br>- `tlock.ts` gana `timeRecipient`, con las comprobaciones y textos de `NewTimeRecipient`;<br>- `scripts/tlock-go-vectors.go` reescribe `EncryptCCAonG2` con sigma fijo, lo comprueba con kyber y tlock, y abre las muestras de `scripts/tlock-ts-samples.mjs`: Go abrió los cuerpos IBE y los ficheros `age` de las rondas 1000 y 1001 con la misma file key y el mismo texto;<br>- todo congelado en `src/lib/dkc/testing/tlock-vectors.json`;<br>- cobertura del 100 % de `ibe.ts` y `tlock.ts` | | 7 | `encryptOnG2RFC9380`, `Recipient` propio, ida y vuelta, interoperabilidad TS → Go a nivel IBE y de fichero `age` (sección 8, puntos 4 y 5) | Go abre lo que TypeScript cifra; vectores congelados.<br>Hecho el 28-09-2026:<br>- `ibe.ts` gana `encryptOnG2RFC9380` y `encryptOnG2WithSigma` (solo para tests);<br>- `tlock.ts` gana `timeRecipient`, con las comprobaciones y textos de `NewTimeRecipient`;<br>- `scripts/tlock-go-vectors.go` reescribe `EncryptCCAonG2` con sigma fijo, lo comprueba con kyber y tlock, y abre las muestras de `scripts/tlock-ts-samples.mjs`: Go abrió los cuerpos IBE y los ficheros `age` de las rondas 1000 y 1001 con la misma file key y el mismo texto;<br>- todo congelado en `src/lib/dkc/testing/tlock-vectors.json`;<br>- cobertura del 100 % de `ibe.ts` y `tlock.ts` |
| 8 | Página (sección 9) | un fixture `time_and_key` se abre en el navegador sin red; `check-build` en verde; tamaño del bundle anotado en el README | | 8 | Página (sección 9) | un fixture `time_and_key` se abre en el navegador sin red; `check-build` en verde; tamaño del bundle anotado en el README.<br>Hecho el 28-09-2026:<br>- `OpenPanel.svelte`, con `release-input.ts` (release pegado o del registro del fixture), `opener.ts` (cargado con `import()`), `opening.ts` (pasos 9 a 18 como los registra la referencia) y `tempfile.ts` (fichero temporal de OPFS, un directorio y un Web Lock por pestaña, cuota libre y limpieza);<br>- `readAccessKey` en `prefix.ts`;<br>- `licenses.txt` con los avisos de `tlock-js` y `age` y los de cada paquete del bundle, escrito por `vite.config.ts`;<br>- nuevas guardas de `check-build`: ninguna página carga noble, `@scure/base` ni `age-encryption` en la primera carga, y `licenses.txt` está completo;<br>- cobertura del 100 % de los módulos nuevos.<br>Comprobado en el navegador con la compilación de producción:<br>- se abren los fixtures `time_only`, `time_and_key_portable` (con su `.dkk`) y `time_and_key_recipients` (con una identidad pegada), cada uno con el SHA-256 de su registro;<br>- una firma alterada da `ERR_RELEASE_INVALID` en el paso 10, y un fragmento STREAM alterado da `ERR_INTEGRITY` en el paso 17, sin descarga ni fichero temporal;<br>- un fichero propio se abre al fichero temporal, se descarga sin violar la CSP y se borra con su lock;<br>- lo que dejó una pestaña terminada se borra en la visita siguiente;<br>- ninguna petición sale del origen, y la página no se desborda a 375 px de ancho.<br>Revisión adversarial, en cuatro dimensiones (protocolo, seguridad, estado de la interfaz, tests y guardas), con cada hallazgo contrastado por un revisor que intentaba refutarlo: se confirmaron 15 hallazgos, algunos repetidos, y se refutaron 5. Todos se corrigieron:<br>- la fecha se vuelve a mirar cuando llega y cuando la pestaña vuelve a verse;<br>- el fichero de una apertura en curso es de la página: se borra con ella, y la apertura se detiene si se carga otra cápsula (`cancellable`);<br>- se vuelve a comprobar tras cada espera si la apertura sigue vigente;<br>- si el navegador rechaza OPFS, la apertura se hace en memoria;<br>- el nombre ofrecido para descargar no lleva caracteres invisibles;<br>- las glosas de `ERR_INVALID_MAGIC` y `ERR_EXTENSION_CRITICAL_UNKNOWN` valen también para la `.dkk`;<br>- el foco va al campo o al mensaje del error;<br>- tras volver de la caché de atrás y adelante no se ofrece un enlace muerto;<br>- un nombre largo no desborda la página;<br>- `licenses.txt` incluye el código de Vite y rolldown que entra en el bundle;<br>- la guarda de la primera carga resuelve los scripts respecto a cada página y falla si no encuentra ninguno |
Cada paso termina con `npm run verify` en verde y un commit en Gitea. El paso 6 puede ir en paralelo con el 4 y el 5. Cada paso termina con `npm run verify` en verde y un commit en Gitea. El paso 6 puede ir en paralelo con el 4 y el 5.
@ -178,7 +183,7 @@ Cada paso termina con `npm run verify` en verde y un commit en Gitea. El paso 6
- **Doble noble en el bundle.** `@noble/post-quantum` 0.5.4 ya trae su propia copia 2.0.x; si alguna dependencia futura arrastra 1.x, sería peor. Mitigación: las guardas de la sección 3 (ningún 1.x, copias 2.x distintas solo anidadas bajo post-quantum, el código BLS en la 2.4.0 exacta) y la medida del bundle en el paso 2. - **Doble noble en el bundle.** `@noble/post-quantum` 0.5.4 ya trae su propia copia 2.0.x; si alguna dependencia futura arrastra 1.x, sería peor. Mitigación: las guardas de la sección 3 (ningún 1.x, copias 2.x distintas solo anidadas bajo post-quantum, el código BLS en la 2.4.0 exacta) y la medida del bundle en el paso 2.
- **Diagnósticos con material interno.** Mitigación: la regla de errores de la sección 4 y un test que busca en los mensajes de error los bytes de `sigma`, `msg` y `r`. - **Diagnósticos con material interno.** Mitigación: la regla de errores de la sección 4 y un test que busca en los mensajes de error los bytes de `sigma`, `msg` y `r`.
- **`H3` devuelve 0**, con probabilidad 2⁻²⁵⁵: `multiply(0)` lanza `RangeError`. Mitigación: tratar cualquier excepción del cálculo como `proof` y cubrirlo con un test que inyecte `r = 0`. - **`H3` devuelve 0**, con probabilidad 2⁻²⁵⁵: `multiply(0)` lanza `RangeError`. Mitigación: tratar cualquier excepción del cálculo como `proof` y cubrirlo con un test que inyecte `r = 0`.
- **Rendimiento en el navegador.** Un pairing y una multiplicación escalar en G2 rondan los 40 a 300 ms en Node; la puerta añade 11 ms por punto. Mitigación: medir en el paso 8 y, si hace falta, descifrar en un worker. - **Rendimiento en el navegador.** Un pairing y una multiplicación escalar en G2 rondan los 40 a 300 ms en Node; la puerta añade 11 ms por punto. Mitigación: medir en el paso 8 y, si hace falta, descifrar en un worker. Medido en el paso 8, en Chromium, sin contar la descarga del código: 0,34 s los pasos 9 a 18 de `time_only`, la primera apertura, y 0,11 s los de `time_and_key_portable`. No hace falta un worker, que la CSP (`worker-src 'none'`) tampoco permite.
- **Rama `wip/align-3820066`.** Si no se fusiona antes, el paso 3 partiría de un árbol distinto del que verifican los tests actuales. Mitigación: el paso 1. - **Rama `wip/align-3820066`.** Si no se fusiona antes, el paso 3 partiría de un árbol distinto del que verifican los tests actuales. Mitigación: el paso 1.
- **Aprobación de la spec.** El paso 6 depende del autor; los pasos 3 a 5 no. - **Aprobación de la spec.** El paso 6 depende del autor; los pasos 3 a 5 no.
@ -188,5 +193,5 @@ Cada paso termina con `npm run verify` en verde y un commit en Gitea. El paso 6
- Writer completo de `.dkc` y `.dkk` en TypeScript y la prueba TS → Go a nivel de cápsula: fase 3. - Writer completo de `.dkc` y `.dkk` en TypeScript y la prueba TS → Go a nivel de cápsula: fase 3.
- Los schemes `pedersen-bls-unchained` y `bls-unchained-on-g1`: sin uso previsto; si se añaden, el módulo crece con `encryptOnG1` y `decryptOnG1` y la puerta cambia de grupo. - Los schemes `pedersen-bls-unchained` y `bls-unchained-on-g1`: sin uso previsto; si se añaden, el módulo crece con `encryptOnG1` y `decryptOnG1` y la puerta cambia de grupo.
- Obtención de releases por red desde el SDK: fuera de esta fase; el servidor y la CLI Go siguen siendo la vía. - Obtención de releases por red desde el SDK: fuera de esta fase; el servidor y la CLI Go siguen siendo la vía. Es lo que cubrirá los SHOULD de §48 (varios relays) y §49 (release directo del proveedor): una fuente drand opcional del SDK, que verifica cada respuesta y descarta las que fallan (corrección 6 de §76), nunca activa por defecto en la página.
- Reutilización de la implementación `age` de tlock-js: descartada; `age-encryption` es la implementación oficial. - Reutilización de la implementación `age` de tlock-js: descartada; `age-encryption` es la implementación oficial.

@ -21,13 +21,19 @@
// - the client bundle holds tlock-js, drand-client or Babel's helpers, or a // - the client bundle holds tlock-js, drand-client or Babel's helpers, or a
// nested copy of a package other than the noble copy under // nested copy of a package other than the noble copy under
// @noble/post-quantum (plan of phase 2, section 3 and decision 5), as // @noble/post-quantum (plan of phase 2, section 3 and decision 5), as
// .svelte-kit/output/client-modules.json records it (vite.config.ts). // .svelte-kit/output/client-modules.json records it (vite.config.ts);
// - a page loads noble, @scure/base or age-encryption with the page instead
// of on demand, or /inspect cannot load the opening on demand (plan of
// phase 2, section 9);
// - licenses.txt lacks the notice of a package in the client bundle, the
// copyright lines kept in the header of a module of src/ derived from
// another project, or the license of the site.
// //
// It reports the JavaScript that each page loads, raw and gzip. // It reports the JavaScript that each page loads, raw and gzip.
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { basename, join, relative, resolve, sep } from 'node:path'; import { basename, dirname, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { gzipSync } from 'node:zlib'; import { gzipSync } from 'node:zlib';
@ -260,16 +266,22 @@ for (const [file, chunk] of Object.entries(chunks)) {
} }
// The JavaScript of a page: the chunks it preloads and the entries its // The JavaScript of a page: the chunks it preloads and the entries its
// inline script imports, as SvelteKit writes them, with their static // inline script imports, as SvelteKit writes them (relative to the page, with
// imports; and apart, what those load on demand, other than the nodes of // ../ below the root), with their static imports; and apart, what those load
// other routes. // on demand, other than the nodes of other routes. A page whose scripts are
function pageScripts(html) { // not all chunks of the bundle fails, so that no guard below passes without
// looking at them.
function pageScripts(file) {
const html = readFileSync(file, 'utf8');
const inSite = (url) => inBuild(resolve(dirname(file), url));
const eager = new Set(); const eager = new Set();
for (const [link] of html.matchAll(/<link\b[^>]*>/gi)) { for (const [link] of html.matchAll(/<link\b[^>]*>/gi)) {
const href = /\brel="modulepreload"/i.test(link) ? link.match(/\bhref="\.\/([^"]+)"/i) : null; const href = /\brel="modulepreload"/i.test(link) ? link.match(/\bhref="([^"]+)"/i) : null;
if (href) eager.add(href[1]); if (href) eager.add(inSite(href[1]));
} }
for (const [, src] of html.matchAll(/\bimport\("\.\/([^"]+)"\)/g)) eager.add(src); for (const [, src] of html.matchAll(/\bimport\("([^"]+)"\)/g)) eager.add(inSite(src));
if (eager.size === 0) fail(`${rel(file)}: no script of the page found`);
for (const f of eager) if (chunks[f] === undefined) fail(`${rel(file)}: script ${f} is not a chunk of the client bundle`);
const close = (set, seeds) => { const close = (set, seeds) => {
const queue = [...seeds]; const queue = [...seeds];
while (queue.length > 0) { while (queue.length > 0) {
@ -302,6 +314,70 @@ const weight = (set) => {
return `${set.size} files, ${raw} bytes, ${gzip} gzip`; return `${set.size} files, ${raw} bytes, ${gzip} gzip`;
}; };
// ---------------------------------------------------------------------------
// The opening is loaded on demand: no page loads noble or age-encryption
// first, and /inspect can load them when the person opens a capsule.
const OPENING_PACKAGES = /^(?:@noble\/|@scure\/|age-encryption$)/;
const bundled = (set) => {
const names = new Set();
for (const f of set) {
for (const id of Object.keys(chunks[f]?.modules ?? {})) {
const pkg = packageOf(id);
if (pkg !== null) names.add(pkg.name);
}
}
return names;
};
for (const f of htmlFiles.filter(existsSync)) {
const { eager, lazy } = pageScripts(f);
const first = [...bundled(eager)].filter((n) => OPENING_PACKAGES.test(n));
if (first.length > 0) fail(`${rel(f)} loads ${first.join(', ')} with the page, not on demand`);
if (basename(f) === 'inspect.html') {
const later = bundled(lazy);
for (const n of ['age-encryption', '@noble/curves', '@noble/ciphers']) {
if (!later.has(n)) fail(`${rel(f)}: the opening loaded on demand lacks ${n}`);
}
}
}
// ---------------------------------------------------------------------------
// licenses.txt holds the notice of every npm package in the client bundle,
// of every module of src/ that keeps the notice of another project, and the
// license of the site (vite.config.ts writes it).
const NOTICES = join(BUILD, 'licenses.txt');
if (!existsSync(NOTICES)) {
fail('licenses.txt is missing from the site');
} else {
const notices = readFileSync(NOTICES, 'utf8');
for (const [file, chunk] of Object.entries(chunks)) {
for (const [id, size] of Object.entries(chunk.modules)) {
const i = id.lastIndexOf('/node_modules/');
const virtual = /^\0(vite|rolldown)\//.exec(id);
if (virtual !== null) {
// The bundler's own code: \0vite/preload-helper.js, \0rolldown/runtime.js.
const { version } = JSON.parse(readFileSync(join(ROOT, 'node_modules', virtual[1], 'package.json'), 'utf8'));
if (!notices.includes(`\n${virtual[1]} ${version} (`)) fail(`licenses.txt lacks ${virtual[1]} ${version}, bundled in ${file}`);
} else if (id.startsWith('\0')) {
if (size > 0) fail(`${file} bundles the virtual module ${JSON.stringify(id)}, of no known license`);
} else if (i >= 0) {
const pkg = packageOf(id);
const dir = `${id.slice(0, i)}/node_modules/${pkg.name}`;
const { version } = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
if (!notices.includes(`\n${pkg.name} ${version} (`)) fail(`licenses.txt lacks ${pkg.name} ${version}, bundled in ${file}`);
} else if (/\/src\/.*\.(ts|js|svelte)$/.test(id) && existsSync(id)) {
// The copyright lines of a notice kept in the module's header.
for (const [, line] of readFileSync(id, 'utf8').matchAll(/^\/\/ {3}(.*copyright.*)$/gim)) {
if (!notices.includes(line)) fail(`licenses.txt lacks "${line}" of ${relative(ROOT, id).split(sep).join('/')}`);
}
}
}
}
const own = readFileSync(join(ROOT, 'LICENSE'), 'utf8').trim();
if (!notices.includes(own)) fail('licenses.txt lacks the license of the site');
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
if (problems.length > 0) { if (problems.length > 0) {
@ -314,7 +390,7 @@ console.log(`build check passed: ${htmlFiles.length} prerendered pages, ${files.
for (const [i, f] of htmlFiles.entries()) { for (const [i, f] of htmlFiles.entries()) {
const p = policies[i]; const p = policies[i];
console.log(` ${rel(f)}: CSP ${[...p].map(([d, s]) => `${d} ${s.join(' ')}`).join('; ')}`); console.log(` ${rel(f)}: CSP ${[...p].map(([d, s]) => `${d} ${s.join(' ')}`).join('; ')}`);
const { eager, lazy } = pageScripts(readFileSync(f, 'utf8')); const { eager, lazy } = pageScripts(f);
console.log(` ${rel(f)}: JavaScript ${weight(eager)}; on demand ${weight(lazy)}`); console.log(` ${rel(f)}: JavaScript ${weight(eager)}; on demand ${weight(lazy)}`);
} }
console.log(` npm packages in the client bundle: ${[...packages].sort().join(', ') || 'none'}`); console.log(` npm packages in the client bundle: ${[...packages].sort().join(', ') || 'none'}`);

@ -1,15 +1,27 @@
<script lang="ts"> <script lang="ts">
// Every PUBLIC_HEADER extension, as the extensions contract of plan §8 asks: // Every extension of PUBLIC_HEADER, or of CONTROL_CBOR once the capsule is
// opened, as the extensions contract of plan §8 asks:
// id, version, critical or not, known or not, length and hex always; text // 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 // 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 // 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 // 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. // the order of the object: nothing is keyed by an identifier read from the capsule.
import type { ExtensionRow } from '$lib/inspector/report.ts'; import type { ExtensionRow } from '$lib/inspector/report.ts';
import { escapeInvisible, formatByteCount } from '$lib/inspector/format.ts'; import { escapeInvisible, formatByteCount } from '$lib/inspector/format.ts';
import DataView from './DataView.svelte'; import DataView from './DataView.svelte';
let { extensions }: { extensions: readonly ExtensionRow[] } = $props(); let {
extensions,
object = 'PUBLIC_HEADER',
}: {
extensions: readonly ExtensionRow[];
/** The object that carries them: the public header, or the control once opened. */
object?: 'PUBLIC_HEADER' | 'CONTROL_CBOR';
} = $props();
const header = $derived(object === 'PUBLIC_HEADER');
// Element ids, distinct for each list of the page.
const idPrefix = $derived(header ? 'ext' : 'ctl-ext');
/** Data up to this many bytes starts with its hexadecimal open. */ /** Data up to this many bytes starts with its hexadecimal open. */
const OPEN_HEX_BYTES = 64; const OPEN_HEX_BYTES = 64;
@ -18,23 +30,32 @@
</script> </script>
<div class="caveat" role="note"> <div class="caveat" role="note">
<p class="caveat-title">Datos públicos, que no prueban autoría</p> {#if header}
<p> <p class="caveat-title">Datos públicos, que no prueban autoría</p>
Cualquiera que tenga el fichero puede leer y reescribir estas extensiones. Nada las vincula al resto de la cápsula <p>
antes del paso 15 (<span lang="en">header binding</span>), después de la fecha de apertura, y ni siquiera entonces Cualquiera que tenga el fichero puede leer y reescribir estas extensiones. Nada las vincula al resto de la cápsula
prueban quién las escribió ni cuándo: solo una extensión de firma podría hacerlo (§55.1, §72). No bases en ellas antes del paso 15 (<span lang="en">header binding</span>), después de la fecha de apertura, y ni siquiera entonces
ninguna decisión de seguridad. prueban quién las escribió ni cuándo: solo una extensión de firma podría hacerlo (§55.1, §72). No bases en ellas
</p> ninguna decisión de seguridad.
</p>
{:else}
<p class="caveat-title">Datos sellados, que tampoco prueban autoría</p>
<p>
Viajaban dentro del control sellado, pero eso no prueba quién las escribió: cualquiera puede sellar un control
hacia esta DateKey con la clave pública del perfil, y quien ya haya abierto la cápsula puede reescribirlo. Solo una
extensión de firma podría probar autoría (§55.1, §72). No bases en ellas ninguna decisión de seguridad.
</p>
{/if}
</div> </div>
{#if extensions.length === 0} {#if extensions.length === 0}
<p class="none">La cabecera no trae extensiones.</p> <p class="none">{header ? 'La cabecera no trae extensiones.' : 'CONTROL_CBOR no trae extensiones.'}</p>
{:else} {:else}
<ol class="exts"> <ol class="exts">
{#each extensions as e, i (i)} {#each extensions as e, i (i)}
<li> <li>
<article aria-labelledby="ext-{i}"> <article aria-labelledby="{idPrefix}-{i}">
<h4 id="ext-{i}" class="id">{e.id}</h4> <h4 id="{idPrefix}-{i}" class="id">{e.id}</h4>
<dl class="facts"> <dl class="facts">
<div> <div>
<dt>Versión</dt> <dt>Versión</dt>
@ -55,7 +76,7 @@
</dl> </dl>
{#if e.critical && !e.known} {#if e.critical && !e.known}
<p class="note bad"> <p class="note bad">
Crítica y desconocida para este lector: basta para rechazar la cápsula en el paso 4 Crítica y desconocida para este lector: basta para rechazar la cápsula en el paso {header ? 4 : 14}
(ERR_EXTENSION_CRITICAL_UNKNOWN). (ERR_EXTENSION_CRITICAL_UNKNOWN).
</p> </p>
{/if} {/if}

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte';
import type { Report, Comparison } from '$lib/inspector/report.ts'; import type { Report, Comparison } from '$lib/inspector/report.ts';
import { import {
displayText, displayText,
@ -17,12 +18,15 @@
report, report,
nowMs, nowMs,
timeZone, timeZone,
opening,
}: { }: {
report: Report; report: Report;
/** When the report was made, for the distance to the unlock date. */ /** When the report was made, for the distance to the unlock date. */
nowMs: number; nowMs: number;
/** The viewer's time zone, for example "Europe/Madrid". */ /** The viewer's time zone, for example "Europe/Madrid". */
timeZone: string | undefined; timeZone: string | undefined;
/** The opening of the capsule, after steps 1 to 8. */
opening?: Snippet;
} = $props(); } = $props();
let copyStatus = $state(''); let copyStatus = $state('');
@ -136,6 +140,8 @@
<StepList steps={report.steps} /> <StepList steps={report.steps} />
</section> </section>
{@render opening?.()}
<div class="grid"> <div class="grid">
<section class="block" aria-labelledby="header-title"> <section class="block" aria-labelledby="header-title">
<h3 id="header-title">Cabecera pública</h3> <h3 id="header-title">Cabecera pública</h3>

@ -0,0 +1,637 @@
<script lang="ts">
// The "abrir" action of the inspector (plan of phase 2, section 9): steps 9
// to 18 of spec §63 on a capsule that passed steps 1 to 8, with the release
// the person supplies directly, pasted from drand or taken from the record
// of an official fixture (decision 4: the page never fetches it), and the
// credentials that time_and_key asks for. The opening code, with noble and
// age-encryption, is imported on demand, so the page's first load does not
// carry it.
//
// The plaintext of a fixture is opened in memory and shown. The plaintext
// of the person's own file goes to a private temporary file of the browser
// (OPFS, tempfile.ts), committed only after step 18 (spec §56), offered for
// download and deleted on request, when another capsule is opened or
// loaded, and when the page is left; in memory, up to MEMORY_LIMIT, when
// the browser has no such file or refuses it. An opening in progress stops
// when the panel is destroyed, and its file is removed.
import { onDestroy, tick } from 'svelte';
import { toHex } from '$lib/dkc/index.ts';
import type { Fixture } from '$lib/inspector/fixtures.ts';
import { errorGloss, escapeInvisible, formatByteCount, formatInteger, printableText } from '$lib/inspector/format.ts';
import { buildOpenReport, type OpenReport, plaintextFileName } from '$lib/inspector/opening.ts';
import { drandReleaseURL, parseReleaseText, releaseText } from '$lib/inspector/release-input.ts';
import type { Report } from '$lib/inspector/report.ts';
import { browserPlatform, cancellable, createTempFile, freeSpace, type TempFile } from '$lib/inspector/tempfile.ts';
import ExtensionList from './ExtensionList.svelte';
import StepList from './StepList.svelte';
let {
report,
capsule,
fixture,
nowMs,
}: {
/** The inspection of the capsule: valid, with its round and profile. */
report: Report;
/** What was inspected: the bytes of a fixture or the person's file. */
capsule: Uint8Array | Blob;
/** The official fixture, when it is one. */
fixture: Fixture | undefined;
/** When the report was made. */
nowMs: number;
} = $props();
/** The largest plaintext opened in memory when the browser has no OPFS. */
const MEMORY_LIMIT = 64 << 20;
/** Printable plaintext up to this many characters is shown whole. */
const SHOWN_TEXT = 100_000;
interface Result {
readonly report: OpenReport;
readonly ms: number;
/** The plaintext offered for download: a temporary file, or memory. */
readonly download?: { readonly url: string; readonly name: string; readonly size: number; readonly temporary: boolean };
}
// Read once: the panel is made again for each inspected capsule.
const initial = (): { round: number; release: string } => ({
round: report.capsule!.round,
release: fixture?.release === undefined ? '' : releaseText(fixture.release.round, toHex(fixture.release.signature)),
});
const { round, release: initialRelease } = initial();
/** The input a problem is about. */
type ProblemField = 'release' | 'identities' | 'accessKey';
const FIELD_IDS: Readonly<Record<ProblemField, string>> = {
release: 'release-input',
identities: 'ids-input',
accessKey: 'dkk-input',
};
let releaseInput = $state(initialRelease);
let identities = $state('');
let accessKey: File | undefined = $state();
let problem: string | undefined = $state();
let problemField: ProblemField | undefined = $state();
let busy = $state(false);
let result: Result | undefined = $state();
let deleted = $state(false);
let announcement = $state('');
const timeAndKey = $derived(report.capsule?.accessPolicy === 'time_and_key');
const due = $derived(report.unlock?.epochMs !== undefined && report.unlock.epochMs <= nowMs);
const drandURL = $derived(drandReleaseURL(report.profile!.chainHash, round));
const payloadLength = $derived(report.prelude?.payloadLength ?? 0);
// The temporary file and the object URL of the last opening.
let temp: TempFile | undefined;
let objectURL: string | undefined;
// The temporary file of the opening in progress, until it becomes `temp`
// or is removed: discard() removes it too, so a panel destroyed or a page
// left in the middle of an opening leaves nothing behind.
let pending: TempFile | undefined;
// Only the latest opening may show its result; destroying the panel
// invalidates the one in progress, which then stops writing.
let openId = 0;
async function discard(): Promise<void> {
if (objectURL !== undefined) URL.revokeObjectURL(objectURL);
objectURL = undefined;
const files = [temp, pending];
temp = undefined;
pending = undefined;
await Promise.all(files.map((f) => f?.remove()));
}
onDestroy(() => {
openId++;
void discard();
});
// pagehide also fires when the page goes into the back/forward cache: if
// it comes back, it must not offer what was deleted here.
function leave(): void {
if (result?.download !== undefined) deleted = true;
void discard();
}
async function deleteNow(): Promise<void> {
await discard();
deleted = true;
announcement = 'Fichero temporal borrado.';
}
function onAccessKey(event: Event & { currentTarget: HTMLInputElement }): void {
accessKey = event.currentTarget.files?.[0];
}
async function submit(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (busy) return;
problem = undefined;
problemField = undefined;
const parsed = parseReleaseText(releaseInput, round);
if (!parsed.ok) {
await fail(parsed.problem, 'release');
return;
}
const id = ++openId;
const stale = (): boolean => id !== openId;
busy = true;
result = undefined;
deleted = false;
announcement = 'Abriendo la cápsula.';
await discard();
// This opening's temporary file, until it becomes `temp`: the finally
// block removes it on every other path.
let t: TempFile | undefined;
try {
const opener = await import('$lib/inspector/opener.ts');
if (stale()) return;
if (fixture === undefined) {
const platform = browserPlatform();
let free: number | undefined;
if (platform !== undefined) {
try {
free = await freeSpace(platform);
if (free === undefined || free >= payloadLength) t = pending = await createTempFile(platform);
} catch {
// The OPFS is there but the browser refuses it (a private window,
// site data blocked): the capsule opens in memory instead.
}
if (stale()) return;
}
if (t === undefined && payloadLength > MEMORY_LIMIT) {
await fail(
free !== undefined && free < payloadLength
? `El navegador deja ${formatByteCount(free)} libres para esta página y el contenido cifrado ocupa ${formatByteCount(payloadLength)}, más de los ${formatByteCount(MEMORY_LIMIT)} que la página abre en memoria. Libera espacio o usa la CLI (datekeys decrypt).`
: `Este navegador no deja a la página un fichero temporal privado (OPFS) y el contenido cifrado ocupa ${formatByteCount(payloadLength)}, más de los ${formatByteCount(MEMORY_LIMIT)} que la página abre en memoria. Usa otro navegador o la CLI (datekeys decrypt).`,
);
return;
}
}
const out = t;
const attempt = await opener.openCapsule({
capsule,
release: parsed.release,
...(timeAndKey ? { identities } : {}),
...(timeAndKey && accessKey !== undefined ? { accessKey } : {}),
// A stale opening stops writing, so open aborts the file and stops.
...(out === undefined ? {} : { output: { writable: cancellable(out.writable, stale), file: () => out.file(), remove: () => out.remove() } }),
});
if (stale()) {
if (attempt.ok) attempt.plaintext?.fill(0);
return;
}
if (!attempt.ok) {
await fail(attempt.problem, attempt.field);
return;
}
const opened = attempt.opened.error === undefined;
let text: string | undefined;
if (opened && fixture !== undefined && attempt.plaintext !== undefined) {
text = printableText(attempt.plaintext);
}
const r = buildOpenReport(
attempt.opened,
attempt.digest === undefined
? undefined
: {
...attempt.digest,
...(fixture?.plaintextSHA256 === undefined ? {} : { expectedSHA256: fixture.plaintextSHA256 }),
...(text === undefined ? {} : { text }),
},
);
let download: Result['download'];
if (opened && fixture === undefined) {
const name = plaintextFileName(report.fileName);
if (t !== undefined) {
const file = await t.file();
if (stale()) return;
objectURL = URL.createObjectURL(file);
temp = t;
pending = undefined;
t = undefined;
download = { url: objectURL, name, size: file.size, temporary: true };
} else {
const blob = new Blob([attempt.plaintext! as Uint8Array<ArrayBuffer>]);
objectURL = URL.createObjectURL(blob);
download = { url: objectURL, name, size: blob.size, temporary: false };
}
}
attempt.plaintext?.fill(0);
result = download === undefined ? { report: r, ms: attempt.ms } : { report: r, ms: attempt.ms, download };
announcement = r.opened
? 'Cápsula abierta: pasos 9 a 18 superados.'
: `Apertura rechazada en el paso ${r.failure!.step}, ${r.failure!.code}.`;
await tick();
document.getElementById('open-result-title')?.focus();
} catch (err) {
if (!stale()) await fail(`No se pudo abrir: ${escapeInvisible(err instanceof Error ? err.message : String(err))}`);
} finally {
// A file that did not become `temp`: a failure, or a stale opening.
if (t !== undefined) {
if (pending === t) pending = undefined;
await t.remove();
}
if (!stale()) busy = false;
}
}
// Shows why the opening could not run, announces it and moves the focus to
// the field at fault, or to the message: the submit button that had it is
// disabled while the opening runs.
async function fail(message: string, field?: ProblemField): Promise<void> {
problem = message;
problemField = field;
announcement = '';
await tick();
announcement = message;
document.getElementById(field === undefined ? 'open-problem' : FIELD_IDS[field])?.focus();
}
function seconds(ms: number): string {
return new Intl.NumberFormat('es-ES', { maximumFractionDigits: 2 }).format(ms / 1000);
}
</script>
<svelte:window onpagehide={leave} />
<section class="open" aria-labelledby="open-title">
<h3 id="open-title">Abrir la cápsula</h3>
{#if !due}
<p class="prose">
La fecha de apertura todavía no ha llegado según el reloj de este dispositivo. Hasta entonces drand no publica la
firma de la ronda {round} y nadie puede abrir la cápsula, tampoco esta página (paso 9, ERR_RELEASE_UNAVAILABLE).
</p>
{:else}
<form class="form" onsubmit={submit} novalidate>
{#if timeAndKey}
<fieldset>
<legend>Credencial de acceso</legend>
<p class="hint">
La política time_and_key pide, además de la firma de la ronda, una clave .dkk de esta cápsula o la identidad
X25519 de uno de sus destinatarios. No salen de este navegador.
</p>
<div class="field">
<label for="dkk-input">Clave .dkk</label>
<input
id="dkk-input"
type="file"
accept=".dkk"
onchange={onAccessKey}
aria-invalid={problemField === 'accessKey' ? 'true' : undefined}
aria-describedby={problemField === 'accessKey' ? 'open-problem' : undefined}
/>
</div>
<div class="field">
<label for="ids-input">Identidades X25519 de age</label>
<textarea
id="ids-input"
rows="2"
bind:value={identities}
autocomplete="off"
spellcheck="false"
placeholder="AGE-SECRET-KEY-1…"
aria-invalid={problemField === 'identities' ? 'true' : undefined}
aria-describedby={problemField === 'identities' ? 'open-problem ids-hint' : 'ids-hint'}
></textarea>
<p id="ids-hint" class="hint">Una por línea, como en un fichero de identidades de age.</p>
</div>
</fieldset>
{/if}
<div class="field">
<label for="release-input">Firma de la ronda {round}, el release que publica drand</label>
<textarea
id="release-input"
rows="3"
bind:value={releaseInput}
autocomplete="off"
spellcheck="false"
aria-invalid={problemField === 'release' ? 'true' : undefined}
aria-describedby={problemField === 'release' ? 'open-problem release-hint' : 'release-hint'}
></textarea>
<p id="release-hint" class="hint">
{#if fixture?.release !== undefined}
Viene del registro del fixture: es la que publicó drand para la ronda {round}, como muestra
<a href={drandURL} target="_blank" rel="noopener noreferrer">su página en drand</a>. Cámbiala para ver cómo la
rechaza el paso 10.
{:else}
Abre <a href={drandURL} target="_blank" rel="noopener noreferrer">la firma de la ronda {round} en drand</a> en
otra pestaña, copia todo lo que muestra y pégalo aquí; vale también la firma sola, en hexadecimal. La página no
se conecta a drand: la abres tú.
{/if}
Solo se leen la ronda y la firma, que se verifican aquí con la clave pública del perfil fijado (§51).
</p>
</div>
{#if problem}
<p id="open-problem" class="problem" tabindex="-1">{problem}</p>
{/if}
<div class="actions">
<button class="button" type="submit" disabled={busy}>{busy ? 'Abriendo…' : 'Abrir la cápsula'}</button>
{#if fixture === undefined}
<p class="hint">
El texto descifrado se escribe en un fichero temporal privado de este navegador y solo se ofrece si age lo
autentica entero (§56). Se borra cuando lo pides, al abrir o cargar otra cápsula y al salir de la página; sin
ese fichero, se abre en la memoria de la página hasta 64 MiB.
</p>
{/if}
</div>
</form>
{/if}
<p class="visually-hidden" role="status">{announcement}</p>
{#if result}
{@const r = result.report}
<div class={['verdict', r.opened ? 'valid' : 'invalid']}>
<h4 id="open-result-title" class="state-word" tabindex="-1">
{r.opened ? 'Abierta' : r.failure ? `Rechazada en el paso ${r.failure.step}` : 'Rechazada'}
</h4>
{#if r.opened}
<p>
Supera los pasos 9 a 18 en {seconds(result.ms)} s. age ha autenticado el texto entero con la clave de
PAYLOAD_AGE, pero eso no prueba quién lo escribió, ni que sea el original si otros abrieron la cápsula antes
(§55.1).
</p>
{:else if r.failure}
<p class="code">{r.failure.code}</p>
<p>{errorGloss(r.failure.code)}</p>
{#if r.steps.length === 0}
<p class="detail">{escapeInvisible(r.failure.message)}</p>
{/if}
{/if}
</div>
{#if r.steps.length > 0}
<section class="block" aria-labelledby="open-steps-title">
<h3 id="open-steps-title">Pasos 9 a 18</h3>
<StepList steps={r.steps} />
</section>
{/if}
{#if r.release}
<section class="block" aria-labelledby="release-title">
<h3 id="release-title">Release verificado</h3>
<dl>
<div>
<dt>Ronda</dt>
<dd class="mono">{r.release.round}</dd>
</div>
<div>
<dt>Firma</dt>
<dd class="mono">{r.release.signature}</dd>
</div>
</dl>
</section>
{/if}
{#if r.plaintext}
{@const p = r.plaintext}
<section class="block" aria-labelledby="plaintext-title">
<h3 id="plaintext-title">Texto en claro</h3>
<dl>
<div>
<dt>Tamaño</dt>
<dd>{formatByteCount(p.length)}</dd>
</div>
<div>
<dt>SHA-256</dt>
<dd class="mono">{p.sha256}</dd>
</div>
{#if p.expectedSHA256 !== undefined}
<div>
<dt>Registro del fixture</dt>
<dd class={p.expectedSHA256 === p.sha256 ? 'match' : 'mismatch'}>
{p.expectedSHA256 === p.sha256 ? 'coincide' : 'no coincide'}: <span class="mono">{p.expectedSHA256}</span>
</dd>
</div>
{/if}
</dl>
{#if p.text !== undefined}
{#if p.text === ''}
<p class="muted">El texto en claro está vacío.</p>
{:else}
<pre class="plaintext">{p.text.length > SHOWN_TEXT ? `${p.text.slice(0, SHOWN_TEXT)}\n…` : p.text}</pre>
{#if p.text.length > SHOWN_TEXT}
<p class="muted">Se muestran los primeros {formatInteger(SHOWN_TEXT)} caracteres.</p>
{/if}
{/if}
{:else if fixture !== undefined}
<p class="muted">No es texto UTF-8 imprimible, así que no se muestra.</p>
{/if}
{#if result.download}
{@const d = result.download}
{#if deleted}
<p class="muted">
{d.temporary
? 'Fichero temporal borrado. Para descargarlo otra vez, abre de nuevo la cápsula.'
: 'El texto descifrado ya no está en la página. Para descargarlo otra vez, abre de nuevo la cápsula.'}
</p>
{:else}
<div class="actions">
<a class="button" href={d.url} download={d.name}>Descargar {d.name}</a>
{#if d.temporary}
<button class="button quiet" type="button" onclick={deleteNow}>Borrar el fichero temporal</button>
{/if}
</div>
<p class="hint">
{#if d.temporary}
Está en un fichero temporal privado de este navegador ({formatByteCount(d.size)}). Se borra cuando lo pides, al
abrir o cargar otra cápsula y al salir de la página.
{:else}
Está en la memoria de esta página ({formatByteCount(d.size)}), porque el navegador no le deja un fichero
temporal privado. Se libera al abrir o cargar otra cápsula y al salir de la página.
{/if}
</p>
{/if}
{/if}
</section>
{/if}
{#if r.controlExtensions}
<section class="block" aria-labelledby="control-ext-title">
<h3 id="control-ext-title">Extensiones de CONTROL_CBOR</h3>
<ExtensionList extensions={r.controlExtensions} object="CONTROL_CBOR" />
</section>
{/if}
{/if}
</section>
<style>
.open {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1.5rem;
align-content: start;
}
/* minmax(0, 1fr) and min-width 0: a fieldset is as wide as its content
by default, which widens the page on a phone. */
.form {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1.1rem;
max-width: var(--measure);
padding: 1.1rem 1.25rem 1.25rem;
border: 1px solid var(--rule);
border-radius: var(--radius);
background: var(--paper-2);
}
fieldset {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-width: 0;
gap: 0.9rem;
margin: 0;
padding: 0.75rem 1rem 1rem;
border: 1px solid var(--rule);
border-radius: var(--radius);
}
legend {
padding-inline: 0.35rem;
font-weight: 650;
}
.field {
display: grid;
gap: 0.35rem;
}
label {
font-weight: 600;
}
textarea {
width: 100%;
font-family: var(--mono);
font-size: 0.82rem;
line-height: 1.45;
padding: 0.5rem 0.6rem;
border: 1px solid var(--rule-strong);
border-radius: var(--radius);
background: var(--paper);
color: var(--ink);
resize: vertical;
overflow-wrap: anywhere;
}
input[type='file'] {
max-width: 100%;
font: inherit;
font-size: var(--t-small);
}
.hint {
font-size: var(--t-small);
color: var(--ink-muted);
max-width: var(--measure);
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1rem;
}
/* The download link quotes the file name, which may have no break points;
break-word would not let the flex item shrink below it on a phone. */
.actions a.button {
overflow-wrap: anywhere;
}
.button[disabled] {
opacity: 0.6;
cursor: progress;
}
.problem {
border-left: 4px solid var(--fail);
background: var(--fail-bg);
padding: 0.65rem 0.9rem;
border-radius: 0 var(--radius) var(--radius) 0;
overflow-wrap: anywhere;
}
.verdict {
display: grid;
gap: 0.5rem;
padding: 1.1rem 1.25rem 1.25rem;
border: 1px solid var(--rule);
border-top: 6px solid var(--state);
border-radius: var(--radius);
background: var(--paper-2);
max-width: var(--measure);
}
.verdict.valid {
--state: var(--pass);
}
.verdict.invalid {
--state: var(--fail);
}
.state-word {
font-size: var(--t-h2);
font-weight: 700;
letter-spacing: -0.015em;
line-height: 1.2;
color: var(--state);
}
.code,
.detail {
font-family: var(--mono);
font-weight: 600;
overflow-wrap: anywhere;
}
.detail {
font-size: 0.82rem;
font-weight: 500;
}
.block {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1rem;
align-content: start;
}
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);
}
dd {
margin: 0;
overflow-wrap: anywhere;
}
dd.mono,
dd .mono {
font-size: 0.85rem;
}
dd.match {
color: var(--pass);
}
dd.mismatch {
color: var(--fail);
}
.plaintext {
max-height: 24rem;
overflow: auto;
padding: 0.85rem 1rem;
background: var(--paper-2);
border: 1px solid var(--rule);
border-radius: var(--radius);
font-size: 0.82rem;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
</style>

@ -6,7 +6,7 @@
</script> </script>
<ol class="rail steps"> <ol class="rail steps">
{#each steps as s (s.step)} {#each steps as s (`${s.step} ${s.name}`)}
<li class={s.state}> <li class={s.state}>
<span class="dot" aria-hidden="true">{s.step}</span> <span class="dot" aria-hidden="true">{s.step}</span>
<div class="body"> <div class="body">

@ -4,7 +4,8 @@
// An encoding, not cryptography. Like age, it accepts strings longer than // An encoding, not cryptography. Like age, it accepts strings longer than
// the 90 characters of BIP 173. // the 90 characters of BIP 173.
// //
// Ported from the Go code, under its license: // Ported from internal/bech32 of filippo.io/age v1.3.2
// (https://github.com/FiloSottile/age), under its license:
// //
// Copyright (c) 2017 Takatoshi Nakagawa // Copyright (c) 2017 Takatoshi Nakagawa
// Copyright (c) 2019 The age Authors // Copyright (c) 2019 The age Authors

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { defaultRegistry, equalBytes, inspectView, inspectWith, MAX_AGE_HEADER_LEN, type Inspection } from './index.ts'; import { decodeAccessKey, defaultRegistry, equalBytes, inspectView, inspectWith, MAX_AGE_HEADER_LEN, MAX_DKK_BODY_LEN, type Inspection } from './index.ts';
import { inspectedLength, readCapsule } from './prefix.ts'; import { inspectedLength, MAX_ACCESS_KEY_READ, readAccessKey, readCapsule } from './prefix.ts';
import { frame, split } from './testing/capsule.ts'; import { frame, split } from './testing/capsule.ts';
import { listTestdata, readBytes } from './testing/testdata.ts'; import { listTestdata, readBytes } from './testing/testdata.ts';
@ -139,3 +139,40 @@ describe('readCapsule', () => {
); );
}); });
}); });
describe('readAccessKey', () => {
// The error of decoding `dkk`, or undefined when it decodes.
const decodeError = (dkk: Uint8Array): string | undefined => {
try {
decodeAccessKey(dkk);
return undefined;
} catch (err) {
return String(err);
}
};
const read = (b: Uint8Array): Promise<Uint8Array> => readAccessKey(new Blob([b as Uint8Array<ArrayBuffer>]));
const dkk = readBytes('fixtures/time_and_key_portable.dkk');
it('reads a .dkk whole', async () => {
expect(equalBytes(await read(dkk), dkk)).toBe(true);
expect(decodeError(await read(dkk))).toBeUndefined();
expect(await read(new Uint8Array(0))).toEqual(new Uint8Array(0));
});
it('reads at most one byte past the largest valid .dkk, with the same error as the whole file', async () => {
expect(MAX_ACCESS_KEY_READ).toBe(12 + MAX_DKK_BODY_LEN + 1);
// A valid .dkk followed by 16 MiB of data: data after BODY_CBOR.
const long = concat(dkk, new Uint8Array(MAX_DKK_BODY_LEN));
const prefix = await read(long);
expect(prefix.length).toBe(MAX_ACCESS_KEY_READ);
expect(decodeError(prefix)).toBe(decodeError(long));
expect(decodeError(long)).toContain('data after BODY_CBOR');
// A BODY_LEN over the limit, in a file longer than the read.
const over = concat(dkk.subarray(0, 8), new Uint8Array([0x01, 0x00, 0x00, 0x01]), new Uint8Array(MAX_DKK_BODY_LEN + 8));
expect(decodeError(await read(over))).toBe(decodeError(over));
expect(decodeError(over)).toContain('outside 1..');
// Not a .dkk at all.
const other = new Uint8Array(MAX_ACCESS_KEY_READ + 5).fill(0x41);
expect(decodeError(await read(other))).toBe(decodeError(other));
});
});

@ -1,9 +1,10 @@
// Reading a .dkc for the inspection without holding a large payload in // Reading a .dkc for the inspection without holding a large payload in
// memory: the page reads that prefix for steps 1 to 8, and open reads it too // memory: the page reads that prefix for steps 1 to 8, and open reads it too
// and streams PAYLOAD_AGE from the rest of the file. // and streams PAYLOAD_AGE from the rest of the file. A .dkk is bounded by the
// limit of its body, so a file far larger is never read whole either.
import { MAX_AGE_HEADER_LEN } from './age.ts'; import { MAX_AGE_HEADER_LEN } from './age.ts';
import { DKC_PRELUDE_SIZE, type Prelude, parsePrelude, payloadOffset } from './framing.ts'; import { DKC_PRELUDE_SIZE, DKK_PRELUDE_SIZE, MAX_DKK_BODY_LEN, type Prelude, parsePrelude, payloadOffset } from './framing.ts';
/** /**
* How many leading bytes of a .dkc of `size` bytes steps 1 to 8 need, given * How many leading bytes of a .dkc of `size` bytes steps 1 to 8 need, given
@ -49,3 +50,20 @@ export async function readCapsule(blob: Blob): Promise<CapsuleBytes> {
const bytes = n === head.length ? head : new Uint8Array(await blob.slice(0, n).arrayBuffer()); const bytes = n === head.length ? head : new Uint8Array(await blob.slice(0, n).arrayBuffer());
return { bytes, size: blob.size }; return { bytes, size: blob.size };
} }
/**
* The most bytes of a .dkk that decodeAccessKey needs: a valid one is its
* 12-byte prelude and a body of at most 16 MiB (spec §57), and one byte more
* is enough to see data after the largest body.
*/
export const MAX_ACCESS_KEY_READ = DKK_PRELUDE_SIZE + MAX_DKK_BODY_LEN + 1;
/**
* Reads a .dkk file for decodeAccessKey: the whole file, or its first
* MAX_ACCESS_KEY_READ bytes when it is longer, which decode to the same
* error as the whole file (a BODY_LEN over the limit, or data after the
* body), since no framing error states a length it did not read.
*/
export async function readAccessKey(blob: Blob): Promise<Uint8Array> {
return new Uint8Array(await blob.slice(0, MAX_ACCESS_KEY_READ).arrayBuffer());
}

@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { equalBytes } from '../dkc/index.ts'; import { equalBytes, toHex } from '../dkc/index.ts';
import { FIXTURES, fetchFixture, fixtureList } from './fixtures.ts'; import { FIXTURES, fetchFixture, fixtureList } from './fixtures.ts';
import { listTestdata, readBytes, readJSON } from '../dkc/testing/testdata.ts'; import { listTestdata, readBytes, readJSON } from '../dkc/testing/testdata.ts';
@ -18,24 +18,55 @@ describe('fixtures', () => {
} }
}); });
it('takes the description of the JSON record of each capsule', () => { it('takes the description, the release and the plaintext SHA-256 of the JSON record of each capsule', () => {
for (const f of FIXTURES) { for (const f of FIXTURES) {
const record = readJSON<{ description: string }>(`fixtures/${f.name.replace(/\.dkc$/, '.json')}`); const record = readJSON<{ description: string; release: { round: number; signature: string }; plaintext_sha256: string }>(
`fixtures/${f.name.replace(/\.dkc$/, '.json')}`,
);
expect(f.description).toBe(record.description); expect(f.description).toBe(record.description);
expect(f.release?.round).toBe(record.release.round);
expect(toHex(f.release!.signature)).toBe(record.release.signature);
expect(f.plaintextSHA256).toBe(record.plaintext_sha256);
} }
}); });
it('pairs names, sorts them and ignores descriptions that are missing or not text', () => { it('pairs names, sorts them and ignores fields that are missing or of the wrong shape', () => {
const sig = 'ab'.repeat(48);
const none = { release: undefined, plaintextSHA256: undefined };
const list = fixtureList( const list = fixtureList(
{ '/t/b.dkc': '/u/b', '/t/a.dkc': '/u/a', '/t/c.dkc': '/u/c', '/t/__proto__.dkc': '/u/p' }, { '/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': '' }, {
description: { '/t/a.json': 'first', '/t/b.json': 42, '/t/c.dkk.json': 'not mine', '/t/__proto__.json': '' },
release: {
'/t/a.json': { round: 7, signature: sig },
'/t/b.json': { round: 7.5, signature: sig },
'/t/c.json': { round: 7, signature: 'ABCD' },
'/t/__proto__.json': null,
},
plaintextSHA256: { '/t/a.json': '01'.repeat(32), '/t/b.json': '01'.repeat(31), '/t/c.json': 5 },
},
); );
expect(list).toEqual([ expect(list).toEqual([
{ name: '__proto__.dkc', url: '/u/p', description: undefined }, { name: '__proto__.dkc', url: '/u/p', description: undefined, ...none },
{ name: 'a.dkc', url: '/u/a', description: 'first' }, { name: 'a.dkc', url: '/u/a', description: 'first', release: { round: 7, signature: new Uint8Array(48).fill(0xab) }, plaintextSHA256: '01'.repeat(32) },
{ name: 'b.dkc', url: '/u/b', description: undefined }, { name: 'b.dkc', url: '/u/b', description: undefined, ...none },
{ name: 'c.dkc', url: '/u/c', description: undefined }, { name: 'c.dkc', url: '/u/c', description: undefined, ...none },
]); ]);
// A round below 1, a missing signature or round, a release that is not an object.
const odd = fixtureList(
{ '/t/d.dkc': '/u/d', '/t/e.dkc': '/u/e', '/t/f.dkc': '/u/f', '/t/g.dkc': '/u/g' },
{
description: {},
release: {
'/t/d.json': { round: 0, signature: sig },
'/t/e.json': { round: 3 },
'/t/f.json': 'release',
'/t/g.json': { signature: sig },
},
plaintextSHA256: {},
},
);
expect(odd.map((f) => f.release)).toEqual([undefined, undefined, undefined, undefined]);
}); });
}); });
@ -51,6 +82,7 @@ describe('fetchFixture', () => {
it('fails on an HTTP error, naming the fixture', async () => { it('fails on an HTTP error, naming the fixture', async () => {
vi.stubGlobal('fetch', async () => new Response('', { status: 404 })); 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'); const f = { name: 'x.dkc', url: '/x.dkc', description: undefined, release: undefined, plaintextSHA256: undefined };
await expect(fetchFixture(f)).rejects.toThrow('x.dkc: HTTP 404');
}); });
}); });

@ -2,18 +2,33 @@
// The official fixtures shipped with the site. Vite bundles them straight // The official fixtures shipped with the site. Vite bundles them straight
// from testdata/fixtures at build time, so testdata/ stays the single source // 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 // 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 // inlines assets), and of its JSON record only three public fields are
// imported. Neither the .dkk access keys, nor the plaintexts, nor the rest of // imported: `description`; `release`, the round and signature that drand
// the JSON records (payload_identity, control_cbor, …) reach the bundle. // published, which the page supplies to open the fixture; and
// `plaintext_sha256`, which the page compares with what it decrypted.
// Neither the .dkk access keys, nor the plaintexts, nor the rest of the JSON
// records (payload_identity, control_cbor, …) reach the bundle; the build
// check (scripts/check-build.mjs) makes sure.
import { fromHex } from '../dkc/index.ts';
import type { SuppliedRelease } from './release-input.ts';
const urls = import.meta.glob<string>('/testdata/fixtures/*.dkc', { query: '?url', import: 'default', eager: true }); const urls = import.meta.glob<string>('/testdata/fixtures/*.dkc', { query: '?url', import: 'default', eager: true });
// The records of the capsules only: neither the .dkk records nor the frozen // The records of the capsules only: neither the .dkk records nor the frozen
// outputs of `datekeys inspect -json` (*.inspect.json), which have no // outputs of `datekeys inspect -json` (*.inspect.json), which have none of
// description. // these fields. Vite needs the arguments of import.meta.glob as literals.
const descriptions = import.meta.glob<unknown>( const descriptions = import.meta.glob<unknown>(
['/testdata/fixtures/*.json', '!/testdata/fixtures/*.dkk.json', '!/testdata/fixtures/*.inspect.json'], ['/testdata/fixtures/*.json', '!/testdata/fixtures/*.dkk.json', '!/testdata/fixtures/*.inspect.json'],
{ import: 'description', eager: true }, { import: 'description', eager: true },
); );
const releases = import.meta.glob<unknown>(
['/testdata/fixtures/*.json', '!/testdata/fixtures/*.dkk.json', '!/testdata/fixtures/*.inspect.json'],
{ import: 'release', eager: true },
);
const digests = import.meta.glob<unknown>(
['/testdata/fixtures/*.json', '!/testdata/fixtures/*.dkk.json', '!/testdata/fixtures/*.inspect.json'],
{ import: 'plaintext_sha256', eager: true },
);
/** One official fixture capsule. */ /** One official fixture capsule. */
export interface Fixture { export interface Fixture {
@ -23,29 +38,54 @@ export interface Fixture {
readonly url: string; readonly url: string;
/** The description of its JSON record (in English, from the Go reference). */ /** The description of its JSON record (in English, from the Go reference). */
readonly description: string | undefined; readonly description: string | undefined;
/** The release of its round, as drand published it. */
readonly release: SuppliedRelease | undefined;
/** The SHA-256 of its plaintext, in lowercase hexadecimal. */
readonly plaintextSHA256: string | undefined;
}
/** The fields of the JSON records, each keyed by the path of its record. */
export interface RecordFields {
readonly description: Readonly<Record<string, unknown>>;
readonly release: Readonly<Record<string, unknown>>;
readonly plaintextSHA256: Readonly<Record<string, unknown>>;
} }
/** /**
* Pairs each .dkc with the description of the JSON record of the same name, * Pairs each .dkc with the fields of the JSON record of the same name,
* sorted by file name. Both arguments come from import.meta.glob, keyed by * sorted by file name. Every argument comes from import.meta.glob, keyed by
* build-time paths, never by input. * build-time paths, never by input; a field of the wrong shape is ignored.
*/ */
export function fixtureList(dkcUrls: Readonly<Record<string, string>>, jsonDescriptions: Readonly<Record<string, unknown>>): Fixture[] { export function fixtureList(dkcUrls: Readonly<Record<string, string>>, fields: RecordFields): Fixture[] {
return Object.keys(dkcUrls) return Object.keys(dkcUrls)
.sort() .sort()
.map((path) => { .map((path) => {
const json = path.replace(/\.dkc$/, '.json'); const json = path.replace(/\.dkc$/, '.json');
const d = Object.hasOwn(jsonDescriptions, json) ? jsonDescriptions[json] : undefined; const field = (m: Readonly<Record<string, unknown>>): unknown => (Object.hasOwn(m, json) ? m[json] : undefined);
const d = field(fields.description);
const h = field(fields.plaintextSHA256);
return { return {
name: path.slice(path.lastIndexOf('/') + 1), name: path.slice(path.lastIndexOf('/') + 1),
url: dkcUrls[path]!, url: dkcUrls[path]!,
description: typeof d === 'string' && d !== '' ? d : undefined, description: typeof d === 'string' && d !== '' ? d : undefined,
release: recordRelease(field(fields.release)),
plaintextSHA256: typeof h === 'string' && /^[0-9a-f]{64}$/.test(h) ? h : undefined,
}; };
}); });
} }
// The release of a record: {"round": <integer>, "signature": "<hex>"}.
function recordRelease(v: unknown): SuppliedRelease | undefined {
if (typeof v !== 'object' || v === null) return undefined;
const round: unknown = Object.hasOwn(v, 'round') ? (v as { round: unknown }).round : undefined;
const signature: unknown = Object.hasOwn(v, 'signature') ? (v as { signature: unknown }).signature : undefined;
if (typeof round !== 'number' || !Number.isSafeInteger(round) || round < 1) return undefined;
if (typeof signature !== 'string' || !/^(?:[0-9a-f]{2})+$/.test(signature)) return undefined;
return { round, signature: fromHex(signature) };
}
/** The official fixtures, sorted by file name. */ /** The official fixtures, sorted by file name. */
export const FIXTURES: readonly Fixture[] = fixtureList(urls, descriptions); export const FIXTURES: readonly Fixture[] = fixtureList(urls, { description: descriptions, release: releases, plaintextSHA256: digests });
/** Fetches the bytes of a fixture from the site's own origin. */ /** Fetches the bytes of a fixture from the site's own origin. */
export async function fetchFixture(f: Fixture): Promise<Uint8Array> { export async function fetchFixture(f: Fixture): Promise<Uint8Array> {

@ -10,9 +10,13 @@ import {
formatInteger, formatInteger,
formatRelative, formatRelative,
INSPECT_STEPS, INSPECT_STEPS,
OPEN_STEPS_TIME_AND_KEY,
OPEN_STEPS_TIME_ONLY,
openStepGloss,
instantToEpochMs, instantToEpochMs,
policyGloss, policyGloss,
printableText, printableText,
safeFileName,
stepGloss, stepGloss,
stepName, stepName,
viewerTimeZone, viewerTimeZone,
@ -37,6 +41,17 @@ describe('steps and codes', () => {
expect(stepGloss(9)).toBe(''); expect(stepGloss(9)).toBe('');
}); });
it('explains every check of the opening, the failure of step 17 included', () => {
const names = new Set([...OPEN_STEPS_TIME_ONLY, ...OPEN_STEPS_TIME_AND_KEY].map(([, name]) => name));
expect(names.size).toBe(10);
const glosses = [...names, 'open payload'].map(openStepGloss);
for (const g of glosses) expect(g).toMatch(/^\p{Lu}.*\.$/u);
expect(new Set(glosses).size).toBe(11);
expect(openStepGloss('tlock stanza')).toBe('');
// The same order as the reference, time_and_key with one more check.
expect(OPEN_STEPS_TIME_AND_KEY.filter(([, n]) => n !== 'access credential' && n !== 'open access layer')).toEqual(OPEN_STEPS_TIME_ONLY);
});
it('explains every normative code in Spanish', () => { it('explains every normative code in Spanish', () => {
const glosses = ERROR_CODES.map(errorGloss); const glosses = ERROR_CODES.map(errorGloss);
for (const g of glosses) expect(g).toMatch(/^\p{Lu}.*\.$/u); for (const g of glosses) expect(g).toMatch(/^\p{Lu}.*\.$/u);
@ -51,6 +66,11 @@ describe('steps and codes', () => {
}); });
describe('text', () => { describe('text', () => {
it('replaces what is not printable in a file name', () => {
expect(safeFileName('informe 2026 — ñ.pdf')).toBe('informe 2026 — ñ.pdf');
expect(safeFileName('a‮b\tc\nd\u{1f600}')).toBe('a_b_c_d\u{1f600}');
});
it('shows printable UTF-8 as text', () => { it('shows printable UTF-8 as text', () => {
expect(printableText(te.encode('public label'))).toBe('public label'); 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 ✓'); expect(printableText(te.encode('línea 1\n\tlínea 2 — ok ✓'))).toBe('línea 1\n\tlínea 2 — ok ✓');

@ -55,11 +55,70 @@ export function stepGloss(step: number): string {
} }
} }
/**
* The checks of steps 9 to 18 that the opening records when it succeeds, by
* policy, as the reference records them (the `stages` of the fixture
* records): step 17 has no check of its own, and its failure is the only
* one recorded under 17; step 18, the commit, follows it.
*/
export const OPEN_STEPS_TIME_ONLY: readonly (readonly [number, string])[] = [
[9, 'release'],
[10, 'release verification'],
[11, 'open sealed control'],
[12, 'policy structure'],
[14, 'control'],
[15, 'header binding'],
[16, 'payload identity'],
[18, 'commit'],
];
export const OPEN_STEPS_TIME_AND_KEY: readonly (readonly [number, string])[] = [
[9, 'access credential'],
[9, 'release'],
[10, 'release verification'],
[11, 'open sealed control'],
[12, 'policy structure'],
[13, 'open access layer'],
[14, 'control'],
[15, 'header binding'],
[16, 'payload identity'],
[18, 'commit'],
];
/** What each check of steps 9 to 18 does, in Spanish, by its CLI name. */
export function openStepGloss(name: string): string {
switch (name) {
case 'access credential':
return 'Solo en time_and_key, antes de usar el release: la .dkk como objeto, su vínculo con esta cápsula (capsule_id y, si lo trae, capsule_digest) y que haya al menos una credencial.';
case 'release':
return 'Antes de usarlo, sin red, se comprueba con el reloj de este dispositivo que la fecha de apertura ya pasó; después se toma el release que has dado, la firma de la ronda.';
case 'release verification':
return 'Primero la ronda del release, que debe ser la de la DateKey; después la firma, que debe ser la codificación canónica de un punto de G1 y verificar con la clave pública del perfil fijado (§17, §51).';
case 'open sealed control':
return 'Se abre OUTER_TIME_AGE con la firma verificada: exactamente un stanza tlock con la ronda y la cadena del perfil, su cuerpo IBE de 128 bytes y el MAC de la cabecera age.';
case 'policy structure':
return 'Lo sellado coincide con access_policy: CONTROL_CBOR directamente en time_only; en time_and_key, INNER_ACCESS_AGE con solo stanzas X25519 (§36).';
case 'open access layer':
return 'Se abre INNER_ACCESS_AGE: cada credencial se prueba contra cada stanza X25519, y ninguna puede abrir más de uno.';
case 'control':
return 'CONTROL_CBOR está en su forma canónica y no trae extensiones críticas desconocidas.';
case 'header binding':
return 'El header_binding de CONTROL_CBOR coincide con los bytes exactos de PRELUDE y PUBLIC_HEADER (§26): coherencia interna, no autoría.';
case 'payload identity':
return 'Se recupera I_PAYLOAD, la identidad X25519 que abre PAYLOAD_AGE.';
case 'open payload':
return 'Se abre PAYLOAD_AGE con I_PAYLOAD: exactamente un stanza X25519 para R_PAYLOAD, el MAC de la cabecera y cada fragmento STREAM, sin truncar ni datos al final.';
case 'commit':
return 'Pasos 17 y 18: PAYLOAD_AGE se abre con I_PAYLOAD y age autentica cada fragmento; el texto en claro se entrega solo cuando lo ha autenticado entero (§56).';
default:
return '';
}
}
/** What a normative code of spec §69 means, in Spanish. */ /** What a normative code of spec §69 means, in Spanish. */
export function errorGloss(code: ErrorCode): string { export function errorGloss(code: ErrorCode): string {
switch (code) { switch (code) {
case 'ERR_INVALID_MAGIC': case 'ERR_INVALID_MAGIC':
return 'El fichero no empieza por DKC1: no es una cápsula DateKeys.'; return 'El fichero no empieza por la marca de su formato: DKC1 en una cápsula .dkc, DKK1 en una clave .dkk.';
case 'ERR_UNSUPPORTED_VERSION': case 'ERR_UNSUPPORTED_VERSION':
return 'La versión de la trama o del esquema no es una que este lector implemente.'; return 'La versión de la trama o del esquema no es una que este lector implemente.';
case 'ERR_INVALID_FLAGS': case 'ERR_INVALID_FLAGS':
@ -75,23 +134,23 @@ export function errorGloss(code: ErrorCode): string {
case 'ERR_DATEKEY_NON_CANONICAL': case 'ERR_DATEKEY_NON_CANONICAL':
return 'La DateKey no está en su forma canónica dk1_.'; return 'La DateKey no está en su forma canónica dk1_.';
case 'ERR_ROUND_MISMATCH': case 'ERR_ROUND_MISMATCH':
return 'La ronda del stanza tlock no es la de la DateKey escrita en decimal canónico.'; return 'La ronda del stanza tlock, o la del release, no es la de la DateKey.';
case 'ERR_RELEASE_UNAVAILABLE': case 'ERR_RELEASE_UNAVAILABLE':
return 'La firma de la ronda todavía no se puede obtener.'; return 'No hay firma de la ronda: la fecha de apertura no ha llegado según el reloj de este dispositivo, o no se ha dado ninguna.';
case 'ERR_RELEASE_INVALID': case 'ERR_RELEASE_INVALID':
return 'La firma de la ronda no verifica con el perfil fijado.'; return 'La firma de la ronda no verifica con el perfil fijado.';
case 'ERR_ACCESS_REQUIRED': case 'ERR_ACCESS_REQUIRED':
return 'La política exige una credencial de acceso: una clave .dkk o la identidad X25519 de un destinatario.'; return 'La política exige una credencial de acceso: una clave .dkk o la identidad X25519 de un destinatario.';
case 'ERR_ACCESS_INVALID': case 'ERR_ACCESS_INVALID':
return 'La clave de acceso no abre esta cápsula.'; return 'La credencial de acceso no vale para esta cápsula: está mal formada, es de otra cápsula o no es de ningún destinatario.';
case 'ERR_POLICY_STRUCTURE_MISMATCH': case 'ERR_POLICY_STRUCTURE_MISMATCH':
return 'El número o el tipo de stanzas age, o el número de argumentos del stanza tlock, no es el que exige el protocolo.'; return 'El número o el tipo de stanzas age, o el número de argumentos del stanza tlock, no es el que exige el protocolo.';
case 'ERR_HEADER_BINDING': case 'ERR_HEADER_BINDING':
return 'La cabecera pública no es la que se selló.'; return 'La cabecera pública no es la que se selló.';
case 'ERR_INTEGRITY': case 'ERR_INTEGRITY':
return 'La trama o una cabecera age está truncada, mal formada o fuera de límites.'; return 'La trama o una cabecera age está truncada, mal formada o fuera de límites, o un fichero age no se autentica.';
case 'ERR_EXTENSION_CRITICAL_UNKNOWN': case 'ERR_EXTENSION_CRITICAL_UNKNOWN':
return 'La cápsula exige una extensión crítica que este lector no conoce, así que la rechaza.'; return 'La cápsula o la clave .dkk exige una extensión crítica que este lector no conoce, así que se rechaza.';
case 'ERR_EXTENSION_DATA_INVALID': case 'ERR_EXTENSION_DATA_INVALID':
return 'Una extensión conocida trae datos que no siguen su esquema registrado.'; return 'Una extensión conocida trae datos que no siguen su esquema registrado.';
} }
@ -150,6 +209,17 @@ export function displayText(s: string): { text: string; escaped: boolean } {
return { text: s, escaped: false }; return { text: s, escaped: false };
} }
/**
* A file name to offer for saving: `s` with every character that is not
* printable replaced by an underscore, so that no bidi override or invisible
* character can disguise its extension, whatever the browser does with it.
*/
export function safeFileName(s: string): string {
let out = '';
for (const ch of s) out += isPrintableRune(ch.codePointAt(0)!) ? ch : '_';
return out;
}
/** /**
* A text for display in which every character that is not printable, except * 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 * line feeds and tabs, is written as a JSON escape (\uXXXX, a surrogate pair

@ -0,0 +1,162 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { equalBytes, fromHex, type Instant } from '../dkc/index.ts';
import { listTestdata, readBytes, readJSON } from '../dkc/testing/testdata.ts';
import { openCapsule, type OpenRequest, parseIdentities, systemClock } from './opener.ts';
import type { TempFile } from './tempfile.ts';
afterEach(() => {
vi.useRealTimers();
});
interface Record {
release: { round: number; signature: string };
access_policy: string;
access_key_file?: string;
identities?: string[];
plaintext_file: string;
plaintext_sha256: string;
}
const later: () => Instant = () => ({ seconds: 2_000_000_000, nanos: 0 });
const blob = (b: Uint8Array): Blob => new Blob([b as Uint8Array<ArrayBuffer>]);
function fixture(name: string): { dkc: Uint8Array; record: Record; request: OpenRequest } {
const record = readJSON<Record>(`fixtures/${name}.json`);
const dkc = readBytes(`fixtures/${name}.dkc`);
return {
dkc,
record,
request: { capsule: dkc, release: { round: record.release.round, signature: fromHex(record.release.signature) }, now: later },
};
}
// A temporary file in memory: its content is the writes, once closed.
function memoryTemp(): TempFile & { removed: boolean } {
const chunks: Uint8Array[] = [];
let content: Uint8Array | undefined;
const t = {
removed: false,
writable: new WritableStream<Uint8Array>({
write: (c) => void chunks.push(c.slice()),
close: () => void (content = new Uint8Array(chunks.flatMap((c) => [...c]))),
}),
file: async () => new File([(content ?? new Uint8Array(0)) as Uint8Array<ArrayBuffer>], 'plaintext'),
remove: async () => void (t.removed = true),
};
return t;
}
const capsules = listTestdata('fixtures', '.dkc').map((p) => p.slice('fixtures/'.length, -'.dkc'.length));
describe('openCapsule', () => {
it('opens every official fixture into memory, with the SHA-256 of its record', async () => {
expect(capsules.length).toBeGreaterThanOrEqual(5);
for (const name of capsules) {
const f = fixture(name);
const credentials: Partial<OpenRequest> =
f.record.access_policy === 'time_and_key' ? { accessKey: blob(readBytes(`fixtures/${f.record.access_key_file!}`)) } : {};
const r = await openCapsule({ ...f.request, ...credentials });
if (!r.ok) throw new Error(r.problem);
expect(r.opened.error, name).toBeUndefined();
const plaintext = readBytes(`fixtures/${f.record.plaintext_file}`);
expect(equalBytes(r.plaintext!, plaintext), name).toBe(true);
expect(r.digest).toEqual({ length: plaintext.length, sha256: f.record.plaintext_sha256 });
expect(r.ms).toBeGreaterThanOrEqual(0);
}
});
it('opens time_and_key with the identities of its recipients, one per line', async () => {
const f = fixture('time_and_key_recipients');
for (const text of [
f.record.identities![0]!,
`# age identity file\n\n ${f.record.identities![1]!} \r\n`,
f.record.identities!.join('\n'),
]) {
const r = await openCapsule({ ...f.request, identities: text });
expect(r.ok && r.opened.error).toBeUndefined();
expect(r.ok && r.digest?.sha256).toBe(f.record.plaintext_sha256);
}
// Without any, the protocol reports it at step 9.
const none = await openCapsule(f.request);
expect(none.ok && none.opened.error?.code).toBe('ERR_ACCESS_REQUIRED');
});
it('writes to a temporary file, and reads the SHA-256 back from it', async () => {
const f = fixture('time_only');
const t = memoryTemp();
const r = await openCapsule({ ...f.request, capsule: blob(f.dkc), output: t });
if (!r.ok) throw new Error(r.problem);
expect(r.opened.error).toBeUndefined();
expect(r.plaintext).toBeUndefined();
expect(r.digest).toEqual({ length: 78000, sha256: f.record.plaintext_sha256 });
expect(await (await t.file()).text()).toBe(new TextDecoder().decode(readBytes('fixtures/time_only.plaintext')));
});
it('reports the failure of the protocol, with no digest', async () => {
const f = fixture('time_only');
const r = await openCapsule({ ...f.request, release: { round: 1000, signature: new Uint8Array(48) } });
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.opened.error?.code).toBe('ERR_RELEASE_INVALID');
expect(r.digest).toBeUndefined();
expect(r.plaintext).toBeUndefined();
});
it('does not run open when an identity line is not an identity, and never quotes it', async () => {
const f = fixture('time_and_key_recipients');
const secret = 'AGE-SECRET-KEY-1NOTAKEY';
const r = await openCapsule({ ...f.request, identities: `${f.record.identities![0]!}\n\n${secret}` });
expect(r).toEqual({ ok: false, problem: 'La línea 3 no es una identidad X25519 de age (AGE-SECRET-KEY-1…).', field: 'identities' });
});
it('reports a .dkk that cannot be read', async () => {
const f = fixture('time_and_key_portable');
const unreadable = { slice: () => ({ arrayBuffer: () => Promise.reject(new Error('NotReadableError')) }) } as unknown as Blob;
expect(await openCapsule({ ...f.request, accessKey: unreadable })).toEqual({
ok: false,
problem: 'No se pudo leer la .dkk: NotReadableError',
field: 'accessKey',
});
const odd = { slice: () => ({ arrayBuffer: () => Promise.reject('gone') }) } as unknown as Blob;
expect(await openCapsule({ ...f.request, accessKey: odd })).toEqual({ ok: false, problem: 'No se pudo leer la .dkk: gone', field: 'accessKey' });
});
it('uses the system clock by default', async () => {
const f = fixture('time_only');
const { now: _, ...request } = f.request;
const r = await openCapsule(request);
expect(r.ok && r.opened.error).toBeUndefined();
});
});
describe('parseIdentities', () => {
it('reads an age identity file: one per line, blank lines and comments ignored', () => {
const ids = readJSON<Record>('fixtures/time_and_key_recipients.json').identities!;
const r = parseIdentities(`# two recipients\n${ids[0]!}\r\n\n \n${ids[1]!}\n# end`);
expect(r.ok && r.ids.length).toBe(2);
expect(parseIdentities('')).toEqual({ ok: true, ids: [] });
expect(parseIdentities('# nothing\n')).toEqual({ ok: true, ids: [] });
});
it('refuses lowercase, a recipient and anything else, by line number', () => {
const id = readJSON<Record>('fixtures/time_and_key_recipients.json').identities![0]!;
for (const [text, line] of [
[id.toLowerCase(), 1],
[`${id}\nage1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq`, 2],
[`\n\n${id.slice(0, -1)}`, 3],
] as const) {
const r = parseIdentities(text);
expect(r).toEqual({ ok: false, problem: `La línea ${line} no es una identidad X25519 de age (AGE-SECRET-KEY-1…).` });
}
});
});
describe('systemClock', () => {
it('splits the milliseconds of the system clock into seconds and nanoseconds', () => {
vi.useFakeTimers();
vi.setSystemTime(1_692_806_364_250);
expect(systemClock()).toEqual({ seconds: 1_692_806_364, nanos: 250_000_000 });
vi.setSystemTime(0);
expect(systemClock()).toEqual({ seconds: 0, nanos: 0 });
});
});

@ -0,0 +1,111 @@
// The opening, which the inspector page loads on demand with a dynamic
// import: it carries open.ts and with it noble and age-encryption, which the
// first load of the page does not need (plan of phase 2, section 9). The page
// builds what it shows from the result with opening.ts, which imports no
// noble. No release is fetched: the caller supplies it directly (spec §63
// step 10), pasted or from the record of a fixture.
import { type Instant, readAccessKey, sha256, toHex } from '../dkc/index.ts';
import { sha256Stream } from '../dkc/digest.ts';
import { open, type Opened } from '../dkc/open.ts';
import { suppliedRelease } from '../dkc/release.ts';
import { parseX25519Identity } from '../dkc/x25519.ts';
import type { SuppliedRelease } from './release-input.ts';
import type { TempFile } from './tempfile.ts';
export interface OpenRequest {
/** The capsule: the bytes of a fixture, or the person's file. */
readonly capsule: Uint8Array | Blob;
/** The release the person supplied, verified at step 10. */
readonly release: SuppliedRelease;
/** age X25519 identities (AGE-SECRET-KEY-1…), one per line, for time_and_key. */
readonly identities?: string;
/** A .dkk file, for time_and_key. */
readonly accessKey?: Blob;
/** Where the plaintext goes; memory when omitted. */
readonly output?: TempFile;
/** The clock; the system clock when omitted. */
readonly now?: () => Instant;
}
export type OpenAttempt =
| {
/** An input could not be used, so open did not run. */
readonly ok: false;
readonly problem: string;
/** The input at fault. */
readonly field: 'identities' | 'accessKey';
}
| {
readonly ok: true;
readonly opened: Opened;
/** The plaintext, when the capsule opened into memory. */
readonly plaintext?: Uint8Array;
/** Length and SHA-256 of the plaintext, when the capsule opened. */
readonly digest?: { readonly length: number; readonly sha256: string };
/** How long open took, in milliseconds. */
readonly ms: number;
};
/** The system clock as an Instant. */
export function systemClock(): Instant {
const ms = Date.now();
const seconds = Math.floor(ms / 1000);
return { seconds, nanos: (ms - seconds * 1000) * 1e6 };
}
/**
* Reads the identities of a text in the form of an age identity file: one
* per line, blank lines and lines starting with # ignored. A line that is
* not an X25519 identity is reported by its number, never by its content.
*/
export function parseIdentities(text: string): { ok: true; ids: Uint8Array[] } | { ok: false; problem: string } {
const ids: Uint8Array[] = [];
const lines = text.split(/\r?\n/);
for (const [i, raw] of lines.entries()) {
const line = raw.trim();
if (line === '' || line.startsWith('#')) continue;
try {
ids.push(parseX25519Identity(line));
} catch {
for (const id of ids) id.fill(0);
return { ok: false, problem: `La línea ${i + 1} no es una identidad X25519 de age (AGE-SECRET-KEY-1…).` };
}
}
return { ok: true, ids };
}
/** Runs steps 1 to 18 on the capsule with what the person supplied. */
export async function openCapsule(req: OpenRequest): Promise<OpenAttempt> {
const parsed = parseIdentities(req.identities ?? '');
if (!parsed.ok) return { ...parsed, field: 'identities' };
const ids = parsed.ids;
try {
let accessKeyFile: Uint8Array | undefined;
if (req.accessKey !== undefined) {
try {
accessKeyFile = await readAccessKey(req.accessKey);
} catch (err) {
return { ok: false, problem: `No se pudo leer la .dkk: ${err instanceof Error ? err.message : String(err)}`, field: 'accessKey' };
}
}
const start = performance.now();
const opened = await open(req.capsule, {
source: suppliedRelease(req.release),
now: req.now ?? systemClock,
identities: ids,
...(accessKeyFile === undefined ? {} : { accessKeyFile }),
...(req.output === undefined ? {} : { output: req.output.writable }),
});
const ms = performance.now() - start;
if (opened.error !== undefined) return { ok: true, opened, ms };
if (req.output !== undefined) {
const file = await req.output.file();
return { ok: true, opened, digest: { length: file.size, sha256: toHex(await sha256Stream(file.stream())) }, ms };
}
const plaintext = opened.plaintext!;
return { ok: true, opened, plaintext, digest: { length: plaintext.length, sha256: toHex(await sha256(plaintext)) }, ms };
} finally {
for (const id of ids) id.fill(0);
}
}

@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest';
import { fromHex, type Instant, toHex } from '../dkc/index.ts';
import { open, type OpenOptions } from '../dkc/open.ts';
import { suppliedRelease } from '../dkc/release.ts';
import { readBytes, readJSON } from '../dkc/testing/testdata.ts';
import { OPEN_STEPS_TIME_AND_KEY, OPEN_STEPS_TIME_ONLY, openStepGloss } from './format.ts';
import { buildOpenReport, plaintextFileName } from './opening.ts';
interface Record {
release: { round: number; signature: string };
stages: { step: number; name: string; ok: boolean }[];
}
const later: () => Instant = () => ({ seconds: 2_000_000_000, nanos: 0 });
function fixture(name: string): { dkc: Uint8Array; record: Record; options: OpenOptions } {
const record = readJSON<Record>(`fixtures/${name}.json`);
const release = { round: record.release.round, signature: fromHex(record.release.signature) };
return { dkc: readBytes(`fixtures/${name}.dkc`), record, options: { source: suppliedRelease(release), now: later } };
}
const rows = (r: ReturnType<typeof buildOpenReport>): [number, string, string, string?][] =>
r.steps.map((s) => (s.code === undefined ? [s.step, s.name, s.state] : [s.step, s.name, s.state, s.code]));
describe('buildOpenReport', () => {
it('lists the checks of steps 9 to 18 as the reference records them', async () => {
for (const name of ['time_only', 'time_only_extensions', 'empty_payload']) {
const f = fixture(name);
const r = buildOpenReport(await open(f.dkc, f.options), { length: 1, sha256: 'ab' });
expect(r.opened, name).toBe(true);
expect(r.failure).toBeUndefined();
expect(r.steps.map((s) => [s.step, s.name, s.state]), name).toEqual(
f.record.stages.filter((s) => s.step >= 9).map((s) => [s.step, s.name, 'ok']),
);
expect(r.steps.map((s) => [s.step, s.name])).toEqual(OPEN_STEPS_TIME_ONLY);
for (const s of r.steps) expect(s.gloss).toBe(openStepGloss(s.name));
expect(r.release).toEqual({ round: f.record.release.round, signature: f.record.release.signature });
expect(r.plaintext).toEqual({ length: 1, sha256: 'ab' });
}
});
it('lists the checks of a time_and_key capsule, credentials first', async () => {
const f = fixture('time_and_key_portable');
const opened = await open(f.dkc, { ...f.options, accessKeyFile: readBytes('fixtures/time_and_key_portable.dkk') });
const r = buildOpenReport(opened);
expect(r.opened).toBe(true);
expect(r.steps.map((s) => [s.step, s.name])).toEqual(OPEN_STEPS_TIME_AND_KEY);
expect(r.steps.map((s) => [s.step, s.name])).toEqual(f.record.stages.filter((s) => s.step >= 9).map((s) => [s.step, s.name]));
expect(r.plaintext).toBeUndefined();
});
it('shows the extensions of CONTROL_CBOR once it is decoded', async () => {
const plain = fixture('time_only');
expect(buildOpenReport(await open(plain.dkc, plain.options)).controlExtensions).toEqual([]);
const f = fixture('time_only_extensions');
const opened = await open(f.dkc, f.options);
const ext = buildOpenReport(opened).controlExtensions!;
expect(ext).toHaveLength(1);
expect(ext[0]).toMatchObject({ critical: false, known: false });
// Critical ones come first; the page opens without a registry, so it only
// meets them in a result made by an application that knows them.
const critical = { id: 'org.example.critical', version: 1, data: undefined };
const both = buildOpenReport({ ...opened, controlCritical: [critical] }).controlExtensions!;
expect(both.map((e) => [e.id, e.critical])).toEqual([
['org.example.critical', true],
[ext[0]!.id, false],
]);
});
it('marks the steps after a failure as not run', async () => {
const f = fixture('time_and_key_portable');
const r = buildOpenReport(await open(f.dkc, f.options), { length: 1, sha256: 'ab' });
expect(r.opened).toBe(false);
expect(r.failure).toMatchObject({ step: 9, code: 'ERR_ACCESS_REQUIRED' });
expect(rows(r)).toEqual([
[9, 'access credential', 'failed', 'ERR_ACCESS_REQUIRED'],
...OPEN_STEPS_TIME_AND_KEY.slice(1).map(([step, name]) => [step, name, 'not-run']),
]);
expect(r.steps[0]!.detail).toBe('capsule: time_and_key capsule and no identity or .dkk supplied: ERR_ACCESS_REQUIRED');
// No plaintext for a capsule that did not open, whatever is passed.
expect(r.plaintext).toBeUndefined();
expect(r.release).toBeUndefined();
expect(r.controlExtensions).toBeUndefined();
});
it('reports a release of another round at step 10, and a clock before the round at step 9', async () => {
const f = fixture('time_only');
const other = fixture('empty_payload').record.release;
const wrong = await open(f.dkc, { ...f.options, source: suppliedRelease({ round: other.round, signature: fromHex(other.signature) }) });
expect(rows(buildOpenReport(wrong)).slice(0, 3)).toEqual([
[9, 'release', 'ok'],
[10, 'release verification', 'failed', 'ERR_ROUND_MISMATCH'],
[11, 'open sealed control', 'not-run'],
]);
const bad = new Uint8Array(48);
bad[0] = 0xc0;
const invalid = await open(f.dkc, { ...f.options, source: suppliedRelease({ round: 1000, signature: bad }) });
expect(buildOpenReport(invalid).failure).toMatchObject({ step: 10, code: 'ERR_RELEASE_INVALID' });
const early = await open(f.dkc, { ...f.options, now: () => ({ seconds: 0, nanos: 0 }) });
const r = buildOpenReport(early);
expect(rows(r)[0]).toEqual([9, 'release', 'failed', 'ERR_RELEASE_UNAVAILABLE']);
expect(r.steps).toHaveLength(OPEN_STEPS_TIME_ONLY.length);
});
it('follows a failure of step 17 with step 18', async () => {
const f = fixture('time_only');
// The last byte of the last STREAM chunk: authentication fails at step 17.
const dkc = f.dkc.slice();
dkc[dkc.length - 1]! ^= 1;
const r = buildOpenReport(await open(dkc, f.options));
expect(r.failure).toMatchObject({ step: 17, code: 'ERR_INTEGRITY' });
expect(rows(r).slice(-3)).toEqual([
[16, 'payload identity', 'ok'],
[17, 'open payload', 'failed', 'ERR_INTEGRITY'],
[18, 'commit', 'not-run'],
]);
expect(r.steps.at(-2)!.gloss).toBe(openStepGloss('open payload'));
// The release was verified before the failure.
expect(r.release?.round).toBe(1000);
expect(toHex(fromHex(r.release!.signature))).toBe(f.record.release.signature);
});
it('has no steps when open stopped before step 9', async () => {
const f = fixture('time_only');
const dkc = f.dkc.slice();
dkc[0] = 0x58;
const r = buildOpenReport(await open(dkc, f.options));
expect(r.steps).toEqual([]);
expect(r.failure).toMatchObject({ step: 1, code: 'ERR_INVALID_MAGIC' });
});
});
describe('plaintextFileName', () => {
it('drops .dkc, as age drops .age', () => {
expect(plaintextFileName('informe.pdf.dkc')).toBe('informe.pdf');
expect(plaintextFileName('carta.DKC')).toBe('carta');
expect(plaintextFileName('capsule')).toBe('capsule.descifrado');
expect(plaintextFileName('.dkc')).toBe('.dkc.descifrado');
expect(plaintextFileName('capsule.dkk')).toBe('capsule.dkk.descifrado');
// A right-to-left override cannot disguise the extension of what is saved.
expect(plaintextFileName('factura\u202efdp.exe.dkc')).toBe('factura_fdp.exe');
expect(plaintextFileName('a\u200bb\u0000.dkc')).toBe('a_b_');
});
});

@ -0,0 +1,92 @@
// The page model of one opening: steps 9 to 18 of spec §63 as open records
// them, the verified release, the extensions of CONTROL_CBOR and the facts of
// the plaintext, with no DOM and no clock. open.ts is imported for its types
// only, so this module stays out of the chunk that carries noble.
import type { Opened } from '../dkc/open.ts';
import { type ErrorCode, TIME_AND_KEY, toHex } from '../dkc/index.ts';
import { OPEN_STEPS_TIME_AND_KEY, OPEN_STEPS_TIME_ONLY, openStepGloss, safeFileName } from './format.ts';
import { type ExtensionRow, extensionRow, type StepRow } from './report.ts';
/** What the page knows of the plaintext of a capsule that opened. */
export interface PlaintextFacts {
readonly length: number;
/** SHA-256 of the plaintext, in hexadecimal. */
readonly sha256: string;
/** The SHA-256 that the record of an official fixture states, if any. */
readonly expectedSHA256?: string;
/** The plaintext as text, when the page shows it and it is printable. */
readonly text?: string;
}
export interface OpenReport {
/** The capsule opened: every step up to 18 passed. */
readonly opened: boolean;
readonly failure?: { readonly step: number; readonly code: ErrorCode; readonly message: string };
/**
* The checks of steps 9 to 18 as open recorded them, followed by the ones
* of the policy that did not run; empty when open stopped before step 9.
*/
readonly steps: readonly StepRow[];
/** The release, once verified at step 10. */
readonly release?: { readonly round: number; readonly signature: string };
/** The extensions of CONTROL_CBOR, once it is decoded (step 14). */
readonly controlExtensions?: readonly ExtensionRow[];
readonly plaintext?: PlaintextFacts;
}
export function buildOpenReport(o: Opened, plaintext?: PlaintextFacts): OpenReport {
const checks = o.inspection.checks.filter((c) => c.step >= 9);
const out: { -readonly [K in keyof OpenReport]: OpenReport[K] } = {
opened: o.error === undefined,
steps: checks.length === 0 ? [] : openSteps(o, checks),
};
if (o.error !== undefined) {
const last = o.inspection.checks.at(-1)!;
out.failure = { step: last.step, code: o.error.code, message: o.error.message };
}
if (o.release !== undefined) out.release = { round: o.release.round, signature: toHex(o.release.signature) };
if (checks.some((c) => c.step === 14 && c.ok)) {
out.controlExtensions = [
...o.controlCritical.map((e) => extensionRow(e, true, o.unusableControlExtensions, undefined)),
...o.controlNoncritical.map((e) => extensionRow(e, false, o.unusableControlExtensions, undefined)),
];
}
if (plaintext !== undefined && out.opened) out.plaintext = plaintext;
return out;
}
/**
* The name offered for the plaintext of the capsule `name`: without its
* .dkc, as age names the output of report.pdf.age report.pdf, or with
* ".descifrado" appended when nothing would be left; with every character
* that is not printable replaced (safeFileName).
*/
export function plaintextFileName(name: string): string {
const stem = /\.dkc$/i.test(name) ? name.slice(0, -'.dkc'.length) : '';
return safeFileName(stem === '' ? `${name}.descifrado` : stem);
}
type Check = Opened['inspection']['checks'][number];
// The recorded checks, then the ones of the policy that come after the last
// of them and did not run.
function openSteps(o: Opened, checks: readonly Check[]): StepRow[] {
// open records a detail with every check of steps 9 to 18, and only its
// last check fails, with the error of the opening.
const rows = checks.map(
(c): StepRow =>
c.ok
? { step: c.step, name: c.name, gloss: openStepGloss(c.name), state: 'ok', detail: c.detail! }
: { step: c.step, name: c.name, gloss: openStepGloss(c.name), state: 'failed', detail: c.detail!, code: o.error!.code },
);
const expected = o.inspection.header?.policy === TIME_AND_KEY ? OPEN_STEPS_TIME_AND_KEY : OPEN_STEPS_TIME_ONLY;
const last = checks.at(-1)!;
const at = expected.findIndex(([step, name]) => step === last.step && name === last.name);
// A check that is not in the list, the failure of step 17, is followed by
// the steps after its number.
for (const [i, [step, name]] of expected.entries()) {
if (at >= 0 ? i > at : step > last.step) rows.push({ step, name, gloss: openStepGloss(name), state: 'not-run' });
}
return rows;
}

@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest';
import { toHex } from '../dkc/index.ts';
import { readJSON } from '../dkc/testing/testdata.ts';
import { drandReleaseURL, MAX_RELEASE_TEXT, parseReleaseText, releaseText } from './release-input.ts';
// What drand's HTTP API answers for round 1000 of Quicknet, fetched on
// 28-09-2026: the release of the time_only fixture.
const DRAND_1000 =
'{"round":1000,"randomness":"fe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd","signature":"b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39"}';
const SIG_1000 =
'b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39';
describe('parseReleaseText', () => {
it("reads drand's answer, and only its round and signature", () => {
const r = parseReleaseText(`\n ${DRAND_1000}\n`, 1000);
expect(r.ok && r.form).toBe('json');
expect(r.ok && r.release.round).toBe(1000);
expect(r.ok && toHex(r.release.signature)).toBe(SIG_1000);
// The same signature as the record of the fixture.
const record = readJSON<{ release: { signature: string } }>('fixtures/time_only.json');
expect(record.release.signature).toBe(SIG_1000);
});
it('ignores every other field, a public key included (spec §11, §13)', () => {
const r = parseReleaseText(JSON.stringify({ public_key: 'ff'.repeat(96), round: 5, signature: 'AB', period: 3 }), 1000);
expect(r).toEqual({ ok: true, release: { round: 5, signature: new Uint8Array([0xab]) }, form: 'json' });
});
it('keeps a round other than the capsule one, for step 10 to reject', () => {
const r = parseReleaseText(DRAND_1000, 1001);
expect(r.ok && r.release.round).toBe(1000);
});
it('takes a signature alone as the release of the round of the capsule', () => {
expect(parseReleaseText(` ${SIG_1000.toUpperCase()} `, 1000)).toEqual({
ok: true,
release: { round: 1000, signature: Uint8Array.from(Buffer.from(SIG_1000, 'hex')) },
form: 'hex',
});
// Any length: step 10 checks it.
expect(parseReleaseText('00ff', 7)).toEqual({ ok: true, release: { round: 7, signature: new Uint8Array([0, 0xff]) }, form: 'hex' });
});
it('explains what cannot be a release', () => {
const problem = (s: string): string => {
const r = parseReleaseText(s, 1000);
if (r.ok) throw new Error(`accepted ${s}`);
return r.problem;
};
expect(problem('')).toMatch(/^Pega/);
expect(problem(' \n')).toMatch(/^Pega/);
expect(problem('x'.repeat(MAX_RELEASE_TEXT + 1))).toContain(`${MAX_RELEASE_TEXT + 1} caracteres`);
expect(problem('abc')).toMatch(/hexadecimal/);
expect(problem('0x' + SIG_1000)).toMatch(/hexadecimal/);
expect(problem('ab cd')).toMatch(/hexadecimal/);
expect(problem('{"round": 1000,')).toMatch(/no es JSON válido/);
expect(problem('{} extra')).toMatch(/no es JSON válido/);
expect(problem('{"round": 1000}')).toMatch(/signature/);
expect(problem('{"signature": "ab"}')).toMatch(/round/);
expect(problem('{"round": "1000", "signature": "ab"}')).toMatch(/round/);
expect(problem('{"round": 1.5, "signature": "ab"}')).toMatch(/round/);
expect(problem('{"round": -1, "signature": "ab"}')).toMatch(/round/);
expect(problem('{"round": 9007199254740993, "signature": "ab"}')).toMatch(/round/);
expect(problem('{"round": 1000, "signature": ""}')).toMatch(/signature/);
expect(problem('{"round": 1000, "signature": "abc"}')).toMatch(/signature/);
expect(problem('{"round": 1000, "signature": "zz"}')).toMatch(/signature/);
expect(problem('{"round": 1000, "signature": 12}')).toMatch(/signature/);
// An inherited property is not a field.
expect(problem('{"__proto__": {"round": 1000, "signature": "ab"}}')).toMatch(/round/);
});
it('reads as JSON only an object', () => {
// JSON that is not an object is not hexadecimal either.
for (const s of ['[1000]', 'null', '"b44679"', 'true']) {
expect(parseReleaseText(s, 1000), s).toMatchObject({ ok: false, problem: expect.stringMatching(/hexadecimal/) });
}
});
});
describe('drandReleaseURL and releaseText', () => {
it("links to drand's API for the round, on the pinned chain", () => {
const chain = '52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971';
expect(drandReleaseURL(chain, 1000)).toBe(`https://api.drand.sh/${chain}/public/1000`);
});
it('writes a release as drand writes its round and signature', () => {
expect(releaseText(1000, SIG_1000)).toBe(`{"round":1000,"signature":"${SIG_1000}"}`);
const r = parseReleaseText(releaseText(1000, SIG_1000), 1000);
expect(r.ok && toHex(r.release.signature)).toBe(SIG_1000);
});
});

@ -0,0 +1,86 @@
// The release that the person opening a capsule supplies directly (spec §63
// step 10): pasted from drand's HTTP API, or taken from the record of an
// official fixture. The page never fetches it: it links to the drand URL of
// the round, which the person opens themselves, and verifies what comes back
// at step 10 like any release the caller supplies (plan of phase 2,
// decision 4, confirmed by the author on 28-09-2026).
//
// Of what is pasted only the round and the signature are read. drand's
// answer also carries `randomness`, and other drand endpoints carry a public
// key, a period or a chain hash: none of them is read, because the root of
// trust is the pinned profile and never a remote input (spec §11, §13).
//
// No noble here: this module is part of the page's initial bundle.
import { fromHex } from '../dkc/index.ts';
/** A release as the caller supplies it, before any verification. */
export interface SuppliedRelease {
readonly round: number;
readonly signature: Uint8Array;
}
/** The longest text read as a release: drand's answer is about 230 characters. */
export const MAX_RELEASE_TEXT = 4096;
/** What the person pasted, read as a release, or why it cannot be. */
export type ReleaseInput =
| { readonly ok: true; readonly release: SuppliedRelease; readonly form: 'json' | 'hex' }
| { readonly ok: false; readonly problem: string };
/**
* Reads the text pasted as the release of `round`: drand's JSON answer,
* `{"round": …, "signature": "…"}`, or a signature alone in hexadecimal,
* which is then taken as the release of `round`. Nothing is verified here:
* a round other than `round` or a signature that is not a valid one goes to
* step 10, which reports it with its normative code.
*/
export function parseReleaseText(text: string, round: number): ReleaseInput {
const s = text.trim();
if (s === '') return { ok: false, problem: 'Pega la respuesta de drand o la firma de la ronda.' };
if (s.length > MAX_RELEASE_TEXT) {
return { ok: false, problem: `El texto pegado tiene ${s.length} caracteres; la respuesta de drand tiene unos 230.` };
}
if (s.startsWith('{')) return fromJSON(s);
if (!/^[0-9a-fA-F]+$/.test(s) || s.length % 2 !== 0) {
return {
ok: false,
problem: 'No es la respuesta de drand (un objeto JSON) ni una firma en hexadecimal (un número par de cifras 0-9 y a-f).',
};
}
return { ok: true, release: { round, signature: fromHex(s) }, form: 'hex' };
}
function fromJSON(s: string): ReleaseInput {
// JSON text that starts with { and parses is an object.
let v: object;
try {
v = JSON.parse(s) as object;
} catch {
return { ok: false, problem: 'Empieza por { pero no es JSON válido. Copia la respuesta de drand entera.' };
}
const round: unknown = Object.hasOwn(v, 'round') ? (v as { round: unknown }).round : undefined;
const signature: unknown = Object.hasOwn(v, 'signature') ? (v as { signature: unknown }).signature : undefined;
if (typeof round !== 'number' || !Number.isSafeInteger(round) || round < 0) {
return { ok: false, problem: 'El campo round falta o no es un número entero de ronda.' };
}
if (typeof signature !== 'string' || !/^[0-9a-fA-F]*$/.test(signature) || signature.length % 2 !== 0 || signature === '') {
return { ok: false, problem: 'El campo signature falta o no es hexadecimal.' };
}
return { ok: true, release: { round, signature: fromHex(signature) }, form: 'json' };
}
/**
* The drand HTTP API URL of the release of `round` on the network of the
* chain hash `chainHash` (lowercase hexadecimal), for the person to open:
* https://api.drand.sh/<chain hash>/public/<round>. The page links to it and
* never fetches it.
*/
export function drandReleaseURL(chainHash: string, round: number): string {
return `https://api.drand.sh/${chainHash}/public/${round}`;
}
/** The JSON text of a release, as drand writes its round and signature. */
export function releaseText(round: number, signatureHex: string): string {
return JSON.stringify({ round, signature: signatureHex });
}

@ -18,6 +18,7 @@ import {
STANZA_TLOCK, STANZA_TLOCK,
type StanzaInfo, type StanzaInfo,
toHex, toHex,
type Unusable,
} from '../dkc/index.ts'; } from '../dkc/index.ts';
import { cborView, type Diagnostic } from './diagnostic.ts'; import { cborView, type Diagnostic } from './diagnostic.ts';
import { cliJSON, displayText, instantToEpochMs, INSPECT_STEPS, printableText, stepGloss, stepName } from './format.ts'; import { cliJSON, displayText, instantToEpochMs, INSPECT_STEPS, printableText, stepGloss, stepName } from './format.ts';
@ -190,8 +191,8 @@ export function buildReport(input: ReportInput): Report {
round: h.dateKey.round, round: h.dateKey.round,
}; };
out.extensions = [ out.extensions = [
...h.critical.map((e) => extensionRow(e, true, r, input.extensions)), ...h.critical.map((e) => extensionRow(e, true, r.unusableExtensions, input.extensions)),
...h.noncritical.map((e) => extensionRow(e, false, r, input.extensions)), ...h.noncritical.map((e) => extensionRow(e, false, r.unusableExtensions, input.extensions)),
]; ];
} }
@ -244,7 +245,11 @@ function steps(r: Inspection): StepRow[] {
}); });
} }
function extensionRow(e: Extension, critical: boolean, r: Inspection, reg: ExtensionRegistry | undefined): ExtensionRow { /**
* The row of one extension of PUBLIC_HEADER, CONTROL_CBOR or a .dkk, given
* the known noncritical extensions of that object that the registry rejects.
*/
export function extensionRow(e: Extension, critical: boolean, unusable: readonly Unusable[], reg: ExtensionRegistry | undefined): ExtensionRow {
const id = displayText(e.id); const id = displayText(e.id);
const row: { -readonly [K in keyof ExtensionRow]: ExtensionRow[K] } = { const row: { -readonly [K in keyof ExtensionRow]: ExtensionRow[K] } = {
critical, critical,
@ -255,8 +260,8 @@ function extensionRow(e: Extension, critical: boolean, r: Inspection, reg: Exten
length: e.data?.length ?? 0, length: e.data?.length ?? 0,
hex: e.data === undefined ? '' : toHex(e.data), hex: e.data === undefined ? '' : toHex(e.data),
}; };
const unusable = critical ? undefined : r.unusableExtensions.find((u) => u.id === e.id && u.version === e.version); const u = critical ? undefined : unusable.find((x) => x.id === e.id && x.version === e.version);
if (unusable !== undefined) row.unusable = unusable.error.message; if (u !== undefined) row.unusable = u.error.message;
if (e.data !== undefined) { if (e.data !== undefined) {
const text = printableText(e.data); const text = printableText(e.data);
if (text !== undefined) row.text = text; if (text !== undefined) row.text = text;

@ -0,0 +1,308 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
browserPlatform,
cancellable,
createTempFile,
freeSpace,
removeStaleTempFiles,
STALE_MS,
TEMP_FILE,
TEMP_ROOT,
type TempDirectory,
type TempFileHandle,
type TempLocks,
type TempPlatform,
} from './tempfile.ts';
afterEach(() => {
vi.unstubAllGlobals();
});
const notFound = (): DOMException => new DOMException('not found', 'NotFoundError');
// An OPFS in memory. A writable keeps its writes apart until close, and drops
// them on abort, as createWritable's swap file does.
class FakeFile implements TempFileHandle {
content = new Uint8Array(0);
failWritable = false;
lastModified: number;
constructor(lastModified: number) {
this.lastModified = lastModified;
}
async createWritable(): Promise<WritableStream<Uint8Array>> {
if (this.failWritable) throw new Error('no writable');
const chunks: Uint8Array[] = [];
return new WritableStream<Uint8Array>({
write: (c) => void chunks.push(c.slice()),
close: () => {
this.content = new Uint8Array(chunks.flatMap((c) => [...c]));
},
abort: () => void (chunks.length = 0),
});
}
async getFile(): Promise<File> {
return new File([this.content as Uint8Array<ArrayBuffer>], TEMP_FILE, { lastModified: this.lastModified });
}
}
class FakeDir implements TempDirectory {
readonly entries = new Map<string, FakeDir | FakeFile>();
failRemove = false;
private readonly time: () => number;
constructor(time: () => number) {
this.time = time;
}
async getDirectoryHandle(name: string, options?: { create?: boolean }): Promise<FakeDir> {
let e = this.entries.get(name);
if (e === undefined) {
if (options?.create !== true) throw notFound();
e = new FakeDir(this.time);
this.entries.set(name, e);
}
if (!(e instanceof FakeDir)) throw new DOMException('a file', 'TypeMismatchError');
return e;
}
async getFileHandle(name: string, options?: { create?: boolean }): Promise<FakeFile> {
let e = this.entries.get(name);
if (e === undefined) {
if (options?.create !== true) throw notFound();
e = new FakeFile(this.time());
this.entries.set(name, e);
}
if (!(e instanceof FakeFile)) throw new DOMException('a directory', 'TypeMismatchError');
return e;
}
async removeEntry(name: string, options?: { recursive?: boolean }): Promise<void> {
const e = this.entries.get(name);
if (this.failRemove) throw new DOMException('busy', 'NoModificationAllowedError');
if (e === undefined) throw notFound();
if (e instanceof FakeDir && e.entries.size > 0 && options?.recursive !== true) throw new DOMException('not empty', 'InvalidModificationError');
this.entries.delete(name);
}
async *keys(): AsyncIterable<string> {
yield* [...this.entries.keys()];
}
}
// Web Locks in memory: a held lock makes ifAvailable requests get null.
class FakeLocks implements TempLocks {
readonly held = new Set<string>();
async request(name: string, options: { ifAvailable?: boolean }, callback: (lock: object | null) => Promise<unknown>): Promise<unknown> {
if (this.held.has(name)) {
if (options.ifAvailable === true) return callback(null);
throw new Error(`the test would wait for ${name}`);
}
this.held.add(name);
try {
return await callback({});
} finally {
this.held.delete(name);
}
}
}
function platform(opts: { locks?: boolean; now?: number } = {}): { pf: TempPlatform; root: FakeDir; locks: FakeLocks } {
let clock = opts.now ?? 1_000_000_000_000;
let n = 0;
const root = new FakeDir(() => clock);
const locks = new FakeLocks();
const pf: TempPlatform = {
getDirectory: async () => root,
estimate: async () => ({ quota: 100, usage: 40 }),
locks: opts.locks === false ? undefined : locks,
now: () => clock,
randomId: () => `tab-${++n}`,
};
return { pf, root, locks };
}
const te = new TextEncoder();
async function write(w: WritableStream<Uint8Array>, ...chunks: string[]): Promise<void> {
const writer = w.getWriter();
for (const c of chunks) await writer.write(te.encode(c));
await writer.close();
}
describe('createTempFile', () => {
it('writes into a directory of its own, under a lock of the same name, and commits on close', async () => {
const { pf, root, locks } = platform();
const t = await createTempFile(pf);
const dir = root.entries.get(TEMP_ROOT) as FakeDir;
expect([...dir.entries.keys()]).toEqual(['tab-1']);
expect(locks.held).toEqual(new Set([`${TEMP_ROOT}/tab-1`]));
await write(t.writable, 'hello, ', 'world');
expect(await (await t.file()).text()).toBe('hello, world');
});
it('keeps nothing of an aborted output', async () => {
const { pf } = platform();
const t = await createTempFile(pf);
const w = t.writable.getWriter();
await w.write(te.encode('partial'));
await w.abort(new Error('STREAM failed'));
expect((await t.file()).size).toBe(0);
});
it('deletes the directory and releases the lock once, and never rejects', async () => {
const { pf, root, locks } = platform();
const t = await createTempFile(pf);
await write(t.writable, 'x');
await t.remove();
await t.remove();
expect((root.entries.get(TEMP_ROOT) as FakeDir).entries.size).toBe(0);
expect(locks.held.size).toBe(0);
const u = await createTempFile(pf);
(root.entries.get(TEMP_ROOT) as FakeDir).failRemove = true;
await expect(u.remove()).resolves.toBeUndefined();
expect(locks.held.size).toBe(0);
});
it('cleans up and releases the lock when the file cannot be opened for writing', async () => {
const { pf, root, locks } = platform();
const dir = await root.getDirectoryHandle(TEMP_ROOT, { create: true });
const sub = await dir.getDirectoryHandle('tab-1', { create: true });
(await sub.getFileHandle(TEMP_FILE, { create: true })).failWritable = true;
await expect(createTempFile(pf)).rejects.toThrow('no writable');
expect(dir.entries.size).toBe(0);
expect(locks.held.size).toBe(0);
// A clean-up that fails too does not hide the error, nor keep the lock.
const other = platform();
const odir = await other.root.getDirectoryHandle(TEMP_ROOT, { create: true });
(await (await odir.getDirectoryHandle('tab-1', { create: true })).getFileHandle(TEMP_FILE, { create: true })).failWritable = true;
odir.failRemove = true;
await expect(createTempFile(other.pf)).rejects.toThrow('no writable');
expect(other.locks.held.size).toBe(0);
// Failing before the root exists leaves nothing either.
const broken: TempPlatform = { ...pf, getDirectory: () => Promise.reject(new Error('no OPFS')) };
await expect(createTempFile(broken)).rejects.toThrow('no OPFS');
expect(locks.held.size).toBe(0);
});
it('works without Web Locks', async () => {
const { pf } = platform({ locks: false });
const t = await createTempFile(pf);
await write(t.writable, 'x');
expect((await t.file()).size).toBe(1);
await t.remove();
});
});
describe('cancellable', () => {
it('writes, closes and aborts through, until cancelled', async () => {
const { pf } = platform();
const t = await createTempFile(pf);
await write(cancellable(t.writable, () => false), 'all ', 'of it');
expect(await (await t.file()).text()).toBe('all of it');
const u = await createTempFile(pf);
const w = cancellable(u.writable, () => false).getWriter();
await w.write(te.encode('partial'));
await w.abort(new Error('STREAM failed'));
expect((await u.file()).size).toBe(0);
});
it('once cancelled, discards what was written and fails the next write', async () => {
const { pf } = platform();
const t = await createTempFile(pf);
let cancelled = false;
const w = cancellable(t.writable, () => cancelled).getWriter();
await w.write(te.encode('first chunk'));
cancelled = true;
await expect(w.write(te.encode('second chunk'))).rejects.toThrow('opening cancelled');
// The inner output was aborted, so nothing is ever committed.
expect((await t.file()).size).toBe(0);
});
});
describe('removeStaleTempFiles', () => {
it('has nothing to do without the directory, and does not create it', async () => {
const { pf, root } = platform();
expect(await removeStaleTempFiles(pf)).toBe(0);
expect(root.entries.size).toBe(0);
});
it('deletes the directories that no tab holds, never one in use', async () => {
const { pf, root } = platform();
const mine = await createTempFile(pf);
// Left by a tab that ended: a directory and a stray file, no lock.
const dir = root.entries.get(TEMP_ROOT) as FakeDir;
await (await dir.getDirectoryHandle('old-tab', { create: true })).getFileHandle(TEMP_FILE, { create: true });
await dir.getFileHandle('stray', { create: true });
expect(await removeStaleTempFiles(pf)).toBe(2);
expect([...dir.entries.keys()]).toEqual(['tab-1']);
await write(mine.writable, 'still here');
expect(await (await mine.file()).text()).toBe('still here');
});
it('counts only what it could delete', async () => {
const { pf, root } = platform();
const dir = await root.getDirectoryHandle(TEMP_ROOT, { create: true });
await dir.getDirectoryHandle('old-tab', { create: true });
dir.failRemove = true;
expect(await removeStaleTempFiles(pf)).toBe(0);
});
it('without Web Locks, deletes only what is a day old, or is not a directory of a tab', async () => {
const start = 1_000_000_000_000;
const { pf, root } = platform({ locks: false, now: start });
const dir = await root.getDirectoryHandle(TEMP_ROOT, { create: true });
const make = async (name: string, at: number): Promise<void> => {
const f = await (await dir.getDirectoryHandle(name, { create: true })).getFileHandle(TEMP_FILE, { create: true });
f.lastModified = at;
};
await make('fresh', start - STALE_MS + 1);
await make('stale', start - STALE_MS);
await dir.getDirectoryHandle('empty', { create: true });
await dir.getFileHandle('stray', { create: true });
expect(await removeStaleTempFiles(pf)).toBe(3);
expect([...dir.entries.keys()]).toEqual(['fresh']);
});
});
describe('freeSpace', () => {
it('is the quota less the usage, when the browser gives a quota', async () => {
const { pf } = platform();
expect(await freeSpace(pf)).toBe(60);
expect(await freeSpace({ ...pf, estimate: async () => ({ quota: 10 }) })).toBe(10);
expect(await freeSpace({ ...pf, estimate: async () => ({ quota: 10, usage: 12 }) })).toBe(0);
expect(await freeSpace({ ...pf, estimate: async () => ({ usage: 12 }) })).toBeUndefined();
});
});
describe('browserPlatform', () => {
it('is undefined without the OPFS or without createWritable', () => {
vi.stubGlobal('navigator', {});
expect(browserPlatform()).toBeUndefined();
vi.stubGlobal('navigator', { storage: { getDirectory: () => undefined, estimate: () => undefined } });
vi.stubGlobal('FileSystemFileHandle', undefined);
expect(browserPlatform()).toBeUndefined();
vi.stubGlobal('FileSystemFileHandle', class {});
expect(browserPlatform()).toBeUndefined();
});
it("wraps the browser's storage, locks, clock and random ids", async () => {
const root = new FakeDir(() => 0);
const locks = new FakeLocks();
vi.stubGlobal('navigator', {
storage: { getDirectory: async () => root, estimate: async () => ({ quota: 5, usage: 1 }) },
locks,
});
vi.stubGlobal(
'FileSystemFileHandle',
class {
createWritable(): void {}
},
);
const pf = browserPlatform()!;
expect(await pf.getDirectory()).toBe(root);
expect(await pf.estimate()).toEqual({ quota: 5, usage: 1 });
expect(pf.locks).toBe(locks);
expect(Math.abs(pf.now() - Date.now())).toBeLessThan(1000);
expect(pf.randomId()).toMatch(/^[0-9a-f-]{36}$/);
// No Web Locks API.
vi.stubGlobal('navigator', { storage: { getDirectory: async () => root, estimate: async () => ({}) } });
expect(browserPlatform()!.locks).toBeUndefined();
});
});

@ -0,0 +1,205 @@
// The private temporary file of an opening (spec §56; plan of phase 2,
// decision 7). PAYLOAD_AGE is decrypted into a file of the origin private
// file system (OPFS) through FileSystemFileHandle.createWritable, which keeps
// the writes in a swap file until close() and discards them on abort(): open
// closes it only after step 18, so nothing is committed before the whole
// payload is authenticated. The page offers the file for download once the
// capsule opened, and deletes it when the person asks, when another capsule
// is loaded or opened, when the page is left and, if the browser ended
// first, on the next visit.
//
// Each tab writes into its own directory, datekeys-open/<random id>, and
// holds a Web Lock of that name while the directory exists, so that the
// clean-up of another tab never deletes a file in use, nor its swap file.
// Without the Web Locks API a directory is deleted only once it is a day old.
/** The directory of the temporary files, in the root of the OPFS. */
export const TEMP_ROOT = 'datekeys-open';
/** The name of the file inside the directory of a tab. */
export const TEMP_FILE = 'plaintext';
/** Without Web Locks, a directory older than this is left over. */
export const STALE_MS = 24 * 3600_000;
/** The part of FileSystemDirectoryHandle that this module uses. */
export interface TempDirectory {
getDirectoryHandle(name: string, options?: { create?: boolean }): Promise<TempDirectory>;
getFileHandle(name: string, options?: { create?: boolean }): Promise<TempFileHandle>;
removeEntry(name: string, options?: { recursive?: boolean }): Promise<void>;
keys(): AsyncIterable<string>;
}
/** The part of FileSystemFileHandle that this module uses. */
export interface TempFileHandle {
createWritable(): Promise<WritableStream<Uint8Array>>;
getFile(): Promise<File>;
}
/** The part of the Web Locks API that this module uses. */
export interface TempLocks {
request(name: string, options: { ifAvailable?: boolean }, callback: (lock: object | null) => Promise<unknown>): Promise<unknown>;
}
/** What the browser provides; tests pass their own. */
export interface TempPlatform {
getDirectory(): Promise<TempDirectory>;
estimate(): Promise<{ quota?: number; usage?: number }>;
readonly locks: TempLocks | undefined;
now(): number;
randomId(): string;
}
/**
* The OPFS of this browser, or undefined when it cannot write a temporary
* file: no navigator.storage.getDirectory, or no createWritable, which not
* every browser offers outside workers.
*/
export function browserPlatform(): TempPlatform | undefined {
const storage = globalThis.navigator?.storage;
if (typeof storage?.getDirectory !== 'function' || typeof storage.estimate !== 'function') return undefined;
if (typeof globalThis.FileSystemFileHandle?.prototype.createWritable !== 'function') return undefined;
const locks = globalThis.navigator.locks as TempLocks | undefined;
return {
getDirectory: () => storage.getDirectory() as unknown as Promise<TempDirectory>,
estimate: () => storage.estimate(),
locks: typeof locks?.request === 'function' ? locks : undefined,
now: () => Date.now(),
randomId: () => crypto.randomUUID(),
};
}
/** The bytes the origin may still store, or undefined when the browser does not say. */
export async function freeSpace(pf: TempPlatform): Promise<number | undefined> {
const { quota, usage } = await pf.estimate();
return quota === undefined ? undefined : Math.max(0, quota - (usage ?? 0));
}
/** A temporary file of this tab, open for writing. */
export interface TempFile {
/** The output of open: closed after step 18, aborted on any failure. */
readonly writable: WritableStream<Uint8Array>;
/** The committed content, once open has closed the output. */
file(): Promise<File>;
/** Deletes the file and its directory and releases the lock; never rejects. */
remove(): Promise<void>;
}
/**
* An output that writes to `w` until `cancelled()` is true. From then on it
* aborts `w`, which discards what was written, and fails the write, so that
* open stops decrypting and reports the failure at step 17. The page cancels
* an opening this way when the person moves on to another capsule. Closing
* and aborting go to `w`.
*/
export function cancellable(w: WritableStream<Uint8Array>, cancelled: () => boolean): WritableStream<Uint8Array> {
const inner = w.getWriter();
return new WritableStream<Uint8Array>({
async write(chunk) {
if (cancelled()) {
const reason = new Error('opening cancelled');
await inner.abort(reason);
throw reason;
}
await inner.write(chunk);
},
close: () => inner.close(),
abort: (reason: unknown) => inner.abort(reason),
});
}
/** Creates the directory of this tab, with its lock, and the file open for writing. */
export async function createTempFile(pf: TempPlatform): Promise<TempFile> {
const id = pf.randomId();
const release = pf.locks === undefined ? () => undefined : await hold(pf.locks, `${TEMP_ROOT}/${id}`);
let root: TempDirectory | undefined;
let handle: TempFileHandle;
let writable: WritableStream<Uint8Array>;
try {
root = await (await pf.getDirectory()).getDirectoryHandle(TEMP_ROOT, { create: true });
const dir = await root.getDirectoryHandle(id, { create: true });
handle = await dir.getFileHandle(TEMP_FILE, { create: true });
writable = await handle.createWritable();
} catch (err) {
await root?.removeEntry(id, { recursive: true }).catch(() => undefined);
release();
throw err;
}
let removed = false;
return {
writable,
file: () => handle.getFile(),
async remove(): Promise<void> {
if (removed) return;
removed = true;
try {
await root.removeEntry(id, { recursive: true });
} catch {
// Already gone, or the browser refuses: the next visit retries.
} finally {
release();
}
},
};
}
/**
* Deletes the directories that no tab holds: left by a tab that ended
* before deleting its file. Returns how many it deleted.
*/
export async function removeStaleTempFiles(pf: TempPlatform): Promise<number> {
let root: TempDirectory;
try {
root = await (await pf.getDirectory()).getDirectoryHandle(TEMP_ROOT);
} catch {
// No such directory: nothing was ever left.
return 0;
}
const names: string[] = [];
for await (const name of root.keys()) names.push(name);
let removed = 0;
for (const name of names) {
// A tab takes the lock before it creates its directory and keeps it
// until it deletes it, so a free lock means the tab has ended.
const left = pf.locks === undefined ? await isStale(root, name, pf.now()) : await isFree(pf.locks, `${TEMP_ROOT}/${name}`);
removed += left && (await tryRemove(root, name)) ? 1 : 0;
}
return removed;
}
// Whether no tab holds the lock `name`.
async function isFree(locks: TempLocks, name: string): Promise<boolean> {
return (await locks.request(name, { ifAvailable: true }, async (lock) => lock !== null)) === true;
}
// Acquires the lock `name` and keeps it until the returned function is called.
function hold(locks: TempLocks, name: string): Promise<() => void> {
return new Promise((resolve, reject) => {
let release!: () => void;
const held = new Promise<void>((r) => (release = r));
locks
.request(name, {}, () => {
resolve(release);
return held;
})
.catch(reject);
});
}
// Whether the directory `name` is at least a day old, by its file. Anything
// that is not a directory of a tab is left over too.
async function isStale(root: TempDirectory, name: string, now: number): Promise<boolean> {
try {
const file = await (await (await root.getDirectoryHandle(name)).getFileHandle(TEMP_FILE)).getFile();
return now - file.lastModified >= STALE_MS;
} catch {
return true;
}
}
async function tryRemove(root: TempDirectory, name: string): Promise<boolean> {
try {
await root.removeEntry(name, { recursive: true });
return true;
} catch {
return false;
}
}

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { resolve } from '$app/paths'; import { asset, resolve } from '$app/paths';
import { page } from '$app/state'; import { page } from '$app/state';
import Mark from '$lib/components/Mark.svelte'; import Mark from '$lib/components/Mark.svelte';
import { SPEC_VERSION, VERSION } from '$lib/dkc/version.ts'; import { SPEC_VERSION, VERSION } from '$lib/dkc/version.ts';
@ -10,6 +10,8 @@
const home = resolve('/'); const home = resolve('/');
const inspector = resolve('/inspect'); const inspector = resolve('/inspect');
// The notices of the third-party code in the bundle, written by vite.config.ts.
const licenses = asset('/licenses.txt');
</script> </script>
<a class="skip-link" href="#main">Saltar al contenido</a> <a class="skip-link" href="#main">Saltar al contenido</a>
@ -34,7 +36,7 @@
<div class="wrap"> <div class="wrap">
<p> <p>
Protocolo DateKeys {SPEC_VERSION}, librería {VERSION}. La página se ejecuta entera en este navegador y su política de Protocolo DateKeys {SPEC_VERSION}, librería {VERSION}. La página se ejecuta entera en este navegador y su política de
seguridad no le permite conectarse a ningún otro sitio. seguridad no le permite conectarse a ningún otro sitio. <a href={licenses} data-sveltekit-reload>Licencias del código de terceros</a>.
</p> </p>
</div> </div>
</footer> </footer>

@ -4,21 +4,22 @@
</script> </script>
<svelte:head> <svelte:head>
<title>DateKeys: comprueba una cápsula sin abrirla</title> <title>DateKeys: comprueba y abre una cápsula en el navegador</title>
<meta <meta
name="description" 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." content="Comprueba en el navegador, sin red, la parte pública de una cápsula DateKeys (.dkc) y, pasada su fecha, ábrela con la firma que publica drand."
/> />
</svelte:head> </svelte:head>
<div class="wrap hero"> <div class="wrap hero">
<div class="pitch"> <div class="pitch">
<h1>Mira una cápsula DateKeys sin abrirla</h1> <h1>Comprueba una cápsula DateKeys y ábrela en su fecha</h1>
<p class="lead"> <p class="lead">
Una cápsula <code>.dkc</code> es un fichero cifrado que no se puede abrir antes de una fecha: la clave depende de Una cápsula <code>.dkc</code> es un fichero cifrado que no se 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 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; la garantía se apoya en que esa red no revele la firma antes de tiempo. El inspector lee la parte pública de la cápsula y comprueba que está bien formada, con los mismos pocos segundos; la garantía se apoya en que esa red no revele la firma antes de tiempo. 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>. pasos que la herramienta <code>datekeys inspect</code>. Pasada la fecha, la abre con esa firma, que pegas tú, y con
tu clave si la cápsula la pide.
</p> </p>
<p class="actions"> <p class="actions">
<a class="button" href={resolve('/inspect')}>Abrir el inspector</a> <a class="button" href={resolve('/inspect')}>Abrir el inspector</a>
@ -47,13 +48,18 @@
<li> <li>
<h3>El fichero no sale del navegador</h3> <h3>El fichero no sale del navegador</h3>
<p> <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 lee y se abre en local. La política de seguridad de la página (CSP) solo permite conexiones a su propio origen,
se usan para descargar las cápsulas de prueba que vienen con el sitio. y solo se usan para descargar las cápsulas de prueba que vienen con el sitio. La firma de la ronda tampoco se pide
a la red: la copias tú de drand.
</p> </p>
</li> </li>
<li> <li>
<h3>Sin secretos</h3> <h3>Tus claves y tu texto, solo aquí</h3>
<p>No pide ni acepta claves de acceso <code>.dkk</code>, identidades ni contraseñas. Solo lee lo que es público.</p> <p>
Solo pide una clave para abrir una cápsula que la exige: una <code>.dkk</code> o una identidad de age, que se usan
en este navegador y nunca se envían. El texto descifrado de tu fichero va a un fichero temporal privado del
navegador hasta que lo descargas, y se borra después.
</p>
</li> </li>
<li> <li>
<h3>El veredicto de la referencia</h3> <h3>El veredicto de la referencia</h3>
@ -63,11 +69,10 @@
</p> </p>
</li> </li>
<li> <li>
<h3>Lo que queda para la apertura</h3> <h3>Lo que no prueba</h3>
<p> <p>
Superar los pasos 1 a 8 no prueba que la cápsula se pueda abrir. La cabecera pública solo queda vinculada al resto Superar los pasos 1 a 8 no prueba que la cápsula se pueda abrir. Al abrirla, la cabecera pública queda vinculada al
de la cápsula en el paso 15, al abrirla después de la fecha, y ni siquiera entonces prueba quién la escribió ni resto en el paso 15 y age autentica el contenido, pero nada de eso prueba quién la escribió ni cuándo (§55.1).
cuándo (§55.1).
</p> </p>
</li> </li>
</ul> </ul>

@ -1,12 +1,21 @@
<script lang="ts"> <script lang="ts">
import { tick } from 'svelte'; import { onMount, tick } from 'svelte';
import { type CapsuleBytes, inspect, readCapsule } from '$lib/dkc/index.ts'; import { type CapsuleBytes, inspect, readCapsule } from '$lib/dkc/index.ts';
import { FIXTURES, type Fixture, fetchFixture } from '$lib/inspector/fixtures.ts'; import { FIXTURES, type Fixture, fetchFixture } from '$lib/inspector/fixtures.ts';
import { buildReport, type Report } from '$lib/inspector/report.ts'; import { buildReport, type Report } from '$lib/inspector/report.ts';
import { displayText, escapeInvisible, viewerTimeZone } from '$lib/inspector/format.ts'; import { displayText, escapeInvisible, viewerTimeZone } from '$lib/inspector/format.ts';
import { browserPlatform, removeStaleTempFiles } from '$lib/inspector/tempfile.ts';
import InspectionReport from '$lib/components/InspectionReport.svelte'; import InspectionReport from '$lib/components/InspectionReport.svelte';
import OpenPanel from '$lib/components/OpenPanel.svelte';
/** What was inspected, for the opening: the fixture bytes or the person's file. */
interface Capsule {
readonly data: Uint8Array | Blob;
readonly fixture: Fixture | undefined;
}
let report: Report | undefined = $state(); let report: Report | undefined = $state();
let capsule: Capsule | undefined = $state.raw();
let problem: string | undefined = $state(); let problem: string | undefined = $state();
let notice: string | undefined = $state(); let notice: string | undefined = $state();
let busy = $state(false); let busy = $state(false);
@ -20,7 +29,36 @@
// dragenter and dragleave fire for every element crossed. // dragenter and dragleave fire for every element crossed.
let dragDepth = 0; let dragDepth = 0;
async function run(name: string, read: () => Promise<CapsuleBytes>, readNotice?: string): Promise<void> { // A capsule inspected before its date can be opened once the date comes:
// the clock of the report is refreshed then, and whenever the tab becomes
// visible again (timers stall in hidden tabs and while the device sleeps).
// setTimeout fires at once past 2^31 - 1 ms, so a far date re-arms in steps.
// open checks the live clock again at step 9.
$effect(() => {
const at = report?.unlock?.epochMs;
if (at === undefined || at <= nowMs) return;
const refresh = (): void => {
nowMs = Date.now();
};
const timer = setTimeout(refresh, Math.min(Math.max(0, at - Date.now()) + 50, 0x7fffffff));
const onVisible = (): void => {
if (document.visibilityState === 'visible') refresh();
};
document.addEventListener('visibilitychange', onVisible);
return () => {
clearTimeout(timer);
document.removeEventListener('visibilitychange', onVisible);
};
});
// Plaintext left in the private storage of the browser by a page that
// ended before deleting it (tempfile.ts) is deleted on the next visit.
onMount(() => {
const platform = browserPlatform();
if (platform !== undefined) void removeStaleTempFiles(platform).catch(() => undefined);
});
async function run(name: string, read: () => Promise<CapsuleBytes & { capsule: Capsule }>, readNotice?: string): Promise<void> {
const id = ++loadId; const id = ++loadId;
const shown = displayText(name).text; const shown = displayText(name).text;
busy = true; busy = true;
@ -28,12 +66,13 @@
notice = readNotice; notice = readNotice;
announcement = `Inspeccionando ${shown}.`; announcement = `Inspeccionando ${shown}.`;
try { try {
const { bytes, size } = await read(); const { bytes, size, capsule: c } = await read();
// The base protocol V1: no extension registry, so every extension is // The base protocol V1: no extension registry, so every extension is
// unknown to this reader, as in `datekeys inspect`. // unknown to this reader, as in `datekeys inspect`.
const inspection = await inspect(bytes); const inspection = await inspect(bytes);
if (id !== loadId) return; if (id !== loadId) return;
report = buildReport({ fileName: name, bytes, size, inspection }); report = buildReport({ fileName: name, bytes, size, inspection });
capsule = c;
nowMs = Date.now(); nowMs = Date.now();
timeZone = viewerTimeZone(); timeZone = viewerTimeZone();
const verdict = report.failure const verdict = report.failure
@ -47,6 +86,7 @@
} catch (err) { } catch (err) {
if (id !== loadId) return; if (id !== loadId) return;
report = undefined; report = undefined;
capsule = undefined;
problem = describeProblem(shown, err); problem = describeProblem(shown, err);
announcement = readNotice ? `${readNotice} ${problem}` : problem; announcement = readNotice ? `${readNotice} ${problem}` : problem;
} finally { } finally {
@ -63,13 +103,13 @@
} }
function inspectFile(file: File, readNotice?: string): void { function inspectFile(file: File, readNotice?: string): void {
void run(file.name, () => readCapsule(file), readNotice); void run(file.name, async () => ({ ...(await readCapsule(file)), capsule: { data: file, fixture: undefined } }), readNotice);
} }
function inspectFixture(f: Fixture): void { function inspectFixture(f: Fixture): void {
void run(f.name, async () => { void run(f.name, async () => {
const bytes = await fetchFixture(f); const bytes = await fetchFixture(f);
return { bytes, size: bytes.length }; return { bytes, size: bytes.length, capsule: { data: bytes, fixture: f } };
}); });
} }
@ -124,7 +164,7 @@
<title>Inspector de cápsulas DateKeys</title> <title>Inspector de cápsulas DateKeys</title>
<meta <meta
name="description" name="description"
content="Carga un fichero .dkc y comprueba los pasos 1 a 8 de DateKeys en este navegador, sin red y sin claves." content="Carga un fichero .dkc, comprueba los pasos 1 a 8 de DateKeys y, pasada su fecha, ábrelo con la firma de la ronda, todo en este navegador y sin red."
/> />
</svelte:head> </svelte:head>
@ -134,9 +174,9 @@
<div class="intro"> <div class="intro">
<h1>Inspector de cápsulas</h1> <h1>Inspector de cápsulas</h1>
<p class="lead"> <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 Comprueba la parte pública de un fichero <code>.dkc</code>: los pasos 1 a 8 de la especificación (§63), con el mismo
especificación (§63), con el mismo resultado que <code>datekeys inspect</code>. El fichero no sale de este navegador resultado que <code>datekeys inspect</code>. Pasada su fecha de apertura, ábrelo con la firma de la ronda que
y no se pide ninguna clave. publica drand (pasos 9 a 18). El fichero no sale de este navegador y la página no se conecta a ningún otro sitio.
</p> </p>
</div> </div>
@ -189,7 +229,15 @@
{#if report} {#if report}
{#key report} {#key report}
<InspectionReport {report} {nowMs} {timeZone} /> {@const r = report}
{@const c = capsule}
<InspectionReport report={r} {nowMs} {timeZone}>
{#snippet opening()}
{#if r.valid && c !== undefined}
<OpenPanel report={r} capsule={c.data} fixture={c.fixture} {nowMs} />
{/if}
{/snippet}
</InspectionReport>
{/key} {/key}
{/if} {/if}
</div> </div>

@ -1,7 +1,7 @@
// Vite configuration of the SvelteKit site. The library tests run with // Vite configuration of the SvelteKit site. The library tests run with
// vitest.config.ts, which vitest prefers when both files exist. // vitest.config.ts, which vitest prefers when both files exist.
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import { mkdirSync, writeFileSync } from 'node:fs'; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { defineConfig, type Plugin } from 'vite'; import { defineConfig, type Plugin } from 'vite';
@ -35,8 +35,143 @@ function clientModules(): Plugin {
}; };
} }
// The package directory of a module id under node_modules, or undefined.
function packageDir(id: string): string | undefined {
const i = id.lastIndexOf('/node_modules/');
if (i < 0) return undefined;
const parts = id.slice(i + '/node_modules/'.length).split('/');
const name = parts[0]!.startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0]!;
return `${id.slice(0, i)}/node_modules/${name}`;
}
// The package of a virtual module of the bundler, such as \0vite/preload-helper.js
// or \0rolldown/runtime.js, or undefined for any other.
function virtualPackageDir(id: string, root: string): string | undefined {
const name = /^\0(vite|rolldown)\//.exec(id)?.[1];
return name === undefined ? undefined : `${root}/node_modules/${name}`;
}
/**
* The license notice kept in the leading comment of a module derived from
* another project, such as src/lib/dkc/ibe.ts: the block of lines indented
* three spaces that holds a copyright line, with the paragraph that
* introduces it. Undefined when the module has none.
*/
function derivedNotice(source: string): string | undefined {
const header: string[] = [];
for (const line of source.split(/\r?\n/)) {
if (!line.startsWith('//')) break;
header.push(line);
}
// The block runs from its first indented line to its last, over blank
// comment lines; list items of the comment are indented too, so it is
// found by its copyright line.
const copyright = header.findIndex((l) => /^\/\/ {3}.*copyright/i.test(l));
if (copyright < 0) return undefined;
let start = copyright;
while (start > 0 && header[start - 1]!.startsWith('// ')) start--;
let end = copyright;
while (end < header.length && (header[end]!.startsWith('// ') || header[end] === '//')) end++;
while (header[end - 1] === '//') end--;
const notice = header.slice(start, end).map((l) => (l === '//' ? '' : l.slice(5)));
// The paragraph before it, which names the project.
let to = start;
while (to > 0 && header[to - 1] === '//') to--;
let from = to;
while (from > 0 && header[from - 1] !== '//') from--;
const intro = header.slice(from, to).map((l) => l.replace(/^\/\/ ?/, ''));
return [...intro, '', ...notice].join('\n');
}
const RULE = '='.repeat(72);
const LINE = '-'.repeat(72);
// Writes licenses.txt at the root of the site: the notices of the third-party
// code in the client bundle, whose minified JavaScript keeps no comments. It
// holds the notice of every module of src/ derived from another project
// (derivedNotice), the license file of every npm package with a module in
// the bundle, and the license of the site itself. scripts/check-build.mjs
// checks that nothing is missing.
function thirdPartyNotices(): Plugin {
let root = process.cwd();
return {
name: 'datekeys:third-party-notices',
apply: 'build',
configResolved(config) {
root = config.root.replace(/\\/g, '/');
},
generateBundle(_, bundle) {
if (this.environment.name !== 'client') return;
const ids = new Set<string>();
const dirs = new Set<string>();
for (const out of Object.values(bundle)) {
if (out.type !== 'chunk') continue;
for (const [raw, m] of Object.entries(out.modules)) {
const id = raw.replace(/\\/g, '/');
ids.add(id);
// The bundler's own virtual modules (\0…) are code of its package.
const dir = id.startsWith('\0') ? virtualPackageDir(id, root) : packageDir(id);
if (dir !== undefined) dirs.add(dir);
else if (id.startsWith('\0') && m.renderedLength > 0) this.error(`no license known for the virtual module ${JSON.stringify(id)}`);
}
}
const derived: string[] = [];
for (const id of [...ids].sort()) {
if (!id.startsWith(`${root}/src/`) || !/\.(ts|js|svelte)$/.test(id)) continue;
const notice = derivedNotice(readFileSync(id, 'utf8'));
if (notice !== undefined) derived.push(`${id.slice(root.length + 1)}\n\n${notice}`);
}
const packages: string[] = [];
for (const dir of [...dirs].sort()) {
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { name: string; version: string; license?: string };
const file = readdirSync(dir).find((f) => /^(licen[cs]e|copying)(\.md|\.txt)?$/i.test(f));
if (file === undefined) this.error(`${pkg.name} ${pkg.version} has no license file`);
const nested = dir.slice(0, dir.lastIndexOf('/node_modules/')).includes('/node_modules/');
const where = nested ? `, anidada bajo ${dir.slice(dir.indexOf('/node_modules/') + '/node_modules/'.length, dir.lastIndexOf('/node_modules/'))}` : '';
let license = readFileSync(join(dir, file), 'utf8').trim();
// Vite's file also carries the licenses of its Node-side dependencies,
// none of which reaches the client: only its own part applies.
const bundled = license.indexOf('\n# Licenses of bundled dependencies');
if (pkg.name === 'vite' && bundled > 0) license = license.slice(0, bundled).trim();
// The code rolldown writes into the bundle derives from Rollup and
// esbuild, whose notices it keeps apart.
const third = join(dir, 'THIRD-PARTY-LICENSE');
if (pkg.name === 'rolldown' && existsSync(third)) license += `\n\n${readFileSync(third, 'utf8').trim()}`;
packages.push(`${LINE}\n${pkg.name} ${pkg.version} (${pkg.license ?? 'sin campo license'}${where})\n${LINE}\n\n${license}`);
}
const text = [
'Avisos de licencia de este sitio',
'',
'Este sitio es DateKeys App, con licencia Apache-2.0 (al final de este fichero). Su',
'JavaScript lleva, minimizado, código de terceros: módulos derivados de otros',
'proyectos y paquetes npm, cada uno con su aviso de copyright y su licencia.',
'',
RULE,
'Módulos de DateKeys App derivados de otros proyectos',
RULE,
'',
derived.join(`\n\n${LINE}\n\n`),
'',
RULE,
'Paquetes npm',
RULE,
'',
packages.join('\n\n'),
'',
RULE,
'DateKeys App (Apache-2.0)',
RULE,
'',
readFileSync(join(root, 'LICENSE'), 'utf8').trim(),
'',
].join('\n');
this.emitFile({ type: 'asset', fileName: 'licenses.txt', source: text });
},
};
}
export default defineConfig({ export default defineConfig({
plugins: [sveltekit(), clientModules()], plugins: [sveltekit(), clientModules(), thirdPartyNotices()],
build: { build: {
// Never inline an asset as a data: URL. The CSP allows only the page's // 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) // own origin, so the official fixtures (src/lib/inspector/fixtures.ts)

@ -27,6 +27,13 @@ export default defineConfig({
'src/lib/dkc/digest.ts': { 100: true }, 'src/lib/dkc/digest.ts': { 100: true },
// The tlock recipient (step 7). // The tlock recipient (step 7).
'src/lib/dkc/tlock.ts': { 100: true }, 'src/lib/dkc/tlock.ts': { 100: true },
// The prefix reads, and the opening of the page (step 8).
'src/lib/dkc/prefix.ts': { 100: true },
'src/lib/inspector/fixtures.ts': { 100: true },
'src/lib/inspector/opener.ts': { 100: true },
'src/lib/inspector/opening.ts': { 100: true },
'src/lib/inspector/release-input.ts': { 100: true },
'src/lib/inspector/tempfile.ts': { 100: true },
'src/lib/dkc/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 }, 'src/lib/dkc/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 },
// The page model and helpers of the inspector (plan §8, phase 1). // The page model and helpers of the inspector (plan §8, phase 1).
'src/lib/inspector/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 }, 'src/lib/inspector/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 },

Loading…
Cancel
Save

Powered by TurnKey Linux.