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.

60 KiB

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<Encrypted>, y nada más que eso y sus tipos.
    • EncryptOptions sigue campo a campo a su equivalente de Go: profile, unlockAt, policy, recipients, newPortableKey, critical, noncritical, controlCritical, controlNoncritical y now. Añade output y progress (decisión 2).
    • Encrypted sigue a Result: dateKey, unlockAt efectivo, capsuleId y portableKey. portableKey es un AccessKey sin codificar, con capsule_digest, como en Go: quien llama lo codifica con encodeAccessKey, que se autocomprueba, y lo borra con wipeAccessKey. Añade size y, cuando no hay output, dkc.
    • encrypt es asíncrono, y quien llama podría cambiar sus entradas durante una espera. Por eso copia al empezar el perfil (cloneProfile, profile.ts:88), los recipients y las extensiones. Go es síncrono y no lo necesita.
    • No se reexporta desde index.ts.
    • Alternativa descartada: devolver la .dkk ya codificada. La aplicación puede querer añadir extensiones no críticas de §44 antes de codificarla, y Go devuelve el objeto.
    • Alternativa descartada: llamar al módulo seal.ts. En el spec, "sellar" es producir SEALED_CONTROL.
    • Recomendación: la propuesta.
  2. Entrada y salida en streaming.

    • src puede ser Uint8Array, Blob o ReadableStream<Uint8Array>. Cualquiera de las tres se trocea en 64 KiB, con subarray y una ReadableStream de tipo pull, antes de entrar en age. Así encryptSTREAM nunca cifra de golpe un trozo grande y la escritura tiene presión inversa.
    • output es un WritableStream. Se cierra solo cuando todo ha terminado. Ante cualquier fallo se aborta, también ante un TypeError de opciones, y los tests lo exigen. Sin output, el .dkc se devuelve en memoria, en un solo buffer reservado con el tamaño exacto, sin concatenar.
    • Nada se escribe hasta que todo está comprobado, incluida la cabecera de PAYLOAD_AGE (sección 4, pasos 15 y 16). Es una garantía más fuerte que la de Go, que escribe esa cabecera en dst antes de comprobarla.
    • progress(written, total) se llama con written = 0 justo antes de la primera escritura, con el tamaño exacto cuando la entrada es un Blob o un Uint8Array. La página comprueba ahí la cuota; si progress lanza, no se escribe nada.
    • 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 <input type="date"> y <input type="time"> nativos;
  • tests de propiedades (fast-check, que sería de desarrollo): un bucle con un generador propio (splitmix64) que imprime la semilla, unas 20 líneas.

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.

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<Uint8Array>;
  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<Uint8Array>, opts: EncryptOptions): Promise<Encrypted>;

