commit 6e8d6c6a7202256897bfd68bc2857d5c3e64137c Author: dev Date: Tue Sep 29 12:52:10 2026 +0200 Project docs, moved out of the App repository The handoff, the plans and the protocol review come unchanged from go/DateKeys-App at f660f0c, where they lived in docs/. They describe the whole project, not the TypeScript implementation, and they are internal, so they get their own private repository. spec_v0.9/ holds the working papers of the v0.9 draft (datekeys-go 1189f2f): the design note, the two design reviews, the drafting report and the final review with its 22 pending corrections. They were only in a temporary session folder. The README maps the workspace and carries the project rules, which the handoff used to hold in its section 3. Co-Authored-By: Claude Opus 5.5 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a790374 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Text files are normalised to LF. +* text=auto eol=lf diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..8ee059a --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,141 @@ +# Handoff DateKeys, 26 de septiembre de 2026 (actualizado el 29 por la noche) + +Estado al parar la sesión del 26 por el límite semanal de uso, actualizado el 28 de septiembre tras cerrar la v0.8.2 del spec (§2.1) y el 29 tras cerrar la fase 2 (§2.2). Sirve para retomar sin contexto previo, sea una persona o una sesión de Claude. + +--- + +## 1. Repositorios + +Los dos repos están en el Gitea privado `g.activething.com` (solo LAN, certificado autofirmado para `git.activething.com`; git funciona, `curl` necesita `-k`). **Ese servidor no es del autor**: no se proponen cambios en él. La integración continua es el gate local. + +| Repo | Rama | Commit | Estado | +|---|---|---|---| +| `datekeys-go` (`go/DateKeys`) | `main` | `3e4755e` | Spec v0.8.2 cerrada, con el tag anotado **`spec-v0.8.2`** en `9ac9cd9`. Después, `datekeys.SpecVersion`, `datekeys.Version()` y `datekeys version`, sin cambios en `testdata`. | +| | `v0.8.2` | `9ac9cd9` | El commit del tag; ya no hace falta. | +| `App` (`go/DateKeys-App`) | `main` | `48d6704` | Versión `0.1.0-dev`, que implementa el spec 0.8.2 (`VERSION` y `SPEC_VERSION`, también en el pie de la página). Librería TypeScript con la inspección (pasos 1 a 8) y la apertura (pasos 9 a 18), en memoria o desde un `Blob` hacia un stream de salida, como un fichero OPFS. Página `/inspect` con la acción "abrir". `testdata` sincronizado a `9ac9cd9`. Fase 2 completa (pasos 2 a 8), incluidos el cifrado tlock y la interoperabilidad de TypeScript a Go. Los 65 casos del corpus pasan por `open` con el código y el paso de Go. 2 611 tests, ninguno saltado; `npm run verify` en verde. | +| `AppOld` (solo local) | `master` | `4d2b0a1` | Prototipo antiguo. No se toca. | + +Verificación ya hecha sobre `datekeys-go`: +- fuzzing de 30 min en cada uno de los 15 objetivos sobre `3820066`, limpio; +- diferencial Go/TypeScript de 407 196 entradas contra `f6f2e9f`, con cero diferencias, textos de error incluidos; +- `scripts/check.sh 60s` sobre `9ac9cd9`, el commit del tag, limpio (28-09); +- govulncheck no encuentra nada alcanzable. Avisa, sin llamada alcanzable, de dos problemas: + - GO-2026-6443, en `google.golang.org/grpc` 1.84.0, arreglado solo en una versión `-dev`. Hay que subir grpc cuando salga la 1.85.0. + - GO-2026-5932, en `golang.org/x/crypto/openpgp`, sin arreglo. No lo importamos. + +--- + +## 2. Qué queda, en orden + +### 2.1 Hecho el 28-09: cierre de la v0.8.2 + +- Los 9 retoques de la 2.ª revisión están en `9ac9cd9`, un solo commit sobre `c57ed48` que absorbe el WIP `69a3342`. La rama WIP está borrada. +- §76 los recoge como correcciones 4 a 6: + - un encoder MUST NOT escribir una extensión fuera de su registro; + - §17 y §51 dan los códigos del paso 10 solo para un release suministrado directamente; + - en el paso 9, cualquier fallo de la fuente es `ERR_RELEASE_UNAVAILABLE` y ningún otro código. +- La regla del encoder no se comprueba en la librería: `capsule.Encrypt` y `accesskey.Encode` no reciben `Registry`, y la aplica la aplicación. Está documentado en `extension.Placement`. +- El autor aprobó el texto el 28-09. El spec lleva esa fecha, y el SHA-256 de sus bytes LF, `5cfa3203…`, está en `spec/README.md` y en el mensaje del tag. +- `main` avanzó por fast-forward hasta `9ac9cd9` y lleva el tag `spec-v0.8.2`. Los dos están subidos. +- `App` sincronizó `testdata` con `9ac9cd9` en `71ab8fb`. Solo cambia `testdata/README.md`. + +Siguen valiendo estas reglas: +- `App/testdata` solo se actualiza desde `datekeys-go`, con `scripts/sync-testdata.mjs`, y nunca se generan fixtures en `App`. Una guarda falla si aparece un fichero de `testdata` que ningún test ejecuta. +- Si cambia un texto de error de Go en los pasos 1 a 8, el TypeScript lo sigue. Hoy coinciden byte a byte. +- Un cambio normativo posterior ya no modifica la v0.8.2: abre una versión nueva, con su caso en §76. + +### 2.2 Fase 2: abrir cápsulas en el navegador + +Plan: `docs/PLAN_fase2_ibe_noble2.md` v2, con las decisiones confirmadas por el autor. Los pasos 2 a 8 de su sección 10: +- hecho el 28-09 (`74e1215`): `age-encryption` 0.3.1 y noble 2.4.0 como dependencias de ejecución, con sus guardas en `src/lib/dependencies.test.ts` y `check-build.mjs`. El README de `App` recoge el coste medido en el bundle, 73 KB con gzip para todo, y `npm audit`; +- hecho el 28-09 (`35be27c`): `ibe.ts` sobre noble 2, con vectores de Go en `src/lib/dkc/testing/ibe-vectors.json` (`scripts/ibe-go-vectors.go`) y cobertura del 100 % fijada como umbral. Los argumentos del stanza y su paso al `Stanza` de `age-encryption`, que guarda el tipo en `args[0]`, van al paso 7; +- hecho el 28-09 (`076f3db`): `release.ts`, con `verifyRelease` en el orden y con los textos de `provider.Verify`, `ReleaseSource` y `suppliedRelease`. Solo verifica el scheme de Quicknet: otro da `ERR_UNKNOWN_PROFILE`. Reproduce los 7 casos del corpus que fallan en el paso 10; +- hecho el 28-09 (`97827ae`), paso 5a: `open.ts`, los pasos 9 a 18 con el texto en claro en memoria. Las identidades estrictas de `agewrap` se apoyan en `x25519.ts`, que abre los stanzas de uno en uno. Usa `@noble/ciphers` 2.4.0, dependencia directa aprobada ese día: es la copia que ya usa `age-encryption`. Las identidades `AGE-SECRET-KEY-1…` se leen con `bech32.ts`. `index.ts` no reexporta aún la apertura, que metería noble en `/inspect`; +- hecho el 28-09 (`a89bee5`), paso 5b. `open` acepta un `Blob` del que solo lee el prefijo de los pasos 1 a 8 (`prefix.ts`, movido de la página a la librería), calcula el `capsule_digest` en streaming (`digest.ts`) y descifra `PAYLOAD_AGE` en streaming. La salida puede ser un `WritableStream`, que se cierra tras el paso 18 y se aborta ante cualquier fallo. Comprobado en el navegador con OPFS real: un fallo de STREAM deja intacto el fichero; +- el paso 6 está cubierto: la enmienda de canonicidad entró en la v0.8.2; +- hecho el 28-09 (`66970cf`), paso 7: `encryptOnG2RFC9380` y `timeRecipient`. Con sigma fijo, el cifrado reproduce byte a byte los vectores de Go. Go abrió los cuerpos IBE y los ficheros `age` que cifró esta librería, con `tlock.TimeUnlock` y con `agewrap.NewTimeIdentity`. Todo está en `src/lib/dkc/testing/tlock-vectors.json`; +- hecho el 28 y 29-09 (`48d6704`), paso 8: la acción "abrir" en `/inspect`, cargada bajo demanda, con la política aprobada por el autor el 28-09 tras revisar lo que dice el protocolo: + - el release lo pega quien abre, o sale del registro del fixture, y la página nunca lo pide a la red; + - el texto en claro de un fichero propio va a un fichero temporal de OPFS (§56), que se borra; + - los avisos de licencia se publican en `licenses.txt`. + Una revisión adversarial confirmó 15 hallazgos, todos corregidos. Los detalles y las comprobaciones en el navegador están en la fila 8 del plan. + +La lectura del protocolo del 28-09 deja dos SHOULD para más adelante: §48 (varios relays) y §49 (obtener el release directamente del proveedor). Los cubrirá una fuente drand opcional del SDK, nunca activa por defecto en la página (sección 12 del plan). + +El vector de GT (`vectors/tlock_ibe.json`) ya está cubierto en `App`. El paso 9 de `open.ts` debe seguir la corrección 6: cualquier fallo de una fuente es `ERR_RELEASE_UNAVAILABLE` y ningún otro código. + +### 2.5 Sesión del 29-09 por la noche: fase 3 y v0.9 del spec (retomar aquí) + +**Estado al parar:** +- `datekeys-go`, rama `v0.9` (`1189f2f`, subida): borrador del spec v0.9, sin aprobar, en `spec/DateKeys_Protocol_Specification_v0.9.md`, con `spec/datekeys.cddl` actualizado. `main` no cambia. +- El repaso final encontró 21 problemas, con su arreglo propuesto (salida del workflow `spec-v0.9-draft`, fichero `scratchpad/v09/review.md` de la sesión, que puede haber desaparecido). Se estaban aplicando cuando se paró el trabajo: **pueden estar a medias**. Lo primero mañana es comprobarlos uno a uno contra el borrador y terminarlos. También falta la entrada v0.9 en `spec/README.md` y ejecutar `go test ./...`, por si algún test lee el CDDL. +- `App`: `docs/PLAN_fase3_escritura.md` (plan del writer TypeScript) y `docs/REVISION_completitud_protocolo.md` (revisión del protocolo). El plan describe aún el writer de la v0.8.2 y hay que replantearlo sobre la v0.9. + +**Decisiones del autor del 29-09 para la v0.9:** +- D1: `time_and_key` lleva siempre exactamente 16 stanzas X25519 en `INNER_ACCESS_AGE`. Los que sobran son señuelos, y todos van en orden aleatorio. Como mucho hay 16 credenciales, contando la `.dkk`. +- D2: el contenido se rellena siempre. El código 1 redondea al siguiente múltiplo de 256, con un mínimo de 256. El código 2, "reforzado", es max(bloque256, Padmé) y es el de por defecto. No hay opción sin relleno. +- D3: la longitud real y el código van en `CONTROL_CBOR` versión 2 (claves 6 y 7). El lector entrega solo L bytes y comprueba que el relleno sea todo ceros y que siga la regla. +- D4: el formato pasa a 2 (`VERSION` 2 del PRELUDE), así que un lector v0.8.2 lo rechaza en el paso 2, sin red. Un lector v0.9 sigue abriendo el formato 1. +- D5: `access_policy` sigue visible. +- D6: nueva sección de privacidad, §55.2. +- D7: reglas normativas del escritor, §62.1: + - `I_PAYLOAD` sale de un CSPRNG y no se reutiliza; + - límites; + - la fecha pedida tiene que ser futura; + - el escritor se autocomprueba; + - rechaza claves X25519 no canónicas o de orden bajo. + +**Preguntas abiertas del borrador para el autor:** +1. L como `bstr` de 8 bytes, no como `uint`. +2. Si el lector informa del formato con SHOULD o con MAY. +3. Si se mantienen el objetivo 8 de §4 y el no objetivo nuevo de §5. +4. Un generador de pruebas que escriba el formato 1. +5. El prefijo `format2_` en los fixtures. +6. Señuelos como claves públicas aleatorias válidas, sin clave privada. +7. Puntos del twist: MAY o SHOULD. + +Hay además reglas añadidas fuera de D1-D7 que confirmar: L_MAX = 2⁵³ − 2⁴⁶, el orden uniforme de los stanzas, y el MUST NOT de §56 sobre entregar el contenido antes de que termine el paso 17. + +**Orden de trabajo:** +1. Terminar y verificar las 21 correcciones. +2. El autor aprueba el texto de la v0.9. +3. La referencia Go implementa el formato 2, con fixtures nuevos y mutaciones, y pasa `check.sh`. +4. Se pone el tag `spec-v0.9`. +5. `App` sincroniza `testdata` y el lector TypeScript abre el formato 2. +6. Se replantea el plan de la fase 3 y se escribe el writer TypeScript del formato 2. + +**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. +- Un fichero único `.dk` con `.dkc` y `.dkk` juntos no conviene: con `time_and_key` equivaldría a `time_only`. Ya se envía un solo fichero con `time_only` o con destinatarios `age1…`. +- `App` sigue en `0.1.0-dev`; decidir si se cierra `0.1.0`. + +### 2.3 Depende del autor + +- Crear `security@datekeys.com` y, si se quiere, un `security.txt` en la web. Después, actualizar `SECURITY.md`, que hoy dice `info@activething.com`. +- Elegir la ruta pública del módulo Go (propuesta: `datekeys.com/go/datekeys`, en minúsculas) y el espejo público, que no puede ser GitHub (por ejemplo Codeberg). Publicar la página `go-import` en `datekeys.com`. Después: renombrar el módulo y poner el tag `v0.1.0` cuando `go get` funcione desde una máquina limpia. +- Decidir si `App` pasa de `0.1.0-dev` a `0.1.0`, ahora que la fase 2 está completa. + +### 2.4 Más adelante + +Fase 3 (writer TypeScript de cápsulas), Release API sobre la librería, traducción del spec al inglés y revisión externa antes de la v1.0. + +--- + +## 3. Reglas del proyecto + +- **Dependencias:** en ejecución, solo `age`, `drand`, `tlock` y lo que ellas arrastran. El tooling de desarrollo sale de la lista del README. Nada nuevo sin aprobación escrita. Nunca GitHub como servicio. +- **Spec:** en español, estilo RFC 2119. Todo cambio normativo se registra en §76 con su caso reproducible, y se actualiza el SHA-256 de `spec/README.md`. +- **Código y comentarios:** en inglés. Commits con `Co-Authored-By: Claude Opus 5.5 `. +- **Precedencia de errores (§69.1):** trama; tipo y versión; perfil CBOR y CDDL; campos con código propio, en orden de clave. Entre objetos decide el orden de pasos de §63. +- **Subagentes:** con `model: "opus"`. + +--- + +## 4. Documentos + +- Planes en `App/docs`: `PLAN_libreria_go.md`, `PLAN_codec_cbor_y_pagina_svelte.md` (v2) y `PLAN_fase2_ibe_noble2.md` (v2). +- En `datekeys-go`: + - `spec/DateKeys_Protocol_Specification_v0.8.2.md`, `spec/datekeys.cddl` y `spec/README.md`; + - `testdata/README.md`, que documenta los ficheros compartidos para segundas implementaciones; + - `docs/traceability.md` y `CHANGELOG.md`. +- Las herramientas de verificación de esta sesión (el diferencial Go/TypeScript de `tsreview/` y las copias congeladas `dkgo-ref-`) están en el scratchpad temporal y pueden haber desaparecido. Su descripción está en los mensajes de los commits de `App` y en el README de `App`. diff --git a/PLAN_codec_cbor_y_pagina_svelte.md b/PLAN_codec_cbor_y_pagina_svelte.md new file mode 100644 index 0000000..a268772 --- /dev/null +++ b/PLAN_codec_cbor_y_pagina_svelte.md @@ -0,0 +1,345 @@ +# Plan: codec CBOR propio y página de prueba en Svelte + +Estado: v2, 25 de septiembre de 2026. Sustituye a la v1 (conservada en `AppOld/docs`) con la revisión, el panel sobre `data` y las decisiones 2, 3 y 4. La decisión 1 queda pendiente de confirmar el límite N (sección 3). + +Alcance: sustituir la dependencia CBOR externa de `datekeys-go` por un codec propio, escribir el mismo codec en TypeScript en el proyecto nuevo `App`, y construir sobre él la página de prueba: primero un inspector de cápsulas, después cifrado y descifrado en el navegador. + +Regla de dependencias del proyecto: en ejecución, solo `age`, `drand`, `tlock` y lo que ellas arrastran, en cualquier lenguaje. El tooling de desarrollo queda fuera de la regla, pero solo se admite el de la lista de la sección 13. Toda dependencia nueva, de ejecución o de tooling, se propone por escrito y no se instala sin aprobación. + +--- + +## 1. Situación de partida + +| Elemento | Estado verificado el 25-09-2026 | +|---|---| +| `datekeys-go`, paquete `codec` | Usa `github.com/fxamacker/cbor/v2` v2.9.4, que arrastra `github.com/x448/float16`. Es la única dependencia directa fuera de la regla. | +| Uso de esa librería | `codec` (Marshal, Unmarshal, Peek, CheckSchema, Valid) y structs con etiquetas `cbor:"N,keyasint"` en `profile`, `capsule`, `accesskey`, `extension` e `internal/testkit`. `extension.Wire.Data` es `cbor.RawMessage`. | +| Fixtures y vectores | Cinco cápsulas `.dkc`, dos `.dkk`, vectores de ronda, `dk1_` y `profile_hash`. Los vectores se regeneran byte a byte. Los `.dkc` se generaron una vez y están congelados: la aleatoriedad de `age` no se puede inyectar, así que regenerar una cápsula cambia `capsule_id`, las claves y todos sus bytes. | +| Proyecto TypeScript | `App`, nuevo y separado. Tiene un esqueleto sin dependencias instaladas, los planes en `docs/` y `testdata/` copiado del commit `5719f6a` de `datekeys-go` con `scripts/sync-testdata.mjs`, que verifica cada fichero contra `testdata/SOURCE.json`. | +| Prototipo | `AppOld`: API Quicknet en Go, CLI tlock y cliente Svelte (`web-client`), commit `4d2b0a1`. No se modifica. | +| `web` | Landing estática de datekeys.com. Fuera de este plan. | + +Fallos de la librería actual que este plan corrige, todos reproducidos el 25-09-2026: + +| Fallo | Reproducción | Se corrige en | +|---|---|---| +| `CheckDisjoint` es cuadrático y `DecodeHeader` lo ejecuta antes de comprobar las extensiones críticas | una PUBLIC_HEADER de 880 KB con 40 000 + 40 000 extensiones se decodifica sin error en 8,3 s, sin red ni secretos | paso 1 (máximo de 64 por array) y paso 2a (fusión lineal) | +| Asimetría entre sellar y abrir | `Encrypt` sella `data` de control con 14 o 15 arrays anidados; al llegar la fecha, `Open` la rechaza en el paso 14 (`exceeded max nested level 16`) y la cápsula ya no se puede abrir | paso 2a (`data` pasa a ser una hoja) y autocomprobación del encoder | +| Veredicto no determinista | la misma cápsula de 810 bytes, con `data` de cabecera `{NaN:0, NaN:1}`, la acepta `Inspect` 437 veces de 500 y `Open` 82 de 100 | paso 2a (fxamacker sale del camino de `data`) | +| `extension.New(id, v, nil)` emite `f6` | contradice §58.1 | paso 2a (`New` recibe `[]byte` y rechaza vacío) | + +--- + +## 2. Subconjunto CBOR del protocolo + +Del CDDL `spec/datekeys.cddl` se deduce que el protocolo solo usa: + +| Tipo CBOR | Tipo mayor | Uso | +|---|---|---| +| Entero sin signo | 0 | versiones, claves de mapa, política, periodo, genesis, versión de extensión | +| Cadena de bytes | 2 | identificadores, hashes, claves, `capsule_digest`, `data` de extensión | +| Cadena de texto | 3 | etiquetas de tipo, ids, DateKey compacta, `access_type` | +| Array | 4 | listas de extensiones | +| Mapa con claves enteras | 5 | todos los objetos | + +El spec lo nombra en §58 como perfil del protocolo: RFC 8949 §4.2.1 restringido a los tipos mayores 0, 2, 3, 4 y 5, con claves enteras sin signo. Con claves enteras coinciden el orden por bytes, el orden por longitud y el orden numérico, así que no hay ambigüedad de perfil. + +Reglas que el codec impone al decodificar y al codificar: + +- enteros y longitudes en su forma más corta; +- longitudes definidas; +- claves de mapa en orden ascendente estricto, sin duplicados; +- mapas cerrados: toda clave no prevista por el schema se rechaza; +- texto UTF-8 válido; +- tamaños acotados por el schema y por los límites de §57, que pasan a ser MUST (sección 9); +- como mucho tres niveles de contenedores (objeto, array de extensiones y extensión). `data` es una hoja y el codec nunca desciende dentro. + +Se rechazan con `ERR_NON_CANONICAL_CBOR`: enteros negativos, flotantes, tags, valores simples, longitudes indefinidas y claves no enteras. Los límites de tamaño usan el código que decida la sección 9. + +Tras decodificar, el decodificador reencodifica y compara byte a byte con la entrada. Es una defensa independiente de la decodificación estricta, y ahora cubre también `data`. + +--- + +## 3. Decisión normativa: `data` de las extensiones + +**Tipo: `bstr` no vacío.** Bytes opacos para el protocolo base. Un panel comparó cuatro opciones con tres jueces independientes (seguridad, interoperabilidad y gobernanza del spec) y un red team contra la ganadora: + +| Opción | Puntos (máx. 30) | +|---|---| +| A. `bstr` opaco no vacío | 25,5 (ganadora para los tres jueces) | +| B. Subconjunto estructurado recursivo | 19 | +| C. `any` fijado a un perfil preciso (CDE o dCBOR) | 11 | +| D. `any` como hoy, fijando el comportamiento de fxamacker | 6,5 | + +A gana porque: +- el protocolo base no analiza nada de `data`, sin superficie de parser ni recursión; +- un lector nunca analiza la `data` de extensiones que no conoce; +- la validez base es una comprobación de longitud, idéntica en todas las implementaciones. + +Es el diseño de X.509 (`extnValue` OCTET STRING) y TLS (`extension_data` opaco). + +B es la rival seria, pero: +- obliga a cada lector a validar datos que §54 dice que no interpreta; +- congela el espacio de valores; +- sus ventajas (canonicidad interna, vista estructurada, comprobación al sellar) se recuperan en A mediante la regla de registro de la sección 9. + +El red team no refutó el tipo. + +**Justificación §76.** Se basa en casos reproducibles, no en el tamaño del código: +1. El CDDL se contradice: las líneas 12 a 14 dicen que `null` y los vacíos nunca representan ausencia, y la línea 82 declara `? 2 => any`. +2. `extension.New(id, v, nil)` emite `f6`. +3. El veredicto no determinista con claves NaN. +4. El fixture oficial `time_only_extensions` usa claves de texto y `true`. +5. La asimetría de profundidad: una cápsula sellada que no se abre tras la espera. Es el caso decisivo. + +**Límite de tamaño: pendiente de confirmar.** El panel propuso N = 65 535 (el límite de TLS). El red team mostró que ninguna evidencia sostiene un N concreto, y que un N uniforme de 64 KiB bloquearía el modo `opaque_onion` del Dead Man Switch de los borradores v0.1 y v0.2, si ese servicio llevara cada capa anterior en una extensión de control. Según una simulación del red team con la librería actual, crece unos 477 bytes por renovación y se bloquea en la renovación 139. El diseño del Dead Man Switch sigue abierto, así que el argumento es condicional. + +Opciones: +1. **Recomendada:** sin N propio. `data` queda acotada por la trama de su contenedor (§57, que pasa a MUST), y cada extensión registrada declara su máximo en §72. +2. Límites por contenedor: 65 535 en PUBLIC_HEADER (pública y leída antes del desbloqueo) y la trama en CONTROL_CBOR y `.dkk`. +3. N uniforme de 65 535, registrando en §76 que limita el `opaque_onion`. + +--- + +## 4. Diseño del codec en Go + +Paquete `codec` reescrito sin reflexión y sin etiquetas de struct. + +```go +// Codificación: se acumula en un []byte. El primer error queda fijado y +// Err lo devuelve; las llamadas posteriores no hacen nada. +type Encoder struct{ buf []byte; err error } +func (e *Encoder) Map(pairs int) +func (e *Encoder) Array(items int) +func (e *Encoder) Uint(v uint64) +func (e *Encoder) Bstr(b []byte) +func (e *Encoder) Text(s string) // rechaza UTF-8 inválido +func (e *Encoder) Out() ([]byte, error) + +// Decodificación: cursor estricto sobre la entrada. El Decoder guarda la +// última clave de cada mapa abierto y exige orden ascendente estricto. +type Decoder struct{ /* entrada, posición, pila de últimas claves */ } +func (d *Decoder) Map(max int) (pairs int, err error) +func (d *Decoder) Key() (uint64, error) +func (d *Decoder) EndMap() error +func (d *Decoder) Array(max int) (items int, err error) +func (d *Decoder) Uint(max uint64) (uint64, error) +func (d *Decoder) Bstr(min, max int) ([]byte, error) // longitud comprobada contra lo que queda antes de copiar +func (d *Decoder) Text(max int) (string, error) +func (d *Decoder) Done() error // rechaza bytes sobrantes + +// Canonicidad: decodifica con decode, reencodifica con encode y compara. +func Unmarshal(in []byte, decode func(*Decoder) error, encode func(*Encoder)) error + +// Lee las claves 0 y 1 de un mapa (tipo y versión) antes de la +// decodificación estricta (spec §70). +func Peek(in []byte) (typeTag string, version uint64, err error) + +// Recorredor genérico del subconjunto, con profundidad y tamaño acotados. +// Es auxiliar: vectores compartidos, fuzzing, vista informativa del +// inspector y extensiones registradas cuyo `data` sea CBOR. Nunca decide +// la validez base de un objeto. +func Walk(in []byte, maxDepth, maxLen int) error +``` + +Cada schema escribe su codificación y decodificación a mano, en su paquete, en sustitución de los structs `wire`: + +| Schema | Paquete | Claves | +|---|---|---| +| Provider Profile | `profile` | 0 a 10 | +| PUBLIC_HEADER | `capsule` | 0 a 4, opcionales 5 y 6 | +| CONTROL_CBOR | `capsule` | 0 a 3, opcionales 4 y 5 | +| `.dkk` body | `accesskey` | 0 a 5, opcionales 6, 7 y 8 | +| `verification_metadata` | `accesskey` | 0 | +| extensión | `extension` | 0, 1, opcional 2 (`bstr` no vacío) | + +Reglas comunes: +- una clave opcional ausente se omite; +- un array vacío, un mapa vacío o un `h''` en una clave opcional es un error explícito, no un efecto de `omitempty`; +- la etiqueta de tipo y la versión se comprueban antes de decodificar el resto; +- los errores envuelven el sentinel de §69 que corresponda. + +Cambios en `extension`: +- `New` recibe `[]byte` y rechaza vacío o fuera de límite; +- el array se rechaza por su cabecera si supera 64 elementos, antes de iterar; +- `CheckDisjoint` se sustituye por una fusión lineal de los dos arrays ordenados. + +Autocomprobación del encoder: `Encrypt`, `EncodeHeader`, `EncodeControl` y el encoder de `.dkk` decodifican lo que producen con el decodificador del lector antes de sellar o escribir. Hay un fuzz target que exige que todo encode correcto implique un decode correcto. + +`go.mod` pierde `github.com/fxamacker/cbor/v2` y `github.com/x448/float16`. No entra nada a cambio. + +--- + +## 5. Diseño del codec en TypeScript + +Mismo diseño y misma API, en `App/src/lib/dkc/cbor.ts`, sobre `Uint8Array`, sin dependencias: + +- texto con `TextDecoder('utf-8', { fatal: true, ignoreBOM: true })`; sin `ignoreBOM`, un BOM inicial desaparece y TS rechaza lo que Go acepta; +- `Text` del encoder comprueba `isWellFormed()` antes de codificar, porque `TextEncoder` sustituye en silencio un surrogate suelto por `efbfbd`; +- los `extension_id` se comparan por sus bytes UTF-8, nunca como cadenas JavaScript, que comparan en orden UTF-16 (el par U+FF61 y U+10000 sale al revés); un port ingenuo divergió en 782 de 200 000 entradas; +- enteros siempre como `number`: todos los `uint` del CDDL quedan acotados por debajo de 2⁵³ (sección 9), y el decodificador rechaza lo que supere `Number.MAX_SAFE_INTEGER`; +- `data` se devuelve como `Uint8Array`; `undefined` significa clave ausente. + +Los parsers de schema van en el mismo directorio: `profile.ts`, `header.ts`, `control.ts`, `accesskey.ts`, `extension.ts`, `datekey.ts` e `inspect.ts`, que cubre los pasos 1 a 8 de §63. + +`datekey.ts` no porta el `roundForTime` del prototipo. Ese código usa `Date.parse`, que trunca a milisegundos, y falla el vector "genesis + 1ns" de `quicknet_rounds.json` (da la ronda 1 en vez de la 2). Hace falta un parser RFC 3339 con precisión de nanosegundos. + +La cabecera `age` de OUTER_TIME_AGE y PAYLOAD_AGE (pasos 5, 6 y 8) se analiza con un parser estricto según la gramática de `age`: stanzas, cuerpo base64 canónico, líneas de 64 columnas y línea MAC, sobre los ficheros binarios que localizan las longitudes del prelude. El filtro de líneas `-> ` del prototipo es demasiado laxo. + +--- + +## 6. Vectores compartidos entre lenguajes + +Fichero `testdata/vectors/cbor.json` en `datekeys-go`, generado desde Go y congelado. `App` lo recibe con `sync-testdata`. Los casos genéricos se ejecutan con `Walk`; los de schema, con el decodificador de cada schema. Los enteros por encima de 2⁵³ van como cadena decimal. + +```json +{ + "accept": [ + { "name": "uint 23 inline", "hex": "17", "value": 23 }, + { "name": "uint 24 one byte", "hex": "1818", "value": 24 }, + { "name": "uint 256 two bytes", "hex": "190100", "value": 256 }, + { "name": "uint 2^32 eight bytes", "hex": "1b0000000100000000", "value": 4294967296 }, + { "name": "empty bstr", "hex": "40" }, + { "name": "map two sorted keys", "hex": "a200010101" }, + { "name": "text with leading BOM", "hex": "64efbbbf61" } + ], + "reject": [ + { "name": "uint 23 with one extra byte", "hex": "1817", "error": "ERR_NON_CANONICAL_CBOR" }, + { "name": "bstr length not shortest", "hex": "5800", "error": "ERR_NON_CANONICAL_CBOR" }, + { "name": "keys out of order", "hex": "a201000001", "error": "ERR_NON_CANONICAL_CBOR" }, + { "name": "duplicate key", "hex": "a200000001", "error": "ERR_NON_CANONICAL_CBOR" }, + { "name": "text key", "hex": "a1616100", "error": "ERR_NON_CANONICAL_CBOR" }, + { "name": "indefinite array", "hex": "9f01ff", "error": "ERR_NON_CANONICAL_CBOR" }, + { "name": "tag", "hex": "c101", "error": "ERR_NON_CANONICAL_CBOR" }, + { "name": "float", "hex": "f97e00", "error": "ERR_NON_CANONICAL_CBOR" }, + { "name": "true", "hex": "f5", "error": "ERR_NON_CANONICAL_CBOR" }, + { "name": "null", "hex": "f6", "error": "ERR_NON_CANONICAL_CBOR" }, + { "name": "negative int", "hex": "20", "error": "ERR_NON_CANONICAL_CBOR" }, + { "name": "truncated uint", "hex": "1901", "error": "ERR_NON_CANONICAL_CBOR" }, + { "name": "length beyond input", "hex": "5affffffff", "error": "ERR_NON_CANONICAL_CBOR" }, + { "name": "trailing byte", "hex": "0100", "error": "ERR_NON_CANONICAL_CBOR" }, + { "name": "invalid UTF-8", "hex": "61ff", "error": "ERR_NON_CANONICAL_CBOR" }, + { "name": "overlong UTF-8", "hex": "62c080", "error": "ERR_NON_CANONICAL_CBOR" }, + { "name": "UTF-8 surrogate", "hex": "63eda080", "error": "ERR_NON_CANONICAL_CBOR" } + ] +} +``` + +Además, un bloque por schema: objeto válido mínimo, clave desconocida, clave obligatoria ausente, tipo incorrecto y tamaño fuera de límite. + +Casos de extensión: +- `data` con `40`, con `5801xx` y en el límite de la sección 3 y uno más; +- `data` con tipo distinto de `bstr`: texto, `null`, `uint`, mapa, tag y longitud indefinida; +- 64 y 65 extensiones en un array; +- un `extension_id` que empieza por BOM; +- el par de orden U+FF61 y U+10000; +- `extension_version` en 2⁵³ − 1 y 2⁵³. + +--- + +## 7. Tests + +1. **Regresión byte a byte.** Tras el paso 2b, `profile_hash` de Quicknet, los hex de prelude, cabecera y control de las cinco cápsulas, y los dos `.dkk` completos tienen que ser idénticos a los fixtures congelados, sin ninguna excepción: el cambio de `data` y la regeneración de su fixture ocurren antes, en el paso 2a. +2. **Asertos sobre los bytes exactos de `data`** en los tests de conformidad, no solo sobre su número (`conformance_test.go:165` y `205`, `accesskey_test.go:54`). +3. **Vectores compartidos** de la sección 6, en Go y en TypeScript. +4. **Corpus de mutaciones exportado.** Los 45 casos de `capsule/mutation_test.go` (23 de ellos en los pasos 1 a 8 y sin red) se exportan como vectores congelados: bytes, código de error y paso. TypeScript tiene que reproducir el código y el paso de cada uno. Además, un corpus diferencial de mutaciones aleatorias con semilla fija y el veredicto de Go. +5. **Fuzzing en Go** de `Decoder`, de `Walk`, de cada decodificador de schema y de la propiedad "encode correcto implica decode correcto". El corpus de mutación existente sigue pasando. +6. **TypeScript con `vitest`.** Carga los fixtures de `App/testdata` y compara `inspect` con la salida congelada de `datekeys inspect -json` de cada fixture, generada en Go y guardada en `testdata`; los tests no ejecutan Go. Pasa `quicknet_rounds.json`, `dk1.json` y `profile_quicknet.json`. +7. **Cobertura** del codec al 100 % en ambos lenguajes. En TypeScript necesita `@vitest/coverage-v8` (sección 13). + +--- + +## 8. La página de prueba en Svelte + +En el proyecto `App`, como sitio estático, sin servidor. + +**Fase 1, inspector.** Ruta `/inspect`. Carga un `.dkc` desde disco o uno de los fixtures, ejecuta los pasos 1 a 8 y muestra, como la CLI Go, el resultado de cada paso: prelude, cabecera, política, DateKey, fecha de apertura, argumentos del stanza `tlock` frente al perfil pinneado y recuento de stanzas de OUTER_TIME_AGE y PAYLOAD_AGE. Sin secretos. Una CSP con `connect-src 'self'` garantiza que la página no habla con ningún otro origen. + +Contrato de las extensiones: +- id, versión, crítica o no, conocida o no, longitud y hex siempre; +- una vista de texto si los bytes son UTF-8 imprimible; +- una vista decodificada con `Walk` si los bytes son CBOR del subconjunto, marcada como informativa y no validada por el protocolo; +- todo el texto escapado; +- nunca se construyen objetos ni `Map` de JavaScript a partir de claves (`__proto__`, colisiones por encima de 2⁵³); +- la `data` de PUBLIC_HEADER y `.dkk` se marca como pública y no autenticada hasta el paso 15. + +**Fase 2, cifrar y descifrar.** Solo cuando la fase 1 pase con todos los fixtures y el corpus exportado. + +Necesita `age-encryption` 0.3.1, la librería `age` oficial en TypeScript, que admite `Recipient` e `Identity` propios. Arrastra `@noble/ciphers`, `@noble/curves` 2, `@noble/hashes` 2, `@noble/post-quantum` y `@scure/base`. Como `tlock-js` 0.9 sigue en `@noble/curves` 1.9.7 y `@noble/hashes` 1.8.0, el bundle tendrá dos versiones mayores de noble. La aprobación se decide al empezar la fase 2 (decisión 4). + +Piezas de `tlock-js` y `drand-client`: sustituido el 26-09-2026 por `PLAN_fase2_ibe_noble2.md`. El bloque original nombraba `decryptOnG1` (para Quicknet es `decryptOnG2`) y daba a `drand-client` la verificación de releases; ninguno de los dos paquetes entra ya en el bundle: el núcleo IBE se escribe en el proyecto sobre `@noble/curves` 2.x y los releases se verifican con noble y el perfil pinneado. + +Las identidades `tlock` y X25519 aplican la cardinalidad de §63 (pasos 11, 13 y 17) dentro de `unwrapFileKey`, como en Go. La prueba de aceptación descifra un fixture `time_and_key` en el navegador con la firma embebida, sin red. + +--- + +## 9. Cambios en la especificación y el CDDL + +Todos en un único cambio normativo, con la justificación §76 de la sección 3: + +- **Extensiones.** + - CDDL: `extensions = [1*64 extension]` y `extension = { 0 => extension-id, 1 => extension-version, ? 2 => bstr .size (1..MAX) }`, con MAX según la opción elegida en la sección 3. + - §31 y §54: `data` es una cadena de bytes opaca. El protocolo base nunca la decodifica ni la valida, y la validez de PUBLIC_HEADER, CONTROL_CBOR y `.dkk` nunca depende de su contenido. Una extensión sin datos omite la clave 2, y `h''` es inválido. + - §31 y §54: se elimina la excepción de multiplicidad ("salvo que el schema registrado permita multiplicidad"), que contradice al CDDL y a la implementación de referencia. La multiplicidad va dentro de `data`. + - §54: una extensión crítica conocida con `data` inválida se rechaza con un código definido (nuevo en §69, o uno existente nombrado expresamente). Una no crítica conocida con `data` inválida se marca como inutilizable y el objeto sigue siendo válido. +- **Enteros.** `extension_version`, `period` y `genesis_time` acotados, como mucho a 2⁵³ − 1; para `extension_version` basta con 2³² − 1. En TypeScript, 2⁵³ + 1 se confunde con 2⁵³, y una versión crítica desconocida podría pasar por conocida. +- **§57.** Los límites pasan de "V1 recomienda" a MUST para encoders y decoders. Un único código de error para toda violación de límites; la referencia usa hoy `ERR_INTEGRITY` para las tramas. +- **§58.** Nombra el perfil de la sección 2, aplica también a la cabecera del `bstr` de `data` y nunca a su contenido. +- **§72.** + - Cada extensión registrada declara la codificación de su `data`, su forma canónica, su longitud máxima y sus vectores. + - Si la codificación es CBOR, usa el perfil de §58 de forma recursiva, con contenedores no vacíos y sin `null`. + - Sus lectores rechazan codificaciones no canónicas, y su encoder decodifica lo que produce antes de sellar. + - Las extensiones de firma firman los bytes exactos de `data`. +- **§64.** Mutaciones nuevas: `data` con tipo incorrecto, `data` vacía, `data` por encima del límite y 65 extensiones. +- **§68.** Un fixture `.dkk` con una extensión. +- **§74.** Se retira "formato exacto de extensiones" de lo provisional. +- **Documentación.** Se actualizan `docs/traceability.md` (decisión 6) y `CHANGELOG.md`, que recoge la rotura de API: `extension.New` recibe `[]byte`, y `data` distinta de `bstr` o vacía deja de ser válida. El fixture se regenera en el mismo commit. + +--- + +## 10. Orden de trabajo y criterios de aceptación + +| Paso | Contenido | Hecho cuando | +|---|---|---| +| 1 | Cambios de spec y CDDL de la sección 9 | texto aprobado | +| 2a | Con fxamacker todavía dentro: `data` como `bstr`, `New([]byte)`, máximo de 64, fusión lineal, autocomprobación del encoder. Se regenera `time_only_extensions` con `-force` (cabecera: los bytes de "public label", que no son CBOR; control: un ejemplo CBOR del subconjunto) y se añade el `.dkk` con extensión. | tests verdes; los tres fallos de la sección 1 tienen test de regresión; commit en Gitea | +| 2b | Codec propio en Go y baja de fxamacker | cero cambios en `testdata/`; regresión completa de la sección 7; `govulncheck` sin cambios; una noche de fuzzing limpia; commit en Gitea | +| 3 | Vectores CBOR, corpus de mutaciones exportado y salidas de `inspect -json` congeladas | Go los genera y los pasa; ficheros congelados | +| 4 | En `App`: `sync-testdata`, codec y parsers TypeScript, `inspect.ts` | `vitest` reproduce fixtures, vectores y corpus exportado; cobertura del codec al 100 % | +| 5 | Ruta `/inspect` | abre los fixtures y un `.dkc` arrastrado; la CSP bloquea cualquier otro origen. `npm run verify`: `svelte-check`, `tsc`, cobertura de `src/lib/dkc` y `src/lib/inspector`, y `build` con `scripts/check-build.mjs` (CSP de cada página prerenderizada, fixtures byte a byte, ningún secreto en el sitio) | +| 6 | Fase 2 | `age-encryption` aprobada; un fixture `time_and_key` se descifra en el navegador | + +--- + +## 11. Riesgos + +- **Dos codecs propios** en vez de una librería auditada por muchos. Mitigación: subconjunto mínimo, `data` opaca, vectores compartidos, regresión byte a byte sin excepciones, corpus de mutaciones exportado y fuzzing. +- **Desviación entre Go y TypeScript.** Los fixtures y vectores del Go son la verdad; TypeScript nunca genera fixtures propios, y `testdata/SOURCE.json` fija el commit de origen y el hash de cada fichero. +- **Parser estricto de cabecera `age` en TypeScript.** Cubierto por las mutaciones de los pasos 5, 6 y 8. +- **`data` opaca aplaza la validación a cada extensión.** Es el tipo de fallo de CVE-2020-1971. Mitigación: la regla de §72, el perfil con nombre y el recorredor compartido. +- **Límite de `data`.** Si se elige un N uniforme, puede limitar servicios futuros, como el `opaque_onion` del Dead Man Switch. + +--- + +## 12. Decisiones + +1. **`data`:** `bstr` no vacío (recomendado, sección 3). Pendiente de confirmar el límite: la trama del contenedor (recomendado), límites por contenedor o un N uniforme de 65 535. +2. **Proyecto TypeScript:** aparte, en `App`. El prototipo queda en `AppOld`. Decidido. +3. **Regla de dependencias:** afecta a las de ejecución. El tooling de desarrollo se admite solo desde la lista explícita de la sección 13. Decidido. +4. **`age-encryption` 0.3.1:** se decide al empezar la fase 2. Decidido. + +--- + +## 13. Tooling de desarrollo de `App` + +| Paquete | Versión | Estado | +|---|---|---| +| `typescript` | 5.9.3 | en `package.json`; ya usado en el prototipo | +| `vitest` | 5.0.1 | en `package.json`; ya usado en el prototipo | +| `@vitest/coverage-v8` | 5.0.1 | en `package.json` (sección 7) | +| `@types/node` | 24.13.6 | en `package.json`; tests que leen `testdata/` | +| `svelte`, `@sveltejs/kit`, `@sveltejs/vite-plugin-svelte`, `vite`, `svelte-check` | 5.57.1, 2.70.3, 7.3.0, 8.3.0, 4.7.6 | en `package.json`; paso 5 | +| `@sveltejs/adapter-static` | 3.0.10 | en `package.json`; paso 5, sitio estático | +| `@noble/curves` | 2.4.0 | solo desarrollo; oráculo auditado del test de contraste de `bls12381.ts` (decidido el 26-09-2026: se mantiene nuestra implementación y se contrasta en cada ejecución). En la fase 2 pasará a ser la dependencia BLS de ejecución, fijada a 2.3.0 o posterior | + +Todas las versiones se fijan exactas y `package-lock.json` se versiona. `.npmrc` activa `legacy-peer-deps` porque npm 11.5.2 falla al resolver los peers opcionales de `vitest` 5.0.1 (`Cannot read properties of null (reading 'edgesOut')`). diff --git a/PLAN_fase2_ibe_noble2.md b/PLAN_fase2_ibe_noble2.md new file mode 100644 index 0000000..f5f34dc --- /dev/null +++ b/PLAN_fase2_ibe_noble2.md @@ -0,0 +1,197 @@ +# Plan: fase 2 del SDK TypeScript. Descifrado y cifrado tlock sobre noble 2, verificación local de releases y canonicidad de puntos + +Estado: v2, 26 de septiembre de 2026. Decisiones de la sección 2 confirmadas por el autor el 26-09-2026, con los ajustes de dos revisiones (la de Claude Opus y la de Fable), verificados contra el código, el spec y npm. Sustituye la "Fase 2, cifrar y descifrar" de la sección 8 del plan v2 (`PLAN_codec_cbor_y_pagina_svelte.md`), cuyo bloque "Piezas de `tlock-js` y `drand-client`" contenía dos errores: para Quicknet el descifrador es `decryptOnG2`, no `decryptOnG1`, y `drand-client` no hace falta. + +Alcance: abrir y crear cápsulas del perfil Quicknet en el navegador, sin red, sobre la librería TypeScript de `App`. El núcleo IBE se escribe en el proyecto sobre `@noble/curves` 2.x, los releases se verifican con noble y el perfil pinneado, y la envoltura `age` la pone `age-encryption`. Ni `tlock-js` ni `drand-client` entran en el bundle. + +Regla de dependencias: la del plan v2. En ejecución, solo `age`, `drand`, `tlock` y lo que ellas arrastran, en cualquier lenguaje; `@noble/curves` y `@noble/hashes` entran por ser lo que `age-encryption` arrastra. Toda dependencia nueva se propone por escrito y no se instala sin aprobación (sección 3). + +--- + +## 1. Situación de partida + +Todo lo de esta tabla se ejecutó el 26-09-2026 sobre `tlock-js` 0.9.0, `@noble/curves` 1.9.7 y 2.4.0, los fixtures de `App/testdata` y la librería Go. Los scripts quedaron en el scratchpad de la sesión (`…\scratchpad\noble2ibe\c`, `…\scratchpad\override`, `…\scratchpad\noblehist`); si el directorio temporal ya no existe, cada punto se reproduce a partir de la descripción. + +| Hecho | Detalle | +|---|---| +| Las 5 615 discrepancias de noble 1.9.7 frente a Go son un solo fenómeno | 5 612 codificaciones no canónicas de coordenada (x + p, c0 o c1 + k·p) que decodifican al punto correcto y 3 identidades con flags o carga no nula. Cero en sentido contrario, cero fallos de subgrupo: `fromHex` de 1.9.7 ya ejecuta `assertValidity`. Sin la comprobación de longitud, 102 más por codificaciones sin comprimir. | +| Efecto en tlock-js sobre 1.9.7 | `decryptOnG2` acepta un U recodificado como c0 + p y una firma x + p y devuelve la misma file key. `bls12-381.js:258` (G1) y `:358` (G2) reducen módulo p. | +| Efecto en Go | U no canónico, identidad, fuera de curva, fuera de subgrupo o cuerpo de 127 o 129 bytes: `ERR_INTEGRITY` en el paso 11. Firma no canónica, identidad, negada o de otra ronda: `ERR_RELEASE_INVALID` en el paso 10. Firma y U malos a la vez: gana el paso 10. Con el cliente drand incorporado, una firma mala servida por el relay es `ERR_RELEASE_UNAVAILABLE` en el paso 9. | +| `checkCompressedPoint` como puerta | Exigiendo `'point'`, bloquea todas las variantes anteriores, incluida la identidad canónica, y coincide con Go en las 49 451 entradas de los corpus. 11,4 ms por punto G2 y 6,5 ms por G1 en Node 24. | +| Un override a noble 2.4.0 no carga | `npm install` termina con exit 0 y sin avisos. Después: `ERR_PACKAGE_PATH_NOT_EXPORTED` (2.x es ESM y solo exporta subrutas `.js`), `ProjectivePoint` y `toRawBytes` no existen, `fromHex` solo acepta string, `sha256.update("IBE-H2")` lanza con hashes 2.x. Con 12 a 18 líneas cambiadas el núcleo corre e interopera en ambos sentidos con 1.9.7. | +| Módulo mínimo sobre noble 2.4.0 | 44 líneas de código (64 con comentarios), solo descifrado Quicknet. Pasa el tsconfig estricto de `App`. Descifra los cinco fixtures con las mismas file keys que tlock-js y que `tlock.TimeUnlock` en Go; el MAC de la cabecera `age` verifica; no carga nada de tlock-js. Rechaza el U no canónico en `fromBytes` como Go. | +| Serialización de GT | `Fp12.toBytes` de noble (igual en 1.9.7 y 2.4.0) escribe c0 primero en cada nivel. kilic, kyber y `fp12ToBytes` de tlock-js escriben los doce limbs de 48 bytes en orden inverso. Con el orden de noble la clave sale mal (control negativo). | +| Verificación de releases sin drand-client | `shortSignatures.verify(sig, shortSignatures.hash(sha256(uint64be(ronda)), DST), pk)` de noble 2.4.0 da `true` para la ronda 1000 y `false` para la 999 con la firma real. El DST por defecto de G1 es el de Quicknet. Ninguna versión de `drand-client` ha estado en noble 2.x; su verificación rfc9380 aceptó tres codificaciones alternativas de la firma real, cada una con un `randomness` distinto. | +| tlock-js 0.9.0 | `gitHead` `17d817e` (18-03-2024) es el commit que pasó a `@noble/curves`; el código auditado en 2022-2023 corría sobre `@noble/bls12-381`; `fp.ts` es nuevo en 0.9.0 y cerca del 40 % de `ibe.ts` cambió después. Sin tags ni releases, master sin cambios desde entonces. Cinco dependencias de ejecución; instalación limpia de 33 paquetes y 10,6 MB. `npm audit` sin avisos. Su guarda de longitud de mensaje solo salta a partir de 512 bytes. | +| Spec v0.8.2 | No nombra U, V ni W, ni sus tamaños, ni la canonicidad de las codificaciones. §12.1 pide "la codificación comprimida de BLS12-381 que usa drand", subgrupo y no infinito, sin x < p. El paso 11 de §63 solo dice que un cuerpo que no es un ciphertext tlock del scheme da `ERR_INTEGRITY`. La regla de facto es el decodificador kilic. | +| Librería TypeScript hoy | `src/lib/dkc` ejecuta los pasos 1 a 8 e `inspect`; `age.ts` parsea cabeceras `age` y `checkTimeStanzas` aplica cardinalidad, tipo, argumentos, ronda y chain hash con los códigos de Go; `bls12381.ts` valida puntos comprimidos; no hay writer de cápsulas. `@noble/curves` 2.4.0 es dependencia de desarrollo y un test prohíbe importarla fuera de tests. | +| Repositorio | `main` en `d5e3236`, en verde: la alineación con `datekeys-go` está fusionada y `testdata` sincronizado a `692cf87` (rama `v0.8.2`). La rama `wip/align-3820066` ya no existe. | +| `age-encryption` 0.3.1 | Declara `@noble/curves ^2.0.1` y `@noble/hashes ^2.0.1`, que resuelven a la 2.4.0 de `App` sin override. Pero arrastra `@noble/post-quantum` 0.5.4, que declara `@noble/curves ~2.0.0` y `@noble/hashes ~2.0.0`: habrá una segunda copia de noble 2.0.x anidada bajo post-quantum, que la usa para ML-KEM y X25519, no para BLS. Admite la Streams API para cifrar y descifrar. | + +--- + +## 2. Decisiones + +Confirmadas por el autor el 26-09-2026 (paso 0 de la sección 10). + +1. **Núcleo IBE propio** en `src/lib/dkc/ibe.ts`, derivado de `tlock-js/crypto/ibe.ts` (commit `17d817e`, licencia Apache-2.0 OR MIT, con aviso de procedencia) y con la semántica de `drand/kyber encrypt/ibe`. Sobre `@noble/curves` 2.3.0 o posterior. Se descarta parchear `tlock-js` y se descarta una puerta alrededor de `tlock-js` sobre noble 1.9.7, que dejaría dos versiones mayores de noble en el bundle. +2. **Puerta de canonicidad**: toda firma, U y clave pública pasa por `checkCompressedPoint` y solo sigue si el resultado es exactamente `'point'`. Aunque noble 2.3.0 y posteriores rechazan lo mismo, la puerta fija la precedencia y el código de error, y trata la identidad canónica antes del pairing, donde noble la acepta y falla con otro mensaje. +3. **Solo Quicknet** (`bls-unchained-g1-rfc9380`: clave pública en G2, firmas en G1, U en G2). Los otros dos schemes de drand quedan fuera de esta fase; se anota en la sección 12. +4. **Releases sin red en esta fase**: la obtención (paso 9) queda fuera del SDK. El SDK recibe el release y lo verifica localmente (paso 10). La CSP `connect-src 'self'` de la página no cambia. El contrato de `ReleaseSource` se fija ya (sección 5): una fuente verifica cada respuesta, y si no obtiene ningún release verificado el resultado es `ERR_RELEASE_UNAVAILABLE` en el paso 9, como el cliente drand de Go (`provider/drand/client.go`); un release entregado por quien llama que no verifica es `ERR_RELEASE_INVALID` en el paso 10. Para el producto: la página habla con un único origen, la Release API propia, que consulta varios relays y verifica en el servidor (§47, §48); el SDK ofrece además la consulta directa a drand como fuente opcional (§49 SHOULD), nunca como opción por defecto de la página. +5. **`age-encryption` 0.3.1** para las tres envolturas `age`, con `Identity` y `Recipient` propios para el stanza `tlock`. Confirma la decisión 4 del plan v2. Sin overrides de npm: el rango `^2.0.1` resuelve a 2.4.0, y la copia 2.0.x que trae `@noble/post-quantum` (que declara `~2.0.0` a propósito) se acepta si solo la usa post-quantum. Las guardas de la sección 3 lo comprueban y el paso 2 mide su coste en el bundle. +6. **Cambio de spec**: canonicidad de puntos y contenido del cuerpo del stanza tlock (sección 7), como **enmienda de v0.8.2**, que no está fusionada ni etiquetada. En curso en `datekeys-go`, rama `v0.8.2`, junto con la limpieza del texto de error de kyber. +7. **Apertura en streaming**: `PAYLOAD_AGE` se descifra con la Streams API de `age-encryption` hacia un fichero temporal en OPFS, que solo se muestra o se ofrece para descargar cuando `age` termina sin error (§56). §57 no acota `PAYLOAD_AGE`, así que el límite es la cuota de almacenamiento del navegador, que se consulta con `navigator.storage.estimate()` antes de empezar. +8. **Licencia de `App`**: Apache-2.0, como `datekeys-go`. `ibe.ts` conserva el aviso de copyright y licencia de `tlock-js` (Apache-2.0 OR MIT). + +--- + +## 3. Dependencias de ejecución y guardas + +| Paquete | Versión | Estado | +|---|---|---| +| `age-encryption` | 0.3.1 | aprobada (decisión 5) e instalada el 28-09-2026. Arrastra `@noble/ciphers` 2.4.0, `@noble/curves` 2, `@noble/hashes` 2, `@noble/post-quantum` 0.5.4 (con su propia copia de `@noble/curves` y `@noble/hashes` 2.0.1) y `@scure/base` 2.4.0. | +| `@noble/curves` | 2.4.0 | pasó de desarrollo a ejecución el 28-09-2026. Fijada exacta. | +| `@noble/hashes` | 2.4.0 | dependencia exacta de `@noble/curves` 2.4.0; declarada explícita el 28-09-2026 por importarse directamente. | +| `tlock-js`, `drand-client` | — | no se instalan. | + +Guardas, todas en tests que corren en cada ejecución: + +- ningún fichero de `src/` importa `tlock-js` ni `drand-client`; +- `@noble/*` solo se importa desde `src/lib/dkc/ibe.ts`, `src/lib/dkc/release.ts` y los tests. El test actual "only tests import @noble/curves" de `bls12381.contrast.test.ts` pasa a una lista blanca con esos dos ficheros; +- `ibe.ts`, `release.ts` y el test de contraste de `bls12381.ts` resuelven `@noble/curves` y `@noble/hashes` a la copia de la raíz, exactamente 2.4.0 (2.3.0 o posterior es el mínimo por la corrección de canonicidad), y ningún fichero de `src/` importa una copia anidada; +- `package-lock.json` no contiene ninguna versión 1.x de `@noble/curves` ni de `@noble/hashes`, y cualquier copia 2.x distinta de la raíz está anidada bajo `@noble/post-quantum`; +- `scripts/check-build.mjs` comprueba además que el sitio construido no contiene `tlock-js`, `drand-client` ni `@babel`, e informa del tamaño del bundle de la página de apertura. + +--- + +## 4. Módulo IBE + +`src/lib/dkc/ibe.ts`, sin dependencias fuera de `@noble/curves`, `@noble/hashes` y `bls12381.ts`. + +**Semilla.** El módulo de descifrado medido el 26-09-2026 (`scratchpad\noble2ibe\c\ibe.ts`), con sus importaciones cambiadas a especificadores normales (`@noble/curves/bls12-381.js`, `@noble/curves/utils.js`, `@noble/hashes/sha2.js`). + +**Contenido.** + +- `Ciphertext { U, V, W }` y `decryptOnG2(signature, ct)`: longitudes (48, 96, `|V| == |W| <= 32`), puerta `'point'` sobre firma y U, `G1.Point.fromBytes` y `G2.Point.fromBytes` con `assertValidity`, `pairing(sig, U)`, `sigma = V xor H2(gt)`, `msg = W xor H4(sigma)`, `r = H3(sigma, msg)`, comprobación `r·G2 == U`. +- `encryptOnG2RFC9380(publicKey, id, msg)`: puerta `'point'` sobre la clave, `|msg| <= 32` (kyber comprueba `len(msg) > Hash().Size()`; tlock-js tiene aquí un fallo que no se copia), `Qid = G1.hashToCurve(id, { DST: 'BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_' })`, `gt = pairing(Qid, pk)`, `sigma` de `|msg|` bytes con `crypto.getRandomValues`, `r = H3(sigma, msg)`, `U = r·G2`, `V = sigma xor H2(gt^r)`, `W = msg xor H4(sigma)`. `Fp12.pow` para `gt^r`. +- `H2(gt) = SHA-256("IBE-H2" ‖ GT)[:len]` con GT serializado en el orden de kilic: para `[gt.c1, gt.c0]`, para `[a.c2, a.c1, a.c0]`, `Fp.toBytes(b.c1) ‖ Fp.toBytes(b.c0)`. Nunca `Fp12.toBytes` de noble. +- `H3(sigma, msg)`: `base = SHA-256("IBE-H3" ‖ sigma ‖ msg)`; para `i = 1 … 65534`, `d = SHA-256(uint16le(i) ‖ base)`, `d[0] >>= 1`, se acepta el primer `d` big-endian menor que `Fr.ORDER`. Si el bucle termina, error. +- `H4(sigma) = SHA-256("IBE-H4" ‖ sigma)[:len]`. +- `roundIdentity(round) = SHA-256(uint64be(round))`. +- Errores: una clase `IbeError` con un motivo fijo (`length`, `encoding`, `identity`, `proof`). Ningún mensaje de error incluye `sigma`, `msg`, `r` ni bytes de entrada. Es la lección del texto de error de kyber, que `datekeys-go` copiaba a sus diagnósticos (se corrige en la misma rama que la enmienda de la sección 7). +- `sigma` y la file key se borran (`fill(0)`) en cuanto dejan de usarse, en todos los caminos, como hace la librería con `access_material` e `I_PAYLOAD`. +- Para tests, una variante interna de `encryptOnG2RFC9380` recibe `sigma` en vez de generarlo, de modo que el cifrado se compara byte a byte con Go. No se exporta desde `index.ts`. + +**Vectores de Go.** La referencia de los tests IBE es la librería Go, no los tests de `tlock-js`, cuyo `ibe.ts` cambió en un 40 % tras la auditoría. `scripts/ibe-go-vectors.go`, como `scripts/bls12381-go-verdicts.go`, genera con kyber y tlock: los bytes de GT de e(G1, G2) y de su cuadrado, H2, H3 y H4 sobre entradas fijas, la file key de cada stanza de los fixtures con la firma de su sidecar y un cifrado con `sigma` fijo. El resultado se congela en `src/lib/dkc/testing/`. El vector de GT de `tlock-js` (`cb87319f24560b5231579a09ad79f12e`) coincide con el de kyber (comprobado el 26-09-2026) y se conserva como contraste. + +**Serialización del stanza.** Cuerpo `U ‖ V ‖ W` de 128 bytes para una file key de 16; argumentos `[decimal canónico de la ronda, chain hash en hexadecimal minúsculo]`. La lectura ya existe en `age.ts`; la escritura se añade en `age.ts` junto a ella. + +--- + +## 5. Verificación de releases + +`src/lib/dkc/release.ts`, `verifyRelease(profile, round, release)`, en el mismo orden que `provider.Verify` de Go (`provider/provider.go:53-76`), que decodifica la clave antes de mirar la firma: + +1. ronda fuera del rango del perfil → `ERR_DATEKEY_INVALID`; +2. ronda del release distinta de la ronda de la condición → `ERR_ROUND_MISMATCH`; +3. firma que no mide 48 bytes → `ERR_RELEASE_INVALID`; +4. clave del perfil pinneado, ya validada en los pasos 1 a 8; si no decodifica → `ERR_UNKNOWN_PROFILE`; +5. `checkCompressedPoint('G1', firma) !== 'point'` → `ERR_RELEASE_INVALID`; +6. `shortSignatures.verify(firma, shortSignatures.hash(sha256(uint64be(ronda)), DST_QUICKNET), clave)` distinto de `true` → `ERR_RELEASE_INVALID`. Una excepción de noble en este punto también es `ERR_RELEASE_INVALID`. + +La interfaz `ReleaseSource` del SDK solo tiene una implementación estática en esta fase: el release embebido en el sidecar del fixture o suministrado por la aplicación. Su contrato se fija ya para las fuentes con red de fases posteriores: una fuente llama a `verifyRelease` con cada respuesta y, si ninguna verifica, falla con `ERR_RELEASE_UNAVAILABLE` en el paso 9; solo un release entregado directamente por quien llama da `ERR_RELEASE_INVALID` en el paso 10. Es el comportamiento de `provider/drand/client.go` en Go. + +Además, desde la corrección 6 de §76, cualquier fallo de una fuente se informa en el paso 9 con `ERR_RELEASE_UNAVAILABLE` y ningún otro código. Si el error de la fuente lleva otro código normativo, o ninguno, se conserva solo su texto. Si el contexto termina, se sigue pudiendo detectar. Así lo hacen `capsule.Open` (`sourceFailure`) y `provider/drand.Client` en Go `9ac9cd9`, y `open.ts` debe hacer lo mismo. + +--- + +## 6. Apertura y cifrado con `age-encryption` + +`src/lib/dkc/open.ts` reproduce los pasos 9 a 18 de `capsule/open.go`, sobre los pasos 1 a 8 que ya ejecuta `inspect`: + +- paso 9, release por `ReleaseSource`; paso 10, `verifyRelease`; +- paso 11, `OUTER_TIME_AGE` con `Decrypter` de `age-encryption` y una `Identity` propia cuyo `unwrapFileKey(stanzas)` ejecuta, en este orden, `checkTimeStanzas` (cardinalidad de §63, como `agewrap.TimeIdentity.Unwrap` en Go), `verifyRelease` otra vez, la comprobación de 128 bytes del cuerpo, `decryptOnG2` y la longitud 16 de la file key. Cada fallo de cuerpo o de descifrado es `ERR_INTEGRITY`; el MAC de la cabecera lo comprueba `age-encryption`; +- pasos 12 a 18 como en Go: estructura de política, `INNER_ACCESS_AGE` con las identidades del llamante, `CONTROL_CBOR` canónico, `header_binding` sobre los bytes exactos, identidad de payload y `PAYLOAD_AGE`; +- `PAYLOAD_AGE` se descifra en streaming (decisión 7): `File.slice(offset).stream()` entra en `Decrypter.decrypt`, la salida se escribe en un fichero temporal de OPFS y el plaintext solo se entrega cuando el stream termina sin error; si falla la autenticación en cualquier chunk, el fichero temporal se borra y no se muestra nada (§56). Antes de empezar se compara el tamaño de `PAYLOAD_AGE` con la cuota libre. + +Cifrado: `Encrypter` con un `Recipient` propio cuyo `wrapFileKey(fileKey)` llama a `encryptOnG2RFC9380(clave del perfil, roundIdentity(ronda), fileKey)` y devuelve el stanza `tlock`. La construcción completa de un `.dkc` (prelude, cabecera, control sellado) requiere el writer TypeScript, que es fase 3; en esta fase el cifrado se prueba a nivel de stanza y de fichero `age` (sección 8). + +Antes de escribir código se comprueba en el paquete instalado la forma exacta de `Identity`, `Recipient` y `Stanza` de `age-encryption` 0.3.1 y se anota en este plan. + +--- + +## 7. Cambios en la especificación y en `datekeys-go` + +Todo en un único cambio normativo, con vectores congelados, según la política de §76 (caso de "segunda implementación independiente" y "prueba de interoperabilidad"). + +- **Definición de codificación canónica de punto**, en §12 o en un apartado nuevo: la serialización comprimida de BLS12-381 que produce drand, 48 bytes en G1 y 96 en G2; bit de compresión a 1; bit de infinito solo en el punto en el infinito, con el resto de bytes a cero; coordenadas big-endian menores que p, y en G2 c1 seguido de c0, ambas menores que p; el punto está en el subgrupo de orden primo. Un decodificador MUST rechazar cualquier otra cadena de bytes, en particular x + p y una identidad con carga o con el bit de compresión ausente. +- **§12.1, punto 2**: `public_key` es una codificación canónica según esa definición, distinta del infinito. Código sin cambio: `ERR_UNKNOWN_PROFILE`. +- **§63, paso 10**: la firma es una codificación canónica de un punto del grupo de firmas del scheme, distinta del infinito, y verifica como firma de la ronda; en otro caso `ERR_RELEASE_INVALID`. Se mantiene la precedencia de `ERR_ROUND_MISMATCH`. +- **§63, paso 11**: el cuerpo del stanza `tlock` es `U ‖ V ‖ W`, con `|U|` igual al tamaño de punto del grupo de claves del scheme (96 para Quicknet), `|V| = |W| = 16`; U es una codificación canónica de un punto de ese grupo distinta del infinito; el descifrado IBE-CCA comprueba `r·G == U`. Cualquier fallo, incluida una longitud distinta de 128, es `ERR_INTEGRITY`. Se cita drand/tlock para H2, H3 y H4. +- **§64, mutaciones nuevas** con código y paso, verificados en Go el 26-09-2026: U con c0 + p, U identidad canónica, U con flag de infinito y carga, cuerpo de 127 y 129 bytes → `ERR_INTEGRITY`, paso 11; firma x + p (requiere un release de prueba cuya x lo permita, o un vector sintético documentado), firma identidad, firma con flag de infinito y carga, firma negada → `ERR_RELEASE_INVALID`, paso 10; firma mala y U malo a la vez → `ERR_RELEASE_INVALID`, paso 10. +- **`datekeys-go`**: las mismas mutaciones en `internal/testkit/mutations.go`, regeneración de `testdata/vectors/mutations.json`, `traceability.md` y `CHANGELOG.md`. El comportamiento del código no cambia. Aparte, la tarea ya propuesta de no copiar el texto de error de kyber a los errores y a `Inspection.Checks[].Detail`. +- **`App`**: `testdata:sync` al commit resultante y reproducción en TypeScript de código y paso de cada mutación nueva. + +--- + +## 8. Tests + +1. **`ibe.test.ts`.** Los vectores de Go de la sección 4 (GT, H2, H3, H4, las file keys de los fixtures y el cifrado con `sigma` fijo), y como contraste el de tlock-js (`gtToHash` de 16 bytes `cb87319f24560b5231579a09ad79f12e`, igual al de kyber). Los cinco fixtures: la file key obtenida con la firma del sidecar abre la cabecera `age`. Control negativo: serializar GT con `Fp12.toBytes` de noble da otra clave. U con c0 + p, U identidad, U negado y firma de otra ronda: rechazados con el motivo esperado. Ida y vuelta `encryptOnG2RFC9380` → `decryptOnG2` con una clave sintética. Cobertura del 100 %. +2. **`release.test.ts`.** Ronda 1000 con la firma real: válida; 999: inválida; codificaciones alternativas de la misma firma (sin comprimir, x + p, flag de signo alterado): `ERR_RELEASE_INVALID`; longitud 47 y 96; identidad. Cobertura del 100 %. +3. **`open.test.ts`.** Los cinco fixtures se abren con el release del sidecar y el SHA-256 del plaintext coincide con el sidecar; los `.dkk` de `time_and_key` abren con sus identidades. El corpus de mutaciones exportado, incluidas las nuevas de la sección 7, reproduce código y paso. +4. **Interoperabilidad TS → Go a nivel IBE.** `scripts/ibe-go-roundtrip.go`, como `scripts/bls12381-go-verdicts.go`: TypeScript cifra 16 bytes para la ronda 1000 con la clave de Quicknet y escribe `U ‖ V ‖ W`; Go los abre con `tlock.BytesToCiphertext` y `tlock.TimeUnlock` con la firma real y compara. Se ejecuta a mano y su resultado se congela como vector. +5. **Interoperabilidad TS → Go a nivel de fichero `age`.** Un fichero `age` con un stanza `tlock` escrito por `age-encryption` con el `Recipient` propio, abierto por `agewrap` de Go con la firma real. Se ejecuta a mano y se congela. +6. **Guardas** de la sección 3. +7. **Rendimiento**, informativo: tiempo de `decryptOnG2` en frío y en caliente en Node y en el navegador de la página. + +--- + +## 9. Página + +La ruta `/inspect` gana una acción "abrir": con un fixture o un `.dkc` arrastrado, un release pegado o el del sidecar, y las identidades `.dkk` cuando la política las exige. Muestra el resultado de cada paso 9 a 18 como hoy muestra 1 a 8, y el SHA-256 del plaintext; para los fixtures, además el plaintext. Sin red: la CSP no cambia y `check-build` lo comprueba. El plaintext nunca se envía. + +Precisión aprobada por el autor el 28-09-2026, tras revisar lo que dice el protocolo: +- **El plaintext de un fichero propio se guarda solo en un fichero temporal** del almacenamiento privado del navegador (OPFS), hasta la descarga. Es el "fichero temporal" que recomienda §56, y ninguna regla del protocolo prohíbe guardarlo en el cliente. Se borra al pedirlo, al abrir o cargar otra cápsula, al salir de la página y, si quedó, en la siguiente visita. +- **El release lo suministra quien abre** (§63 paso 10): pegado de la respuesta de drand, que la persona abre en otra pestaña desde un enlace de la página, o el del registro de un fixture. Solo se leen su ronda y su firma (§11, §13). +- **§48 y §49 siguen pendientes.** Con solo releases suministrados, la librería no cumple aún el SHOULD de §48 (varios relays) y no resuelve por sí misma el de §49 (obtener el release directamente del proveedor, sin la API DateKeys). Los cubrirá la fuente drand opcional del SDK, nunca activa por defecto en la página (decisión 4, sección 12). + +--- + +## 10. Orden de trabajo y criterios de aceptación + +| Paso | Contenido | Hecho cuando | +|---|---|---| +| 0 | Confirmar las decisiones de la sección 2 y aprobar las dependencias de la sección 3 | hecho el 26-09-2026 | +| 1 | Precondición: `main` verde con `testdata` sincronizado al último commit de `datekeys-go` | cumplida en `d5e3236` (`692cf87`) y de nuevo en `71ab8fb`, con `testdata` en `9ac9cd9` (`spec-v0.8.2`) | +| 2 | Dependencias y guardas | instaladas con versiones exactas; `npm audit --omit=dev` sin avisos; guardas en verde; en el lockfile, un solo noble 2.4.0 en la raíz, la copia 2.0.1 solo bajo `@noble/post-quantum` y ningún 1.x. Hecho el 28-09-2026: el README de `App` recoge las guardas, la medida del bundle y el resultado de `npm audit` | +| 3 | `ibe.ts` de descifrado desde la semilla, `roundIdentity`, escritura del stanza en `age.ts`, `ibe.test.ts` sin la parte de cifrado | los cinco fixtures dan la file key correcta; U no canónico e identidad rechazados; cobertura 100 %. Hecho el 28-09-2026: vectores de `scripts/ibe-go-vectors.go` en `src/lib/dkc/testing/ibe-vectors.json`; `ibe.ts` al 100 %, fijado como umbral. `ibe.ts` también pasa el cuerpo `U ‖ V ‖ W` a bytes; los argumentos del stanza y su paso al `Stanza` de `age-encryption`, que guarda el tipo en `args[0]`, van al paso 7, con el `Recipient` que los usa | +| 4 | `release.ts` y `release.test.ts` | ronda real válida, alias rechazados. Hecho el 28-09-2026:
- `verifyRelease` con el orden y los textos de `provider.Verify`;
- `ReleaseSource`, con el contrato de la sección 5, y `suppliedRelease`;
- los casos de `TestVerifyRejects` de Go y los 7 del corpus de mutaciones que fallan en el paso 10;
- las firmas publicadas de las rondas 1000, 1001, 2000 y 1004, esta última obtenida restando p a la codificación x + p del corpus;
- cobertura del 100 %, fijada como umbral | +| 5 | `open.ts` con la `Identity` propia, pasos 9 a 18, `open.test.ts` | los cinco fixtures se abren y el plaintext coincide con el sidecar; el corpus de mutaciones existente reproduce código y paso.
5a hecho el 28-09-2026, con el texto en claro en memoria:
- los 65 casos del corpus pasan por `open` con el código y el paso de Go;
- los cinco fixtures se abren con cada credencial;
- los textos siguen a `capsule.Open` y `agewrap`.
El paso 13 exige probar cada identity contra cada stanza X25519, y `age-encryption` no expone su `X25519Identity`. Por eso `x25519.ts` abre los stanzas de uno en uno, con ChaCha20-Poly1305 de `@noble/ciphers` 2.4.0, dependencia aprobada el 28-09-2026 porque es la copia que ya usa `age-encryption`. `bech32.ts` lee las identidades `AGE-SECRET-KEY-1…`.
`index.ts` aún no reexporta la apertura, que metería noble en `/inspect`: el paso 8 la cargará bajo demanda.
5b hecho el 28-09-2026: `open` acepta un `Blob`, del que lee solo el prefijo de los pasos 1 a 8 (`prefix.ts`, movido de la página a la librería), calcula el `capsule_digest` en streaming (`digest.ts`) y descifra `PAYLOAD_AGE` en streaming. La salida puede ser un `WritableStream`, que se cierra tras el paso 18 y se aborta ante cualquier fallo. Los 65 casos del corpus pasan también así.
Comprobado en el navegador con un fichero OPFS (`createWritable`): `time_only` se abre con el SHA-256 del sidecar, y un fallo de STREAM deja intacto el contenido anterior del fichero.
La consulta de cuota (`navigator.storage.estimate()`), el fichero temporal y la descarga van con la página, en el paso 8 | +| 6 | Spec, mutaciones en Go, `testdata:sync`, reproducción en TypeScript (sección 7) | texto aprobado; vectores congelados en ambos repositorios; commits en Gitea. Hecho: la enmienda de canonicidad entró en la v0.8.2 (`f6f2e9f`, tag `spec-v0.8.2`), y sus 10 mutaciones pasan por `open` en TypeScript desde el paso 5a | +| 7 | `encryptOnG2RFC9380`, `Recipient` propio, ida y vuelta, interoperabilidad TS → Go a nivel IBE y de fichero `age` (sección 8, puntos 4 y 5) | Go abre lo que TypeScript cifra; vectores congelados.
Hecho el 28-09-2026:
- `ibe.ts` gana `encryptOnG2RFC9380` y `encryptOnG2WithSigma` (solo para tests);
- `tlock.ts` gana `timeRecipient`, con las comprobaciones y textos de `NewTimeRecipient`;
- `scripts/tlock-go-vectors.go` reescribe `EncryptCCAonG2` con sigma fijo, lo comprueba con kyber y tlock, y abre las muestras de `scripts/tlock-ts-samples.mjs`: Go abrió los cuerpos IBE y los ficheros `age` de las rondas 1000 y 1001 con la misma file key y el mismo texto;
- todo congelado en `src/lib/dkc/testing/tlock-vectors.json`;
- cobertura del 100 % de `ibe.ts` y `tlock.ts` | +| 8 | Página (sección 9) | un fixture `time_and_key` se abre en el navegador sin red; `check-build` en verde; tamaño del bundle anotado en el README.
Hecho el 28-09-2026:
- `OpenPanel.svelte`, con `release-input.ts` (release pegado o del registro del fixture), `opener.ts` (cargado con `import()`), `opening.ts` (pasos 9 a 18 como los registra la referencia) y `tempfile.ts` (fichero temporal de OPFS, un directorio y un Web Lock por pestaña, cuota libre y limpieza);
- `readAccessKey` en `prefix.ts`;
- `licenses.txt` con los avisos de `tlock-js` y `age` y los de cada paquete del bundle, escrito por `vite.config.ts`;
- nuevas guardas de `check-build`: ninguna página carga noble, `@scure/base` ni `age-encryption` en la primera carga, y `licenses.txt` está completo;
- cobertura del 100 % de los módulos nuevos.
Comprobado en el navegador con la compilación de producción:
- se abren los fixtures `time_only`, `time_and_key_portable` (con su `.dkk`) y `time_and_key_recipients` (con una identidad pegada), cada uno con el SHA-256 de su registro;
- una firma alterada da `ERR_RELEASE_INVALID` en el paso 10, y un fragmento STREAM alterado da `ERR_INTEGRITY` en el paso 17, sin descarga ni fichero temporal;
- un fichero propio se abre al fichero temporal, se descarga sin violar la CSP y se borra con su lock;
- lo que dejó una pestaña terminada se borra en la visita siguiente;
- ninguna petición sale del origen, y la página no se desborda a 375 px de ancho.
Revisión adversarial, en cuatro dimensiones (protocolo, seguridad, estado de la interfaz, tests y guardas), con cada hallazgo contrastado por un revisor que intentaba refutarlo: se confirmaron 15 hallazgos, algunos repetidos, y se refutaron 5. Todos se corrigieron:
- la fecha se vuelve a mirar cuando llega y cuando la pestaña vuelve a verse;
- el fichero de una apertura en curso es de la página: se borra con ella, y la apertura se detiene si se carga otra cápsula (`cancellable`);
- se vuelve a comprobar tras cada espera si la apertura sigue vigente;
- si el navegador rechaza OPFS, la apertura se hace en memoria;
- el nombre ofrecido para descargar no lleva caracteres invisibles;
- las glosas de `ERR_INVALID_MAGIC` y `ERR_EXTENSION_CRITICAL_UNKNOWN` valen también para la `.dkk`;
- el foco va al campo o al mensaje del error;
- tras volver de la caché de atrás y adelante no se ofrece un enlace muerto;
- un nombre largo no desborda la página;
- `licenses.txt` incluye el código de Vite y rolldown que entra en el bundle;
- la guarda de la primera carga resuelve los scripts respecto a cada página y falla si no encuentra ninguno | + +Cada paso termina con `npm run verify` en verde y un commit en Gitea. El paso 6 puede ir en paralelo con el 4 y el 5. + +--- + +## 11. Riesgos + +- **API de `age-encryption` 0.3.1** para `Identity`, `Recipient` y `Stanza`. Mitigación: comprobarla en el paquete instalado antes del paso 5 y fijar la versión exacta. +- **Cambios de API de noble 2.x.** Ya ocurrió entre 1.x y 2.x. Mitigación: versión exacta, guardas y el test del vector de GT, que detecta cualquier cambio de orden o de `Fp.toBytes`. +- **Orden de bytes de GT.** Es el único punto donde una implementación puede coincidir en todo lo demás y fallar aquí. Mitigación: el vector de tlock-js y el control negativo. +- **Doble noble en el bundle.** `@noble/post-quantum` 0.5.4 ya trae su propia copia 2.0.x; si alguna dependencia futura arrastra 1.x, sería peor. Mitigación: las guardas de la sección 3 (ningún 1.x, copias 2.x distintas solo anidadas bajo post-quantum, el código BLS en la 2.4.0 exacta) y la medida del bundle en el paso 2. +- **Diagnósticos con material interno.** Mitigación: la regla de errores de la sección 4 y un test que busca en los mensajes de error los bytes de `sigma`, `msg` y `r`. +- **`H3` devuelve 0**, con probabilidad 2⁻²⁵⁵: `multiply(0)` lanza `RangeError`. Mitigación: tratar cualquier excepción del cálculo como `proof` y cubrirlo con un test que inyecte `r = 0`. +- **Rendimiento en el navegador.** Un pairing y una multiplicación escalar en G2 rondan los 40 a 300 ms en Node; la puerta añade 11 ms por punto. Mitigación: medir en el paso 8 y, si hace falta, descifrar en un worker. Medido en el paso 8, en Chromium, sin contar la descarga del código: 0,34 s los pasos 9 a 18 de `time_only`, la primera apertura, y 0,11 s los de `time_and_key_portable`. No hace falta un worker, que la CSP (`worker-src 'none'`) tampoco permite. +- **Rama `wip/align-3820066`.** Si no se fusiona antes, el paso 3 partiría de un árbol distinto del que verifican los tests actuales. Mitigación: el paso 1. +- **Aprobación de la spec.** El paso 6 depende del autor; los pasos 3 a 5 no. + +--- + +## 12. Fuera de alcance y decisiones aplazadas + +- Writer completo de `.dkc` y `.dkk` en TypeScript y la prueba TS → Go a nivel de cápsula: fase 3. +- Los schemes `pedersen-bls-unchained` y `bls-unchained-on-g1`: sin uso previsto; si se añaden, el módulo crece con `encryptOnG1` y `decryptOnG1` y la puerta cambia de grupo. +- Obtención de releases por red desde el SDK: fuera de esta fase; el servidor y la CLI Go siguen siendo la vía. Es lo que cubrirá los SHOULD de §48 (varios relays) y §49 (release directo del proveedor): una fuente drand opcional del SDK, que verifica cada respuesta y descarta las que fallan (corrección 6 de §76), nunca activa por defecto en la página. +- Reutilización de la implementación `age` de tlock-js: descartada; `age-encryption` es la implementación oficial. diff --git a/PLAN_fase3_escritura.md b/PLAN_fase3_escritura.md new file mode 100644 index 0000000..df292f8 --- /dev/null +++ b/PLAN_fase3_escritura.md @@ -0,0 +1,579 @@ +# 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 + +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. + +> **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`. + +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ó después el borrador con el spec, con Go y con el código instalado. Sus 26 correcciones están incorporadas. + +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: +- 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`; +- 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. + +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). + +--- + +## 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. + +| Hecho | Detalle | +|---|---| +| Encoders TypeScript que ya existen | `encodeHeader` (header.ts:151) y `encodeControl` (control.ts:117).
`marshalAccessKeyBody` y `encodeAccessKey` (accesskey.ts:231, 273); `marshalAccessKeyBody` ya se autocomprueba.
`preludeBytes`, `headerBinding` y `dkkPreludeBytes` (framing.ts:62, 79, 150).
`newExtension`, `canonicalExtensions` y `checkDisjoint` (extension.ts).
`resolveDateKey` y `roundTime` (datekey.ts:507, 485).
`timeRecipient` (tlock.ts:21).
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`.
Generar identidades X25519 en bytes crudos y derivar su recipient.
Leer y escribir `age1…`.
Un SHA-256 que calcule sobre lo que se escribe: `sha256Stream` (digest.ts:8) consume el stream.
Un comparador de `Instant` exportado: `before` es privado en open.ts:114.
Las comprobaciones que `Encrypter` no hace. | +| `capsule.Encrypt` de Go | Orden (encrypt.go:72-238):
1. opciones y reloj; paso 1;
2. recipients de acceso, con `R_ACCESS` al final;
3. `capsule_id`, luego `I_PAYLOAD`;
4. PUBLIC_HEADER y su autocomprobación;
5. sellado de un borrador de CONTROL_CBOR, con binding e identidad a cero, para medir `SEALED_CONTROL_LEN` (160-178);
6. PRELUDE, `header_binding`, y CONTROL_CBOR con su autocomprobación;
7. sellado real, con la misma longitud exigida (196-202);
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;
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`.
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.
No tiene labels, así que nada impide mezclar el recipient tlock con otros.
`addRecipient(string)` también acepta `age1pq1…`, `age1tag1…` y `age1tagpq1…`.
`X25519Recipient` no se exporta.
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).
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).
`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]`.
`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`.
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:
- `OUTER_TIME_AGE` = 335 + d + \|pt\| + 16·c;
- `INNER_ACCESS_AGE` = 86 + 98·n + \|pt\| + 16·c, con n recipients;
- `PAYLOAD_AGE` = 184 + \|pt\| + 16·c.
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.
`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.
§62: las file keys de las tres envolturas son independientes (MUST).
§15: la ronda se resuelve a la primera cuyo instante es igual o posterior al pedido, nunca hacia atrás (MUST).
§21, §37, §38 y §42: `capsule_id`, las identidades X25519 y `credential_id` salen de un generador criptográfico (MUST).
§57: límites que también obligan al encoder.
§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.
§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.
§53: el SDK oficial SHOULD advertir en horizontes largos; el umbral es política de producto.
§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`.
Las importaciones de `age-encryption` no tienen lista blanca.
`check-build.mjs` comprueba la carga bajo demanda solo para `inspect.html`.
`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.
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`.
`datekeys-go`: `main` en `3e4755e`, sin cambios en `testdata` desde `9ac9cd9`. | + +--- + +## 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. + +1. **Módulo y API, espejo de `capsule.Encrypt`.** + - `src/lib/dkc/encrypt.ts` exporta `encrypt(src, opts): Promise`, 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. + - 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`. 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. + - 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. + - 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. + +4. **Aleatoriedad, y determinismo en los tests.** + - 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); + - 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. + - 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`. + - 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.** + - `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. + - 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.** + - `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). + - `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`: + - 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. + - 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. + - Alternativa: exactamente las de Go. + - Recomendación: las de Go y las dos más, documentadas como diferencia con la referencia. + +9. **Errores: textos y códigos de Go donde hay equivalente, y diferencias listadas.** + - 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. + - 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).** + - 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. + - Recomendación: la propuesta. + +11. **Interoperabilidad TS → Go a nivel de cápsula, con el patrón de la fase 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`. + - 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.** + - 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. + - 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. + - `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. + - 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. + - Recomendación: la propuesta. + +--- + +## 3. Dependencias de ejecución y guardas + +| Paquete | Versión | Estado | +|---|---|---| +| `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`) | + +**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: +- **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 `` y `` 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. + +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). + +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. + +--- + +## 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. + +```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 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 noncritical?: readonly Extension[]; + readonly controlCritical?: readonly Extension[]; // CONTROL_CBOR + readonly controlNoncritical?: readonly Extension[]; + readonly now: () => Instant; // required, called once + readonly output?: WritableStream; + readonly progress?: (written: number, total: number | undefined) => void; +} +export interface Encrypted { + readonly dateKey: DateKey; + readonly unlockAt: Instant; // effective round time + readonly capsuleId: Uint8Array; + readonly portableKey?: AccessKey; // the caller encodes and wipes it + readonly size: number; + readonly dkc?: Uint8Array; // only without output +} +export function encrypt(src: Uint8Array | Blob | ReadableStream, opts: EncryptOptions): Promise; +``` + +**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. +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 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`. + +Si `age-encryption` falla durante un sellado, el error lleva un texto fijo y no tiene código normativo (decisión 9). + +--- + +## 5. Piezas de apoyo + +**`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; +- `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`. + +Ninguna de estas funciones produce la forma `AGE-SECRET-KEY-1…` de una identidad. + +**`digest.ts`.** `sha256Hasher()` devuelve `{ update(b), digest() }` sobre `sha256.create()`. `sha256Stream` pasa a usarlo. + +**`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. + +**`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. + +--- + +## 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. + +**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. +- 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. + +**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`. +- 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. +- 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. + +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): +- 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 `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. + +--- + +## 8. Tests + +1. **`recipient.test.ts`, `x25519.test.ts` y `digest.test.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. + - 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; + - 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. + + 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; + - 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. + + 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: + - 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; + - 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. + + 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`. +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. + - 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. +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. + - 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: + - dejan la salida abortada y nunca cerrada; + - relanzan el error de la fuente o de la salida sin tocar y sin código; + - 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. +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 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; + - 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; + - 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); + - 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. + +La cobertura de los módulos nuevos se fija en el 100 %. + +--- + +## 9. Página + +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. + 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. `` admite años hasta 275760. + 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; + - la casilla "generar una clave portable (.dkk)", marcada por defecto y obligatoria si no hay recipients. +- **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. + - 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. +- **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-.dkc` y `.dkk`, que ya es pública en la DateKey y no revela cuándo se creó; la persona puede editarlo. +- **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. + - 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. + - 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. +- **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: +- el nombre de la ruta; +- la política por defecto; +- el selector de zona, o solo la zona del dispositivo; +- la regla para horas ambiguas; +- 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 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. + +--- + +## 10. Orden de trabajo y criterios de aceptación + +| 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`).
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.
`parseX25519Recipient` y `checkX25519Recipient` rechazan los casos de la sección 8, punto 1.
El hasher da lo mismo que Web Crypto.
`open.ts` usa `compareInstants` sin cambiar ningún test.
La caché del pairing mantiene los vectores de la fase 2.
`tempfile.ts` parametrizado, con la apertura intacta.
Guardas nuevas en verde.
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.
Las secciones deterministas de los cinco fixtures salen byte a byte (sección 8, punto 2).
Ida y vuelta con `open` para las dos políticas y todas las credenciales.
Claves portables nunca repetidas; caso `now + 1 h`.
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.
Nada se escribe antes del paso 17 del flujo.
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.
`capsule_digest` es el SHA-256 de lo escrito.
El bucle de propiedades pasa con 50 semillas, y con 500 a mano.
Las mezclas dan el código y el paso esperados.
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.
Go recodifica PUBLIC_HEADER, CONTROL_CBOR y `.dkk` a los mismos bytes.
El diferencial de encoders no da ninguna diferencia.
Las mezclas dan el mismo código y paso en Go y en TypeScript.
Los veredictos de recipients y los textos de opciones coinciden, salvo las diferencias de las decisiones 6 y 9.
Todo congelado en `capsule-vectors.json`.
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.
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`.
El aviso de §53 aparece antes de cifrar, solo pasado el umbral.
Cancelar a mitad no deja fichero temporal.
Sin OPFS, la escritura va a memoria.
375 px de ancho.
`check-build` generalizado, en verde.
Tamaño del bundle de la página anotado.
Módulos nuevos al 100 %.
Revisión adversarial con cada hallazgo contrastado, como en la fase 2 | + +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. + +--- + +## 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. +- **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. +- **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. +- **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. +- **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. +- 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). +- 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). +- 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. diff --git a/PLAN_libreria_go.md b/PLAN_libreria_go.md new file mode 100644 index 0000000..4bc4312 --- /dev/null +++ b/PLAN_libreria_go.md @@ -0,0 +1,276 @@ +# Plan de implementación: `datekeys-go` + +Implementación de referencia en Go de la **DateKeys Protocol Specification v0.8.1**. + +Estado: borrador para revisión. Fecha: 25 de septiembre de 2026. + +--- + +## 1. Objetivo y principios + +Construir desde cero una librería Go que implemente el protocolo base tal como lo fija la v0.8.1, con estas reglas: + +1. **La especificación manda.** El código no añade semántica. Si el código descubre un problema en el spec, se abre un caso reproducible según la política de cambios de la sección 76. +2. **Cero criptografía propia.** Solo `age`, `tlock` y la verificación BLS de `drand`. La librería aporta framing, CBOR, bindings, reglas de verificación y flujo. +3. **Validar todo lo verificable localmente antes de tocar red o secretos.** Sección 63. +4. **Fallar cerrado.** Ningún plaintext parcial, ningún error enmascarado, ningún "verified: true" ajeno. +5. **Superficie pequeña y auditable.** Pocas dependencias, todas pinneadas, builds reproducibles, y un mapa de trazabilidad spec ↔ código para la revisión externa. +6. **Nueva librería, no refactor.** Lo aprovechable del prototipo se copia y se adapta; el repositorio actual no se modifica. + +--- + +## 2. Alcance de la primera versión de la librería (v0.1.0) + +Implementa: + +- DateKey: resolución fecha → ronda, `dk1_` canónico, vectores (secciones 14 a 19). +- Provider Profile Quicknet: struct, CBOR canónico, `profile_hash`, registro pinneado (secciones 10 a 13). +- Adaptador drand: obtención de releases por relays, verificación BLS local, modo estricto (secciones 35, 45 a 52). +- `.dkc`: prelude, PUBLIC_HEADER, CONTROL_CBOR, `header_binding`, `time_only` y `time_and_key`, flujo de cifrado y descifrado completo con SHOULD y MUST (secciones 20 a 39, 61 a 63). +- `.dkk`: framing, body, identity portable de un solo uso (secciones 40 a 44). +- Extensiones genéricas con reglas de duplicados y orden canónico (sección 54). +- Catálogo de errores (sección 69). Límites de parser (57). Canonicidad CBOR y omisión de opcionales (58, 58.1). +- Fixtures, mutaciones, fuzzing y CLI mínima. + +No implementa, a propósito: + +- Servidor de Release API ni Release Queue. La librería define la interfaz de fuente de releases; el servidor actual podrá consumirla más adelante. +- Almacenamiento, entrega de `.dkk`, servicios, extensiones concretas. +- Cliente TypeScript. Vendrá después con la misma suite de fixtures. + +--- + +## 3. Decisiones de diseño + +### 3.1 Módulo y layout + +Repositorio: Gitea propio, `https://g.activething.com/go/DateKeys.git`. Módulo: `g.activething.com/go/DateKeys`, la ruta que el propio Gitea anuncia en su etiqueta `go-import`. Si más adelante se quiere una ruta en `datekeys.com` que no dependa del servidor, es un cambio de una línea en `go.mod` y un `sed` en las importaciones, siempre antes de publicar. Go 1.26, con `go.mod` fijando la última versión de parche de la serie. No se usa GitHub para nada del proyecto; las dependencias alojadas allí se obtienen como módulos Go, igual que cualquier otra. + +```text +datekeys-go/ + go.mod + LICENSE Apache-2.0 (código) + README.md SECURITY.md CONTRIBUTING.md TRADEMARKS.md CHANGELOG.md + spec/ + DateKeys_Protocol_Specification_v0.8.1.md copia congelada + datekeys.cddl schema normativo de todos los CBOR + datekey/ DateKey, resolución, dk1_ canónico + profile/ Provider Profile, CBOR canónico, hash, registro pinneado, quicknet.go + provider/ interfaces Condition, Release, ReleaseSource, Verify + provider/drand/ relays HTTP, verificación BLS, modo estricto + codec/ CBOR determinista: Encode, Decode con comprobación por reencodificación, límites + codec/bech32/ codificación Bech32 para la forma humana de identities (ver 4.3) + agewrap/ identities y recipients envolventes de age: cardinalidad, sonda de inspección, stanza tlock + extension/ tipos y reglas de extensiones + capsule/ .dkc: framing, cabecera, control, binding, Encrypt, Inspect, Open + accesskey/ .dkk: framing, body, identity + errors.go sentinel errors del catálogo de la sección 69 + internal/testkit/ generación de fixtures, escritor de cabeceras age malformadas, mutadores + testdata/fixtures/ fixtures oficiales y sus valores esperados + docs/traceability.md mapa sección del spec → paquete, función, test + cmd/datekeys/ CLI: encrypt, decrypt, inspect, datekey, profile +``` + +### 3.2 API pública, deliberadamente pequeña + +```go +// datekey +type DateKey struct { ProfileID string; Round uint64 } +func Resolve(p *profile.Profile, at time.Time) (DateKey, error) // §15, precisión completa +func Parse(s string) (DateKey, error) // §19, solo forma canónica +func (d DateKey) Compact() string // dk1_... +func (d DateKey) UnlockAt(p *profile.Profile) time.Time + +// profile +type Profile struct { ID, Provider, Network string; ChainHash [32]byte; PublicKey []byte; + Period time.Duration; GenesisTime int64; Scheme string; GenesisSeed [32]byte } +func (p *Profile) CanonicalCBOR() []byte +func (p *Profile) Hash() [32]byte // §11 +var Quicknet *Profile // §12, pinneado en el binario +type Registry interface { Lookup(id string) (*Profile, bool) } + +// provider +type Condition struct { Round uint64 } +type Release struct { Round uint64; Signature []byte } +type ReleaseSource interface { Fetch(ctx context.Context, p *profile.Profile, c Condition) (Release, error) } +func Verify(p *profile.Profile, c Condition, r Release) error // §51, BLS local siempre + +// capsule +type Policy uint8 // TimeOnly = 0, TimeAndKey = 1 +type EncryptOptions struct { + Profile *profile.Profile + UnlockAt time.Time + Policy Policy + Recipients []age.Recipient // X25519 de destinatarios conocidos + NewPortableKey bool // genera I_ACCESS y devuelve la .dkk + Critical, Noncritical []extension.Extension +} +type Result struct { DateKey datekey.DateKey; CapsuleID [16]byte; PortableKey *accesskey.AccessKey } +func Encrypt(dst io.Writer, src io.Reader, opts EncryptOptions) (*Result, error) + +type Inspection struct { /* prelude, cabecera, política, ronda, unlock, resultado de cada comprobación */ } +func Inspect(r io.ReadSeeker, reg profile.Registry) (*Inspection, error) // pasos 1 a 8, sin red ni secretos + +type OpenOptions struct { + Registry profile.Registry + Source provider.ReleaseSource + Identities []age.Identity // X25519 propias del destinatario + AccessKey *accesskey.AccessKey // .dkk portable + Now func() time.Time // inyectable para tests +} +func Open(dst io.Writer, r io.ReadSeeker, opts OpenOptions) error // pasos 9 a 18 + +// accesskey +type AccessKey struct { CredentialID, CapsuleID [16]byte; Type string; Material []byte; + Verification *Verification; Critical, Noncritical []extension.Extension } +func Encode(w io.Writer, k *AccessKey) error +func Decode(r io.Reader) (*AccessKey, error) +func (k *AccessKey) Identity() (age.Identity, error) +``` + +Reglas de la API: + +- `io.Reader` y `io.Writer` en todo; el payload nunca se carga entero en memoria. +- `Open` recibe `io.ReadSeeker` para poder inspeccionar la cabecera de PAYLOAD_AGE en el offset `16 + PUBLIC_HEADER_LEN + SEALED_CONTROL_LEN` antes de pedir el release. Si el lector no permite seek, se omite el SHOULD y se aplica el MUST al abrir. +- El reloj se inyecta. Ningún paquete llama a `time.Now` directamente salvo la CLI. +- Ningún tipo imprime secretos en `String()`. Las identities se pasan como tipos de `age`, no como bytes sueltos, salvo en `accesskey`. + +### 3.3 Cómo se usan `age` y `tlock` + +- **PAYLOAD_AGE e INNER_ACCESS_AGE:** `age.Encrypt` y `age.Decrypt` con recipients e identities X25519 de la API pública de `age`. +- **OUTER_TIME_AGE:** `age.Encrypt` con un recipient propio en `agewrap` que llama a `tlock.TimeLock` y `tlock.CiphertextToBytes`, ambos exportados, y emite el stanza `tlock ` con el mismo formato que `tlock`. Para abrir, una identity propia que comprueba el conjunto completo de stanzas, la ronda y el chain hash contra la DateKey y el perfil, y llama a `tlock.TimeUnlock`, que verifica el beacon antes de descifrar. + - Motivo: `tlock.New(...).Decrypt` usa una identity no exportada en la que no podemos imponer la cardinalidad ni evitar que todo error se convierta en `ErrTooEarly`. Usando su núcleo exportado se mantiene la compatibilidad de stanza con `tle` y se gana control del flujo. +- **Cardinalidad MUST:** en las tres identities envolventes, dentro de `Unwrap`, que recibe todos los stanzas del fichero. Sin APIs internas. +- **Inspección SHOULD:** `age.ExtractHeader` lee únicamente la cabecera del fichero, y `age.DecryptHeader` con una identity sonda, cuyo `Unwrap` registra los stanzas recibidos y devuelve un error centinela distinto de `ErrIncorrectIdentity`, entrega los stanzas parseados por `age` sin descifrar nada ni usar secretos. Ambas funciones son API pública de `age` 1.3. Para PAYLOAD_AGE se aplica en el offset del prelude. No hace falta parser propio. + +--- + +## 4. Dependencias + +| Dependencia | Versión | Uso | Nota | +|---|---|---|---| +| `filippo.io/age` | v1.3.2 | ficheros age, X25519, `Stanza`, `Recipient`, `Identity` | única implementación de STREAM; no se reimplementa | +| `github.com/drand/tlock` | v1.2.0 | `TimeLock`, `TimeUnlock`, `CiphertextToBytes`, `BytesToCiphertext` | no se usa `tlock.New`, `Encrypt` ni `Decrypt` | +| `github.com/drand/drand/v2` | v2.1.7 | `crypto.Scheme`, `VerifyBeacon`, `chain.Info` | arrastra gRPC y otros; `govulncheck` ya confirmó que no son alcanzables | +| `github.com/drand/kyber` y `kyber-bls12381` | transitivas | pairing BLS12-381 | sobre `kilic/bls12-381`, archivado; vigilar y documentar en SECURITY.md | +| `github.com/fxamacker/cbor/v2` | v2.9.4 | CBOR determinista | `CoreDetEncOptions` para codificar; límites en decodificación | + +Sin otras dependencias en la librería. Tests con la biblioteca estándar. CLI con `flag`. HTTP con `net/http`. + +### 4.1 Política de versiones + +`go.mod` pinneado, `go.sum` verificado, `go mod verify` en CI, `govulncheck` en cada PR, Renovate (compatible con Gitea) solo para parches, con revisión manual de cualquier cambio en `age`, `tlock`, `drand` o `kyber`. + +### 4.2 Canonicidad CBOR + +Codificar con Core Deterministic Encoding. Al decodificar, además de los límites de `fxamacker` (sin longitudes indefinidas, sin tags, sin claves duplicadas, profundidad y tamaños acotados), se **reencodifica y se compara byte a byte** con la entrada. Cualquier diferencia es `ErrNonCanonicalCBOR`. Es el mismo principio que `dk1_` y no depende de que una librería prometa rechazar todas las formas no canónicas. + +### 4.3 Bech32 + +`age` solo acepta identities y recipients X25519 en Bech32 por su API pública, y su paquete Bech32 es `internal`. El `.dkk` guarda 32 bytes crudos por spec. Se necesita un codificador Bech32 de unas cien líneas para pasar de bytes a `AGE-SECRET-KEY-1...` y de `age1...` a bytes. Es codificación, no criptografía. Opciones: copiar el paquete interno de `age` con su licencia BSD y atribución, o reimplementar BIP-173 con vectores de prueba. Propuesta: copiar, para no divergir. + +--- + +## 5. Qué se copia del prototipo + +| Origen | Destino | Cambios | +|---|---|---| +| `pkg/datekeys/key.go`: `RoundForTime`, `TimeForRound`, `ParseDateKey`, `FromRound` | `datekey/` | quitar campos de API y el alias `EncryptionKey`; el struct de respuesta HTTP se queda en el servidor | +| `pkg/datekeys/key_test.go`, `FuzzRecipient` | `datekey/` | renombrar, ampliar con vectores de la sección 65 | +| `internal/quicknet/trust.go` | `profile/quicknet.go` | añadir CBOR canónico y hash; conservar la autocomprobación de chain hash | +| `internal/drand/client.go` y tests | `provider/drand/` | carrera entre relays, respuestas malformadas, cancelación; adaptar a `ReleaseSource` | +| `pkg/datekeys/crypto.go` | no se copia | sustituido por `agewrap` sobre el núcleo exportado de `tlock`; se conserva la doble verificación BLS | +| `tests/integration/quicknet_test.go` | `provider/drand/`, build tag `integration` | prueba en vivo contra Quicknet | +| `cmd/datekeys` | reescrito | misma ergonomía, nueva API | +| `internal/api`, `web-client` | no se tocan | consumirán la librería más adelante | + +--- + +## 6. Estilo de código + +- Identificadores, comentarios de código, mensajes de error y commits en **inglés**. Documentación de usuario en inglés con versión en español. Motivo: la revisión externa y la comunidad de `age` y drand. +- `gofmt`, `goimports`, `go vet`, `staticcheck`, `golangci-lint` con configuración corta: `errcheck`, `govet`, `staticcheck`, `ineffassign`, `unparam`, `gosec` en modo aviso. +- Errores: un sentinel por entrada del catálogo de la sección 69, envueltos con `%w` y contexto; nunca se sustituye un error por otro más cómodo. Los tests de mutación comprueban `errors.Is`. +- Parsers: límites antes de reservar memoria, `io.LimitReader`, ningún `panic` alcanzable desde la entrada. Todo parser tiene un objetivo de fuzzing. +- Sin estado global mutable. El registro de perfiles se pasa explícitamente; el registro por defecto contiene solo Quicknet. +- Comentarios de paquete y de función citan la sección del spec que implementan, por ejemplo `// Spec §26`. +- Secretos: no se registran en logs, no aparecen en `String()`, se borran de buffers propios al terminar, sin prometer más de lo que Go garantiza. +- Salida atómica en la CLI: fichero temporal en el mismo directorio y renombrado al final; nunca sobrescribir destinos existentes. +- Versionado semántico. `v0.x` hasta que el spec sea v1.0. Sin promesa de estabilidad de API antes de `v1.0.0`. + +--- + +## 7. Estrategia de tests + +1. **Unitarios** por paquete, tabla-driven, biblioteca estándar. +2. **Vectores de spec**, generados por el código y guardados como golden files versionados: ronda (frontera, un segundo antes y después, fracción de segundo tras frontera, 2030-01-01, cercanos al genesis), `dk1_` (objeto, JSON canónico, base64url, cadena), `profile_hash` de Quicknet. +3. **Fixtures `.dkc` y `.dkk`** (secciones 67 y 68): generados una vez con `internal/testkit` sobre una **ronda ya publicada**, con la firma BLS embebida en el fixture. Los tests verifican la firma contra la clave pinneada y descifran **sin red**. Se comparan todos los valores intermedios: cabecera, prelude, binding, control, `I_PAYLOAD`, plaintext y el error esperado en cada etapa. +4. **Corpus de mutaciones** (sección 64): las veinte mutaciones como funciones sobre un fixture válido; cada una afirma su sentinel error concreto. +5. **Escritor de cabeceras age malformadas** en `testkit`: añade stanzas o cambia tipos manteniendo el MAC válido, porque en el test se conoce la file key. Demuestra que la comprobación estructural rechaza lo que el MAC aceptaría. Es la prueba directa de la amenaza del creador. Implementación: según la especificación C2SP, el MAC de cabecera es HMAC-SHA-256 con clave HKDF-SHA-256 de la file key e info `header`, sobre el texto de la cabecera; `testkit` lo recalcula con `golang.org/x/crypto/hkdf`, ya presente en el grafo por `age`. Para abrir ficheros de test con una file key conocida se usa `age.NewInjectedFileKeyIdentity`, también API pública. +6. **Propiedades:** `Encode(Decode(x)) == x` para todo fixture; toda reordenación de claves o ensanchamiento de enteros se rechaza como no canónico; `Parse(Compact(d)) == d`. +7. **Fuzzing** nativo de Go: prelude, PUBLIC_HEADER, CONTROL_CBOR, `.dkk`, `dk1_`, cabecera age vía sonda. Corpus sembrado con fixtures y mutaciones. Presupuesto corto en cada PR, largo en nightly. +8. **Integración en vivo** con build tag: cifrar hacia ahora más treinta segundos, esperar, abrir por relays reales. Nightly, no en cada PR. +9. **Interoperabilidad con herramientas ajenas:** extraer SEALED_CONTROL de un fixture `time_only` y abrirlo con la CLI `tle` oficial; extraer PAYLOAD_AGE y abrirlo con la CLI `age` usando `I_PAYLOAD`. Si ambas pasan, la afirmación "son ficheros age estándar" queda demostrada por terceros. +10. **Cobertura:** mínimo 90 % en `codec`, `capsule`, `accesskey`, `datekey`, `agewrap`; `-race` siempre. +11. **CDDL:** `spec/datekeys.cddl` valida los fixtures en un job opcional de CI con la herramienta `cddl`; si no se quiere otra toolchain, se mantiene como documento normativo y se comprueba a mano en cada cambio de schema. + +--- + +## 8. CI, releases y cadena de suministro + +- Gitea Actions, con la misma sintaxis que GitHub Actions: `test` en Linux y Windows con la versión estable de Go y la anterior, macOS cuando haya runner; `lint`; `vuln` con `govulncheck`; `fuzz-short`; `integration` nightly por cron; `sbom` con `cyclonedx-gomod`. +- Releases con `goreleaser`, que publica en Gitea: `-trimpath`, `CGO_ENABLED=0`, checksums publicados, firma con `cosign` con clave propia, ya que la firma sin clave depende de un proveedor OIDC que Gitea no ofrece. +- `SECURITY.md` con canal de reporte, versiones soportadas y la dependencia de `kilic/bls12-381` declarada como riesgo conocido y vigilado. +- `TRADEMARKS.md`: "DateKeys" reservado; "implements the DateKey protocol" permitido. + +--- + +## 9. Hitos y criterios de aceptación + +**M0. Cimientos.** Esqueleto, `go.mod` pinneado, CI con lint y `govulncheck`, `codec` con canonicidad por reencodificación y límites, `spec/datekeys.cddl` inicial, `profile` con Quicknet, su CBOR y su hash como primer vector oficial. +Hecho cuando: el hash del perfil está congelado en un golden file y CI está verde en tres sistemas. + +**M1. Objetos.** `datekey` copiado y ampliado; framing DKC1 y DKK1; codecs de PUBLIC_HEADER, CONTROL_CBOR y `.dkk`; `header_binding`; `extension`; catálogo de errores; fuzzing de todos los parsers. +Hecho cuando: los vectores de ronda y `dk1_` están generados y versionados y el fuzzing lleva una noche sin fallos. + +**M2. `time_only`.** `agewrap` con recipient e identity tlock estrictos, sonda de inspección, PAYLOAD_AGE, `Encrypt`, `Inspect`, `Open` con los 18 pasos; `provider/drand` copiado. +Hecho cuando: un fixture `time_only` sobre ronda pasada se abre offline, la prueba con `tle` y `age` externas pasa, y las mutaciones aplicables fallan con su error exacto. + +**M3. `time_and_key`.** INNER_ACCESS_AGE con varios recipients, `.dkk` portable de un solo uso, `Identities` y `AccessKey` en `Open`. +Hecho cuando: fixtures `time_and_key` con uno y varios recipients se abren offline, la reutilización de `I_ACCESS` está prohibida en `Encrypt` y probada, y las veinte mutaciones están cubiertas. + +**M4. Conformidad.** Suite de fixtures oficial, golden files, `docs/traceability.md` completo, integración nightly, SBOM y release firmada `v0.1.0`. +Hecho cuando: cada sección normativa de la 14 a la 69 apunta a al menos un test, y `v0.1.0` se construye de forma reproducible. + +**M5. CLI.** `datekeys encrypt`, `decrypt`, `inspect`, `datekey resolve`, `profile hash`. `inspect` ejecuta solo los pasos 1 a 8 y nunca pide un release ni usa secretos. +Hecho cuando: la CLI abre los fixtures y reproduce la ergonomía del prototipo con salida atómica. + +Fuera de estos hitos: SDK TypeScript, migración del cliente web, servidor de Release API sobre la librería, segundo perfil `evmnet`. + +--- + +## 10. Trazabilidad + +`docs/traceability.md` mantiene una tabla sección → paquete → función → test. Se actualiza en el mismo PR que cualquier cambio de código normativo. Es el documento que se entrega al revisor externo junto con el spec, los fixtures y el corpus de mutaciones. + +--- + +## 11. Decisiones a revisar + +1. Decidida: ruta `g.activething.com/go/DateKeys`. Queda abierto si se pasa a una ruta en `datekeys.com` antes de publicar. Nota: el servidor ejecuta Gitea 1.18.3, sin Actions; los workflows de `.gitea/workflows` quedan listos para Gitea 1.21 o superior con un `act_runner`, y hasta entonces `scripts/check.sh` es el control obligatorio antes de cada push. +2. Inglés para código, errores y commits. +3. Licencia Apache-2.0 para el código, CC-BY-4.0 para el spec. +4. Copiar el Bech32 interno de `age` frente a reimplementarlo. +5. Depender del módulo `drand/v2` completo o extraer solo la verificación BLS a un paquete propio sobre `kyber-bls12381`. La primera opción es más simple; la segunda reduce el grafo y el ruido de `govulncheck`. +6. Incluir en la librería un cliente de la Release API de DateKeys además del de relays drand, o dejarlo para el servidor. +7. Validación CDDL en CI con toolchain externa, o solo como documento. +8. Si `Open` exige `io.ReadSeeker` o acepta `io.Reader` y degrada el SHOULD. + +--- + +## 12. Riesgos conocidos + +- `kilic/bls12-381` archivado bajo `drand` y `tlock`. Mitigación: pinneado, vigilancia, plan de fork o de migración documentado en `SECURITY.md`. +- `tlock` sin etiqueta desde agosto de 2024. Mitigación: pinneado por versión; solo se usa su núcleo exportado. +- Fixtures sobre rondas pasadas dependen de que la firma embebida sea auténtica. Mitigación: se verifica BLS en cada ejecución contra la clave pinneada. +- Desviación spec ↔ código. Mitigación: trazabilidad, política de cambios de la sección 76, y una segunda implementación en TypeScript como prueba de interoperabilidad. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a3de63 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# DateKeys: documentación del proyecto + +Estado, planes, revisiones y reglas de trabajo del proyecto. Es un repositorio privado y no se publica: lo público vive en cada repo de código. + +Para retomar el trabajo, lee [HANDOFF.md](HANDOFF.md): el estado de cada repo, lo que queda en orden y las decisiones del autor. + +## Espacio de trabajo + +`G:\bussines\datekeys` es la carpeta de trabajo, no un repositorio. Cada repo es independiente y lleva sus propios commits; no hay repo padre. Las sesiones de Claude se abren en esta carpeta, que es la que tiene la memoria del proyecto y el `CLAUDE.md`. + +| Carpeta | Qué es | Remoto | +|---|---|---| +| `datekeys-go/` | Especificación del protocolo (`spec/`), CDDL, testdata compartido e implementación de referencia en Go: librería y CLI | `go/DateKeys` | +| `datekeys-ts/` | Implementación en TypeScript y página `/inspect` | `go/DateKeys-App` | +| `web/` | Landing de datekeys.com, con `api/enquiry.php` | ninguno | +| `docs/` | Este repositorio | ninguno | +| `brand/` | Logos | no es un repo | +| `archive/prototype/` | Prototipo anterior (API Quicknet en Go, CLI tlock y cliente Svelte), commit `4d2b0a1`. No se toca | ninguno | +| `archive/spec-drafts/` | Borradores v0.1 a v0.8.1 de la especificación, anteriores a su entrada en git | no es un repo | + +- Los remotos están en el Gitea privado `g.activething.com`. Sus nombres no coinciden con los de las carpetas y no se cambian. El servidor no es del autor: no se propone ningún cambio en él. +- La integración continua es el gate local de cada repo: `scripts/check.sh` en `datekeys-go` y `npm run verify` en `datekeys-ts`. +- La especificación está en `datekeys-go` porque cada cambio normativo va en el mismo commit que su caso reproducible en Go y sus fixtures. Pasará a un repo propio al publicarla (traducción, revisión externa, espejo público). +- La aplicación de producto, cuando exista, tendrá su propia carpeta. + +## Reglas + +- **Dependencias:** en ejecución, solo `age`, `drand`, `tlock` y lo que ellas arrastran. El tooling de desarrollo sale de la lista del README de cada repo. Nada nuevo sin aprobación escrita. Al pedirla, decir si es un paquete nuevo o uno que ya va en el bundle, y ofrecer la alternativa de código propio. Nunca GitHub como servicio. +- **Spec:** en español, estilo RFC 2119. Todo cambio normativo se registra en §76 con su caso reproducible, y se actualiza el SHA-256 de `spec/README.md`. Una versión cerrada con tag no cambia: el cambio siguiente abre una versión nueva. +- **Idioma:** la spec y los documentos de este repo, en español. Código, comentarios y mensajes de commit, en inglés. +- **Commits** con `Co-Authored-By: Claude Opus 5.5 `. +- **Testdata:** `datekeys-ts/testdata` solo se actualiza desde `datekeys-go`, con `scripts/sync-testdata.mjs`, y en `datekeys-ts` nunca se generan fixtures. Una guarda falla si aparece un fichero de `testdata` que ningún test ejecuta. +- **Textos de error:** si cambia un texto de error de Go en los pasos 1 a 8, el TypeScript lo sigue. Hoy coinciden byte a byte. +- **Precedencia de errores (§69.1):** trama; tipo y versión; perfil CBOR y CDDL; campos con código propio, en orden de clave. Entre objetos decide el orden de pasos de §63. +- **Subagentes** de Claude con `model: "opus"`. + +## Documentos + +| Documento | Contenido | +|---|---| +| [HANDOFF.md](HANDOFF.md) | Estado actual y trabajo pendiente | +| [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 | +| [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 | diff --git a/REVISION_completitud_protocolo.md b/REVISION_completitud_protocolo.md new file mode 100644 index 0000000..f322d45 --- /dev/null +++ b/REVISION_completitud_protocolo.md @@ -0,0 +1,202 @@ +# Revisión de completitud del protocolo DateKeys v0.8.2 + +29 de septiembre de 2026. Revisión interna asistida por IA, no una revisión externa. La hicieron cuatro revisores automáticos con enfoques distintos (criptografía y seguridad, longevidad, casos de uso, madurez del spec), y un verificador contrastó cada hueco con el texto del spec antes de esta síntesis. Los números de línea se refieren a `spec/DateKeys_Protocol_Specification_v0.8.2.md` de `datekeys-go`. + +Lo que ya recoge el borrador v0.9 (rama `v0.9` de `datekeys-go`): +- el versionado, con la promesa de compatibilidad (punto 1); +- las reglas del escritor (punto 2); +- la privacidad, con los 16 huecos fijos y el relleno del contenido (punto 7b). + +El resto queda como trabajo futuro, en §74 del borrador. + +--- + +## 1. Veredicto + +No, no lo resuelve todo, y el propio texto tampoco lo pretende. El núcleo criptográfico es sólido para lo que promete: una fecha mínima de apertura que el cliente comprueba por sí mismo y, si se quiere, una llave encima. Antes de la v1.0 no hace falta criptografía nueva. Hace falta decidir qué se congela, reglas para quien escribe cápsulas, un modelo de amenaza del proveedor bien escrito, una revisión externa humana y dos piezas que casi todos los casos reales necesitan: la firma y el archivo de releases. + +## 2. Lo que hace especialmente bien + +1. **No confía en el servidor para nada que el cliente pueda comprobar.** §3 l.54: «Nunca confiar en el servidor cuando la misma propiedad puede verificarse criptográficamente en el cliente». Y lo cumple: + - el perfil va pinneado (§13 l.401); + - la ronda se calcula en local (§17); + - el release se verifica en local; + - la ronda se comprueba antes que la firma (§63 paso 10, l.1811-1823). + + Si la empresa desaparece, las cápsulas siguen abriéndose. §49 l.1352 dice que la API es «no una autoridad criptográfica obligatoria». Esa es la diferencia de fondo con SealedFor, donde la fecha la impone el servidor. + +2. **No inventa criptografía.** Son tres ficheros age v1 completos, y OUTER es un fichero tlock estándar. El MAC de cabecera, el STREAM y la detección de truncado vienen de age (§28 l.768-788, §30 l.863-878). §37 l.1115: «No se define un KEM propio». + +3. **time_and_key es un AND de verdad y está anidado en el orden correcto.** tlock va por fuera (§33 l.981-1031). Antes de la fecha nadie ve los stanzas de los recipients. Si drand cae, la capa X25519 sigue protegiendo frente a terceros. + +4. **Las vinculaciones están bien pensadas.** + - Hay tres file keys independientes (§28 l.788, §62 l.1735). + - I_PAYLOAD ata el payload al control (§30.1). + - header_binding hashea los bytes exactos del PRELUDE y de PUBLIC_HEADER, sin volver a serializarlos (§26). + - Cada intercambio posible tiene su test de mutación (§64). + - El MAC de age compromete la file key, así que el creador no puede enseñar plaintexts distintos a recipients distintos (§63 pasos 11 y 13). + - La cardinalidad de stanzas hace visible un escrow en OUTER y en PAYLOAD (§32 l.967, §29 l.849). En INNER, en cambio, un recipient extra es legítimo (§39). + +5. **La canonicidad llega a todas las capas:** + - CBOR que se vuelve a codificar y se compara (§58); + - dk1_ canónico (§19); + - una sola codificación por punto BLS12-381 (§12.2); + - el orden de GT en H2, con su vector (l.1918-1926). + + Esto reduce mucho las divergencias entre implementaciones, aunque no las elimina (puntos 2 y 5 de la sección 4). Varias de estas reglas salieron de fallos reales encontrados por la segunda implementación (§76). + +6. **El modelo de confianza es honesto y normativo.** §36.1 l.1093: «`time_only` **NO proporciona autenticidad del creador**». §55.1 l.1503 prohíbe con un MUST NOT presentar una sección como prueba de lo que no prueba. Además, todo lo que se puede comprobar en local se comprueba antes de cualquier petición de red (§27 l.758). La Release API pide por ronda, no por cápsula (§45). + +## 3. Lo que deja fuera a propósito, y por qué es razonable + +1. **Que Quicknet exista siempre y que su histórico se conserve (§5 l.97-98).** Nadie puede garantizar una red ajena. Esto excluye de paso cualquier vía de respaldo: OUTER lleva «exactamente un stanza, de tipo tlock» (§32 l.967). Es coherente, porque es lo que da sentido a la defensa estructural contra el escrow de §27 l.760. Lo que falta es escribir el coste (punto 3 de la sección 4). + +2. **Resistencia post-cuántica del timelock (§5 l.99, §7.7, §53).** Hoy no hay ningún beacon de umbral post-cuántico en producción. Un matiz: la exclusión solo cubre el timelock. El texto fija además X25519 en la capa de acceso (§33 l.1002), y eso ninguna exclusión lo obliga (punto 1). + +3. **Revocar copias, anonimato absoluto, dispositivo comprometido y control del plaintext tras abrir (§5 l.100-105, §7.8).** Es inherente a cualquier cifrado de ficheros. El anonimato está bien como no-objetivo, pero falta decir qué queda siempre visible (punto 7). + +4. **Autoría legal y fecha probatoria de creación (§5 l.102-103, §36.1, §55.1).** Un timelock dice cuándo se puede abrir, no quién selló ni cuándo. Está bien fuera del núcleo. + - La autoría depende de una extensión de firma que aún no existe (punto 8). + - La fecha de creación se puede cubrir con un sello de tiempo (RFC 3161 u OpenTimestamps) sobre capsule_digest, en un fichero aparte. + +5. **Almacenamiento, descubrimiento, distribución y entrega de la .dkk (§6 l.109-120).** Es una buena separación de capas. Por la misma razón, los umbrales N-de-M y el AND de varias llaves no deben ir al núcleo: se construyen anidando cápsulas o en la capa de credencial. Si algún día se hace Shamir para herencia, conviene revisar antes las patentes de SafeTech/Inheriti. + +## 4. Lo que abordaría antes de la v1.0, por orden de importancia + +### 1. Versionado: qué se congela y cómo evoluciona (núcleo) + +- **Problema:** no está decidido si un lector v1.0 abrirá las cápsulas v0.8.2, y los lectores v1 verán los objetos futuros como corruptos. +- **Por qué importa:** + - La fase 3 va a sellar cápsulas con fecha a años vista. + - §74 l.2306-2308 sigue dando por provisional el «schema CBOR final byte-a-byte» de los tres objetos. + - La v0.8.2 ya invalidó objetos v0.8.1 sin cambiar de versión (§76 l.2390). + - Hacia delante, un `access_policy` 2 da `ERR_NON_CANONICAL_CBOR` (§69.1 l.2116). + - §70 l.2139 habla de «major versions», un concepto que el formato no tiene. +- **Propuesta:** + - Congelar el formato en bytes de la v0.8.2: la v1.0 solo añade reglas que ningún objeto válido incumpla. Los fixtures y el corpus de mutaciones pasan a ser una suite permanente. + - Añadir a §70: «toda implementación posterior MUST abrir todo objeto DKC1/DKK1 versión 1 válido». + - Regla de evolución: una política, un tipo de acceso o un proveedor nuevos implican schema N+1 o prefijo `dk2_`. Así un lector v1 responde `ERR_UNSUPPORTED_VERSION`. + - Decidir ahora si entra el access_type `mlkem768x25519`. Lo trae age v1.3.2, que ya usan las dos implementaciones. En INNER, los stanzas serían todos X25519 o todos híbridos, nunca mezclados. Después de congelar, añadirlo exige versión nueva. + - Sustituir §74/§75 por una tabla de estado (elemento, estado, evidencia, criterio de salida). + - Quitar «prevista» de l.8. + +### 2. El lado del escritor (núcleo) + +- **Problema:** la especificación dice con precisión cómo leer, pero no cómo escribir, y un escritor defectuoso produce cápsulas que solo fallan en la fecha. +- **Por qué importa:** en un timelock, el peor fallo es una cápsula que parece sana durante veinte años. El caso 5 de §76 (l.2387) ya produjo una que se sellaba y no se abría. Hoy: + - El orden de §61 l.1674-1686 es circular. El PRELUDE se hashea en el paso 7, pero SEALED_CONTROL_LEN no existe hasta el paso 9. Go lo resuelve con un sellado borrador y una suposición que no está escrita. + - Ningún escritor descifra lo que escribe. + - Los límites «No son normativos» (§74 l.2315). Un escritor puede sellar 1.025 recipients y la referencia rechaza la cápsula tras la apertura. + - I_PAYLOAD no tiene ningún MUST de frescura ni de no reutilización; solo lo tiene I_ACCESS (§38 l.1132). Si se reutiliza, al madurar la cápsula A se puede abrir la B años antes. + - Nada exige que la ronda objetivo siga sin publicarse. Con el reloj atrasado se crea una cápsula que cualquiera puede abrir ya (App/docs/PLAN_fase3_escritura.md l.479). +- **Propuesta:** + - Una sección normativa de escritura con el orden correcto y la regla de predicción de longitud. + - Los límites como MUST para lectores y escritores. + - «I_PAYLOAD MUST generarse con un CSPRNG para cada cápsula y MUST NOT reutilizarse ni derivarse de otro secreto». Pasar §28 l.788 a MUST NOT. + - MUST rechazar un instante que no sea futuro. SHOULD tomar now = max(reloj local, round_time del último release verificado); un relay que mienta no puede empeorarlo. + - SHOULD autoverificarse antes de emitir: abrir INNER con cada identidad generada, abrir la cabecera de PAYLOAD con I_PAYLOAD y hacer una prueba tlock con respuesta conocida sobre una ronda ya publicada. + - SHOULD borrar I_PAYLOAD, FK_* e I_ACCESS tras sellar. + - Publicar vectores de escritura por capa. + +### 3. El riesgo del proveedor, bien escrito (núcleo en el texto y los estados de perfil; servicio para el re-sellado) + +- **Problema:** el mayor riesgo real ocupa una sola frase: «Si deja de cumplirse el supuesto de seguridad del provider, puede fallar la confidencialidad temporal» (§7.6 l.176). +- **Por qué importa:** + - Si t miembros de la League of Entropy se ponen de acuerdo, o se les obliga, o se filtra la clave de grupo, se abren todas las cápsulas pendientes a la vez. Nadie lo nota y no tiene vuelta atrás. + - El resharing conserva la clave de grupo, así que la exposición crece con el horizonte. + - Si la red se para antes de la ronda, la cápsula se pierde. drand programó el borrado de la clave de fastnet para el 6 de noviembre de 2024, y §12.1 l.338 todavía admite su scheme. + - El único aviso obligatorio de horizonte largo (§53 l.1406-1412) habla solo del riesgo post-cuántico, y lo describe como harvest-now-decrypt-later. Para time_only el riesgo cuántico es otro: abrir antes de tiempo, porque la clave maestra de drand se puede sacar de la clave pública pinneada. +- **Propuesta:** + - Reescribir §7.6: el supuesto t-de-n con fuente y fecha, qué pasa si se rompe y qué pasa si la red se para. + - Escribir en §5 y §73: «un .dkc V1 no contiene ninguna vía alternativa de apertura; si un perfil deja de operar antes de la ronda, solo puede re-sellar quien conserve el plaintext». + - Unir §50 y §53 en un único aviso normativo. El riesgo cuántico va en dos casos: time_only (apertura anticipada) y time_and_key con recipients age1 públicos (descifrado posterior). + - Recomendar time_and_key para cápsulas largas o valiosas, porque sigue protegiendo frente a terceros tras una brecha de drand. Hoy §36.1 l.1099 lo presenta solo como «una barrera adicional de acceso». + - Dar valores al campo «estado» de §71: + - `active`; + - `deprecated`: no se sella nada nuevo; + - `halted_after_round N`: se rechazan las rondas posteriores y el lector las declara irrecuperables; + - `compromised_since T`: se muestra un aviso. + - Añadir: «un perfil publicado nunca se retira de los lectores conformes». + - En el servicio: ofrecer re-sellado cuando se anuncie un apagado. + +### 4. Revisión externa y evidencia de independencia (proceso) + +- **Problema:** el punto 10 de §75 sigue abierto, y la evidencia actual es más débil de lo que sugiere el texto. +- **Por qué importa:** + - §76 l.2429 atribuye correcciones a «la revisión formal e independiente». En realidad fue una revisión hecha por agentes en estas mismas sesiones. + - TypeScript sigue los textos de error de Go «byte a byte» (HANDOFF l.44), y §16 l.498 exige que los vectores salgan de la referencia. + - Las 407.196 entradas diferenciales prueban que TypeScript coincide con Go. No prueban que el texto baste para implementar. + - Los objetivos de §4 son informales. +- **Propuesta:** + - Una revisión criptográfica humana, con revisor, alcance y fecha anotados en §76. + - Etiquetar la revisión actual como interna y asistida por IA. + - Un anexo breve de modelo de seguridad: los adversarios de §7, qué se garantiza antes de la ronda, la confidencialidad del factor de acceso, la integridad frente a quien no tiene la llave y qué vale frente al creador. + - Una tercera implementación hecha por otra persona sin ver el código existente, solo con la spec y testdata, anotando cada pregunta que surja. + - Antes de la revisión: la versión inglesa y una regla de precedencia. El texto manda sobre el CDDL y testdata/README, y hay que declarar el idioma normativo. + +### 5. Raíz de confianza y algoritmos de verificación (núcleo) + +- **Problema:** para verificar un release hay que leer código ajeno, y la raíz de confianza tiene una puerta sin especificar. +- **Por qué importa:** + - El paso 10 verifica «según el scheme de drand» (l.1818). + - H3 y H4 son las de kyber (l.1843-1847). + - El mensaje de ronda y el DST no aparecen en el texto. + - §12.1 admite tres schemes, y las implementaciones ya divergen: Go acepta los tres y TypeScript solo el de Quicknet. + - §12 l.323 permite «una representación firmada equivalente» sin decir quién firma ni si puede introducir un profile_id nuevo. Quien tenga esa clave podría publicar una red falsa coherente. +- **Propuesta:** + - Limitar §12.1 a `bls-unchained-g1-rfc9380`. + - Junto a «Serialización de GT en H2», escribir msg = SHA-256(uint64_be(round)), el DST, Q_ID = hash_to_G1(msg, DST), H3 (con su iteración y su máscara) y H4. + - Poner valores intermedios en tlock_ibe.json y citar ePrint 2023/189 y RFC 9380. + - Para V1, pinnear Quicknet en el código y quitar «o una representación firmada equivalente». La alternativa es especificar esa clave, su distribución y su rotación. + +### 6. Recuperación a largo plazo (núcleo para el objeto, el paso 9.c y el anexo; servicio para el archivo) + +- **Problema:** el release, que es la pieza que abre la cápsula, no tiene formato definido, y el reloj local puede vetar uno válido. +- **Por qué importa:** + - §1 dice que el documento define la Release API y la Release Cache, pero §47 l.1313 deja `release_material` sin definir. Nadie puede guardar el release de su cápsula junto al .dkc en una forma que cualquier lector acepte. + - §49 solo nombra relays de drand, y fastnet demostró que los relays pueden dejar de servir una red entera. + - El paso 9.c (l.1800-1801) da `ERR_RELEASE_UNAVAILABLE` si el reloj va atrasado, aunque se aporte un release que el paso 10 verificaría. Así, un dato local que no se puede verificar pesa más que uno que sí: lo contrario de §3. + - La recuperación sin software DateKeys no está escrita. tle solo se prueba con time_only, y fuera de scripts/check.sh. +- **Propuesta:** + - Un objeto release canónico (profile_id, round, signature) en CBOR determinista y en JSON, con semántica HTTP: 200; 404 antes de round_time; 400 si el perfil es desconocido. La alternativa es pasar §45-§47 a un anexo informativo. + - Añadir «release desde fichero o archivo» como fuente en §49. + - SHOULD guardar el release verificado junto a la cápsula cuando madure. + - Aplicar 9.c solo a las fuentes de red y caché. + - Un anexo informativo «Recuperación sin software DateKeys»: offsets del PRELUDE, tle, la clave 3 de CONTROL_CBOR como AGE-SECRET-KEY-1 y age. Añadir un caso time_and_key a las pruebas locales. + - En el servicio: un archivo completo de Quicknet en instantáneas que cualquiera pueda replicar. Son 48 bytes por ronda, unos 505 MB al año, y cada release se verifica por sí solo. + +### 7. Escribir lo que no garantiza (núcleo para el texto; extensión para el relleno; servicio para redondear horas) + +- **Problema:** cuatro límites reales no están en el texto, y un texto de producto podría prometer lo contrario. +- **Por qué importa:** + - **(a)** Nadie puede comprobar antes de la fecha que una cápsula abrirá. La ronda del stanza tlock es una etiqueta que nada liga al cifrado IBE. Un creador puede cifrar a otra ronda, o romper el stanza de un solo recipient, y se descubre tras el release (pasos 11 y 13). En pujas o predicciones, eso le permite no revelar. §4 l.77 («El SDK debe detectar condiciones, perfiles y releases manipulados.») puede leerse como una promesa mayor. + - **(b)** Hay metadatos siempre visibles: la ronda, la política, el número de recipients y el tamaño exacto del plaintext. En los fixtures, SEALED_CONTROL_LEN vale 446, 646 y 842 bytes (98 por recipient). Un PAYLOAD_AGE de 78.216 bytes corresponde exactamente a 78.000 bytes de plaintext. + - **(c)** En el cliente web, el mismo origen del que §3 desconfía sirve el código que maneja el plaintext, I_PAYLOAD e I_ACCESS. §7.1 no lo menciona. + - **(d)** La caducidad, las condiciones por evento, varias fechas en un objeto y cancelar antes de la fecha no están en §5. +- **Propuesta:** + - Añadir a §5: «verificar antes de la madurez, frente a un creador malicioso, que la cápsula se abrirá o que su ronda es la declarada». Añadir también los no-objetivos de (d). + - Añadir a §4 un objetivo de unicidad de apertura, con sus supuestos. + - Una sección «Consideraciones de privacidad» con la lista de metadatos visibles y un relleno opcional: + - recipients X25519 ficticios hasta un tamaño fijo, lo que no cambia el protocolo; + - una extensión crítica de CONTROL_CBOR con la longitud real del payload. + - Añadir «servir un cliente web malicioso» a §7.1. En §59: hashes de build publicados, URLs versionadas inmutables, SRI y una build offline. + - En producto, redondear la hora de apertura al minuto. + +### 8. Firma del creador y .dkk protegida (extensión) + +- **Problema:** las dos piezas que casi todos los casos reales necesitan (herencia, pujas, embargos) son extensiones sin definir. +- **Por qué importa:** + - §27 l.764 dice: «Solo una extensión de firma puede aportar autenticidad del creador». No hay ninguna registrada. + - Colocar la firma tiene trampas. En PUBLIC_HEADER es circular con header_binding. Debe cubrir SHA-256(PAYLOAD_AGE), porque §55.1 l.1509 admite que, tras abrir, se puede cifrar otro payload. Eso obliga a escribir en dos pasadas. Y en time_only cualquiera puede quitarla y volver a sellar. + - La .dkk son «32 bytes crudos» (§38 l.1130) y «no lleva MAC ni firma» (§55.1 l.1510). Un bit cambiado se descubre en la fecha. Sin un formato estándar protegido con contraseña, cada aplicación inventará el suyo. +- **Propuesta:** + - Registrar `datekeys.signature` v1 en CONTROL_CBOR antes de la revisión externa, para que se revise junto al resto. + - La firma lleva una etiqueta de dominio y cubre header_binding, SHA-256(PAYLOAD_AGE) y los bytes exactos de las demás extensiones. + - Su ausencia no es un error. El firmante esperado se conoce por fuera de la cápsula. + - Mutaciones nuevas: firma quitada, payload recifrado y control trasplantado. + - Para la .dkk: una forma opcional dentro de un fichero age con scrypt, y un valor de comprobación de 4 bytes, SHA-256("datekeys-dkk-check" || I_ACCESS), en verification_metadata. + - De paso, reservar el prefijo `datekeys.` en extension_id y exigir a terceros nombres de dominio invertidos (§31 l.919). + +## 5. Frase final + +Es un núcleo bueno y honesto, pero no un sistema completo: antes de la v1.0, congela lo que después no podrás cambiar y escribe lo que no garantiza, antes de que lo prometa un texto comercial. \ No newline at end of file diff --git a/spec_v0.9/README.md b/spec_v0.9/README.md new file mode 100644 index 0000000..972a93f --- /dev/null +++ b/spec_v0.9/README.md @@ -0,0 +1,13 @@ +# Papeles de trabajo del borrador v0.9 + +Salida del workflow `spec-v0.9-draft` del 29-09-2026, que redactó el borrador de la v0.9 en `datekeys-go`, rama `v0.9`, commit `1189f2f`. Estaban en la carpeta temporal de aquella sesión y se copiaron aquí el 29-09 sin cambios. Están en inglés, como los escribieron los agentes. + +| Fichero | Contenido | +|---|---| +| [design.md](design.md) | Nota de diseño: formato 2, 16 slots, relleno del payload, reglas del escritor y privacidad, sobre las decisiones D1 a D7 del autor | +| [security.md](security.md) | Revisión adversarial del diseño: seguridad y privacidad | +| [consistency.md](consistency.md) | Revisión del diseño: coherencia, compatibilidad e implementabilidad | +| [report.md](report.md) | Informe de la redacción: secciones cambiadas y comprobaciones | +| [review.md](review.md) | Revisión final del borrador: 22 correcciones, sin aplicar a 29-09 | + +Los números de línea de `review.md` se refieren al borrador de `1189f2f`. diff --git a/spec_v0.9/consistency.md b/spec_v0.9/consistency.md new file mode 100644 index 0000000..5fb2b0a --- /dev/null +++ b/spec_v0.9/consistency.md @@ -0,0 +1,74 @@ +# Review of the DateKeys v0.9 design: consistency, compatibility and implementability + +These checks passed, so the core design holds: +- **Arithmetic.** Every padding and length figure in the note recomputes exactly, and all 5 fixtures fit the length formulas. The v2 control is 103 bytes, and `reforzado(L_MAX+1)` is 2^53. +- **Old readers.** None of the 1825 `inspect_differential.json` cases produces VERSION 2 (byte 4 comes out as a7, 05, e8 or 20). Go `framing.go:111` and TS `framing.ts:47` both reject VERSION 2 at step 2 with no network request. +- **Precedence.** Every existing §69.1 example stays true, and so do the six new rows. +- **Format-1 codes.** No invalid format-1 input changes its code except those with VERSION 2. A control v1 with keys 6/7 gives `ERR_NON_CANONICAL_CBOR` in both readers. A control v2 in format 1 gives `ERR_UNSUPPORTED_VERSION` in both. + +Corrections, most serious first: + +1. **HIGH: relabel cases that offer a `.dkk` fail at step 9.a, not at 12 or 14.** Every writer-made `.dkk` has a `capsule_digest`, and editing byte 4 breaks it. + - Evidence: + - `encrypt.go:232` always sets `Verification{CapsuleDigest}`. + - `time_and_key_portable.dkk.json` has `capsule_digest 2e97…`. + - The existing mutation "capsule_digest of the .dkk does not match" (one byte edit) gives `ERR_ACCESS_INVALID`, step 9, `network:false`. + - The corpus reader gets a seekable file, so the digest is checked (`testdata/README.md:212-214`). + - Wrong as written: + - §11 row "format 1 time_and_key relabeled format 2 | time_and_key_portable.dkc + .dkk → ERR_POLICY_STRUCTURE_MISMATCH 12". + - §11 row "format 2 time_and_key relabeled format 1 | … + .dkk → ERR_UNSUPPORTED_VERSION 14". + - Unconditional claims that need qualifying: the time_and_key rows of the §1.3 table, the §26 addition ("Antes falla en el paso 12 o en el 14"), the last paragraph of §70, and §76 case 4 ("falla en el paso 14 en los dos lectores"). + - Fix: build these cases with `identities` (the raw `access_material`), not the `.dkk`. In §64 write "…con una identity". Add to §26 and §70: "o en el paso 9.a, con `ERR_ACCESS_INVALID`, si se ofrece una `.dkk` con `capsule_digest` y se comprueba (§43, §69.1 Alcance)". + +2. **MEDIUM: "En ningún caso entrega el contenido con su relleno" (§70) and "No path outputs padding as content" (§1.3) claim too much.** + - For a format-2 `time_only` capsule, anyone can do this after the release: + 1. Open the control with `FK_TIME` and read `I_PAYLOAD`. + 2. Set VERSION to 1. + 3. Seal a v1 control with the same `I_PAYLOAD` and a recomputed `header_binding` (§36.1). + - Both readers then open it as format 1 and output content‖zeros. + - Fix: limit the claim to "una cápsula de formato 2 cuyo control no se ha vuelto a sellar", and cite §36.1 and §55.1. + +3. **MEDIUM: new normative rules outside D1–D7 that §12 does not list for the author's confirmation.** + - `L_MAX = 2^53 − 2^46`. It is a new writer MUST NOT and a new reader rejection (`ERR_NON_CANONICAL_CBOR`). D2 only said "up to the format limits", and today `PAYLOAD_AGE` has no bound. + - The MUST to shuffle the 16 stanzas with a CSPRNG. It is needed for D1: the portable-key holder is last in `encrypt.go:294` and would learn the exact count. The text should also require an unbiased shuffle. + - "Control schema version MUST equal the format". This ties every future format to a new control version. Better as a format → version table. Also state that a new padding code needs a new format, so old readers still fail at step 2. + - The §72 clause "salvo que su registro diga otra cosa". It adds a registry declaration that is not in §72's MUST-declare list: either add it to the list or drop the clause. + +4. **MEDIUM: writer implementability — L must be known up front, and a streaming writer cannot take back what it wrote.** + - Go: `Encrypt(dst, src io.Reader, …)` streams a source of unknown length (`encrypt.go:218`), after writing PRELUDE, header and control to `dst` (`207-211`). + - TS: the phase 3 plan (decision 2) accepts a `ReadableStream`, whose length is unknown. + - Both APIs need an explicit length input, or must spool the source to a temporary file first. §61.1 and §62.1 rule 6 should say a writer MAY spool and MUST NOT guess L. + - "abortar y descartar lo escrito" (§61 step 13, rule 9) is something a streaming writer cannot do. Rephrase as: the writer MUST report the error, and whoever receives the output MUST discard it, as with the capsule-rejected case of §56. + +5. **LOW-MEDIUM: nothing says a reader must not reserve memory from L.** A control may declare L up to 8.9·10^15. Add to §57 and §63 step 16: "L no es una longitud de trama: un lector no reserva memoria según L; la acota el ciphertext". The TS reader already bounds its buffer by the ciphertext length (`open.ts:422`); the text should keep that. + +6. **LOW-MEDIUM: §69.1 paragraph L2106 (cross-object checks) is not updated.** The design moves one cross-object check (control version against PRELUDE VERSION) into layer 2, but L2106 still lists every cross-object check as belonging to its own step. Add "y la versión de `CONTROL_CBOR` frente al formato (paso 14, capa 2)" to that list. + +7. **LOW: a proposed `cbor.json` vector cannot test what it claims.** "padding 3 and an unknown critical extension (layer 3 before layer 4)" does not work there: schema vectors never check critical extensions (`testdata/README.md:115-118, 131-133`). Keep it only as a mutation or a `TestPrecedenceAcrossSteps` case at step 14. + +8. **LOW: the informative note of §62.1 is wrong.** It says "Con C = 91 y k = 1 o 3, las fórmulas dan las longitudes de los cinco fixtures". But `time_only_extensions` has C = 127 (482 = 335 + 4 + 127 + 16), and three of the fixtures are `time_only`, with no k. Fix: "C = 91 (127 en time_only_extensions); k = 1 o 3 en los dos time_and_key". + +9. **LOW: the floating-point log2 example is outside the valid range.** 2^53 − 1 is above L_MAX. Use L = 2^49 − 1, the first failing case in range: `Math.log2` gives 49 and the correct value is 48 (checked in Node). + +10. **LOW: "bloque256 añade menos de 256 bytes" is false for L = 0**, which adds 256. Write "como mucho 256". + +11. **LOW: new §39 says "En formato 1, INNER_ACCESS_AGE MAY contener uno o más stanzas".** That weakens the MUST (≥ 1) of §33 and §36. Write "contiene (MUST, §33) uno o más". + +12. **LOW: §74 contradicts itself and the README.** + - The design declares the encoding of L and the 16 slots "no provisionales". §74 still lists "schema CBOR final byte-a-byte de CONTROL_CBOR" as open, and spec/README says v0.9 is "not frozen". + - The out-of-scope list sits under "Aspectos todavía provisionales". Put it in its own "Trabajo futuro" paragraph. + +13. **LOW: forward references.** §29.1 cites `testdata/vectors/padding.json`, which does not exist. §76 cites "TODO" tests. Every existing §76 entry cites only tests that exist. Mark these "a generar con la implementación de v0.9", or keep the test list out of §76 until they exist. + +14. **LOW: wording in §55.2.** + - The ".dkk lleva en claro …" line leaves out the credential itself (`access_material`). + - "Credencial" in §39 means a recipient (a public key). In §63 step 9 it means an identity or `.dkk` the reader offers. Define the term once. + +15. **INFO: effects on the TS phase 3 plan (`App/docs/PLAN_fase3_escritura.md`).** + - Decision 6 keeps `R_ACCESS` last; it must become the shuffle. + - The test "1 024 recipients + portable" becomes a rejection above 16. + - Decision 8 ("no vuelve a descifrar") and plan §6, which wipes `I_PAYLOAD` right after the real seal, conflict with the new SHOULD self-check that `I_PAYLOAD` opens the `PAYLOAD_AGE` header. Wipe it after that check. + +16. **INFO: test data.** + - The `cbor.json` vector "unknown key 6" (control, key 6 = uint 0) has a misleading name once key 6 exists; rename it. + - Go's `Open` never records a step 17 stage (`open.go:246` goes to `261`), so fixture `stages` lack step 17. The new step 17 checks make that entry worth adding for §67. \ No newline at end of file diff --git a/spec_v0.9/design.md b/spec_v0.9/design.md new file mode 100644 index 0000000..0ab4e9e --- /dev/null +++ b/spec_v0.9/design.md @@ -0,0 +1,1273 @@ +# DateKeys v0.9 design note: format 2, 16 slots, payload padding, writer rules and privacy + +**Date:** 29-09-2026. **Scope:** specification text only, which goes in `spec/DateKeys_Protocol_Specification_v0.9.md`, `spec/datekeys.cddl` and `spec/README.md`. I edited no files. Line numbers refer to the current v0.9 working copy, which is byte-identical to v0.8.2 (2488 lines). + +**Checked against the repository:** +- all section lengths of the 5 fixtures; +- `capsule.Encrypt`; +- `filippo.io/age` v1.3.2 `x25519.go`: lines 75, 85-87 and 176-183; +- `mutations.json`, `cbor.json` and `inspect_differential.json`; +- the PURBs paper, from its PoPETs page and PDF: title, authors, 2019(4), pp. 6–33, DOI and Algorithm 1. + +A script computed every padding value in this note exactly, as integers. + +--- + +## 0. Decisions at a glance + +| Question | Recommendation | Reason (one line) | +|---|---|---| +| Version mechanism (D4) | PRELUDE `VERSION` = 2 ("formato 2"), plus `CONTROL_CBOR` schema version 2. `PUBLIC_HEADER` stays schema 1. `.dkk` stays DKK1 v1. | This is the earliest check (step 2, no network), and the change covers the whole capsule, not the header. The sealed control then states its own format, so a relabel fails at step 14 in both old and new readers. | +| Rejected alternatives | `PUBLIC_HEADER` schema 2: bumps a schema that did not change, and breaks 2 `cbor.json` vectors, one mutation and the §69.1 example. MAGIC `DKC2`: a v0.8.2 reader would say `ERR_INVALID_MAGIC` ("not a capsule"), which misleads. | — | +| New `CONTROL_CBOR` keys | 6 = `payload_length`, as `bstr .size 8` (L, uint64 big-endian). 7 = `padding`, as uint in {1, 2}. Required in v2, forbidden in v1. | A fixed width keeps `SEALED_CONTROL_LEN` independent of L. A CBOR `uint` would leak L's width class. | +| Bound on L | 0 ≤ L ≤ L_MAX = 2⁵³ − 2⁴⁶ = 8936830510563328, the same bound for both codes. | This is the largest L for which both rules keep P ≤ 2⁵³ − 1: `reforzado(L_MAX+1) = 2⁵³`. | +| Codes of the new checks | No new error code. Control/format version mismatch → `ERR_UNSUPPORTED_VERSION`, step 14, layer 2. Rules of keys 6 and 7 → `ERR_NON_CANONICAL_CBOR`, step 14, layer 3. INNER count ≠ 16 → `ERR_POLICY_STRUCTURE_MISMATCH`, step 12 (and as a MUST at step 13). Payload length ≠ P or non-zero padding → `ERR_INTEGRITY`, step 17. | Each code already covers its class of failure (§57, §28.1, §30.1). | +| Where the padding is checked | Step 17: length and zero bytes. Step 18: output the first L bytes. Step 16 computes P and cannot fail. | L and the code are validated at step 14, so P is known before the first payload byte. | +| Pre-unlock size check (A10) | Do not add. | It would be one more optional check that changes the step and code (§69.1, "Alcance"). | +| Stanza order | The writer MUST shuffle the 16 recipients with a CSPRNG. | `age` keeps recipient order, and the reference appends `R_ACCESS` last. | +| Credential bounds | The writer MUST accept 1 to 16 credentials, no duplicates, and none with `time_only`. | With 0 credentials nobody can open the capsule. A duplicate fails its holder at step 13. | +| Non-canonical or low-order recipients | The writer MUST reject them. Readers cannot detect them. | Their stanzas cannot be opened, or (low order) anyone could open them. | +| Self-check and wiping secrets | SHOULD, as approved in D7. The official SDK SHOULD default to code 2. | — | +| `SEALED_CONTROL_LEN` circularity | Normative rule: the exact length, known before `header_binding` is computed. The writer MAY measure it with a draft seal or compute it; either way it MUST check the real seal has the same length. The formula is informative. | — | +| New sections | §29.1 «Relleno del payload», §55.2 «Consideraciones de privacidad», §62.1 «Reglas del escritor». No renumbering; §63 keeps its 18 steps. | Many sections cite step numbers, and §76 cites "18 pasos". | +| Terminology | «formato 1» / «formato 2». "V1" stays the protocol generation. | — | + +--- + +## 1. Versioning (D4) + +### 1.1 Mechanism + +- `VERSION` (PRELUDE byte 4) is the **capsule format**: 1 (v0.8.2 semantics) or 2 (v0.9). The layout is the same in both. +- `CONTROL_CBOR` key 1 MUST equal the format. +- `PUBLIC_HEADER` is schema 1 in both formats. +- The `.dkk` is unchanged. It carries no count and no size, so nothing forces a change. +- v0.9 writers MUST write format 2. v0.9 readers MUST open both formats. + +### 1.2 How a v0.9 reader dispatches, step by step + +| Step | Format 1 (`VERSION` 1) | Format 2 (`VERSION` 2) | +|---|---|---| +| 1 | same | same | +| 2 | accepted | accepted. Any other `VERSION` → `ERR_UNSUPPORTED_VERSION` | +| 3–11 | same; header schema 1; same pre-inspection (5, 6, 8) | same | +| 12 | `time_and_key`: one or more X25519 stanzas, distinct shares (v0.8.2) | `time_and_key`: exactly 16 X25519 stanzas, distinct shares, else `ERR_POLICY_STRUCTURE_MISMATCH` | +| 13 | "uno o más" | "exactamente 16"; the other rules are unchanged | +| 14 | control version MUST be 1; keys 6 and 7 absent (closed map) | control version MUST be 2; keys 6 and 7 present and valid | +| 15 | same | same | +| 16 | `I_PAYLOAD` | `I_PAYLOAD`, L and the code; P = regla(L) | +| 17 | v0.8.2 | v0.8.2, plus: plaintext length = P and bytes [L, P) all zero, else `ERR_INTEGRITY` | +| 18 | the whole plaintext | the first L bytes | + +A format-1 `INNER_ACCESS_AGE` with any count (1, 3, 16, 17…) keeps the v0.8.2 rules. The reference parser limit of 1024 stanzas still gives `ERR_POLICY_STRUCTURE_MISMATCH` at step 12. + +### 1.3 Relabelling + +`VERSION` is public, so anyone can edit it. + +| Input | v0.9 reader | v0.8.2 reader | +|---|---|---| +| format 2, genuine | opens | `ERR_UNSUPPORTED_VERSION`, step 2, no network | +| format 2 `time_only`, relabelled 1 | `ERR_UNSUPPORTED_VERSION`, step 14 (control v2 in format 1) | same code, step 14 | +| format 2 `time_and_key`, relabelled 1 | 16 stanzas pass the format-1 rule; `ERR_UNSUPPORTED_VERSION`, step 14 | same code, step 14 | +| format 1 `time_only`, relabelled 2 | `ERR_UNSUPPORTED_VERSION`, step 14 (control v1 in format 2) | — | +| format 1 `time_and_key` with ≠ 16 stanzas, relabelled 2 | `ERR_POLICY_STRUCTURE_MISMATCH`, step 12 | — | +| format 1 `time_and_key` with exactly 16 stanzas, relabelled 2 | `ERR_UNSUPPORTED_VERSION`, step 14 | — | +| `VERSION` 3 | `ERR_UNSUPPORTED_VERSION`, step 2 | same | + +`header_binding` covers the PRELUDE, so it is a third guard at step 15. No path outputs padding as content, or cuts content short. + +--- + +## 2. `CONTROL_CBOR` version 2 (D3) + +**Keys:** +- `6 → payload_length`: `bstr .size 8`, L as uint64 big-endian with leading zeros. L = 0 is `48 0000000000000000`. +- `7 → padding`: unsigned integer, 1 (`bloque256`) or 2 (`reforzado`). +- Keys 4 and 5 keep their meaning, so one decoder serves both versions. Canonical key order puts 6 and 7 after 4 and 5. + +**Minimal v2 control:** 103 bytes, versus 91 for v1. + +``` +a60070646174656b6579732d636f6e74726f6c0102025820<11×32>035820<22×32>064800000000000000000702 +``` + +**Why `bstr .size 8` (A2(a)):** with a shortest-form `uint`, the value would take 1, 2, 3, 5 or 9 bytes. `SEALED_CONTROL_LEN` would then reveal, inside the P = 256 bucket, whether L < 24, 24 ≤ L ≤ 255 or L = 256. It would also single out L = 65536 in the P = 65536 bucket, and L = 2³² in the 2³² bucket. + +**Layers of §69.1 at step 14:** +- **Layer 2:** the type tag, then the version. The version MUST equal the format, otherwise `ERR_UNSUPPORTED_VERSION` whatever follows. +- **Layer 3:** + - v2: keys 6 and 7 present; + - v1: keys 6 and 7 absent; + - key 6 is a bstr of exactly 8 bytes with value ≤ L_MAX; + - key 7 is 1 or 2; + - plus all existing rules → `ERR_NON_CANONICAL_CBOR`. The order inside the layer does not matter, because every rule gives the same code. +- **Layer 4:** critical extensions (key 4). +- **Then** step 15 (`header_binding`) and step 17 (padding). + +--- + +## 3. Padding rules (D2) + +### 3.1 Exact definition, for 0 ≤ L ≤ L_MAX + +``` +L ≤ 256: bloque256(L) = reforzado(L) = 256 +L > 256: bloque256(L) = 256·⌈L/256⌉ + E = bitlen(L) − 1 (8..52) + S = bitlen(E) (4..6) + lastBits = E − S + mask = 2^lastBits − 1 + Padme(L) = (L + mask) AND NOT mask (= 2^lastBits·⌈L/2^lastBits⌉) + reforzado(L) = max(bloque256(L), Padme(L)) +``` + +This matches Algorithm 1 of the paper (E = ⌊log2 L⌋, S = ⌊log2 E⌋ + 1, z = E − S). Handling L ≤ 256 separately means log2 is never taken of 0, and E, S ≥ 4 whenever Padme is used. + +### 3.2 Arithmetic + +- For L ≤ L_MAX, L + mask ≤ 2⁵³ − 1, so every value is exact. +- Floating-point log2 is wrong: `log2(2^53 − 1)` evaluates to 53.0 in IEEE 754, but the correct value is 52. +- JavaScript's bitwise operators are 32-bit. TypeScript should use BigInt or the `⌈⌉` form, which is exact in doubles (division by a power of two). + +### 3.3 Properties (verified for L = 0..200000 and 2·10⁵ random L up to L_MAX) + +- P is a multiple of 256, P ≥ 256 and P ≥ L. +- The two rules are identical for L ≤ 8192. The first difference is at L = 8193: 8448 with `bloque256`, 8704 with `reforzado`. +- `reforzado` = `Padme` for L ≥ 4096. +- Above 8192, `reforzado` adds less than L/2^S: under 6.25 %, under 3.125 % from 64 KiB, and under 1.5625 % from 2³². +- At most 2^S values of P per power of two: 16, 32 or 64. + +### 3.4 Worked examples + +The last column is the length of `PAYLOAD_AGE`: 184 + P + 16·max(1, ⌈P/65536⌉). + +| L | code 1 (bloque256) | code 2 (reforzado) | E, S, lastBits, Padme | `PAYLOAD_AGE` (1 / 2) | +|---|---|---|---|---| +| 0 | 256 | 256 | — | 456 / 456 | +| 1 | 256 | 256 | — | 456 / 456 | +| 40 | 256 | 256 | — | 456 / 456 | +| 255 | 256 | 256 | — | 456 / 456 | +| 256 | 256 | 256 | — | 456 / 456 | +| 257 | 512 | 512 | 8, 4, 4, 272 | 712 / 712 | +| 1000 | 1024 | 1024 | 9, 4, 5, 1024 | 1224 / 1224 | +| 4096 | 4096 | 4096 | 12, 4, 8, 4096 | 4296 / 4296 | +| 8192 | 8192 | 8192 | 13, 4, 9, 8192 | 8392 / 8392 | +| 8193 | 8448 | 8704 | 13, 4, 9, 8704 | 8648 / 8904 | +| 10000 | 10240 | 10240 | 13, 4, 9, 10240 | 10440 / 10440 | +| 65536 | 65536 | 65536 | 16, 5, 11, 65536 | 65736 / 65736 | +| 65537 | 65792 | 67584 | 16, 5, 11, 67584 | 66008 / 67800 | +| 78000 (`time_only` fixture) | 78080 | 79872 | 16, 5, 11, 79872 | 78296 / 80088 | +| 1000000 | 1000192 | 1015808 | 19, 5, 14, 1015808 | 1000632 / 1016248 | +| 3000000 | 3000064 | 3014656 | 21, 5, 16, 3014656 | 3000984 / 3015576 | +| 600000000 | 600000000 | 603979776 | 29, 5, 24, 603979776 | 600146680 / 604127416 | +| 10⁹ | 1000000000 | 1006632960 | 29, 5, 24, 1006632960 | 1000244328 / 1006878904 | +| 2³² − 1 | 4294967296 | 4294967296 | 31, 5, 26, 4294967296 | 4296016056 / 4296016056 | +| L_MAX = 8936830510563328 | 8936830510563328 | 8936830510563328 | 52, 6, 46, L_MAX | 8939012353949880 / same | +| L_MAX + 1 | out of range | out of range (would be 2⁵³) | — | — | + +--- + +## 4. Reader checks for padding (D3) + +- **Step 16** computes P and cannot fail. +- **Step 17**, after the existing stanza, MAC and STREAM rules: + - the plaintext length MUST equal P; + - bytes L..P−1 MUST be 0x00; + - otherwise `ERR_INTEGRITY`. +- **Why `ERR_INTEGRITY`:** a mismatch is a failure of the control↔payload binding of §30.1. The existing failures of that binding (A+B swap, truncation, data after the end) are already `ERR_INTEGRITY` at step 17. `ERR_POLICY_STRUCTURE_MISMATCH` means stanza structure, `ERR_HEADER_BINDING` covers only the header, and a new code would change §69. +- **Streaming reader:** P is known before the first byte. + - It writes bytes [0, L) to its temporary output. + - It MAY fail as soon as it goes past P or finds a non-zero byte at or after L. + - A plaintext shorter than P is found only at the end of the STREAM. + - Every one of these failures has the same code, so the moment of detection never changes the result. + - Nothing is committed before step 17 ends (§56, step 18). +- **Limit (A12):** the check adds determinism (one valid plaintext per content, every reader agrees) and catches writer bugs. It adds no authenticity. + - Whoever knows `FK_TIME` or `FK_ACCESS` can seal a control with another L that has the same P, for example L + 1 when the next padding byte is zero. + - Whoever knows `I_PAYLOAD` can re-encrypt the payload. + +--- + +## 5. Sixteen slots (D1) + +- **§33 "uno o más":** becomes format-dependent (one or more in format 1, exactly 16 in format 2). +- **§36 / step 12:** the structural count check, no secrets needed → `ERR_POLICY_STRUCTURE_MISMATCH`. Step 13 enforces it again as a MUST. +- **Parser limit:** the 1024-stanza limit changes no code in format 2. Any header above 1024 stanzas also has more than 16, and both give the same code at the same step. +- **Other step 13 rules are unchanged:** a malformed stanza is `ERR_INTEGRITY`; an identity that unwraps more than one stanza is `ERR_POLICY_STRUCTURE_MISMATCH`; no identity unwrapping anything is `ERR_ACCESS_INVALID`. Dummies are opened by nobody. +- **Dummy rule:** for each free slot, generate a fresh X25519 identity from a CSPRNG, take its recipient, and discard the private key at once. It MUST NOT be stored, logged or returned, reused, or derived. +- **Why dummies are indistinguishable:** + - An age X25519 stanza holds only a fresh ephemeral share and `AEAD(HKDF(X25519(e, R), share‖R), FK)`. R never appears in it. + - Testing a stanza against a candidate R needs the shared secret, which means r or e. The ephemeral e is discarded by age. + - This holds even for a holder who knows `FK_ACCESS`. + - A dummy's key has the same distribution as a real key, and every X25519 stanza is 98 bytes. + - Colluding holders learn only a lower bound on the number of credentials. +- **Order:** a uniformly random permutation from a CSPRNG (MUST). + +--- + +## 6. Writer rules (D7): MUST vs SHOULD + +| Rule | Level | +|---|---| +| Write format 2; never format 1 | MUST / MUST NOT | +| Requested instant strictly after the writer's clock (the reference already rejects "equal to now") | MUST | +| `time_only`: no credentials. `time_and_key`: 1–16 credentials, no duplicate public keys, X25519 only, canonical (bit 255 clear, u < p) and not low order | MUST | +| Dummies fill the free slots; private keys never stored or output; random order | MUST | +| `capsule_id`, `I_PAYLOAD`, `I_ACCESS`, `credential_id`, dummies and the permutation from a CSPRNG; `I_PAYLOAD` and dummies never reused or derived | MUST | +| L known before sealing; L ≤ L_MAX; code 1 or 2; abort if the source length ≠ L | MUST | +| `SEALED_CONTROL_LEN` exact; known before `header_binding` (draft seal or formula); real seal checked to have the same length | MUST | +| §57 limits; extension rules of §54 and §72 | MUST | +| Discard the output on any error | MUST | +| Official SDK defaults to code 2 | SHOULD | +| Self-check: decode own `PUBLIC_HEADER` and `CONTROL_CBOR` with the reader's rules; INNER has 16 X25519 stanzas with distinct shares; each kept identity (`I_ACCESS`) opens exactly one; `I_PAYLOAD` opens the `PAYLOAD_AGE` header | SHOULD | +| Wipe `I_PAYLOAD`, `CONTROL_CBOR`, spare copies of `I_ACCESS`, dummy key memory and content buffers | SHOULD | + +**Length formula** (informative, Quicknet, checked against all 5 fixtures), with c(n) = max(1, ⌈n/65536⌉), X25519 stanza = 98 bytes, tlock stanza = 249 + d bytes (d = digits of the round): +- `PAYLOAD_AGE` = 184 + P + 16c; +- `INNER_ACCESS_AGE` = 86 + 98k + C + 16c(C); +- `OUTER_TIME_AGE` = 335 + d + n + 16c(n). + +In format 2, with round 1000 and no control extensions: `time_only` 458, `time_and_key` 2128 (INNER 1773). + +--- + +## 7. Privacy (D5, D6) + +The full text is in §8, under "§55.2". Summary: + +- **Visible to anyone with the `.dkc`, before and after the date:** format, frame lengths, `capsule_id`, DateKey (and so the date), `access_policy` (kept visible for the fail-fast at step 9), header extensions, tlock stanza, P exactly, and the control length. The control length now reveals only the size of the control extensions. +- **Hidden until the date:** L, the code (except when P gives it away), the control, the number of credentials and the recipients. +- **After the date:** `time_only` exposes everything. `time_and_key` exposes the 16-stanza INNER header only. +- **Never visible:** recipients, whether a stanza is a dummy, and the credential count beyond the colluders' lower bound. +- **Format 1** leaks both the credential count and the exact L. + +--- + +## 8. Spec text, section by section (ready to paste) + +### Title (L3, L6) + +````markdown +### Borrador normativo v0.9 + +**Estado:** Draft / pre-estándar +**Fecha:** 29 septiembre 2026 +```` + +### §1: add to the list + +After «- recipient X25519 V1;»: + +````markdown +- formatos 1 y 2 de `.dkc` y su compatibilidad; +- huecos fijos de `INNER_ACCESS_AGE` y relleno del payload; +```` + +After «- verificación;»: + +````markdown +- reglas del escritor; +```` + +Change «- extensiones genéricas.» to «- extensiones genéricas;» and add: + +````markdown +- consideraciones de privacidad. +```` + +### §4: add goal 8 + +````markdown +8. **Privacidad de metadatos** + Antes de la fecha, una cápsula de formato 2 no debe revelar la longitud exacta de su contenido ni el número de credenciales que la abren (§55.2). +```` + +### §5: add a non-goal + +````markdown +- ocultar la fecha de apertura, la política de acceso, `capsule_id` o el tamaño rellenado del contenido (§55.2); +```` + +### §15: after «Nunca se redondea hacia atrás.» + +````markdown +Al escribir una cápsula, el instante pedido MUST ser posterior al instante actual del reloj del escritor (§62.1). +```` + +### §22 + +In the table: `4 1 VERSION = 1 o 2 (formato)`. After «El payload es el resto del fichero hasta EOF.», add: + +````markdown +`VERSION` es el formato de la cápsula. La trama es la misma en los dos formatos; cambian `INNER_ACCESS_AGE`, el plaintext de `PAYLOAD_AGE` y la versión de `CONTROL_CBOR`: + +| Formato | `VERSION` | Semántica | +|---|---|---| +| 1 | 1 | La de la v0.8.2. `INNER_ACCESS_AGE` lleva uno o más stanzas; el plaintext de `PAYLOAD_AGE` es el contenido; `CONTROL_CBOR` tiene la versión de schema 1. | +| 2 | 2 | La de esta versión. `INNER_ACCESS_AGE` lleva exactamente 16 stanzas (§39); el plaintext de `PAYLOAD_AGE` es el contenido con relleno (§29.1); `CONTROL_CBOR` tiene la versión de schema 2 (§31). | + +Un escritor MUST escribir el formato 2 (§62.1). Un lector MUST aceptar los dos (§70). +```` + +In the «V1 MUST exigir» block, add a first line: `1 <= VERSION <= 2`. + +### §23 + +Item 3 → «3. `VERSION` distinto de 1 y de 2 → `ERR_UNSUPPORTED_VERSION` (paso 2);». After the final paragraph, add: + +````markdown +Un lector que solo conoce el formato 1, como los de la v0.8.2, rechaza aquí una cápsula de formato 2, antes de cualquier petición de red (§70). +```` + +### §24: after «Las claves 5 y 6 son opcionales…» + +````markdown +`PUBLIC_HEADER` es la misma en los dos formatos, con la versión de schema 1. No contiene nada del relleno ni del número de credenciales (§55.2). +```` + +### §26: add at the end + +````markdown +El PRELUDE incluye `VERSION`: una cápsula a la que se cambia el formato falla, como tarde, en el paso 15. Antes falla en el paso 12 o en el 14 (§31, §39, §63). +```` + +### §27 + +- «- version;» → «- versión, que es el formato (1 o 2);». +- L762: «de las secciones 29, 32 y 33» → «de las secciones 29, 32, 33 y 39». + +### §28.1 + +- Table header: «viola §29, §32 o §33» → «viola §29, §32, §33 o §39». +- After the paragraph at L824, add: + +````markdown +En formato 2 también es `ERR_INTEGRITY`, en el paso 17, un plaintext de `PAYLOAD_AGE` cuya longitud no es la P de su L o cuyo relleno no es nulo (§29.1). +```` + +### §29: replace the whole section + +````markdown +## 29. PAYLOAD_AGE + +El contenido del usuario se cifra como un **fichero age v1 estándar completo**. + +Durante la creación se genera: + +```text +I_PAYLOAD = X25519 identity aleatoria de 32 bytes +R_PAYLOAD = X25519 public recipient correspondiente +``` + +`I_PAYLOAD` MUST salir de un CSPRNG, nueva para cada cápsula. MUST NOT reutilizarse en otra cápsula ni derivarse de otro valor, como el contenido, `capsule_id` o `I_ACCESS`: el binding de §30.1 solo separa dos cápsulas si sus `I_PAYLOAD` son distintas. + +Entonces: + +```text +PAYLOAD_AGE = +age.Encrypt( + recipient = R_PAYLOAD, + plaintext = PAYLOAD_PLAINTEXT +) +``` + +donde, con L la longitud del contenido en bytes: + +```text +formato 1: PAYLOAD_PLAINTEXT = contenido +formato 2: PAYLOAD_PLAINTEXT = contenido || 0x00^(P − L), P = regla(L) (§29.1) +``` + +`PAYLOAD_AGE` MUST contener exactamente un stanza, de tipo X25519, para `R_PAYLOAD`. + +`age` genera internamente: + +```text +FK_PAYLOAD = 16 random bytes +``` + +y la protege para `R_PAYLOAD`. + +`I_PAYLOAD` se almacena dentro de `CONTROL_CBOR`; en formato 2, también L y el código de la regla de relleno (§31). +```` + +### §29.1 (new, after §29) + +````markdown +## 29.1 Relleno del payload + +En formato 2 el plaintext de `PAYLOAD_AGE` es el contenido seguido de ceros: + +```text +PAYLOAD_PLAINTEXT = contenido || 0x00^(P − L) +P = regla(L) +``` + +donde L es la longitud del contenido en bytes. La longitud de `PAYLOAD_AGE`, visible para cualquiera, revela P y no L (§55.2). + +El escritor elige la regla. Su código viaja sellado en `CONTROL_CBOR`, junto a L (claves 6 y 7, §31); nada del relleno va en `PUBLIC_HEADER`. + +| Código | Nombre | P | +|---|---|---| +| 1 | `bloque256` | el menor múltiplo de 256 mayor o igual que L, y como mínimo 256 | +| 2 | `reforzado` | el mayor de `bloque256(L)` y `Padme(L)` | + +No existe una regla sin relleno. Un lector MUST aceptar los dos códigos. El SDK oficial SHOULD usar el código 2 por defecto. + +Definición exacta, para 0 ≤ L ≤ L_MAX: + +```text +L_MAX = 2^53 − 2^46 = 8936830510563328 + +si L <= 256: + bloque256(L) = 256 + reforzado(L) = 256 + +si L > 256: + bloque256(L) = 256 · ceil(L / 256) + E = bitlen(L) − 1 ; floor(log2 L), de 8 a 52 + S = bitlen(E) ; floor(log2 E) + 1, de 4 a 6 + lastBits = E − S + mask = 2^lastBits − 1 + Padme(L) = (L + mask) AND NOT mask + reforzado(L) = max(bloque256(L), Padme(L)) +``` + +`bitlen(x)` es el número de bits de x sin ceros a la izquierda: `bitlen(1) = 1`, `bitlen(256) = 9`. `Padme` es la función Padmé de Nikitin et al. (§77): conserva los S + 1 bits más significativos de L y redondea hacia arriba los demás. `(L + mask) AND NOT mask` es igual a `2^lastBits · ceil(L / 2^lastBits)`. + +Aritmética: + +- todos los valores son enteros exactos; para L ≤ L_MAX, ningún resultado intermedio supera 2⁵³ − 1; +- `floor(log2 L)` MUST calcularse con enteros, como `bitlen(L) − 1`, nunca con un logaritmo en coma flotante: en IEEE 754, `log2(2^53 − 1)` redondea a 53, y el resultado correcto es 52; +- los operadores de bits de 32 bits, como los de JavaScript, no bastan: una implementación en ese lenguaje usa BigInt o la forma con `ceil`, exacta en doubles porque divide y multiplica por potencias de dos. + +L_MAX es el mayor L para el que las dos reglas dan una P de como mucho 2⁵³ − 1 (§58): `reforzado(L_MAX + 1) = 2^53`. Un escritor MUST NOT sellar un L mayor (§62.1), y un lector lo rechaza en `CONTROL_CBOR` (§31). + +Propiedades, para todo L de 0 a L_MAX (informativo): + +- P es múltiplo de 256, P ≥ 256 y P ≥ L; +- las dos reglas coinciden para L ≤ 8192; la primera diferencia está en L = 8193: 8448 con `bloque256` y 8704 con `reforzado`; +- `bloque256` añade menos de 256 bytes; por encima de L = 8192, `reforzado` añade menos de L / 2^S: menos del 6,25 %, del 3,125 % desde L = 65 536 y del 1,5625 % desde L = 2³²; +- entre 2^E y 2^(E+1), `reforzado` admite como mucho 2^S valores de P (16, 32 o 64), y `bloque256`, 2^(E − 8). + +Vectores (`testdata/vectors/padding.json`), con la longitud de `PAYLOAD_AGE`, `184 + P + 16·max(1, ⌈P / 65536⌉)` (§62.1): + +| L | P, código 1 | P, código 2 | `PAYLOAD_AGE`, código 1 / 2 | +|---|---|---|---| +| 0, 1, 40, 255, 256 | 256 | 256 | 456 / 456 | +| 257 | 512 | 512 | 712 / 712 | +| 1000 | 1024 | 1024 | 1224 / 1224 | +| 8192 | 8192 | 8192 | 8392 / 8392 | +| 8193 | 8448 | 8704 | 8648 / 8904 | +| 65 537 | 65 792 | 67 584 | 66 008 / 67 800 | +| 78 000 | 78 080 | 79 872 | 78 296 / 80 088 | +| 3 000 000 | 3 000 064 | 3 014 656 | 3 000 984 / 3 015 576 | +| 600 000 000 | 600 000 000 | 603 979 776 | 600 146 680 / 604 127 416 | +| 2³² − 1 | 4 294 967 296 | 4 294 967 296 | 4 296 016 056 / 4 296 016 056 | +| L_MAX | L_MAX | L_MAX | 8 939 012 353 949 880 | + +El código va sellado, pero P lo delata a veces (§55.2). +```` + +### §30: add at the end + +````markdown +El relleno de §29.1 forma parte del plaintext de `age`: no existe un subformato DateKeys para él. +```` + +### §30.1: add at the end + +````markdown +En formato 2 el control fija, además, la longitud y el relleno del plaintext de `PAYLOAD_AGE`: su L y su código dan P (§29.1), y el paso 17 rechaza un plaintext de otra longitud o con un byte de relleno distinto de 0x00 (`ERR_INTEGRITY`). Esta comprobación complementa el binding de `I_PAYLOAD`, no lo sustituye. Tampoco añade autenticidad: quien conoce `FK_TIME` o `FK_ACCESS` puede sellar otro control con otra L de la misma P, y quien conoce `I_PAYLOAD` puede cifrar otro plaintext (§55.1). + +El binding solo separa dos cápsulas si sus `I_PAYLOAD` son distintas: `I_PAYLOAD` MUST ser nueva para cada cápsula (§29). +```` + +### §31: replace L904–L919 (schema up to «…identificada por namespace.») + +````markdown +Schema base: + +```text +0 → "datekeys-control" +1 → versión de schema: 1 en formato 1, 2 en formato 2 +2 → header_binding (32 bytes) +3 → payload_identity (32 raw bytes, I_PAYLOAD) +4 → critical_extensions +5 → noncritical_extensions +6 → payload_length (8 bytes: L, entero sin signo big-endian) +7 → padding (código de relleno: 1 = bloque256, 2 = reforzado) +``` + +Reglas del schema: + +- la versión de schema (clave 1) MUST ser el formato de la cápsula, el `VERSION` de su PRELUDE (§22); otra es `ERR_UNSUPPORTED_VERSION` en el paso 14 (§69.1, capa 2); +- las claves 4 y 5 son opcionales y se omiten cuando no hay extensiones (§58.1); significan lo mismo en las dos versiones; +- las claves 6 y 7 MUST existir en la versión 2 y MUST NOT existir en la versión 1; +- `payload_length` MUST ser una cadena de bytes de exactamente 8 bytes con L, la longitud del contenido, como entero sin signo big-endian, con ceros a la izquierda; su valor MUST ser como mucho L_MAX = 2⁵³ − 2⁴⁶ (8936830510563328, §29.1). L = 0 se codifica `48 0000000000000000`; +- `padding` MUST ser el entero sin signo 1 o 2 (§29.1); +- cualquier violación de las reglas de las claves 6 y 7 es `ERR_NON_CANONICAL_CBOR` (§57). + +`payload_length` tiene tamaño fijo para que la longitud de `CONTROL_CBOR`, visible en `SEALED_CONTROL_LEN`, no dependa de L (§55.2): un entero CBOR en su forma más corta ocuparía 1, 2, 3, 5 o 9 bytes según L. Un `CONTROL_CBOR` de versión 2 sin extensiones mide 103 bytes. + +No contiene el payload grande. + +DateKeys V1 utiliza **un único mecanismo de extensión**. No existe un campo core separado para aplicaciones o semánticas superiores. Cualquier semántica adicional se registra como una extensión identificada por namespace. Las claves 6 y 7 son campos del protocolo, no semántica de aplicación. +```` + +### §33 + +In the code block: `recipients = X25519: en formato 2, 16, credenciales y señuelos (§39)`. Replace L1002 with: + +````markdown +`INNER_ACCESS_AGE` MUST contener stanzas de tipo X25519, exactamente uno por recipient: uno o más en formato 1, y exactamente 16 en formato 2 (§39). +```` + +### §36 (time_and_key) + +Replace «cuyo header contenga uno o más stanzas, todos ellos de tipo X25519,» with «cuyo header contenga uno o más stanzas en formato 1, o exactamente 16 en formato 2 (§39), todos ellos de tipo X25519,». After the paragraph «`age` genera un share efímero nuevo…», add: + +````markdown +En formato 2 el límite de 1024 stanzas del parser de la implementación de referencia (§74) no cambia ningún código: una cabecera que lo supera también tiene más de 16 stanzas, y las dos causas dan `ERR_POLICY_STRUCTURE_MISMATCH` en el paso 12. +```` + +### §36.1: add at the end + +````markdown +Los señuelos de §39 no añaden ninguna barrera de acceso ni ninguna autenticidad. +```` + +### §37: add after «No se define un KEM propio.» + +````markdown +Un recipient X25519 son 32 bytes: la coordenada u de RFC 7748 en little-endian. El escritor MUST rechazar (§62.1): + +- un recipient no canónico: con el bit 255 a 1, o con u ≥ p = 2²⁵⁵ − 19; +- un recipient de orden bajo: aquel para el que X25519(k, u) es la cadena de 32 ceros. Con el recorte de escalares de RFC 7748, el resultado no depende de k. + +X25519 ignora el bit 255 y reduce u módulo p, pero `age` pone los 32 bytes recibidos en el salt de HKDF, y la identity, su clave pública canónica: el stanza de un recipient no canónico no lo abre nadie. Con un recipient de orden bajo, el secreto compartido es cero: `age` se niega a cifrar, y una librería que no lo comprobara dejaría abrir ese stanza a cualquiera. Un lector no puede detectar ninguno de los dos casos: el stanza no contiene el recipient. +```` + +### §38 + +L1128 → «`R_ACCESS` se usa como recipient de `INNER_ACCESS_AGE`. En formato 2 cuenta como una de sus credenciales, como mucho 16, y ocupa una posición aleatoria entre los 16 stanzas (§39).» + +### §39: replace the whole section + +````markdown +## 39. Recipients de `INNER_ACCESS_AGE` + +Una **credencial** es un recipient X25519 que el escritor recibe o la clave portable `R_ACCESS` (§38). + +En formato 2: + +- `INNER_ACCESS_AGE` MUST contener exactamente 16 stanzas X25519, uno por hueco; +- una cápsula `time_and_key` MUST tener entre 1 y 16 credenciales, sin repetir ninguna; +- cada hueco que no ocupa una credencial lleva un **señuelo**: la clave pública de una identity X25519 nueva que el escritor genera con un CSPRNG y descarta en el acto. La clave privada de un señuelo MUST NOT almacenarse, registrarse ni entregarse; ningún señuelo se reutiliza ni se deriva de otro valor; +- el escritor MUST pasar los 16 recipients a `age` en un orden uniformemente aleatorio, obtenido de un CSPRNG, porque `age` escribe los stanzas en el orden de sus recipients; +- toda identity real abre exactamente un stanza, y ninguna abre el de un señuelo. + +En formato 1, `INNER_ACCESS_AGE` MAY contener uno o más stanzas X25519, uno por recipient, sin señuelos ni orden prescrito: es la regla de la v0.8.2. + +En los dos formatos todos los stanzas envuelven la misma: + +```text +FK_ACCESS +``` + +incluidos los de los señuelos. Por tanto el `CONTROL_CBOR` y `PAYLOAD_AGE` no se duplican. + +Un señuelo es indistinguible de una credencial. Un stanza X25519 de `age` contiene un share efímero nuevo y la file key cifrada con una clave derivada del secreto compartido con el recipient (C2SP age, §77); el recipient no aparece en él. Un señuelo es una clave pública con la misma distribución que la de una identity real, y su stanza tiene la misma forma y la misma longitud, 98 bytes. Saber si un stanza es para una clave pública dada exige el secreto compartido: la clave privada del recipient o el escalar efímero, que `age` descarta. Ni siquiera quien conoce `FK_ACCESS` puede comprobarlo. Quien tiene una credencial sabe cuál es su stanza y nada de los otros 15; varias credenciales juntas solo saben cuántos stanzas abren entre todas. +```` + +### §40: add after the frame + +````markdown +La `.dkk` conserva `VERSION` 1 con los dos formatos de `.dkc`: no contiene nada que dependa del número de credenciales ni del relleno. +```` + +### §55.1: row `PAYLOAD_AGE` + +- «Vinculada desde el paso…» column: append «En formato 2, L y el código de `CONTROL_CBOR` fijan además su longitud y su relleno (§29.1).» +- «Nunca prueba» column: append «Tampoco que L sea la longitud original: quien puede reescribir el control puede declarar otra L de la misma P (§30.1).» + +### §55.2 (new, after §55.1) + +````markdown +## 55.2 Consideraciones de privacidad + +Esta sección enumera qué revela una cápsula y a quién. No añade reglas de lectura: resume las consecuencias de §22, §24, §29.1, §31 y §39. Describe el formato 2; el formato 1 revela más (al final). + +### Visible para quien tenga el `.dkc`, antes y después de la fecha + +- `VERSION`, que es el formato, `PUBLIC_HEADER_LEN` y `SEALED_CONTROL_LEN`; +- `capsule_id`, que permite reconocer copias de la misma cápsula; +- la DateKey: el perfil y la ronda, y por tanto el instante de apertura; +- `access_policy`: si hace falta una credencial. Sigue visible para que el paso 9 pueda fallar sin red (`ERR_ACCESS_REQUIRED`); +- las extensiones de `PUBLIC_HEADER`, con su `data`; +- el stanza tlock de `OUTER_TIME_AGE`: la ronda y el chain hash; +- la cabecera de `PAYLOAD_AGE` y su longitud, que da P exactamente: `184 + P + 16·max(1, ⌈P / 65536⌉)`. P acota L: entre 0 y 256 si P = 256; si no, en un intervalo de 256 bytes con `bloque256`, y con `reforzado`, de 256 bytes hasta L = 8192 y de menos de P / 16 por encima; +- la longitud de `CONTROL_CBOR`, que se deduce de `SEALED_CONTROL_LEN` (§62.1): 103 bytes más los de sus extensiones. Revela el tamaño de las extensiones de control, no su contenido. No depende de L, que ocupa siempre 8 bytes, ni del número de credenciales, porque `INNER_ACCESS_AGE` tiene siempre 16 stanzas. + +El código de relleno va sellado, pero P lo delata a veces: los dos códigos dan la misma P para L ≤ 8192, y por encima una P que `reforzado` no produce revela `bloque256`. + +### Oculto hasta la fecha + +- el contenido y su longitud exacta L; +- el código de relleno, salvo lo que delata P; +- `I_PAYLOAD` y todo `CONTROL_CBOR`, incluida la `data` de sus extensiones; +- el número de credenciales y los recipients. + +### Después de la fecha + +Publicada la ronda, cualquiera puede abrir `OUTER_TIME_AGE`: + +- `time_only`: cualquiera lee `CONTROL_CBOR` y el contenido, con L y el código. Nada queda oculto; +- `time_and_key`: cualquiera ve la cabecera de `INNER_ACCESS_AGE`, siempre con 16 stanzas; `CONTROL_CBOR` y el contenido siguen ocultos para quien no tiene una credencial. Quien tiene una lee L, el código, el contenido y las extensiones de control, y sabe cuál es su stanza; no sabe cuáles de los otros 15 son señuelos. Varias credenciales juntas solo saben cuántos stanzas abren entre todas: una cota inferior del número de credenciales. + +### Nunca visible + +- los recipients: un stanza X25519 no contiene la clave pública de su recipient, y comprobar si un stanza es para una clave pública dada exige su clave privada (§39); +- si un stanza es de un señuelo; +- el número de credenciales, salvo la cota inferior de arriba. + +### Fuera del `.dkc` + +- una `.dkk` lleva en claro `capsule_id`, `credential_id`, el `capsule_digest` opcional, que la ata a los bytes exactos de un `.dkc`, y la `data` de sus extensiones (§43, §44); +- pedir el release revela a la Release API y a los relays el perfil, la ronda y la dirección de quien pregunta, pero no `capsule_id` (§45, §46); +- el nombre del fichero, sus fechas en el sistema de ficheros y el canal de entrega no pertenecen al protocolo (§6). + +### Formato 1 + +Una cápsula de formato 1 no oculta ni el número de credenciales ni L: + +- `SEALED_CONTROL_LEN` crece 98 bytes por stanza de `INNER_ACCESS_AGE`: en los fixtures de la v0.8.2, 646 bytes con una credencial y 842 con tres; +- la longitud de `PAYLOAD_AGE`, `184 + L + 16·max(1, ⌈L / 65536⌉)`, da L exacta. + +El protocolo no rellena la `data` de las extensiones. Una extensión registrada que necesite ocultar su longitud define su propio relleno (§72). +```` + +### §56: add at the end + +````markdown +En formato 2, los L primeros bytes no pueden presentarse como válidos antes de que el paso 17 compruebe la longitud y el relleno del plaintext (§63, paso 18). +```` + +### §57 + +L1543 → «`PAYLOAD_AGE` no tiene trama propia: va hasta EOF, se procesa en streaming y es, como mínimo, una cabecera `age` bien formada (§22, §28.1). En formato 2 su plaintext mide exactamente P = regla(L) (§29.1).» + +After «Los tamaños se validan antes de reservar memoria.», add: + +````markdown +Límites del escritor que el lector no comprueba como trama (§62.1): + +- `time_and_key` admite entre 1 y 16 credenciales (§39); un lector de formato 2 solo ve 16 stanzas; +- L es como mucho L_MAX = 2⁵³ − 2⁴⁶ (§29.1); un lector lo comprueba en `CONTROL_CBOR` (§31), con `ERR_NON_CANONICAL_CBOR`. +```` + +In the error mapping (L1550), replace «versión de schema (clave 1) no soportada → `ERR_UNSUPPORTED_VERSION` (§70)» with «versión de schema (clave 1) que el objeto no admite —para `CONTROL_CBOR`, la que no es el formato de su cápsula (§31)— → `ERR_UNSUPPORTED_VERSION` (§70)». After «el orden de los `extension_id` y el máximo de 64 extensiones por array», insert «, y las de las claves 6 y 7 de `CONTROL_CBOR` (§31)». + +### §58: after L1586 + +````markdown +L (clave 6 de `CONTROL_CBOR`) no es un entero CBOR: es una cadena de 8 bytes con un entero sin signo big-endian de como mucho 2⁵³ − 2⁴⁶ (§31), también exacto como double. +```` + +### §58.1: add to the «Por tanto:» list + +````markdown +- las claves 6 y 7 de `CONTROL_CBOR` de versión 2 son obligatorias y nunca se omiten; L = 0 es un dato real (`48 0000000000000000`), no una ausencia, y ningún código de relleno representa «sin relleno» (§29.1, §31). +```` + +### §61: replace the flow (L1664–L1688) + +````markdown +## 61. Flujo de cifrado `time_only` + +Un orden válido para escribir una cápsula de formato 2. Un escritor MAY seguir otro que produzca un objeto equivalente y cumpla §62.1. + +```text +1. Validar las opciones (§62.1): ninguna credencial; instante pedido + posterior al reloj del escritor; L conocida y como mucho L_MAX; + código de relleno 1 o 2; extensiones conformes a §54 y §72. +2. Resolver DateKey localmente (§15). +3. Generar capsule_id (CSPRNG, §21). +4. Generar I_PAYLOAD X25519 nueva (CSPRNG, §29). +5. Construir PUBLIC_HEADER (versión de schema 1). +6. Medir SEALED_CONTROL_LEN (§62.1): la longitud del OUTER_TIME_AGE + del paso 10 con un CONTROL_CBOR de 103 bytes más sus extensiones. + Más de 64 MiB → abortar (§57). +7. Construir PRELUDE: VERSION = 2 y las longitudes de los pasos 5 y 6. +8. Calcular header_binding = SHA-256(PRELUDE || PUBLIC_HEADER). +9. Crear CONTROL_CBOR, versión de schema 2: + header_binding + I_PAYLOAD + critical_extensions + noncritical_extensions + payload_length = L + padding = código +10. Crear OUTER_TIME_AGE: + age genera FK_TIME (16 bytes) + recipient = tlock(DateKey) + plaintext = CONTROL_CBOR + Su longitud MUST ser la del paso 6. +11. SEALED_CONTROL = OUTER_TIME_AGE. +12. Escribir PRELUDE || PUBLIC_HEADER || SEALED_CONTROL. +13. Escribir PAYLOAD_AGE a continuación: + age genera FK_PAYLOAD (16 bytes) + recipient = R_PAYLOAD + plaintext = contenido || 0x00^(P − L), P = regla(L) (§29.1) + Si el contenido no mide exactamente L bytes → abortar y descartar + lo escrito. +``` +```` + +(Keep «File keys utilizadas: FK_PAYLOAD, FK_TIME».) + +### §62: replace the flow (L1701–L1725) + +````markdown +```text +1. Validar las opciones (§62.1): entre 1 y 16 credenciales —los + recipients X25519 dados y, si se pide, una clave portable—, sin + repetir ninguna, canónicas y no de orden bajo (§37); el resto, + como en §61. +2. Resolver DateKey localmente. +3. Generar capsule_id. +4. Generar I_PAYLOAD X25519 nueva. +5. Si se pide una clave portable: generar I_ACCESS (§38). +6. Completar 16 recipients con señuelos (§39): por cada hueco libre, + generar una identity X25519 nueva, tomar su recipient y descartar + la identity. Ordenar los 16 al azar (CSPRNG). +7. Construir PUBLIC_HEADER. +8. Medir SEALED_CONTROL_LEN (§62.1): la del OUTER_TIME_AGE del paso + 13, que contiene un INNER_ACCESS_AGE de 16 stanzas. +9. Construir PRELUDE (VERSION = 2). +10. Calcular header_binding. +11. Crear CONTROL_CBOR, versión de schema 2, con los campos de §61, + paso 9. +12. Crear INNER_ACCESS_AGE: + age genera FK_ACCESS + recipients = los 16 del paso 6, en ese orden + plaintext = CONTROL_CBOR +13. Crear OUTER_TIME_AGE: + age genera FK_TIME + recipient = tlock(DateKey) + plaintext = exact INNER_ACCESS_AGE bytes + Su longitud MUST ser la del paso 8. +14. SEALED_CONTROL = OUTER_TIME_AGE. +15. Escribir PRELUDE || PUBLIC_HEADER || SEALED_CONTROL. +16. Escribir PAYLOAD_AGE como en §61, paso 13. +17. Si se generó I_ACCESS: escribir I_ACCESS cruda en una .dkk + (§40 a §43), con el capsule_digest del .dkc completo si se + incluye. +``` +```` + +(Keep «Las tres file keys… MUST ser independientes».) + +### §62.1 (new) + +````markdown +## 62.1 Reglas del escritor + +Un escritor conforme con esta versión cumple estas reglas, siga o no el orden de §61 y §62. + +MUST: + +1. **Formato.** Escribir el formato 2: `VERSION` = 2 (§22) y `CONTROL_CBOR` de versión 2 (§31). MUST NOT escribir el formato 1. +2. **Instante.** Rechazar un `requested_unlock_at` que no sea posterior al instante actual de su reloj. La ronda se resuelve después, con §15. +3. **Credenciales.** Con `time_only`, rechazar cualquier credencial. Con `time_and_key`, exigir entre 1 y 16 credenciales —recipients X25519 y, si se pide, la clave portable (§38)—, rechazar una clave pública repetida y rechazar todo recipient que no sea X25519, que no sea canónico o que sea de orden bajo (§37). Sin credenciales nadie abriría la cápsula; una repetida daría a su identity dos stanzas, y la cápsula fallaría para ella en el paso 13. +4. **Huecos.** Completar los 16 recipients con señuelos y ordenarlos al azar (§39). La clave privada de un señuelo MUST NOT almacenarse, registrarse ni entregarse. +5. **Aleatoriedad.** Obtener de un CSPRNG, nuevos para cada cápsula, `capsule_id` (§21), `I_PAYLOAD` (§29), `I_ACCESS` (§38), `credential_id` (§42), los señuelos y el orden de los stanzas. `I_PAYLOAD` y los señuelos MUST NOT reutilizarse ni derivarse de otro valor. +6. **Contenido.** Conocer L antes de sellar `CONTROL_CBOR`, con L ≤ L_MAX, y usar el código 1 o 2 (§29.1). Si el contenido no mide exactamente L bytes, abortar: la cápsula fallaría en el paso 17, cuando ya no puede repararse. +7. **Longitud del control.** Escribir en `SEALED_CONTROL_LEN` la longitud exacta de `SEALED_CONTROL`. `header_binding` cubre el PRELUDE y va dentro de `CONTROL_CBOR`, así que esa longitud se conoce antes de calcular `header_binding`. La de un fichero `age` depende solo de la longitud de su plaintext y de la forma de sus stanzas, y la de `CONTROL_CBOR` de versión 2 no depende de `header_binding`, `I_PAYLOAD`, L ni el código, todos de tamaño fijo. El escritor MAY medirla sellando antes un `CONTROL_CBOR` provisional de la misma longitud, con `header_binding` e `I_PAYLOAD` a cero, o calcularla con la nota de abajo. En los dos casos MUST comprobar que el sellado real mide lo mismo. +8. **Límites.** Respetar los límites de §57 y las reglas de extensiones de §54 y §72. +9. **Salida.** Ante cualquier error, no entregar lo escrito como una cápsula: descartarlo. + +SHOULD: + +10. **Código por defecto.** El SDK oficial SHOULD usar el código 2. +11. **Autocomprobación.** Antes de sellar, el escritor SHOULD decodificar su `PUBLIC_HEADER` y su `CONTROL_CBOR` con las reglas del lector (§63, pasos 4 y 14). Después SHOULD comprobar que `INNER_ACCESS_AGE` tiene 16 stanzas X25519 con shares distintos, que cada identity que genera y conserva, como `I_ACCESS`, abre exactamente uno, y que `I_PAYLOAD` abre la cabecera de `PAYLOAD_AGE`. Una cápsula que el lector rechaza solo se descubre tras la fecha. +12. **Borrado.** El escritor SHOULD borrar de la memoria, en cuanto dejan de necesitarse, `I_PAYLOAD`, `CONTROL_CBOR` y sus copias, las copias de `I_ACCESS` que no entrega en la `.dkk`, las claves privadas de los señuelos y los buffers del contenido. + +Nota informativa: longitudes en Quicknet. Con `age` estándar, un stanza X25519 mide 98 bytes, y el stanza tlock, 249 + d, con d el número de dígitos decimales de la ronda. Con c(n) = max(1, ⌈n / 65536⌉) y k stanzas X25519 (16 en formato 2): + +```text +PAYLOAD_AGE = 184 + P + 16·c(P) +INNER_ACCESS_AGE = 86 + 98·k + C + 16·c(C) ; C = |CONTROL_CBOR| +OUTER_TIME_AGE = 335 + d + n + 16·c(n) ; n = C en time_only, + ; |INNER_ACCESS_AGE| en time_and_key +``` + +Sin extensiones de control, C = 103: en la ronda 1000, `SEALED_CONTROL_LEN` vale 458 en `time_only` y 2128 en `time_and_key`. Con C = 91 y k = 1 o 3, las fórmulas dan las longitudes de los cinco fixtures de formato 1. +```` + +### §63: step changes + +Step 2 → + +```text +2. Validar PRELUDE: version, que es el formato (1 o 2), flags, + reserved, longitudes y límites (§22, §23). Los pasos siguientes + aplican las reglas de ese formato. +``` + +Step 4: after «tipo y versión de schema (§70)» insert «(versión 1 en los dos formatos)». + +Step 12 → + +```text +12. Verificar que la estructura resultante coincide con access_policy + (§36). En formato 2 con time_and_key, la cabecera de + INNER_ACCESS_AGE MUST tener exactamente 16 stanzas (§39) + → si no, ERR_POLICY_STRUCTURE_MISMATCH. +``` + +Step 13, line «MUST existir uno o más stanzas;» → + +```text + MUST existir uno o más stanzas en formato 1, y exactamente 16 + en formato 2; +``` + +Step 14 → + +```text +14. Parsear CONTROL_CBOR canónico, con las capas de §69.1. En la + capa 2, su versión de schema (clave 1) MUST ser el formato de la + cápsula, el VERSION del paso 2 (§31) + → si no, ERR_UNSUPPORTED_VERSION. + En la capa 3, las reglas de las claves 6 y 7 (§31): presentes en + la versión 2 y ausentes en la 1, L de 8 bytes y como mucho + 2⁵³ − 2⁴⁶, código 1 o 2 + → si no, ERR_NON_CANONICAL_CBOR. + Validar sus extensiones críticas (clave 4) como en el paso 4. +``` + +Step 16 → + +```text +16. Recuperar I_PAYLOAD. En formato 2, recuperar también L y el código + de relleno (claves 6 y 7) y calcular P = regla(L) (§29.1). Este + paso no falla: el paso 14 ya validó las dos claves. +``` + +Step 17: append + +```text + En formato 2, además (§29.1): + el plaintext MUST medir exactamente P bytes; + sus bytes L a P − 1 MUST valer 0x00; + → si no, ERR_INTEGRITY. + Un lector en streaming conoce P antes de descifrar el primer byte + (paso 16): MAY fallar en cuanto el plaintext supera P bytes o + aparece un byte de relleno distinto de 0x00; un plaintext de menos + de P bytes se detecta al final del STREAM. Todos estos fallos + tienen el mismo código, así que el momento en que se detectan no + lo cambia. +``` + +Step 18 → + +```text +18. Commit del resultado solo si el paso 17 termina sin error, con + todas sus comprobaciones: + formato 1 → el plaintext completo de PAYLOAD_AGE; + formato 2 → sus L primeros bytes, el contenido. El relleno + nunca se entrega. +``` + +Paragraph at L1902: append to its last sentence «…cumple la política DateKeys V1 y, en formato 2, que `INNER_ACCESS_AGE` tiene 16 stanzas y que el plaintext de `PAYLOAD_AGE` tiene la longitud y el relleno de su L (§29.1).» + +### §64 + +- L1932: append a sentence: «La lista vale para los dos formatos: el corpus oficial la aplica a fixtures de formato 2 y conserva los de formato 1 como casos de compatibilidad (§70).» +- L1939: «version cambiada» → «version cambiada a 3». +- After the point block and its note (after L1975), add: + +````markdown +y, con el código y el paso de §63 en que fallan, las del formato 2 (§22, §29.1, §31, §39), sobre fixtures de formato 2 salvo donde se indica: + +```text +VERSION 3 ERR_UNSUPPORTED_VERSION, paso 2 +time_only de formato 1 con VERSION 2 ERR_UNSUPPORTED_VERSION, paso 14 +time_and_key de formato 1, un stanza, con VERSION 2 + ERR_POLICY_STRUCTURE_MISMATCH, paso 12 +time_only con VERSION 1 ERR_UNSUPPORTED_VERSION, paso 14 +time_and_key con VERSION 1 ERR_UNSUPPORTED_VERSION, paso 14 +INNER_ACCESS_AGE con 15 stanzas ERR_POLICY_STRUCTURE_MISMATCH, paso 12 +INNER_ACCESS_AGE con 17 stanzas ERR_POLICY_STRUCTURE_MISMATCH, paso 12 +16 stanzas, dos para un mismo recipient ERR_POLICY_STRUCTURE_MISMATCH, paso 13 +identity que no es recipient de ninguno de los 16 ERR_ACCESS_INVALID, paso 13 +CONTROL_CBOR de versión 2 sin la clave 6 ERR_NON_CANONICAL_CBOR, paso 14 +CONTROL_CBOR de versión 2 sin la clave 7 ERR_NON_CANONICAL_CBOR, paso 14 +código de relleno 0 ERR_NON_CANONICAL_CBOR, paso 14 +código de relleno 3 ERR_NON_CANONICAL_CBOR, paso 14 +L = 2⁵³ − 2⁴⁶ + 1 ERR_NON_CANONICAL_CBOR, paso 14 +último byte de relleno distinto de 0x00 ERR_INTEGRITY, paso 17 +plaintext de P − 1 bytes ERR_INTEGRITY, paso 17 +plaintext de P + 256 bytes ERR_INTEGRITY, paso 17 +plaintext sin relleno, de L bytes ERR_INTEGRITY, paso 17 +código 2 cambiado a 1, con L = 78000 ERR_INTEGRITY, paso 17 +L − 1, con el último byte del contenido distinto de 0 + ERR_INTEGRITY, paso 17 +``` + +Las mutaciones de `VERSION` editan un byte de un fixture. Las de `INNER_ACCESS_AGE` las construye quien conoce `FK_ACCESS`, como el creador. Las de `CONTROL_CBOR` sellan otro control, lo que en `time_only` puede hacer cualquiera (§36.1); las que cambian su longitud recalculan también el PRELUDE y `header_binding`. Las del plaintext las construye quien conoce `I_PAYLOAD`. Todas conservan MAC válidos: solo las reglas de su paso las rechazan. +```` + +### §67: after «- resultado esperado de cada etapa de verificación.» + +````markdown +- el formato (1 o 2); +- en formato 2: L, el código de relleno y P esperados, y los 16 stanzas de `INNER_ACCESS_AGE` en `time_and_key`, con el que abre cada credencial; +- en formato 2, el plaintext final esperado son los L primeros bytes del plaintext de `PAYLOAD_AGE`. + +Los fixtures de la v0.8.2 se conservan como fixtures de compatibilidad de formato 1: un lector MUST abrirlos con la semántica de la v0.8.2 (§70). Los de formato 2 cubren, como mínimo: + +- las dos políticas; +- los dos códigos de relleno, con un mismo L ≥ 8193 para el que dan P distintas; +- L = 0 (P = 256); +- una credencial y 15 señuelos; +- varias credenciales, entre ellas una clave portable; +- 16 credenciales, sin señuelos; +- extensiones en `CONTROL_CBOR`. + +Los vectores de las reglas de relleno (§29.1) van en un fichero propio con L, código y P. +```` + +### §68: after «Al menos un vector oficial `.dkk` MUST incluir una extensión con `data`.» + +````markdown +Al menos un vector oficial `.dkk` MUST acompañar a una cápsula de formato 2. El formato de la `.dkk` no cambia (§40). +```` + +### §69: no change + +The catalogue stays as it is, so `TestCatalogueMatchesSpec` stays green. + +### §69.1 + +- **Layer 1 (L2095):** «magic, prelude completo, versión de framing,» → «magic, prelude completo, versión de trama —el formato, 1 o 2, en el `.dkc` (§23), y 1 en la `.dkk` (§40)—,». +- **Layer 2 (L2096):** replace the last sentence with: + +````markdown +Solo entonces una versión que el objeto no admite es `ERR_UNSUPPORTED_VERSION`, sea lo que sea lo que la sigue: claves desconocidas, elementos fuera del perfil, truncado o bytes sobrantes. El Provider Profile, `PUBLIC_HEADER` y la `.dkk` admiten la versión 1; `CONTROL_CBOR`, la versión igual al formato de su cápsula (§31). Es la única regla de esta capa que depende de otro objeto. +```` + +- **Layer 3 (L2097):** after «el orden y la unicidad de `extension_id` (§54)», insert «, en `CONTROL_CBOR` las reglas de las claves 6 y 7 —presentes en la versión 2 y ausentes en la 1, L de 8 bytes y como mucho L_MAX, código 1 o 2 (§31)—». +- **Cross-object paragraph (L2106):** replace «la estructura frente a `access_policy` (paso 12) y `header_binding` (paso 15)» with «la estructura frente a `access_policy` y, en formato 2, los 16 stanzas de `INNER_ACCESS_AGE` (paso 12), `header_binding` (paso 15), y la longitud y el relleno del plaintext de `PAYLOAD_AGE` frente a L y el código (paso 17)». +- **"Alcance" (L2108), last sentence:** append «, ni el momento en que un lector en streaming detecta un fallo del paso 17». +- **Examples table:** add rows + +````markdown +| Cápsula de formato 2 con 15 stanzas en `INNER_ACCESS_AGE` y una identity que abre uno | `ERR_POLICY_STRUCTURE_MISMATCH`, paso 12 | +| `CONTROL_CBOR` de versión 1 en una cápsula de formato 2, con una clave desconocida | `ERR_UNSUPPORTED_VERSION`, paso 14 | +| Código de relleno 3 y una extensión crítica desconocida en `CONTROL_CBOR` | `ERR_NON_CANONICAL_CBOR`, paso 14 | +| Código de relleno 3 y el `header_binding` de otra cabecera | `ERR_NON_CANONICAL_CBOR`, paso 14 | +| Extensión crítica desconocida en `CONTROL_CBOR` y un byte de relleno distinto de 0x00 | `ERR_EXTENSION_CRITICAL_UNKNOWN`, paso 14 | +| Byte de relleno distinto de 0x00 y un stanza adicional en `PAYLOAD_AGE` | `ERR_POLICY_STRUCTURE_MISMATCH`, paso 6 | +```` + +### §70: replace the list + +````markdown +Una implementación V1: + +- MUST aceptar `DKC1` con `VERSION` 1 o 2, y `DKK1` con `VERSION` 1; +- MUST rechazar cualquier otra versión de trama (§23, §40) y toda versión de schema que el objeto no admita (§69.1); +- MUST abrir todo objeto válido de formato 1 con la semántica de la v0.8.2: uno o más stanzas en `INNER_ACCESS_AGE` y el plaintext completo de `PAYLOAD_AGE`, sin relleno; +- MUST escribir el formato 2 y MUST NOT escribir el formato 1 (§62.1); +- SHOULD indicar al llamador el formato de una cápsula abierta o inspeccionada: el formato 1 no oculta el número de credenciales ni la longitud exacta del contenido (§55.2); +- MUST rechazar critical extensions desconocidas; +- MAY ignorar noncritical extensions desconocidas; +- MUST mantener inmutable la interpretación de perfiles publicados. + +Un lector que solo conoce el formato 1, como los de la v0.8.2, rechaza una cápsula de formato 2 en el paso 2, con `ERR_UNSUPPORTED_VERSION` y sin ninguna petición de red. Si alguien cambia su `VERSION` a 1, falla en el paso 14, también con `ERR_UNSUPPORTED_VERSION`, porque su `CONTROL_CBOR` tiene la versión 2. En ningún caso entrega el contenido con su relleno. +```` + +### §72: after the bullet about objects and arrays + +````markdown +Una extensión registrada en `CONTROL_CBOR` vale en sus dos versiones de schema, salvo que su registro diga otra cosa. +```` + +### §73 + +- Title → «Decisiones canónicas v0.9». +- Replace these entries: + +```text +DKC framing += sin PAYLOAD_LEN; VERSION es el formato de la cápsula: 1 (v0.8.2) + o 2 (v0.9); se escribe solo el 2 + +payload += fichero age v1 estándar completo; en formato 2, su plaintext es el + contenido seguido de ceros hasta P = regla(L) + +payload access += X25519 identity I_PAYLOAD dentro de CONTROL_CBOR, nueva de un CSPRNG + para cada cápsula + +time_and_key += age(tlock → age(X25519 recipient(s) → CONTROL_CBOR)); en formato 2, + exactamente 16 recipients: de 1 a 16 credenciales y señuelos, en + orden aleatorio +``` + +- Add these entries (before «recovery»): + +```text +payload padding += códigos 1 (bloque256) y 2 (reforzado, con Padmé), sin opción sin + relleno; L y el código en las claves 6 y 7 de CONTROL_CBOR versión 2, + L en 8 bytes y como mucho 2^53 − 2^46; comprobado en el paso 17 + (ERR_INTEGRITY); el SDK usa el 2 por defecto + +compatibility += un lector v0.9 abre el formato 1 con la semántica de la v0.8.2; un + lector v0.8.2 rechaza el formato 2 en el paso 2, sin red + +privacy += §55.2: visibles la fecha, access_policy, capsule_id y P; ocultos L, + el número de credenciales y los recipients + +writer rules += §62.1: MUST para formato, credenciales, señuelos, aleatoriedad, L y + SEALED_CONTROL_LEN; SHOULD para autocomprobación y borrado +``` + +### §74 + +L2313 → «El framing base, la ausencia de `PAYLOAD_LEN`, el uso de age files estándar, la identity X25519 cruda de `.dkk`, el formato de extensiones (§54), los 16 huecos de `INNER_ACCESS_AGE` (§39) y las reglas de relleno 1 y 2 con la codificación de L (§29.1, §31) dejan de considerarse provisionales en este borrador.» Then, after the limits paragraph (L2327), add: + +````markdown +Quedan fuera de la v0.9, como trabajo futuro que esta versión no especifica: + +- el formato de un objeto de release y de su fuente de archivo; +- una nueva redacción del modelo de amenazas del proveedor (§7.6); +- una extensión de firma; +- el uso del reloj local en el paso 9.c de §63; +- un tipo de acceso post-cuántico. +```` + +The limits table needs no new row. 16 and L_MAX are normative, not implementation limits. + +### §75 + +- 5 → «5. fixtures oficiales `.dkc` de descifrado/validación, de formato 2 y de compatibilidad de formato 1.» +- 7 → «7. mutation tests completos, en los dos formatos.» +- Add «11. vectores de las reglas de relleno (§29.1).» + +### §76: insert after L2447, before `---` + +Existing entries and the title stay untouched. + +````markdown +### Cambios normativos de la v0.9 + +La v0.9 añade el formato 2 de `.dkc`, que oculta hasta la fecha la longitud exacta del contenido y el número de credenciales, y pasa a texto normativo las reglas del escritor. Sus cambios normativos: + +1. **Formato de la cápsula** (§22, §23, §24, §31, §63, §69.1, §70). `VERSION` del PRELUDE es el formato: 1, el de la v0.8.2, o 2. `CONTROL_CBOR` gana la versión de schema 2, que MUST coincidir con el formato (`ERR_UNSUPPORTED_VERSION`, paso 14). `PUBLIC_HEADER` y la `.dkk` conservan la versión 1. Un escritor MUST escribir el formato 2, y un lector MUST abrir los dos, el 1 con la semántica de la v0.8.2. +2. **16 huecos en `INNER_ACCESS_AGE`** (§33, §36, §38, §39, §63 pasos 12 y 13). En formato 2, `time_and_key` lleva exactamente 16 stanzas X25519: de 1 a 16 credenciales y, en los huecos libres, señuelos, en orden aleatorio. Otro número es `ERR_POLICY_STRUCTURE_MISMATCH` en el paso 12. +3. **Relleno del payload** (§29, §29.1, §30.1, §31, §63 pasos 14 a 18). En formato 2 el plaintext de `PAYLOAD_AGE` es el contenido seguido de ceros hasta P = regla(L), con los códigos 1 (`bloque256`) y 2 (`reforzado`, con Padmé); no hay regla sin relleno. L y el código van sellados en las claves 6 y 7 de `CONTROL_CBOR`, L en 8 bytes y como mucho 2⁵³ − 2⁴⁶. Un plaintext de otra longitud o con un byte de relleno distinto de 0x00 es `ERR_INTEGRITY` en el paso 17, y el lector entrega solo los L primeros bytes. +4. **Consideraciones de privacidad** (§4, §5, §55.2). Qué revela una cápsula, antes y después de la fecha, y a quién. `access_policy` sigue visible, para que el paso 9 pueda fallar sin red. +5. **Reglas del escritor** (§15, §29, §37, §39, §57, §61, §62, §62.1). Orden de escritura sin circularidad; `I_PAYLOAD` nueva de un CSPRNG; de 1 a 16 credenciales, sin repetir, canónicas y no de orden bajo; instante pedido posterior al reloj del escritor; L conocida antes de sellar; autocomprobación y borrado de secretos (SHOULD). + +Ningún código de error es nuevo (§69). + +Casos reproducibles que lo justifican, obtenidos con la implementación de referencia v0.8.2, sus fixtures y las librerías `age` de Go (`filippo.io/age` 1.3.2) y TypeScript (`age-encryption` 0.3.1): + +1. **Número de credenciales.** `SEALED_CONTROL_LEN` (bytes 12 a 15 del PRELUDE) vale 646 en `time_and_key_portable.dkc`, con una credencial, y 842 = 646 + 2·98 en `time_and_key_recipients.dkc`, con tres: cualquiera cuenta las credenciales antes de la fecha, y tras el release en la cabecera de `INNER_ACCESS_AGE`. En formato 2, con la ronda 1000 y sin extensiones de control, vale siempre 2128 en `time_and_key` y 458 en `time_only`. +2. **Longitud del contenido.** `time_only.dkc` mide 78 799 bytes y su `PAYLOAD_AGE`, 78 216 = 184 + 78 000 + 2·16: cualquiera lee L = 78 000. El `PAYLOAD_AGE` de `empty_payload.dkc`, de 200 bytes, da L = 0. En formato 2 con el código 2, un `PAYLOAD_AGE` de 80 088 bytes corresponde a cualquier L entre 77 825 y 79 872. +3. **L de longitud fija.** Con L como entero CBOR en su forma más corta (§58), su valor ocuparía 1, 2, 3, 5 o 9 bytes, y `SEALED_CONTROL_LEN` lo revelaría: en el hueco P = 256, el de los mensajes cortos, distinguiría L < 24, 24 ≤ L ≤ 255 y L = 256; en P = 65 536, L = 65 536 del resto. Con 8 bytes fijos, un `CONTROL_CBOR` de versión 2 sin extensiones mide siempre 103 bytes. +4. **Versión visible.** La mutación «version changed» de `mutations.json` (`[4,1,"02"]` sobre `time_only.dkc`) da `ERR_UNSUPPORTED_VERSION` en el paso 2, sin red, con la implementación de referencia v0.8.2: un lector v0.8.2 rechaza el formato 2 antes de pedir el release. Sin un cambio visible de versión, lo pediría y fallaría en el paso 14 por las claves 6 y 7, desconocidas en su mapa cerrado (§58). Se cambia `VERSION` y no la versión de `PUBLIC_HEADER` porque es la primera comprobación, porque el cambio es de toda la cápsula y la cabecera no cambia, y porque así siguen valiendo los vectores «schema version 2» de `PUBLIC_HEADER` en `cbor.json`, la mutación «header schema version changed» y el ejemplo de §69.1 «Versión 2, una clave desconocida y una DateKey inválida». Un formato 2 con `VERSION` 1 falla en el paso 14 en los dos lectores, por la versión 2 de su control. +5. **Orden de escritura.** §61 y §62 construían el PRELUDE (paso 6) antes que `SEALED_CONTROL` (pasos 9 a 11), cuya longitud lleva el PRELUDE, y `header_binding`, que cubre el PRELUDE, va dentro del control sellado: el orden era circular. `capsule.Encrypt` lo resuelve sellando antes un control provisional de la misma longitud, algo que el texto no decía. +6. **`I_PAYLOAD` nueva.** Si dos cápsulas compartieran `I_PAYLOAD`, «SEALED_CONTROL_A + PAYLOAD_AGE_B» (§64) se abriría en lugar de fallar en el paso 17. §29 solo decía «aleatoria». +7. **Recipients no canónicos y de orden bajo.** `filippo.io/age` y `age-encryption` aceptan un recipient X25519 con el bit 255 a 1 o con u ≥ p y cifran para él un stanza que ninguna identity abre: el salt de HKDF lleva los 32 bytes recibidos, y la identity usa su clave pública canónica. Con un recipient de orden bajo, las dos librerías fallan al cifrar. El lector no puede detectar ninguno de los dos casos. +8. **Credenciales y reloj del escritor.** La referencia ya rechaza un instante pedido que no es posterior a su reloj, `time_only` con credenciales, `time_and_key` sin ellas y un recipient repetido (`capsule.TestEncryptRejectsInvalidOptions`), sin texto normativo que lo exija. Un recipient repetido daría a su identity dos stanzas, y la cápsula fallaría para ella en el paso 13 (mutación «two INNER_ACCESS_AGE stanzas for one recipient»). +9. **Orden de los stanzas.** `age` escribe los stanzas en el orden de sus recipients, y `capsule.Encrypt` añade `R_ACCESS` al final. Con los señuelos detrás de las credenciales, quien abre el stanza k sabría que hay al menos k credenciales. +10. **L conocida antes de sellar.** `capsule.Encrypt` cifra en streaming una fuente de longitud desconocida. En formato 2, L se sella antes que el payload: si la fuente entrega otra longitud, la cápsula fallaría en el paso 17, cuando ya no puede repararse. +11. **Autocomprobación.** El caso 5 de la v0.8.2: `Encrypt` selló un `CONTROL_CBOR` que el lector rechazaba en el paso 14, con la cápsula ya desbloqueada. + +Ningún objeto válido de la v0.8.2 cambia de veredicto. Los cinco fixtures y las mutaciones construidas por el creador, todos de formato 1, quedan como casos de compatibilidad (§70), y ninguno de los 1 825 casos de `inspect_differential.json` produce `VERSION` 2. Cambian estos datos de prueba: + +- la mutación «version changed» pasa a `VERSION` 3 (`[4,1,"03"]`): con `VERSION` 2, ese `time_only.dkc` de formato 1 llega al paso 14 (`ERR_UNSUPPORTED_VERSION`), un caso que pasa a la lista nueva de §64; +- los vectores `control_cbor` de `cbor.json` dependen del formato: «schema version 2» es `ERR_UNSUPPORTED_VERSION` en formato 1 y `ERR_NON_CANONICAL_CBOR` en formato 2, donde le faltan las claves 6 y 7; +- `testdata/README.md` cita esa mutación, la «framing version» y el número de mutaciones de §64. + +Reproducirán cada caso, cuando la implementación de referencia implemente la v0.9, los tests (TODO, nombres provisionales) `capsule.TestFormatDispatch`, `TestFormatRelabel`, `TestFormat1Compatibility`, `TestInnerHasSixteenStanzas`, `TestDummyRecipients`, `TestCredentialBounds`, `TestPaddingRules`, `TestPaddingChecksAtStep17`, `TestPaddingAcrossChunks`, `TestControlLengthIsConstant`, `TestSealedControlLength`, `TestPayloadIdentityReuse` y `TestEncryptSourceLength`; `capsule.TestEncryptRejectsInvalidOptions`, `TestExportedMutationCorpus`, `TestTrustModel` y `TestDecodeControlRejects`, ampliados; y `agewrap.TestNonCanonicalRecipients`. +```` + +### §77: add + +````markdown +- K. Nikitin, L. Barman, W. Lueks, M. Underwood, J.-P. Hubaux, B. Ford — «Reducing Metadata Leakage from Encrypted Files and Communication with PURBs», Proceedings on Privacy Enhancing Technologies 2019(4), pp. 6–33: función de relleno Padmé (§29.1) + https://doi.org/10.2478/popets-2019-0056 + +- RFC 7748 — X25519: codificación de u, recorte de escalares y puntos de orden bajo (§37) +```` + +--- + +## 9. `datekeys.cddl` + +Replace lines 1–4: + +``` +; DateKeys Protocol Specification v0.9 (working draft) - CBOR schemas (RFC +; 8610 CDDL). +; +; Normative companion of spec/DateKeys_Protocol_Specification_v0.9.md. The +; reference implementation g.activething.com/go/DateKeys still implements +; v0.8.2, whose schemas are in tag spec-v0.8.2. These schemas include both +; control versions: 1, of capsule format 1 (v0.8.2), and 2, of format 2. +``` + +Add to the rules block, before the "When bytes break several rules" bullet: + +``` +; - The schema version of a control (key 1) is the format of its capsule, +; the VERSION of the PRELUDE (spec section 22): control-v1 only in format +; 1, control-v2 only in format 2. A decoder picks the rule by the format, +; and another version is ERR_UNSUPPORTED_VERSION, read before the rest of +; the schema (spec section 69.1, layer 2). CDDL cannot express that link. +; - payload-length holds L as an unsigned 64-bit big-endian integer in +; exactly 8 bytes, whatever its value; the value is at most +; max-payload-length (spec section 29.1, 31). A larger value is +; ERR_NON_CANONICAL_CBOR, like any rule of this schema. +``` + +In the last bullet: «then type tag and schema version (keys 0 and 1)» → «then type tag and schema version (keys 0 and 1; for a control, the version of its capsule format)». + +Replace lines 77–86: + +``` +; Spec section 31. Sealed inside OUTER_TIME_AGE (time_only) or inside +; INNER_ACCESS_AGE inside OUTER_TIME_AGE (time_and_key). +control = control-v1 / control-v2 + +; Capsule format 1 (v0.8.2). +control-v1 = { + 0 => "datekeys-control", + 1 => 1, + 2 => bstr .size 32, ; header_binding + 3 => bstr .size 32, ; payload_identity, raw X25519 identity I_PAYLOAD + ? 4 => extensions, ; critical_extensions + ? 5 => extensions, ; noncritical_extensions +} + +; Capsule format 2 (v0.9). 103 bytes without extensions. +control-v2 = { + 0 => "datekeys-control", + 1 => 2, + 2 => bstr .size 32, ; header_binding + 3 => bstr .size 32, ; payload_identity, raw X25519 identity I_PAYLOAD + ? 4 => extensions, ; critical_extensions + ? 5 => extensions, ; noncritical_extensions + 6 => payload-length, ; L, the length of the content (spec section 29.1) + 7 => padding-scheme, ; the padding rule of PAYLOAD_AGE (spec section 29.1) +} + +; Fixed width, so that the length of CONTROL_CBOR, visible in +; SEALED_CONTROL_LEN, never depends on L (spec section 55.2). Value at most +; max-payload-length (see the rules above). +payload-length = bstr .size 8 +padding-scheme = &(bloque256: 1, reforzado: 2) + +; 2^53 - 2^46: the largest L whose padded length P stays within +; max-safe-uint under both padding rules (spec section 29.1). +max-payload-length = 8936830510563328 +``` + +Change the comment at L65–66 to add: «The same header, schema version 1, in both capsule formats.» + +--- + +## 10. `spec/README.md` + +Keep the v0.8.2 entry. Add after it: + +```markdown +- `DateKeys_Protocol_Specification_v0.9.md`: working draft v0.9 (29 September + 2026), not frozen and not yet implemented: the reference implementation and + the fixtures still follow v0.8.2. It adds capsule format 2 (PRELUDE VERSION + 2): exactly 16 recipient slots in INNER_ACCESS_AGE, filled with dummy + recipients in random order; padding of the PAYLOAD_AGE plaintext (codes 1, + bloque256, and 2, reforzado with Padmé), with the real length and the code + sealed in CONTROL_CBOR version 2; privacy considerations; and normative + writer rules. Readers keep opening format 1 with v0.8.2 semantics. The + changes and their reproducible cases are in its §76. +``` + +Replace the CDDL bullet: + +```markdown +- `datekeys.cddl`: the CBOR schemas of the v0.9 draft, both control versions + included, with the encoding rules CDDL cannot express. The schemas of + v0.8.2 as implemented are in tag `spec-v0.8.2`. +``` + +The text «draft v0.9 (» is what `TestSpecVersionNamesTheSpecification` will need once `SpecVersion` becomes "0.9". The v0.8.2 entry keeps the current test green. + +--- + +## 11. Tests and test data (to be generated later by the Go reference) + +**`mutations.json`.** "version changed" becomes `[4,1,"03"]` (step 2, `network:false`). New cases, all `spec:true`, all with `network:true` except `VERSION` 3: + +| Name | Base / built by | Code | Step | +|---|---|---|---| +| format 1 time_only relabeled format 2 | `time_only.dkc` `[4,1,"02"]` | `ERR_UNSUPPORTED_VERSION` | 14 | +| format 1 time_and_key relabeled format 2 | `time_and_key_portable.dkc` + `.dkk`, `[4,1,"02"]` | `ERR_POLICY_STRUCTURE_MISMATCH` | 12 | +| format 2 time_only relabeled format 1 | `format2_time_only.dkc` `[4,1,"01"]` | `ERR_UNSUPPORTED_VERSION` | 14 | +| format 2 time_and_key relabeled format 1 | `format2_time_and_key_portable.dkc` + `.dkk` | `ERR_UNSUPPORTED_VERSION` | 14 | +| 15 / 17 INNER_ACCESS_AGE stanzas | frozen, creator | `ERR_POLICY_STRUCTURE_MISMATCH` | 12 | +| two of the 16 stanzas for one recipient | frozen, creator | `ERR_POLICY_STRUCTURE_MISMATCH` | 13 | +| identity that is not a recipient of the 16 | `format2_time_and_key_portable.dkc`, foreign identity | `ERR_ACCESS_INVALID` | 13 | +| control v2 without key 6 / key 7 | frozen, resealed, PRELUDE recomputed | `ERR_NON_CANONICAL_CBOR` | 14 | +| padding code 0 / 3 | frozen, resealed | `ERR_NON_CANONICAL_CBOR` | 14 | +| payload_length 2^53 − 2^46 + 1 | frozen, resealed | `ERR_NON_CANONICAL_CBOR` | 14 | +| non-zero last padding byte | frozen, `I_PAYLOAD` | `ERR_INTEGRITY` | 17 | +| payload plaintext of P − 1 / P + 256 bytes / without padding | frozen, `I_PAYLOAD` | `ERR_INTEGRITY` | 17 | +| padding code 2 changed to 1, L = 78000 | frozen, resealed | `ERR_INTEGRITY` | 17 | +| payload_length L − 1, last content byte non-zero | frozen, resealed | `ERR_INTEGRITY` | 17 | + +Also add the §69.1 precedence examples as reference tests (`TestPrecedenceAcrossSteps`, extended). + +**`cbor.json`:** +- control_cbor vectors gain `"format"` (default 1). +- New format-2 vectors: + - accepted: minimal (103 bytes, hex in §2), both extension arrays, L = 0, L = L_MAX; + - rejected with `ERR_NON_CANONICAL_CBOR`: L = L_MAX + 1, L = 2⁶⁴ − 1, `payload_length` of 7 or 9 bytes, `payload_length` as a uint, padding 0, padding 3, padding as a bstr, missing key 6, missing key 7, and "padding 3 and an unknown critical extension" (layer 3 before layer 4); + - rejected with `ERR_UNSUPPORTED_VERSION`: "format 2: schema version 1 with keys 6 and 7", "format 2: version 3", "format 1: version 2 with keys 6 and 7". +- Also "format 1: version 1 with keys 6 and 7" (`ERR_NON_CANONICAL_CBOR`). +- "unknown key 6" keeps its verdict in format 1. + +**`testdata/vectors/padding.json`** (new): `{L, bloque256, reforzado, payload_age_length}` for every row of §3.4, plus `l_max`, and informational E, S and lastBits. + +**Fixtures (format 2, round 1000):** +- `format2_time_only.dkc`: code 2, L = 78000, P = 79872. +- `format2_time_only_bloque256.dkc`: same content, code 1, P = 78080. +- `format2_empty_payload.dkc`: L = 0, P = 256. +- `format2_time_only_extensions.dkc`: header and control extensions. +- `format2_time_and_key_portable.dkc` and `.dkk`: 1 credential and 15 dummies. +- `format2_time_and_key_recipients.dkc` and `.dkk`: 3 recipients plus a portable key, 12 dummies. +- `format2_time_and_key_sixteen.dkc`: 16 recipients, no dummies. + +Sidecars gain `format`, `payload_length`, `padding`, `padded_length`, and the stanza index each credential opens. Padding that spans chunks (L = 5 000 000, code 2 → P = 5 111 808, a full chunk of zeros) is better as a test generated at run time than as a 5 MB fixture. + +**Unchanged:** the 5 format-1 fixtures and the crafted format-1 mutations, and `inspect_differential.json`. None of its 1825 cases yields `VERSION` 2 (its byte-4 edits give a7, 05, e8 and 20). Format-2 bases, if wanted, go in a new block. + +--- + +## 12. Points for the author to confirm + +1. **Encoding of L:** `bstr .size 8` (recommended) instead of a CBOR `uint`, which would add the width leak to §55.2. +2. **Reader and format:** readers SHOULD report the format (format 1 leaks more). Alternatively MAY. +3. **§4 and §5:** goal 8 and the new non-goal are optional but short. +4. **Test builders:** "MUST NOT write format 1" binds conforming writers. New format-1 test objects, if any, come from a test-only builder. +5. **Fixture names:** the `format2_` prefix is a proposal. \ No newline at end of file diff --git a/spec_v0.9/report.md b/spec_v0.9/report.md new file mode 100644 index 0000000..79e7be7 --- /dev/null +++ b/spec_v0.9/report.md @@ -0,0 +1,73 @@ +The v0.9 draft is written in `spec/DateKeys_Protocol_Specification_v0.9.md` (title "Borrador normativo v0.9", date 29 septiembre 2026) and in `spec/datekeys.cddl`. Nothing is committed. `spec/README.md`, testdata, Go and TS are untouched. The two Go tests that read the spec still pass, because they read the v0.8.2 file. The existing §76 text is byte-identical to v0.8.2. + +I rechecked the design's numbers and the critics' claims before writing: +- **Padding:** every value recomputes exactly, and the stated properties hold for L from 0 to 300 000 and for 200 000 random L up to L_MAX. +- **Fixture lengths:** all 5 fixtures match the length formulas. +- **JS arithmetic:** 32-bit operators first give a wrong P at L = 2³² + 1, and in Node `Math.log2(2^49 − 1)` returns 49 instead of 48. +- **Recipients:** with a recipient whose bit 255 is set, both `filippo.io/age` 1.3.2 and `age-encryption` 0.3.1 encrypt a stanza that the identity cannot open. With the zero point (low order), both refuse to encrypt. + +## Sections changed +- **Top of the document:** title, date, §1, §4 (goal 8), §5 (new non-goal), §15. +- **Format and header:** §22 (table mapping each format to its control version), §23, §24, §26, §27, §28.1. +- **Padding:** §29, new §29.1 «Relleno del payload», §30, §30.1. +- **Control and slots:** §31 (keys 6 and 7, the 103-byte control), §33, §36, §36.1, §37, §38, §39 (rewritten, defines «credencial» and «señuelo»), §40. +- **Privacy and limits:** §55.1, new §55.2 «Consideraciones de privacidad», §56, §57, §58, §58.1. +- **Writing:** §61, §62, new §62.1 «Reglas del escritor». +- **Reading and tests:** §63 steps 2, 4, 12, 13, 14, 16, 17 and 18, §64, §67, §68. +- **Precedence and compatibility:** §69.1 (layers 1 to 3, the cross-object paragraph, Alcance, 7 new examples), §70, §72. +- **Closing sections:** §73, §74 (with a new «Trabajo futuro» part), §75, new «Cambios normativos de la v0.9» at the end of §76 (13 numbered items, each with Cambio / Motivo / Caso / Tests), §77. +- **Unchanged:** §65, §66 and §69. +- **CDDL:** new header; `control = control-v1 / control-v2`; `payload-length`, `padding-scheme` and `max-payload-length`; the rule linking control version to format, and the 8-byte reading rule. + +## Where I followed a critic over the design +1. **Relabel tests (both critics):** the `time_and_key` relabel cases offer the identity, not the `.dkk`. A `.dkk` carries a `capsule_digest`, and editing the VERSION byte breaks it, so those cases would fail earlier (step 9.a, `ERR_ACCESS_INVALID`). I qualified §26 and §70 the same way, added a §69.1 example for the `.dkk` case, and planned `spec:false` companion cases. +2. **Downgrade claim scoped (both):** §70 now says only a capsule whose control was not resealed never outputs its padding. Resealing by someone who knows `I_PAYLOAD` is described as a rewrite, not a downgrade. +3. **Format table, not "control version equals VERSION" (consistency):** §22 has the table and says a new padding code needs a new format. I dropped the clause «salvo que su registro diga otra cosa» from §72. +4. **Padding arithmetic (security):** rows above 2³² added to §29.1; a MUST NOT on 32-bit operations and `Math.clz32`; the float example is now 2⁴⁹ − 1, which is inside the range; a planned accept vector `48 0000000100000001`. +5. **Dummies (security):** + - A dummy's private key is stated to be a full credential while it exists, with a note on how to generate and wipe it. + - The permutation and which slots are dummies are MUST NOT stored or delivered. + - The shuffle rule binds the written header order, unbiased (Fisher–Yates). + - The indistinguishability argument now rests on age's recipient anonymity under the Diffie–Hellman assumption, not on the distribution of keys. +6. **Readers (both):** L and P MUST NOT drive memory or disk reservation; a MAY check of the `PAYLOAD_AGE` length at step 17, with the same code; §56 gets a MUST NOT for format 2. +7. **`I_PAYLOAD` reason (security):** the stated reason is now confidentiality (shared, content-derived or master-derived keys), not only the binding. +8. **§55.2 additions (security):** + - a subsection on what format 2 makes impossible to check; + - the quantum caveat; + - whether a portable `.dkk` exists; + - the fixed 256-byte bucket of `bloque256`; + - what VERSION 1 and the choice of code reveal. +9. **Writers (consistency):** a writer MAY spool the source to learn L and MUST NOT guess it; "abortar" became "report the error, and the receiver MUST discard the output". The self-check also counts P and checks the `PAYLOAD_AGE` length (security). +10. **Smaller corrections:** + - §62.1 fixture note (C = 127 for `time_only_extensions`); + - «como mucho 256»; + - format 1 «contiene (MUST, §33)»; + - one definition of «credencial»; + - §69.1 cross-object list now includes the control version; + - §74 no longer contradicts itself (the CONTROL_CBOR schema stays open, L stays fixed-width); + - the precedence case "padding 3 + unknown critical extension" is a step test, not a `cbor.json` vector; + - forward references are marked "(por generar)" / "(por implementar)". + +## Rules I added that were not in D1–D7 (please confirm) +- L_MAX = 2⁵³ − 2⁴⁶, with a reader rejection (`ERR_NON_CANONICAL_CBOR`). +- The MUST for a uniform, unbiased stanza order. +- The MAY to reject twist points (the Legendre check in §37). The security critic offered SHOULD or "the list is not exhaustive"; I wrote the latter plus a MAY. +- The official SDK SHOULD show the effective round time, and MAY compare it with the latest published round when online (§62.1 rule 2). +- The MAY check of the `PAYLOAD_AGE` length at step 17. +- The MUST NOT on presenting content before step 17 ends (§56). + +## Open questions for the author +1. Is `bstr .size 8` for L acceptable, instead of a CBOR `uint` (which would leak L's width through the control length)? +2. Should readers report the format as SHOULD (as written) or MAY? +3. Keep goal 8 in §4 and the new non-goal in §5? +4. Any new format-1 test objects need a test-only builder, since conforming writers MUST NOT write format 1. Is that acceptable? +5. The `format2_` prefix for the planned fixture names. +6. Optional change, not written: a dummy could be a random valid public key (a canonical u that is not low order), so no private key ever exists. D1 fixes the mechanism, so this needs your approval. +7. Twist points: keep MAY, or make it SHOULD? + +## Notes +- **README:** the design also proposed edits to `spec/README.md`; I left them out because the task forbids touching that file. It will need the v0.9 entry once `SpecVersion` becomes "0.9". +- **Effects on the TS phase 3 plan** (from the consistency critic, not written into the spec): + - `R_ACCESS` must move from last place to the shuffle. + - The "1 024 recipients" test becomes a rejection above 16. + - `I_PAYLOAD` should be wiped after the new self-check, not right after sealing. \ No newline at end of file diff --git a/spec_v0.9/review.md b/spec_v0.9/review.md new file mode 100644 index 0000000..68afaaf --- /dev/null +++ b/spec_v0.9/review.md @@ -0,0 +1,72 @@ +- **spec/DateKeys_Protocol_Specification_v0.9.md:955 (and §76 item 4, :2934). The claim about 32-bit arithmetic is false, and the vectors would not catch the fault.** The text says 32-bit operations "dan la P correcta hasta L = 2³² − 1". I checked this in Node against a BigInt Padmé: + - With JavaScript's signed bitwise operators, P is first wrong at L = 2 113 929 217: they give 2 113 929 472 instead of 2 147 483 648. + - With `>>> 0`, P is first wrong at L = 4 227 858 433: 4 227 858 688 instead of 4 294 967 296. + + The vector table (:968–984) has no row in that range. Row 2³² − 1 passes only by coincidence. So a TS writer using 32-bit operations would pass every planned vector and still produce capsules of about 2–4 GB that correct readers reject at step 17, after the date. + **Fix:** change the claim to "dan una P incorrecta desde L = 2 113 929 217 con los operadores con signo, y desde L = 4 227 858 433 con `>>> 0`". Add two rows to the table and to `padding.json`: + + | L | P código 1 | P código 2 | PAYLOAD_AGE 1 / 2 | + |---|---|---|---| + | 2 113 929 217 | 2 113 929 472 | 2 147 483 648 | 2 114 445 768 / 2 148 008 120 | + | 4 227 858 433 | 4 227 858 688 | 4 294 967 296 | 4 228 891 080 / 4 296 016 056 | + + In §76 item 4, change "da la P correcta en todas las filas … hasta 2³² − 1" to match. + +- **spec/DateKeys_Protocol_Specification_v0.9.md:1773 (§56). The new MUST NOT conflicts with streaming readers.** It says a reader "MUST NOT entregar … los L primeros bytes … antes de que el paso 17 termine". This goes beyond D3, which only keeps the commit at step 18. It forbids the streaming `Open(dst)` design of the reference implementation (`capsule/open.go:85-88`: dst may hold partial plaintext, which is discarded on error). It also contradicts step 17's "lector en streaming" and the SHOULD earlier in §56. + **Fix:** "MUST NOT presentar como válidos …; un lector que escribe el contenido en streaming MUST NOT escribir el relleno y MUST señalar el error del paso 17 para que se descarte lo escrito (§56)". Or keep the MUST NOT and list it among the rules added outside D1–D7 for the author to confirm. + +- **spec/DateKeys_Protocol_Specification_v0.9.md:2363 (§64). Two format-2 mutations cannot be reproduced as written.** + - "INNER_ACCESS_AGE con 15/17 stanzas" changes `SEALED_CONTROL_LEN` by ±98. Only the CONTROL mutations are said to recalculate the PRELUDE. Without that, these cases fail at step 5 or 6 with `ERR_INTEGRITY`, not at step 12. + - "16 stanzas, dos para un mismo recipient" (:2347) does not say which identity is offered. + - "Las de `time_and_key` ofrecen la identity" is justified only by "el byte editado". But any change to the `.dkc` breaks `capsule_digest`. + + **Fix:** + - "las que cambian la longitud de `SEALED_CONTROL`, también las de 15 y 17 stanzas, recalculan el PRELUDE y `header_binding`"; + - ":2347 → «…, y la identity de ese recipient»"; + - "todas las de `time_and_key` ofrecen la identity: cualquier cambio del `.dkc` rompe el `capsule_digest` de la `.dkk`". + +- **spec/README.md:1-17. The README is now out of date.** It lists only v0.8.2 and describes `datekeys.cddl` as "the CBOR schemas of the specification as implemented". The CDDL now holds the v0.9 draft (`control-v2`), which nothing implements. The task lists this file as one to edit. **Fix:** add a v0.9 working-draft entry, and describe `datekeys.cddl` as the v0.9 draft, with the v0.8.2 schemas at tag `spec-v0.8.2`. + +- **spec/DateKeys_Protocol_Specification_v0.9.md:2918 (§76 item 1, Motivo). The reason given is inaccurate.** With control version 2, a v0.8.2 reader fails at step 14, layer 2, with `ERR_UNSUPPORTED_VERSION` for that version. It never reaches keys 6 and 7; they would matter only if the control had stayed at version 1. **Fix:** "…y fallaría allí, tras la petición de red, por la versión 2 de su control (o, si el control conservara la versión 1, por las claves 6 y 7, desconocidas en su mapa cerrado)". + +- **spec/DateKeys_Protocol_Specification_v0.9.md:986 (§29.1). The sentence about the rows above 2³² is false for L_MAX and contradicts §76 item 4.** It says "en ellas `Padme` supera a `bloque256`". In the L_MAX row, Padmé equals bloque256 (both are L_MAX), and §76 item 4 says so. **Fix:** "en ellas, salvo L_MAX, `Padme` supera a `bloque256`". + +- **spec/DateKeys_Protocol_Specification_v0.9.md:954. S does not change.** The text says the Node `Math.log2` error changes "E, S y lastBits". I checked every failing L: E moves from 48–51 to 49–52, and `bitlen` is 6 in all cases, so S stays 6. **Fix:** "no cambia P ni S, pero sí E y lastBits". + +- **spec/DateKeys_Protocol_Specification_v0.9.md:654 (§22). "no detectaría" is inaccurate.** A v0.9 reader does detect an unknown padding code, at step 14 with `ERR_NON_CANONICAL_CBOR`, but only after the network request. **Fix:** "que un lector anterior solo detectaría después de pedir el release". + +- **spec/DateKeys_Protocol_Specification_v0.9.md:1314 (§39) against :2427 and :2430 (§67). §39 has no exception for vectors.** §39 says which slots are dummies MUST NOT be stored or delivered. The official vectors record which stanza each credential opens, which reveals the dummies. **Fix:** add to §39 "salvo en los vectores oficiales de prueba (§67)". + +- **spec/DateKeys_Protocol_Specification_v0.9.md:2557 (§70). The writing rule binds every implementation.** "MUST escribir el formato 2" also binds read-only implementations and the test-only builders of format-1 fixtures (the writer's open question 4). **Fix:** "una implementación que escribe cápsulas MUST escribir el formato 2 …; solo un generador de vectores de prueba MAY escribir el formato 1". + +- **spec/DateKeys_Protocol_Specification_v0.9.md:2954 (§76 item 8). Wrong step numbers.** In v0.8.2, `SEALED_CONTROL` is built at steps 9–10 of §61 and at steps 9–11 of §62. **Fix:** "(pasos 9 y 10 de §61, 9 a 11 de §62)". + +- **spec/DateKeys_Protocol_Specification_v0.9.md:650 (§22). Wrong section reference.** The `.dkk` schema version is in §41; §40 is its framing. **Fix:** "(§24, §41)". + +- **spec/DateKeys_Protocol_Specification_v0.9.md:1749 (§55.2). Wrong section reference.** Relays are §48; §46 is the Release Queue. **Fix:** "(§45, §48)". + +- **spec/DateKeys_Protocol_Specification_v0.9.md:1279 (§37). The claim is too strong.** "Un lector no puede detectar ninguno de los dos casos" is not strictly true for low order: a reader could try the few low-order encodings with a zero shared secret. **Fix:** "Las reglas de §63 no detectan ninguno de los dos casos". + +- **spec/DateKeys_Protocol_Specification_v0.9.md:1743 (§55.2). Missing exception.** "En formato 2 nadie puede saber cuántas partes…" should except the writer. **Fix:** "nadie salvo el escritor". + +- **spec/DateKeys_Protocol_Specification_v0.9.md:2037 (§62.1 rule 7). Wording.** "así que esa longitud se conoce antes de calcular `header_binding`" reads as a description, but it is a requirement. **Fix:** "así que esa longitud debe conocerse antes…". + +- **spec/DateKeys_Protocol_Specification_v0.9.md:2047 (§62.1 rule 11). Forward reference.** `c(P)` is used before the note at :2052 defines it. **Fix:** write `16·max(1, ⌈P / 65536⌉)`, or add "(nota de abajo)". + +- **spec/DateKeys_Protocol_Specification_v0.9.md:1989 (§62 step 8). Missing check.** Step 8 lacks the "Más de 64 MiB → error (§57)" that §61 step 6 has. **Fix:** add it. + +- **spec/DateKeys_Protocol_Specification_v0.9.md:2077 (§63 step 4). Formatting.** The line is 104 characters long inside the step block, where the other lines wrap at about 72. **Fix:** rewrap. + +- **spec/DateKeys_Protocol_Specification_v0.9.md:2970 (§76 item 11). Inconsistent marking.** "`capsule.TestEncryptRejectsInvalidOptions`, ampliado," lacks the "(por implementar)" that the other extended tests carry. **Fix:** add it. + +- **spec/DateKeys_Protocol_Specification_v0.9.md:2336-2361 and §76 (:2987). No reject cases for the shape of `payload_length`.** The test lists have no case for a `payload_length` of 7 or 9 bytes, or encoded as a CBOR uint, all of which should be `ERR_NON_CANONICAL_CBOR` at step 14. **Fix:** add them to §64, or to the new format-2 `cbor.json` vectors listed in §76. + +- **spec/datekeys.cddl:114-115. Field names missing from the comments.** The key 6 and key 7 comments do not name the fields as the text does (`payload_length`, `padding`). **Fix:** "; payload_length, L, …" and "; padding, the padding code …". + +**Checked and found correct:** +- **Decisions:** D1–D7 are all present and match the approved decisions. The out-of-scope items appear only under Trabajo futuro in §74. +- **§76:** the existing text is byte-identical to v0.8.2. §65, §66 and §69 are unchanged. +- **Version 1:** behaviour is unchanged, except for the acknowledged change to objects relabelled to `VERSION` 2. +- **Numbers:** all padding vectors, L_MAX, the 103-byte control, the length formulas and the fixture lengths (446, 482, 646, 842, 78 216, 200) recompute. +- **Test data:** the "1 825 cases, byte 4 → a7, 05, e8, 20" claim, the fixture stages 16→18, and the existence of the cited tests and mutations all match the repository. +- **Precedence and CDDL:** every new example in §69.1 is consistent with §63 and the layers. The CDDL matches the text. \ No newline at end of file diff --git a/spec_v0.9/security.md b/spec_v0.9/security.md new file mode 100644 index 0000000..2bfae67 --- /dev/null +++ b/spec_v0.9/security.md @@ -0,0 +1,121 @@ +# Adversarial review of the v0.9 design (security and privacy) + +Paths: Go repo `G:\bussines\datekeys\datekeys-go` (spec lines refer to `spec\DateKeys_Protocol_Specification_v0.9.md`, which is still the same as v0.8.2). TS library: `G:\bussines\datekeys\App\node_modules\age-encryption` 0.3.1. I edited no files. + +## Corrections, most serious first + +1. **The padding vectors miss the 32-bit bug that §29.1 warns about. Readers could disagree, and capsules over 4 GiB could be lost.** + - **Evidence:** I simulated a JS Padme built with 32-bit operators (`((L+mask)&~mask)>>>0`) inside `max(bloque256, ·)`. + - It gives the right P for every row of the proposed §29.1 table and `padding.json`: 0 … 2³²−1 and L_MAX. + - The reason: at 2³²−1 and at L_MAX, Padme equals `bloque256`, and `max` hides the wrong Padme. At 6·10⁸ and 10⁹ the 32-bit operators are still exact. + - The first wrong value is at L = 2³²+1: it gives 4294967552 where the right value is 4362076160. + - A `bitlen` built as `31 − Math.clz32(L)` fails the same way. + - **Consequence:** a TS writer with this bug declares code 2 (the product default) but pads only to `bloque256`. + - Go rejects that capsule at step 17 with `ERR_INTEGRITY`, after the date, when it can no longer be repaired. + - The TS reader, with the same bug, opens it. + - **Fix:** add rows above 2³² where Padme > `bloque256`, to §29.1 and `padding.json`: + + | L | P, code 1 | P, code 2 | `PAYLOAD_AGE`, code 1 / 2 | + |---|---|---|---| + | 2³² + 1 | 4 294 967 552 | 4 362 076 160 | 4 296 016 328 / 4 363 141 304 | + | 5·10⁹ | 5 000 000 000 | 5 033 164 800 | 5 001 220 888 / 5 034 393 784 | + | 10¹² | 1 000 000 000 000 | 1 005 022 347 264 | 1 000 244 140 824 / 1 005 267 714 232 | + | 2⁵² + 1 | 4 503 599 627 370 752 | 4 573 968 371 548 160 | 4 504 699 138 998 728 / 4 575 085 063 045 304 | + + - **Also:** + - Add to «Aritmética»: «`bitlen` MUST NOT calcularse con `Math.clz32` ni con operaciones de 32 bits». + - Add a `cbor.json` accept vector with `payload_length` = `48 0000000100000001`, which must decode to 4294967297. It catches readers that read only the low 4 bytes. + +2. **A dummy's private key is a full credential. The spec treats it as privacy hygiene.** + - **Why:** a dummy stanza wraps the real `FK_ACCESS`. Anyone who holds, predicts or recovers a dummy's private key opens the `time_and_key` capsule. + - **§39 should say so:** «Un señuelo envuelve la misma `FK_ACCESS`: su clave privada abre la cápsula como una credencial hasta que se borra». + - **The approved "discard at once" cannot be done through the age APIs:** + - TS `generateX25519Identity()` returns an immutable `"AGE-SECRET-KEY-1…"` string (`recipients.js:34-38`). + - Go `RawX25519Identity` goes through `id.String()` (`agewrap/agewrap.go:431-436`). + - Proposed informative note: generate the scalar in a buffer that can be wiped, compute X25519(k, 9), wipe the buffer, and give only the recipient to age. + - **Also a MUST:** «MUST NOT almacenar, registrar ni entregar la permutación ni qué huecos son señuelos». That map reveals the number of credentials. The sidecars' "stanza index each credential opens" is fine only in test data. + - **Optional, needs your approval** (D1 fixes the mechanism): + - A dummy could be a uniformly random canonical u that is not of low order. Then no private key ever exists. + - It stays indistinguishable, because R never appears in the stanza (`age x25519.go:80-87`). + +3. **The relabel tests for `time_and_key` stop at the optional `capsule_digest`. They never reach the control-version guard.** + - **Evidence:** + - §11 builds these cases with "+ .dkk". + - The v0.8.2 `.dkk` carries `capsule_digest` (`testdata/fixtures/time_and_key_portable.dkk.json`), and `Encrypt` always writes one (`capsule/encrypt.go:232`). + - `Open` checks it when the input is seekable (`capsule/open.go:140-146`). The result is `ERR_ACCESS_INVALID` at step 9, with no network: see the mutation "capsule_digest of the .dkk does not match" (`mutations.json` ~L1086, `spec:false`). + - §69.1 «Alcance» (L2108) says the official vectors assume every optional check runs. So the expected codes "step 12" and "step 14" contradict the spec. + - The step-14 guard, which is the real anti-downgrade defense, would stay untested for `time_and_key`. + - **Fix:** + - Pass the credential as `identities` (the `AGE-SECRET-KEY` form of `access_material`), as "access_policy=time_and_key with time_only structure" already does. + - In §64, write «con la identity, sin `capsule_digest`». + - Add a `spec:false` companion case with the `.dkk`: `ERR_ACCESS_INVALID`, step 9. + +4. **"En ningún caso entrega el contenido con su relleno" (§70) and "No path outputs padding as content" (§1.3) claim too much.** + - **Who can do it:** anyone who knows `I_PAYLOAD` and can seal a control. That is anyone after the date in `time_only`, and any credential holder in `time_and_key`. + - In `time_and_key`, the holder keeps the 16 stanzas and recomputes the INNER MAC and STREAM with `FK_ACCESS`. + - `OUTER` only needs the public tlock key. + - **What they can build:** a format-1 capsule with the same `PAYLOAD_AGE` (`VERSION` 1, control v1, recomputed `header_binding`). Every reader opens it as content followed by zeros. + - This is the rewriter case of §55.1, but the text must be scoped. Proposed wording: + > «…salvo que quien ya conoce `I_PAYLOAD` selle otro control de formato 1 (§55.1): es una reescritura, no un downgrade. `VERSION` no está autenticado hasta los pasos 14 y 15; lo que impide el downgrade es la versión de `CONTROL_CBOR`, autenticada por los MAC de `age` frente a quien no conoce la file key, y `header_binding`.» + +5. **The reason given for a fresh `I_PAYLOAD` is too weak.** + - The proposed §29 text and §76 case 6 give only the binding of §30.1. The real threat is confidentiality. + - **Shared `I_PAYLOAD`:** opening capsule A (by anyone, at A's date, in `time_only`) opens B's payload before B's date. + - **Derived from the content:** the `PAYLOAD_AGE` stanza is visible before the date. Anyone can confirm a guessed content and decrypt it early. + - **Derived from a master secret:** that one secret opens every payload, whatever the date. + - **Fix:** write this in §29, §62.1 rule 5 and §76 case 6. The same argument applies to dummies. + +6. **Loss of auditability is not documented.** + - In format 1, a holder who expected n recipients could count the stanzas. In format 2 nobody can tell how many parties can open the capsule. + - Nobody can check the MUST NOT on storing dummy keys. + - A compromised SDK (§7.5) or creator device (§7.8) can keep a dummy key as a hidden credential, and nobody would notice. + - **Fix:** add this to §55.2 (for example «Lo que el formato 2 impide comprobar») and cite it from §39. + +7. **The shuffle MUST should bind the resulting header, not the call into age.** + - Both libraries keep recipient order: Go `age.go:125-137`, TS `index.js` `encrypt`. The reference appends `R_ACCESS` last (`capsule/encrypt.go:289-295`). + - A library that sorts or groups stanzas would silently break the rule. + - **Fix:** «El orden de los 16 stanzas en la cabecera MUST ser una permutación uniformemente aleatoria, sin sesgo (p. ej. Fisher–Yates con muestreo por rechazo), independiente de qué huecos son credenciales y del orden de entrada.» + +8. **Gaps in §55.2 (D6):** + - **"Nunca visible" holds only under X25519 Diffie–Hellman.** A future quantum adversary (§7.7) who keeps the `.dkc` can recover the ephemeral scalars. With candidate public keys, it can test each stanza and identify real recipients. + - **Add to "Oculto":** whether a portable `.dkk` exists, and which stanza is its own. + - **`bloque256` gives a 256-byte bucket at any size,** so a known public file can be recognised by its size. Also, a P that `reforzado` does not produce reveals a non-default writer. + - **`VERSION` 1** reveals a writer from before v0.9. + - **"Número de credenciales" under "Oculto":** say it applies to `time_and_key`. In `time_only` it is 0, and `access_policy` shows that. + +9. **L and P can be used for denial of service.** + - Anyone can seal a `time_only` control (§36.1) that declares L = L_MAX with a 456-byte `PAYLOAD_AGE`. + - **Add to §57:** + - «L y P MUST NOT usarse para reservar memoria ni disco antes de recibir el plaintext». + - Optionally: MAY check `|PAYLOAD_AGE|` = 184 + P + 16·c(P) at step 17, after the stanza rules. It has the same code. + +10. **§56 needs an RFC keyword for format 2.** + - In format 2 the padding failure is found only after all L content bytes have gone out. A non-transactional reader, such as a caller of `Open` that ignores the error (`capsule/open.go:85-88, 251-258`), is left holding complete-looking content from an invalid object. + - The proposed «no pueden presentarse» has no keyword. Use: «En formato 2 un lector MUST NOT entregar ni presentar como válidos los L primeros bytes hasta que el paso 17 termine sin error». + +11. **The writer self-check does not catch missing padding.** + - «`I_PAYLOAD` abre la cabecera» passes even when padding is missing. That bug leaks L exactly, and after the date the capsule fails at step 17. + - **Add as a SHOULD:** count the plaintext bytes handed to age (= P) and check that `|PAYLOAD_AGE|` = 184 + P + 16·c(P). + +12. **The float `log2` rationale needs a better example.** + - `log2(2⁵³−1)` is outside [0, L_MAX]. Inside the range, the first wrong result is at L = 2⁴⁹−1 (`Math.log2` gives 49; the right value is 48). + - I checked k = 9..52 with offsets ±40 around 2^k: the wrong E never changes P, because both roundings reach 2^k. + - Keep the MUST (the E, S and lastBits vectors), but use L = 2⁵²−1 (in range) as the example. Say that the real risk to P is 32-bit operators (item 1). + +13. **Twist points are missing from the recipient rules.** A canonical u on the twist is nobody's public key, so its stanza cannot be opened. This is the same reason as for non-canonical keys. Add it as a SHOULD (Legendre check), or say that the §37 list is not exhaustive. + +14. **The writer-clock MUST only catches user error.** A writer whose clock is late can seal to a round that is already published. Add: the SDK SHOULD show the effective round time, and MAY compare the round with the latest published one when it is online. + +15. **§39 «misma distribución que la de una identity real» is misleading.** Indistinguishability does not depend on how R is distributed, because R is not in the stanza. Replace it with the actual assumption: age X25519 anonymity (`x25519.go:28-29`) under Diffie–Hellman. + +## Checked, no correction needed + +- **Version check before the network:** v0.8.2 rejects `VERSION` 2 at step 2 with no network (`capsule/framing.go:110`; `mutations.json` "version changed", `network:false`). Relabelling with the original control fails at step 14 in both reader versions. +- **Padding determinism:** zero bytes, P = rule(L) and plaintext length = P give one valid plaintext per control and payload. Without `I_PAYLOAD` or a file key nobody can change it. +- **No new covert channel of any size:** the only one is the code choice, and P shows it only sometimes. It is negligible next to `capsule_id`. +- **Padme arithmetic:** every value in §3.4 reproduces. `reforzado(L_MAX+1)` = 2⁵³ exactly. The L ≤ 256 branch avoids log2(0). + - For L up to 300 000, P is monotone, P ≥ L, P is a multiple of 256, and rule(P) = P. + - Comparing the 8-byte L with L_MAX is safe even as hi·2³² + lo in doubles. +- **Credential count:** the fixtures' `SEALED_CONTROL_LEN` (446 / 646 / 842) and `PAYLOAD_AGE` lengths match the formulas (98 bytes per stanza). In format 2 it is constant (2128 / 458), and the minimal control is 103 bytes. +- **Timing:** the reader tries every identity against every stanza (`agewrap/agewrap.go:385-403`), so timing does not reveal the stanza index. +- **Low-order keys:** both age libraries refuse to encrypt to a low-order recipient (Go `x25519.go:75-78`; noble and WebCrypto in TS). \ No newline at end of file