Phase 3 plan v2: the TypeScript writer of capsule format 2 (spec v0.9)

Replans the writer on the writer rules of spec §62.1: format 2 only,
L known before sealing, reforzado padding by default, 16 slots with
dummies in a uniform order, recipients checked as Go does,
SEALED_CONTROL_LEN from the §62.1 formula with the real seal checked,
and the self-checks of rule 11. An adversarial review found no blocker
and no major issue; its 12 minor corrections are incorporated. The 17
decisions await the author's confirmation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
dev 1 week ago
parent a9ab9e386b
commit 768f20feb8

@ -95,7 +95,7 @@ El vector de GT (`vectors/tlock_ibe.json`) ya está cubierto en `App`. El paso 9
- El lector MUST NOT presentar el contenido como válido antes de que termine el paso 17.
- Un lector en streaming MUST NOT escribir el relleno y MUST señalar el error para que se descarte lo escrito.
- Así siguen valiendo `Open(dst)` de Go y la salida en streaming de TypeScript.
- En este repo están [PLAN_fase3_escritura.md](PLAN_fase3_escritura.md), el plan del writer TypeScript, y [REVISION_completitud_protocolo.md](REVISION_completitud_protocolo.md), la revisión del protocolo. El plan describe aún el writer de la v0.8.2 y hay que replantearlo sobre la v0.9.
- En este repo están [PLAN_fase3_escritura.md](PLAN_fase3_escritura.md), el plan del writer TypeScript, y [REVISION_completitud_protocolo.md](REVISION_completitud_protocolo.md), la revisión del protocolo. El plan está en su v2, replanteado sobre la v0.9 y revisado; sus decisiones esperan la confirmación del autor.
**Aprobación del 29-09.** El autor aprobó el texto de la v0.9 con las respuestas recomendadas a las preguntas abiertas:
1. L es un `bstr` de 8 bytes, no un `uint`.
@ -159,7 +159,14 @@ El vector de GT (`vectors/tlock_ibe.json`) ya está cubierto en `App`. El paso 9
- la página muestra el formato, avisa del 1 y, al abrir una cápsula del formato 2, da la regla y P;
- `ibe-vectors.json` añade los siete fixtures del formato 2, calculados con `scripts/ibe-go-vectors.go`; los valores congelados no cambian;
- comprobado: los 125 casos del corpus dan en TypeScript el código, el paso y el texto de error de `capsule.Open`, en memoria y desde un `Blob`.
6. Se replantea el plan de la fase 3 y se escribe el writer TypeScript del formato 2. **Siguiente paso.** [PLAN_fase3_escritura.md](PLAN_fase3_escritura.md) describe aún el writer de la v0.8.2: hay que rehacerlo sobre las reglas del §62.1 (16 slots con señuelos, relleno, L conocido de antemano, autocomprobaciones, recipients canónicos).
6. Se replantea el plan de la fase 3 y se escribe el writer TypeScript del formato 2. **En curso.** El 29-09 por la noche, [PLAN_fase3_escritura.md](PLAN_fase3_escritura.md) pasó a su v2, sobre las reglas del §62.1:
- 16 huecos con señuelos y orden uniforme;
- relleno `reforzado` por defecto;
- L conocida de antemano;
- autocomprobaciones;
- recipients canónicos y no de orden bajo, como Go;
- `SEALED_CONTROL_LEN` con la fórmula del §62.1 y comprobada.
Una revisión crítica con sondas no encontró nada bloqueante ni grave, y sus 12 correcciones menores están incorporadas. **Siguiente:** el autor confirma las 17 decisiones de su sección 2 (paso 0 del plan); después se implementa por pasos.
**Conclusiones de la conversación, sin decisión pendiente:**
- El protocolo no contempla un sello de tiempo de creación (§5, §55.1). Si se quiere, lo recomendado es un sello RFC 3161 u OpenTimestamps sobre el SHA-256 del `.dkc` completo, guardado aparte.

