diff --git a/CHANGELOG.md b/CHANGELOG.md
index f913448..069ec63 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,16 @@ Cambios notables de la librería TypeScript y de la página. El proyecto usa ver
## 0.2.0 — sin publicar
-Fase 3: la escritura de cápsulas de formato 2, según `PLAN_fase3_escritura.md` (v3, en `../docs`). En curso.
+Fase 3: la escritura de cápsulas de formato 2, según `PLAN_fase3_escritura.md` (v3, en `../docs`). Hecha, pasos 0 a 7; la versión sigue sin publicar hasta que el autor la cierre.
+
+### Pasos 6 y 7: la página `/create`
+
+- El autor confirma las decisiones de la página con cada recomendación: `time_only` por defecto, la zona del dispositivo con un selector, los avisos de §53 y §50 desde 365 días, un aviso de protocolo preliminar y los nombres `capsula-`.
+- `/create` cifra un fichero propio en un `.dkc` de formato 2 y, si se pide, en una `.dkk` portable, en el navegador y sin red. Antes de cifrar muestra el instante efectivo, la ronda, la `dk1_`, el tamaño exacto del `.dkc` y lo que deja ver hasta la fecha. El `.dkc` va a un fichero temporal de OPFS, o a memoria hasta 64 MiB, y la escritura se puede cancelar. La `.dkk` vive solo en memoria, y la página avisa si se sale sin descargarla. El resultado lleva el informe de los pasos 1 a 8 de lo escrito.
+- `lengths.ts`: `sealedControlLength` sale de `writer.ts`, y `capsuleLength` da el tamaño del `.dkc` antes de escribirlo; el bucle de propiedades lo comprueba en cada cápsula. `datekey.ts`: `LONG_HORIZON_SECONDS` e `isLongHorizon`.
+- `localtime.ts`, `create-input.ts` y `creator.ts`, al 100 %. `/inspect` limpia también la zona de crear. `check-build.mjs` comprueba la carga bajo demanda de las dos páginas que la tienen.
+- Comprobado en el navegador: una cápsula creada para dentro de cuatro minutos se abrió después en `/inspect` con el release pegado y con `datekeys decrypt` de Go, con el mismo contenido.
+- Una revisión adversarial encontró un fallo mayor y ocho menores, todos corregidos. El mayor: la `.dkk` que es la única credencial se podía borrar sin confirmación. Ahora la página la pide al olvidarla y al crear otra cápsula. Entre los menores: la zona desconocida del dispositivo pasa a UTC; el reloj de la página se lee cada segundo; hay un mensaje propio para un reloj anterior a Quicknet; lo escrito no se ofrece si los pasos 1 a 8 lo rechazan; y el foco va a «Cancelar» durante la escritura. `localtime.test.ts` compara la conversión con una búsqueda exhaustiva alrededor de todos los cambios de hora de 2030.
### Paso 5: interoperabilidad con Go a nivel de cápsula
diff --git a/README.md b/README.md
index 8e4bd52..c07f66e 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ La implementación de referencia es la librería Go `g.activething.com/go/DateKe
| Codec CBOR del subconjunto, parsers de schema, DateKey, `inspect` | `src/lib/dkc/` | 4 | hecho |
| Página inspector, sin red | SvelteKit estático: `src/routes/`, `src/lib/inspector/`, `src/lib/components/` | 5 | hecho |
| Apertura de cápsulas en el navegador, y la acción "abrir" de la página | fase 2 (`PLAN_fase2_ibe_noble2.md`, en `../docs`) | 6 | hecho (pasos 2 a 8 de la fase 2) |
-| Escritura de cápsulas en TypeScript | fase 3 (`PLAN_fase3_escritura.md`, en `../docs`) | 7 | la librería está hecha y Go abre lo que escribe (`encrypt.ts`, pasos 2 a 5 del plan); falta la página |
+| Escritura de cápsulas en TypeScript | fase 3 (`PLAN_fase3_escritura.md`, en `../docs`) | 7 | hecho: la librería, su interoperabilidad con Go y la página `/create` (pasos 0 a 7 del plan) |
## Versiones
@@ -54,6 +54,7 @@ La inspección (pasos 1 a 8) no importa ninguna dependencia. Funciona en navegad
| `open.ts` | Los pasos 9 a 18 de §63 sobre los pasos 1 a 8 de `inspectWith`, con los checks, códigos y textos de `capsule.Open`: - las credenciales y el release (paso 9), que cualquier fallo de la fuente convierte en `ERR_RELEASE_UNAVAILABLE` (corrección 6); - la verificación del release (10); - `OUTER_TIME_AGE` (11), la estructura frente a `access_policy` (12) e `INNER_ACCESS_AGE` (13); - `CONTROL_CBOR` (14), `header_binding` (15), `I_PAYLOAD` (16), `PAYLOAD_AGE` (17) y el commit (18). Lee los dos formatos (§22, §70). En el formato 2, `INNER_ACCESS_AGE` tiene exactamente 16 stanzas (paso 12); `CONTROL_CBOR` es de la versión de schema 2, con L y la regla de relleno (14); el paso 16 calcula P, y el 17 exige un texto en claro de exactamente P bytes con ceros tras el contenido, `ERR_INTEGRITY` en otro caso. Solo se entregan los L primeros bytes, nunca el relleno (§29.1, §56). `Opened` da el formato y, en el formato 2, L, la regla y P. Abre los tres ficheros `age` con el `Decrypter` de `age-encryption` y con identidades propias que aplican las reglas de `agewrap`: la de tiempo, sobre `ibe.ts`; las de acceso y payload, sobre `x25519.ts`, stanza a stanza. Los fallos de `age` que no informa una identidad son `ERR_INTEGRITY` con el motivo fijo de su fase, cabecera o STREAM, sin copiar el texto de `age-encryption`. La entrada puede ser un `Uint8Array` o un `Blob`, como un `File`. De un `Blob` solo se lee el prefijo de los pasos 1 a 8 (`prefix.ts`), el `capsule_digest` de la `.dkk` se calcula sobre su stream (`digest.ts`) y `PAYLOAD_AGE` se descifra en streaming. El texto en claro va a memoria o a `output`, un `WritableStream`. Se escribe a medida que `age` autentica cada chunk, se cierra solo tras el paso 18 y se aborta ante cualquier fallo, en cualquier paso (§56). Un fallo del stream de salida es `ERR_INTEGRITY` con su texto, como en Go. El `WritableStream` de un fichero OPFS guarda lo escrito en un fichero de intercambio hasta el cierre: comprobado en el navegador, un fallo de STREAM deja intacto el contenido anterior | `capsule.Open`, `agewrap` (`TimeIdentity`, `AccessIdentity`, `PayloadIdentity`) |
| `encrypt.ts`, `writer.ts` | El writer de la fase 3: `encrypt(src, opts)` escribe un `.dkc` de formato 2 y, si se pide, una `.dkk` portable (§61, §62, §62.1), en el orden y con los textos y códigos de `capsule.Encrypt`: - el formato 2 siempre; L conocida de antemano (el tamaño de un `Uint8Array` o un `Blob`, o `length` con un `ReadableStream`), y una fuente que da más o menos bytes falla con los textos de Go; - el relleno `reforzado` por defecto, o `bloque256`; - de 1 a 16 credenciales, canónicas y no de orden bajo, un señuelo en cada hueco libre, cuyo escalar se borra al derivar su clave pública, y un orden uniforme de los 16 (`random.ts`); - `SEALED_CONTROL_LEN` con la fórmula del §62.1, comprobada con el sellado real; - las autocomprobaciones de la regla 11 y dos más: `OUTER_TIME_AGE` con las reglas del lector, y la cabecera de `PAYLOAD_AGE`, que `I_PAYLOAD` abre antes de escribir nada. Nada se escribe hasta que todo lo anterior al contenido está comprobado. El contenido va en trozos de 64 KiB, seguido de los ceros del relleno, con presión inversa, hacia memoria (hasta `MAX_MEMORY_DKC`, 1 GiB) o hacia `output`, que se cierra solo con la cápsula completa y comprobada y se aborta ante cualquier fallo. Los errores de la fuente y de la salida se relanzan tal cual. El núcleo, `writer.ts`, recibe la aleatoriedad de quien lo llama: `encrypt.ts` le da la de `crypto.getRandomValues`, y solo `testing/encrypt.ts` la fija, para reproducir los fixtures de Go | `capsule.Encrypt`, `accesskey.Encode` |
| `tlock.ts` | `timeRecipient`, el `Recipient` de `age-encryption` para `OUTER_TIME_AGE` (§32, §35), como `agewrap.TimeRecipient`: cifra la file key con `ibe.ts` para una ronda de un perfil pinneado y escribe el stanza `tlock ` de tlock. Comprueba el perfil y luego el rango de la ronda, con los textos de `NewTimeRecipient`. `age-encryption` no tiene etiquetas, así que quien escriba `OUTER_TIME_AGE` (fase 3) lo añade como único recipient | `agewrap.TimeRecipient` |
+| `lengths.ts` | El tamaño de un `.dkc` de formato 2 antes de escribirlo: `sealedControlLength`, la fórmula de `SEALED_CONTROL_LEN` del §62.1 con la que el writer comprueba su sellado, y `capsuleLength`, el tamaño exacto que escribe `encrypt` para una ronda, una política, L, el relleno y las extensiones, que la página muestra antes de cifrar porque cualquiera con el fichero lo ve (§55.2). Sin noble ni `age-encryption` | `capsule.Encrypt`, que mide un borrador sellado |
| `padding.ts` | El relleno del formato 2 (§29.1): los códigos 1 (`bloque256`) y 2 (`reforzado`), `paddedLength`, exacta hasta L_MAX = 2⁵³ − 2⁴⁶ (`bitlen` con `BigInt` y los redondeos con `ceil`, exactos en doubles; nunca operaciones de 32 bits, `Math.clz32` ni `Math.log2`), y la longitud de `PAYLOAD_AGE` | `capsule/padding.go` |
| `digest.ts` | SHA-256 incremental con `@noble/hashes` (`sha256Hasher`, `sha256Stream`), para el `capsule_digest` de un `.dkc` que no está en memoria o que se está escribiendo (Web Crypto solo calcula el hash de buffers enteros) | |
| `agefile.ts` | Ficheros `age` enteros con el `Decrypter` de `age-encryption`, compartidos por la apertura y las autocomprobaciones del writer: los errores de una identidad conservan su código, y cualquier otro fallo de `age` es `ERR_INTEGRITY` con el motivo fijo de su fase, cabecera o STREAM. Lo que se lee en memoria se borra trozo a trozo | `capsule.Open` |
@@ -61,7 +62,7 @@ La inspección (pasos 1 a 8) no importa ninguna dependencia. Funciona en navegad
| `random.ts` | Un índice uniforme sin sesgo (rechaza las palabras de 32 bits desde ⌊2³²/n⌋·n) y la permutación de Fisher–Yates, para el orden de los 16 huecos de `INNER_ACCESS_AGE` (§39) | `capsule.permute` |
| `x25519.ts` | El stanza X25519 de `age`, abierto de uno en uno como `X25519Identity.Unwrap` de `age`: argumentos, share, acuerdo de claves, longitud del cuerpo y autenticación, en ese orden, con las primitivas que usa `age-encryption` (X25519 de `@noble/curves`, HKDF-SHA-256 de `@noble/hashes` y ChaCha20-Poly1305 de `@noble/ciphers`). También lee identidades `AGE-SECRET-KEY-1…`, genera identidades nuevas en bytes crudos (`newX25519Identity`) y deriva su clave pública (`x25519PublicKey`) | `filippo.io/age` (`X25519Identity`), `agewrap` |
| `bech32.ts` | Bech32 (BIP 173) tal como `internal/bech32` de `age`, que la referencia copia como `codec/bech32`; conserva su aviso MIT | `codec/bech32` |
-| `datekey.ts` | `dk1_` canónico con las reglas de lectura de §19 (CR, LF y todo carácter fuera del alfabeto fallan el paso 1; números JSON por su valor decimal exacto), ronda desde una fecha con precisión de nanosegundos y cota de 9999-12-31T23:59:59Z (§15), parser RFC 3339 equivalente a `time.Parse(time.RFC3339Nano, …)`; `compareInstants` e `isInstant` | `datekey` |
+| `datekey.ts` | `dk1_` canónico con las reglas de lectura de §19 (CR, LF y todo carácter fuera del alfabeto fallan el paso 1; números JSON por su valor decimal exacto), ronda desde una fecha con precisión de nanosegundos y cota de 9999-12-31T23:59:59Z (§15), parser RFC 3339 equivalente a `time.Parse(time.RFC3339Nano, …)`; `compareInstants` e `isInstant`; `LONG_HORIZON_SECONDS` e `isLongHorizon`, el umbral de 365 días de los avisos de §53 y §50, una política de producto | `datekey` |
| `header.ts`, `control.ts`, `accesskey.ts` | PUBLIC_HEADER, CONTROL_CBOR y `.dkk` (cuerpo y trama), decodificar y codificar, con las capas de §69.1. CONTROL_CBOR se lee y se escribe para un formato: versión de schema 1 sin las claves 6 y 7, o 2 con `payload_length` (8 bytes, hasta L_MAX) y `padding` (1 o 2); 103 bytes sin extensiones sea cual sea L | `capsule`, `accesskey` |
| `framing.ts` | Prelude DKC1 (16 bytes), con el formato de la cápsula, 1 o 2, 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, con los 16 de `INNER_ACCESS_AGE` en el formato 2 (`ACCESS_SLOTS`); `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` |
@@ -94,12 +95,13 @@ Secretos: `access_material` de un `.dkk` e `I_PAYLOAD` de CONTROL_CBOR se borran
## Página inspector
-Sitio SvelteKit estático (`@sveltejs/adapter-static`, `strict`): las dos páginas se prerenderizan a HTML y no hay código de servidor.
+Sitio SvelteKit estático (`@sveltejs/adapter-static`, `strict`): las tres páginas se prerenderizan a HTML y no hay código de servidor.
| Ruta | Contenido |
|---|---|
-| `/` | Qué es el inspector y qué garantiza; enlaza con `/inspect` |
+| `/` | Qué es el inspector y qué garantiza; enlaza con `/inspect` y `/create` |
| `/inspect` | El inspector |
+| `/create` | Crear una cápsula (fase 3, paso 7) |
`/inspect` carga un `.dkc` con el selector de ficheros, soltándolo en cualquier parte de la página o desde la lista de fixtures oficiales, y ejecuta `inspect` (pasos 1 a 8 de §63) sin registro de extensiones, como `datekeys inspect`. Muestra:
@@ -124,6 +126,31 @@ Tras los pasos 1 a 8, si la cápsula es válida y su fecha de apertura ya pasó
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).
+### Crear
+
+`/create` (plan de la fase 3, sección 9, con las decisiones del paso 6) cifra un fichero propio en un `.dkc` de formato 2 y, si se pide, en una `.dkk` portable, todo en el navegador y sin red.
+
+- **El formulario** (`create-input.ts`) pide el fichero, elegido o soltado en la página; su nombre no entra en la cápsula. Después, el día y la hora, en la zona del dispositivo, en otra de las que conoce el navegador o en UTC. Y la política: «solo con la fecha» (`time_only`, la de por defecto) o «con la fecha y una clave» (`time_and_key`). Esta lleva destinatarios `age1…`, uno por línea y comprobados mientras se escriben (`recipient.ts`, sin noble), y la casilla de la clave portable, marcada por defecto; como mucho 16 credenciales. Los campos se comprueban en su orden, y el foco va al campo del primer problema.
+- **La hora local** (`localtime.ts`) se convierte al instante UTC con `Intl` y las reglas de zona que conoce hoy el navegador. Una hora que la zona se salta al adelantar los relojes se rechaza. De una que repite al atrasarlos se toma la más tardía, para no abrir nunca antes de lo querido. La cápsula no guarda la zona.
+- **Antes de cifrar**, la página muestra el instante efectivo, que es el de la ronda, en la zona elegida y en UTC, y cuánto cae después del pedido. También la ronda, la `dk1_`, la hora del dispositivo junto a la UTC, el tamaño exacto del `.dkc` (`capsuleLength`) y lo que la cápsula deja ver hasta la fecha (§55.2). Y los avisos: el de protocolo preliminar (§74), siempre; los de §53 y §50, con sus textos, si el instante efectivo está a más de 365 días (`LONG_HORIZON_SECONDS`); y uno informativo si está a menos de una hora. La hora del dispositivo se lee cada segundo mientras la pestaña se ve y al crear la cápsula; la página no la pregunta a ningún servidor. Si el navegador no conoce la zona del dispositivo (V8 da entonces `Etc/Unknown`), la página empieza en UTC.
+- **El writer se carga bajo demanda**: la página importa `creator.ts` con `import()` al pulsar «Crear la cápsula», y con él `encrypt.ts`, noble y `age-encryption`. El relleno es siempre `reforzado`, y no hay extensiones. Lo escrito no se ofrece si no mide lo que se mostró o si los pasos 1 a 8 lo rechazan, y ante cualquier fallo se borra la `.dkk`. Si la fecha llega mientras la página se prepara para escribir, el writer la rechaza con su propio reloj (§62.1, regla 2), y la página lo dice en el campo de la fecha.
+- **El `.dkc`** se escribe en un fichero temporal de OPFS, `datekeys-create//capsule` (`tempfile.ts`), que el navegador solo confirma cuando el writer lo cierra con la cápsula completa y comprobada. Si el navegador no tiene OPFS o lo rechaza, o la cuota no alcanza, se escribe en memoria, hasta 64 MiB. La cuota se comprueba con el tamaño exacto antes del primer byte. «Cancelar» detiene la escritura tras el trozo en curso: la salida se aborta y el fichero se borra. El fichero temporal se borra al pulsar «Borrar el fichero temporal», al crear otra cápsula y al salir de la página. Si el navegador terminó antes, se borra en la siguiente visita a `/create` o a `/inspect`, que limpian las dos zonas.
+- **La `.dkk`** (152 bytes sin extensiones) vive solo en la memoria de la página: nunca en OPFS, y nunca se muestra como `AGE-SECRET-KEY-1…`. Sus bytes se borran al pulsar «Olvidar la clave», al crear otra cápsula y al salir, y con ellos las URL de sus descargas. Junto a ella va el aviso de §7.4. Si la cápsula solo se abre con su `.dkk` y no se ha descargado, la página pide confirmación antes de borrarla, al olvidarla o al crear otra, y avisa antes de salir: el navegador pregunta al cerrar o recargar, y la página, al seguir un enlace del sitio. Salir de la página también cancela una escritura en curso.
+- **Las descargas** van por separado, con nombres que se pueden editar. Por defecto son `capsula-.dkc` y `.dkk`, que no dicen cuándo se creó la cápsula. La URL `blob:` de cada descarga se revoca un minuto después del clic.
+- **El resultado** muestra el `capsule_id`, la política, el tamaño y el relleno, y el informe de los pasos 1 a 8 del `.dkc` escrito, con el componente del inspector.
+
+Comprobado el 29-09-2026 en Chromium (el navegador de la app de escritorio), sobre la compilación de producción:
+- un fichero de 77 bytes se cifró en `time_and_key`, con clave portable, para dentro de cuatro minutos, sin ninguna petición fuera del origen, y el informe pasó los pasos 1 a 8 con el formato 2;
+- pasada la fecha, la cápsula se abrió en `/inspect`, con el release pegado de drand y la `.dkk`, y con `datekeys decrypt` de Go, con red y la `.dkk`: el mismo contenido, con el mismo SHA-256;
+- los avisos de §53 y §50 salen solo a partir de 365 días;
+- cancelar a mitad de 200 MB no deja fichero, y sin `createWritable` la cápsula se escribe en memoria;
+- la página avisa al salir sin la `.dkk`;
+- a 375 px ningún elemento desborda el ancho, ni con un nombre de fichero largo sin espacios.
+
+Una revisión adversarial del 29-09-2026 encontró un fallo mayor: la `.dkk` que era la única credencial se podía borrar sin confirmación. Encontró también ocho menores, entre ellos una zona del dispositivo desconocida, el reloj de la página parado mientras la pestaña se veía, un mensaje equivocado con el reloj del dispositivo anterior a Quicknet y el foco durante la escritura. Todos se contrastaron y se corrigieron, y los del navegador se volvieron a comprobar en él. La revisión comparó además `localToEpochMs` con una búsqueda exhaustiva en las 418 zonas, sin ninguna diferencia en 26 114 horas locales. `localtime.test.ts` repite esa comparación en cada ejecución, alrededor de los 260 cambios de hora de 2030.
+
+Rendimiento, informativo, en ese navegador con la ventana en segundo plano: una cápsula pequeña tarda 0,14 s en `time_and_key` y 0,2 s en `time_only`. 64 MiB tardan 5,4 s hacia OPFS (unos 12 MiB/s), y 60 MiB, 3,2 s en memoria; 1 GiB, 74,5 s hacia OPFS. Escribir 64 MiB en OPFS cuesta 1 s en trozos de 64 KiB, los del writer, y 0,55 s en trozos de 1 MiB.
+
| Fichero | Contenido |
|---|---|
| `src/lib/inspector/report.ts` | `buildReport`: el modelo de la página a partir de `Inspection`, sin DOM ni reloj |
@@ -134,9 +161,12 @@ Medido en Chromium (el navegador de la app de escritorio) sobre la compilación
| `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/inspector/tempfile.ts` | El fichero temporal de OPFS, con un directorio y un Web Lock por pestaña y por zona (la apertura y crear), la cuota libre y la limpieza de lo que quedó |
+| `src/lib/inspector/localtime.ts` | Una fecha y una hora locales de una zona como instante UTC, con `Intl`: las horas que no existen y las repetidas; la lista de zonas |
+| `src/lib/inspector/create-input.ts` | El formulario de `/create`, sin DOM, reloj ni writer: `planCapsule` comprueba los campos en su orden y da lo que se muestra antes de cifrar; los destinatarios y los nombres de los ficheros |
+| `src/lib/inspector/creator.ts` | La escritura, cargada bajo demanda: `encrypt` hacia el fichero temporal o la memoria, con la cancelación y la cuota, y los pasos 1 a 8 de lo escrito |
| `src/lib/components/` | `InspectionReport`, `OpenPanel`, `StepList`, `ExtensionList`, `DataView`, `Mark` |
-| `src/routes/` | Layout, portada e inspector |
+| `src/routes/` | Layout, portada, inspector y crear |
### Fixtures
@@ -157,7 +187,7 @@ style-src 'self'; style-src-attr 'unsafe-hashes' 'sha256-…'; base-uri 'none';
- `style-src 'self'`: solo hojas de estilo del sitio; sin fuentes web ni CDN, con las fuentes del sistema.
- `style-src-attr`: solo el atributo `style` del anunciador de rutas de SvelteKit, por su hash (`ANNOUNCER_STYLE_HASH`, válido para `@sveltejs/kit` 2.70.3; `app.css` lo oculta también si el navegador bloquea el atributo).
-`npm run build` ejecuta después `scripts/check-build.mjs` (`postbuild`; también `npm run build:check`), que falla si una ruta no tiene su HTML prerenderizado; si una página no tiene exactamente esa política, con la etiqueta antes de cualquier elemento que cargue recursos; si un script en línea no está en `script-src` o sobra un hash; si `style-src-attr` no coincide con los atributos `style` del bundle; si hay estilos en línea, manejadores de eventos en atributos, `@import` o URL a otro origen; si algún `.dkc` oficial no está byte a byte; 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.
+`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` y `/create` no pueden cargar bajo demanda `age-encryption`, `@noble/curves` y `@noble/ciphers`, y `/create` también `@noble/hashes`; 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`
@@ -188,7 +218,7 @@ npm run build:check # solo la comprobación del sitio ya construido
npm run verify # check, typecheck, coverage y build (con su comprobación)
```
-Umbrales de cobertura (`vitest.config.ts`): `cbor.ts`, `ibe.ts`, `release.ts`, `tlock.ts`, `x25519.ts`, `bech32.ts`, `digest.ts`, `padding.ts`, `agefile.ts`, `recipient.ts`, `random.ts`, `writer.ts` y `encrypt.ts` al 100 % en líneas, ramas, funciones y sentencias; el conjunto de `src/lib/dkc` al 95/90/95/95, y el de `src/lib/inspector` también.
+Umbrales de cobertura (`vitest.config.ts`): `cbor.ts`, `ibe.ts`, `release.ts`, `tlock.ts`, `x25519.ts`, `bech32.ts`, `digest.ts`, `padding.ts`, `agefile.ts`, `recipient.ts`, `random.ts`, `writer.ts`, `encrypt.ts` y `lengths.ts`, y `localtime.ts`, `create-input.ts` y `creator.ts` de la página, al 100 % en líneas, ramas, funciones y sentencias; el conjunto de `src/lib/dkc` al 95/90/95/95, y el de `src/lib/inspector` también.
`vitest.config.ts` es la configuración de los tests; `vite.config.ts`, la del sitio con el plugin de SvelteKit. Vitest prefiere la primera, así que los tests de `src/lib` corren sin SvelteKit, y `src/lib/inspector` importa la librería por rutas relativas, sin el alias `$lib`. `tsconfig.json` extiende el que genera `svelte-kit sync` (por eso `typecheck` y `check` lo ejecutan antes, y `npm install` también, con `prepare`).
@@ -286,7 +316,14 @@ En el sitio, según `check-build.mjs` el 28-09-2026:
| 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.
+Y según `check-build.mjs` el 29-09-2026, con la página de crear:
+
+| `/create` | JavaScript | gzip |
+|---|---|---|
+| Primera carga, con el formulario y el informe de los pasos 1 a 8 | 207 348 B | 76 525 B |
+| Bajo demanda, al crear: `creator.ts`, `encrypt.ts`, noble y `age-encryption` | 212 515 B | 76 534 B |
+
+Los 9,5 KB con gzip que crece la primera carga de `/inspect` 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.
diff --git a/scripts/check-build.mjs b/scripts/check-build.mjs
index b5971f6..66d574e 100644
--- a/scripts/check-build.mjs
+++ b/scripts/check-build.mjs
@@ -23,8 +23,9 @@
// @noble/post-quantum (plan of phase 2, section 3 and decision 5), as
// .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);
+// of on demand, or a page of ON_DEMAND cannot load its code on demand: the
+// opening on /inspect (plan of phase 2, section 9) and the writer on
+// /create (plan of phase 3, section 3);
// - 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.
@@ -315,10 +316,18 @@ const weight = (set) => {
};
// ---------------------------------------------------------------------------
-// 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.
+// The opening and the writer are loaded on demand: no page loads noble or
+// age-encryption first, and each page of ON_DEMAND can load the packages of
+// its code when the person opens or creates a capsule.
const OPENING_PACKAGES = /^(?:@noble\/|@scure\/|age-encryption$)/;
+const ON_DEMAND = {
+ 'inspect.html': ['age-encryption', '@noble/curves', '@noble/ciphers'],
+ 'create.html': ['age-encryption', '@noble/curves', '@noble/ciphers', '@noble/hashes'],
+};
+for (const name of Object.keys(ON_DEMAND)) {
+ if (!htmlFiles.some((f) => basename(f) === name && existsSync(f))) fail(`${name}, a page that loads code on demand, is not in the site`);
+}
const bundled = (set) => {
const names = new Set();
for (const f of set) {
@@ -333,11 +342,9 @@ 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}`);
- }
+ const later = bundled(lazy);
+ for (const n of ON_DEMAND[basename(f)] ?? []) {
+ if (!later.has(n)) fail(`${rel(f)}: the code loaded on demand lacks ${n}`);
}
}
diff --git a/src/lib/dkc/datekey.test.ts b/src/lib/dkc/datekey.test.ts
index ba38924..ba5ed7b 100644
--- a/src/lib/dkc/datekey.test.ts
+++ b/src/lib/dkc/datekey.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { compareInstants, isInstant } from './datekey.ts';
+import { compareInstants, isInstant, isLongHorizon, LONG_HORIZON_SECONDS } from './datekey.ts';
import {
base64RawURL,
canonicalJSON,
@@ -382,6 +382,16 @@ describe('instants', () => {
expect(compareInstants(t(-5, 3), t(-5, 2))).toBe(1);
});
+ it('warns past a horizon of 365 days, to the nanosecond (spec §53, §50)', () => {
+ const now = { seconds: 1_790_000_000, nanos: 500 };
+ const at = (seconds: number, nanos: number) => ({ seconds: now.seconds + seconds, nanos });
+ expect(LONG_HORIZON_SECONDS).toBe(31_536_000);
+ expect(isLongHorizon(at(LONG_HORIZON_SECONDS, 500), now)).toBe(false);
+ expect(isLongHorizon(at(LONG_HORIZON_SECONDS, 501), now)).toBe(true);
+ expect(isLongHorizon(at(1, 0), now)).toBe(false);
+ expect(isLongHorizon(at(100 * LONG_HORIZON_SECONDS, 0), now)).toBe(true);
+ });
+
it('recognizes an instant: safe integer seconds and nanoseconds 0..999 999 999', () => {
for (const ok of [{ seconds: 0, nanos: 0 }, { seconds: -62135596800, nanos: 999_999_999 }, { seconds: 253402300799, nanos: 1 }]) {
expect(isInstant(ok), JSON.stringify(ok)).toBe(true);
diff --git a/src/lib/dkc/datekey.ts b/src/lib/dkc/datekey.ts
index 2cb55cd..918e3e1 100644
--- a/src/lib/dkc/datekey.ts
+++ b/src/lib/dkc/datekey.ts
@@ -47,6 +47,20 @@ export function compareInstants(a: Instant, b: Instant): number {
return a.nanos === b.nanos ? 0 : a.nanos < b.nanos ? -1 : 1;
}
+/**
+ * How far ahead an effective unlock time must be for the official SDK to warn
+ * that Quicknet V1 is not post-quantum and the ciphertext may be kept for
+ * years (spec §53), and that the release of its round must still be
+ * available then (§50): 365 days. A product policy, not part of the protocol
+ * (§53).
+ */
+export const LONG_HORIZON_SECONDS = 365 * 86_400;
+
+/** Whether `unlockAt` is more than LONG_HORIZON_SECONDS after `now`: the warnings of spec §53 and §50 apply. */
+export function isLongHorizon(unlockAt: Instant, now: Instant): boolean {
+ return compareInstants(unlockAt, { seconds: now.seconds + LONG_HORIZON_SECONDS, nanos: now.nanos }) > 0;
+}
+
// ---------------------------------------------------------------------------
// dk1_
diff --git a/src/lib/dkc/encrypt.property.test.ts b/src/lib/dkc/encrypt.property.test.ts
index 0bb6e43..3a2a984 100644
--- a/src/lib/dkc/encrypt.property.test.ts
+++ b/src/lib/dkc/encrypt.property.test.ts
@@ -4,9 +4,10 @@
// invalid, without writing anything and with its output aborted; or writes a
// capsule that inspect accepts, that open opens with all its credentials, in
// which each credential opens exactly one of the 16 stanzas, whose lengths
-// follow the formulas of §62.1, and whose .dkk decodes and encodes back the
-// same. 50 seeds in every run; DATEKEYS_PROPERTY_SEEDS=500 for the run by
-// hand of each step. The seed is in the name of each case.
+// follow the formulas of §62.1, whose size capsuleLength gives before
+// writing, and whose .dkk decodes and encodes back the same. 50 seeds in
+// every run; DATEKEYS_PROPERTY_SEEDS=500 for the run by hand of each step.
+// The seed is in the name of each case.
import { describe, expect, it } from 'vitest';
import { decodeAccessKey, encodeAccessKey } from './accesskey.ts';
@@ -18,13 +19,13 @@ import { encrypt, type EncryptOptions } from './encrypt.ts';
import type { Extension } from './extension.ts';
import { TIME_AND_KEY, TIME_ONLY } from './header.ts';
import { inspect } from './inspect.ts';
+import { capsuleLength, sealedControlLength } from './lengths.ts';
import { open, timeIdentity } from './open.ts';
import { paddedLength, payloadAgeLength, REFORZADO } from './padding.ts';
import { quicknet } from './profile.ts';
import { type Release, suppliedRelease } from './release.ts';
import { split } from './testing/capsule.ts';
import { h, hx, readJSON } from './testing/testdata.ts';
-import { sealedControlLength } from './writer.ts';
import { newX25519Identity, unwrapX25519, x25519PublicKey } from './x25519.ts';
const SEEDS = Number(process.env.DATEKEYS_PROPERTY_SEEDS ?? 50);
@@ -183,6 +184,8 @@ describe('the property loop of the writer', () => {
at += b.length;
}
expect([state.closed, res.size]).toEqual([true, dkc.length]);
+ // The size that lengths.ts gives before writing.
+ expect(capsuleLength({ ...c.opts, profileId: res.dateKey.profileId, round: res.dateKey.round, length: c.body.length })).toBe(dkc.length);
const P = paddedLength(c.body.length, c.opts.padding ?? REFORZADO);
expect(res.paddedLength).toBe(P);
diff --git a/src/lib/dkc/encrypt.test.ts b/src/lib/dkc/encrypt.test.ts
index fa8cf9e..2f98dec 100644
--- a/src/lib/dkc/encrypt.test.ts
+++ b/src/lib/dkc/encrypt.test.ts
@@ -26,7 +26,8 @@ import { type Release, suppliedRelease } from './release.ts';
import { split } from './testing/capsule.ts';
import { encryptWith, wordsFor } from './testing/encrypt.ts';
import { h, hx, listTestdata, readBytes, readJSON } from './testing/testdata.ts';
-import { sealedControlLength, selfCheckInner, selfCheckPayloadHeader } from './writer.ts';
+import { sealedControlLength } from './lengths.ts';
+import { selfCheckInner, selfCheckPayloadHeader } from './writer.ts';
import { newX25519Identity, parseX25519Identity, unwrapX25519, x25519PublicKey } from './x25519.ts';
interface FixtureRecord {
diff --git a/src/lib/dkc/index.ts b/src/lib/dkc/index.ts
index b0dd90d..5dc2224 100644
--- a/src/lib/dkc/index.ts
+++ b/src/lib/dkc/index.ts
@@ -15,6 +15,7 @@ export * from './extension.ts';
export * from './framing.ts';
export * from './header.ts';
export * from './inspect.ts';
+export * from './lengths.ts';
export * from './padding.ts';
export * from './prefix.ts';
export * from './profile.ts';
diff --git a/src/lib/dkc/lengths.test.ts b/src/lib/dkc/lengths.test.ts
new file mode 100644
index 0000000..60a0fcf
--- /dev/null
+++ b/src/lib/dkc/lengths.test.ts
@@ -0,0 +1,97 @@
+// Tests of lengths.ts: the size of a .dkc of format 2 before writing it,
+// against the format 2 fixtures of the Go reference and against what encrypt
+// writes. The property loop (encrypt.property.test.ts) checks it on every
+// capsule it writes.
+
+import { describe, expect, it } from 'vitest';
+import { decodeControl } from './control.ts';
+import { parseRFC3339 } from './datekey.ts';
+import { encrypt } from './encrypt.ts';
+import { FORMAT_2 } from './framing.ts';
+import { decodeHeader, TIME_AND_KEY, TIME_ONLY } from './header.ts';
+import { capsuleLength, sealedControlLength } from './lengths.ts';
+import { BLOQUE256, MAX_PAYLOAD_LENGTH, REFORZADO } from './padding.ts';
+import { QUICKNET_ID, quicknet } from './profile.ts';
+import { h, listTestdata, readBytes, readJSON } from './testing/testdata.ts';
+import { newX25519Identity, x25519PublicKey } from './x25519.ts';
+
+const GENESIS = parseRFC3339('2023-08-23T15:09:27Z');
+const roundAt = (r: number) => ({ seconds: GENESIS.seconds + (r - 1) * 3, nanos: 0 });
+
+describe('sealedControlLength', () => {
+ it('gives the lengths of spec §62.1 for a control of 103 bytes: 458 and 2128 at round 1000', () => {
+ expect(sealedControlLength(TIME_ONLY, 103, 1000)).toBe(458);
+ expect(sealedControlLength(TIME_AND_KEY, 103, 1000)).toBe(2128);
+ });
+
+ it('counts the digits of the round and a tag of 16 bytes per chunk of 64 KiB', () => {
+ expect(sealedControlLength(TIME_ONLY, 103, 83_903_165_811) - sealedControlLength(TIME_ONLY, 103, 1)).toBe(10);
+ expect(sealedControlLength(TIME_ONLY, 65537, 1000) - sealedControlLength(TIME_ONLY, 65536, 1000)).toBe(1 + 16);
+ // INNER_ACCESS_AGE of a control of 63 866 bytes is 65 536 bytes, one
+ // chunk of OUTER_TIME_AGE; one byte more takes two.
+ expect(sealedControlLength(TIME_AND_KEY, 63_867, 1000) - sealedControlLength(TIME_AND_KEY, 63_866, 1000)).toBe(1 + 16);
+ });
+});
+
+describe('capsuleLength', () => {
+ const records = listTestdata('fixtures', '.json')
+ .filter((f) => /\/format2_[^/]*\.json$/.test(f) && !f.endsWith('.inspect.json') && !f.endsWith('.dkk.json'))
+ .map((f) => readJSON<{ file: string; public_header: string; control_cbor: string }>(f));
+
+ it('gives the size of each format 2 fixture of the Go reference from its header and its control', () => {
+ expect(records).toHaveLength(7);
+ for (const r of records) {
+ const header = decodeHeader(h(r.public_header));
+ const control = decodeControl(h(r.control_cbor), FORMAT_2);
+ const size = capsuleLength({
+ profileId: header.dateKey.profileId,
+ round: header.dateKey.round,
+ policy: header.policy,
+ length: control.payloadLength!,
+ padding: control.padding!,
+ critical: header.critical,
+ noncritical: header.noncritical,
+ controlCritical: control.critical,
+ controlNoncritical: control.noncritical,
+ });
+ expect(size, r.file).toBe(readBytes(`fixtures/${r.file}`).length);
+ }
+ });
+
+ it('gives the size of what encrypt writes, with its defaults: reforzado and no extensions', async () => {
+ const cases = [
+ { policy: TIME_ONLY, round: 1, length: 0 },
+ { policy: TIME_ONLY, round: 1000, length: 65_536 },
+ { policy: TIME_ONLY, round: 1000, length: 78_000 },
+ { policy: TIME_AND_KEY, round: 1001, length: 41 },
+ { policy: TIME_AND_KEY, round: 83_903_165_811, length: 300_000 },
+ ];
+ for (const c of cases) {
+ const keyed = c.policy === TIME_AND_KEY;
+ const res = await encrypt(new Uint8Array(c.length), {
+ profile: quicknet(),
+ unlockAt: roundAt(c.round),
+ policy: c.policy,
+ now: () => ({ seconds: GENESIS.seconds - 3600, nanos: 0 }),
+ ...(keyed ? { recipients: [x25519PublicKey(newX25519Identity())], newPortableKey: true } : {}),
+ });
+ expect(res.dkc!.length).toBe(res.size);
+ expect(capsuleLength({ profileId: QUICKNET_ID, round: c.round, policy: c.policy, length: c.length }), JSON.stringify(c)).toBe(res.size);
+ }
+ });
+
+ it('follows the padding rule, reforzado by default, which pads some L past 8 KiB more than bloque256', () => {
+ const at = { profileId: QUICKNET_ID, round: 1000, policy: TIME_ONLY };
+ expect(capsuleLength({ ...at, length: 70_000 })).toBe(capsuleLength({ ...at, length: 70_000, padding: REFORZADO }));
+ expect(capsuleLength({ ...at, length: 8192, padding: REFORZADO })).toBe(capsuleLength({ ...at, length: 8192, padding: BLOQUE256 }));
+ // P = 71 680 with reforzado and 70 144 with bloque256, both in two chunks.
+ expect(capsuleLength({ ...at, length: 70_000, padding: REFORZADO }) - capsuleLength({ ...at, length: 70_000, padding: BLOQUE256 })).toBe(1536);
+ });
+
+ it('throws, as the encoders do, for an invalid DateKey, policy or L', () => {
+ const at = { profileId: QUICKNET_ID, round: 1000, policy: TIME_ONLY, length: 1 };
+ expect(() => capsuleLength({ ...at, round: 0 })).toThrow('capsule: invalid DateKey');
+ expect(() => capsuleLength({ ...at, policy: 7 })).toThrow('capsule: unknown access policy 7');
+ expect(() => capsuleLength({ ...at, length: MAX_PAYLOAD_LENGTH + 1 })).toThrow(/^capsule: payload_length \d+ outside 0\.\.L_MAX/);
+ });
+});
diff --git a/src/lib/dkc/lengths.ts b/src/lib/dkc/lengths.ts
new file mode 100644
index 0000000..8ae207c
--- /dev/null
+++ b/src/lib/dkc/lengths.ts
@@ -0,0 +1,78 @@
+// The lengths of a .dkc of format 2 that follow from its inputs, without
+// writing it (spec §62.1, informative note, and §29.1): what a page shows
+// before encrypting, since the size is visible to anyone who holds the file
+// (§55.2), and what the writer checks its seal against. No noble and no
+// age-encryption, so that a page can load it with its first load.
+
+import { ACCESS_SLOTS } from './age.ts';
+import { encodeControl } from './control.ts';
+import type { Extension } from './extension.ts';
+import { DKC_PRELUDE_SIZE, FORMAT_2 } from './framing.ts';
+import { CAPSULE_ID_SIZE, encodeHeader, type Policy, TIME_AND_KEY } from './header.ts';
+import { paddedLength, type Padding, payloadAgeLength, REFORZADO } from './padding.ts';
+
+// The chunks of an age STREAM of n bytes: at least one, even when empty.
+const chunks = (n: number): number => Math.max(1, Math.ceil(n / 65536));
+
+/**
+ * SEALED_CONTROL_LEN from the lengths of spec §62.1 (informative note), with
+ * C the length of CONTROL_CBOR: INNER_ACCESS_AGE holds 16 X25519 stanzas of
+ * 98 bytes, and the tlock stanza of OUTER_TIME_AGE is 249 bytes plus the
+ * digits of the round. The writer checks that the real seal measures exactly
+ * this.
+ */
+export function sealedControlLength(policy: Policy, controlLength: number, round: number): number {
+ const n = policy === TIME_AND_KEY ? 86 + 98 * ACCESS_SLOTS + controlLength + 16 * chunks(controlLength) : controlLength;
+ return 335 + String(round).length + n + 16 * chunks(n);
+}
+
+/** What the size of a .dkc depends on: every input of encrypt but the content and the credentials. */
+export interface CapsuleLengthInput {
+ /** The profile_id of the DateKey, datekeys:quicknet:v1 for Quicknet. */
+ readonly profileId: string;
+ readonly round: number;
+ readonly policy: Policy;
+ /** L, the length of the content. */
+ readonly length: number;
+ /** The padding rule; reforzado when omitted, as in encrypt. */
+ readonly padding?: Padding;
+ readonly critical?: readonly Extension[];
+ readonly noncritical?: readonly Extension[];
+ readonly controlCritical?: readonly Extension[];
+ readonly controlNoncritical?: readonly Extension[];
+}
+
+/**
+ * The exact size of the .dkc that encrypt writes for these inputs: PRELUDE,
+ * PUBLIC_HEADER, SEALED_CONTROL_LEN and the length of PAYLOAD_AGE for P =
+ * rule(L). The random values and the credentials do not change it: a
+ * time_and_key capsule always holds 16 stanzas (§39). It throws, as the
+ * encoders do, for an invalid DateKey, policy, extension or L.
+ */
+export function capsuleLength(input: CapsuleLengthInput): number {
+ const padding = input.padding ?? REFORZADO;
+ const header = encodeHeader({
+ capsuleId: new Uint8Array(CAPSULE_ID_SIZE),
+ dateKey: { profileId: input.profileId, round: input.round },
+ policy: input.policy,
+ critical: input.critical ?? [],
+ noncritical: input.noncritical ?? [],
+ });
+ const control = encodeControl(
+ {
+ headerBinding: new Uint8Array(32),
+ payloadIdentity: new Uint8Array(32),
+ payloadLength: input.length,
+ padding,
+ critical: input.controlCritical ?? [],
+ noncritical: input.controlNoncritical ?? [],
+ },
+ FORMAT_2,
+ );
+ return (
+ DKC_PRELUDE_SIZE +
+ header.length +
+ sealedControlLength(input.policy, control.length, input.round) +
+ payloadAgeLength(paddedLength(input.length, padding))
+ );
+}
diff --git a/src/lib/dkc/writer.ts b/src/lib/dkc/writer.ts
index 9d28bf8..2b1ac08 100644
--- a/src/lib/dkc/writer.ts
+++ b/src/lib/dkc/writer.ts
@@ -24,6 +24,7 @@ import type { Extension } from './extension.ts';
import { DKC_PRELUDE_SIZE, FORMAT_2, headerBinding, MAX_SEALED_CONTROL_LEN, preludeBytes } from './framing.ts';
import { decodeHeader, encodeHeader, type Policy, TIME_AND_KEY, TIME_ONLY } from './header.ts';
import { accessIdentity, payloadIdentity } from './open.ts';
+import { sealedControlLength } from './lengths.ts';
import { isPadding, MAX_PAYLOAD_LENGTH, paddedLength, type Padding, payloadAgeLength, REFORZADO } from './padding.ts';
import { cloneProfile, type Profile, validateProfile } from './profile.ts';
import { permute, type RandomWords } from './random.ts';
@@ -107,18 +108,6 @@ export interface Draws {
}
const CHUNK = 64 << 10;
-const chunks = (n: number): number => Math.max(1, Math.ceil(n / 65536));
-
-/**
- * SEALED_CONTROL_LEN from the lengths of spec §62.1 (informative note), with
- * C the length of CONTROL_CBOR: INNER_ACCESS_AGE holds 16 X25519 stanzas of
- * 98 bytes, and the tlock stanza of OUTER_TIME_AGE is 249 bytes plus the
- * digits of the round. The real seal is checked to measure exactly this.
- */
-export function sealedControlLength(policy: Policy, controlLength: number, round: number): number {
- const n = policy === TIME_AND_KEY ? 86 + 98 * ACCESS_SLOTS + controlLength + 16 * chunks(controlLength) : controlLength;
- return 335 + String(round).length + n + 16 * chunks(n);
-}
/**
* Writes a .dkc of format 2 for the content of `src` with the random values
diff --git a/src/lib/inspector/create-input.test.ts b/src/lib/inspector/create-input.test.ts
new file mode 100644
index 0000000..a434d0d
--- /dev/null
+++ b/src/lib/inspector/create-input.test.ts
@@ -0,0 +1,194 @@
+// Tests of create-input.ts: the form of the create page, checked field by
+// field in its order, and what the page shows before encrypting.
+
+import { describe, expect, it } from 'vitest';
+import { parseDateKey } from '../dkc/datekey.ts';
+import { TIME_AND_KEY, TIME_ONLY } from '../dkc/header.ts';
+import { capsuleLength } from '../dkc/lengths.ts';
+import { MAX_PAYLOAD_LENGTH, paddedLength, REFORZADO } from '../dkc/padding.ts';
+import { formatX25519Recipient } from '../dkc/recipient.ts';
+import { recipientKeys, sampleIdentity } from '../dkc/testing/interop.ts';
+import { x25519PublicKey } from '../dkc/x25519.ts';
+import { type CreateInput, defaultFileNames, downloadName, planCapsule, readRecipients, SOON_MS } from './create-input.ts';
+
+// Round 1000 of Quicknet opens at 2023-08-23T15:59:24Z.
+const ROUND_1000_MS = Date.UTC(2023, 7, 23, 15, 59, 24);
+const GENESIS_MS = Date.UTC(2023, 7, 23, 15, 9, 27);
+const input = (extra: Partial = {}): CreateInput => ({
+ fileSize: 1000,
+ date: '2023-08-23',
+ time: '15:59:24',
+ timeZone: 'UTC',
+ policy: TIME_ONLY,
+ recipients: '',
+ portable: true,
+ ...extra,
+});
+const recipient = (i: number): string => formatX25519Recipient(x25519PublicKey(sampleIdentity(i)));
+const problem = (extra: Partial, nowMs = GENESIS_MS): [string, string] => {
+ const r = planCapsule(input(extra), nowMs);
+ if (r.ok) throw new Error('expected a problem');
+ return [r.field, r.problem];
+};
+
+describe('planCapsule', () => {
+ it('plans a time_only capsule: the round, its time, the dk1_, L, P and the size of the .dkc', () => {
+ const r = planCapsule(input(), GENESIS_MS);
+ expect(r.ok).toBe(true);
+ const p = r.ok ? r.plan : undefined!;
+ expect([p.requestedMs, p.requested]).toEqual([ROUND_1000_MS, { seconds: ROUND_1000_MS / 1000, nanos: 0 }]);
+ expect([p.dateKey, p.effectiveMs, p.effective]).toEqual([
+ { profileId: 'datekeys:quicknet:v1', round: 1000 },
+ ROUND_1000_MS,
+ { seconds: ROUND_1000_MS / 1000, nanos: 0 },
+ ]);
+ expect(parseDateKey(p.dk1)).toEqual(p.dateKey);
+ expect([p.policy, p.recipients, p.portable, p.ambiguous]).toEqual([TIME_ONLY, [], false, false]);
+ expect([p.length, p.paddedLength]).toEqual([1000, paddedLength(1000, REFORZADO)]);
+ expect(p.size).toBe(capsuleLength({ profileId: 'datekeys:quicknet:v1', round: 1000, policy: TIME_ONLY, length: 1000 }));
+ expect(p.names).toEqual({ dkc: 'capsula-20230823T155924Z.dkc', dkk: 'capsula-20230823T155924Z.dkk' });
+ });
+
+ it('opens at the first round at or after the requested instant, to the millisecond', () => {
+ const at = (time: string) => {
+ const r = planCapsule(input({ time }), GENESIS_MS);
+ return r.ok ? [r.plan.dateKey.round, r.plan.effectiveMs - r.plan.requestedMs] : undefined;
+ };
+ expect(at('15:59:24')).toEqual([1000, 0]);
+ expect(at('15:59:24.001')).toEqual([1001, 2999]);
+ expect(at('15:59:26')).toEqual([1001, 1000]);
+ expect(at('15:59:27')).toEqual([1001, 0]);
+ });
+
+ it('flags a date more than 365 days away, and one less than an hour away', () => {
+ const plan = (nowMs: number) => {
+ const r = planCapsule(input(), nowMs);
+ return r.ok ? [r.plan.longHorizon, r.plan.soon] : undefined;
+ };
+ expect(plan(ROUND_1000_MS - SOON_MS)).toEqual([false, false]);
+ expect(plan(ROUND_1000_MS - SOON_MS + 1)).toEqual([false, true]);
+ expect(plan(ROUND_1000_MS - 1)).toEqual([false, true]);
+ const far = (date: string) => {
+ const r = planCapsule(input({ date, time: '00:00' }), Date.UTC(2030, 0, 1));
+ return r.ok ? r.plan.longHorizon : undefined;
+ };
+ expect(far('2031-01-01')).toBe(false);
+ expect(far('2031-01-02')).toBe(true);
+ });
+
+ it('keeps the later instant of a repeated local time, and says so', () => {
+ const r = planCapsule(input({ date: '2030-10-27', time: '02:30', timeZone: 'Europe/Madrid' }), GENESIS_MS);
+ expect(r.ok && [r.plan.requestedMs, r.plan.ambiguous]).toEqual([Date.UTC(2030, 9, 27, 1, 30), true]);
+ });
+
+ it('checks the fields in the order of the form, the first problem only', () => {
+ expect(problem({ fileSize: undefined, date: '' })).toEqual(['file', 'Elige el fichero que guardará la cápsula.']);
+ expect(problem({ fileSize: MAX_PAYLOAD_LENGTH + 1 })[0]).toBe('file');
+ expect(problem({ date: '', time: '' })).toEqual(['date', 'Elige el día de apertura.']);
+ expect(problem({ time: '' })).toEqual(['time', 'Elige la hora de apertura.']);
+ expect(problem({ date: '2023-02-30' })).toEqual(['date', 'La fecha o la hora no son válidas.']);
+ expect(problem({ timeZone: 'Mars/Olympus' })).toEqual(['zone', 'Este navegador no conoce esa zona horaria.']);
+ expect(problem({ date: '2030-03-31', time: '02:30', timeZone: 'Europe/Madrid' })).toEqual([
+ 'time',
+ 'Esa hora no existe en Europe/Madrid: ese día los relojes se adelantan y se la saltan. Elige otra.',
+ ]);
+ });
+
+ it('asks for a date after the clock of the device, and not after the last round of Quicknet', () => {
+ const past = ['date', 'Esa fecha ya ha pasado según el reloj de este dispositivo.'];
+ expect(problem({}, ROUND_1000_MS)).toEqual(past);
+ expect(problem({}, ROUND_1000_MS + 1)).toEqual(past);
+ expect(planCapsule(input(), ROUND_1000_MS - 1).ok).toBe(true);
+ const last = planCapsule(input({ date: '9999-12-31', time: '23:59:57' }), GENESIS_MS);
+ expect(last.ok && last.plan.dateKey.round).toBe(83_903_165_811);
+ expect(problem({ date: '9999-12-31', time: '23:59:58' })).toEqual([
+ 'date',
+ 'La fecha más lejana posible es el 31 de diciembre de 9999 a las 23:59:57 UTC, la última ronda de Quicknet.',
+ ]);
+ // A clock of the device before Quicknet began lets through a date that no round opens.
+ const early = [
+ 'date',
+ 'Esa fecha es anterior al comienzo de Quicknet, la red de drand que usa DateKeys, el 23 de agosto de 2023 a las 15:09:27 UTC: ninguna ronda la abre. Si para ti es una fecha futura, el reloj de este dispositivo va atrasado.',
+ ];
+ expect(problem({ date: '2022-06-01', time: '12:00' }, Date.UTC(2021, 0, 1))).toEqual(early);
+ expect(problem({ date: '2023-08-23', time: '15:09:26.999' }, Date.UTC(2021, 0, 1))).toEqual(early);
+ const first = planCapsule(input({ date: '2023-08-23', time: '15:09:27' }), Date.UTC(2021, 0, 1));
+ expect(first.ok && first.plan.dateKey.round).toBe(1);
+ });
+
+ it('reads the clock to the millisecond for the horizon of 365 days', () => {
+ const horizon = (nowMs: number) => {
+ const r = planCapsule(input(), nowMs);
+ return r.ok ? r.plan.longHorizon : undefined;
+ };
+ const year = 365 * 86_400_000;
+ expect(horizon(ROUND_1000_MS - year - 1)).toBe(true);
+ expect(horizon(ROUND_1000_MS - year)).toBe(false);
+ expect(horizon(ROUND_1000_MS - year + 1)).toBe(false);
+ expect(horizon(ROUND_1000_MS - year - 500)).toBe(true);
+ });
+
+ it('plans a time_and_key capsule with its recipients and its portable key, 16 credentials at most', () => {
+ const lines = (n: number, from = 0) => Array.from({ length: n }, (_, i) => recipient(from + i)).join('\n');
+ const keyed = (recipients: string, portable: boolean) => planCapsule(input({ policy: TIME_AND_KEY, recipients, portable }), GENESIS_MS);
+ const ok = keyed(`# the team\n\n ${recipient(1)} \r\n${recipient(2)}\n`, true);
+ expect(ok.ok && [ok.plan.policy, ok.plan.recipients, ok.plan.portable]).toEqual([
+ TIME_AND_KEY,
+ [x25519PublicKey(sampleIdentity(1)), x25519PublicKey(sampleIdentity(2))],
+ true,
+ ]);
+ expect(ok.ok && ok.plan.size).toBe(capsuleLength({ profileId: 'datekeys:quicknet:v1', round: 1000, policy: TIME_AND_KEY, length: 1000 }));
+ expect(keyed('', true).ok).toBe(true);
+ expect(keyed(lines(15), true).ok).toBe(true);
+ expect(keyed(lines(16), false).ok).toBe(true);
+ const many = `Una cápsula admite como mucho 16 credenciales: 15 destinatarios con la clave portable, o 16 sin ella.`;
+ expect(problem({ policy: TIME_AND_KEY, recipients: lines(16), portable: true })).toEqual(['recipients', `${many} Hay 16 destinatarios.`]);
+ expect(problem({ policy: TIME_AND_KEY, recipients: lines(17), portable: false })).toEqual(['recipients', `${many} Hay 17 destinatarios.`]);
+ expect(problem({ policy: TIME_AND_KEY, recipients: `${recipient(1)}\nage1nope`, portable: true })).toEqual([
+ 'recipients',
+ 'La línea 2 no es un destinatario de age (age1…).',
+ ]);
+ expect(problem({ policy: TIME_AND_KEY, recipients: '# nobody', portable: false })).toEqual([
+ 'portable',
+ 'Sin destinatarios, la clave portable es la única credencial de la cápsula: déjala marcada o añade un destinatario.',
+ ]);
+ // time_only ignores both.
+ const plain = planCapsule(input({ recipients: 'not a recipient', portable: true }), GENESIS_MS);
+ expect(plain.ok && [plain.plan.recipients, plain.plan.portable]).toEqual([[], false]);
+ });
+});
+
+describe('readRecipients', () => {
+ it('names the first bad line by its number and never its content', () => {
+ const [valid, , high, p, , , zero] = recipientKeys();
+ const good = formatX25519Recipient(valid!);
+ const line = (text: string) => {
+ const r = readRecipients(text);
+ return r.ok ? r.keys.length : r.problem;
+ };
+ expect(line(`${good}\n${recipient(3)}`)).toBe(2);
+ expect(line(`${good}\nage1nope`)).toBe('La línea 2 no es un destinatario de age (age1…).');
+ expect(line('AGE-SECRET-KEY-1GFPYYSJZGFPYYSJZGFPYYSJZGFPYYSJZGFPYYSJZGFPYYSJZGFPQ4EGAEX')).toBe(
+ 'La línea 1 es una identidad secreta (AGE-SECRET-KEY-1…), no un destinatario: bórrala de aquí y no la compartas.',
+ );
+ expect(line(`\n${formatX25519Recipient(high!)}`)).toBe('La línea 2 no es una clave X25519 canónica: nadie podría abrir su parte de la cápsula.');
+ expect(line(formatX25519Recipient(p!))).toBe('La línea 1 no es una clave X25519 canónica: nadie podría abrir su parte de la cápsula.');
+ expect(line(formatX25519Recipient(zero!))).toBe('La línea 1 es una clave X25519 de orden bajo, que no protege nada.');
+ expect(line(`${good}\n# again\n${good}`)).toBe('La línea 3 repite un destinatario de una línea anterior.');
+ });
+});
+
+describe('the names of the files', () => {
+ it('offers capsula-, which is public in the DateKey', () => {
+ expect(defaultFileNames(Date.UTC(2031, 0, 2, 3, 4, 5))).toEqual({ dkc: 'capsula-20310102T030405Z.dkc', dkk: 'capsula-20310102T030405Z.dkk' });
+ });
+
+ it('saves under the name written, printable, without directories and with its extension', () => {
+ expect(downloadName(' regalo.dkc ', '.dkc', 'x.dkc')).toBe('regalo.dkc');
+ expect(downloadName('regalo.DKC', '.dkc', 'x.dkc')).toBe('regalo.DKC');
+ expect(downloadName('regalo', '.dkk', 'x.dkk')).toBe('regalo.dkk');
+ expect(downloadName('../a/b\\c', '.dkc', 'x.dkc')).toBe('.._a_b_c.dkc');
+ expect(downloadName('evilcod.dkc', '.dkc', 'x.dkc')).toBe('evil_cod.dkc');
+ expect(downloadName(' ', '.dkc', 'x.dkc')).toBe('x.dkc');
+ });
+});
diff --git a/src/lib/inspector/create-input.ts b/src/lib/inspector/create-input.ts
new file mode 100644
index 0000000..aea4c0d
--- /dev/null
+++ b/src/lib/inspector/create-input.ts
@@ -0,0 +1,200 @@
+// The form of the create page (plan of phase 3, section 9, as decided in
+// step 6), without DOM, clock or writer: what the person asked for, checked
+// field by field in the order of the form, and everything the page shows
+// before encrypting. Its imports carry no noble and no age-encryption, so the
+// page loads it with its first load and checks the recipients as they are
+// typed.
+
+import { ACCESS_SLOTS } from '../dkc/age.ts';
+import { compactDateKey, type DateKey, type Instant, isLongHorizon, resolveDateKey, roundTime } from '../dkc/datekey.ts';
+import { type Policy, TIME_AND_KEY } from '../dkc/header.ts';
+import { capsuleLength } from '../dkc/lengths.ts';
+import { MAX_PAYLOAD_LENGTH, paddedLength, REFORZADO } from '../dkc/padding.ts';
+import { quicknet } from '../dkc/profile.ts';
+import { parseRecipientList, type RecipientLineProblem, RecipientListError } from '../dkc/recipient.ts';
+import { formatByteCount, formatInteger, safeFileName } from './format.ts';
+import { localToEpochMs } from './localtime.ts';
+
+/** An effective unlock time closer than this gets a notice: the capsule opens almost at once. */
+export const SOON_MS = 3600_000;
+
+/** The inputs of the form. */
+export type CreateField = 'file' | 'date' | 'time' | 'zone' | 'recipients' | 'portable';
+
+/** What the person entered. */
+export interface CreateInput {
+ /** L, the size of the chosen file; undefined while there is none. */
+ readonly fileSize: number | undefined;
+ /** The values of and . */
+ readonly date: string;
+ readonly time: string;
+ /** The zone of the date and time, an IANA name or UTC. */
+ readonly timeZone: string;
+ /** TIME_ONLY or TIME_AND_KEY. */
+ readonly policy: Policy;
+ /** For time_and_key: the recipients, age1… one per line, and whether to generate a portable .dkk. */
+ readonly recipients: string;
+ readonly portable: boolean;
+}
+
+/** Everything the page shows before encrypting, and what encrypt takes. */
+export interface CapsulePlan {
+ /** The requested instant, the one the person picked. */
+ readonly requested: Instant;
+ readonly requestedMs: number;
+ /** The zone repeats the local time, and the later of its two instants was taken. */
+ readonly ambiguous: boolean;
+ readonly dateKey: DateKey;
+ /** The DateKey as the dk1_ string (§17). */
+ readonly dk1: string;
+ /** The effective unlock time: the time of the round, at or after the requested instant (§15). */
+ readonly effective: Instant;
+ readonly effectiveMs: number;
+ readonly policy: Policy;
+ /** The raw X25519 public keys of the recipients. */
+ readonly recipients: readonly Uint8Array[];
+ readonly portable: boolean;
+ /** L, and P = reforzado(L), the rule the page always uses. */
+ readonly length: number;
+ readonly paddedLength: number;
+ /** The size of the .dkc. */
+ readonly size: number;
+ /** The effective time is more than 365 days away: the warnings of §53 and §50. */
+ readonly longHorizon: boolean;
+ /** The effective time is less than SOON_MS away. */
+ readonly soon: boolean;
+ /** The names offered for the files. */
+ readonly names: { readonly dkc: string; readonly dkk: string };
+}
+
+export type Planned =
+ | { readonly ok: true; readonly plan: CapsulePlan }
+ | { readonly ok: false; readonly field: CreateField; readonly problem: string };
+
+const LINE_PROBLEMS: Readonly> = {
+ malformed: 'no es un destinatario de age (age1…).',
+ identity: 'es una identidad secreta (AGE-SECRET-KEY-1…), no un destinatario: bórrala de aquí y no la compartas.',
+ 'not canonical': 'no es una clave X25519 canónica: nadie podría abrir su parte de la cápsula.',
+ 'low order': 'es una clave X25519 de orden bajo, que no protege nada.',
+ duplicate: 'repite un destinatario de una línea anterior.',
+};
+
+/**
+ * The recipients of the text of the form, or the problem of its first bad
+ * line, by number and never by content (recipient.ts).
+ */
+export function readRecipients(text: string): { ok: true; keys: Uint8Array[] } | { ok: false; problem: string } {
+ try {
+ return { ok: true, keys: parseRecipientList(text) };
+ } catch (err) {
+ const e = err as RecipientListError;
+ return { ok: false, problem: `La línea ${e.line} ${LINE_PROBLEMS[e.problem]}` };
+ }
+}
+
+/**
+ * The capsule that the form asks for at `nowMs`, or the first problem, in
+ * the order of the form. The requested instant must be after `nowMs` (§62.1,
+ * rule 2), and the page checks it again with the clock when encrypting.
+ */
+export function planCapsule(input: CreateInput, nowMs: number): Planned {
+ const fail = (field: CreateField, problem: string): Planned => ({ ok: false, field, problem });
+ if (input.fileSize === undefined) return fail('file', 'Elige el fichero que guardará la cápsula.');
+ if (input.fileSize > MAX_PAYLOAD_LENGTH) return fail('file', `El fichero ocupa más de ${formatByteCount(MAX_PAYLOAD_LENGTH)}, el máximo de una cápsula (§29.1).`);
+ if (input.date === '') return fail('date', 'Elige el día de apertura.');
+ if (input.time === '') return fail('time', 'Elige la hora de apertura.');
+ const local = localToEpochMs(input.date, input.time, input.timeZone);
+ if (!local.ok) {
+ if (local.reason === 'zone') return fail('zone', 'Este navegador no conoce esa zona horaria.');
+ if (local.reason === 'nonexistent') {
+ return fail('time', `Esa hora no existe en ${input.timeZone}: ese día los relojes se adelantan y se la saltan. Elige otra.`);
+ }
+ return fail('date', 'La fecha o la hora no son válidas.');
+ }
+ const requestedMs = local.epochMs;
+ if (requestedMs <= nowMs) return fail('date', 'Esa fecha ya ha pasado según el reloj de este dispositivo.');
+ const seconds = Math.floor(requestedMs / 1000);
+ const requested: Instant = { seconds, nanos: (requestedMs - seconds * 1000) * 1e6 };
+ const p = quicknet();
+ // After the clock of the device and before Quicknet began: that clock is
+ // behind.
+ if (seconds < p.genesisTime) {
+ return fail(
+ 'date',
+ 'Esa fecha es anterior al comienzo de Quicknet, la red de drand que usa DateKeys, el 23 de agosto de 2023 a las 15:09:27 UTC: ninguna ronda la abre. Si para ti es una fecha futura, el reloj de este dispositivo va atrasado.',
+ );
+ }
+ let dateKey: DateKey;
+ try {
+ dateKey = resolveDateKey(p, requested);
+ } catch {
+ return fail('date', 'La fecha más lejana posible es el 31 de diciembre de 9999 a las 23:59:57 UTC, la última ronda de Quicknet.');
+ }
+
+ const policy = input.policy;
+ let recipients: Uint8Array[] = [];
+ let portable = false;
+ if (policy === TIME_AND_KEY) {
+ const read = readRecipients(input.recipients);
+ if (!read.ok) return fail('recipients', read.problem);
+ recipients = read.keys;
+ portable = input.portable;
+ if (recipients.length === 0 && !portable) {
+ return fail('portable', 'Sin destinatarios, la clave portable es la única credencial de la cápsula: déjala marcada o añade un destinatario.');
+ }
+ if (recipients.length + (portable ? 1 : 0) > ACCESS_SLOTS) {
+ return fail(
+ 'recipients',
+ `Una cápsula admite como mucho ${ACCESS_SLOTS} credenciales: ${ACCESS_SLOTS - 1} destinatarios con la clave portable, o ${ACCESS_SLOTS} sin ella. Hay ${formatInteger(recipients.length)} destinatarios.`,
+ );
+ }
+ }
+
+ const effective = roundTime(p, dateKey.round);
+ const effectiveMs = effective.seconds * 1000;
+ const now: Instant = { seconds: Math.floor(nowMs / 1000), nanos: (nowMs % 1000) * 1e6 };
+ const length = input.fileSize;
+ return {
+ ok: true,
+ plan: {
+ requested,
+ requestedMs,
+ ambiguous: local.ambiguous,
+ dateKey,
+ dk1: compactDateKey(dateKey),
+ effective,
+ effectiveMs,
+ policy,
+ recipients,
+ portable,
+ length,
+ paddedLength: paddedLength(length, REFORZADO),
+ size: capsuleLength({ profileId: dateKey.profileId, round: dateKey.round, policy, length }),
+ longHorizon: isLongHorizon(effective, now),
+ soon: effectiveMs - nowMs < SOON_MS,
+ names: defaultFileNames(effectiveMs),
+ },
+ };
+}
+
+/**
+ * The names offered for the files of a capsule that opens at `effectiveMs`:
+ * capsula-.dkc and .dkk. The date is already
+ * public in the DateKey, and says nothing of when the capsule was made.
+ */
+export function defaultFileNames(effectiveMs: number): { dkc: string; dkk: string } {
+ const stamp = new Date(effectiveMs).toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[-:]/g, '');
+ return { dkc: `capsula-${stamp}.dkc`, dkk: `capsula-${stamp}.dkk` };
+}
+
+/**
+ * The name to save a file under, from what the person wrote: its characters
+ * that are not printable replaced (safeFileName), no directory separators,
+ * and the extension `ext` added when it lacks it. An empty name takes
+ * `fallback`.
+ */
+export function downloadName(name: string, ext: string, fallback: string): string {
+ const base = safeFileName(name.trim()).replace(/[/\\]/g, '_');
+ if (base === '') return fallback;
+ return base.toLowerCase().endsWith(ext) ? base : `${base}${ext}`;
+}
diff --git a/src/lib/inspector/creator.test.ts b/src/lib/inspector/creator.test.ts
new file mode 100644
index 0000000..95dbaaa
--- /dev/null
+++ b/src/lib/inspector/creator.test.ts
@@ -0,0 +1,195 @@
+// Tests of creator.ts: the writing of the create page, in memory and into a
+// temporary file, with the person's cancellation, the room of the browser and
+// the checks of what it wrote against what the page showed. What it writes
+// opens. encodeAccessKey is wrapped to keep the .dkk it encodes, so that its
+// wiping on a failure can be seen.
+
+import { describe, expect, it, vi } from 'vitest';
+import { decodeAccessKey } from '../dkc/accesskey.ts';
+import { concatBytes, toHex } from '../dkc/bytes.ts';
+import { TIME_AND_KEY } from '../dkc/header.ts';
+import { open } from '../dkc/open.ts';
+import { suppliedRelease } from '../dkc/release.ts';
+import { h, readJSON } from '../dkc/testing/testdata.ts';
+import { type CapsulePlan, type CreateInput, planCapsule } from './create-input.ts';
+import { createCapsule, CreateStopped } from './creator.ts';
+import type { TempFile } from './tempfile.ts';
+
+const encoded = vi.hoisted(() => [] as Uint8Array[]);
+vi.mock('../dkc/index.ts', async (importOriginal) => {
+ const mod = await importOriginal();
+ return {
+ ...mod,
+ encodeAccessKey: (k: Parameters[0]): Uint8Array => {
+ const b = mod.encodeAccessKey(k);
+ encoded.push(b);
+ return b;
+ },
+ };
+});
+const wiped = (b: Uint8Array | undefined): boolean => b !== undefined && b.length > 0 && b.every((x) => x === 0);
+
+const GENESIS_MS = Date.UTC(2023, 7, 23, 15, 9, 27);
+const genesis = () => ({ seconds: GENESIS_MS / 1000, nanos: 0 });
+const RELEASE = (() => {
+ const r = readJSON<{ release: { round: number; signature: string } }>('fixtures/time_only.json').release;
+ return { round: r.round, signature: h(r.signature) };
+})();
+
+// A plan for round 1000, whose release is published.
+function plan(extra: Partial = {}, nowMs = GENESIS_MS): CapsulePlan {
+ const r = planCapsule(
+ { fileSize: 0, date: '2023-08-23', time: '15:59:24', timeZone: 'UTC', policy: 0, recipients: '', portable: true, ...extra },
+ nowMs,
+ );
+ if (!r.ok) throw new Error(r.problem);
+ return r.plan;
+}
+
+function content(n: number): Uint8Array {
+ return Uint8Array.from({ length: n }, (_, i) => (i * 7 + 3) & 0xff);
+}
+
+// A temporary file of the page, in memory; `read` makes what file() gives
+// from what was written.
+function temp(read = async (b: Uint8Array): Promise => new File([b as Uint8Array], 'capsule')): TempFile & {
+ chunks: Uint8Array[];
+ state: { closed: boolean; aborted: unknown };
+} {
+ const chunks: Uint8Array[] = [];
+ const state: { closed: boolean; aborted: unknown } = { closed: false, aborted: undefined };
+ return {
+ writable: new WritableStream({
+ write: (c) => void chunks.push(c.slice()),
+ close: () => void (state.closed = true),
+ abort: (reason) => void (state.aborted = reason ?? 'aborted'),
+ }),
+ file: () => read(concatBytes(...chunks)),
+ remove: async () => undefined,
+ chunks,
+ state,
+ };
+}
+
+async function failure(p: Promise): Promise {
+ try {
+ await p;
+ } catch (err) {
+ return err as Error;
+ }
+ throw new Error('expected a failure');
+}
+
+async function opened(capsule: Blob, extra: { accessKeyFile?: Uint8Array } = {}): Promise {
+ const r = await open(capsule, { source: suppliedRelease(RELEASE), now: () => ({ seconds: RELEASE.round * 3 + GENESIS_MS / 1000, nanos: 0 }), ...extra });
+ expect(r.error).toBeUndefined();
+ return r.plaintext;
+}
+
+describe('createCapsule', () => {
+ it('writes a time_only capsule in memory, of the size planned, that inspect accepts and open opens', async () => {
+ const body = content(5000);
+ const p = plan({ fileSize: body.length });
+ const progress: [number, number][] = [];
+ const c = await createCapsule({ file: new Blob([body as Uint8Array]), plan: p, cancelled: () => false, now: genesis, progress: (w, t) => void progress.push([w, t]) });
+ expect([c.capsule.size, c.dkk, c.inspection.error]).toEqual([p.size, undefined, undefined]);
+ expect(c.capsuleId).toBe(toHex(c.inspection.header!.capsuleId));
+ expect(c.inspection.header!.dateKey.round).toBe(1000);
+ expect(c.bytes.length).toBeLessThanOrEqual(p.size);
+ expect([progress[0], progress.at(-1)]).toEqual([
+ [0, p.size],
+ [p.size, p.size],
+ ]);
+ expect(c.ms).toBeGreaterThan(0);
+ expect(await opened(c.capsule)).toEqual(body);
+ });
+
+ it('writes a time_and_key capsule into the temporary file, closed, with a .dkk that opens it', async () => {
+ const body = content(70_000);
+ const p = plan({ fileSize: body.length, policy: TIME_AND_KEY, portable: true });
+ const out = temp();
+ const c = await createCapsule({ file: new Blob([body as Uint8Array]), plan: p, output: out, room: p.size, cancelled: () => false, now: genesis });
+ expect([out.state.closed, out.state.aborted, c.capsule.size]).toEqual([true, undefined, p.size]);
+ expect(c.capsule).toBeInstanceOf(File);
+ expect(decodeAccessKey(c.dkk!).capsuleId).toEqual(c.inspection.header!.capsuleId);
+ expect([c.dkk, wiped(c.dkk)]).toEqual([encoded.at(-1), false]);
+ expect(await opened(c.capsule, { accessKeyFile: c.dkk!.slice() })).toEqual(body);
+ });
+
+ it('writes with the clock of the system when given none, and without a progress callback', async () => {
+ const at = new Date(Date.now() + 2 * 3600_000).toISOString();
+ const p = plan({ fileSize: 3, date: at.slice(0, 10), time: at.slice(11, 19) }, Date.now());
+ const c = await createCapsule({ file: new Blob([new Uint8Array(3)]), plan: p, cancelled: () => false });
+ expect([c.capsule.size, c.inspection.header!.dateKey.round]).toEqual([p.size, p.dateKey.round]);
+ });
+
+ it('stops when the person cancels: before writing, or after the piece in progress, the output aborted', async () => {
+ const p = plan({ fileSize: 300_000 });
+ const out = temp();
+ const err = await failure(createCapsule({ file: new Blob([content(300_000) as Uint8Array]), plan: p, output: out, cancelled: () => true, now: genesis }));
+ expect(err).toBeInstanceOf(CreateStopped);
+ expect([(err as CreateStopped).reason, (err as CreateStopped).total, err.message]).toEqual(['cancelled', p.size, 'create: cancelled']);
+ expect([out.chunks.length, out.state.closed, out.state.aborted]).toEqual([0, false, err]);
+
+ let calls = 0;
+ const out2 = temp();
+ const err2 = await failure(
+ createCapsule({ file: new Blob([content(300_000) as Uint8Array]), plan: p, output: out2, cancelled: () => ++calls > 5, now: genesis }),
+ );
+ expect((err2 as CreateStopped).reason).toBe('cancelled');
+ expect(out2.chunks.length).toBeGreaterThan(0);
+ expect([out2.state.closed, out2.state.aborted]).toEqual([false, err2]);
+ });
+
+ it('writes nothing when the .dkc does not fit in the room of the browser', async () => {
+ const p = plan({ fileSize: 10 });
+ const out = temp();
+ const err = await failure(createCapsule({ file: new Blob([new Uint8Array(10)]), plan: p, output: out, room: p.size - 1, cancelled: () => false, now: genesis }));
+ expect([(err as CreateStopped).reason, (err as CreateStopped).total, err.message]).toEqual(['room', p.size, `create: the .dkc of ${p.size} bytes does not fit`]);
+ expect([out.chunks.length, out.state.aborted]).toEqual([0, err]);
+ });
+
+ it('refuses a capsule of another size or round than the page showed, and wipes its .dkk', async () => {
+ const p = plan({ fileSize: 10 });
+ const bigger = { ...p, size: p.size + 1 };
+ expect((await failure(createCapsule({ file: new Blob([new Uint8Array(10)]), plan: bigger, cancelled: () => false, now: genesis }))).message).toBe(
+ `create: internal error: wrote ${p.size} bytes for round 1000, planned ${p.size + 1} for round 1000`,
+ );
+ const keyed = plan({ fileSize: 10, policy: TIME_AND_KEY });
+ const other = { ...keyed, dateKey: { ...keyed.dateKey, round: 999 } };
+ expect((await failure(createCapsule({ file: new Blob([new Uint8Array(10)]), plan: other, cancelled: () => false, now: genesis }))).message).toBe(
+ `create: internal error: wrote ${keyed.size} bytes for round 1000, planned ${keyed.size} for round 999`,
+ );
+ expect(wiped(encoded.at(-1))).toBe(true);
+ });
+
+ it('does not offer a .dkc whose file is not what was written or that inspect rejects, and wipes its .dkk', async () => {
+ const keyed = plan({ fileSize: 10, policy: TIME_AND_KEY });
+ const make = (out: TempFile) => createCapsule({ file: new Blob([new Uint8Array(10)]), plan: keyed, output: out, cancelled: () => false, now: genesis });
+
+ const shorter = temp(async (b) => new File([b.subarray(1) as Uint8Array], 'capsule'));
+ expect((await failure(make(shorter))).message).toBe(`create: internal error: the .dkc written is ${keyed.size - 1} bytes, planned ${keyed.size}`);
+ expect(wiped(encoded.at(-1))).toBe(true);
+
+ // The first byte of PUBLIC_HEADER changed: step 4 does not decode it.
+ const changed = temp(async (b) => {
+ const c = b.slice();
+ c[16]! ^= 0xff;
+ return new File([c as Uint8Array], 'capsule');
+ });
+ expect((await failure(make(changed))).message).toMatch(/^create: internal error: the \.dkc written fails step 4: capsule: PUBLIC_HEADER: /);
+ expect(wiped(encoded.at(-1))).toBe(true);
+
+ const gone = new Error('NotFoundError: the file is gone');
+ expect(await failure(make(temp(() => Promise.reject(gone))))).toBe(gone);
+ expect(wiped(encoded.at(-1))).toBe(true);
+ });
+
+ it('rethrows the errors of encrypt as they are', async () => {
+ const p = plan({ fileSize: 10 });
+ const late = () => ({ seconds: p.requested.seconds, nanos: 0 });
+ expect((await failure(createCapsule({ file: new Blob([new Uint8Array(10)]), plan: p, cancelled: () => false, now: late }))).message).toBe(
+ 'capsule: unlock time 2023-08-23T15:59:24Z is not in the future',
+ );
+ });
+});
diff --git a/src/lib/inspector/creator.ts b/src/lib/inspector/creator.ts
new file mode 100644
index 0000000..4c1ebc1
--- /dev/null
+++ b/src/lib/inspector/creator.ts
@@ -0,0 +1,105 @@
+// The writing of a capsule, which the create page loads on demand with a
+// dynamic import: it carries encrypt.ts and with it noble and
+// age-encryption, which the first load of the page does not need (plan of
+// phase 3, section 9). Nothing goes to the network: the round is resolved in
+// the browser, and tlock uses only the pinned public key (§35).
+
+import { encodeAccessKey, type Inspection, type Instant, inspect, quicknet, readCapsule, toHex, wipeAccessKey } from '../dkc/index.ts';
+import { encrypt } from '../dkc/encrypt.ts';
+import type { CapsulePlan } from './create-input.ts';
+import { systemClock } from './opener.ts';
+import type { TempFile } from './tempfile.ts';
+
+export interface CreateRequest {
+ /** The person's file: its L bytes are the content. */
+ readonly file: Blob;
+ /** What the form asked for, checked by planCapsule. */
+ readonly plan: CapsulePlan;
+ /** Where the .dkc goes; memory when omitted. */
+ readonly output?: TempFile;
+ /** The bytes the .dkc may take, the free space that the browser reports; no limit when omitted. */
+ readonly room?: number;
+ /** Whether the person cancelled: the writing stops after the piece in progress. */
+ readonly cancelled: () => boolean;
+ /** Called with (0, total) before the first write, then after each piece. */
+ readonly progress?: (written: number, total: number) => void;
+ /** The clock; the system clock when omitted. It must still be before the requested instant. */
+ readonly now?: () => Instant;
+}
+
+/** A capsule written and checked. */
+export interface Created {
+ /** The .dkc: the temporary file, or a Blob in memory. */
+ readonly capsule: Blob;
+ /** The encoded .dkk, when the plan asked for a portable key: the caller wipes it. */
+ readonly dkk?: Uint8Array;
+ readonly capsuleId: string;
+ /** Steps 1 to 8 of the .dkc written, and the bytes of the .dkc that they read. */
+ readonly inspection: Inspection;
+ readonly bytes: Uint8Array;
+ /** How long encrypt took, in milliseconds. */
+ readonly ms: number;
+}
+
+/** The writing stopped because the person cancelled it, or before writing anything because the .dkc did not fit in `room`. */
+export class CreateStopped extends Error {
+ readonly reason: 'cancelled' | 'room';
+ /** The size of the .dkc. */
+ readonly total: number;
+
+ constructor(reason: 'cancelled' | 'room', total: number) {
+ super(reason === 'cancelled' ? 'create: cancelled' : `create: the .dkc of ${total} bytes does not fit`);
+ this.name = 'CreateStopped';
+ this.reason = reason;
+ this.total = total;
+ }
+}
+
+/**
+ * Writes the capsule of `req.plan` with the content of `req.file`, and
+ * inspects what it wrote. The output is closed only once the capsule is
+ * complete and checked, and aborted on any failure, a cancellation
+ * included; the errors of encrypt are rethrown as they are.
+ */
+export async function createCapsule(req: CreateRequest): Promise {
+ const { plan } = req;
+ const start = performance.now();
+ const res = await encrypt(req.file, {
+ profile: quicknet(),
+ unlockAt: plan.requested,
+ policy: plan.policy,
+ recipients: plan.recipients,
+ newPortableKey: plan.portable,
+ now: req.now ?? systemClock,
+ ...(req.output === undefined ? {} : { output: req.output.writable }),
+ progress: (written, total) => {
+ if (req.cancelled()) throw new CreateStopped('cancelled', total);
+ if (written === 0 && req.room !== undefined && total > req.room) throw new CreateStopped('room', total);
+ req.progress?.(written, total);
+ },
+ });
+ const ms = performance.now() - start;
+ let dkk: Uint8Array | undefined;
+ if (res.portableKey !== undefined) {
+ dkk = encodeAccessKey(res.portableKey);
+ wipeAccessKey(res.portableKey);
+ }
+ // A capsule that is not what the page showed, or that steps 1 to 8 reject,
+ // is not offered (§62.1, rule 9); its .dkk is wiped on any failure.
+ try {
+ if (res.size !== plan.size || res.dateKey.round !== plan.dateKey.round) {
+ throw new Error(`create: internal error: wrote ${res.size} bytes for round ${res.dateKey.round}, planned ${plan.size} for round ${plan.dateKey.round}`);
+ }
+ const capsule = req.output === undefined ? new Blob([res.dkc! as Uint8Array]) : await req.output.file();
+ if (capsule.size !== plan.size) throw new Error(`create: internal error: the .dkc written is ${capsule.size} bytes, planned ${plan.size}`);
+ const { bytes } = await readCapsule(capsule);
+ const inspection = await inspect(bytes);
+ if (inspection.error !== undefined) {
+ throw new Error(`create: internal error: the .dkc written fails step ${inspection.checks.at(-1)!.step}: ${inspection.error.message}`);
+ }
+ return { capsule, ...(dkk === undefined ? {} : { dkk }), capsuleId: toHex(res.capsuleId), inspection, bytes, ms };
+ } catch (err) {
+ dkk?.fill(0);
+ throw err;
+ }
+}
diff --git a/src/lib/inspector/localtime.test.ts b/src/lib/inspector/localtime.test.ts
new file mode 100644
index 0000000..5af14f7
--- /dev/null
+++ b/src/lib/inspector/localtime.test.ts
@@ -0,0 +1,159 @@
+// Tests of localtime.ts: local dates and times in a zone as the UTC instant
+// they stand for, with the zones that skip or repeat an hour or half an hour.
+
+import { afterEach, describe, expect, it } from 'vitest';
+import { isTimeZone, localParts, localToEpochMs, parseLocal, supportedTimeZones, timeZoneList, UTC } from './localtime.ts';
+
+const iso = (date: string, time: string, zone: string): string => {
+ const r = localToEpochMs(date, time, zone);
+ return r.ok ? `${new Date(r.epochMs).toISOString()}${r.ambiguous ? ' ambiguous' : ''}` : r.reason;
+};
+
+describe('localToEpochMs', () => {
+ it('gives the instant of a date and time in a zone, with its offset of that day', () => {
+ expect(iso('2030-01-01', '00:00', UTC)).toBe('2030-01-01T00:00:00.000Z');
+ expect(iso('2030-01-15', '12:00', 'Europe/Madrid')).toBe('2030-01-15T11:00:00.000Z');
+ expect(iso('2030-07-15', '12:00', 'Europe/Madrid')).toBe('2030-07-15T10:00:00.000Z');
+ expect(iso('2030-01-01', '00:00', 'Asia/Kolkata')).toBe('2029-12-31T18:30:00.000Z');
+ // The widest offsets there are, +14 and -12.
+ expect(iso('2030-01-01', '00:00', 'Pacific/Kiritimati')).toBe('2029-12-31T10:00:00.000Z');
+ expect(iso('2030-01-01', '00:00', 'Etc/GMT+12')).toBe('2030-01-01T12:00:00.000Z');
+ // Up to the last round of Quicknet, with a zone ahead of UTC.
+ expect(iso('9999-12-31', '23:59:57', UTC)).toBe('9999-12-31T23:59:57.000Z');
+ expect(iso('9999-12-31', '23:59:57', 'Pacific/Kiritimati')).toBe('9999-12-31T09:59:57.000Z');
+ });
+
+ it('rejects a time that the zone skips when the clocks go forward', () => {
+ expect(iso('2030-03-31', '01:59', 'Europe/Madrid')).toBe('2030-03-31T00:59:00.000Z');
+ expect(iso('2030-03-31', '02:00', 'Europe/Madrid')).toBe('nonexistent');
+ expect(iso('2030-03-31', '02:30', 'Europe/Madrid')).toBe('nonexistent');
+ expect(iso('2030-03-31', '03:00', 'Europe/Madrid')).toBe('2030-03-31T01:00:00.000Z');
+ expect(iso('2030-03-10', '02:30', 'America/New_York')).toBe('nonexistent');
+ // Lord Howe moves its clocks by half an hour.
+ expect(iso('2030-10-06', '02:15', 'Australia/Lord_Howe')).toBe('nonexistent');
+ });
+
+ it('takes the later instant of a time that the zone repeats when the clocks go back', () => {
+ expect(iso('2030-10-27', '01:59', 'Europe/Madrid')).toBe('2030-10-26T23:59:00.000Z');
+ expect(iso('2030-10-27', '02:00', 'Europe/Madrid')).toBe('2030-10-27T01:00:00.000Z ambiguous');
+ expect(iso('2030-10-27', '02:30', 'Europe/Madrid')).toBe('2030-10-27T01:30:00.000Z ambiguous');
+ expect(iso('2030-10-27', '03:00', 'Europe/Madrid')).toBe('2030-10-27T02:00:00.000Z');
+ expect(iso('2030-11-03', '01:30', 'America/New_York')).toBe('2030-11-03T06:30:00.000Z ambiguous');
+ expect(iso('2030-04-07', '01:45', 'Australia/Lord_Howe')).toBe('2030-04-06T15:15:00.000Z ambiguous');
+ });
+
+ it('keeps the seconds and milliseconds of the time', () => {
+ expect(iso('2030-01-01', '12:00:30', UTC)).toBe('2030-01-01T12:00:30.000Z');
+ expect(iso('2030-01-01', '12:00:30.5', 'Europe/Madrid')).toBe('2030-01-01T11:00:30.500Z');
+ expect(iso('2030-01-01', '12:00:30.123', UTC)).toBe('2030-01-01T12:00:30.123Z');
+ });
+
+ it('refuses what is not a date and a time, and a zone that the browser does not know', () => {
+ for (const [date, time] of [
+ ['2030-02-30', '00:00'],
+ ['2031-02-29', '00:00'],
+ ['2030-04-31', '00:00'],
+ ['2030-13-01', '00:00'],
+ ['2030-00-10', '00:00'],
+ ['2030-01-00', '00:00'],
+ ['0000-01-01', '00:00'],
+ ['2030-1-1', '00:00'],
+ ['', '00:00'],
+ ['2030-01-01', ''],
+ ['2030-01-01', '24:00'],
+ ['2030-01-01', '12:60'],
+ ['2030-01-01', '12:00:60'],
+ ['2030-01-01', '12:00:00.1234'],
+ ['2030-01-01', '1:00'],
+ ]) {
+ expect(iso(date!, time!, UTC), `${date} ${time}`).toBe('format');
+ }
+ expect(iso('2032-02-29', '00:00', UTC)).toBe('2032-02-29T00:00:00.000Z');
+ expect(iso('2030-01-01', '00:00', 'Mars/Olympus')).toBe('zone');
+ expect(isTimeZone('Europe/Madrid')).toBe(true);
+ expect(isTimeZone(UTC)).toBe(true);
+ expect(isTimeZone('Mars/Olympus')).toBe(false);
+ // What V8 reports as the zone of a device whose zone ICU does not know.
+ expect(isTimeZone('Etc/Unknown')).toBe(false);
+ });
+
+ it('agrees, in every zone, with a search of every quarter hour around each change of offset of 2030', () => {
+ // An independent wall clock of a zone: the date and time it shows at an
+ // instant, as milliseconds of a UTC date and time.
+ const formats = new Map();
+ const wall = (zone: string, ms: number): number => {
+ let f = formats.get(zone);
+ if (f === undefined) {
+ f = new Intl.DateTimeFormat('en-US-u-ca-gregory-nu-latn', { timeZone: zone, hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' });
+ formats.set(zone, f);
+ }
+ const p = Object.fromEntries(f.formatToParts(ms).map(({ type, value }) => [type, Number(value)]));
+ return Date.UTC(p.year!, p.month! - 1, p.day!, p.hour!, p.minute!, p.second!);
+ };
+ const MIN = 60_000;
+ const Q = 15 * MIN;
+ let checked = 0;
+ for (const zone of supportedTimeZones()) {
+ const offset = (ms: number) => wall(zone, ms) - ms;
+ for (let at = Date.UTC(2030, 0, 1); at < Date.UTC(2031, 0, 1); at += 7 * 86_400_000) {
+ if (offset(at) === offset(at + 7 * 86_400_000)) continue;
+ // The change, to the minute.
+ let [lo, hi] = [at, at + 7 * 86_400_000];
+ while (hi - lo > MIN) {
+ const mid = lo + Math.floor((hi - lo) / 2 / MIN) * MIN;
+ if (offset(mid) === offset(lo)) lo = mid;
+ else hi = mid;
+ }
+ const [before, after] = [offset(lo), offset(hi)];
+ for (const w of [hi + before - Q, hi + before, hi + before + Q, hi + after - Q, hi + after, hi + after + Q]) {
+ // Every instant within 14 hours whose wall clock is w.
+ const found: number[] = [];
+ for (let t = w - 14 * 3_600_000; t <= w + 14 * 3_600_000; t += Q) if (wall(zone, t) === w) found.push(t);
+ const iso = new Date(w).toISOString();
+ const got = localToEpochMs(iso.slice(0, 10), iso.slice(11, 16), zone);
+ const want = found.length === 0 ? { ok: false, reason: 'nonexistent' } : { ok: true, epochMs: Math.max(...found), ambiguous: found.length > 1 };
+ expect(got, `${zone} ${iso}`).toEqual(want);
+ checked++;
+ }
+ }
+ }
+ // With the zones of Node 24, 130 change their clocks twice in 2030: 1 560
+ // cases. The bound only guards against a sweep that finds nothing.
+ expect(checked).toBeGreaterThan(1000);
+ });
+
+ it('reads years of one to three digits as they are, not as 19xx', () => {
+ expect(new Date(parseLocal('0099-06-01', '00:00')!).getUTCFullYear()).toBe(99);
+ expect(new Date(parseLocal('0001-01-01', '00:00')!).toISOString()).toBe('0001-01-01T00:00:00.000Z');
+ });
+});
+
+describe('localParts', () => {
+ it('writes an instant as the inputs of a zone show it', () => {
+ expect(localParts(Date.UTC(2030, 6, 15, 10), 'Europe/Madrid')).toEqual({ date: '2030-07-15', time: '12:00' });
+ expect(localParts(Date.UTC(2029, 11, 31, 18, 30, 59, 999), 'Asia/Kolkata')).toEqual({ date: '2030-01-01', time: '00:00' });
+ expect(localParts(Date.UTC(2030, 0, 1, 9, 5), UTC)).toEqual({ date: '2030-01-01', time: '09:05' });
+ });
+});
+
+describe('the list of zones', () => {
+ const original = Intl.supportedValuesOf;
+ afterEach(() => {
+ Object.defineProperty(Intl, 'supportedValuesOf', { value: original, configurable: true, writable: true });
+ });
+
+ it('puts the device first, then UTC, then the rest in order and once', () => {
+ const supported = ['Europe/Madrid', 'America/New_York', 'Africa/Abidjan', 'Africa/Abidjan', UTC];
+ expect(timeZoneList('Europe/Madrid', supported)).toEqual(['Europe/Madrid', UTC, 'Africa/Abidjan', 'America/New_York']);
+ expect(timeZoneList(undefined, supported)).toEqual([UTC, 'Africa/Abidjan', 'America/New_York', 'Europe/Madrid']);
+ expect(timeZoneList(UTC, supported)).toEqual([UTC, 'Africa/Abidjan', 'America/New_York', 'Europe/Madrid']);
+ // An alias that the list of the browser leaves out.
+ expect(timeZoneList('Asia/Calcutta', ['Asia/Kolkata'])).toEqual(['Asia/Calcutta', UTC, 'Asia/Kolkata']);
+ });
+
+ it('asks the browser for its zones, and has none without Intl.supportedValuesOf', () => {
+ expect(supportedTimeZones()).toContain('Europe/Madrid');
+ Object.defineProperty(Intl, 'supportedValuesOf', { value: undefined, configurable: true, writable: true });
+ expect(supportedTimeZones()).toEqual([]);
+ });
+});
diff --git a/src/lib/inspector/localtime.ts b/src/lib/inspector/localtime.ts
new file mode 100644
index 0000000..c0237ea
--- /dev/null
+++ b/src/lib/inspector/localtime.ts
@@ -0,0 +1,143 @@
+// Local dates and times of the create page (plan of phase 3, section 9): the
+// person picks a date and a time in a time zone, and the capsule seals the
+// UTC instant they stand for (§15), with the rules of the zone as this
+// browser knows them today; the zone itself is not kept. Intl only:
+// formatToParts gives the wall clock of a zone at an instant, and the
+// instants of a wall clock are found from the offsets of the zone around it.
+// A time that the zone skips, when the clock goes forward, does not exist. A
+// time that it repeats, when the clock goes back, is ambiguous, and the later
+// of its two instants is taken, so that a capsule never opens before the
+// person could have meant.
+
+/** The zone that is no zone: the instant itself. */
+export const UTC = 'UTC';
+
+const HOUR = 3600_000;
+
+// The Gregorian calendar and Latin digits, whatever the locale of the device.
+const LOCALE = 'en-US-u-ca-gregory-nu-latn';
+
+const formatters = new Map();
+
+// The formatter of the wall clock of `timeZone`; a RangeError for a zone that
+// this browser does not know.
+function formatter(timeZone: string): Intl.DateTimeFormat {
+ let f = formatters.get(timeZone);
+ if (f === undefined) {
+ f = new Intl.DateTimeFormat(LOCALE, {
+ timeZone,
+ year: 'numeric',
+ month: 'numeric',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: 'numeric',
+ second: 'numeric',
+ hourCycle: 'h23',
+ });
+ formatters.set(timeZone, f);
+ }
+ return f;
+}
+
+// Milliseconds since the epoch of a date and time taken as UTC, for any year
+// from 1: Date.UTC reads the years 0 to 99 as 1900 to 1999.
+function utcMs(year: number, month: number, day: number, hour: number, minute: number, second: number): number {
+ const d = new Date(Date.UTC(2000, month - 1, day, hour, minute, second));
+ d.setUTCFullYear(year);
+ return d.getTime();
+}
+
+// The wall clock of the zone of `f` at `epochMs`, as milliseconds of a UTC
+// date and time.
+function wallMs(epochMs: number, f: Intl.DateTimeFormat): number {
+ const p: Record = {};
+ for (const { type, value } of f.formatToParts(epochMs)) if (type !== 'literal') p[type] = Number(value);
+ const ms = ((epochMs % 1000) + 1000) % 1000;
+ return utcMs(p.year!, p.month!, p.day!, p.hour!, p.minute!, p.second!) + ms;
+}
+
+/** Whether this browser knows the time zone `timeZone`. */
+export function isTimeZone(timeZone: string): boolean {
+ try {
+ formatter(timeZone);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * The date and time of the values of and , "YYYY-MM-DD" and "HH:MM", "HH:MM:SS" or "HH:MM:SS.mmm", as
+ * milliseconds of a UTC date and time; undefined when either is not one.
+ */
+export function parseLocal(date: string, time: string): number | undefined {
+ const d = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
+ const t = /^(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?$/.exec(time);
+ if (d === null || t === null) return undefined;
+ const [year, month, day] = [Number(d[1]), Number(d[2]), Number(d[3])];
+ const [hour, minute, second] = [Number(t[1]), Number(t[2]), Number(t[3] ?? 0)];
+ if (year < 1 || month < 1 || month > 12 || day < 1 || hour > 23 || minute > 59 || second > 59) return undefined;
+ const ms = utcMs(year, month, day, hour, minute, second);
+ // A day past the end of its month rolls over into the next one.
+ if (new Date(ms).getUTCDate() !== day) return undefined;
+ return ms + Number((t[4] ?? '').padEnd(3, '0'));
+}
+
+/** The instant of a local date and time in a zone, or why there is none. */
+export type LocalTime =
+ | {
+ readonly ok: true;
+ readonly epochMs: number;
+ /** The zone repeats this time, and the later of its two instants was taken. */
+ readonly ambiguous: boolean;
+ }
+ | {
+ readonly ok: false;
+ /** Not a date and a time, a zone that this browser does not know, or a time that the zone skips. */
+ readonly reason: 'format' | 'zone' | 'nonexistent';
+ };
+
+/**
+ * The instant at which the clocks of `timeZone` show `date` and `time`, the
+ * values of the inputs of the page. The offsets that the zone can have at
+ * that time are those of the instants 14 hours around it, the widest offsets
+ * there are; each gives an instant, kept if the zone shows that very time at
+ * it.
+ */
+export function localToEpochMs(date: string, time: string, timeZone: string): LocalTime {
+ const wall = parseLocal(date, time);
+ if (wall === undefined) return { ok: false, reason: 'format' };
+ if (!isTimeZone(timeZone)) return { ok: false, reason: 'zone' };
+ const f = formatter(timeZone);
+ const offsets = new Set([wall - 14 * HOUR, wall, wall + 14 * HOUR].map((at) => wallMs(at, f) - at));
+ const instants = [...offsets].map((o) => wall - o).filter((at) => wallMs(at, f) === wall);
+ if (instants.length === 0) return { ok: false, reason: 'nonexistent' };
+ return { ok: true, epochMs: Math.max(...instants), ambiguous: instants.length > 1 };
+}
+
+/** The date and the time of `epochMs` in `timeZone`, as the inputs of the page write them: "YYYY-MM-DD" and "HH:MM". */
+export function localParts(epochMs: number, timeZone: string): { date: string; time: string } {
+ const at = new Date(wallMs(epochMs, formatter(timeZone)));
+ const two = (n: number): string => String(n).padStart(2, '0');
+ return {
+ date: `${String(at.getUTCFullYear()).padStart(4, '0')}-${two(at.getUTCMonth() + 1)}-${two(at.getUTCDate())}`,
+ time: `${two(at.getUTCHours())}:${two(at.getUTCMinutes())}`,
+ };
+}
+
+/**
+ * The zones to offer, the device's first: then UTC, then every other zone
+ * that this browser knows, in alphabetical order. `supported` is
+ * Intl.supportedValuesOf('timeZone'), which may leave out UTC and the name
+ * that the device gives its own zone.
+ */
+export function timeZoneList(device: string | undefined, supported: readonly string[]): string[] {
+ const first = device === undefined || device === UTC ? [UTC] : [device, UTC];
+ return [...first, ...[...new Set(supported)].filter((z) => !first.includes(z)).sort()];
+}
+
+/** Intl.supportedValuesOf('timeZone'), or none when this browser lacks it. */
+export function supportedTimeZones(): string[] {
+ return typeof Intl.supportedValuesOf === 'function' ? Intl.supportedValuesOf('timeZone') : [];
+}
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index d43d052..793ac80 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -10,6 +10,7 @@
const home = resolve('/');
const inspector = resolve('/inspect');
+ const create = resolve('/create');
// The notices of the third-party code in the bundle, written by vite.config.ts.
const licenses = asset('/licenses.txt');
@@ -23,6 +24,7 @@
DateKeys
@@ -61,6 +63,10 @@
font-size: 1.1rem;
letter-spacing: -0.01em;
}
+ nav {
+ display: flex;
+ gap: 1.25rem;
+ }
nav a {
display: inline-block;
padding: 0.5rem 0.25rem;
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 1beecb3..a927a24 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -21,8 +21,12 @@
pasos que la herramienta datekeys inspect. Pasada la fecha, la abre con esa firma, que pegas tú, y con
tu clave si la cápsula la pide.
+
+ Y para crear una, la página cifra tu fichero en este navegador para la fecha que elijas, también sin red.
+
+ Cifra un fichero en una cápsula .dkc que nadie puede abrir antes de la fecha que elijas: su clave depende de
+ la firma que la red drand publicará en esa fecha. El fichero se cifra en este navegador y no sale de él; la página no se
+ conecta a ningún otro sitio.
+
+
+
+
+
+
{announcement}
+
+ {#if result}
+ {@const r = result}
+
+
+
Cápsula creada
+
+ Se abrirá el {formatDateTime(r.plan.effectiveMs, timeZone)} ({formatDateTime(r.plan.effectiveMs, UTC)}), con la
+ ronda {r.plan.dateKey.round}. Cifrada y comprobada en {seconds(r.ms)} s. A partir de esa fecha se abre en el
+ inspector o con datekeys decrypt.
+
+
+
+
capsule_id
+
{r.capsuleId}
+
+
+
Política
+
{r.plan.policy === TIME_AND_KEY ? `fecha y clave, con ${credentials(r.plan)}` : 'solo fecha'}
+
+
+
Tamaño
+
+ {formatByteCount(r.capsule.size)}: formato 2, con el contenido ({formatByteCount(r.plan.length)}) rellenado con
+ ceros hasta {formatByteCount(r.plan.paddedLength)} (relleno reforzado, §29.1)
+
+
+
+
+
+
+
La cápsula (.dkc)
+ {#if capsuleGone}
+
+ {r.temporary
+ ? 'Fichero temporal borrado. Para tener otra cápsula, créala de nuevo.'
+ : 'La cápsula ya no está en la página. Para tener otra, créala de nuevo.'}
+
+ {:else}
+
+
+
+
+ El nombre se ve junto al fichero: el propuesto solo repite la fecha de apertura, que la cápsula ya muestra, y no
+ dice cuándo se creó.
+
+
+
+
+ {#if r.temporary}
+
+ {/if}
+
+
+ {r.temporary
+ ? 'Está en un fichero temporal privado de este navegador. Se borra cuando lo pides, al crear otra cápsula y al salir de la página.'
+ : r.inMemory === 'no-room'
+ ? 'Está en la memoria de esta página, porque el almacenamiento privado del navegador no tenía sitio para ella. Se libera al crear otra cápsula y al salir de la página.'
+ : 'Está en la memoria de esta página, porque el navegador no le deja un fichero temporal privado. Se libera al crear otra cápsula y al salir de la página.'}
+
+ {/if}
+
+
+ {#if r.plan.portable}
+
+
La clave portable (.dkk)
+ {#if hasKey}
+
+ Guarda esta clave en secreto: quien la tenga podrá abrir la cápsula desde la fecha. Solo sirve para esta cápsula.
+ {#if r.keyOnly}
+ Es la única credencial: sin ella, nadie podrá abrir la cápsula, tampoco tú.
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ Está solo en la memoria de esta página, nunca en el almacenamiento del navegador. Se borra cuando pulsas «Olvidar la
+ clave», al crear otra cápsula y al salir de la página. Suele convenir enviarla por otro camino que el .dkc.
+
+ {:else}
+
+ La clave ya no está en la página.{keySaved ? '' : ' No se descargó: si no hay otra credencial, esta cápsula no podrá abrirse.'}
+
+ {/if}
+
+ {/if}
+
+
+
+ {/if}
+
+
+{#if dragging}
+
+
Suelta el fichero para cifrarlo
+
+{/if}
+
+
diff --git a/src/routes/inspect/+page.svelte b/src/routes/inspect/+page.svelte
index 66d199c..7fa00f5 100644
--- a/src/routes/inspect/+page.svelte
+++ b/src/routes/inspect/+page.svelte
@@ -4,7 +4,7 @@
import { FIXTURES, type Fixture, fetchFixture } from '$lib/inspector/fixtures.ts';
import { buildReport, type Report } from '$lib/inspector/report.ts';
import { displayText, escapeInvisible, viewerTimeZone } from '$lib/inspector/format.ts';
- import { browserPlatform, removeStaleTempFiles } from '$lib/inspector/tempfile.ts';
+ import { browserPlatform, CREATE_AREA, OPEN_AREA, removeStaleTempFiles } from '$lib/inspector/tempfile.ts';
import InspectionReport from '$lib/components/InspectionReport.svelte';
import OpenPanel from '$lib/components/OpenPanel.svelte';
@@ -51,11 +51,14 @@
};
});
- // 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.
+ // Plaintexts and capsules left in the private storage of the browser by a
+ // page that ended before deleting them (tempfile.ts) are deleted on the
+ // next visit to either page.
onMount(() => {
const platform = browserPlatform();
- if (platform !== undefined) void removeStaleTempFiles(platform).catch(() => undefined);
+ if (platform !== undefined) {
+ for (const area of [OPEN_AREA, CREATE_AREA]) void removeStaleTempFiles(platform, area).catch(() => undefined);
+ }
});
async function run(name: string, read: () => Promise, readNotice?: string): Promise {
diff --git a/vitest.config.ts b/vitest.config.ts
index fbd1992..0e25337 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -43,6 +43,12 @@ export default defineConfig({
// The writer (plan of phase 3, steps 3 and 4).
'src/lib/dkc/writer.ts': { 100: true },
'src/lib/dkc/encrypt.ts': { 100: true },
+ // The page of step 7: the lengths known before writing, the local
+ // times, the form and the writing loaded on demand.
+ 'src/lib/dkc/lengths.ts': { 100: true },
+ 'src/lib/inspector/localtime.ts': { 100: true },
+ 'src/lib/inspector/create-input.ts': { 100: true },
+ 'src/lib/inspector/creator.ts': { 100: true },
'src/lib/dkc/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 },
// The page model and helpers of the inspector (plan §8, phase 1).
'src/lib/inspector/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 },