Flujo, en el orden de capsule.Encrypt. Cada condición lleva el texto y el código de Go, salvo las diferencias de la decisión 9. Ante cualquier fallo, output se aborta y nunca se cierra.

  1. Falta profile, now o policy, o unlockAt no es un Instant válido: TypeError con texto propio.
  2. Copias de las entradas (decisión 1).
  3. validateProfile(profile) (profile.ts:306): los textos y códigos de p.Validate().
  4. now(), una sola vez. Si unlockAt no es posterior: capsule: unlock time <RFC3339Nano> is not in the future, sin código (encrypt.go:83-85).
  5. Paso 1 de §61 y §62: resolveDateKey, con los textos de datekey.Resolve, y unlock = roundTime(round). Si unlock es anterior a lo pedido (§15): capsule: resolved round %d opens before the requested time → ERR_ROUND_MISMATCH. Es defensivo e inalcanzable, y lleva v8 ignore justificado.
  6. Recipients de acceso, como accessRecipients (encrypt.go:264-300):
    • time_only con recipients o con clave portable → capsule: time_only takes no recipients and no portable key;
    • política desconocida → capsule: unknown access policy %d;
    • un recipient que no son 32 bytes, que no es canónico o que es de orden bajo → TypeError, con texto propio (decisiones 6 y 9);
    • un recipient repetido → capsule: recipient age1… listed twice; INNER_ACCESS_AGE holds one stanza per recipient;
    • si se pide clave portable: I_ACCESS (32 bytes) y R_ACCESS, que se añade el último;
    • sin ninguno → capsule: time_and_key needs at least one recipient or a portable key.
  7. capsule_id (16 bytes) e I_PAYLOAD (32 bytes); de I_PAYLOAD se deriva R_PAYLOAD.
  8. encodeHeader, que ya aplica el máximo de §57, y después decodeHeader como autocomprobación → capsule: self-check: the reader rejects this PUBLIC_HEADER: …, con el código del decoder (encrypt.go:243-248).
  9. timeRecipient(profile, round) (tlock.ts:21), con el pairing en caché (decisión 3).
  10. seal(control):
    • con time_and_key: un Encrypter con los recipients de acceso formateados, cuyo encrypt(control) da INNER_ACCESS_AGE. Después, ageStanzas: más de 1 024 stanzas dan su texto y ERR_INTEGRITY (decisión 6). Después, checkAccessStanzas y número de stanzas igual al de recipients; si falla → capsule: INNER_ACCESS_AGE self-check failed → ERR_POLICY_STRUCTURE_MISMATCH (encrypt.go:151-153), rama inalcanzable con encoders correctos;
    • después, en los dos casos, un Encrypter distinto con timeRecipient solo, sobre el control o sobre INNER_ACCESS_AGE. Su resultado pasa checkTimeStanzas (decisión 8). Cada encrypt de age genera su propia file key, así que las tres son independientes (§62, MUST).
  11. Borrador: encodeControl con las extensiones reales y con binding e identidad de 32 bytes a cero, y después seal. Si el sellado pasa de 64 MiB → capsule: SEALED_CONTROL of %d bytes exceeds 67108864 → ERR_INTEGRITY (encrypt.go:165-176). Si el propio CONTROL_CBOR ya pasa de 64 MiB, el sellado también pasaría: se falla antes de sellar, con el mismo texto y la longitud de las fórmulas de la sección 1, para no sellar en memoria un control enorme.
  12. preludeBytes({ PUBLIC_HEADER_LEN, SEALED_CONTROL_LEN }) y headerBinding(prelude, header), sobre los bytes exactos (§26).
  13. encodeControl real, y decodeControl como autocomprobación, que borra la identidad decodificada → capsule: self-check: the reader rejects this CONTROL_CBOR: … (encrypt.go:186-193, 253-260).
  14. seal real. Si su longitud no es la del borrador → capsule: internal error: SEALED_CONTROL is %d bytes, measured %d (encrypt.go:196-202). Se borran CONTROL_CBOR e I_PAYLOAD.
  15. PAYLOAD_AGE: un Encrypter con R_PAYLOAD, sobre la entrada troceada en 64 KiB (decisión 2). Se lee su primer trozo, que es la cabecera age sola, y pasa checkPayloadStanzas. total = 16 + |PUBLIC_HEADER| + |SEALED_CONTROL| + stream.size(n) cuando se conoce el tamaño n de la entrada. Si algo falla desde aquí sin haber escrito, se cancela ese stream para soltar la fuente.
  16. progress(0, total). Si lanza, no se escribe nada.
  17. Escritura de PRELUDE, PUBLIC_HEADER, SEALED_CONTROL, la cabecera de PAYLOAD_AGE y el resto de sus trozos, cada uno por el hash antes de escribirse. Después se cierra la salida.
  18. .dkk: un credential_id aleatorio y AccessKey { capsuleId, type: 'x25519', material: copia de I_ACCESS, verification: { capsuleDigest } } (encrypt.go:225-236). El writer borra su copia de I_ACCESS en un finally.

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. <input type="date"> 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-<fecha UTC de apertura>.dkc y .dkk, que ya es pública en la DateKey y no revela cuándo se creó; la persona puede editarlo.
  • 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.

Powered by TurnKey Linux.