@ -1,198 +1,229 @@
# Plan: fase 3 del SDK TypeScript. Writer de cápsulas `.dkc` y claves de acceso `.dkk`, comprobado contra Go a nivel de cápsula, y página para crearlas
# Plan: fase 3 del SDK TypeScript. Writer de cápsulas `.dkc` de formato 2 y claves `.dkk`, comprobado contra Go a nivel de cápsula, y página para crearlas
Estado: v1, 29 de septiembre de 2026. Es un borrador. Las decisiones de la sección 2 son **propuestas**: el autor tiene que confirmarlas en el paso 0 de la sección 10, y hasta entonces no se implementa nada.
Estado: v2, 29 de septiembre de 2026. Replantea la v1 del mismo día sobre la v0.9 del spec (tag `spec-v0.9` de `datekeys-go`), que añade el formato 2 de cápsula y hace normativas las reglas del escritor (§62.1). La v1 describía el writer de la v0.8.2 y queda en la historia de este repo.
> **Pendiente de replantear sobre la v0.9 del spec.** El mismo 29-09 el autor decidió una v0.9 del protocolo (rama `v0.9` de `datekeys-go`), y el writer tiene que escribir su formato 2:
> - `INNER_ACCESS_AGE` con exactamente 16 stanzas, rellenos con señuelos y en orden aleatorio, así que `R_ACCESS` ya no va el último y más de 16 credenciales se rechazan;
> - relleno del contenido con los códigos 1 (múltiplo de 256) y 2 (reforzado, el de por defecto), con la longitud real y el código en `CONTROL_CBOR`;
> - las reglas del escritor de §62.1.
>
> Este plan describe todavía el writer de la v0.8.2. Se revisa cuando el autor apruebe el texto de la v0.9 y la referencia Go lo implemente; hasta entonces sus seis decisiones siguen abiertas, y la de la versión (decisión 14) depende de la v0.9. Este plan continúa el punto "Writer completo de `.dkc` y `.dkk` en TypeScript y la prueba TS → Go a nivel de cápsula: fase 3" de la sección 12 de `PLAN_fase2_ibe_noble2.md`.
Las decisiones de la sección 2 son **propuestas**. El autor tiene que confirmarlas en el paso 0 de la sección 10, y hasta entonces no se implementa nada.
Parte de cuatro lecturas del 29-09-2026, contrastadas con el código:
- el spec v0.8.2 y `datekeys.cddl`;
- `capsule/encrypt.go` y la CLI de la referencia Go;
- la librería TypeScript, con `age-encryption` 0.3.1 instalado;
- los documentos del proyecto.
Un revisor crítico contrastó este borrador con el spec, con Go, con el código TypeScript y con `age-encryption` instalado, con sondas propias. No encontró nada bloqueante ni grave: ninguna decisión rompe un MUST del §62.1. Sus 12 correcciones menores están incorporadas, entre ellas los textos de relleno de Go, el punto del twist, las cifras del relleno, la comprobación de la cabecera del payload y los huecos de los tests frente a los de Go.
Un revisor crítico contrastó después el borrador con el spec, con Go y con el código instalado. Sus 26 correcciones están incorporadas.
Parte de lo que había el 29-09-2026 por la noche, contrastado con el código:
- el spec v0.9 (§21, §29.1, §37 a §39, §42, §53, §55.2, §57, §61, §62, §62.1 y §70) y `datekeys.cddl`;
- `capsule/encrypt.go` y la CLI de `datekeys-go` en `7e2d83c`, la referencia que ya escribe el formato 2;
- `datekeys-ts` en `0118890`, que ya lee los dos formatos, con `age-encryption` 0.3.1 instalado;
- la v1 de este plan y sus 26 correcciones de revisión, que siguen valiendo donde el formato 2 no cambia nada.
Alcance: escribir en TypeScript, sin red, cápsulas `.dkc` y claves portables `.dkk` del perfil Quicknet, como hacen `capsule.Encrypt` y `accesskey.Encode` en Go. Esto incluye:
Alcance: escribir en TypeScript, sin red, cápsulas `.dkc` de formato 2 y claves portables `.dkk` del perfil Quicknet, como hacen `capsule.Encrypt` y `accesskey.Encode` en Go. Esto incluye:
- dos módulos nuevos de librería, `src/lib/dkc/encrypt.ts` y `src/lib/dkc/writer.ts`;
- un módulo sin noble para los recipients `age1…`, `src/lib/dkc/recipient.ts`;
- las piezas que faltan en `x25519.ts`, `digest.ts`, `datekey.ts`, `tlock.ts` y `tempfile.ts`;
- las piezas que faltan en `x25519.ts`, `digest.ts`, `datekey.ts` y `tempfile.ts`, y el uso de `compareInstants` en `open.ts`;
- las guardas nuevas en `dependencies.test.ts` y `check-build.mjs`;
- los tests y la prueba de interoperabilidad TS → Go a nivel de cápsula;
- como último paso, con decisiones de interfaz propias, una página para crear cápsulas en el navegador.
El writer no entra en `index.ts` ni en la primera carga de ninguna página: se carga bajo demanda, igual que la apertura.
El writer no entra en `index.ts` ni en la primera carga de ninguna página: se carga bajo demanda, igual que la apertura. Nunca escribe el formato 1: solo un generador de vectores de prueba puede hacerlo (§62.1, regla 1, y §70), y ese generador es `internal/testkit` de Go.
Regla de dependencias: la de las fases anteriores. En ejecución solo están `age-encryption` 0.3.1 y `@noble/curves`, `@noble/hashes` y `@noble/ciphers` 2.4.0. Nada nuevo entra sin aprobación escrita del autor. **Esta fase no necesita ningún paquete nuevo** (sección 3).
### Qué cambia respecto a la v1
- **Formato 2 siempre.** `VERSION` = 2 en el PRELUDE y `CONTROL_CBOR` de versión 2, con L y el código de relleno en las claves 6 y 7.
- **L se conoce antes de sellar** (§62.1, regla 6). Con un `Blob` o un `Uint8Array` sale de su tamaño; con un `ReadableStream`, quien llama la da. La fuente tiene que entregar exactamente L bytes, o `encrypt` falla y la salida se aborta (decisión 2).
- **Relleno.** El plaintext de `PAYLOAD_AGE` es el contenido seguido de ceros hasta P = regla(L), con `reforzado` por defecto (decisiones 2 y 16).
- **16 huecos.** De 1 a 16 credenciales, señuelos en el resto y un orden uniformemente aleatorio (§39, §62.1, reglas 3 y 4). Desaparecen el límite de 1 024 stanzas y "`R_ACCESS` el último" (decisiones 4, 5 y 6).
- **Recipients no canónicos y de orden bajo.** Go ya los rechaza desde la v0.9 (`agewrap.CheckX25519Recipient`), así que la diferencia con la referencia que proponía la v1 desaparece: los mismos casos y los mismos textos (decisión 6).
- **`SEALED_CONTROL_LEN` con la fórmula** de la nota del §62.1 y comprobando el sellado real, en lugar del borrador de Go. Así hay un solo sellado de 16 stanzas y un solo wrap tlock, y sobra la caché del pairing de la v1 (decisión 3).
- **Autocomprobaciones de la regla 11**, las mismas que Go, y dos más: la de `OUTER_TIME_AGE`, que ya proponía la v1, y la cabecera de `PAYLOAD_AGE` antes de escribir nada, con `decryptHeader` (decisión 8).
- **Tests.** Se reproducen los siete fixtures de formato 2, con la permutación fijada desde su registro. Se añaden la uniformidad del orden y los bordes del relleno (sección 8).
- **Página.** Hasta 16 credenciales, el aviso de privacidad del §55.2, y ninguna elección de regla de relleno (sección 9).
---
## 1. Situación de partida
Todo lo de esta tabla se ejecutó o se leyó el 29-09-2026, en Node 24.9.0, sobre `App` en `ccee18c` (`age-encryption` 0.3.1, noble 2.4.0) y `datekeys-go` en `3e4755e`. Las sondas quedaron en el scratchpad de la sesión (`p3\probe.mjs`, `p3\speed.mjs`, `p3r\probe1.mjs` a `probe3.mjs`). Si ese directorio ya no existe, cada punto se reproduce a partir de la descripción.
Todo lo de esta tabla se ejecutó o se leyó el 29-09-2026 en Node 24.9.0, sobre `datekeys-ts` en `0118890` y `datekeys-go` en `7e2d83c`. La sonda de ese día (orden de los stanzas, coste del sellado interno y orden bajo) se describe en cada punto y se reproduce con él.
| Hecho | Detalle |
|---|---|
| Encoders TypeScript que ya existen | `encodeHeader` (header.ts:151) y `encodeControl` (control.ts:117).<br>`marshalAccessKeyBody` y `encodeAccessKey` (accesskey.ts:231, 273); `marshalAccessKeyBody` ya se autocomprueba.<br>`preludeBytes`, `headerBinding` y `dkkPreludeBytes` (framing.ts:62, 79, 150).<br>`newExtension`, `canonicalExtensions` y `checkDisjoint` (extension.ts).<br>`resolveDateKey` y `roundTime` (datekey.ts:507, 485).<br>`timeRecipient` (tlock.ts:21).<br>Todos se comparan byte a byte con los fixtures y vectores de Go. `encodeHeader` y `encodeControl` no se autocomprueban: Go hace esa comprobación en `capsule.Encrypt` (`selfCheckHeader`, `selfCheckControl`, encrypt.go:243-260). `encodeHeader` ya aplica el máximo de 1 MiB de §57 (header.ts:163-165). |
| Lo que falta | La orquestación de `capsule.Encrypt`.<br>Generar identidades X25519 en bytes crudos y derivar su recipient.<br>Leer y escribir `age1…`.<br>Un SHA-256 que calcule sobre lo que se escribe: `sha256Stream` (digest.ts:8) consume el stream.<br>Un comparador de `Instant` exportado: `before` es privado en open.ts:114.<br>Las comprobaciones que `Encrypter` no hace. |
| `capsule.Encrypt` de Go | Orden (encrypt.go:72-238):<br>1. opciones y reloj; paso 1;<br>2. recipients de acceso, con `R_ACCESS` al final;<br>3. `capsule_id`, luego `I_PAYLOAD`;<br>4. PUBLIC_HEADER y su autocomprobación;<br>5. sellado de un borrador de CONTROL_CBOR, con binding e identidad a cero, para medir `SEALED_CONTROL_LEN` (160-178);<br>6. PRELUDE, `header_binding`, y CONTROL_CBOR con su autocomprobación;<br>7. sellado real, con la misma longitud exigida (196-202);<br>8. escritura de PRELUDE, cabecera y control y, al final, `PAYLOAD_AGE` en streaming por `io.MultiWriter(dst, sha256)` (205-223). `age.Encrypt` escribe su cabecera en `dst` en la línea 214;<br>9. la `.dkk` como objeto, con `capsule_digest` (225-236). La codifica y la autocomprueba `accesskey.Encode` (`MarshalBody`, accesskey.go:223-264), que llama la CLI (main.go:191), no `Encrypt`.<br>Un error anterior a la primera escritura deja `dst` vacío (encrypt_test.go:134). Los errores de `dst` y de `src` se devuelven sin tocar y sin código (208-209, 218-222). |
| `Encrypter` de `age-encryption` 0.3.1 | Sin recipients devuelve `age-encryption.org/v1\n--- …`: una cabecera sin stanzas, que todo lector rechaza. Go falla en ese caso.<br>No tiene labels, así que nada impide mezclar el recipient tlock con otros.<br>`addRecipient(string)` también acepta `age1pq1…`, `age1tag1…` y `age1tagpq1…`.<br>`X25519Recipient` no se exporta.<br>Los recipients de orden bajo (u = 0, u = 1, los de orden 8 y p) hacen fallar el cifrado: con Web Crypto, una `DOMException` (`OperationError`); sin él, un `Error` de noble (x25519.js:7-25).<br>Un recipient no canónico (bit 255 activado, o u ≥ p) pasa su constructor (recipients.js:280-288) y el wrap funciona, pero el salt de HKDF usa los bytes tal como llegan (recipients.js:293-295), mientras la identidad usa su clave pública canónica. **Nadie puede abrir ese stanza.** A `age.ParseX25519Recipient` de Go le pasa lo mismo (age x25519.go:85-87 frente a 181-183).<br>`encrypt(ReadableStream)` devuelve un `ReadableStreamWithSize` (`dist/index.js:119-120`): primero la cabecera, sola (`prepend`, io.js:54-62), luego el nonce, luego trozos de 64 KiB + 16. Su `size(n)` da la longitud total. Una prueba dio trozos `[168, 16, 65552, 65552, 65552, 3408]`.<br>`encryptSTREAM` cifra cada trozo de entrada en una sola llamada y encola todo su resultado, sin presión inversa (stream.js:76-80). Un trozo de entrada de 64 MiB tarda 818 ms en dar la tercera lectura y ocupa 144 MiB de `arrayBuffers`.<br>Toda su aleatoriedad sale de `crypto.getRandomValues`, a través de `randomBytes` de `@noble/hashes`. |
| Recipients en mayúsculas | Los dos lados rechazan `AGE1…`. `age-encryption` exige el prefijo `age1`. En Go, `internal/bech32.Decode` de `age` 1.3.2 no pasa el HRP a minúsculas, así que el HRP queda `AGE` y `ParseX25519Recipient` lo rechaza por no ser `age` (x25519.go:50-63). |
| Longitudes | Con c = max(1, ⌈\|pt\|/65 536⌉) y d el número de dígitos de la ronda:<br>- `OUTER_TIME_AGE` = 335 + d + \|pt\| + 16·c;<br>- `INNER_ACCESS_AGE` = 86 + 98·n + \|pt\| + 16·c, con n recipients;<br>- `PAYLOAD_AGE` = 184 + \|pt\| + 16·c.<br>Medido en TypeScript con `timeRecipient` y un CONTROL_CBOR de 91 bytes: 446 bytes de `OUTER_TIME_AGE` en la ronda 1000; 291 y 487 de `INNER_ACCESS_AGE` con 1 y 3 recipients; 646 y 842 de la capa externa sobre esos; 200, 246, 65 736 y 78 216 de `PAYLOAD_AGE` con 0, 46, 65 536 y 78 000 bytes. Coinciden con los cinco fixtures. Go no usa estas fórmulas: mide con el borrador. |
| Coste | Un wrap tlock (pairing, `gt^r`, `r·G2`) tarda de 83 a 180 ms en caliente, según la carga de la máquina. El primero tarda 363 ms, con la carga del código. El borrador de Go añade un wrap por cápsula.<br>`PAYLOAD_AGE` de 64 MiB en streaming, en trozos de 64 KiB: 70 MiB/s solo con `age`, y 57 MiB/s con el SHA-256 en la misma pasada. |
| Fixtures | Cada sidecar `testdata/fixtures/*.json` trae `capsule_id`, `datekey`, `unlock_at`, `prelude`, `public_header`, `header_binding`, `control_cbor`, `payload_identity`, las identidades de los recipients, los stanzas y el release publicado de su ronda. Las `.dkk` traen `credential_id` e `I_ACCESS`. `time_and_key_portable_extension.dkk` no sale de `Encrypt`: la deriva `deriveDKK` en `genfixtures` (main.go:283-338). |
| Spec v0.8.2 | §61 y §62 dan los pasos. El orden de `PAYLOAD_AGE`, y cómo conocer `SEALED_CONTROL_LEN` antes de sellar, quedan a la implementación.<br>§62: las file keys de las tres envolturas son independientes (MUST).<br>§15: la ronda se resuelve a la primera cuyo instante es igual o posterior al pedido, nunca hacia atrás (MUST).<br>§21, §37, §38 y §42: `capsule_id`, las identidades X25519 y `credential_id` salen de un generador criptográfico (MUST).<br>§57: límites que también obligan al encoder.<br>§67 y §68: los vectores `.dkc` y `.dkk` son fixtures de descifrado. No se exige reproducir los bytes de `age` ni controlar sus file keys, efímeras y nonces.<br>§76, corrección 4: un encoder MUST NOT escribir una extensión registrada fuera de su objeto o array. La referencia deja esa regla a la aplicación.<br>§53: el SDK oficial SHOULD advertir en horizontes largos; el umbral es política de producto.<br>§74 sigue dando por abiertos los esquemas byte a byte hasta la v1.0. |
| Guardas y página hoy | `NOBLE_IMPORTERS` (dependencies.test.ts:49) contiene `digest.ts`, `ibe.ts`, `release.ts` y `x25519.ts`.<br>Las importaciones de `age-encryption` no tienen lista blanca.<br>`check-build.mjs` comprueba la carga bajo demanda solo para `inspect.html`.<br>`tempfile.ts` es propio de la apertura (`TEMP_ROOT = 'datekeys-open'`, `TEMP_FILE = 'plaintext'`), y `cancellable` (tempfile.ts:93-108) ya cancela una apertura por su salida.<br>La CSP tiene `connect-src 'self'` y `worker-src 'none'`. |
| Repositorio | `App`: `main` en `ccee18c`, fase 2 completa, 2 611 tests y `npm run verify` en verde. `testdata` en `9ac9cd9` (`spec-v0.8.2`). Versión `0.1.0-dev`.<br>`datekeys-go`: `main` en `3e4755e`, sin cambios en `testdata` desde `9ac9cd9`. |
| Encoders TypeScript que ya existen | `encodeHeader` (header.ts) y `encodeControl(c, format)` (control.ts), que ya escribe la versión 2, con `payload_length` y `padding`.<br>`marshalAccessKeyBody` y `encodeAccessKey` (accesskey.ts); `marshalAccessKeyBody` ya se autocomprueba.<br>`preludeBytes` (con el formato), `headerBinding` y `dkkPreludeBytes` (framing.ts).<br>`paddedLength`, `payloadAgeLength` y `MAX_PAYLOAD_LENGTH` (padding.ts), exactas hasta L_MAX.<br>`ACCESS_SLOTS` y `checkAccessStanzas(stanzas, slots)` (age.ts).<br>`newExtension`, `canonicalExtensions` y `checkDisjoint` (extension.ts).<br>`resolveDateKey` y `roundTime` (datekey.ts).<br>`timeRecipient` (tlock.ts).<br>Todos se comparan byte a byte con los fixtures y vectores de Go. `encodeHeader` y `encodeControl` no se autocomprueban: Go lo hace en `capsule.Encrypt` (`selfCheckHeader`, `selfCheckControl`). |
| Lo que falta | La orquestación de `capsule.Encrypt`.<br>Generar identidades X25519 en bytes crudos y derivar su clave pública, también la de los señuelos.<br>Un índice aleatorio sin sesgo y la permutación de Fisher–Yates.<br>Leer y escribir `age1…`, y comprobar que un recipient es canónico y no de orden bajo.<br>Un SHA-256 que calcule sobre lo que se escribe: `sha256Stream` (digest.ts) consume el stream.<br>Un comparador de `Instant` exportado: `before` es privado en open.ts.<br>Escribir exactamente L bytes de la fuente y después el relleno.<br>Las comprobaciones que `Encrypter` no hace. |
| `capsule.Encrypt` de Go en `7e2d83c` | Orden (encrypt.go):<br>1. perfil y reloj obligatorios; `p.Validate()`; instante posterior a `Now()`; `Length` no negativa; código 0 → `Reforzado`; `PaddedLength(L, código)`, que rechaza L > L_MAX;<br>2. `datekey.Resolve` y la ronda que no abre antes de lo pedido;<br>3. `accessRecipients`: `time_only` sin credenciales; política conocida; de 1 a 16 credenciales; cada recipient X25519, canónico y no de orden bajo (`CheckX25519Recipient`), ninguno repetido; `I_ACCESS` nueva si se pide, y su recipient el último de la lista;<br>4. `capsule_id`, luego `I_PAYLOAD`;<br>5. `fillSlots`: un señuelo por hueco libre (`age.GenerateX25519Identity`, que se descarta), y `permute`, Fisher–Yates con `crypto/rand.Int`;<br>6. PUBLIC_HEADER y `selfCheckHeader`;<br>7. borrador de CONTROL_CBOR con binding e identidad a cero y los mismos L y código; su sellado mide `SEALED_CONTROL_LEN`, con el límite de 64 MiB;<br>8. PRELUDE (formato 2), `header_binding`, CONTROL_CBOR real y `selfCheckControl`;<br>9. sellado real: `INNER_ACCESS_AGE` con los 16 recipients y `selfCheckInner` (16 stanzas X25519 con shares distintos; `I_ACCESS` abre exactamente uno y da el control), después `OUTER_TIME_AGE`; la longitud igual a la del borrador;<br>10. escritura de PRELUDE, cabecera y control y, en streaming, `PAYLOAD_AGE` por `io.MultiWriter(dst, sha256)`: `writeContent` copia exactamente L bytes, comprueba que la fuente no da más, y escribe los ceros hasta P;<br>11. `selfCheckPayload`: `PAYLOAD_AGE` mide `PayloadAgeLength(P)` e `I_PAYLOAD` abre su cabecera;<br>12. la `.dkk` como objeto, con `capsule_digest` y un `credential_id` aleatorio.<br>`Result` da la DateKey, el instante efectivo, `capsule_id`, el formato, L, el código, P y la `.dkk`. Los errores de `dst` y de `src` se devuelven sin tocar y sin código. |
| `Encrypter` de `age-encryption` 0.3.1 | Lo que ya anotaba la v1 sigue igual:<br>- sin recipients escribe una cabecera sin stanzas, que todo lector rechaza;<br>- no tiene labels;<br>- `addRecipient(string)` acepta también `age1pq1…`, `age1tag1…` y `age1tagpq1…`;<br>- `X25519Recipient` no se exporta;<br>- un recipient no canónico produce un stanza que ninguna identidad abre;<br>- `encrypt(ReadableStream)` devuelve primero la cabecera sola y cifra cada trozo de entrada de una vez, sin presión inversa;<br>- toda su aleatoriedad sale de `crypto.getRandomValues`.<br>Comprobado además el 29-09 por la noche:<br>- **escribe los stanzas en el orden de `addRecipient`**: con 16 recipients, la identidad i abre el stanza i. Es lo que exige §62, paso 12;<br>- los recipients de orden bajo hacen fallar el cifrado: con Web Crypto, una `DOMException` (`OperationError`); sin él, noble rechaza el secreto compartido cero. Sus coordenadas u canónicas son 0, 1, las dos de orden 8 y p − 1; p − 2, por ejemplo, no falla;<br>- un punto canónico del twist, como u = 2, se cifra sin error, y nadie podría abrir ese stanza (§37);<br>- `Decrypter.decryptHeader(header)` analiza la cabecera, desenvuelve la file key y verifica su MAC sin necesitar el payload. Del stream de `encrypt`, el primer trozo son los 168 bytes de la cabecera sola, y para entonces ya se han leído unos 3 × 64 KiB de la fuente. |
| Longitudes | Son las de la nota informativa del §62.1, con c(n) = max(1, ⌈n / 65 536⌉), C = \|CONTROL_CBOR\| y d el número de dígitos de la ronda:<br>- `PAYLOAD_AGE` = 184 + P + 16·c(P);<br>- `INNER_ACCESS_AGE` = 86 + 98·16 + C + 16·c(C);<br>- `OUTER_TIME_AGE` = 335 + d + n + 16·c(n).<br>Sin extensiones de control, C = 103. `SEALED_CONTROL_LEN` vale 458 en `time_only` y 2 128 en `time_and_key` para la ronda 1000, como en los fixtures `format2_*`. La sonda midió un `INNER_ACCESS_AGE` de 16 stanzas con C = 103 en 1 773 bytes, lo que da la fórmula. |
| Coste | Sellar `INNER_ACCESS_AGE` con 16 recipients: 57 ms en Node la primera vez y unos 14 ms en caliente. Cada wrap hace una multiplicación por la base, otra por la clave y una importación pkcs8 de Web Crypto.<br>Un wrap tlock (pairing, `gt^r`, `r·G2`): de 83 a 180 ms en caliente, 363 ms el primero (v1).<br>`PAYLOAD_AGE` en streaming, en trozos de 64 KiB: 70 MiB/s solo con `age`, 57 MiB/s con el SHA-256 en la misma pasada (v1).<br>El relleno añade a lo cifrado P − L bytes (§29.1): 256 con L = 0 y como mucho 255 hasta 8 192 bytes, donde las dos reglas coinciden. Por encima, con `reforzado`, menos de L/2^S: menos de un 6,25 %, de un 3,125 % desde 65 536 bytes y de un 1,5625 % desde 2³². |
| Fixtures | Siete de formato 2. Cada registro trae `capsule_id`, `payload_identity`, `payload_length`, `padding`, `padded_length`, las identidades de los recipients, `access_key_stanza` e `identity_stanzas` (el hueco que abre cada credencial), los stanzas, la DateKey, el release publicado y las extensiones.<br>Los cinco de formato 1 son de la v0.8.2 y el writer no los puede reproducir, porque no escribe el formato 1. |
| Spec v0.9 | §61 y §62 dan un orden válido. §62.1 da las reglas, normativas se siga o no ese orden:<br>- 1, formato 2;<br>- 2, instante posterior al reloj;<br>- 3, de 1 a 16 credenciales X25519, canónicas, no de orden bajo y sin repetir;<br>- 4, señuelos y orden uniforme, sin guardar sus claves ni el orden;<br>- 5, CSPRNG para `capsule_id`, `I_PAYLOAD`, `I_ACCESS`, `credential_id`, los señuelos y el orden;<br>- 6, L conocida antes de sellar, sin suponerla;<br>- 7, `SEALED_CONTROL_LEN` exacta, con un sellado provisional o con la fórmula, y comprobada;<br>- 8, límites y extensiones;<br>- 9, ante un error no presentar lo escrito como cápsula.<br>Recomendaciones (SHOULD): la 10, el código 2 por defecto; la 11, las autocomprobaciones; la 12, el borrado.<br>Además:<br>- §15: la ronda es la primera en el instante pedido o después;<br>- §53: el SDK advierte en horizontes largos;<br>- §55.2: qué oculta y qué no el formato 2;<br>- §57: límites que también obligan al encoder;<br>- §67 y §68: los vectores son de descifrado, y no se exige reproducir los bytes de `age`. |
| Guardas y página hoy | `NOBLE_IMPORTERS` (dependencies.test.ts) contiene `digest.ts`, `ibe.ts`, `release.ts` y `x25519.ts`.<br>Las importaciones de `age-encryption` no tienen lista blanca.<br>`check-build.mjs` comprueba la carga bajo demanda solo para `inspect.html`.<br>`tempfile.ts` es propio de la apertura (`datekeys-open`, `plaintext`), y `cancellable` ya cancela una apertura por su salida.<br>La CSP tiene `connect-src 'self'` y `worker-src 'none'`. |
| Repositorios | `datekeys-ts`: `main` en `0118890`, que implementa el spec 0.9 y lee los dos formatos. Tiene 5 425 tests y `npm run verify` en verde; `testdata` en `7e2d83c` (`spec-v0.9`). Versión `0.1.0-dev`.<br>`datekeys-go`: `main` en `7e2d83c`, con el tag `spec-v0.9`. |
---
## 2. Decisiones propuestas
Propuestas el 29-09-2026 y pendientes de confirmación del autor (paso 0 de la sección 10). Cada una lleva sus alternativas y una recomendación.
Pendientes de confirmación del autor (paso 0 de la sección 10). Cada una lleva sus alternativas y una recomendación, y dice si cambia respecto a la v1.
1. **Módulo y API, espejo de `capsule.Encrypt`.**
1. **Módulo y API, espejo de `capsule.Encrypt`.** *Cambia: `length`, `padding` y los campos del resultado.*
- `src/lib/dkc/encrypt.ts` exporta `encrypt(src, opts): Promise<Encrypted>`, y nada más que eso y sus tipos.
- `EncryptOptions` sigue campo a campo a su equivalente de Go: `profile`, `unlockAt`, `policy`, `recipients`, `newPortableKey`, `critical`, `noncritical`, `controlCritical`, `controlNoncritical` y `now`. Añade `output` y `progress` (decisión 2).
- `Encrypted` sigue a `Result`: `dateKey`, `unlockAt` efectivo, `capsuleId` y `portableKey`. `portableKey` es un `AccessKey` sin codificar, con `capsule_digest`, como en Go: quien llama lo codifica con `encodeAccessKey`, que se autocomprueba, y lo borra con `wipeAccessKey`. Añade `size` y, cuando no hay `output`, `dkc`.
- `encrypt` es asíncrono, y quien llama podría cambiar sus entradas durante una espera. Por eso copia al empezar el perfil (`cloneProfile`, profile.ts:88), los recipients y las extensiones. Go es síncrono y no lo necesita.
- `EncryptOptions` sigue campo a campo a su equivalente de Go: `profile`, `unlockAt`, `policy`, `recipients`, `newPortableKey`, `length`, `padding`, `critical`, `noncritical`, `controlCritical`, `controlNoncritical` y `now`. Añade `output` y `progress` (decisión 2).
- `Encrypted` sigue a `Result`:
- `dateKey`, el `unlockAt` efectivo y `capsuleId`;
- `format` (siempre 2), `length` (L), `padding` y `paddedLength` (P);
- `portableKey`: un `AccessKey` sin codificar, con `capsule_digest`, como en Go. Quien llama lo codifica con `encodeAccessKey`, que se autocomprueba, y lo borra con `wipeAccessKey`.
Añade `size` y, cuando no hay `output`, `dkc`.
- `encrypt` es asíncrono, y quien llama podría cambiar sus entradas durante una espera. Por eso copia al empezar el perfil (`cloneProfile`), `unlockAt`, los recipients y las extensiones. El resultado de `now()` se valida como cualquier `Instant`.
- No se reexporta desde `index.ts`.
- Alternativa descartada: devolver la `.dkk` ya codificada. La aplicación puede querer añadir extensiones no críticas de §44 antes de codificarla, y Go devuelve el objeto.
- Alternativa descartada: llamar al módulo `seal.ts`. En el spec, "sellar" es producir `SEALED_CONTROL`.
- Recomendación: la propuesta.
2. **Entrada y salida en streaming.**
- `src` puede ser `Uint8Array`, `Blob` o `ReadableStream<Uint8Array>`. Cualquiera de las tres se trocea en 64 KiB, con `subarray` y una `ReadableStream` de tipo pull, antes de entrar en `age`. Así `encryptSTREAM` nunca cifra de golpe un trozo grande y la escritura tiene presión inversa.
- `output` es un `WritableStream`. Se cierra solo cuando todo ha terminado. Ante cualquier fallo se aborta, también ante un `TypeError` de opciones, y los tests lo exigen. Sin `output`, el `.dkc` se devuelve en memoria, en un solo buffer reservado con el tamaño exacto, sin concatenar.
- Nada se escribe hasta que todo está comprobado, incluida la cabecera de `PAYLOAD_AGE` (sección 4, pasos 15 y 16). Es una garantía más fuerte que la de Go, que escribe esa cabecera en `dst` antes de comprobarla.
- `progress(written, total)` se llama con `written = 0` justo antes de la primera escritura, con el tamaño exacto cuando la entrada es un `Blob` o un `Uint8Array`. La página comprueba ahí la cuota; si `progress` lanza, no se escribe nada.
2. **Entrada, longitud y salida en streaming.** *Cambia: L conocida de antemano y el relleno.*
- `src` puede ser `Uint8Array`, `Blob` o `ReadableStream<Uint8Array>`.
- **L:**
- con un `Uint8Array` o un `Blob`, es su tamaño. Si además llega `length`, tiene que ser igual, o hay `TypeError`;
- con un `ReadableStream`, `length` es obligatoria.
En los tres casos se comprueba que no pase de L_MAX (`PaddedLength` de Go da el texto) antes de escribir nada.
- **La fuente entrega exactamente L bytes.** Si da menos, o más, `encrypt` falla con los textos de `writeContent` de Go:
- `capsule: the source ended after %d bytes, and EncryptOptions.Length is %d`;
- `capsule: the source delivers more than the %d bytes of EncryptOptions.Length`.
Solo se descubre al leerla, cuando ya se ha escrito parte del `.dkc`: la salida se aborta (§62.1, regla 9). Con un `Blob`, o un `Uint8Array` que nadie cambia, no pasa; aun así se cuentan los bytes de toda fuente, porque la vista de un `ArrayBuffer` redimensionable o transferido puede encogerse durante una espera. Los trozos vacíos no cuentan como "más".
- La entrada se trocea en 64 KiB antes de entrar en `age`, con `subarray` y una `ReadableStream` de tipo pull. Así `encryptSTREAM` nunca cifra de golpe un trozo grande y la escritura tiene presión inversa. Tras los L bytes llegan los P − L ceros del relleno, en trozos de 64 KiB nuevos, nunca un búfer compartido que `age` pudiera retener.
- `output` es un `WritableStream`. Se cierra solo cuando todo ha terminado y se aborta ante cualquier fallo, también ante un `TypeError` de opciones.
- Sin `output`, el `.dkc` se devuelve en memoria, en un buffer del tamaño exacto, que ahora siempre se conoce: `16 + |PUBLIC_HEADER| + SEALED_CONTROL_LEN + payloadAgeLength(P)`. Como con un `ReadableStream` ese tamaño sale de un `length` que nada respalda todavía (hasta L_MAX), la salida en memoria tiene un máximo exportado, `MAX_MEMORY_DKC`, que se propone de 1 GiB: por encima, `TypeError` antes de sellar nada. El buffer se reserva después de `progress(0, total)`, nunca antes (§57).
- Nada se escribe hasta que todo lo que se puede comprobar antes del contenido está comprobado, incluida la cabecera de `PAYLOAD_AGE` (decisión 8). Es una garantía más fuerte que la de Go, que escribe esa cabecera antes de comprobarla.
- `progress(written, total)` se llama con `written = 0` justo antes de la primera escritura, y `total` es siempre el tamaño exacto. La página comprueba ahí la cuota; si `progress` lanza, no se escribe nada.
- El SHA-256 del `capsule_digest` se actualiza con cada trozo antes de escribirlo; es el `io.MultiWriter` de Go.
- La página cancela con `cancellable`, el mismo mecanismo que la apertura: su salida deja de aceptar datos.
- Alternativa descartada: `ReadableStream.tee()` para el hash. Con un destino lento, la otra rama crece sin límite.
- Alternativa aplazada: una `AbortSignal` en las opciones. Go no la tiene, y `cancellable` basta.
- La página cancela con `cancellable`, como la apertura: su salida deja de aceptar datos.
- Alternativa descartada: volcar a un fichero temporal un `ReadableStream` sin longitud. §62.1 lo permite (MAY), pero la librería no tiene dónde, y la página solo lee ficheros, cuyo tamaño se conoce (decisión 17).
- Recomendación: la propuesta.
3. **`SEALED_CONTROL_LEN` con un borrador, como Go, y el pairing de la ronda en caché.**
- Se sella un CONTROL_CBOR con las extensiones reales y con `header_binding` e `I_PAYLOAD` a cero. La longitud del resultado va al PRELUDE. Tras el sellado real se exige la misma longitud.
- `timeRecipient` guarda el pairing `e(H(id), clave)` de su ronda, que no depende de sigma. Así el borrador y el sellado real hacen un solo pairing, y el coste de escribir casi se reduce a la mitad.
- Alternativa: calcular la longitud con las fórmulas de la sección 1. Ahorra un wrap tlock, pero copia el formato de `age` en el código y fallaría en silencio si `age-encryption` cambiara su cabecera.
- Recomendación: el borrador y la caché. Las fórmulas quedan como aserción de los tests.
3. **`SEALED_CONTROL_LEN` con la fórmula del §62.1, comprobada.** *Cambia: la v1 proponía el borrador de Go y una caché del pairing.*
- Se codifica CONTROL_CBOR con `header_binding` e `I_PAYLOAD` a cero y con L, el código y las extensiones reales. Su longitud C no depende de esos ceros (§62.1, regla 7). Con C, las fórmulas de la sección 1 dan `SEALED_CONTROL_LEN`, que va al PRELUDE.
- Después se sella una sola vez, el control real, y se exige que el sellado mida exactamente eso. Si no, el error interno de Go: `capsule: internal error: SEALED_CONTROL is %d bytes, measured %d`. Nunca sale una cápsula mal enmarcada.
- Coste: un sellado de 16 stanzas y un wrap tlock por cápsula, en lugar de dos de cada.
- Alternativa: el borrador de Go, sellar dos veces. Es la otra vía del §62.1 y la de la referencia. Cuesta el doble, y con la caché del pairing de la v1 algo menos, a cambio de más código en `tlock.ts`.
- Recomendación: la fórmula, sin caché. La igualdad con el sellado real la hace segura, y los tests comprueban la fórmula contra el borrador.
4. **Aleatoriedad, y determinismo en los tests.**
4. **Aleatoriedad, y determinismo en los tests.** *Cambia: los señuelos y el orden son valores propios del writer.*
- Todo sale de `crypto.getRandomValues`:
- los valores propios del writer: `capsule_id`, `I_PAYLOAD`, `I_ACCESS` y `credential_id` (MUST de §21, §37, §38 y §42);
- los valores propios del writer, MUST de §62.1 regla 5: `capsule_id`, `I_PAYLOAD`, `I_ACCESS`, `credential_id`, los escalares de los señuelos y los índices de la permutación;
- sigma, en `ibe.ts`;
- lo que extrae `age-encryption`: file keys, efímeras y nonces.
- El orden de extracción es el de Go: `I_ACCESS`, `capsule_id`, `I_PAYLOAD`, borrador, sellado real, payload y `credential_id`.
- El núcleo del writer está en `src/lib/dkc/writer.ts` y recibe un generador. `encrypt.ts` lo llama siempre con `crypto.getRandomValues`, y ningún módulo de producción puede pasarle otro.
- Para los tests, `src/lib/dkc/testing/encrypt.ts` llama al núcleo con los cuatro valores fijados por nombre. Una guarda comprueba que solo `encrypt.ts` y `testing/` importan `writer.ts`. El precedente es `encryptOnG2WithSigma`, que solo fija sigma.
- Los valores internos de `age` no se inyectan, porque §67 no lo exige. Con los valores de un sidecar, las secciones deterministas salen iguales byte a byte a las del fixture de Go.
- Alternativa descartada: exportar `encryptWithDraws` desde `encrypt.ts`. Dejaría fijar `capsule_id` o `I_ACCESS` desde código de producción, contra los MUST de generador criptográfico.
- Alternativa descartada: sustituir `crypto.getRandomValues` en los tests por un generador con semilla. Ataría los tests al orden interno de extracciones de `age-encryption` 0.3.1 y de Web Crypto.
- **Índice sin sesgo:** para un índice en `[0, n)`, se sacan 32 bits y se rechaza todo valor desde `⌊2³² / n⌋ · n`. La permutación es Fisher–Yates con ese índice, como `permute` de Go con `crypto/rand.Int`.
- El orden de extracción es el de Go: `I_ACCESS`, `capsule_id`, `I_PAYLOAD`, los señuelos, la permutación y, al final, `credential_id`.
- El núcleo del writer está en `src/lib/dkc/writer.ts` y recibe un objeto de extracciones con un método por valor. `encrypt.ts` lo llama siempre con el de `crypto.getRandomValues`, y ningún módulo de producción puede pasarle otro.
- Para los tests, `src/lib/dkc/testing/encrypt.ts` llama al núcleo con valores fijados por nombre. La permutación se fija desde `access_key_stanza` e `identity_stanzas` del registro, así que cada credencial abre el hueco del fixture. Una guarda comprueba que solo `encrypt.ts` y `testing/` importan `writer.ts`.
- Los valores internos de `age` no se inyectan, porque §67 no lo exige. Con los valores de un registro, las secciones deterministas salen iguales byte a byte a las del fixture de Go.
- La permutación y qué huecos son señuelos no salen nunca del writer: ni en el resultado, ni en un error, ni en un registro (§62.1, regla 4). Solo la variante de tests los conoce, porque se los da quien llama.
- Alternativa descartada: `Math.random`, o el módulo de 32 bits sin rechazo. El segundo sesga el orden para 16 huecos, y §39 exige uniformidad.
- Recomendación: la propuesta.
5. **Identidades X25519 en bytes crudos; ningún secreto como cadena.**
- `I_PAYLOAD` e `I_ACCESS` son 32 bytes de `getRandomValues`.
- Su recipient se deriva con `x25519.getPublicKey` de noble, en `x25519.ts`, que ya está en la lista blanca de noble.
- El recipient llega a `age-encryption` como `age1…`, escrito por `recipient.ts`. El wrap lo hace el `X25519Recipient` de `age-encryption`, igual que Go usa el de `age`.
5. **Identidades X25519 en bytes crudos; ningún secreto como cadena.** *Sin cambios, ampliada a los señuelos.*
- `I_PAYLOAD`, `I_ACCESS` y cada escalar de señuelo son 32 bytes de `getRandomValues`.
- Su clave pública se deriva con `x25519.getPublicKey` de noble, en `x25519.ts`, que ya está en la lista blanca de noble.
- El escalar de un señuelo se pone a cero en cuanto se deriva su clave pública, antes de sellar nada (§39, §62.1, regla 4). Los bigints en que noble lo convierte no se pueden borrar (sección 6).
- Los recipients llegan a `age-encryption` como `age1…`, escritos por `recipient.ts`. El wrap lo hace el `X25519Recipient` de `age-encryption`, igual que Go usa el de `age`.
- Alternativa descartada: `generateX25519Identity` e `identityToRecipient` de `age-encryption`. Devuelven cadenas que no se pueden borrar, y `generateIdentity` avisa de que puede pasar a devolver identidades híbridas.
- Alternativa descartada: un `Recipient` X25519 propio. Es más criptografía propia que revisar, y solo ganaría poder borrar la efímera y el secreto compartido, que `age-encryption` no borra (sección 6).
- Recomendación: la propuesta.
6. **Recipients: bytes crudos en la librería, `age1…` en la aplicación, y solo los que se pueden abrir.**
6. **Recipients: bytes crudos en la librería, `age1…` en la aplicación, con las comprobaciones de Go.** *Cambia: ya no hay diferencia con la referencia, y el máximo es 16.*
- `EncryptOptions.recipients` son claves públicas X25519 de 32 bytes, como los `*age.X25519Recipient` de Go.
- `src/lib/dkc/recipient.ts`, **sin noble**, lee y escribe las cadenas:
- `parseX25519Recipient` acepta `age1…` en minúsculas, HRP `age` y 32 bytes. No acepta `AGE1…`, mayúsculas mezcladas, `age1pq1…` ni `age1tag1…`;
- `formatX25519Recipient` escribe `age1…`.
Tiene que ir aparte de `x25519.ts`, que importa noble: la página valida las líneas mientras se escriben, y eso metería noble en su primera carga, que `check-build` prohíbe.
- **Además, rechaza las claves que nadie podría abrir**, tanto en `parseX25519Recipient` como en `encrypt`, con un texto propio:
- las no canónicas (bit 255 activado, o u ≥ p): el stanza se escribe, pero ninguna identidad lo abre. Si es la única credencial, la cápsula no se abre nunca;
- las de orden bajo (u = 0, u = 1, los dos puntos de orden 8 y p − 1): `age` falla al cifrar con un error de Web Crypto.
Es una diferencia documentada con Go, que acepta las no canónicas en silencio. Se propone a Go para una versión posterior.
- Una lista escrita por una persona se lee como un fichero `-R` de `age`: se recortan espacios, se aceptan CRLF, se ignoran las líneas vacías y las que empiezan por `#`, y un error se da por número de línea.
Va aparte de `x25519.ts`, que importa noble: la página valida las líneas mientras se escriben, y eso metería noble en su primera carga, que `check-build` prohíbe.
- `checkX25519Recipient(raw)` aplica la regla 3 de §62.1 como `agewrap.CheckX25519Recipient`, con sus textos:
- bit 255 activado → `agewrap: recipient age1… is not canonical: bit 255 is set`;
- u ≥ p → `… is not canonical: u is not below 2^255 - 19`;
- orden bajo → `… is a point of low order: the shared secret would be zero`.
El orden bajo se comprueba con la lista de las cinco coordenadas u canónicas de la sección 1, sin aritmética de curva. Una vez descartadas las no canónicas, es equivalente a la prueba de Go con un escalar cualquiera, y un test lo contrasta con noble.
- `encrypt` los comprueba todos, en el orden de `accessRecipients` de Go y con sus textos:
- `time_only` con credenciales → `capsule: time_only takes no recipients and no portable key`;
- política desconocida → `capsule: unknown access policy %d`;
- ninguna credencial → `capsule: time_and_key needs at least one recipient or a portable key`;
- más de 16 → `capsule: time_and_key takes at most 16 credentials, recipients and portable key together; %d given`;
- un recipient que no es canónico o es de orden bajo → `capsule: recipient %d: agewrap: …`;
- uno repetido → `capsule: recipient age1… listed twice; INNER_ACCESS_AGE holds one stanza per recipient`.
- Una lista escrita por una persona se lee como un fichero `-R` de `age`, algo más tolerante: se aceptan CRLF, se ignoran las líneas vacías y las que empiezan por `#`, y además se recortan los espacios de cada línea, que `age` 1.3.2 no recorta. Como `age`, una línea que empieza por `AGE-` se rechaza con un aviso propio: es una identidad secreta pegada por error. Un error se da por número de línea, nunca por su contenido.
- **Puntos del twist.** §37 deja en MAY rechazar un recipient canónico que está en el twist, con el símbolo de Legendre: nadie podría abrir su stanza. `CheckX25519Recipient` no lo hace, y `age-encryption` cifra hacia él sin error. Se propone lo mismo que Go, aceptarlo, para mantener la paridad. Alternativa: comprobar el símbolo de Legendre con `BigInt` en `recipient.ts`, sin noble, y proponerlo también para Go.
- A `addRecipient` solo llegan cadenas escritas por `formatX25519Recipient`.
- Los duplicados se detectan por bytes y dan el texto de Go. El orden es el de quien llama, con `R_ACCESS` el último.
- Más de 1 024 stanzas: se mantiene la paridad con Go. `ageStanzas` rechaza la cabecera con el texto de la referencia y `ERR_INTEGRITY` (age.ts:244-252), igual que `agewrap.Stanzas` (encrypt.go:147-149). La página limita el número de líneas.
- Alternativa descartada: aceptar cadenas en la librería. En Go las lee la CLI, no `capsule.Encrypt`, y pasar a `addRecipient` lo que escribe una persona dejaría entrar recipients que no son X25519.
- Recomendación: la propuesta.
7. **Reloj y entradas obligatorias.**
7. **Reloj y entradas obligatorias.** *Sin cambios.*
- `now: () => Instant` es obligatorio y se llama una sola vez.
- `unlockAt` tiene que ser estrictamente posterior a `now()`, como en Go (encrypt.go:83).
- Un `Instant` mal formado (segundos no enteros, o `nanos` fuera de 0 a 999 999 999) es un `TypeError`. `resolveDateKey` no lo comprueba (datekey.ts:507-521).
- `unlockAt` tiene que ser estrictamente posterior a `now()` (§62.1, regla 2) → `capsule: unlock time <RFC3339Nano> is not in the future`.
- Un `Instant` mal formado (segundos no enteros, o `nanos` fuera de 0 a 999 999 999) es un `TypeError`, porque `resolveDateKey` no lo comprueba.
- `policy` es obligatoria. En Go su valor cero es `time_only`; en TypeScript, omitirla es un `TypeError`, para que la política sea siempre explícita.
- `compareInstants` pasa a `datekey.ts`, y `open.ts` lo usa en lugar de su `before`.
- Alternativa descartada: aceptar instantes pasados y dejar la política a la aplicación. El spec no lo prohíbe, pero Go lo exige y la paridad simplifica los tests.
- Recomendación: la propuesta.
8. **Autocomprobaciones: las de Go y dos más.**
- Las de Go, dentro de `encrypt`:
8. **Autocomprobaciones: las de la regla 11, como Go, y dos más.** *Cambia: las del formato 2.*
- Dentro de `encrypt`, antes de escribir nada:
- PUBLIC_HEADER con `decodeHeader`;
- `INNER_ACCESS_AGE` con `ageStanzas`, `checkAccessStanzas` y el número de stanzas;
- CONTROL_CBOR con `decodeControl`;
- la longitud sellada igual a la medida.
- CONTROL_CBOR con `decodeControl(…, FORMAT_2)`; la copia de `I_PAYLOAD` que devuelve se borra en el acto, como en Go;
- `INNER_ACCESS_AGE`: `ageStanzas` y `checkAccessStanzas(stanzas, 16)`, 16 stanzas X25519 con shares distintos. Con clave portable, `accessIdentity([I_ACCESS], 16)` lo abre y da exactamente el control, como `selfCheckInner`, y ese control descifrado se borra después. `decryptAll` y `readAll`, que ya borran sus trozos, son privados de `open.ts`: pasan a un módulo interno que comparten la apertura y el writer;
- `OUTER_TIME_AGE` con `checkTimeStanzas`, las comprobaciones de los pasos 5 y 8 de §63. Go no la necesita, porque su `age` tiene labels;
- la longitud del sellado igual a la calculada (decisión 3);
- la cabecera de `PAYLOAD_AGE`, el primer trozo del stream de `age`: `Decrypter.decryptHeader` con `payloadIdentity(I_PAYLOAD)`, que aplica `checkPayloadStanzas`, desenvuelve la file key y verifica el MAC sin necesitar el payload. La file key que devuelve se borra en el acto, como el `clear(fileKey)` de Go. Si falla, se cancelan el stream de `age` y la fuente. Go lo comprueba después de escribir; aquí va antes.
- Al terminar el contenido: se entregaron a `age` exactamente P bytes y `PAYLOAD_AGE` mide `payloadAgeLength(P)`, como `selfCheckPayload`.
- La `.dkk` se autocomprueba al codificarla, en `encodeAccessKey`, como `MarshalBody` en Go. No es parte de `encrypt`.
- Dos más, que Go no necesita porque su `age` tiene labels:
- `checkTimeStanzas` sobre `OUTER_TIME_AGE`: las comprobaciones de los pasos 5 y 8 de §63;
- `checkPayloadStanzas` sobre la cabecera de `PAYLOAD_AGE` (paso 6), antes de escribir nada.
- Como en Go, el writer no vuelve a descifrar lo que escribe.
- Con encoders correctos estas comprobaciones no fallan nunca. Sus ramas de fallo se cubren con `vi.mock` de los encoders cuando se puede, y si no con `v8 ignore` justificado, como en accesskey.ts:263-268. La lista está en la sección 8.
- Como Go, el writer no vuelve a descifrar el payload que escribe.
- Con encoders correctos estas comprobaciones no fallan nunca. Como en `TestEncryptSelfCheck` de Go, cada una es una función que `testing/` puede llamar directamente con entradas malas, sin `v8 ignore`.
- Alternativa: exactamente las de Go.
- Recomendación: las de Go y las dos más, documentadas como diferencia con la referencia.
- Recomendación: las de Go, con la de `OUTER_TIME_AGE` y la cabecera del payload antes de escribir, documentadas como diferencia.
9. **Errores: textos y códigos de Go donde hay equivalente, y diferencias listadas.**
9. **Errores: textos y códigos de Go donde hay equivalente.** *Cambia poco: menos diferencias.*
- Todo error con equivalente en Go lleva su texto byte a byte y su código normativo. Cuando Go no le da código, el error TypeScript tampoco lo lleva, como `datekeys.Code`.
- Las entradas que faltan o están mal formadas son `TypeError` con texto propio, como en `open` (open.ts:125-129 frente a open.go:91-94). Son diferencias documentadas:
- sin `profile`, sin `now` o sin `policy`;
- un `Instant` mal formado;
- un recipient que no es de 32 bytes (Go imprime el tipo con `%T`);
- un recipient no canónico o de orden bajo (Go acepta el primero y devuelve el texto de `age` en el segundo).
- **Los errores de la fuente y de la salida se relanzan sin tocar y sin código**, como hace Go (encrypt.go:208-209, 218-222). No pasan a `ERR_INTEGRITY` como en `open`, porque escribir no es un paso de §63.
- El código de relleno y L los comprueba el writer, con los textos de `PaddedLength` de Go (`capsule: padding code %d is not defined`, `capsule: content of %d bytes exceeds L_MAX = %d`), antes de llamar a `paddedLength`, cuyos `RangeError` tienen texto propio. `padding` ausente es `reforzado`; `padding: 0` es un error, donde en Go el valor cero es el de por defecto.
- Las entradas que faltan o están mal formadas son `TypeError`, como en `open`:
- sin `profile` o sin `now`, con los textos de Go (`capsule: EncryptOptions.Profile is required`, `… Now is required`);
- sin `policy`, y un `Instant` mal formado, con texto propio;
- un recipient que no son 32 bytes (Go imprime el tipo con `%T`);
- un `length` que no es un entero seguro no negativo, o que no coincide con el tamaño de un `Blob` o un `Uint8Array`.
- **Los errores de la fuente y de la salida se relanzan sin tocar y sin código**, como en Go. No pasan a `ERR_INTEGRITY` como en `open`, porque escribir no es un paso de §63.
- Los fallos de `age-encryption`, Web Crypto o noble llevan un texto fijo, con el original solo en `cause`.
- El script de Go del paso 5 registra los textos de la tabla de opciones inválidas, y un test los compara.
- Recomendación: la propuesta.
10. **Extensiones sin registro en el writer (§76, corrección 4).**
10. **Extensiones sin registro en el writer (§72).** *Sin cambios.*
- Como en Go, `encrypt` y `encodeAccessKey` escriben las extensiones que reciben, después de las reglas de §54 que ya aplican los encoders. No reciben un `ExtensionRegistry`: la regla de ubicación de §72 la aplica la aplicación.
- La página de esta fase no escribe extensiones, así que cumple la regla sin más.
- Alternativa aplazada: un `extensions?: ExtensionRegistry` opcional que rechace una extensión registrada fuera de su sitio. Rompe la paridad de API con Go; si se quiere, conviene proponerlo también para Go en una versión posterior.
- La página de esta fase no escribe extensiones, así que la cumple sin más.
- Recomendación: la propuesta.
11. **Interoperabilidad TS → Go a nivel de cápsula, con el patrón de la fase 2.**
11. **Interoperabilidad TS → Go a nivel de cápsula.** *Cambia: muestras de formato 2.*
- `scripts/capsule-ts-samples.mjs` escribe cápsulas para rondas ya publicadas (1000, 1001 y 2000), con `now` en el génesis, y claves e instantes fijos.
- `scripts/capsule-go-verdicts.go`, en un módulo temporal con `replace` a `../datekeys-go`, las inspecciona y las abre con `capsule.Open` y las firmas publicadas de esas rondas. No puede usar `internal/testkit`. Usa:
- `profile.Default` y un `extension.Registry` para las muestras con extensiones críticas;
- `provider.ReleaseSourceFunc` y `capsule.ParsePrelude`;
- `agewrap.NewTimeIdentity`, `NewAccessIdentity` y `X25519IdentityFromRaw`.
- `scripts/capsule-go-verdicts.go`, en un módulo temporal con `replace` a `../datekeys-go` y la directiva `go` de la referencia, las inspecciona y las abre con `capsule.Open` y las firmas publicadas. No puede usar `internal/testkit`: usa `profile.Default`, `provider.ReleaseSourceFunc`, `capsule.ParsePrelude`, `capsule.DecodeControl` y `agewrap`.
- Go registra, además de abrirlas con cada credencial: el formato, L, el código y P que da `Opened`, y el número de stanzas de `INNER_ACCESS_AGE`, que tiene que ser 16.
- El resultado se congela en `src/lib/dkc/testing/capsule-vectors.json`. El script se ejecuta a mano.
- Alternativa descartada: la CLI `datekeys decrypt`. Solo pide releases a los relays (main.go:209-215).
- Alternativa descartada: generar las muestras en cada ejecución. Metería Go en `npm run verify`.
- Recomendación: la propuesta.
12. **La página, en una ruta propia y como último paso.**
12. **La página, en una ruta propia y como último paso.** *Sin cambios.*
- Tiene sus propias decisiones de interfaz (sección 9), que el autor confirma en el paso 6. Los pasos de librería no dependen de ella.
- Ruta propuesta: `/create`, por coherencia con `/inspect`.
- Alternativa: `/crear`.
- Alternativa descartada: una acción dentro de `/inspect`. Mezclaría estados, y el writer se cargaría con el inspector.
- Ruta propuesta: `/create`, por coherencia con `/inspect`. Alternativa: `/crear`.
- Recomendación: `/create`.
13. **Listas blancas de importación.**
- `age-encryption` solo la importan `open.ts`, `tlock.ts`, `writer.ts` y los tests. Es simétrica a la de noble, que no cambia: `encrypt.ts`, `writer.ts` y `recipient.ts` no importan noble.
13. **Listas blancas de importación.** *Sin cambios.*
- `age-encryption` solo la importan `open.ts`, `tlock.ts`, `writer.ts` y los tests. La de noble no cambia: `encrypt.ts`, `writer.ts` y `recipient.ts` no importan noble.
- `writer.ts` solo lo importan `encrypt.ts` y `testing/` (decisión 4).
- Alternativa: sin guardas nuevas, como hoy.
- Recomendación: añadirlas.
14. **Versión.**
- Cerrar `0.1.0` con la fase 2 antes de empezar, que es lo que el HANDOFF deja pendiente, y llevar la fase 3 en `0.2.0-dev`. El writer produce ficheros que la gente guardará años y cambia la superficie de seguridad.
14. **Versión.** *Sin cambios, sigue abierta.*
- Cerrar `0.1.0` antes de empezar, con la inspección y la apertura de los dos formatos (spec 0.9), y llevar la fase 3 en `0.2.0-dev`. El writer produce ficheros que la gente guardará años y cambia la superficie de seguridad.
- Alternativa: incluir la fase 3 en `0.1.0`.
- Recomendación: la primera. Decide el autor.
15. **Bucle de propiedades: 50 semillas en `verify` y 500 a mano.**
- Cada caso hace dos sellados tlock más el IBE de `open`. Con 500 semillas, `npm run verify` tardaría de 3 a 4 minutos más, aun con la caché de la decisión 3.
- 50 semillas en cada ejecución, y 500 en una ejecución manual por paso, anotada en el HANDOFF.
15. **Bucle de propiedades: 50 semillas en `verify` y 500 a mano.** *Sin cambios.*
- Cada caso hace un sellado tlock y, en `time_and_key`, 16 wraps X25519, más el IBE de `open`. 50 semillas en cada ejecución y 500 en una ejecución manual por paso, anotada en el HANDOFF.
- Recomendación: la propuesta.
16. **El código de relleno.** *Nueva.*
- La librería usa `reforzado` por defecto (§62.1, regla 10) y acepta `padding: BLOQUE256`, como Go.
- La página no deja elegir: siempre `reforzado` (sección 9). `bloque256` revela más de L, y la diferencia de tamaño no compensa a quien no sabe elegir.
- Alternativa: ofrecer las dos en la página, con una explicación.
- Recomendación: la propuesta.
17. **Contenido de longitud desconocida.** *Nueva.*
- La librería exige L. Un `ReadableStream` sin `length` es un `TypeError` antes de leer nada. No vuelca la fuente a un fichero temporal.
- La página solo cifra ficheros, cuyo tamaño da el navegador, así que no lo necesita.
- Alternativa: que `encrypt` vuelque un `ReadableStream` sin longitud a OPFS antes de sellar (§62.1, regla 6, MAY). Metería OPFS en la librería, que hoy no lo toca.
- Recomendación: la propuesta.
---
@ -204,85 +235,85 @@ Propuestas el 29-09-2026 y pendientes de confirmación del autor (paso 0 de la s
| `age-encryption` | 0.3.1 | ya aprobada e instalada; hace las tres envolturas `age` |
| `@noble/curves` | 2.4.0 | ya directa; `x25519.getPublicKey`, desde `x25519.ts` |
| `@noble/hashes` | 2.4.0 | ya directa; SHA-256 incremental, desde `digest.ts` |
| `@noble/ciphers` | 2.4.0 | ya directa; el writer no la usa (ChaCha20-Poly1305 lo aplica `age-encryption`) |
| `@noble/ciphers` | 2.4.0 | ya directa; el cifrado lo hace `age-encryption`, y el writer solo la usa a través de `x25519.ts` en las autocomprobaciones |
**No hace falta ningún paquete nuevo.** `@scure/base` y `@noble/post-quantum` ya van en el bundle a través de `age-encryption`, pero no son dependencias directas y no se importan.
Donde un paquete podría parecer útil, la alternativa con código propio es esta:
**No hace falta ningún paquete nuevo.** Donde uno podría parecer útil, la alternativa con código propio es esta:
- **zip para entregar `.dkc` y `.dkk` juntos:** dos descargas separadas, que es lo recomendado, porque a menudo la `.dkk` debe viajar por otro canal;
- **zonas horarias (polyfill de Temporal, luxon, date-fns-tz):** `Intl.DateTimeFormat` con `formatToParts` e `Intl.supportedValuesOf('timeZone')`, unas 40 líneas en la página;
- **selector de fecha:** los `<input type="date">` y `<input type="time">` nativos;
- **tests de propiedades (`fast-check`, que sería de desarrollo):** un bucle con un generador propio (splitmix64) que imprime la semilla, unas 20 líneas.
- **tests de propiedades (`fast-check`, que sería de desarrollo):** un bucle con un generador propio (splitmix64) que imprime la semilla, unas 20 líneas;
- **índices aleatorios sin sesgo:** el rechazo de la decisión 4, unas 10 líneas.
Guardas, todas en tests que corren en cada ejecución:
- siguen las actuales: versiones exactas, lockfile, lista blanca de noble, y ningún noble, `@scure/base` ni `age-encryption` en la primera carga de ninguna página;
- nuevas: las listas blancas de `age-encryption` y de `writer.ts` (decisión 13);
- `index.ts` no reexporta `encrypt.ts`, `writer.ts` ni `recipient.ts`, y un test lo comprueba;
- `check-build.mjs` generaliza la comprobación de carga bajo demanda: de `inspect.html` pasa a una lista de páginas, cada una con los paquetes que carga después (`inspect.html` y la página de crear).
- `check-build.mjs` generaliza la comprobación de carga bajo demanda a una lista de páginas, cada una con los paquetes que carga después (`inspect.html` y la página de crear).
La ausencia de red de la página de crear la garantiza la CSP (`connect-src 'self'`), que `check-build` ya exige, y se comprueba en el navegador en el paso 7. No se busca `fetch(` en el texto de los chunks: `ReleaseSource.fetch(` aparece en código compartido y daría falsos positivos.
La CSP (`connect-src 'self'`), que `check-build` ya exige, garantiza que la página de crear no hace ninguna petición a otro origen, y se comprueba en el navegador en el paso 7. Que el fichero no salga del dispositivo lo garantiza el código, que no lo envía a ningún sitio, no la CSP: `'self'` permite peticiones al propio origen.
---
## 4. `encrypt.ts` y `writer.ts`
`src/lib/dkc/encrypt.ts` exporta `encrypt`, que llama al núcleo de `writer.ts` con `crypto.getRandomValues`. `writer.ts` importa `Encrypter` de `age-encryption` y los módulos propios: `accesskey.ts`, `age.ts`, `control.ts`, `datekey.ts`, `digest.ts`, `errors.ts`, `extension.ts`, `framing.ts`, `header.ts`, `profile.ts`, `recipient.ts`, `tlock.ts` y `x25519.ts`. No importa noble directamente.
`src/lib/dkc/encrypt.ts` exporta `encrypt`, que llama al núcleo de `writer.ts` con `crypto.getRandomValues`. `writer.ts` importa `Encrypter` y `Decrypter` de `age-encryption` y los módulos propios. No importa noble directamente.
```ts
export interface EncryptOptions {
readonly profile: Profile; // required, as Go
readonly unlockAt: Instant; // requested instant
readonly policy: Policy; // required: TIME_ONLY or TIME_AND_KEY
readonly profile: Profile; // required, as Go
readonly unlockAt: Instant; // requested instant, after now()
readonly policy: Policy; // required: TIME_ONLY or TIME_AND_KEY
readonly recipients?: readonly Uint8Array[]; // raw 32-byte X25519 public keys
readonly newPortableKey?: boolean; // fresh I_ACCESS, never an existing one (§38)
readonly critical?: readonly Extension[]; // PUBLIC_HEADER
readonly newPortableKey?: boolean; // fresh I_ACCESS, never an existing one (§38)
readonly length?: number; // L: required for a ReadableStream
readonly padding?: Padding; // REFORZADO when omitted (§62.1 rule 10)
readonly critical?: readonly Extension[]; // PUBLIC_HEADER
readonly noncritical?: readonly Extension[];
readonly controlCritical?: readonly Extension[]; // CONTROL_CBOR
readonly controlNoncritical?: readonly Extension[];
readonly now: () => Instant; // required, called once
readonly now: () => Instant; // required, called once
readonly output?: WritableStream<Uint8Array>;
readonly progress?: (written: number, total: number | undefined) => void;
readonly progress?: (written: number, total: number) => void;
}
export interface Encrypted {
readonly dateKey: DateKey;
readonly unlockAt: Instant; // effective round time
readonly unlockAt: Instant; // effective round time
readonly capsuleId: Uint8Array;
readonly portableKey?: AccessKey; // the caller encodes and wipes it
readonly format: 2;
readonly length: number; // L
readonly padding: Padding;
readonly paddedLength: number; // P
readonly portableKey?: AccessKey; // the caller encodes and wipes it
readonly size: number;
readonly dkc?: Uint8Array; // only without output
readonly dkc?: Uint8Array; // only without output
}
export function encrypt(src: Uint8Array | Blob | ReadableStream<Uint8Array>, opts: EncryptOptions): Promise<Encrypted>;
```
**Flujo**, en el orden de `capsule.Encrypt`. Cada condición lleva el texto y el código de Go, salvo las diferencias de la decisión 9. Ante cualquier fallo, `output` se aborta y nunca se cierra.
1. Falta `profile`, `now` o `policy`, o `unlockAt` no es un `Instant` válido: `TypeError` con texto propio.
1. Entradas: falta `profile`, `now` o `policy`, `unlockAt` no es un `Instant` válido, o L no se puede fijar (decisiones 2 y 17): `TypeError`.
2. Copias de las entradas (decisión 1).
3. `validateProfile(profile)` (profile.ts:306): los textos y códigos de `p.Validate()`.
4. `now()`, una sola vez. Si `unlockAt` no es posterior: `capsule: unlock time <RFC3339Nano> is not in the future`, sin código (encrypt.go:83-85).
5. Paso 1 de §61 y §62: `resolveDateKey`, con los textos de `datekey.Resolve`, y `unlock = roundTime(round)`. Si `unlock` es anterior a lo pedido (§15): `capsule: resolved round %d opens before the requested time` → `ERR_ROUND_MISMATCH`. Es defensivo e inalcanzable, y lleva `v8 ignore` justificado.
6. Recipients de acceso, como `accessRecipients` (encrypt.go:264-300):
- `time_only` con recipients o con clave portable → `capsule: time_only takes no recipients and no portable key`;
- política desconocida → `capsule: unknown access policy %d`;
- un recipient que no son 32 bytes, que no es canónico o que es de orden bajo → `TypeError`, con texto propio (decisiones 6 y 9);
- un recipient repetido → `capsule: recipient age1… listed twice; INNER_ACCESS_AGE holds one stanza per recipient`;
- si se pide clave portable: `I_ACCESS` (32 bytes) y `R_ACCESS`, que se añade el último;
- sin ninguno → `capsule: time_and_key needs at least one recipient or a portable key`.
7. `capsule_id` (16 bytes) e `I_PAYLOAD` (32 bytes); de `I_PAYLOAD` se deriva `R_PAYLOAD`.
8. `encodeHeader`, que ya aplica el máximo de §57, y después `decodeHeader` como autocomprobación → `capsule: self-check: the reader rejects this PUBLIC_HEADER: …`, con el código del decoder (encrypt.go:243-248).
9. `timeRecipient(profile, round)` (tlock.ts:21), con el pairing en caché (decisión 3).
10. `seal(control)`:
- con `time_and_key`: un `Encrypter` con los recipients de acceso formateados, cuyo `encrypt(control)` da `INNER_ACCESS_AGE`. Después, `ageStanzas`: más de 1 024 stanzas dan su texto y `ERR_INTEGRITY` (decisión 6). Después, `checkAccessStanzas` y número de stanzas igual al de recipients; si falla → `capsule: INNER_ACCESS_AGE self-check failed` → `ERR_POLICY_STRUCTURE_MISMATCH` (encrypt.go:151-153), rama inalcanzable con encoders correctos;
- después, en los dos casos, un `Encrypter` distinto con `timeRecipient` **solo**, sobre el control o sobre `INNER_ACCESS_AGE`. Su resultado pasa `checkTimeStanzas` (decisión 8).
Cada `encrypt` de `age` genera su propia file key, así que las tres son independientes (§62, MUST).
11. Borrador: `encodeControl` con las extensiones reales y con binding e identidad de 32 bytes a cero, y después `seal`. Si el sellado pasa de 64 MiB → `capsule: SEALED_CONTROL of %d bytes exceeds 67108864` → `ERR_INTEGRITY` (encrypt.go:165-176). Si el propio CONTROL_CBOR ya pasa de 64 MiB, el sellado también pasaría: se falla antes de sellar, con el mismo texto y la longitud de las fórmulas de la sección 1, para no sellar en memoria un control enorme.
12. `preludeBytes({ PUBLIC_HEADER_LEN, SEALED_CONTROL_LEN })` y `headerBinding(prelude, header)`, sobre los bytes exactos (§26).
13. `encodeControl` real, y `decodeControl` como autocomprobación, que borra la identidad decodificada → `capsule: self-check: the reader rejects this CONTROL_CBOR: …` (encrypt.go:186-193, 253-260).
14. `seal` real. Si su longitud no es la del borrador → `capsule: internal error: SEALED_CONTROL is %d bytes, measured %d` (encrypt.go:196-202). Se borran CONTROL_CBOR e `I_PAYLOAD`.
15. `PAYLOAD_AGE`: un `Encrypter` con `R_PAYLOAD`, sobre la entrada troceada en 64 KiB (decisión 2). Se lee su primer trozo, que es la cabecera `age` sola, y pasa `checkPayloadStanzas`. `total = 16 + |PUBLIC_HEADER| + |SEALED_CONTROL| + stream.size(n)` cuando se conoce el tamaño n de la entrada. Si algo falla desde aquí sin haber escrito, se cancela ese stream para soltar la fuente.
16. `progress(0, total)`. Si lanza, no se escribe nada.
17. Escritura de PRELUDE, PUBLIC_HEADER, SEALED_CONTROL, la cabecera de `PAYLOAD_AGE` y el resto de sus trozos, cada uno por el hash antes de escribirse. Después se cierra la salida.
18. `.dkk`: un `credential_id` aleatorio y `AccessKey { capsuleId, type: 'x25519', material: copia de I_ACCESS, verification: { capsuleDigest } }` (encrypt.go:225-236). El writer borra su copia de `I_ACCESS` en un `finally`.
3. `validateProfile(profile)`, con los textos y códigos de `p.Validate()`.
4. `now()`, una sola vez. Si `unlockAt` no es posterior: `capsule: unlock time <RFC3339Nano> is not in the future`, sin código.
5. Código: el de `padding`, o `REFORZADO`. `paddedLength(L, código)` con los textos de `PaddedLength`: un código que no es 1 ni 2, o L > L_MAX.
6. `resolveDateKey` con los textos de `datekey.Resolve`, y `unlock = roundTime(round)`. Si `unlock` es anterior a lo pedido (§15): `capsule: resolved round %d opens before the requested time` → `ERR_ROUND_MISMATCH`. Es defensivo e inalcanzable, y lleva `v8 ignore` justificado.
7. Credenciales, como `accessRecipients` (decisión 6). Con clave portable: `I_ACCESS` y su clave pública, añadida la última de la lista de credenciales.
8. `capsule_id` (16 bytes) e `I_PAYLOAD` (32 bytes); de `I_PAYLOAD` se deriva `R_PAYLOAD`.
9. En `time_and_key`, los 16 huecos: las credenciales y un señuelo por hueco libre, cuyo escalar se borra al derivar su clave pública; después, la permutación (decisión 4).
10. `encodeHeader`, que ya aplica el máximo de §57, y `decodeHeader` como autocomprobación → `capsule: self-check: the reader rejects this PUBLIC_HEADER: …`, con el código del decoder.
11. `encodeControl` con binding e identidad a cero y los L, código y extensiones reales, en formato 2. De su longitud C, las fórmulas dan `SEALED_CONTROL_LEN` (decisión 3). Si pasa de 64 MiB → `capsule: SEALED_CONTROL of %d bytes exceeds 67108864` → `ERR_INTEGRITY`, sin sellar nada. Este control provisional se borra en cuanto se mide: lleva L y las extensiones de control, ocultas hasta la fecha (§55.2).
12. `preludeBytes({ format: 2, … })` y `headerBinding(prelude, header)`, sobre los bytes exactos (§26).
13. `encodeControl` real, y `decodeControl(…, FORMAT_2)` como autocomprobación → `capsule: self-check: the reader rejects this CONTROL_CBOR: …`; la copia decodificada se borra en el acto. Su longitud es C.
14. Sellado:
- con `time_and_key`, un `Encrypter` con los 16 recipients en su orden, sobre el control: `INNER_ACCESS_AGE`. Después, las autocomprobaciones de la decisión 8, con los textos de `selfCheckInner`;
- en los dos casos, un `Encrypter` distinto con `timeRecipient` **solo**, sobre el control o sobre `INNER_ACCESS_AGE`: `OUTER_TIME_AGE`. Pasa `checkTimeStanzas`.
Cada `encrypt` de `age` genera su propia file key, así que las tres son independientes (§62, MUST). Si la longitud no es la calculada → `capsule: internal error: SEALED_CONTROL is %d bytes, measured %d`. Se borran los bytes de CONTROL_CBOR y el control descifrado por la autocomprobación de `INNER_ACCESS_AGE`; `I_PAYLOAD` sigue hasta el paso 15.
15. `PAYLOAD_AGE`: un `Encrypter` con `R_PAYLOAD`, sobre la entrada troceada seguida del relleno (decisión 2). Su primer trozo es la cabecera `age` sola: `decryptHeader` con `payloadIdentity(I_PAYLOAD)` la abre, y la file key que devuelve se borra (decisión 8). Después se borra `I_PAYLOAD`. Si falla, se cancelan el stream de `age` y la fuente.
16. `progress(0, total)`, con `total = 16 + |PUBLIC_HEADER| + SEALED_CONTROL_LEN + payloadAgeLength(P)`. Si lanza, no se escribe nada. Sin `output`, el buffer de `total` bytes se reserva ahora.
17. Escritura de PRELUDE, PUBLIC_HEADER, SEALED_CONTROL, la cabecera de `PAYLOAD_AGE` y el resto de sus trozos, cada uno por el hash antes de escribirse. Si la fuente da menos o más de L bytes, el error de la decisión 2.
18. Autocomprobación final: P bytes entregados a `age` y `payloadAgeLength(P)` escritos. Después se cierra la salida.
19. `.dkk`: un `credential_id` aleatorio y `AccessKey { capsuleId, type: 'x25519', material: copia de I_ACCESS, verification: { capsuleDigest } }`. El writer borra su copia de `I_ACCESS` en un `finally`.
Si `age-encryption` falla durante un sellado, el error lleva un texto fijo y no tiene código normativo (decisión 9).
@ -292,12 +323,12 @@ Si `age-encryption` falla durante un sellado, el error lleva un texto fijo y no
**`recipient.ts`** (nuevo, sin noble):
- `parseX25519Recipient(s): Uint8Array` y `formatX25519Recipient(raw): string`, sobre `bech32.ts` (decisión 6);
- `checkX25519Recipient(raw)`: 32 bytes, canónico (bit 255 a cero y u < p, comparado como entero) y fuera de los cinco u de orden bajo canónicos. No necesita aritmética de curva;
- `checkX25519Recipient(raw)`: 32 bytes, canónico y fuera de las cinco coordenadas u de orden bajo, con los textos de Go;
- `parseRecipientList(text)`: el formato de un fichero `-R` de `age`, con errores por número de línea que nunca citan el contenido.
**`x25519.ts`.** Se añaden:
- `newX25519Identity(): Uint8Array`: 32 bytes de `getRandomValues`, sin clamping guardado, como `age.GenerateX25519Identity`;
- `x25519Recipient(identity): Uint8Array`: `x25519.getPublicKey`.
- `x25519PublicKey(identity): Uint8Array`: `x25519.getPublicKey`.
Ninguna de estas funciones produce la forma `AGE-SECRET-KEY-1…` de una identidad.
@ -305,158 +336,150 @@ Ninguna de estas funciones produce la forma `AGE-SECRET-KEY-1…` de una identid
**`datekey.ts`.** `compareInstants(a, b)` compara segundos y después nanosegundos; `checkInstant(t)` valida la forma. `open.ts` usa `compareInstants` en lugar de su `before`, sin cambiar ningún test de la apertura.
**`tlock.ts`.** La caché del pairing de la ronda dentro del recipient (decisión 3). Los vectores con sigma fijo de la fase 2 siguen saliendo iguales.
**`writer.ts`.** Además del núcleo, `randomIndex(n, draw)` y `permute(items, draw)` (decisión 4), y las autocomprobaciones como funciones propias (decisión 8), probados aparte.
**`agefile.ts`** (nuevo, interno). `decryptAll` y `readAll`, que hoy son privados de `open.ts`, para que la apertura y el writer los compartan (decisión 8). `open.ts` no cambia de comportamiento, y lo comprueban sus tests actuales.
**`tempfile.ts`.** La raíz y el nombre del fichero pasan a ser parámetros: `datekeys-open/…/plaintext` para la apertura y `datekeys-create/…/capsule` para crear. Cada página limpia las dos raíces al cargarse, para que lo que dejó una no espere a volver a la otra.
**`page-policy.ts`** (nuevo, sin dependencias) o `datekey.ts`: el umbral de 365 días del aviso de §53. No puede estar en `encrypt.ts`: la página lo importa de forma estática, y con él entrarían `age-encryption` y noble en su primera carga, que `check-build` prohíbe.
**`tempfile.ts`.** La raíz y el nombre del fichero pasan a ser parámetros: `datekeys-open/…/plaintext` para la apertura y `datekeys-create/…/capsule` para crear. Cada página limpia las dos raíces al cargarse.
`tlock.ts` no cambia: con la decisión 3 no hace falta la caché del pairing.
---
## 6. Escritura en streaming, salida y secretos
**Orden y hash.**
- Se escribe PRELUDE (16 bytes), luego PUBLIC_HEADER, luego SEALED_CONTROL y después el stream de `PAYLOAD_AGE`: su cabecera, el nonce de 16 bytes, trozos de 65 552 bytes y el último, más corto.
- Cada trozo se pasa primero por el hash (`hash.update(chunk)`) y después se escribe con `await writer.write(chunk)`. Esa espera da presión inversa, porque la entrada llega en trozos de 64 KiB (decisión 2).
- Un test exige que el primer trozo del stream de `age` sea exactamente su cabecera (`parseAgeHeader(primero).length === primero.length`): que venga sola es un detalle interno de `age-encryption` 0.3.1.
- Se escribe PRELUDE (16 bytes), luego PUBLIC_HEADER, luego SEALED_CONTROL y después el stream de `PAYLOAD_AGE`: su cabecera, el nonce de 16 bytes y trozos de 65 552 bytes. El último es más corto, salvo cuando P es múltiplo de 65 536, lo que con `reforzado` pasa con todo L desde 2 MiB.
- Cada trozo se pasa primero por el hash y después se escribe con `await writer.write(chunk)`. Esa espera da presión inversa, porque la entrada llega en trozos de 64 KiB.
- Un test exige que el primer trozo del stream de `age` sea exactamente su cabecera: que venga sola es un detalle interno de `age-encryption` 0.3.1.
**Contenido y relleno.**
- La fuente troceada entrega sus bytes y se cuentan. Al llegar a L, se lee una vez más: si da algo, es el error "more than"; si termina antes de L, el error "ended after".
- Después siguen los P − L ceros, en trozos nuevos de 64 KiB, con las cotas de la sección 1.
- `age` recibe exactamente P bytes, y la autocomprobación final lo confirma con la longitud de `PAYLOAD_AGE`.
**Confirmar o abortar.**
- No se escribe nada antes del paso 17 del flujo: todas las comprobaciones, los dos sellados y la cabecera de `PAYLOAD_AGE` van antes.
- La salida se cierra solo después del último trozo. Ante cualquier fallo, también un `TypeError` de opciones, se aborta.
- No se escribe nada antes del paso 17 del flujo: todas las comprobaciones, el sellado y la cabecera de `PAYLOAD_AGE` van antes.
- La salida se cierra solo después del último trozo y de la autocomprobación final. Ante cualquier fallo, también un `TypeError` de opciones o una fuente de longitud distinta de L, se aborta.
- Con un fichero de OPFS escrito con `createWritable`, abortar descarta el fichero swap, así que no se publica nada.
- Sin `output`, los trozos se copian en un buffer reservado con `total`. Si `total` no se conoce (una `ReadableStream`), se acumulan y se concatenan una sola vez al terminar.
- Sin `output`, los trozos se copian en un buffer reservado con `total`, que siempre se conoce.
**Secretos.**
- `I_PAYLOAD` vive desde que se genera hasta el sellado real. Después se borra, junto con los bytes de CONTROL_CBOR y la copia decodificada por la autocomprobación. El payload solo necesita `R_PAYLOAD`.
- `I_PAYLOAD` vive desde que se genera hasta la comprobación de la cabecera de `PAYLOAD_AGE` (paso 15). Los bytes de CONTROL_CBOR se borran tras el sellado (paso 14), y cada copia que usa una autocomprobación, en cuanto termina: la decodificada del paso 13, el control descifrado de `INNER_ACCESS_AGE` y la file key de la cabecera de `PAYLOAD_AGE`. El payload solo necesita `R_PAYLOAD`.
- El escalar de cada señuelo se borra en cuanto se deriva su clave pública, y nunca sale del writer.
- La copia de `I_ACCESS` del writer se borra en un `finally`, tanto si todo va bien como si falla. Solo la copia de `portableKey.material` sale del writer.
- El borrador no contiene secretos.
- El control provisional del paso 11 no lleva `I_PAYLOAD`, pero sí L y las extensiones de control, ocultas hasta la fecha: se borra en cuanto se mide.
- Los valores que fija la variante de tests pertenecen al writer y se borran igual; así los tests pueden comprobarlo, y pasan copias.
- Ningún mensaje de error contiene bytes de secretos.
- Ningún mensaje de error contiene bytes de secretos, ni dice qué huecos son señuelos.
No se pueden borrar, y se documenta en el README ("Secretos"). `SECURITY.md` de Go solo dice, en general, que el borrado es de mejor esfuerzo (SECURITY.md:34-36):
No se pueden borrar, y se documenta en el README ("Secretos"):
- las file keys, la stream key y el `plaintextBuffer` de 64 KiB de `encryptSTREAM`, que conserva una copia de CONTROL_CBOR con `I_PAYLOAD` y, en `PAYLOAD_AGE`, los últimos 64 KiB del fichero de la persona, hasta que actúa el recolector de basura;
- la efímera y el secreto compartido del `X25519Recipient`;
- los bigints en que `x25519.getPublicKey` de noble convierte `I_PAYLOAD` e `I_ACCESS`;
- los bigints en que `x25519.getPublicKey` de noble convierte `I_PAYLOAD`, `I_ACCESS` y los escalares de los señuelos;
- los `CryptoKey` de Web Crypto.
---
## 7. Extensiones, registro y especificación
- **Reglas de §54 en los encoders.** Ya las aplican: como mucho 64 extensiones por array, en orden estricto por bytes UTF-8 (no por unidades UTF-16); ids de 1 a 256 bytes; versión de 0 a 2³² − 1; `data` de 1 byte a 64 MiB; ningún id en los dos arrays de un mismo objeto. Un array vacío se omite (§58.1).
- **Ubicación (§72, corrección 4 de §76).** La aplica la aplicación (decisión 10). La obligación de §72 de que el encoder de `data` en CBOR decodifique su propia salida corresponde al encoder de cada extensión, no al writer, para el que `data` es opaca.
- **Extensiones de la `.dkk` (§44).** El writer devuelve el `AccessKey` sin extensiones, como Go. La aplicación puede añadir extensiones no críticas antes de llamar a `encodeAccessKey`.
- **Especificación.** Esta fase no necesita ningún cambio normativo, y la v0.8.2 está cerrada. Si el autor lo quiere, en una versión posterior se podrían añadir:
- en §61 y §62, una nota informativa de que `PAYLOAD_AGE` puede generarse el último, en streaming, y de cómo conocer `SEALED_CONTROL_LEN` antes de sellar;
- en §37, que un encoder SHOULD rechazar un recipient X25519 no canónico o de orden bajo, con su caso reproducible;
- en §74, junto al límite de 1 024 stanzas del lector, la consecuencia para un writer que ponga más recipients.
Nada de esto bloquea la fase.
- **`datekeys-go`.** No necesita cambios. Como coordinación opcional, rechazar recipients no canónicos y de orden bajo con un texto fijo, igual que TypeScript.
- **Reglas de §54 en los encoders.** Ya las aplican: como mucho 64 extensiones por array, en orden estricto por bytes UTF-8; ids de 1 a 256 bytes; versión de 0 a 2³² − 1; `data` de 1 byte a 64 MiB; ningún id en los dos arrays de un mismo objeto. Un array vacío se omite (§58.1).
- **Ubicación (§72).** La aplica la aplicación (decisión 10).
- **Extensiones de la `.dkk` (§44).** El writer devuelve el `AccessKey` sin extensiones, como Go.
- **Especificación.** Esta fase no necesita ningún cambio normativo: la v0.9 ya da las reglas del escritor, la fórmula de las longitudes y el rechazo de recipients no canónicos y de orden bajo, que la v1 proponía como cambios.
- **`datekeys-go`.** No necesita cambios: ya escribe el formato 2 con las mismas reglas.
---
## 8. Tests
1. **`recipient.test.ts`, `x25519.test.ts` y `digest.test.ts`.**
1. **`recipient.test.ts`, `x25519.test.ts`, `digest.test.ts` y las piezas de `writer.ts`.**
- El recipient de una identidad nueva coincide con el de `identityToRecipient` de `age-encryption` para la misma identidad, y con los vectores de RFC 7748.
- `parseX25519Recipient` rechaza `AGE1…`, mayúsculas mezcladas, `age1pq1…`, `age1tag1…`, 31 y 33 bytes, un checksum malo, el bit 255 activado, u ≥ p y los cinco u de orden bajo, y acepta todo lo que escribe `formatX25519Recipient`.
- Un recipient con el bit 255 activado se escribe con `age-encryption` y ninguna identidad lo abre: el test documenta por qué se rechaza.
- `parseX25519Recipient` rechaza `AGE1…`, mayúsculas mezcladas, `age1pq1…`, `age1tag1…`, 31 y 33 bytes y un checksum malo, y acepta todo lo que escribe `formatX25519Recipient`.
- `checkX25519Recipient` rechaza con los textos de Go el bit 255, u ≥ p (p y p + 1, entre otros) y las cinco coordenadas u de orden bajo. Un test comprueba con noble que esas cinco, y ninguna otra de una muestra, dan el secreto compartido cero.
- `randomIndex` rechaza exactamente los valores desde ⌊2³²/n⌋·n, con un generador fijado que los produce: esa es la guarda real contra el sesgo.
- `permute` es uniforme, como `TestStanzaOrderIsUniform` de Go: 32 000 permutaciones de 16 elementos, las posiciones del primero y del último, y χ² ≤ 60 con 15 grados de libertad. Con `getRandomValues` el test puede fallar por azar con probabilidad despreciable (del orden de 10⁻⁶); con un generador con semilla, que se imprime, es determinista. Las frecuencias por posición no detectan todo sesgo (una rotación aleatoria las pasa), y por eso cuenta la guarda anterior.
- El hasher da lo mismo que Web Crypto con la entrada troceada de varias formas.
- Cobertura del 100 %.
2. **Reproducción de los fixtures de Go**, con la variante de tests de `testing/encrypt.ts`. Para cada uno de los cinco fixtures, con **copias** de los valores del sidecar (el writer los borra):
- su `capsule_id` y su `payload_identity`;
- sus recipients, derivados de las identidades del sidecar;
2. **Reproducción de los siete fixtures de formato 2**, con la variante de tests. Para cada uno, con **copias** de los valores del registro (el writer los borra):
- su `capsule_id`, su `payload_identity`, su L, su código y sus extensiones;
- sus recipients, derivados de las identidades del registro, y la permutación que deja cada credencial en su hueco (`access_key_stanza`, `identity_stanzas`);
- su `I_ACCESS` y su `credential_id`, sacados de su `.dkk`;
- sus extensiones (las de `time_only_extensions`);
- su `unlock_at`, con `now` en el génesis;
- su plaintext.
- su `unlock_at`, con `now` en el génesis, y su contenido.
Resultados exigidos:
- PRELUDE, PUBLIC_HEADER, `header_binding` y CONTROL_CBOR iguales byte a byte; la variante de test devuelve CONTROL_CBOR;
- `SEALED_CONTROL` y `PAYLOAD_AGE` de la misma longitud;
- PRELUDE (con `VERSION` = 2), PUBLIC_HEADER, `header_binding` y CONTROL_CBOR (con las claves 6 y 7) iguales byte a byte;
- `SEALED_CONTROL` y `PAYLOAD_AGE` de la misma longitud que en el fixture;
- los argumentos del stanza tlock iguales;
- el stanza i de `INNER_ACCESS_AGE` se abre con la identidad i, y el último con `I_ACCESS`;
- en `time_and_key_portable` y `time_and_key_recipients`, la `.dkk` escrita es igual a `encodeAccessKey` de la del fixture con el `capsule_digest` sustituido por el SHA-256 del `.dkc` escrito. `time_and_key_portable_extension.dkk` no sale de `Encrypt` y no se compara.
- cada credencial abre el hueco que le da el registro, y los demás no los abre ninguna;
- en los fixtures con `.dkk`, la escrita es igual a `encodeAccessKey` de la del fixture con el `capsule_digest` sustituido por el SHA-256 del `.dkc` escrito.
Es la comparación byte a byte con Go de todo lo que es determinista (§67, §68).
3. **Opciones inválidas.** Los casos de `TestEncrypt` de Go y los propios:
3. **Opciones inválidas.** Los casos de `TestEncrypt`, `TestCredentialBounds` y `TestEncryptSourceLength` de Go y los propios:
- sin perfil, sin reloj, sin política, un `Instant` mal formado;
- un instante pasado, y uno igual a ahora;
- `time_only` con recipients, y con clave portable;
- `time_and_key` sin ninguna de las dos cosas;
- un recipient de 31 bytes, uno no canónico, uno de orden bajo, uno repetido;
- la política 7;
- `time_and_key` sin credenciales, y con 17: 16 recipients y la clave portable, o 17 recipients;
- un recipient de 31 bytes, uno con el bit 255, uno con u = p, uno de orden bajo, uno repetido;
- la política 7; el código de relleno 3;
- L = L_MAX + 1; un `length` distinto del tamaño de un `Blob`; un `ReadableStream` sin `length`;
- un chain hash con un bit cambiado;
- una extensión repetida en la cabecera, y una de control en los dos arrays;
- 1 024 recipients y la clave portable, es decir 1 025 stanzas.
- una extensión repetida en la cabecera, y una de control en los dos arrays.
Cada caso da el texto y el código de Go, o el texto propio listado en la decisión 9, y la salida recibe cero `write` y un `abort`.
Cada caso da el texto y el código de Go, o el `TypeError` de la decisión 9, y la salida recibe cero `write` y un `abort`.
4. **Ida y vuelta TS → TS, con `open`.**
- Credenciales: `time_only`; `time_and_key` solo con clave portable, con 1 y con 3 recipients, y con recipients y clave portable. Cada credencial abre sola, y todas juntas también.
- Payloads de 0, 1, 65 535, 65 536, 65 537 y 320 000 bytes.
- Entrada como `Uint8Array`, como `Blob` y como `ReadableStream`, con trozos irregulares y con un solo trozo de varios MiB.
- Credenciales: `time_only`; `time_and_key` con 1 y con 16 credenciales, con clave portable, con recipients y con las dos cosas. Cada credencial abre sola, y todas juntas también. Ningún señuelo coincide con una credencial.
- Contenidos de 0, 1, 255, 256, 257, 8 192, 8 193, 65 535, 65 536, 65 537, 78 000, 320 000 y 5 000 000 bytes, con los dos códigos: `open` devuelve el contenido y da el formato, L, el código y P. El último es el de `TestPaddingAcrossChunks` de Go: P = 5 111 808, un relleno de varios trozos STREAM. Los tamaños redondos en MiB no sirven para eso, porque su relleno es cero.
- Entrada como `Uint8Array`, como `Blob` y como `ReadableStream` con `length`, con trozos irregulares y con un solo trozo de varios MiB.
- Salida en memoria y en `WritableStream`.
- Extensiones críticas y no críticas en la cabecera y en el control, abiertas con un `ExtensionRegistry` que las conoce; y en la `.dkk`, recodificada.
- Rondas 1000, 1001 y 2000, con su release publicado.
- Un instante con nanosegundos: génesis + 2 997 s + 1 ns resuelve a la ronda 1001.
`open` devuelve el plaintext, e `inspect` pasa los 8 pasos.
- Rondas 1000, 1001 y 2000, con su release publicado, y un instante con nanosegundos: génesis + 2 997 s + 1 ns resuelve a la ronda 1001.
5. **Propiedades de Go.**
- Las claves portables nunca se repiten: dos cifrados dan material, `credential_id` y `capsule_id` distintos.
- La `.dkk` de A sobre la cápsula B da `ERR_ACCESS_INVALID` en el paso 9, sin ninguna petición de release. La identidad cruda de A sobre B da `ERR_ACCESS_INVALID` en el paso 13, después del release, porque solo ahí se prueba.
- `I_PAYLOAD` nunca se repite: dos cápsulas con el mismo contenido tienen `R_PAYLOAD` distintos, como `TestPayloadIdentityReuse` de Go.
- Los señuelos son nuevos en cada cápsula: dos cápsulas con la misma credencial dan 32 shares distintos, y sin clave portable no se devuelve ninguna clave, como `TestDummyRecipients` de Go.
- La `.dkk` de A sobre la cápsula B da `ERR_ACCESS_INVALID` en el paso 9, sin ninguna petición de release. La identidad cruda de A sobre B da `ERR_ACCESS_INVALID` en el paso 13, después del release.
- Con `now + 1 h`: pedido ≤ `unlockAt` < pedido + periodo. Abrir con ese `now` da `ERR_RELEASE_UNAVAILABLE` sin ninguna petición, e `inspect` da la misma DateKey.
- Las fórmulas de la sección 1 se cumplen en todas las muestras, con extensiones.
- El primer trozo del stream de `PAYLOAD_AGE` es su cabecera sola.
6. **Fallos durante la escritura.** Una salida que falla en el trozo k, una fuente que falla y un `progress` que lanza:
- Las fórmulas de longitud se cumplen en todas las muestras, con extensiones, y coinciden con un sellado provisional como el de Go (decisión 3).
- `SEALED_CONTROL_LEN` no depende de L ni del código: dos cápsulas con L distintas y las mismas extensiones tienen el mismo, como pide §55.2.
6. **Fallos durante la escritura.** Una salida que falla en el trozo k, una fuente que falla, una fuente que da L − 1 o L + 1 bytes y un `progress` que lanza:
- dejan la salida abortada y nunca cerrada;
- relanzan el error de la fuente o de la salida sin tocar y sin código;
- relanzan el error de la fuente o de la salida sin tocar y sin código, y dan los textos de Go en los de longitud;
- dejan a cero los secretos que fija la variante de tests, tanto si todo va bien como si falla;
- no ponen en ningún mensaje de error bytes de `I_PAYLOAD`, de `I_ACCESS` ni de CONTROL_CBOR.
- no ponen en ningún mensaje de error bytes de `I_PAYLOAD`, de `I_ACCESS`, de un señuelo ni de CONTROL_CBOR, ni el orden de los huecos.
7. **Bucle de propiedades**, con el generador propio y la semilla impresa: 50 semillas en cada ejecución y 500 a mano (decisión 15). Varía:
- la política;
- de 0 a 5 recipients, a veces repetidos, de 31 bytes o no canónicos;
- la política y de 0 a 17 credenciales, a veces repetidas, de 31 bytes, no canónicas o de orden bajo;
- la clave portable;
- de 0 a 65 extensiones por array, con ids de caracteres UTF-8 de 1 a 4 bytes (incluidos `。` y U+10000, por el orden por bytes), versiones cerca de 2³² − 1 y `data` de 0 a unos KiB;
- el tamaño del payload, alrededor de los bordes de trozo.
Cada caso termina de una de dos formas. O falla con un error esperado, sin escribir nada. O la cápsula pasa `inspect`, se abre con `open`, cumple las fórmulas de longitud y su `.dkk` se decodifica y se recodifica igual. Es el "encode implica decode" de `FuzzEncodeImpliesDecode` de Go.
8. **Mezclas generadas por el writer.** Con dos cápsulas A y B de la misma ronda:
- PUBLIC_HEADER de A con el resto de B;
- SEALED_CONTROL de B dentro de A;
- `PAYLOAD_AGE` de B dentro de A;
- una cabecera `time_only` con el control de una cápsula `time_and_key`.
TypeScript da el código y el paso que registró Go en el punto 9.
9. **Interoperabilidad TS → Go a nivel de cápsula** (decisión 11), con claves e instantes fijos, porque los textos "listed twice" y "not in the future" los incluyen.
Muestras, con payloads generados de forma determinista (solo se guardan las cápsulas):
- `time_only` con 0, 46, 65 536 y 78 000 bytes;
- `time_and_key` con clave portable, con 2 recipients y clave portable, y solo con recipients;
- de 0 a 65 extensiones por array, con ids de caracteres UTF-8 de 1 a 4 bytes, versiones cerca de 2³² − 1 y `data` de 0 a unos KiB;
- el código de relleno, y L alrededor de los bordes de trozo y de relleno, a veces con un relleno de varios trozos (L de varios MB que no sea redondo).
Cada caso termina de una de dos formas. O falla con un error esperado, sin escribir nada. O la cápsula pasa `inspect`, se abre con `open` con cada credencial, cumple las fórmulas y su `.dkk` se decodifica y se recodifica igual. Es el "encode implica decode" de `FuzzEncodeImpliesDecode` de Go.
8. **Mezclas generadas por el writer.** Con dos cápsulas A y B de la misma ronda: la cabecera de A con el resto de B; el SEALED_CONTROL de B dentro de A; el `PAYLOAD_AGE` de B dentro de A; una cabecera `time_only` con el control de una `time_and_key`. TypeScript da el código y el paso que registró Go en el punto 9.
9. **Interoperabilidad TS → Go a nivel de cápsula** (decisión 11), con claves e instantes fijos.
Muestras, con contenidos generados de forma determinista (solo se guardan las cápsulas):
- `time_only` con 0, 46, 65 536 y 78 000 bytes, con los dos códigos;
- `time_and_key` con clave portable, con 3 recipients y clave portable, y con 16 recipients;
- extensiones en los tres objetos;
- un instante con nanosegundos;
- las mezclas del punto 8.
Go, para cada muestra:
- ejecuta `capsule.Inspect`;
- la abre con `capsule.Open` y con cada credencial (la `.dkk` y cada identidad), y registra el SHA-256 del plaintext;
- vuelve a codificar PUBLIC_HEADER, CONTROL_CBOR (abierto capa a capa, como `genfixtures`) y la `.dkk`, y compara los bytes;
- la abre con `capsule.Open` y con cada credencial, y registra el SHA-256 del contenido, el formato, L, el código, P y el número de stanzas de `INNER_ACCESS_AGE`;
- vuelve a codificar PUBLIC_HEADER, CONTROL_CBOR (abierto capa a capa) y la `.dkk`, y compara los bytes;
- registra código y paso de cada mezcla.
Además, el script de Go:
- ejecuta un diferencial de encoders: 500 juegos de entradas aleatorias con semilla fija, con los bytes de `EncodeHeader`, `EncodeControl` y `MarshalBody` comparados con los intermedios del writer TypeScript;
- da los veredictos de `age.ParseX25519Recipient` sobre el corpus de cadenas; las diferencias esperadas son las claves no canónicas y de orden bajo (decisión 6);
- ejecuta un diferencial de encoders: 500 juegos de entradas aleatorias con semilla fija, con los bytes de `EncodeHeader`, `EncodeControl(…, Format2)` y `MarshalBody` comparados con los del writer TypeScript;
- da los veredictos de `age.ParseX25519Recipient` y `agewrap.CheckX25519Recipient` sobre el corpus de cadenas y claves;
- da los textos de `capsule.Encrypt` para la tabla del punto 3.
Todo se congela en `capsule-vectors.json`. El test comprueba en cada ejecución los veredictos de Go y que `open` de TypeScript da lo mismo sobre los bytes congelados.
10. **Guardas** de la sección 3.
11. **Rendimiento, informativo.** El tiempo de `encrypt` (un pairing y dos wraps con la caché) y el caudal con 64 MiB y con 1 GiB, en Node y en el navegador, con salida a OPFS.
12. **Ramas inalcanzables.** Con encoders correctos no fallan:
- las autocomprobaciones de cabecera y de control;
- `checkTimeStanzas` y `checkPayloadStanzas` sobre lo escrito;
- el recuento de `INNER_ACCESS_AGE`;
- la longitud sellada frente a la medida;
- la ronda que abriría antes de lo pedido.
Las que dependen de un encoder o de `age-encryption` se prueban con `vi.mock`. La de la ronda lleva `v8 ignore` justificado. Ninguna aparece en el script de Go, que tampoco puede producirlas.
11. **Rendimiento, informativo.** El tiempo de `encrypt` en las dos políticas y el caudal con 64 MiB y con 1 GiB, en Node y en el navegador, con salida a OPFS.
12. **Autocomprobaciones con entradas malas**, como `TestEncryptSelfCheck` de Go: un `INNER_ACCESS_AGE` de 15 stanzas, con `I_ACCESS` dos veces, con una clave que no es recipient o con otro control dentro; un `PAYLOAD_AGE` un byte corto, o para otro recipient; una cabecera o un control que el lector rechaza. Cada una da su texto. Solo la ronda que abriría antes de lo pedido lleva `v8 ignore` justificado.
La cobertura de los módulos nuevos se fija en el 100 %.
@ -467,49 +490,47 @@ La cobertura de los módulos nuevos se fija en el 100 %.
Propuesta para el paso 6, pendiente de confirmación del autor:
- **Ruta y navegación.**
- `/create`, con el enlace "Crear" junto a "Inspector" en `+layout.svelte`.
- El título de la portada pasa a algo como "DateKeys: crea, comprueba y abre cápsulas en el navegador".
- El writer se carga con `import()`, igual que la apertura. La lista de recipients se valida con `recipient.ts`, que no trae noble.
- **Qué pide.**
1. **El fichero**, arrastrado o elegido. Se lee con `File.stream()` y nunca sale del dispositivo. Su nombre no entra en la cápsula: no hay campo core para él.
1. **El fichero**, arrastrado o elegido. Se lee con `File.stream()` y nunca sale del dispositivo. Su tamaño es L. Su nombre no entra en la cápsula: no hay campo core para él.
2. **Fecha y hora, con zona horaria.**
- Por defecto, la zona del dispositivo. Un selector ofrece `Intl.supportedValuesOf('timeZone')` y UTC.
- La conversión es código propio. Una hora que no existe en la zona (cambio a horario de verano) se rechaza con un mensaje.
- Una hora ambigua (cambio a horario de invierno) toma la más tardía de las dos, para no abrir nunca antes de lo que la persona pudo querer.
- El límite es 9999-12-31T23:59:57Z, la última ronda de Quicknet (§15), con un mensaje en español. `<input type="date">` admite años hasta 275760.
- Una hora que no existe en la zona (cambio a horario de verano) se rechaza con un mensaje. Una hora ambigua (cambio a horario de invierno) toma la más tardía de las dos, para no abrir nunca antes de lo que la persona pudo querer.
- El límite es 9999-12-31T23:59:57Z, la última ronda de Quicknet (§15).
3. **Política:** "solo fecha" (`time_only`) o "fecha y clave" (`time_and_key`). La página explica que, con `time_only`, cualquiera que tenga el `.dkc` puede abrirlo desde la fecha.
4. **Con `time_and_key`:**
- recipients `age1…`, uno por línea, con el formato de un fichero `-R` de `age` y validados línea a línea (decisión 6), con un límite de líneas;
- recipients `age1…`, uno por línea, con el formato de un fichero `-R` de `age` y validados línea a línea (decisión 6): como mucho 15 con la clave portable y 16 sin ella;
- la casilla "generar una clave portable (.dkk)", marcada por defecto y obligatoria si no hay recipients.
El relleno es siempre `reforzado` (decisión 16), y la página no lo pregunta.
- **Qué muestra antes de cifrar.**
- El instante pedido, en UTC y en la zona elegida.
- La ronda, el instante efectivo (el de la ronda, igual o posterior al pedido, §15) y la `dk1_`.
- La hora del dispositivo junto a la hora UTC. Un reloj atrasado podría crear sin aviso una cápsula para un instante ya pasado, que cualquiera con el `.dkc` abriría enseguida. La página no pregunta la hora a ningún servidor.
- El aviso de §53 cuando el instante efectivo está a más del umbral, antes de cifrar y no después, como hace la CLI (main.go:200-203). La librería exporta el umbral, 365 días, porque §53 pide el aviso al SDK.
- La ronda, el instante efectivo (el de la ronda, igual o posterior al pedido, §15) y la `dk1_`: es el SHOULD de §62.1, regla 2.
- La hora del dispositivo junto a la hora UTC. Con el reloj atrasado se podría sellar sin aviso hacia una ronda ya publicada, que cualquiera con el `.dkc` abriría enseguida. La página no pregunta la hora a ningún servidor.
- El tamaño que tendrá el `.dkc`, y qué deja ver hasta la fecha (§55.2):
- la fecha, la política, el `capsule_id` y ese tamaño, que da el del fichero de forma aproximada: con un margen de 256 bytes hasta 8 KiB, y de menos de un 6,25 % por encima;
- no deja ver el tamaño exacto ni cuántas credenciales abren la cápsula, que no es lo mismo que cuántas personas. Ocultar las credenciales se apoya en Diffie–Hellman, que no resiste un adversario cuántico futuro, como el resto del cifrado (§53).
- El aviso de §53 cuando el instante efectivo está a más del umbral, antes de cifrar. La librería exporta el umbral, 365 días, porque §53 pide el aviso al SDK, desde un módulo sin dependencias (sección 5).
- Que la cápsula no guarda la zona horaria, y que se fija un instante UTC con las reglas de zona de hoy.
- "Ahora" se vuelve a leer al pulsar el botón y al volver a la pestaña, como en la apertura.
- "Ahora" se vuelve a leer al pulsar el botón y al volver a la pestaña.
- **Qué entrega.**
- El `.dkc` y, si la hay, la `.dkk`, en dos descargas separadas.
- Después, el informe de los pasos 1 a 8 del `.dkc` escrito, con el componente del inspector, y el `capsule_id`.
- El nombre de los ficheros es un metadato público. Por defecto, `capsula-<fecha UTC de apertura>.dkc` y `.dkk`, que ya es pública en la DateKey y no revela cuándo se creó; la persona puede editarlo.
- Después, el informe de los pasos 1 a 8 del `.dkc` escrito, con el componente del inspector (formato 2), y el `capsule_id`.
- Los nombres de los ficheros son un metadato público. Por defecto, `capsula-<fecha UTC de apertura>.dkc` y `.dkk`, que ya es pública en la DateKey y no revela cuándo se creó; la persona puede editarlos.
- **Dónde queda la salida.**
- El `.dkc` se escribe en un fichero temporal de OPFS (`datekeys-create`, sección 5), con la misma política de borrado que la apertura: al pedirlo, al crear otra cápsula, al salir de la página y, si quedó, en la siguiente visita a cualquiera de las dos páginas.
- El `.dkc` se escribe en un fichero temporal de OPFS (`datekeys-create`, sección 5), con la política de borrado de la apertura.
- La cuota se comprueba en la llamada a `progress` con `written = 0`, con el tamaño exacto.
- Sin OPFS, o si el navegador lo rechaza, se escribe en memoria, hasta 64 MiB.
- Cancelar usa `cancellable`: la salida deja de aceptar datos, `encrypt` falla y el fichero temporal se borra.
- La `.dkk` (152 bytes sin extensiones) queda solo en memoria, nunca en OPFS. Sus bytes se borran al pulsar "olvidar la clave", al crear otra cápsula y en `pagehide` siempre, también cuando la página va a la caché de atrás y adelante.
- La URL `blob:` de cada descarga se revoca pasado un plazo, no en el mismo clic, que en algunos navegadores cortaría la descarga. La copia del `Blob` no se puede borrar.
- La `.dkk` (152 bytes sin extensiones) queda solo en memoria, nunca en OPFS. Sus bytes se borran al pulsar "olvidar la clave", al crear otra cápsula y en `pagehide`.
- La URL `blob:` de cada descarga se revoca pasado un plazo, no en el mismo clic.
- Si la cápsula solo se abre con su `.dkk` y la persona intenta salir sin haberla descargado, la página avisa.
- **Avisos.**
- **§53**: "Aviso: el cifrado por tiempo de Quicknet V1 no es poscuántico. El texto cifrado puede seguir guardado durante años, y su confidencialidad futura depende del proveedor y de la criptografía en que se basa."
- **§50**, con el mismo umbral: "Para abrirla hará falta el release de su ronda, que publica la red drand. Si en esa fecha ningún relay ni ninguna copia conservada lo ofrece, la cápsula no podrá abrirse."
- **§7.4**, junto a la `.dkk`: "Guarda esta clave en secreto: quien la tenga podrá abrir la cápsula desde la fecha. Solo sirve para esta cápsula."
- **§36.1 y §55.1:** ningún texto presenta la cápsula como prueba de autoría ni de fecha de creación.
- **Sin red.**
- La página nunca habla con drand para cifrar: la ronda se calcula en el dispositivo, y tlock usa solo la clave pública pinneada (§35).
- La CSP no cambia. Se comprueba con `check-build` y en el navegador.
- **Rendimiento.**
- Todo corre en el hilo principal, porque `worker-src 'none'` no permite workers.
- Barra de progreso con `progress`, y botón de cancelar.
- **Sin red.** La ronda se calcula en el dispositivo, y tlock usa solo la clave pública pinneada (§35). La CSP no cambia.
- **Rendimiento.** Todo corre en el hilo principal, porque `worker-src 'none'` no permite workers. Barra de progreso con `progress`, y botón de cancelar.
- **Convenciones de las páginas actuales.** Textos en español, nunca `{@html}`, 375 px de ancho, el foco al campo o al mensaje de error, y `licenses.txt`.
Queda para que el autor decida en el paso 6:
@ -520,10 +541,10 @@ Queda para que el autor decida en el paso 6:
- los nombres de los ficheros;
- si se muestra la `.dkk` como `AGE-SECRET-KEY-1…` (§38 MAY). La recomendación es no hacerlo por defecto, por el historial del portapapeles;
- el umbral y los textos de §53 y §50;
- un aviso de protocolo preliminar (v0.8.2; los esquemas pueden cambiar antes de la v1.0, §74);
- un aviso de protocolo preliminar (v0.9; los esquemas pueden cambiar antes de la v1.0, §74);
- un tamaño máximo más allá de la cuota;
- un aviso para fechas muy cercanas;
- que las extensiones no se exponen en esta fase.
- que las extensiones y el código de relleno no se exponen en esta fase.
---
@ -531,14 +552,14 @@ Queda para que el autor decida en el paso 6:
| Paso | Contenido | Hecho cuando |
|---|---|---|
| 0 | Confirmar las decisiones de la sección 2, y que no hace falta ninguna dependencia nueva (sección 3) | el autor confirma o ajusta cada decisión; el plan pasa a v2, con la fecha |
| 1 | Precondición | `main` en verde, con `testdata` en `9ac9cd9` (`spec-v0.8.2`).<br>La decisión 14 está aplicada; si se cierra `0.1.0`, su commit y su tag van antes del paso 2 |
| 2 | Piezas de apoyo (sección 5) y guardas nuevas (decisión 13) | Derivación del recipient igual a la de `identityToRecipient` y a RFC 7748.<br>`parseX25519Recipient` y `checkX25519Recipient` rechazan los casos de la sección 8, punto 1.<br>El hasher da lo mismo que Web Crypto.<br>`open.ts` usa `compareInstants` sin cambiar ningún test.<br>La caché del pairing mantiene los vectores de la fase 2.<br>`tempfile.ts` parametrizado, con la apertura intacta.<br>Guardas nuevas en verde.<br>Módulos tocados al 100 % |
| 3 | `encrypt.ts` y `writer.ts` con entrada en memoria (sección 4) | La tabla de opciones inválidas da los textos y códigos esperados, sin escribir nada y con la salida abortada.<br>Las secciones deterministas de los cinco fixtures salen byte a byte (sección 8, punto 2).<br>Ida y vuelta con `open` para las dos políticas y todas las credenciales.<br>Claves portables nunca repetidas; caso `now + 1 h`.<br>Los dos módulos al 100 %, fijado como umbral |
| 4 | Streaming y salida (sección 6) | `Uint8Array`, `Blob` y `ReadableStream` dan el mismo plaintext al abrir y las mismas longitudes, también con un trozo de entrada de varios MiB.<br>Nada se escribe antes del paso 17 del flujo.<br>Una salida o una fuente que fallan, y un `progress` que lanza, dejan la salida abortada y nunca cerrada; los errores de la fuente y de la salida se relanzan sin tocar.<br>`capsule_digest` es el SHA-256 de lo escrito.<br>El bucle de propiedades pasa con 50 semillas, y con 500 a mano.<br>Las mezclas dan el código y el paso esperados.<br>Medida de rendimiento en Node, anotada en el README |
| 5 | Interoperabilidad TS → Go a nivel de cápsula (sección 8, punto 9) | Go inspecciona y abre todas las muestras con cada credencial, con el mismo SHA-256 del plaintext.<br>Go recodifica PUBLIC_HEADER, CONTROL_CBOR y `.dkk` a los mismos bytes.<br>El diferencial de encoders no da ninguna diferencia.<br>Las mezclas dan el mismo código y paso en Go y en TypeScript.<br>Los veredictos de recipients y los textos de opciones coinciden, salvo las diferencias de las decisiones 6 y 9.<br>Todo congelado en `capsule-vectors.json`.<br>README con la fila "Equivale en Go" de `encrypt.ts` (`capsule.Encrypt`, `accesskey.Encode`) |
| 0 | Confirmar las decisiones de la sección 2, y que no hace falta ninguna dependencia nueva (sección 3) | el autor confirma o ajusta cada decisión; el plan pasa a v3, con la fecha |
| 1 | Precondición | `main` en verde, con `testdata` en `7e2d83c` (`spec-v0.9`): ya se cumple en `0118890`.<br>La decisión 14 está aplicada; si se cierra `0.1.0`, su commit y su tag van antes del paso 2 |
| 2 | Piezas de apoyo (sección 5) y guardas nuevas (decisión 13) | Derivación del recipient igual a la de `identityToRecipient` y a RFC 7748.<br>`parseX25519Recipient` y `checkX25519Recipient` rechazan los casos de la sección 8, punto 1, con los textos de Go.<br>`permute` pasa la prueba de uniformidad.<br>El hasher da lo mismo que Web Crypto.<br>`open.ts` usa `compareInstants` sin cambiar ningún test.<br>`tempfile.ts` parametrizado, con la apertura intacta.<br>Guardas nuevas en verde.<br>Módulos tocados al 100 % |
| 3 | `encrypt.ts` y `writer.ts` con entrada en memoria (sección 4) | La tabla de opciones inválidas da los textos y códigos esperados, sin escribir nada y con la salida abortada.<br>Las secciones deterministas de los siete fixtures de formato 2 salen byte a byte, y cada credencial abre su hueco (sección 8, punto 2).<br>Ida y vuelta con `open` para las dos políticas, de 1 a 16 credenciales y los dos códigos.<br>Claves portables nunca repetidas; caso `now + 1 h`.<br>Los dos módulos al 100 %, fijado como umbral |
| 4 | Streaming y salida (sección 6) | `Uint8Array`, `Blob` y `ReadableStream` dan el mismo contenido al abrir y las mismas longitudes, también con un trozo de entrada de varios MiB.<br>Nada se escribe antes del paso 17 del flujo.<br>Una salida o una fuente que fallan, una fuente de L ± 1 bytes y un `progress` que lanza dejan la salida abortada y nunca cerrada.<br>`capsule_digest` es el SHA-256 de lo escrito.<br>El bucle de propiedades pasa con 50 semillas, y con 500 a mano.<br>Las mezclas dan el código y el paso esperados.<br>Medida de rendimiento en Node, anotada en el README |
| 5 | Interoperabilidad TS → Go a nivel de cápsula (sección 8, punto 9) | Go inspecciona y abre todas las muestras con cada credencial, con el mismo SHA-256 del contenido y los mismos L, código y P.<br>Go recodifica PUBLIC_HEADER, CONTROL_CBOR y `.dkk` a los mismos bytes.<br>El diferencial de encoders no da ninguna diferencia.<br>Las mezclas dan el mismo código y paso en Go y en TypeScript.<br>Los veredictos de recipients y los textos de opciones coinciden, salvo los `TypeError` de la decisión 9.<br>Todo congelado en `capsule-vectors.json`.<br>README con la fila "Equivale en Go" de `encrypt.ts` (`capsule.Encrypt`, `accesskey.Encode`) |
| 6 | Decisiones de la página (sección 9) | el autor confirma la ruta, las entradas, los avisos y sus textos, la salida y la privacidad |
| 7 | Página | En la compilación de producción, un fichero propio se cifra a `.dkc` y `.dkk` sin ninguna petición fuera del origen, y el informe de los pasos 1 a 8 del `.dkc` escrito pasa.<br>Una cápsula creada para dentro de dos o tres minutos se abre después en `/inspect` con el release pegado, y a mano con `datekeys decrypt` de Go, con red y con su `.dkk`.<br>El aviso de §53 aparece antes de cifrar, solo pasado el umbral.<br>Cancelar a mitad no deja fichero temporal.<br>Sin OPFS, la escritura va a memoria.<br>375 px de ancho.<br>`check-build` generalizado, en verde.<br>Tamaño del bundle de la página anotado.<br>Módulos nuevos al 100 %.<br>Revisión adversarial con cada hallazgo contrastado, como en la fase 2 |
| 7 | Página | En la compilación de producción, un fichero propio se cifra a `.dkc` y `.dkk` sin ninguna petición fuera del origen, y el informe de los pasos 1 a 8 del `.dkc` escrito pasa, con formato 2.<br>Una cápsula creada para dentro de dos o tres minutos se abre después en `/inspect` con el release pegado, y a mano con `datekeys decrypt` de Go, con red y con su `.dkk`.<br>El aviso de §53 aparece antes de cifrar, solo pasado el umbral.<br>Cancelar a mitad no deja fichero temporal.<br>Sin OPFS, la escritura va a memoria.<br>375 px de ancho.<br>`check-build` generalizado, en verde.<br>Tamaño del bundle de la página anotado.<br>Módulos nuevos al 100 %.<br>Revisión adversarial con cada hallazgo contrastado |
Cada paso termina con `npm run verify` en verde y un commit en Gitea, y actualiza README, CHANGELOG, HANDOFF y la fila de esta tabla. El paso 6 puede ir en paralelo con los pasos 2 a 5. El script de Go del paso 5 puede empezarse durante el paso 4.
@ -546,34 +567,40 @@ Cada paso termina con `npm run verify` en verde y un commit en Gitea, y actualiz
## 11. Riesgos
- **`age-encryption` no tiene labels y no exige un mínimo de recipients.** Mitigación: el recipient tlock va solo, en un `Encrypter` propio; el writer comprueba que hay al menos un recipient antes de sellar; `checkTimeStanzas` y `checkAccessStanzas` se aplican sobre lo sellado (decisión 8).
- **`addRecipient` acepta recipients que no son X25519.** Mitigación: la API recibe bytes, y a `addRecipient` solo llegan cadenas de `formatX25519Recipient`. Un test comprueba que `age1pq1…` no pasa `parseX25519Recipient`.
- **Recipients que nadie puede abrir.** Una clave no canónica produce un stanza válido que ninguna identidad abre, en `age-encryption` y en Go. Mitigación: se rechazan antes de cifrar (decisión 6).
- **Memoria con trozos grandes.** `encryptSTREAM` cifra y encola de golpe cada trozo de entrada. Mitigación: la entrada se trocea en 64 KiB (decisión 2), y un test lo cubre con un trozo de varios MiB.
- **Un cambio de formato en una versión futura de `age-encryption`.** El borrador dejaría de medir lo mismo que el sellado real, o su primer trozo dejaría de ser la cabecera sola. Mitigación: versión exacta; la comprobación de igualdad de longitudes, que da un error interno y nunca una cápsula mal enmarcada; los tests de fórmulas, de fixtures y del primer trozo.
- **`age-encryption` no tiene labels y no exige un mínimo de recipients.** Mitigación: el recipient tlock va solo, en un `Encrypter` propio; el writer comprueba las credenciales antes de sellar; `checkTimeStanzas` y `checkAccessStanzas(…, 16)` se aplican sobre lo sellado (decisión 8).
- **`addRecipient` acepta recipients que no son X25519.** Mitigación: la API recibe bytes, y a `addRecipient` solo llegan cadenas de `formatX25519Recipient`.
- **Recipients que nadie puede abrir.** Mitigación: los no canónicos y los de orden bajo se rechazan antes de cifrar, como en Go (decisión 6). Un punto del twist se acepta, como en Go, porque §37 lo deja en MAY: si fuera la única credencial, la cápsula no se abriría nunca.
- **Un orden de huecos sesgado o que se filtra.** Mitigación: índice por rechazo y Fisher–Yates, con su prueba de uniformidad; ni el orden ni los señuelos salen del writer (decisión 4).
- **La clave privada de un señuelo.** Si se guardara, abriría la cápsula (§39). Mitigación: se borra al derivar su clave pública y no pasa por ninguna cadena ni error; sus bigints en noble no se pueden borrar (sección 6).
- **Una fuente que no da exactamente L bytes.** Solo se descubre al leerla, con parte de la cápsula ya escrita. Mitigación: la salida se aborta y el error lo dice (decisión 2); con ficheros no puede pasar.
- **Un relleno omitido o mal calculado.** Revelaría L y la cápsula fallaría en el paso 17, tras la fecha. Mitigación: `paddedLength` exacta, con sus vectores; la autocomprobación de la longitud de `PAYLOAD_AGE` antes de cerrar la salida (decisión 8).
- **Memoria con trozos grandes.** Mitigación: la entrada se trocea en 64 KiB, y un test lo cubre con un trozo de varios MiB.
- **Un cambio de formato en una versión futura de `age-encryption`.** La fórmula dejaría de medir lo mismo que el sellado real, o su primer trozo dejaría de ser la cabecera sola. Mitigación: versión exacta; la comprobación de igualdad de longitudes, que da un error interno y nunca una cápsula mal enmarcada; los tests de fórmulas, de fixtures y del primer trozo.
- **Entradas que cambian durante una espera.** Mitigación: copias al empezar (decisión 1).
- **Esquemas provisionales (§74).** Una cápsula escrita hoy con horizonte largo podría no abrirse con un lector v1.0 si los esquemas cambian. Mitigación: la decisión de la sección 9 sobre el aviso de protocolo preliminar. Si la v1.0 conserva un lector de la v0.8.2 lo decide el autor, fuera de esta fase.
- **Esquemas provisionales (§74).** Una cápsula escrita hoy con horizonte largo podría no abrirse con un lector v1.0 si los esquemas cambian. Mitigación: el aviso de protocolo preliminar de la sección 9.
- **Secretos que no se pueden borrar**, dentro de `age-encryption`, en los bigints de noble y en el `Blob` de la descarga. Mitigación: documentarlos, reducir al mínimo las copias y no usar nunca cadenas para secretos.
- **Rendimiento en el hilo principal.** En Node, 57 MiB/s. Mitigación: streaming con presión inversa, barra de progreso y cancelación; medir en el paso 7 con 64 MiB y con 1 GiB. Un worker exigiría cambiar la CSP, y eso lo decide el autor.
- **Rendimiento en el hilo principal.** En Node, 57 MiB/s con el hash, más el relleno. Mitigación: streaming con presión inversa, barra de progreso y cancelación; medir en el paso 7 con 64 MiB y con 1 GiB.
- **Pérdida de la `.dkk`** de una cápsula que solo se abre con ella. Mitigación: la página lo advierte y pide confirmación antes de descartarla.
- **El reloj del dispositivo.** Con el reloj atrasado se crearía sin aviso una cápsula para un instante ya pasado, que cualquiera con el `.dkc` abriría enseguida. Mitigación: mostrar la hora del dispositivo junto a UTC y avisar; `encrypt` exige que el instante sea futuro según ese reloj, como Go.
- **Zonas horarias.** Se fija un instante UTC con las reglas de zona de hoy. Si las reglas cambian (por ejemplo, si se suprime el horario de verano), la hora local que la persona quiso deja de coincidir, y las tablas de zonas pueden diferir entre navegadores. Mitigación: mostrar el instante UTC y explicar que es el que cuenta; reglas explícitas para horas que no existen o son ambiguas.
- **Muestras congeladas que dejan de reflejar el writer.** Mitigación: el test de reproducción de fixtures detecta cualquier cambio en las secciones deterministas, y la cabecera del script dice que se regeneran cuando cambia el writer.
- **El reloj del dispositivo.** Mitigación: mostrar la hora del dispositivo junto a UTC y el instante efectivo, y exigir, como Go, que el instante sea futuro según ese reloj.
- **Zonas horarias.** Mitigación: mostrar el instante UTC y explicar que es el que cuenta; reglas explícitas para horas que no existen o son ambiguas.
- **Muestras congeladas que dejan de reflejar el writer.** Mitigación: el test de reproducción de fixtures detecta cualquier cambio en las secciones deterministas, y la cabecera del script dice cuándo se regeneran.
- **Afirmaciones de autoría en la interfaz (§55.1, MUST NOT).** Mitigación: revisar los textos en el paso 7.
---
## 12. Fuera de alcance y decisiones aplazadas
- La fuente drand del SDK, para §48 (varios relays) y §49 (obtener el release directamente del proveedor). Escribir no la necesita y sigue aplazada, como en la fase 2.
- Escribir el formato 1: solo lo hace el generador de vectores de Go (§62.1, regla 1).
- La fuente drand del SDK, para §48 y §49. Escribir no la necesita.
- Los schemes de drand distintos de Quicknet, y la Release API.
- Extensiones en la página, y el registro de `extension_id` (§72, MAY).
- Una extensión de nombre de fichero o de tipo MIME. Tendría que ser no crítica, ir en CONTROL_CBOR y registrarse (§54, §72).
- Una extensión de nombre de fichero o de tipo MIME.
- Extensiones de firma y de autoría (§36.1).
- La entrega y el almacenamiento de cápsulas y `.dkk` (§6).
- Exportar `I_ACCESS` como `AGE-SECRET-KEY-1…`, salvo que el autor lo decida en la sección 9.
- El zip y los códigos QR.
- Workers (lo impide la CSP).
- Volcar a un fichero temporal un `ReadableStream` sin longitud (decisión 17).
- Añadir recipients a una cápsula ya escrita, o rotar sus claves: requiere una cápsula nueva.
- Cambios normativos y cambios en `datekeys-go`: ninguno es necesario (sección 7).
- Una `AbortSignal` en la API (decisión 2) y un registro de extensiones en el writer (decisión 10): aplazados.
- Una `AbortSignal` en la API y un registro de extensiones en el writer: aplazados.

@ -42,6 +42,6 @@ Para retomar el trabajo, lee [HANDOFF.md](HANDOFF.md): el estado de cada repo, l
| [PLAN_libreria_go.md](PLAN_libreria_go.md) | Plan de la librería Go, hitos M0 a M5 (hecho) |
| [PLAN_codec_cbor_y_pagina_svelte.md](PLAN_codec_cbor_y_pagina_svelte.md) | v2: codec CBOR propio en Go y TypeScript, librería TypeScript y página `/inspect` (hecho) |
| [PLAN_fase2_ibe_noble2.md](PLAN_fase2_ibe_noble2.md) | v2: fase 2, abrir cápsulas en el navegador (hecho) |
| [PLAN_fase3_escritura.md](PLAN_fase3_escritura.md) | Fase 3, writer TypeScript. Borrador escrito para la v0.8.2: hay que replantearlo sobre la v0.9 |
| [PLAN_fase3_escritura.md](PLAN_fase3_escritura.md) | Fase 3, writer TypeScript del formato 2 (v0.9). v2, revisada; sus decisiones esperan la confirmación del autor |
| [REVISION_completitud_protocolo.md](REVISION_completitud_protocolo.md) | Qué le falta al protocolo antes de la v1.0 |
| [spec_v0.9/](spec_v0.9/README.md) | Papeles de trabajo del borrador v0.9: diseño, revisiones y correcciones pendientes |

Loading…
Cancel
Save

Powered by TurnKey Linux.