You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
198 lines
33 KiB
198 lines
33 KiB
|
1 week ago
|
# 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:<br>- `verifyRelease` con el orden y los textos de `provider.Verify`;<br>- `ReleaseSource`, con el contrato de la sección 5, y `suppliedRelease`;<br>- los casos de `TestVerifyRejects` de Go y los 7 del corpus de mutaciones que fallan en el paso 10;<br>- las firmas publicadas de las rondas 1000, 1001, 2000 y 1004, esta última obtenida restando p a la codificación x + p del corpus;<br>- 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.<br>5a hecho el 28-09-2026, con el texto en claro en memoria:<br>- los 65 casos del corpus pasan por `open` con el código y el paso de Go;<br>- los cinco fixtures se abren con cada credencial;<br>- los textos siguen a `capsule.Open` y `agewrap`.<br>El paso 13 exige probar cada identity contra cada stanza X25519, y `age-encryption` no expone su `X25519Identity`. Por eso `x25519.ts` abre los stanzas de uno en uno, con ChaCha20-Poly1305 de `@noble/ciphers` 2.4.0, dependencia aprobada el 28-09-2026 porque es la copia que ya usa `age-encryption`. `bech32.ts` lee las identidades `AGE-SECRET-KEY-1…`.<br>`index.ts` aún no reexporta la apertura, que metería noble en `/inspect`: el paso 8 la cargará bajo demanda.<br>5b hecho el 28-09-2026: `open` acepta un `Blob`, del que lee solo el prefijo de los pasos 1 a 8 (`prefix.ts`, movido de la página a la librería), calcula el `capsule_digest` en streaming (`digest.ts`) y descifra `PAYLOAD_AGE` en streaming. La salida puede ser un `WritableStream`, que se cierra tras el paso 18 y se aborta ante cualquier fallo. Los 65 casos del corpus pasan también así.<br>Comprobado en el navegador con un fichero OPFS (`createWritable`): `time_only` se abre con el SHA-256 del sidecar, y un fallo de STREAM deja intacto el contenido anterior del fichero.<br>La consulta de cuota (`navigator.storage.estimate()`), el fichero temporal y la descarga van con la página, en el paso 8 |
|
||
|
|
| 6 | Spec, mutaciones en Go, `testdata:sync`, reproducción en TypeScript (sección 7) | texto aprobado; vectores congelados en ambos repositorios; commits en Gitea. Hecho: la enmienda de canonicidad entró en la v0.8.2 (`f6f2e9f`, tag `spec-v0.8.2`), y sus 10 mutaciones pasan por `open` en TypeScript desde el paso 5a |
|
||
|
|
| 7 | `encryptOnG2RFC9380`, `Recipient` propio, ida y vuelta, interoperabilidad TS → Go a nivel IBE y de fichero `age` (sección 8, puntos 4 y 5) | Go abre lo que TypeScript cifra; vectores congelados.<br>Hecho el 28-09-2026:<br>- `ibe.ts` gana `encryptOnG2RFC9380` y `encryptOnG2WithSigma` (solo para tests);<br>- `tlock.ts` gana `timeRecipient`, con las comprobaciones y textos de `NewTimeRecipient`;<br>- `scripts/tlock-go-vectors.go` reescribe `EncryptCCAonG2` con sigma fijo, lo comprueba con kyber y tlock, y abre las muestras de `scripts/tlock-ts-samples.mjs`: Go abrió los cuerpos IBE y los ficheros `age` de las rondas 1000 y 1001 con la misma file key y el mismo texto;<br>- todo congelado en `src/lib/dkc/testing/tlock-vectors.json`;<br>- cobertura del 100 % de `ibe.ts` y `tlock.ts` |
|
||
|
|
| 8 | Página (sección 9) | un fixture `time_and_key` se abre en el navegador sin red; `check-build` en verde; tamaño del bundle anotado en el README.<br>Hecho el 28-09-2026:<br>- `OpenPanel.svelte`, con `release-input.ts` (release pegado o del registro del fixture), `opener.ts` (cargado con `import()`), `opening.ts` (pasos 9 a 18 como los registra la referencia) y `tempfile.ts` (fichero temporal de OPFS, un directorio y un Web Lock por pestaña, cuota libre y limpieza);<br>- `readAccessKey` en `prefix.ts`;<br>- `licenses.txt` con los avisos de `tlock-js` y `age` y los de cada paquete del bundle, escrito por `vite.config.ts`;<br>- nuevas guardas de `check-build`: ninguna página carga noble, `@scure/base` ni `age-encryption` en la primera carga, y `licenses.txt` está completo;<br>- cobertura del 100 % de los módulos nuevos.<br>Comprobado en el navegador con la compilación de producción:<br>- se abren los fixtures `time_only`, `time_and_key_portable` (con su `.dkk`) y `time_and_key_recipients` (con una identidad pegada), cada uno con el SHA-256 de su registro;<br>- una firma alterada da `ERR_RELEASE_INVALID` en el paso 10, y un fragmento STREAM alterado da `ERR_INTEGRITY` en el paso 17, sin descarga ni fichero temporal;<br>- un fichero propio se abre al fichero temporal, se descarga sin violar la CSP y se borra con su lock;<br>- lo que dejó una pestaña terminada se borra en la visita siguiente;<br>- ninguna petición sale del origen, y la página no se desborda a 375 px de ancho.<br>Revisión adversarial, en cuatro dimensiones (protocolo, seguridad, estado de la interfaz, tests y guardas), con cada hallazgo contrastado por un revisor que intentaba refutarlo: se confirmaron 15 hallazgos, algunos repetidos, y se refutaron 5. Todos se corrigieron:<br>- la fecha se vuelve a mirar cuando llega y cuando la pestaña vuelve a verse;<br>- el fichero de una apertura en curso es de la página: se borra con ella, y la apertura se detiene si se carga otra cápsula (`cancellable`);<br>- se vuelve a comprobar tras cada espera si la apertura sigue vigente;<br>- si el navegador rechaza OPFS, la apertura se hace en memoria;<br>- el nombre ofrecido para descargar no lleva caracteres invisibles;<br>- las glosas de `ERR_INVALID_MAGIC` y `ERR_EXTENSION_CRITICAL_UNKNOWN` valen también para la `.dkk`;<br>- el foco va al campo o al mensaje del error;<br>- tras volver de la caché de atrás y adelante no se ofrece un enlace muerto;<br>- un nombre largo no desborda la página;<br>- `licenses.txt` incluye el código de Vite y rolldown que entra en el bundle;<br>- la guarda de la primera carga resuelve los scripts respecto a cada página y falla si no encuentra ninguno |
|
||
|
|
|
||
|
|
Cada paso termina con `npm run verify` en verde y un commit en Gitea. El paso 6 puede ir en paralelo con el 4 y el 5.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 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.
|