The writer signs and seals: the hooks of capsule.EncryptFiles of Go

encryptFiles gains authorKey (alg 1, an AuthorSigner such as AuthorKey),
cmsSigner (alg 2, a CMS signature with certificates), sealer (seal_type 2,
an RFC 3161 token) and largeArea, as EncryptOptions of Go at spec-v0.12:
the same checks in the same order with the same texts, the signature and
the seal made with the final control and head and before anything is
written, and the security area evaluated by the reader of this library in
the context of the capsule before it is written, as Go's security does.
The hooks may be asynchronous. The area grows to 64 KiB only when what was
signed does not fit and largeArea allows it, and the larger capsule counts
in the limit of memory. security.ts encodes the area with its signature and
seal, and securitycms.ts encodes SIGNERS.

scripts/signing-go-vectors_test.go, run as a test in an export of
datekeys-go at spec-v0.12, writes testing/signing-vectors.json: with the
draws of crypto/rand of Go and the signatures and tokens of its hooks,
encryptFiles writes the eight signed and sealed capsules of Go byte for
byte, asks the hooks over the same messages, and fails with the text of Go
in the other 15 recipes; and Go opens the five capsules that
scripts/signing-ts-samples.mjs writes with this library, its own random
values and certificates, with the same verdicts and lines.

check-build.mjs fails when a page loads the author keys with the page, or
when /inspect can load them at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
dev 1 day ago
parent 96614d5e79
commit 9e5e3ba081

@ -9,6 +9,19 @@ Cambios notables de la librería TypeScript y de la página. El proyecto usa ver
- `testdata` se sincroniza con `datekeys-go` en `a83b44d`, el borrador v0.13, sin aprobar: añade `vectors/resolved_ip.json`, y ningún otro fichero cambia. `SPEC_VERSION` sigue en `0.12`.
- `checkResolvedIp` cuenta una dirección de NAT64 a la que resuelve un nombre, de `64:ff9b::/96` o del prefijo de la red (el parámetro `nat64`), por la IPv4 que lleva dentro, con los textos de `locator.CheckResolvedIP` de Go. `ipaddr.ts` exporta `isIpv4In6`. Un bloque nuevo de `vectors.test.ts` corre los 42 casos.
### Las claves de autor y la firma al escribir (06-10-2026)
Las claves de autor de `alg` 1 y los enganches de firma y de sello del escritor, como `authorkey` y `capsule.EncryptFiles` de Go en `spec-v0.12`, byte a byte. Ningún paquete ni módulo nuevo: el SHA-512, el HKDF, ChaCha20-Poly1305 y el scrypt de `age-encryption` ya estaban.
- **`ed25519sign.ts`**, la firma Ed25519 en código propio: `crypto_sign` de TweetNaCl, como el port de Dart, con el SHA-512 de `@noble/hashes`. Aritmética exacta en `Float64Array`, y los secretos nunca en `BigInt`. JavaScript no promete tiempo constante ni borrar la memoria, y el código lo dice.
- **`authorkey.ts`**, el paquete `authorkey` de Go: `AuthorKey` (`generate` con una fuente de azar inyectable, `fromSeed`, `publicKey`, `sign`, `clear`, `secret` y un `toString` que oculta el secreto), `authorPublicString`, `parseAuthorPublic`, `parseAuthorSecret`, `marshalAuthorKey`, `encryptAuthorKey` y `readAuthorKey`, con los textos de error de Go byte a byte, también los de `age`, también los números al revés del de `PublicString`. Las cadenas se leen como bytes, con las mayúsculas, las minúsculas y los espacios del paquete `unicode` de Go, que `gounicode.ts` trae en tablas generadas con Go (`scripts/go-unicode-tables.go`). El fichero cifrado lleva scrypt con logN 16, y se lee con un máximo de 16, 64 KiB y las líneas de `bufio.Scanner`.
- **El escritor.** `encryptFiles` gana `authorKey` (`alg` 1), `cmsSigner` (`alg` 2) y `sealer` (`seal_type` 2), que pueden ser asíncronos, y `largeArea`. Se comprueban en el orden de Go y con sus textos; la firma y el sello se piden con el control y el head finales y antes de escribir nada, y el área se evalúa con el lector antes de escribirla, como `security` de Go. Un área que crece a 64 KiB cuenta en el límite de memoria. `security.ts` escribe el área con firma y sello, y `securitycms.ts`, `SIGNERS`.
- **Interoperabilidad con Go**, en dos ficheros congelados de `testing/`:
- `authorkey-vectors.json`, de `scripts/authorkey-go-vectors.go`: 234 firmas, la reducción de escalares, 24 claves, `Generate` y el fichero cifrado con los valores al azar de Go, que `encryptAuthorKey` reproduce byte a byte, 1 288 cadenas, 3 240 runas y 130 ficheros que leer, cada uno con el resultado o el texto de Go;
- `signing-vectors.json`, de `scripts/signing-go-vectors_test.go` en una exportación de `spec-v0.12`: con los valores al azar y las firmas de Go, `encryptFiles` escribe sus ocho cápsulas firmadas y selladas byte a byte, y falla con su texto en las otras 15. Y Go abre las cinco cápsulas que escribe `scripts/signing-ts-samples.mjs` con esta librería, sus certificados y su azar, con los mismos veredictos y las mismas líneas.
- **Guardas.** `dependencies.test.ts` deja importar noble a `authorkey.ts` y `ed25519sign.ts`, y `age-encryption` a `authorkey.ts`, y no deja que `index.ts` los reexporte. `check-build.mjs` falla si una página carga las claves de autor con la página, o si `/inspect` puede cargarlas. `authorkey.ts`, `ed25519sign.ts` y `gounicode.ts` quedan al 100 % de cobertura.
- **Pruebas.** `npm run verify` pasa con 7 904; una más, la de un área que crece y ya no cabe en memoria, lee 1 GiB y corre con `DATEKEYS_LARGE=1`. Con 27 fallos inyectados uno a uno en la firma, las claves, el escritor y los codificadores, las pruebas detectan 26; el otro es equivalente: el escritor compara la clave del veredicto con la del enganche, que no puede ser otra si la firma da F4. Y `check-build.mjs` detecta las claves de autor en `/inspect`.
### El localizador de `datekeys.capsule` (06-10-2026)
El paquete `locator` de `datekeys-go` en `spec-v0.12` (§43 a §44.1), con los mismos checks en el mismo orden, los mismos códigos y los mismos textos de error, byte a byte, como lo portó `datekeys-dart` en sus partes 7a y 7b.

@ -1,6 +1,6 @@
# datekeys-ts
Implementación en TypeScript del protocolo DateKeys (formato 3 de la v0.10; de la v0.11, la firma de clave propia, la firma con certificados y el sello de tiempo, que lee y verifica con el perfil del certificado y los textos del borrador v0.12, y el escritor con el área de 32 KiB y la nota pública; de la v0.12, el localizador de `datekeys.capsule`; firmar al escribir está pendiente) y página de prueba en el navegador. Sustituye al prototipo, archivado en `../archive/prototype` (API Quicknet en Go, CLI tlock y cliente Svelte, commit `4d2b0a1`).
Implementación en TypeScript del protocolo DateKeys (formato 3 de la v0.10; de la v0.11, la firma de clave propia, la firma con certificados y el sello de tiempo, que lee y verifica con el perfil del certificado y los textos del borrador v0.12, y el escritor con el área de 32 KiB, la nota pública, las claves de autor y la firma y el sello al escribir, como los escribe Go; de la v0.12, el localizador de `datekeys.capsule`) y página de prueba en el navegador. Sustituye al prototipo, archivado en `../archive/prototype` (API Quicknet en Go, CLI tlock y cliente Svelte, commit `4d2b0a1`).
La implementación de referencia es la librería Go `g.activething.com/go/DateKeys`, en `../datekeys-go`. Los planes y el estado del trabajo están en `../docs`, el repositorio privado de documentación del proyecto.
@ -37,7 +37,7 @@ La versión actual es `0.2.0-dev`: la fase 3 añade la escritura de cápsulas de
## `src/lib/dkc`
La inspección (pasos 1 a 8) no importa ninguna dependencia. Funciona en navegadores y en Node 20+: solo usa `Uint8Array`, `DataView`, `TextEncoder`/`TextDecoder`, `BigInt` y `crypto.subtle` (SHA-256). La apertura (fase 2) y la escritura usan las dependencias de ejecución de su sección: noble solo lo importan `ageio.ts`, `author.ts`, `cms.ts`, `digest.ts`, `ed25519strict.ts`, `ibe.ts`, `release.ts` y `x25519.ts`, y `age-encryption` solo `agefile.ts`, `open.ts`, `tlock.ts` y `writer.ts`.
La inspección (pasos 1 a 8) no importa ninguna dependencia. Funciona en navegadores y en Node 20+: solo usa `Uint8Array`, `DataView`, `TextEncoder`/`TextDecoder`, `BigInt` y `crypto.subtle` (SHA-256). La apertura (fase 2) y la escritura usan las dependencias de ejecución de su sección: noble solo lo importan `ageio.ts`, `author.ts`, `authorkey.ts`, `cms.ts`, `digest.ts`, `ed25519sign.ts`, `ed25519strict.ts`, `ibe.ts`, `release.ts` y `x25519.ts`, y `age-encryption` solo `agefile.ts`, `authorkey.ts`, `open.ts`, `tlock.ts` y `writer.ts`.
`crypto.subtle` solo existe en contextos seguros: `https`, o `http` en `localhost`. La página del paso 5 servida por `http` desde una IP de la red local (por ejemplo `vite --host` para probar en un móvil) no lo tiene, y `inspect` rechaza entonces con un `Error` que lo dice (`SHA-256 needs Web Crypto (crypto.subtle), …`) en vez de dar un veredicto. El registro por defecto no memoriza ese fallo: la siguiente llamada lo vuelve a intentar.
@ -53,7 +53,7 @@ La inspección (pasos 1 a 8) no importa ninguna dependencia. Funciona en navegad
| `ibe.ts` | IBE-CCA de tlock sobre G2 para Quicknet (§63 paso 11): `decryptOnG2` y `encryptOnG2RFC9380` (Qid = H(id) en G1 con el DST de RFC 9380, sigma aleatorio, U = r·G2), con la puerta de codificación canónica de `bls12381.ts` sobre la firma y U; H2 sobre GT serializado en el orden de kilic (nunca `Fp12.toBytes` de noble), H3 y H4; `roundIdentity`; el cuerpo `U ‖ V ‖ W` de 128 bytes del stanza. Errores `IbeError` con motivo (`length`, `encoding`, `identity`, `proof`) y texto fijos, sin ningún valor del cálculo; borra sigma y los hashes derivados. Sobre `@noble/curves` 2.4.0; lleva el aviso MIT de `tlock-js`, cuya estructura sigue. Lo usa la apertura (`open.ts`) | `encrypt/ibe` de drand/kyber (`DecryptCCAonG2`), `tlock.BytesToCiphertext` y `TimeUnlock` |
| `release.ts` | Verificación local del release (§17, §51, §63 paso 10), en el orden y con los textos de `provider.Verify`:<br>1. el rango de la ronda (`ERR_DATEKEY_INVALID`);<br>2. la ronda del release antes que la firma (`ERR_ROUND_MISMATCH`);<br>3. la longitud de la firma;<br>4. la clave pinneada (`ERR_UNKNOWN_PROFILE`);<br>5. la firma: codificación canónica de un punto de G1 que no sea el infinito, y firma BLS válida de la ronda sobre `@noble/curves` 2.4.0, con el DST de RFC 9380 para G1 (`ERR_RELEASE_INVALID`).<br>Nada de noble se copia a los errores. Solo verifica el scheme de Quicknet: un perfil de otro scheme falla con `ERR_UNKNOWN_PROFILE` tras las comprobaciones de ronda, donde la referencia sí lo verificaría (decisión 3 del plan de la fase 2). También define `ReleaseSource`, con su contrato de fuentes de red y de la corrección 6, y `suppliedRelease`, el release que entrega quien llama | `provider` (`Verify`, `ReleaseSource`) |
| `open.ts` | Los pasos 9 a 18 de §63 sobre los pasos 1 a 8 de `inspectWith`, con los checks, códigos y textos de `capsule.Open`:<br>- las credenciales y el release (paso 9), que cualquier fallo de la fuente convierte en `ERR_RELEASE_UNAVAILABLE` (corrección 6);<br>- la verificación del release (10);<br>- `OUTER_TIME_AGE` (11), la estructura frente a `access_policy` (12) e `INNER_ACCESS_AGE` (13);<br>- `CONTROL_CBOR` (14), `header_binding` (15), `I_PAYLOAD` (16), `PAYLOAD_AGE` (17) y el commit (18).<br>Lee los dos formatos (§22, §70). En el formato 2, `INNER_ACCESS_AGE` tiene exactamente 16 stanzas (paso 12); `CONTROL_CBOR` es de la versión de schema 2, con L y la regla de relleno (14); el paso 16 calcula P, y el 17 exige un texto en claro de exactamente P bytes con ceros tras el contenido, `ERR_INTEGRITY` en otro caso. Solo se entregan los L primeros bytes, nunca el relleno (§29.1, §56). `Opened` da el formato y, en los formatos 2 y 3, L, la regla y P.<br>En el formato 3, el paso 17 lo hace `open3.ts`, y los ficheros van a `sink`; sin él, `open` rechaza con un `TypeError` justo tras el paso 2, antes de pedir nada, como `ErrSinkRequired`. `Opened` da entonces el head, los veredictos del área de seguridad y el tamaño del área.<br>Abre los tres ficheros `age` con el `Decrypter` de `age-encryption` y con identidades propias que aplican las reglas de `agewrap`: la de tiempo, sobre `ibe.ts`; las de acceso y payload, sobre `x25519.ts`, stanza a stanza. Los fallos de `age` que no informa una identidad son `ERR_INTEGRITY` con el motivo fijo de su fase, cabecera o STREAM, sin copiar el texto de `age-encryption`.<br>La entrada puede ser un `Uint8Array` o un `Blob`, como un `File`. De un `Blob` solo se lee el prefijo de los pasos 1 a 8 (`prefix.ts`), el `capsule_digest` de la `.dkk` se calcula sobre su stream (`digest.ts`) y `PAYLOAD_AGE` se descifra en streaming.<br>El texto en claro va a memoria o a `output`, un `WritableStream`. Se escribe a medida que `age` autentica cada chunk, se cierra solo tras el paso 18 y se aborta ante cualquier fallo, en cualquier paso (§56). Un fallo del stream de salida es `ERR_INTEGRITY` con su texto, como en Go. El `WritableStream` de un fichero OPFS guarda lo escrito en un fichero de intercambio hasta el cierre: comprobado en el navegador, un fallo de STREAM deja intacto el contenido anterior | `capsule.Open`, `agewrap` (`TimeIdentity`, `AccessIdentity`, `PayloadIdentity`) |
| `encrypt.ts`, `writer.ts` | Los writers. `encryptFiles(files, opts)` escribe un `.dkc` de formato 3, como `capsule.EncryptFiles`: comprueba las rutas y los textos con las reglas del lector y con los textos de Go, pone los ficheros en el orden de los bytes de sus rutas, mide L con un head de sal y hashes a cero, lee cada fichero dos veces y falla si cambió entre las dos lecturas; el head, el control y el área de seguridad se decodifican antes de escribir. El área de seguridad es la vacía, porque esta librería no firma, en un área de 32 KiB sea lo que sea lo que guarde la cápsula (§62.1, regla 13). `publicNote` es la nota pública de la cabecera (§24.1), que se rechaza con los textos de `extension.CheckNote` tras `capsule: `, y nunca se corrige; las opciones se comprueban en el orden de `newSealer` de Go. Con un área de 512 bytes, que solo puede pedir un generador de vectores, reproduce byte a byte `PRELUDE`, PUBLIC_HEADER, CONTROL_CBOR y `BODY` de los cinco fixtures que escribió `EncryptFiles` en la v0.10. `fileSource` hace la fuente de un `File`.<br>El formato 2 solo lo escribe un generador de vectores (§62.1, regla 1). `encrypt(src, opts)` tiene la forma de `capsule.Encrypt`, pero sus opciones no pueden pedirlo, así que falla con el texto de Go. Lo que solo pide un generador, el formato 2 y otra área (`TestVectors`, como `EncryptOptions.TestVectors` de Go), solo lo pasan al núcleo los ayudantes de `testing/encrypt.ts` (`encryptVectors`, `encryptWith` y `encryptFilesWith`), que ninguna página puede cargar. Con ellos, las pruebas y los scripts escriben un `.dkc` de formato 2, sin head, área ni nota, y, si se pide, una `.dkk` portable (§61, §62, §62.1), en el orden y con los textos y códigos de `capsule.Encrypt`:<br>- el formato 2 siempre; L conocida de antemano (el tamaño de un `Uint8Array` o un `Blob`, o `length` con un `ReadableStream`), y una fuente que da más o menos bytes falla con los textos de Go;<br>- el relleno `reforzado` por defecto, o `bloque256`;<br>- de 1 a 16 credenciales, canónicas y no de orden bajo, un señuelo en cada hueco libre, cuyo escalar se borra al derivar su clave pública, y un orden uniforme de los 16 (`random.ts`);<br>- `SEALED_CONTROL_LEN` con la fórmula del §62.1, comprobada con el sellado real;<br>- las autocomprobaciones de la regla 11 y dos más: `OUTER_TIME_AGE` con las reglas del lector, y la cabecera de `PAYLOAD_AGE`, que `I_PAYLOAD` abre antes de escribir nada.<br>Nada se escribe hasta que todo lo anterior al contenido está comprobado. El contenido va en trozos de 64 KiB, seguido de los ceros del relleno, con presión inversa, hacia memoria (hasta `MAX_MEMORY_DKC`, 1 GiB) o hacia `output`, que se cierra solo con la cápsula completa y comprobada y se aborta ante cualquier fallo. Los errores de la fuente y de la salida se relanzan tal cual.<br>El núcleo, `writer.ts`, recibe la aleatoriedad de quien lo llama: `encrypt.ts` le da la de `crypto.getRandomValues`, y solo `testing/encrypt.ts` la fija, para reproducir los fixtures de Go | `capsule.Encrypt`, `accesskey.Encode` |
| `encrypt.ts`, `writer.ts` | Los writers. `encryptFiles(files, opts)` escribe un `.dkc` de formato 3, como `capsule.EncryptFiles`: comprueba las rutas y los textos con las reglas del lector y con los textos de Go, pone los ficheros en el orden de los bytes de sus rutas, mide L con un head de sal y hashes a cero, lee cada fichero dos veces y falla si cambió entre las dos lecturas; el head, el control y el área de seguridad se decodifican antes de escribir. El área de seguridad mide 32 KiB sea lo que sea lo que guarde la cápsula (§62.1, regla 13), y va vacía o con la firma y el sello de los enganches de Go: `authorKey` firma con `alg` 1 (un `AuthorKey` de `authorkey.ts` o cualquier `AuthorSigner`), `cmsSigner` con `alg` 2, la firma con certificados, y `sealer` pide el sello de `seal_type` 2; `largeArea` deja ensanchar el área a 64 KiB solo si lo firmado no cabe en 32 KiB. Los enganches pueden ser asíncronos. Se comprueban como en `newSealer` de Go, en su orden y con sus textos: una sola firma, y con `cmsSigner` los sellos van dentro de cada firma. Se llaman cuando el control y el head ya son los finales y antes de escribir nada: la firma se compromete con ellos y el sello con la firma (§29.8, §29.11). El área se evalúa con el lector de la librería en el contexto de la cápsula antes de escribirla, como `security` de Go, y una firma que no daría F4 o F6, o un sello que no daría S4 o S5, la hace fallar con el texto de Go (reglas 17, 19 y 21). Lo que lanza `cmsSigner` o `sealer` llega con `capsule: signing: ` o `capsule: sealing: ` y su mensaje, y el error como `cause`. `publicNote` es la nota pública de la cabecera (§24.1), que se rechaza con los textos de `extension.CheckNote` tras `capsule: `, y nunca se corrige; las opciones se comprueban en el orden de `newSealer` de Go. Con un área de 512 bytes, que solo puede pedir un generador de vectores, reproduce byte a byte `PRELUDE`, PUBLIC_HEADER, CONTROL_CBOR y `BODY` de los cinco fixtures que escribió `EncryptFiles` en la v0.10. `fileSource` hace la fuente de un `File`.<br>El formato 2 solo lo escribe un generador de vectores (§62.1, regla 1). `encrypt(src, opts)` tiene la forma de `capsule.Encrypt`, pero sus opciones no pueden pedirlo, así que falla con el texto de Go. Lo que solo pide un generador, el formato 2 y otra área (`TestVectors`, como `EncryptOptions.TestVectors` de Go), solo lo pasan al núcleo los ayudantes de `testing/encrypt.ts` (`encryptVectors`, `encryptWith` y `encryptFilesWith`), que ninguna página puede cargar. Con ellos, las pruebas y los scripts escriben un `.dkc` de formato 2, sin head, área ni nota, y, si se pide, una `.dkk` portable (§61, §62, §62.1), en el orden y con los textos y códigos de `capsule.Encrypt`:<br>- el formato 2 siempre; L conocida de antemano (el tamaño de un `Uint8Array` o un `Blob`, o `length` con un `ReadableStream`), y una fuente que da más o menos bytes falla con los textos de Go;<br>- el relleno `reforzado` por defecto, o `bloque256`;<br>- de 1 a 16 credenciales, canónicas y no de orden bajo, un señuelo en cada hueco libre, cuyo escalar se borra al derivar su clave pública, y un orden uniforme de los 16 (`random.ts`);<br>- `SEALED_CONTROL_LEN` con la fórmula del §62.1, comprobada con el sellado real;<br>- las autocomprobaciones de la regla 11 y dos más: `OUTER_TIME_AGE` con las reglas del lector, y la cabecera de `PAYLOAD_AGE`, que `I_PAYLOAD` abre antes de escribir nada.<br>Nada se escribe hasta que todo lo anterior al contenido está comprobado. El contenido va en trozos de 64 KiB, seguido de los ceros del relleno, con presión inversa, hacia memoria (hasta `MAX_MEMORY_DKC`, 1 GiB) o hacia `output`, que se cierra solo con la cápsula completa y comprobada y se aborta ante cualquier fallo. Los errores de la fuente y de la salida se relanzan tal cual.<br>El núcleo, `writer.ts`, recibe la aleatoriedad de quien lo llama: `encrypt.ts` le da la de `crypto.getRandomValues`, y solo `testing/encrypt.ts` la fija, para reproducir los fixtures de Go | `capsule.Encrypt`, `accesskey.Encode` |
| `tlock.ts` | `timeRecipient`, el `Recipient` de `age-encryption` para `OUTER_TIME_AGE` (§32, §35), como `agewrap.TimeRecipient`: cifra la file key con `ibe.ts` para una ronda de un perfil pinneado y escribe el stanza `tlock <ronda> <chain hash>` de tlock. Comprueba el perfil y luego el rango de la ronda, con los textos de `NewTimeRecipient`. `age-encryption` no tiene etiquetas, así que quien escriba `OUTER_TIME_AGE` (fase 3) lo añade como único recipient | `agewrap.TimeRecipient` |
| `lengths.ts` | El tamaño de un `.dkc` de formato 2 o 3 antes de escribirlo. Para el formato 3, `bodyLength` da L con `headLength`, que mide el head por los tamaños de sus elementos CBOR sin codificarlo ni cargar las tablas de Unicode, y `mtimeSeconds` y `headComment` dan la mtime y el comentario tal como el writer los guarda. Para los dos formatos: `sealedControlLength`, la fórmula de `SEALED_CONTROL_LEN` del §62.1 con la que el writer comprueba su sellado, y `capsuleLength`, el tamaño exacto que escriben los writers para una ronda, una política, L, el relleno, las extensiones y la nota pública, que la página muestra antes de cifrar porque cualquiera con el fichero lo ve (§55.2). Sin noble, `age-encryption` ni tablas de Unicode | `capsule.Encrypt`, que mide un borrador sellado |
| `padding.ts` | El relleno del formato 2 (§29.1): los códigos 1 (`bloque256`) y 2 (`reforzado`), `paddedLength`, exacta hasta L_MAX = 2⁵³ − 2⁴⁶ (`bitlen` con `BigInt` y los redondeos con `ceil`, exactos en doubles; nunca operaciones de 32 bits, `Math.clz32` ni `Math.log2`), y la longitud de `PAYLOAD_AGE` | `capsule/padding.go` |
@ -65,12 +65,15 @@ La inspección (pasos 1 a 8) no importa ninguna dependencia. Funciona en navegad
| `bech32.ts` | Bech32 (BIP 173) tal como `internal/bech32` de `age`, que la referencia copia como `codec/bech32`; conserva su aviso MIT | `codec/bech32` |
| `datekey.ts` | `dk1_` canónico con las reglas de lectura de §19 (CR, LF y todo carácter fuera del alfabeto fallan el paso 1; números JSON por su valor decimal exacto), ronda desde una fecha con precisión de nanosegundos y cota de 9999-12-31T23:59:59Z (§15), parser RFC 3339 equivalente a `time.Parse(time.RFC3339Nano, …)`; `compareInstants` e `isInstant`; `LONG_HORIZON_SECONDS` e `isLongHorizon`, el umbral de 365 días de los avisos de §53 y §50, una política de producto | `datekey` |
| `header.ts`, `control.ts`, `accesskey.ts` | PUBLIC_HEADER, CONTROL_CBOR y `.dkk` (cuerpo y trama), decodificar y codificar, con las capas de §69.1. CONTROL_CBOR se lee y se escribe para un formato: versión de schema 1 sin las claves 6 y 7, o 2 y 3 con `payload_length` (8 bytes, hasta L_MAX) y `padding` (1 o 2); 103 bytes sin extensiones sea cual sea L | `capsule`, `accesskey` |
| `body.ts`, `security.ts`, `head.ts` | El formato 3 (§29.2 a §29.7): la trama de `BODY` (`AREA_LEN`, `SECURITY_LEN` y `HEAD_LEN`) y los ceros del área, `ERR_INTEGRITY`; el `SECURITY_CBOR` vacío que escriben los writers, y los veredictos de la v0.11 con sus textos y las líneas que los muestran, las de un certificado con los textos del borrador v0.12 (cada nombre entre « y », la autoridad del sello de cada firmante de F6 y el aviso de que DateKeys no comprueba quién emitió los sellos): X; F0 a F6, con la firma de `alg` 1 y la de `alg` 2 comprobadas en el contexto de la cápsula; y S0 a S5, con el sello de `seal_type` 2. `evaluateSecurity` nunca lanza: una excepción al evaluar la firma da F1, y una al evaluar el sello, S2, cada una sin tocar el otro veredicto. Y el head, con las capas de §69.1: R1 y R8 en el CDDL, R8 por los bytes UTF-8 y no por el orden UTF-16 de las cadenas de JavaScript, y luego el comentario, el autor declarado, las rutas, la maquetación, R7 y R9, todo `ERR_HEAD_INVALID`, y las extensiones críticas del objeto `head` | `capsule/format3.go`, `capsule/signature.go` |
| `body.ts`, `security.ts`, `head.ts` | El formato 3 (§29.2 a §29.7): la trama de `BODY` (`AREA_LEN`, `SECURITY_LEN` y `HEAD_LEN`) y los ceros del área, `ERR_INTEGRITY`; el `SECURITY_CBOR` que escriben los writers, vacío o con la firma y el sello (`encodeSecurityWith`, `encodeAuthorSignatureItem` y `encodeSealItem`), y los veredictos de la v0.11 con sus textos y las líneas que los muestran, las de un certificado con los textos del borrador v0.12 (cada nombre entre « y », la autoridad del sello de cada firmante de F6 y el aviso de que DateKeys no comprueba quién emitió los sellos): X; F0 a F6, con la firma de `alg` 1 y la de `alg` 2 comprobadas en el contexto de la cápsula; y S0 a S5, con el sello de `seal_type` 2. `evaluateSecurity` nunca lanza: una excepción al evaluar la firma da F1, y una al evaluar el sello, S2, cada una sin tocar el otro veredicto. Y el head, con las capas de §69.1: R1 y R8 en el CDDL, R8 por los bytes UTF-8 y no por el orden UTF-16 de las cadenas de JavaScript, y luego el comentario, el autor declarado, las rutas, la maquetación, R7 y R9, todo `ERR_HEAD_INVALID`, y las extensiones críticas del objeto `head` | `capsule/format3.go`, `capsule/signature.go` |
| `authorkey.ts` | Las claves de autor de `alg` 1 (§29.9, §29.12), como el paquete `authorkey` de Go en `spec-v0.12`, con sus comprobaciones en su orden y sus textos byte a byte: `AuthorKey` (`generate` con una fuente de azar inyectable, `fromSeed`, `publicKey`, `sign`, `clear`, `secret`, y `toString`, `toJSON` y `util.inspect` que ocultan el secreto), `authorPublicString`, `parseAuthorPublic` (canónica, en la curva y no de orden pequeño), `parseAuthorSecret` y `marshalAuthorKey`. Las cadenas se leen como bytes de Go: la mayúscula y la minúscula son las de `strings.ToLower` y `strings.ToUpper` de Go, y los espacios de una línea los de `strings.TrimSpace`, con las tablas de `gounicode.ts`; un `Uint8Array` es una cadena de Go que puede no ser UTF-8. El fichero de clave: `encryptAuthorKey` lo escribe con el `Encrypter` de `age-encryption` y una frase de paso, scrypt con logN 16; `readAuthorKey` lee uno cifrado o en claro de hasta 64 KiB, con las líneas de `bufio.Scanner`, y del cifrado lee la cabecera con `age.ts`, comprueba el stanza scrypt como `ScryptIdentity` de Go con un factor máximo de 16, deja a `age-encryption` el scrypt y el MAC, y descifra el STREAM, todo con los textos de `age` de Go. A diferencia de Go, una clave borrada lanza al usarla. JavaScript no promete tiempo constante ni borrar la memoria: se borran las copias propias, no las del motor, `age-encryption` o noble, ni las cadenas. En Node 24.9, firmar tarda unos 6 ms, y escribir o leer un fichero de clave cifrado, unos 0,3 s, los del scrypt de 64 MiB | `authorkey` |
| `ed25519sign.ts` | La firma Ed25519 (RFC 8032, 5.1.5 y 5.1.6) en código propio: `crypto_sign` de TweetNaCl, como el port de Dart, con el SHA-512 de `@noble/hashes`. Aritmética exacta en `Float64Array` (16 miembros de 16 bits en el cuerpo, 64 de 8 bits para los escalares módulo ℓ); los secretos nunca pasan por `BigInt`, una rama o un índice que dependa de ellos. Da la firma de Go byte a byte, también si la clave pública que se le da es otra | `crypto/ed25519` |
| `gounicode.ts` | Las tablas de `unicode.ToLower`, `unicode.ToUpper` y `unicode.IsSpace` de Go 1.26 (Unicode 15.0.0) que usan `strings.ToLower`, `strings.ToUpper` y `strings.TrimSpace`, en tramos. Las genera `scripts/go-unicode-tables.go` con el SHA-256 de cada conjunto, que una prueba recalcula | `unicode` |
| `author.ts` | Lo que se firma y se sella (§29.8, §29.11): `payload_commit`, `control_commit` sobre `CONTROL_SIG`, `head_digest`, `signers_digest`, `AUTHOR_MESSAGE` (99 bytes de ASCII) y su código, que se toma byte a byte como en Go, `SIG_PART` y `SEAL_SUBJECT`. Nada se guarda: todo se recalcula de la cápsula abierta | `capsule/signature.go` |
| `ed25519strict.ts` | La verificación estricta de la firma de `alg` 1 (§29.9): las cuatro condiciones del perfil, con la aritmética del grupo de `@noble/curves`, cuyo `verify` usa la ecuación con cofactor y acepta lo que el perfil rechaza. Da la respuesta de Go en los 18 vectores de `ed25519_strict.json` | `internal/ed25519strict` |
| `der.ts` | La comprobación estricta de DER que hace la firma CMS antes de mirar dentro (§29.10): longitudes definidas y mínimas, BOOLEAN, INTEGER, NULL, OID y BIT STRING canónicos, UTCTime y GeneralizedTime en sus formas de X.690 y con una fecha que existe (`parseTime` los lee), solo los tipos universales que usan los certificados, las firmas y los tokens, los tipos de cadena restringidos entre ellos, y una profundidad de 32; `setOfSorted` para los SET OF cuyo esquema conoce quien llama, que pueden repetir un elemento | `internal/der` |
| `cms.ts` | La firma CMS (RFC 5652) de `alg` 2 y el token RFC 3161 del sello (§29.10, §29.11), con el orden de comprobaciones y los resultados de Go y la tabla cerrada de algoritmos: RSA PKCS #1 v1.5 y PSS con `BigInt`, de 2048 a 4096 bits y con un módulo impar, y ECDSA sobre P-256, P-384 y P-521 con la aritmética de `@noble/curves`, solo con el punto sin comprimir. Lee el certificado campo a campo con el perfil del §29.10 del borrador v0.12, como Go: a quién nombra (su `givenName` y su `surname` antes que su `commonName`), el emisor que dice (su `commonName` o su `organizationName`), su validez y su clave; nunca comprueba quién lo emitió ni si se revocó. Compara los OID por los bytes de su DER, acepta un SET OF que repite un elemento y cuenta como uno un certificado repetido, y un nombre conserva un U+FEFF inicial, como en Go | `internal/cms` |
| `securitycms.ts` | Los veredictos de una firma de `alg` 2, F1, F2, F5 y F6, con cada firmante requerido y ajeno nombrado como el §29.7 del borrador v0.12 (su nombre si cumple las reglas del autor declarado, tiene como mucho 64 puntos de código y no lleva dos espacios seguidos, y si no el SHA-256 del certificado; su emisor, o el SHA-256 de su `Name`; y la autoridad de su sello), y los de un sello, S1 a S5, con su autoridad y t | `capsule/signature2.go` |
| `securitycms.ts` | Los veredictos de una firma de `alg` 2, F1, F2, F5 y F6, con cada firmante requerido y ajeno nombrado como el §29.7 del borrador v0.12 (su nombre si cumple las reglas del autor declarado, tiene como mucho 64 puntos de código y no lleva dos espacios seguidos, y si no el SHA-256 del certificado; su emisor, o el SHA-256 de su `Name`; y la autoridad de su sello), y los de un sello, S1 a S5, con su autoridad y t. `encodeSigners` escribe `SIGNERS` para el escritor, ordenado y con los textos de Go | `capsule/signature2.go` |
| `note.ts` | La nota pública (§24.1): la extensión `datekeys.note` de la cabecera, de 1 a 1024 bytes de UTF-8 que cumplen las reglas del autor declarado, con los textos y el orden de `extension.CheckNote`. Una cadena con un sustituto suelto se rechaza: nunca se escribe con U+FFFD. `checkNoteData` comprueba los bytes de una nota en el orden de Go: la longitud, el UTF-8 y las reglas. `publicNote` la lee como Go, con un U+FEFF inicial que la deja inservible, y `unusableNote` distingue una nota inservible de ninguna | `extension` (`CheckNote`, `NewNote`, `Note`), `capsule` (`Header.UnusableNote`) |
| `pathrule.ts`, `pathrule-tables.ts` | Las reglas de las rutas y de los textos del head (§29.5, §29.6), de R1 a R10 con R4b, R6b, R6c y R9, NFD, el pliegue y la clave de R7, con los textos de error de Go. Nunca usa `normalize`, `toLowerCase`, `localeCompare`, `Intl` ni las clases `\p{…}`, cuya versión de Unicode cambia con el motor: las tablas de Unicode 18.0.0 y WindowsBestFit las genera `datekeys-go` (`pathrule/gen -ts`), y una prueba recalcula su digest | `internal/pathrule` |
| `open3.ts`, `sink.ts` | El paso 17 del formato 3 en sus subpasos 17.2 a 17.8, con la precedencia de Go: un fallo de `age` o un texto en claro que no mide P prevalecen, manda el primer subpaso que falla, y los códigos distintos de `ERR_INTEGRITY` solo se dan tras leer `PAYLOAD_AGE` hasta el final. `Sink` (`begin`, `create`, `commit`, `abort`) recibe los ficheros y solo los publica en el paso 18; `MemorySink` los guarda en memoria, que crece con los bytes recibidos y nunca con los tamaños que declara el head | `capsule/open3.go` |
@ -80,7 +83,7 @@ La inspección (pasos 1 a 8) no importa ninguna dependencia. Funciona en navegad
| `prefix.ts` | Lecturas acotadas de un `Blob`: el prefijo de un `.dkc` que necesitan los pasos 1 a 8 (`readCapsule`) y, de una `.dkk`, como mucho 12 bytes + 16 MiB + 1 (`readAccessKey`), que dan el mismo resultado que el fichero entero | |
| `locator.ts`, `ipaddr.ts` | El localizador de `datekeys.capsule` sin criptografía (§43 a §44.1 de la v0.12), con los checks, el orden y los textos del paquete `locator` de Go en `spec-v0.12`:<br>- los datos de la extensión: `parseInfo`, con `ERR_EXTENSION_DATA_INVALID` como único código, e `infoExtension`, que relee lo que escribe (§72);<br>- `standardExtensions`, el `extension.Standard` de Go: con el validador por defecto comprueba los datos de `datekeys.capsule` como `locator.Standard`, y con `null` solo que haya datos;<br>- las direcciones: `checkURI` con cada regla del §44.1 de la v0.12, `addressHost` y `usableAddresses`. Una dirección se lee como sus bytes, y un sustituto suelto como los tres bytes de su punto de código (WTF-8), que no son UTF-8 y se rechazan como Go rechaza esos bytes. Los bloques de IANA se comparan byte a byte sobre los 4 o 16 bytes de la dirección, nunca como números (`ipaddr.ts`, que lee como `netip.ParseAddr` y escribe como su `String`);<br>- `checkResolvedIp`, la IP a la que resuelve un nombre, que Go no tiene: es la de `datekeys-dart`, con su texto, y rechaza hoy `64:ff9b::/96` como el §44.1 de la v0.12;<br>- el texto en claro: `marshalLocator`, `unmarshalLocator` y `plaintextLength`, con el relleno de la clave 6 hasta el menor múltiplo de 4096 que puede llenar;<br>- el resto: `restIn` y `hide`.<br>Los errores sin código son `LocatorError`, con el texto de Go | `locator` (`locator.go`, `open.go` y `hide.go`), `net/netip` |
| `ageio.ts`, `envelope.ts` | La criptografía del localizador, con los textos de Go: `openSealed` (`Open`), que lee como mucho 1 MiB del texto como Go con `io.LimitReader`; `openInfoLocator` (`Info.OpenLocator`); `openEnvelope`; `seal` y `newEnvelope`, con una fuente inyectable de lo aleatorio (`RandomFill`, `crypto.getRandomValues` por defecto) que se lee en el orden de Go: con los mismos valores escriben los mismos bytes. `ageio.ts` lee y escribe ficheros `age` como `filippo.io/age` 1.3.2, con sus textos, porque `locator.Open` y `OpenEnvelope` los copian: la cabecera con el parser de `age.ts`, la identidad X25519, el MAC, el nonce y STREAM; la cápsula sigue leyendo y escribiendo los suyos con `age-encryption`. Solo el esquema de Quicknet, como la apertura | `locator` (`Seal`, `NewEnvelope`, `Open`, `OpenEnvelope`), `filippo.io/age` |
| `index.ts` | Reexporta todo salvo la fase 2 (`ibe.ts`, `release.ts`, `open.ts`, `tlock.ts`, `x25519.ts`, `bech32.ts`, `digest.ts` y `agefile.ts`), la fase 3 (`encrypt.ts`, `writer.ts`, `recipient.ts` y `random.ts`), el formato 3 (`open3.ts`, `sink.ts`, `head.ts`, `pathrule.ts` y sus tablas, que pesan 136 KB), la v0.11 (`author.ts`, `ed25519strict.ts`, `der.ts`, `cms.ts`, `securitycms.ts` y `note.ts`) y el localizador (`locator.ts`, `ipaddr.ts`, `ageio.ts` y `envelope.ts`), y una guarda lo comprueba. La página importa `index.ts`, y reexportarlos metería noble, `age-encryption` o las tablas en la primera carga de `/inspect` aunque no los use, porque noble ejecuta código al cargarse. La página carga la apertura bajo demanda (`src/lib/inspector/opener.ts`) | |
| `index.ts` | Reexporta todo salvo la fase 2 (`ibe.ts`, `release.ts`, `open.ts`, `tlock.ts`, `x25519.ts`, `bech32.ts`, `digest.ts` y `agefile.ts`), la fase 3 (`encrypt.ts`, `writer.ts`, `recipient.ts` y `random.ts`), el formato 3 (`open3.ts`, `sink.ts`, `head.ts`, `pathrule.ts` y sus tablas, que pesan 136 KB), la v0.11 (`author.ts`, `ed25519strict.ts`, `der.ts`, `cms.ts`, `securitycms.ts` y `note.ts`) el localizador (`locator.ts`, `ipaddr.ts`, `ageio.ts` y `envelope.ts`) y las claves de autor (`authorkey.ts`, `ed25519sign.ts` y `gounicode.ts`), y una guarda lo comprueba. La página importa `index.ts`, y reexportarlos metería noble, `age-encryption` o las tablas en la primera carga de `/inspect` aunque no los use, porque noble ejecuta código al cargarse. La página carga la apertura bajo demanda (`src/lib/inspector/opener.ts`) | |
| `testing/` | Solo para tests: lectura de `testdata/` y de sus formatos (`vectors.ts`: ediciones, vectores), constructores de CBOR en hex, cirugía de cápsulas, firmas y tokens de prueba (`cmsbuild.ts`), el writer como generador de vectores (`encrypt.ts`), el único que escribe el formato 2 u otra área, y lo del localizador: la lectura de sus vectores (`locator.ts`), una fuente de lo aleatorio de semilla fija con ChaCha20 propio (`seeded.ts`) y las recetas de su interoperabilidad (`locator-interop.ts`). Solo lo importan los tests y `testing/` mismo: una guarda de `dependencies.test.ts` lo comprueba en `src/`, y `check-build.mjs` en el bundle de las páginas | |
Los tests (`*.test.ts`) están junto a cada fichero.
@ -240,7 +243,7 @@ style-src 'self'; style-src-attr 'unsafe-hashes' 'sha256-…'; base-uri 'none';
- `style-src 'self'`: solo hojas de estilo del sitio; sin fuentes web ni CDN, con las fuentes del sistema.
- `style-src-attr`: solo el atributo `style` del anunciador de rutas de SvelteKit, por su hash (`ANNOUNCER_STYLE_HASH`, válido para `@sveltejs/kit` 2.70.3; `app.css` lo oculta también si el navegador bloquea el atributo).
`npm run build` ejecuta después `scripts/check-build.mjs` (`postbuild`; también `npm run build:check`), que falla si una ruta no tiene su HTML prerenderizado; si una página no tiene exactamente esa política, con la etiqueta antes de cualquier elemento que cargue recursos; si un script en línea no está en `script-src` o sobra un hash; si `style-src-attr` no coincide con los atributos `style` del bundle; si hay estilos en línea, manejadores de eventos en atributos, `@import` o URL a otro origen; si algún `.dkc` oficial no está byte a byte; si aparece en el sitio algún secreto de los fixtures (`.dkk`, textos en claro, identidades, `payload_identity`, `access_material`, `control_cbor`); si el bundle del cliente contiene `tlock-js`, `drand-client` o helpers de Babel, o una copia anidada de un paquete que no sea la de noble bajo `@noble/post-quantum`; si contiene un test o un módulo de `src/lib/dkc/testing/`, cuyos ayudantes escriben lo que solo puede escribir un generador de vectores; si una página carga noble, `@scure/base` o `age-encryption` en su primera carga, o si `/inspect` y `/create` no pueden cargar bajo demanda `age-encryption`, `@noble/curves` y `@noble/ciphers`, y `/create` también `@noble/hashes`; o si a `licenses.txt` le falta el aviso de un paquete del bundle, las líneas de copyright de un módulo de `src/` derivado de otro proyecto o la licencia del sitio. `vite.config.ts` registra los módulos de cada chunk en `.svelte-kit/output/client-modules.json`, fuera del sitio. Al terminar informa del JavaScript que carga cada página, en bytes y con gzip, en la primera carga y bajo demanda, y de los paquetes npm que lleva el bundle.
`npm run build` ejecuta después `scripts/check-build.mjs` (`postbuild`; también `npm run build:check`), que falla si una ruta no tiene su HTML prerenderizado; si una página no tiene exactamente esa política, con la etiqueta antes de cualquier elemento que cargue recursos; si un script en línea no está en `script-src` o sobra un hash; si `style-src-attr` no coincide con los atributos `style` del bundle; si hay estilos en línea, manejadores de eventos en atributos, `@import` o URL a otro origen; si algún `.dkc` oficial no está byte a byte; si aparece en el sitio algún secreto de los fixtures (`.dkk`, textos en claro, identidades, `payload_identity`, `access_material`, `control_cbor`); si el bundle del cliente contiene `tlock-js`, `drand-client` o helpers de Babel, o una copia anidada de un paquete que no sea la de noble bajo `@noble/post-quantum`; si contiene un test o un módulo de `src/lib/dkc/testing/`, cuyos ayudantes escriben lo que solo puede escribir un generador de vectores; si una página carga noble, `@scure/base` o `age-encryption` en su primera carga, o las claves de autor y su firma (`authorkey.ts`, `ed25519sign.ts` y `gounicode.ts`), que `/inspect` no carga ni bajo demanda, o si `/inspect` y `/create` no pueden cargar bajo demanda `age-encryption`, `@noble/curves` y `@noble/ciphers`, y `/create` también `@noble/hashes`; o si a `licenses.txt` le falta el aviso de un paquete del bundle, las líneas de copyright de un módulo de `src/` derivado de otro proyecto o la licencia del sitio. `vite.config.ts` registra los módulos de cada chunk en `.svelte-kit/output/client-modules.json`, fuera del sitio. Al terminar informa del JavaScript que carga cada página, en bytes y con gzip, en la primera carga y bajo demanda, y de los paquetes npm que lleva el bundle.
### Avisos de licencia: `licenses.txt`
@ -271,7 +274,7 @@ npm run build:check # solo la comprobación del sitio ya construido
npm run verify # check, typecheck, coverage y build (con su comprobación)
```
Umbrales de cobertura (`vitest.config.ts`), al 100 % en líneas, ramas, funciones y sentencias: de la librería, `cbor.ts`, `ibe.ts`, `release.ts`, `x25519.ts`, `bech32.ts`, `digest.ts`, `tlock.ts`, `prefix.ts`, `recipient.ts`, `random.ts`, `agefile.ts`, `padding.ts`, `writer.ts`, `encrypt.ts` y `lengths.ts`, los del formato 3, `pathrule.ts`, `body.ts`, `security.ts`, `head.ts`, `open3.ts` y `sink.ts`, y los del localizador, `locator.ts`, `ipaddr.ts`, `ageio.ts` y `envelope.ts`; de la página, `fixtures.ts`, `opener.ts`, `opening.ts`, `release-input.ts`, `tempfile.ts`, `localtime.ts`, `create-input.ts` y `creator.ts`, y los del formato 3, `crc32.ts`, `zip.ts`, `zipsink.ts`, `files.ts`, `create-files.ts` y `create-check.ts`. El conjunto de `src/lib/dkc` al 95/90/95/95, y el de `src/lib/inspector` también.
Umbrales de cobertura (`vitest.config.ts`), al 100 % en líneas, ramas, funciones y sentencias: de la librería, `cbor.ts`, `ibe.ts`, `release.ts`, `x25519.ts`, `bech32.ts`, `digest.ts`, `tlock.ts`, `prefix.ts`, `recipient.ts`, `random.ts`, `agefile.ts`, `padding.ts`, `writer.ts`, `encrypt.ts` y `lengths.ts`, los del formato 3, `pathrule.ts`, `body.ts`, `security.ts`, `head.ts`, `open3.ts` y `sink.ts`, los del localizador, `locator.ts`, `ipaddr.ts`, `ageio.ts` y `envelope.ts`, y los de las claves de autor, `authorkey.ts`, `ed25519sign.ts` y `gounicode.ts`; de la página, `fixtures.ts`, `opener.ts`, `opening.ts`, `release-input.ts`, `tempfile.ts`, `localtime.ts`, `create-input.ts` y `creator.ts`, y los del formato 3, `crc32.ts`, `zip.ts`, `zipsink.ts`, `files.ts`, `create-files.ts` y `create-check.ts`. El conjunto de `src/lib/dkc` al 95/90/95/95, y el de `src/lib/inspector` también.
`vitest.config.ts` es la configuración de los tests; `vite.config.ts`, la del sitio con el plugin de SvelteKit. Vitest prefiere la primera, así que los tests de `src/lib` corren sin SvelteKit, y `src/lib/inspector` importa la librería por rutas relativas, sin el alias `$lib`. `tsconfig.json` extiende el que genera `svelte-kit sync` (por eso `typecheck` y `check` lo ejecutan antes, y `npm install` también, con `prepare`).
@ -326,6 +329,24 @@ Umbrales de cobertura (`vitest.config.ts`), al 100 % en líneas, ramas, funcione
- un bucle de propiedades con opciones aleatorias, a veces inválidas: 50 semillas en cada ejecución y 500 con `DATEKEYS_PROPERTY_SEEDS=500` (pasó el 29-09-2026, en 203 s).
Rendimiento en Node 24.9, informativo: `time_only`, unos 120 ms por cápsula en caliente (470 ms la primera, con la carga del código); `time_and_key` con clave portable, unos 200 ms; 64 MiB desde un `Blob` hacia una salida, 50 MiB/s con el SHA-256 en la misma pasada.
- `src/lib/dkc/testing/authorkey-vectors.json`: las claves de autor y la firma Ed25519, contra el paquete `authorkey` de Go y `crypto/ed25519`. Lo escribe `scripts/authorkey-go-vectors.go`, el generador del port de Dart con la salida de esta librería, y lo comprueban `ed25519sign.test.ts` y `authorkey.test.ts`:
- 234 firmas: las líneas de `sign.input` de Go (SUPERCOP, con los tests 1 a 3 del RFC 8032, 7.1, y el de 1023 bytes), TEST SHA(abc), semillas y mensajes de 0 bytes a 1 MiB, y claves cuya mitad pública es otra, que Go usa tal cual;
- la reducción módulo ℓ de 214 números de 64 bytes y 136 productos (a·b + c) mod ℓ, en las esquinas y al azar, con `math/big`;
- `NewFromSeed`, `Public`, `PublicString`, `Secret`, `Marshal` y `String` de 24 semillas, y sus errores;
- `Generate` y `Encrypt` con cada valor que lee `crypto/rand`, en su orden: `encryptAuthorKey`, con esos valores en `crypto.getRandomValues`, escribe el fichero de Go byte a byte, salvo la etiqueta al azar que saca el `ScryptRecipient` de Go y que `age-encryption` no saca;
- `ParsePublic` y `ParseSecret` sobre 1 288 cadenas, como bytes: en otro caso o mezclado, de otras longitudes, con cada error de Bech32, otros prefijos, rellenos, claves no canónicas, fuera de la curva o de orden pequeño, y bytes que no son UTF-8;
- 3 240 runas en los bordes de los conjuntos de `unicode.ToLower`, `unicode.ToUpper` y `unicode.IsSpace` de Go, en el prefijo y en los datos;
- `Read` de 130 ficheros en claro y cifrados: comentarios, cada espacio de Go alrededor de la línea, bytes sueltos, los límites de 64 KiB y del `bufio.Scanner`, y ficheros cifrados con otra frase, otro factor, otro stanza, la cabecera editada campo a campo, el nonce o el STREAM cortados, o un MAC cambiado. Los ficheros grandes se guardan como receta, con los valores al azar de Go, y la prueba los vuelve a escribir con `age-encryption`.
Para regenerarlo, en una exportación `git archive` de `datekeys-go` en el tag `spec-v0.12`, sin tocar el repositorio: las órdenes están en la cabecera del script. Todos los valores salen de Go, y cada ejecución escribe los mismos bytes.
- `src/lib/dkc/gounicode.ts` lo escribe `scripts/go-unicode-tables.go` con el toolchain de Go 1.26 (`go run scripts/go-unicode-tables.go -out src/lib/dkc/gounicode.ts`), y comprueba sus tramos contra las funciones de Go en cada punto de código.
- `src/lib/dkc/testing/signing-vectors.json`: los enganches del escritor contra `capsule.EncryptFiles` de Go, que comprueba `encrypt.signing.test.ts`. Lo escribe `scripts/signing-go-vectors_test.go`, que importa paquetes internos y corre como prueba en una exportación `git archive` de `spec-v0.12`:
- 23 recetas de formato 3 en `time_only`, para la ronda 1000. Go escribe cada cápsula con `crypto/rand` leyendo un flujo ChaCha20 fijo, y guarda cada valor en su orden, y lo que recibió y devolvió cada enganche: una clave de autor de una semilla, y las firmas CMS y los tokens RFC 3161 de `internal/cms/cmstest`, cuyos ECDSA y RSA fija `cryptotest.SetGlobalRandom`;
- con esos valores y esas firmas, `encryptFiles` escribe las ocho cápsulas de Go byte a byte: `alg` 1, `alg` 1 y un sello, un sello solo, uno posterior a la fecha (S5), `alg` 2 con dos firmantes sellados, `alg` 2 en un área de 64 KiB, `largeArea` sin ensanchar y un área de 512 bytes de generador de vectores. Pide la firma y el sello sobre los mismos mensajes, y lee en lo que escribe los veredictos y las líneas de `capsule.Open`, también con la clave guardada (F3). La prueba entrega a `age-encryption` y a `ibe.ts` los valores de Go por `crypto.getRandomValues`, sin el sellado de medida de Go, que esta librería calcula con una fórmula, ni las etiquetas al azar de sus recipients, que `age-encryption` no saca, y deja pasar el cegado de las multiplicaciones de noble, que no cambia ningún resultado;
- las otras 15 fallan con el texto de Go: las exclusiones, el área de prueba con `largeArea`, una clave de 31 bytes, una firma de ceros (F2), una firma CMS sin un firmante exigido (F5, con el nombre), sin sellos o que no es una firma (F1), un área que no cabe en 32 KiB o en 64 KiB, `SIGNERS` vacío o repetido, y la aplicación de firma o la autoridad que fallan;
- `samples`: cinco cápsulas que `scripts/signing-ts-samples.mjs` escribe con `encryptFiles`, sus propios valores al azar, un `AuthorKey` nuevo y los certificados, firmas y tokens de `testing/cmsbuild.ts`. `capsule.Open` de Go las abre a sus ficheros y les da los mismos veredictos y las mismas líneas que esta librería.
Para regenerarlo: `node scripts/signing-ts-samples.mjs > ts-signing.json`, y la prueba de Go con `-samples ts-signing.json`; las órdenes están en la cabecera del script. Las cápsulas de Go salen igual en cada ejecución; las muestras son aleatorias y se congelan.
- `src/lib/dkc/testing/capsule-vectors.json`: la interoperabilidad de los writers con Go a nivel de cápsula (plan de la fase 3, sección 8, punto 9, y paso 4 del plan del formato 3), que comprueba `interop.test.ts`. Se regeneró el 02-10-2026 con el escritor de la v0.11, contra `spec-v0.11`. `scripts/capsule-ts-samples.mjs` escribe con `encryptVectors` de `testing/encrypt.ts`, como generador de vectores, trece cápsulas de formato 2 para las rondas 1000, 1001 y 2000:
- `time_only` de 0, 46, 65 536 y 78 000 bytes, con las dos reglas de relleno;
- `time_and_key` con una clave portable, con tres recipients y una clave portable, y con dieciséis recipients;
@ -351,10 +372,10 @@ Aprobadas en el plan de la fase 2 (sección 3 y decisión 5) e instaladas con su
| Paquete | Versión | Licencia | Uso |
|---|---|---|---|
| `age-encryption` | 0.3.1 | BSD-3-Clause | las tres envolturas `age` (§28), con `Identity` y `Recipient` propios para el stanza `tlock` |
| `age-encryption` | 0.3.1 | BSD-3-Clause | las tres envolturas `age` (§28), con `Identity` y `Recipient` propios para el stanza `tlock`; y el stanza scrypt del fichero de una clave de autor (`setPassphrase`, `setScryptWorkFactor` y `addPassphrase`), cuyo scrypt de `@noble/hashes` ya iba en el bundle: `authorkey.ts` no importa ningún módulo nuevo |
| `@noble/curves` | 2.4.0 | MIT | BLS12-381 del núcleo IBE y de la verificación de releases, X25519 de los stanzas, la aritmética de Ed25519 de la firma de `alg` 1 (`ed25519strict.ts`) y ECDSA sobre P-256, P-384 y P-521 de las firmas y los sellos con certificados (`cms.ts`); también el oráculo de `bls12381.contrast.test.ts` |
| `@noble/hashes` | 2.4.0 | MIT | los hashes del IBE, el HKDF de los stanzas X25519, el SHA-256 incremental de `digest.ts`, lo que se firma (`author.ts`), el SHA-512 de Ed25519, y SHA-1 y SHA-2 de CMS (`cms.ts`); se declara porque se importa directamente |
| `@noble/ciphers` | 2.4.0 | MIT | el ChaCha20-Poly1305 de los stanzas X25519 (`x25519.ts`): la misma copia que usa `age-encryption`, así que no añade nada al bundle. Aprobada el 28-09-2026 para abrir los stanzas de uno en uno, como exige §36 |
| `@noble/hashes` | 2.4.0 | MIT | los hashes del IBE, el HKDF de los stanzas X25519 y del STREAM de un fichero de clave de autor, el SHA-256 incremental de `digest.ts`, lo que se firma (`author.ts`), el SHA-512 de Ed25519, al verificar y al firmar (`ed25519sign.ts`), y SHA-1 y SHA-2 de CMS (`cms.ts`); se declara porque se importa directamente |
| `@noble/ciphers` | 2.4.0 | MIT | el ChaCha20-Poly1305 de los stanzas X25519 (`x25519.ts`) y del STREAM de un fichero de clave de autor (`authorkey.ts`): la misma copia que usa `age-encryption`, así que no añade nada al bundle. Aprobada el 28-09-2026 para abrir los stanzas de uno en uno, como exige §36 |
`age-encryption` arrastra `@noble/ciphers` 2.4.0, `@scure/base` 2.4.0 y `@noble/post-quantum` 0.5.4, todos con licencia MIT. `@noble/post-quantum` fija `@noble/curves` y `@noble/hashes` a `~2.0.0` y trae su propia copia 2.0.1, que usa para el ML-KEM híbrido. La decisión 5 la acepta, sin overrides de npm.
@ -362,8 +383,8 @@ Guardas de `src/lib/dependencies.test.ts`, en cada `npm test`:
- `package.json` declara exactamente estas cuatro dependencias, con versión exacta;
- `package-lock.json` no contiene `tlock-js` ni `drand-client`, ningún noble 1.x, ni más copias 2.x de `@noble/curves` o `@noble/hashes` que la 2.4.0 de la raíz y la 2.0.1 bajo `@noble/post-quantum`;
- ningún fichero de `src/` importa `tlock-js` ni `drand-client`;
- solo `ageio.ts`, `author.ts`, `cms.ts`, `digest.ts`, `ed25519strict.ts`, `ibe.ts`, `release.ts`, `x25519.ts` y los tests nombran `@noble/`, siempre con subrutas de `@noble/curves`, `@noble/hashes` y `@noble/ciphers` que resuelven a la copia 2.4.0 de la raíz;
- solo `agefile.ts`, `open.ts`, `tlock.ts`, `writer.ts` y los tests importan `age-encryption`; solo `encrypt.ts`, `testing/` y los tests importan `writer.ts`, cuyo núcleo recibe la aleatoriedad de quien lo llama (plan de la fase 3, decisiones 4 y 13); e `index.ts` no reexporta la apertura, el writer, los módulos de la v0.11 ni nada de `testing/`;
- solo `ageio.ts`, `author.ts`, `authorkey.ts`, `cms.ts`, `digest.ts`, `ed25519sign.ts`, `ed25519strict.ts`, `ibe.ts`, `release.ts`, `x25519.ts` y los tests nombran `@noble/`, siempre con subrutas de `@noble/curves`, `@noble/hashes` y `@noble/ciphers` que resuelven a la copia 2.4.0 de la raíz;
- solo `agefile.ts`, `authorkey.ts`, `open.ts`, `tlock.ts`, `writer.ts` y los tests importan `age-encryption`; solo `encrypt.ts`, `testing/` y los tests importan `writer.ts`, cuyo núcleo recibe la aleatoriedad de quien lo llama (plan de la fase 3, decisiones 4 y 13); e `index.ts` no reexporta la apertura, el writer, los módulos de la v0.11 ni nada de `testing/`;
- solo los tests y `testing/` mismo importan `testing/`, cuyos ayudantes escriben el formato 2 y otra área, lo que solo puede escribir un generador de vectores;
- cada comprobación se ejecuta también sobre entradas malas, así que una guarda que dejara de detectar algo fallaría.

@ -35,6 +35,9 @@
// of on demand, or a page of ON_DEMAND cannot load its code on demand: the
// opening on /inspect (plan of phase 2, section 9) and the writer on
// /create (plan of phase 3, section 3);
// - a page loads the author keys or the signing of alg 1 (authorkey.ts,
// ed25519sign.ts and their tables of Go, gounicode.ts) with the page, or
// /inspect, which only reads signatures, loads them at all;
// - licenses.txt lacks the notice of a package in the client bundle, the
// copyright lines kept in the header of a module of src/ derived from
// another project, or the license of the site.
@ -339,6 +342,8 @@ const weight = (set) => {
// its code when the person opens or creates a capsule.
const OPENING_PACKAGES = /^(?:@noble\/|@scure\/|age-encryption$)/;
// The modules that make author keys and sign with them.
const SIGNING = /\/src\/lib\/dkc\/(?:authorkey|ed25519sign|gounicode)\.ts$/;
const ON_DEMAND = {
'inspect.html': ['age-encryption', '@noble/curves', '@noble/ciphers'],
'create.html': ['age-encryption', '@noble/curves', '@noble/ciphers', '@noble/hashes'],
@ -374,6 +379,11 @@ for (const f of htmlFiles.filter(existsSync)) {
for (const n of ON_DEMAND[basename(f)] ?? []) {
if (!later.has(n)) fail(`${rel(f)}: the code loaded on demand lacks ${n}`);
}
// The author keys and the signing of alg 1 never come with a page, and
// /inspect, which only reads signatures, never loads them.
const signing = (c) => Object.keys(chunks[c]?.modules ?? {}).filter((id) => SIGNING.test(id.replaceAll('\\', '/')));
for (const id of [...eager].flatMap(signing)) fail(`${rel(f)} loads ${id} with the page`);
if (basename(f) === 'inspect.html') for (const id of [...lazy].flatMap(signing)) fail(`${rel(f)} can load ${id}, which signs`);
}
// ---------------------------------------------------------------------------

@ -0,0 +1,492 @@
// Writes src/lib/dkc/testing/signing-vectors.json, the interoperability of
// the hooks of the writer of this library with the Go reference at
// spec-v0.12: what capsule.EncryptFiles writes when it signs with alg 1 or
// alg 2 and seals with seal_type 2, and how Go reads what encryptFiles of
// this library writes.
//
// cases: for each recipe of this file, EncryptFiles runs while crypto/rand
// reads a ChaCha20 keystream under SHA-256(seed) and a zero nonce, and each
// draw is recorded in hexadecimal, in its order: the salt of the head,
// capsule_id, I_PAYLOAD, what age draws for the measured seal, the real seal
// and PAYLOAD_AGE. The hooks are those of the tests of package capsule
// (signed_test.go): an author key from a seed (alg 1), and the CMS signatures
// and RFC 3161 tokens of internal/cms/cmstest (alg 2 and seal_type 2), whose
// ECDSA and RSA draw from Go's internal generator, which only
// testing/cryptotest.SetGlobalRandom fixes, in a test binary: this file runs
// as a test. What each hook was given and returned is recorded, so that the
// tests of this library hand the writer the same signatures and tokens, check
// that it asks for them over the same messages, and write the same bytes with
// the same draws. For each capsule it records its length, its SHA-256, its
// SECURITY_CBOR and the size of its area, and what capsule.Open gives, with
// and without the author key saved under a label: the verdicts, the lines that
// show them, the head and the files. For each error, its text.
//
// samples: with -samples, the capsules that scripts/signing-ts-samples.mjs
// writes with encryptFiles of this library, with its own random values and
// its own certificates, opened with capsule.Open, with their verdicts and
// lines.
//
// It imports internal packages, so it runs as a test in an export of
// datekeys-go at the tag spec-v0.12 made with git archive, which it does not
// change, never in the repository itself. From the root of this repository:
//
// node scripts/signing-ts-samples.mjs > /tmp/ts-signing.json
// commit=$(git -C ../datekeys-go rev-parse 'spec-v0.12^{commit}')
// tmp=$(mktemp -d)
// git -C ../datekeys-go archive "$commit" | tar -x -C "$tmp"
// mkdir "$tmp/signingvectors"
// cp scripts/signing-go-vectors_test.go "$tmp/signingvectors/"
// (cd "$tmp/signingvectors" && go test -run TestSigningVectors -count=1 \
// -args -source "$commit" -samples /tmp/ts-signing.json \
// -out "$OLDPWD/src/lib/dkc/testing/signing-vectors.json")
// rm -rf "$tmp"
//
// The cases are the same on every run with Go 1.26.8; the samples are
// random, and frozen with what Go gives for them.
package signingvectors
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"io"
"os"
"runtime"
"testing"
"testing/cryptotest"
"time"
"golang.org/x/crypto/chacha20"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/authorkey"
"g.activething.com/go/DateKeys/capsule"
"g.activething.com/go/DateKeys/datekey"
"g.activething.com/go/DateKeys/internal/cms/cmstest"
"g.activething.com/go/DateKeys/internal/testkit"
"g.activething.com/go/DateKeys/profile"
"g.activething.com/go/DateKeys/provider"
)
var (
sourceFlag = flag.String("source", "", "the commit of datekeys-go that this tree exports")
outFlag = flag.String("out", "", "the JSON file to write")
samplesFlag = flag.String("samples", "", "the output of scripts/signing-ts-samples.mjs")
)
type obj = map[string]any
func h(b []byte) string { return hex.EncodeToString(b) }
func sum(b []byte) string {
s := sha256.Sum256(b)
return h(s[:])
}
func must[T any](v T, err error) T {
if err != nil {
panic(err)
}
return v
}
func unhex(s string) []byte { return must(hex.DecodeString(s)) }
// ---------------------------------------------------------------------------
// The recipes
type authorIn struct {
Seed string `json:"seed"` // hex, 32 bytes
Bad string `json:"bad,omitempty"` // zero_signature, short_key
}
type cmsIn struct {
Signers []string `json:"signers"` // names of the certificates that sign
Extra string `json:"extra,omitempty"` // a required signer that does not sign
Unsealed bool `json:"unsealed,omitempty"` // no seal in the signatures
Junk int `json:"junk,omitempty"` // bytes of an unsigned attribute; -1 for the most that still fails as too large
Error string `json:"error,omitempty"` // Sign fails with this text
Garbage bool `json:"garbage,omitempty"` // Sign returns bytes that are no signature
}
type sealerIn struct {
Error string `json:"error,omitempty"` // Seal fails with this text
Late bool `json:"late,omitempty"` // the authority seals an hour after the time of the round
}
type fileIn struct {
Path string `json:"path"`
Text string `json:"text"`
}
type recipe struct {
Name string `json:"name"`
Seed string `json:"seed"`
Files []fileIn `json:"files,omitempty"`
Comment string `json:"comment,omitempty"`
Author string `json:"author,omitempty"`
AuthorKey *authorIn `json:"author_key,omitempty"`
CMS *cmsIn `json:"cms,omitempty"`
Sealer *sealerIn `json:"sealer,omitempty"`
LargeArea bool `json:"large_area,omitempty"`
TestAreaLen uint32 `json:"test_area_len,omitempty"`
}
const authorSeed = "a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0"
func recipes() []recipe {
files := []fileIn{{"nota.txt", "Hola.\n"}, {"fotos/año 2026.txt", "Una foto que no es una foto.\n"}}
key := func() *authorIn { return &authorIn{Seed: authorSeed} }
var out []recipe
add := func(r recipe) {
r.Seed = "signing " + r.Name
out = append(out, r)
}
// Capsules.
add(recipe{Name: "alg 1", Files: files, Comment: "Firmado con mi clave.", Author: "Ana López", AuthorKey: key()})
add(recipe{Name: "alg 1 and a seal", Files: files, AuthorKey: key(), Sealer: &sealerIn{}})
add(recipe{Name: "a seal alone", Files: files[:1], Sealer: &sealerIn{}})
add(recipe{Name: "alg 2, two signers, sealed", Files: files, Comment: "Firmado por los dos.", CMS: &cmsIn{Signers: []string{"Ana López", "Luis Gómez"}}})
add(recipe{Name: "alg 2, a large area", Files: files[:1], CMS: &cmsIn{Signers: []string{"Ana López"}, Junk: 40000}, LargeArea: true})
add(recipe{Name: "alg 1, a large area that does not widen", Comment: "Solo un comentario.", AuthorKey: key(), LargeArea: true})
add(recipe{Name: "alg 1, an area of 512", Files: files[:1], AuthorKey: key(), TestAreaLen: 512})
add(recipe{Name: "a seal after the date", Files: files[:1], AuthorKey: key(), Sealer: &sealerIn{Late: true}})
// Errors.
add(recipe{Name: "AuthorKey and CMSSigner", Files: files, AuthorKey: key(), CMS: &cmsIn{Signers: []string{"Ana López"}}})
add(recipe{Name: "CMSSigner and Sealer", Files: files, CMS: &cmsIn{Signers: []string{"Ana López"}}, Sealer: &sealerIn{}})
add(recipe{Name: "a test area and LargeArea", Files: files, AuthorKey: key(), TestAreaLen: 512, LargeArea: true})
add(recipe{Name: "an author key of 31 bytes", Files: files, AuthorKey: &authorIn{Seed: authorSeed, Bad: "short_key"}})
add(recipe{Name: "a signature of zeros", Files: files, AuthorKey: &authorIn{Seed: authorSeed, Bad: "zero_signature"}})
add(recipe{Name: "a signature of zeros and a seal", Files: files, AuthorKey: &authorIn{Seed: authorSeed, Bad: "zero_signature"}, Sealer: &sealerIn{}})
add(recipe{Name: "alg 2 that does not fit", Files: files, CMS: &cmsIn{Signers: []string{"Ana López"}, Junk: 40000}})
add(recipe{Name: "the signing application fails", Files: files, CMS: &cmsIn{Signers: []string{"Ana López"}, Error: "la persona canceló la firma"}})
add(recipe{Name: "the authority fails", Files: files, AuthorKey: key(), Sealer: &sealerIn{Error: "the authority does not answer"}})
add(recipe{Name: "alg 2 without a required signer", Files: files, CMS: &cmsIn{Signers: []string{"Ana López"}, Extra: "Luis Gómez"}})
add(recipe{Name: "alg 2 without seals", Files: files, CMS: &cmsIn{Signers: []string{"Luis Gómez"}, Unsealed: true}})
add(recipe{Name: "alg 2 that is not a signature", Files: files, CMS: &cmsIn{Signers: []string{"Ana López"}, Garbage: true}})
add(recipe{Name: "SIGNERS empty", Files: files, CMS: &cmsIn{}})
add(recipe{Name: "SIGNERS twice", Files: files, CMS: &cmsIn{Signers: []string{"Ana López", "Ana López"}}})
// Last, since its search draws from the generator of the test.
add(recipe{Name: "alg 2 too large for any area", Files: files[:1], CMS: &cmsIn{Signers: []string{"Ana López"}, Junk: -1}, LargeArea: true})
return out
}
// seeded is the crypto/rand.Reader of a case: the ChaCha20 keystream under
// SHA-256(seed) and a zero nonce. It records every draw.
type seeded struct {
c *chacha20.Cipher
draws [][]byte
}
func newSeeded(seed string) *seeded {
key := sha256.Sum256([]byte(seed))
return &seeded{c: must(chacha20.NewUnauthenticatedCipher(key[:], make([]byte, chacha20.NonceSize)))}
}
func (s *seeded) Read(p []byte) (int, error) {
clear(p)
s.c.XORKeyStream(p, p)
s.draws = append(s.draws, bytes.Clone(p))
return len(p), nil
}
// ---------------------------------------------------------------------------
// The keys and the hooks
type certs struct {
byName map[string]cmstest.Signer
tsa cmstest.Signer
}
var certFrom, certTo = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2040, 1, 1, 0, 0, 0, 0, time.UTC)
func newCerts() *certs {
c := &certs{byName: map[string]cmstest.Signer{}}
c.byName["Ana López"] = cmstest.NewECDSA("Ana López", elliptic.P256(), certFrom, certTo)
c.byName["Luis Gómez"] = cmstest.NewRSA("Luis Gómez", 2048, certFrom, certTo)
c.tsa = cmstest.NewECDSA("TSA de prueba", elliptic.P256(), certFrom, certTo)
return c
}
type hooks struct{ rec obj }
func (k *hooks) set(name string, v any) { k.rec[name] = v }
type authorHook struct {
key *authorkey.Key
bad string
k *hooks
}
func (a *authorHook) Public() []byte {
pub := a.key.Public()
if a.bad == "short_key" {
pub = pub[:31]
}
a.k.set("author_public", h(pub))
return pub
}
func (a *authorHook) Sign(msg []byte) []byte {
sig := a.key.Sign(msg)
if a.bad == "zero_signature" {
sig = make([]byte, ed25519.SignatureSize)
}
a.k.set("author_message", h(msg))
a.k.set("author_signature", h(sig))
return sig
}
type cmsHook struct {
in cmsIn
c *certs
when time.Time
k *hooks
}
func (s *cmsHook) signers() []cmstest.Signer {
var out []cmstest.Signer
for _, n := range s.in.Signers {
out = append(out, s.c.byName[n])
}
return out
}
func (s *cmsHook) Signers() [][32]byte {
out := [][32]byte{}
for _, x := range s.signers() {
out = append(out, sha256.Sum256(x.Cert.Raw))
}
if s.in.Extra != "" {
out = append(out, sha256.Sum256(s.c.byName[s.in.Extra].Cert.Raw))
}
list := []string{}
for _, x := range out {
list = append(list, h(x[:]))
}
s.k.set("cms_signers", list)
return out
}
func (s *cmsHook) der(msg []byte, junk int) []byte {
o := cmstest.Options{Junk: junk}
if !s.in.Unsealed {
o.Token = func(sig []byte) []byte {
return cmstest.Token(sig, s.when, cmstest.TokenOptions{Accuracy: time.Second}, s.c.tsa)
}
}
return cmstest.Signature(msg, o, s.signers()...)
}
func (s *cmsHook) Sign(msg []byte) ([]byte, error) {
s.k.set("cms_message", h(msg))
if s.in.Error != "" {
return nil, errors.New(s.in.Error)
}
var der []byte
switch {
case s.in.Garbage:
der = []byte("not a signature")
case s.in.Junk < 0:
// The most junk whose signature still fits in key 2 of the
// reader, 64 KiB, while SECURITY_CBOR is more than 64 KiB.
base := len(s.der(msg, 1))
junk := 65536 - 45 - base
for der = s.der(msg, junk); len(der) > 65536-45; der = s.der(msg, junk) {
junk--
}
default:
der = s.der(msg, s.in.Junk)
}
s.k.set("cms_der", h(der))
return der, nil
}
type sealHook struct {
in sealerIn
c *certs
when time.Time
k *hooks
}
func (s *sealHook) Seal(subject [32]byte) ([]byte, error) {
s.k.set("seal_subject", h(subject[:]))
if s.in.Error != "" {
return nil, errors.New(s.in.Error)
}
token := cmstest.Token(subject[:], s.when, cmstest.TokenOptions{}, s.c.tsa)
s.k.set("seal_token", h(token))
return token, nil
}
// ---------------------------------------------------------------------------
// Writing and opening
func sourceOf(f fileIn) capsule.Source {
return capsule.Source{Path: f.Path, Size: int64(len(f.Text)), Open: func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader([]byte(f.Text))), nil
}}
}
// The genesis of Quicknet: the capsules open at round 1000.
var genesis = time.Unix(profile.Quicknet().GenesisTime, 0).UTC()
func run(r recipe, c *certs) (*capsule.Result, []byte, *seeded, obj, error) {
q := profile.Quicknet()
opts := capsule.EncryptOptions{
Profile: q, UnlockAt: must(datekey.RoundTime(q, 1000)), Now: func() time.Time { return genesis },
Comment: r.Comment, Author: r.Author, LargeArea: r.LargeArea,
TestVectors: r.TestAreaLen != 0, TestAreaLen: r.TestAreaLen,
}
k := &hooks{rec: obj{}}
if r.AuthorKey != nil {
opts.AuthorKey = &authorHook{key: must(authorkey.NewFromSeed(unhex(r.AuthorKey.Seed))), bad: r.AuthorKey.Bad, k: k}
}
if r.CMS != nil {
opts.CMSSigner = &cmsHook{in: *r.CMS, c: c, when: genesis, k: k}
}
if r.Sealer != nil {
when := genesis
if r.Sealer.Late {
when = opts.UnlockAt.Add(time.Hour)
}
opts.Sealer = &sealHook{in: *r.Sealer, c: c, when: when, k: k}
}
var sources []capsule.Source
for _, f := range r.Files {
sources = append(sources, sourceOf(f))
}
s := newSeeded(r.Seed)
old := rand.Reader
rand.Reader = s
var dst bytes.Buffer
res, err := capsule.EncryptFiles(&dst, sources, opts)
rand.Reader = old
return res, dst.Bytes(), s, k.rec, err
}
func releases() provider.ReleaseSource {
return testkit.NewSource(testkit.Release(1000))
}
// opened is what capsule.Open gives for dkc, with the author keys saved.
func opened(dkc []byte, keys map[string]string) obj {
files := &testkit.MemorySink{}
o := capsule.OpenOptions{
Registry: testkit.Registry(), Source: releases(), Sink: files, AuthorKeys: keys,
Now: func() time.Time { return time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC) },
}
res, err := capsule.Open(context.Background(), nil, bytes.NewReader(dkc), o)
v := obj{}
if err != nil {
v["result"] = datekeys.Code(err)
if v["result"] == "" {
v["result"] = "error: " + err.Error()
}
return v
}
v["result"] = "ok"
fs := []obj{}
for i, f := range res.Head.Files {
fs = append(fs, obj{"path": f.Path, "size": f.Size, "sha256": sum(files.Files[i])})
}
v["files"] = fs
v["head"] = h(must(capsule.EncodeHead(res.Head)))
v["verdicts"] = []string{string(res.Verdicts.Signature), string(res.Verdicts.Seal)}
if res.Verdicts.AuthorKey != ([32]byte{}) {
v["author_key"] = h(res.Verdicts.AuthorKey[:])
}
v["lines"] = res.Verdicts.Lines()
v["area_len"] = res.AreaLen
v["length"] = res.PayloadLength
return v
}
func caseOf(r recipe, c *certs) obj {
res, dkc, s, rec, err := run(r, c)
draws := []obj{}
for _, d := range s.draws {
draws = append(draws, obj{"n": len(d), "hex": h(d)})
}
out := obj{"recipe": r, "draws": draws}
if len(rec) > 0 {
out["hooks"] = rec
}
if err != nil {
out["error"] = err.Error()
if len(dkc) != 0 {
panic(r.Name + ": an error after writing")
}
return out
}
out["written"] = obj{"length": len(dkc), "sha256": sum(dkc), "capsule_id": h(res.CapsuleID[:]), "body_length": res.Length}
out["opened"] = opened(dkc, nil)
if r.AuthorKey != nil {
pub := must(authorkey.PublicString(must(authorkey.NewFromSeed(unhex(r.AuthorKey.Seed))).Public()))
out["opened_saved"] = opened(dkc, map[string]string{pub: "mi clave de 2026"})
}
return out
}
type sampleIn struct {
Name string `json:"name"`
DKC string `json:"dkc"`
AuthorKeys map[string]string `json:"author_keys,omitempty"`
TS obj `json:"ts"`
}
func TestSigningVectors(t *testing.T) {
if *sourceFlag == "" || *outFlag == "" {
t.Skip("run with -args -source COMMIT -out FILE [-samples FILE]")
}
cryptotest.SetGlobalRandom(t, 20261006)
c := newCerts()
cases := []obj{}
for _, r := range recipes() {
v := caseOf(r, c)
cases = append(cases, v)
if e, ok := v["error"]; ok {
t.Logf("%s: %s", r.Name, e)
} else {
t.Logf("%s: %d bytes, %v", r.Name, v["written"].(obj)["length"], v["opened"].(obj)["verdicts"])
}
}
samples := []obj{}
if *samplesFlag != "" {
var in struct {
Samples []sampleIn `json:"samples"`
}
must(0, json.Unmarshal(must(os.ReadFile(*samplesFlag)), &in))
for _, s := range in.Samples {
dkc := unhex(s.DKC)
v := obj{"name": s.Name, "dkc": s.DKC, "ts": s.TS, "opened": opened(dkc, nil)}
if s.AuthorKeys != nil {
v["author_keys"] = s.AuthorKeys
v["opened_saved"] = opened(dkc, s.AuthorKeys)
}
samples = append(samples, v)
t.Logf("sample %s: %v", s.Name, v["opened"].(obj)["verdicts"])
}
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
must(0, enc.Encode(obj{
"description": "What capsule.EncryptFiles of datekeys-go writes when it signs and seals, for each recipe, while crypto/rand reads the keystream of ChaCha20 under SHA-256(seed) with a zero nonce, with each draw and what each hook was given and returned, and how capsule.Open reads it; the text of each error; and how capsule.Open reads the samples that encryptFiles of datekeys-ts wrote (scripts/signing-go-vectors_test.go). The capsules are time_only for round 1000 of Quicknet, written at its genesis.",
"source": *sourceFlag,
"go": runtime.Version(),
"release": h(testkit.Release(1000).Signature),
"cases": cases,
"samples": samples,
}))
must(0, os.WriteFile(*outFlag, buf.Bytes(), 0o644))
t.Logf("wrote %s, %d bytes", *outFlag, buf.Len())
}

@ -0,0 +1,82 @@
#!/usr/bin/env node
// Writes, as JSON, the capsules that scripts/signing-go-vectors_test.go opens
// with the Go reference: capsules of format 3 that encryptFiles of this
// library writes as any caller writes them, with the random values of
// crypto.getRandomValues, signed with alg 1 by a fresh AuthorKey of
// authorkey.ts, sealed with seal_type 2, and signed with alg 2, with the
// certificates, signatures and tokens that src/lib/dkc/testing/cmsbuild.ts
// makes, its own and not those of Go; and, for each, what this library reads
// in it. time_only for round 1000 of Quicknet, written at its genesis.
//
// The capsules are random, so the output is generated once and frozen with
// what Go reads in src/lib/dkc/testing/signing-vectors.json. Node runs the
// TypeScript sources directly (type stripping, Node 22.6+):
//
// node scripts/signing-ts-samples.mjs > ts-signing.json
import { readFileSync } from 'node:fs';
import { AuthorKey } from '../src/lib/dkc/authorkey.ts';
import { fromHex, toHex } from '../src/lib/dkc/bytes.ts';
import { parseRFC3339 } from '../src/lib/dkc/datekey.ts';
import { encryptFiles } from '../src/lib/dkc/encrypt.ts';
import { TIME_ONLY } from '../src/lib/dkc/header.ts';
import { open } from '../src/lib/dkc/open.ts';
import { quicknet } from '../src/lib/dkc/profile.ts';
import { suppliedRelease } from '../src/lib/dkc/release.ts';
import { verdictLines } from '../src/lib/dkc/security.ts';
import { MemorySink } from '../src/lib/dkc/sink.ts';
import * as b from '../src/lib/dkc/testing/cmsbuild.ts';
const GENESIS = parseRFC3339('2023-08-23T15:09:27Z');
const at = (r) => ({ seconds: GENESIS.seconds + (r - 1) * 3, nanos: 0 });
const signedAt = new Date(GENESIS.seconds * 1000);
// The release of round 1000 of Quicknet, as drand published it, from a fixture.
const fixture = JSON.parse(readFileSync(new URL('../testdata/fixtures/format3_single.json', import.meta.url), 'utf8'));
const RELEASE = { round: fixture.release.round, signature: fromHex(fixture.release.signature) };
const te = new TextEncoder();
const file = (path, text) => {
const bytes = te.encode(text);
return { path, size: bytes.length, open: () => new Blob([bytes]).stream() };
};
const files = [file('carta.txt', 'Querida Ana:\n'), file('docs/año 2026/acta.txt', 'Acta de la reunión.\n')];
const options = (extra) => ({ profile: quicknet(), unlockAt: at(1000), policy: TIME_ONLY, now: () => GENESIS, ...extra });
const from = new Date('2020-01-01T00:00:00Z');
const to = new Date('2040-01-01T00:00:00Z');
const tsa = await b.newECDSA('Autoridad de sellado de prueba', 'P-256', from, to);
const ana = await b.newECDSA('Ana Pérez', 'P-384', from, to);
const luis = await b.newRSA('Luis Martín', 2048, from, to);
const sealer = { seal: (subject) => b.token(subject, signedAt, {}, tsa) };
const certHash = async (s) => new Uint8Array(await crypto.subtle.digest('SHA-256', s.cert));
const cms = (signers, extra = {}) => ({
signers: () => signers.hashes,
sign: (msg) => b.signature(msg, { token: (sig) => b.token(sig, signedAt, { accuracy: b.accuracyOf(1) }, tsa), ...extra }, ...signers.list),
});
const signersOf = async (...list) => ({ list, hashes: await Promise.all(list.map(certHash)) });
const key = AuthorKey.generate();
const saved = { [key.publicString()]: 'la clave de prueba' };
const samples = [];
const add = async (name, extra, authorKeys) => {
const w = await encryptFiles(files, options(extra));
const sink = new MemorySink();
const o = await open(w.dkc, {
source: suppliedRelease(RELEASE),
now: () => at(1000),
sink,
...(authorKeys === undefined ? {} : { authorKeys: new Map(Object.entries(authorKeys)) }),
});
if (o.error !== undefined) throw o.error;
samples.push({
name,
dkc: toHex(w.dkc),
...(authorKeys === undefined ? {} : { author_keys: authorKeys }),
ts: { verdicts: [o.verdicts.signature, o.verdicts.seal], lines: verdictLines(o.verdicts), area_len: o.areaLen },
});
};
await add('alg 1', { authorKey: key, comment: 'Firmado.' }, saved);
await add('alg 1 and a seal', { authorKey: key, sealer }, saved);
await add('a seal alone', { sealer });
await add('alg 2, two signers, sealed', { cmsSigner: cms(await signersOf(ana, luis)) });
await add('alg 2, a large area', { cmsSigner: cms(await signersOf(ana), { junk: 40000 }), largeArea: true });
process.stdout.write(`${JSON.stringify({ samples }, null, 1)}\n`);

@ -0,0 +1,519 @@
// Tests of the hooks of encryptFiles (writer.ts): the signature of alg 1 with
// an author key, the signature of alg 2 with certificates, the seal of
// seal_type 2 and the large area (spec §29.2, §29.3, §29.8 to §29.11, §62.1
// rules 13, 17, 19 and 21), against src/lib/dkc/testing/signing-vectors.json,
// written by scripts/signing-go-vectors_test.go:
//
// - cases: with the draws of crypto/rand of capsule.EncryptFiles of Go, in
// their order, and its hooks' signatures and tokens, encryptFiles writes
// the capsule of Go byte for byte, asks the hooks over the same messages,
// and reads in it the verdicts and lines of capsule.Open; and it fails with
// the text of Go where Go fails;
// - samples: what encryptFiles wrote with its own random values and its own
// certificates (scripts/signing-ts-samples.mjs), which capsule.Open of Go
// reads with the verdicts and lines that open gives here.
//
// And the hooks as this library defines them: asynchronous, checked as types,
// called before anything is written, and their failures.
import { readFileSync } from 'node:fs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AuthorKey } from './authorkey.ts';
import { ALG_ED25519, authorMessage, controlCommit, headDigest, sealSubject, signersDigest } from './author.ts';
import { AREA_LEN, AREA_UNIT, LARGE_AREA_LEN, parseBodyFrame } from './body.ts';
import type { Control } from './control.ts';
import { type Instant, parseRFC3339, resolveDateKey } from './datekey.ts';
import { encrypt, encryptFiles, type EncryptOptions, type FileSource, MAX_MEMORY_DKC } from './encrypt.ts';
import { bodyLength, capsuleLength } from './lengths.ts';
import { FORMAT_3, headerBinding } from './framing.ts';
import { TIME_ONLY } from './header.ts';
import { open, type OpenOptions, payloadIdentity } from './open.ts';
import { BLOQUE256, REFORZADO } from './padding.ts';
import { quicknet } from './profile.ts';
import { suppliedRelease } from './release.ts';
import { evaluateSecurity, verdictLines } from './security.ts';
import { encodeSigners } from './securitycms.ts';
import { MemorySink } from './sink.ts';
import { split } from './testing/capsule.ts';
import * as b from './testing/cmsbuild.ts';
import { encryptFilesWith, encryptVectors } from './testing/encrypt.ts';
import { h, hx } from './testing/testdata.ts';
import { decrypt } from './agefile.ts';
import type { AuthorSigner, CmsSigner, Sealer } from './writer.ts';
interface Recipe {
name: string;
seed: string;
files?: { path: string; text: string }[];
comment?: string;
author?: string;
author_key?: { seed: string; bad?: string };
cms?: { signers: string[] | null; extra?: string; unsealed?: boolean; junk?: number; error?: string; garbage?: boolean };
sealer?: { error?: string };
large_area?: boolean;
test_area_len?: number;
}
interface Opened {
result: string;
files?: { path: string; size: number; sha256: string }[];
head?: string;
verdicts?: [string, string];
author_key?: string;
lines?: string[];
area_len?: number;
length?: number;
}
interface Case {
recipe: Recipe;
draws: { n: number; hex: string }[];
hooks?: Record<string, string | string[]>;
error?: string;
written?: { length: number; sha256: string; capsule_id: string; body_length: number };
opened?: Opened;
opened_saved?: Opened;
}
interface Sample {
name: string;
dkc: string;
author_keys?: Record<string, string>;
ts: { verdicts: [string, string]; lines: string[]; area_len: number };
opened: Opened;
opened_saved?: Opened;
}
const V = JSON.parse(readFileSync(new URL('./testing/signing-vectors.json', import.meta.url), 'utf8')) as {
release: string;
cases: Case[];
samples: Sample[];
};
const GENESIS: Instant = parseRFC3339('2023-08-23T15:09:27Z');
const at = (r: number): Instant => ({ seconds: GENESIS.seconds + (r - 1) * 3, nanos: 0 });
const RELEASE = { round: 1000, signature: h(V.release) };
const te = new TextEncoder();
const opening = (extra: Partial<OpenOptions> = {}): OpenOptions => ({ source: suppliedRelease(RELEASE), now: () => at(1000), ...extra });
const options = (extra: Partial<EncryptOptions> = {}): EncryptOptions => ({ profile: quicknet(), unlockAt: at(1000), policy: TIME_ONLY, now: () => GENESIS, ...extra });
const source = (path: string, text: string | Uint8Array): FileSource => {
const bytes = typeof text === 'string' ? te.encode(text) : text;
return { path, size: bytes.length, open: () => new Blob([bytes as Uint8Array<ArrayBuffer>]).stream() };
};
const SAVED_LABEL = 'mi clave de 2026';
async function sha256Hex(b: Uint8Array): Promise<string> {
return hx(new Uint8Array(await crypto.subtle.digest('SHA-256', b as Uint8Array<ArrayBuffer>)));
}
afterEach(() => {
vi.restoreAllMocks();
});
// Hands each call of crypto.getRandomValues the next of `draws`, which must
// have its length; but for the blinding of the scalar multiplications of
// @noble/curves, which changes no result and which Go does not draw.
function replay(draws: readonly Uint8Array[]): () => number {
const queue = [...draws];
const own = crypto.getRandomValues.bind(crypto);
vi.spyOn(crypto, 'getRandomValues').mockImplementation(<T extends ArrayBufferView | null>(a: T): T => {
if (new Error().stack!.includes('mulCTBlinded')) return own(a!) as T;
const d = queue.shift();
const view = new Uint8Array(a!.buffer, a!.byteOffset, a!.byteLength);
if (d === undefined || d.length !== view.length) throw new Error(`replay: a draw of ${view.length} bytes, not the next of Go (${d?.length})`);
view.set(d);
return a;
});
return () => queue.length;
}
// What a capsule opens to, in the shape of the vectors.
async function openedOf(dkc: Uint8Array, keys?: Record<string, string>): Promise<Opened> {
const sink = new MemorySink();
const o = await open(dkc, opening({ sink, ...(keys === undefined ? {} : { authorKeys: new Map(Object.entries(keys)) }) }));
if (o.error !== undefined) return { result: o.error.code };
const head = sink.opened!.head;
const files = await Promise.all(head.files.map(async (f, i) => ({ path: f.path, size: f.size, sha256: await sha256Hex(sink.opened!.files[i]!) })));
return {
result: 'ok',
files,
verdicts: [o.verdicts!.signature, o.verdicts!.seal],
...(o.verdicts!.authorKey === undefined ? {} : { author_key: hx(o.verdicts!.authorKey) }),
lines: verdictLines(o.verdicts!),
area_len: o.areaLen!,
};
}
const pick = (o: Opened): Opened => {
const { head: _head, length: _length, ...rest } = o;
return rest;
};
describe('encryptFiles with the hooks of capsule.EncryptFiles of Go (signing-vectors.json, cases)', () => {
it('holds the recipes that it should', () => {
const names = V.cases.map((c) => c.recipe.name);
expect(names.length).toBe(23);
expect(V.cases.filter((c) => c.error === undefined).length).toBe(8);
});
for (const c of V.cases) {
const r = c.recipe;
it(`${r.name}: ${c.error === undefined ? 'the capsule of Go, byte for byte, and its verdicts' : 'the error of Go'}`, async () => {
const draws = c.draws.map((d) => h(d.hex));
const asked: Record<string, string | string[]> = {};
const hooks = c.hooks ?? {};
const opts: Partial<EncryptOptions> = {
...(r.comment === undefined ? {} : { comment: r.comment }),
...(r.author === undefined ? {} : { author: r.author }),
...(r.large_area === undefined ? {} : { largeArea: r.large_area }),
};
if (r.author_key !== undefined) {
// The key of the seed, or what the hook of Go returned.
const key = AuthorKey.fromSeed(h(r.author_key.seed));
const signer: AuthorSigner = {
publicKey: () => {
asked.author_public = hx(key.publicKey());
return h(hooks.author_public as string);
},
sign: async (msg) => {
asked.author_message = hx(msg);
const sig = r.author_key!.bad === 'zero_signature' ? new Uint8Array(64) : key.sign(msg);
asked.author_signature = hx(sig);
return sig;
},
};
(opts as { authorKey: AuthorSigner }).authorKey = signer;
}
if (r.cms !== undefined) {
const cms: CmsSigner = {
signers: () => ((hooks.cms_signers as string[] | undefined) ?? []).map(h),
sign: (msg) => {
asked.cms_message = hx(msg);
if (r.cms!.error !== undefined) throw new Error(r.cms!.error);
return h(hooks.cms_der as string);
},
};
(opts as { cmsSigner: CmsSigner }).cmsSigner = cms;
}
if (r.sealer !== undefined) {
const sealer: Sealer = {
seal: async (subject) => {
asked.seal_subject = hx(subject);
if (r.sealer!.error !== undefined) throw new Error(r.sealer!.error);
return h(hooks.seal_token as string);
},
};
(opts as { sealer: Sealer }).sealer = sealer;
}
const files = (r.files ?? []).map((f) => source(f.path, f.text));
// The salt, capsule_id and I_PAYLOAD are draws of the writer; then Go
// seals a measured control (four draws, which this writer does not
// need: it measures with a formula), seals the control (the file key,
// sigma, a random label that age-encryption does not draw, the nonce)
// and writes PAYLOAD_AGE (the file key, the ephemeral share, the
// nonce).
const fixed = draws.length >= 3 ? { salt: draws[0]!, capsuleId: draws[1]!, payloadIdentity: draws[2]! } : {};
let left = (): number => 0;
if (draws.length === 14) {
expect(c.draws.map((d) => d.n)).toEqual([32, 16, 32, 16, 16, 16, 16, 16, 16, 16, 16, 16, 32, 16]);
left = replay([7, 8, 10, 11, 12, 13].map((i) => draws[i]!));
}
const write = encryptFilesWith(files, options(opts), fixed, r.test_area_len === undefined ? undefined : { areaLen: r.test_area_len });
if (c.error !== undefined) {
await expect(write).rejects.toThrow(c.error);
await write.catch((err: Error) => expect(err.message).toBe(c.error));
} else {
const w = await write;
expect(left()).toBe(0);
vi.restoreAllMocks();
expect(w.dkc!.length).toBe(c.written!.length);
expect(await sha256Hex(w.dkc!)).toBe(c.written!.sha256);
expect(hx(w.capsuleId)).toBe(c.written!.capsule_id);
expect(w.length).toBe(c.written!.body_length);
expect(await openedOf(w.dkc!)).toEqual(pick(c.opened!));
if (c.opened_saved !== undefined) {
const key = AuthorKey.fromSeed(h(r.author_key!.seed));
expect(await openedOf(w.dkc!, { [key.publicString()]: SAVED_LABEL })).toEqual(pick(c.opened_saved));
}
}
// Each hook was asked over the message of Go, and answered as Go's.
for (const k of ['author_message', 'author_signature', 'cms_message', 'seal_subject']) {
if (k in hooks || k in asked) expect(asked[k], k).toBe(hooks[k]);
}
});
}
});
describe('capsules of encryptFiles, opened by Go (signing-vectors.json, samples)', () => {
it('holds the five samples', () => {
expect(V.samples.map((s) => s.name)).toEqual(['alg 1', 'alg 1 and a seal', 'a seal alone', 'alg 2, two signers, sealed', 'alg 2, a large area']);
});
for (const s of V.samples) {
it(`${s.name}: Go reads the verdicts and lines that this library reads`, async () => {
const dkc = h(s.dkc);
const here = await openedOf(dkc);
expect(here).toEqual(pick(s.opened));
expect([s.opened.verdicts, s.opened.area_len]).toEqual([s.ts.verdicts.map((v) => (v === 'F3' ? 'F4' : v)), s.ts.area_len]);
if (s.author_keys !== undefined) {
expect(await openedOf(dkc, s.author_keys)).toEqual(pick(s.opened_saved!));
expect(s.opened_saved!.lines).toEqual(s.ts.lines);
} else {
expect(s.opened.lines).toEqual(s.ts.lines);
}
});
}
});
// The BODY of a capsule written with I_PAYLOAD fixed: its area, its
// SECURITY_CBOR and its head, read back from PAYLOAD_AGE.
async function bodyOf(dkc: Uint8Array, payloadId: Uint8Array): Promise<{ security: Uint8Array; head: Uint8Array; areaLen: number; length: number }> {
const plain = new Uint8Array(await new Response(await decrypt(new Blob([split(dkc).payload as Uint8Array<ArrayBuffer>]).stream(), payloadIdentity(payloadId), 'PAYLOAD_AGE')).arrayBuffer());
const o = await open(dkc, opening({ sink: new MemorySink() }));
const length = o.payloadLength!;
const frame = parseBodyFrame(plain.subarray(0, 12), length);
return {
security: plain.slice(12, 12 + frame.securityLen),
head: plain.slice(12 + frame.areaLen, 12 + frame.areaLen + frame.headLen),
areaLen: frame.areaLen,
length,
};
}
describe('the hooks', () => {
const from = new Date('2020-01-01T00:00:00Z');
const to = new Date('2040-01-01T00:00:00Z');
const signedAt = new Date(GENESIS.seconds * 1000);
const files = [source('a.txt', 'uno'), source('b/c.txt', 'dos')];
const certHash = async (s: b.Signer): Promise<Uint8Array> => new Uint8Array(await crypto.subtle.digest('SHA-256', s.cert as Uint8Array<ArrayBuffer>));
it('sign and seal before anything is written, and the output gets the capsule only once it is complete', async () => {
const events: string[] = [];
const key = AuthorKey.generate();
const output = new WritableStream<Uint8Array>({
write: () => void events.push('write'),
close: () => void events.push('close'),
});
const tsa = await b.newECDSA('TSA', 'P-256', from, to);
const w = await encryptFiles(files, {
...options(),
output,
authorKey: {
publicKey: () => key.publicKey(),
sign: async (m) => {
events.push('sign');
await new Promise((r) => setTimeout(r, 5));
return key.sign(m);
},
},
sealer: {
seal: async (s) => {
events.push('seal');
return b.token(s, signedAt, {}, tsa);
},
},
});
expect(events.slice(0, 3)).toEqual(['sign', 'seal', 'write']);
expect(events.at(-1)).toBe('close');
expect(w.dkc).toBeUndefined();
});
it('sign AUTHOR_MESSAGE of the control and of the head that the reader holds, and seal SEAL_SUBJECT over the signature', async () => {
const key = AuthorKey.generate();
const tsa = await b.newECDSA('TSA', 'P-256', from, to);
let message: Uint8Array = new Uint8Array(0);
let subject: Uint8Array = new Uint8Array(0);
const payloadId = crypto.getRandomValues(new Uint8Array(32));
const w = await encryptFilesWith(
files,
options({
authorKey: { publicKey: () => key.publicKey(), sign: (m) => ((message = m), key.sign(m)) },
sealer: { seal: (s) => ((subject = s), b.token(s, signedAt, {}, tsa)) },
}),
{ payloadIdentity: payloadId },
);
const o = await openedOf(w.dkc!);
expect([o.verdicts, o.author_key, o.area_len]).toEqual([['F4', 'S4'], hx(key.publicKey()), AREA_LEN]);
expect((await openedOf(w.dkc!, { [key.publicString()]: 'mía' })).lines![0]).toBe('Firmado con la clave que guardaste como mía.');
// The control from the capsule: its binding, I_PAYLOAD and L, whatever L is.
const p = split(w.dkc!);
const body = await bodyOf(w.dkc!, payloadId);
const control: Control = { headerBinding: await headerBinding(p.prelude, p.header), payloadIdentity: payloadId, critical: [], noncritical: [], payloadLength: body.length, padding: REFORZADO };
const cc = controlCommit(control, FORMAT_3);
expect(hx(message)).toBe(hx(authorMessage(cc, headDigest(body.head), signersDigest(ALG_ED25519))));
// The seal seals the signature: key 2 of the area as it is.
const signature = evaluateSecurity(body.security, { controlCommit: cc, headDigest: headDigest(body.head) });
expect(signature.signature).toBe('F4');
expect(hx(subject).length).toBe(64);
expect(hx(subject)).not.toBe(hx(sealSubject(cc, headDigest(body.head), undefined)));
});
it('take a CMS signature with certificates, F6, and widen the area only when it is asked and needed', async () => {
const tsa = await b.newECDSA('TSA', 'P-256', from, to);
const ana = await b.newECDSA('Ana', 'P-256', from, to);
const hash = await certHash(ana);
const signer = (junk: number): CmsSigner => ({
signers: () => [hash],
sign: (m) => b.signature(m, { junk, token: (sig) => b.token(sig, signedAt, { accuracy: b.accuracyOf(1) }, tsa) }, ana),
});
const small = await encryptFiles(files, options({ cmsSigner: signer(0), largeArea: true }));
expect((await openedOf(small.dkc!)).area_len).toBe(AREA_LEN);
const large = await encryptFiles(files, options({ cmsSigner: signer(40000), largeArea: true }));
const o = await openedOf(large.dkc!);
expect([o.verdicts, o.area_len]).toEqual([['F6', 'S0'], LARGE_AREA_LEN]);
expect(large.length).toBe(small.length + LARGE_AREA_LEN - AREA_LEN);
await expect(encryptFiles(files, options({ cmsSigner: signer(40000) }))).rejects.toThrow(/does not fit in the area of 32768 bytes: LargeArea lets the writer widen it to 65536$/);
});
it('write SIGNERS in ascending order of bytes, whatever the order that the signer gives', async () => {
const tsa = await b.newECDSA('TSA', 'P-256', from, to);
const ana = await b.newECDSA('Ana', 'P-256', from, to);
const luis = await b.newECDSA('Luis', 'P-256', from, to);
const hashes = [await certHash(ana), await certHash(luis)].sort((x, y) => (hx(x) < hx(y) ? 1 : -1));
const o = { token: (sig: Uint8Array) => b.token(sig, signedAt, { accuracy: b.accuracyOf(1) }, tsa) };
const w = await encryptFiles(files, options({ cmsSigner: { signers: () => hashes, sign: (m) => b.signature(m, o, ana, luis) } }));
expect((await openedOf(w.dkc!)).verdicts).toEqual(['F6', 'S0']);
expect(hx(encodeSigners(hashes))).toBe(hx(encodeSigners([...hashes].reverse())));
expect(() => encodeSigners([])).toThrow('capsule: SIGNERS holds from 1 to 16 certificates');
expect(() => encodeSigners(Array.from({ length: 17 }, (_, i) => new Uint8Array(32).fill(i)))).toThrow('capsule: SIGNERS holds from 1 to 16 certificates');
expect(() => encodeSigners([hashes[0]!, hashes[1]!, hashes[0]!])).toThrow('capsule: SIGNERS names a certificate twice');
});
// A first reading of 1 GiB: a few seconds, but minutes under the coverage of
// v8, so it runs with DATEKEYS_LARGE=1 (it passed on 2026-10-06).
it.skipIf(process.env.DATEKEYS_LARGE !== '1')('count the large area in the limit of a capsule in memory', async () => {
const tsa = await b.newECDSA('TSA', 'P-256', from, to);
const ana = await b.newECDSA('Ana', 'P-256', from, to);
const hash = await certHash(ana);
// A file that fits in memory with the area of 32 KiB, and not with that of 64 KiB.
const profileId = resolveDateKey(quicknet(), at(1000)).profileId;
const total = (length: number): number => capsuleLength({ profileId, round: 1000, policy: TIME_ONLY, length, padding: BLOQUE256 });
const body = (size: number): number => bodyLength({ files: [{ path: 'grande', size }] });
let lo = 0;
let hi = 1 << 30;
while (lo < hi) {
const mid = Math.floor((lo + hi) / 2);
if (total(body(mid) + LARGE_AREA_LEN - AREA_LEN) > MAX_MEMORY_DKC) hi = mid;
else lo = mid + 1;
}
const size = lo;
expect(total(body(size))).toBeLessThanOrEqual(MAX_MEMORY_DKC);
let readings = 0;
const huge: FileSource = {
path: 'grande',
size,
open: () => {
readings++;
let left = size;
return new ReadableStream<Uint8Array>({
pull(c) {
const n = Math.min(left, 4 << 20);
if (n === 0) c.close();
else c.enqueue(new Uint8Array(n));
left -= n;
},
});
},
};
const cms: CmsSigner = { signers: () => [hash], sign: (m) => b.signature(m, { junk: 40000, token: (sig) => b.token(sig, signedAt, {}, tsa) }, ana) };
const err = (await encryptFiles([huge], options({ cmsSigner: cms, largeArea: true, padding: BLOQUE256 })).catch((e: Error) => e)) as Error;
expect(err).toBeInstanceOf(TypeError);
expect(err.message).toBe(`encrypt: a capsule of ${total(body(size) + LARGE_AREA_LEN - AREA_LEN)} bytes needs EncryptOptions.output; in memory the limit is ${MAX_MEMORY_DKC}`);
// Only the first reading: nothing was written.
expect(readings).toBe(1);
}, 120_000);
it('may be synchronous or asynchronous', async () => {
const key = AuthorKey.generate();
const sync = await encryptFiles(files, options({ authorKey: { publicKey: () => key.publicKey(), sign: (m) => key.sign(m) } }));
const later = await encryptFiles(files, options({ authorKey: { publicKey: () => key.publicKey(), sign: async (m) => key.sign(m) } }));
expect((await openedOf(sync.dkc!)).verdicts).toEqual(['F4', 'S0']);
expect((await openedOf(later.dkc!)).verdicts).toEqual(['F4', 'S0']);
});
it('copy what they return: a hook that changes its bytes afterwards changes nothing', async () => {
const key = AuthorKey.generate();
const w = await encryptFiles(
files,
options({
authorKey: {
publicKey: () => key.publicKey(),
sign: (m) => {
const sig = key.sign(m);
setTimeout(() => sig.fill(0), 0);
return sig;
},
},
}),
);
expect((await openedOf(w.dkc!)).verdicts).toEqual(['F4', 'S0']);
});
it('are checked as types before anything else, and an output is aborted', async () => {
const key = AuthorKey.generate();
const cases: [Partial<EncryptOptions>, string][] = [
[{ authorKey: null as unknown as AuthorSigner }, 'encrypt: EncryptOptions.authorKey has no publicKey and sign methods: leave it undefined for none'],
[{ authorKey: { publicKey: () => key.publicKey() } as unknown as AuthorSigner }, 'encrypt: EncryptOptions.authorKey has no publicKey and sign methods: leave it undefined for none'],
[{ cmsSigner: {} as CmsSigner }, 'encrypt: EncryptOptions.cmsSigner has no signers and sign methods: leave it undefined for none'],
[{ sealer: 'tsa' as unknown as Sealer }, 'encrypt: EncryptOptions.sealer has no seal methods: leave it undefined for none'],
[{ largeArea: 1 as unknown as boolean }, 'encrypt: EncryptOptions.largeArea is not a boolean'],
];
for (const [extra, msg] of cases) {
const aborted: unknown[] = [];
const output = new WritableStream<Uint8Array>({ abort: (r) => void aborted.push(r) });
const err = (await encryptFiles(files, options({ ...extra, output })).catch((e: Error) => e)) as Error;
expect([err.constructor, err.message]).toEqual([TypeError, msg]);
expect(aborted).toEqual([err]);
}
});
it('must return bytes', async () => {
const key = AuthorKey.generate();
const cases: [Partial<EncryptOptions>, string][] = [
[{ authorKey: { publicKey: () => 'key' as unknown as Uint8Array, sign: (m) => key.sign(m) } }, 'encrypt: AuthorSigner.publicKey() did not return a Uint8Array'],
[{ authorKey: { publicKey: () => key.publicKey(), sign: async () => [1, 2] as unknown as Uint8Array } }, 'encrypt: AuthorSigner.sign() did not return a Uint8Array'],
[{ cmsSigner: { signers: () => 'x' as unknown as Uint8Array[], sign: () => new Uint8Array(1) } }, 'encrypt: CmsSigner.signers() did not return an array of SHA-256 values of 32 bytes'],
[{ cmsSigner: { signers: () => [new Uint8Array(31)], sign: () => new Uint8Array(1) } }, 'encrypt: CmsSigner.signers() did not return an array of SHA-256 values of 32 bytes'],
[{ cmsSigner: { signers: () => [new Uint8Array(32)], sign: () => 'der' as unknown as Uint8Array } }, 'capsule: signing: encrypt: CmsSigner.sign() did not return a Uint8Array'],
[{ sealer: { seal: () => null as unknown as Uint8Array } }, 'capsule: sealing: encrypt: Sealer.seal() did not return a Uint8Array'],
];
for (const [extra, msg] of cases) {
const err = (await encryptFiles(files, options(extra)).catch((e: Error) => e)) as Error;
expect(err.message).toBe(msg);
}
});
it('fail with what they throw, after their prefix, which keeps it as its cause', async () => {
const thrown = 'not an Error';
const err = (await encryptFiles(files, options({ sealer: { seal: () => Promise.reject(thrown) } })).catch((e: Error) => e)) as Error;
expect([err.message, err.cause]).toEqual(['capsule: sealing: not an Error', thrown]);
const cause = new RangeError('cancelled');
const err2 = (await encryptFiles(files, options({ cmsSigner: { signers: () => [new Uint8Array(32)], sign: () => Promise.reject(cause) } })).catch((e: Error) => e)) as Error;
expect([err2.message, err2.cause]).toEqual(['capsule: signing: cancelled', cause]);
// What the author key throws reaches the caller as it is: in Go, Sign cannot fail.
const own = new Error('the key is locked');
await expect(encryptFiles(files, options({ authorKey: { publicKey: () => new Uint8Array(32).fill(1), sign: () => Promise.reject(own) } }))).rejects.toBe(own);
});
it('are refused by the writer of format 2, with the text of Go', async () => {
const key = AuthorKey.generate();
const hooks: Partial<EncryptOptions>[] = [
{ authorKey: key },
{ cmsSigner: { signers: () => [], sign: () => new Uint8Array(0) } },
{ sealer: { seal: () => new Uint8Array(0) } },
{ largeArea: true },
];
for (const extra of hooks) {
await expect(encryptVectors(new Uint8Array(1), options(extra))).rejects.toThrow(
'capsule: format 2 has no security area or public note: AuthorKey, CMSSigner, Sealer, LargeArea and PublicNote are for EncryptFiles',
);
}
await expect(encrypt(new Uint8Array(1), options({ authorKey: key }))).rejects.toThrow(/only a generator of test vectors/);
});
it('write the area of a generator of test vectors when it is asked', async () => {
const key = AuthorKey.generate();
const w = await encryptFilesWith(files, options({ authorKey: key, largeArea: false }), {}, { areaLen: AREA_UNIT });
expect((await openedOf(w.dkc!)).area_len).toBe(AREA_UNIT);
await expect(encryptFilesWith(files, options({ authorKey: key, largeArea: true }), {}, { areaLen: 2 * AREA_UNIT })).rejects.toThrow(
'capsule: a test area of 1024 bytes: a multiple of 512 up to 65536, without LargeArea',
);
});
});

@ -1,6 +1,6 @@
// The writers: encryptFiles writes a .dkc of capsule format 3, with, when
// asked, a portable .dkk (spec §61, §62, §62.1), as capsule.EncryptFiles of
// the Go reference at spec-v0.11; encrypt, as capsule.Encrypt, writes format
// the Go reference at spec-v0.12; encrypt, as capsule.Encrypt, writes format
// 2 only for a generator of test vectors, which these options cannot ask
// for. Their random values all come from crypto.getRandomValues (§62.1 rule
// 5); the core of writer.ts, which takes them from its caller, is imported
@ -9,18 +9,21 @@
import { cryptoWords } from './random.ts';
import {
type AuthorSigner,
type CmsSigner,
type Draws,
type EncryptOptions,
type Encrypted,
type EncryptSource,
type FileSource,
MAX_MEMORY_DKC,
type Sealer,
writeCapsule,
writeFiles,
} from './writer.ts';
import { newX25519Identity } from './x25519.ts';
export type { EncryptOptions, Encrypted, EncryptSource, FileSource };
export type { AuthorSigner, CmsSigner, EncryptOptions, Encrypted, EncryptSource, FileSource, Sealer };
export { MAX_MEMORY_DKC };
/**
@ -35,9 +38,19 @@ export { MAX_MEMORY_DKC };
* streams PAYLOAD_AGE, reading each file again: a file whose size or SHA-256
* has changed makes it fail (§62.1 rule 18). The files go in the byte order
* of their paths, whatever the order given (R8), without the empty folders,
* which a path cannot name. The security area is the empty one of this
* library, which does not sign, in an area of 32 KiB, whatever the capsule
* holds (rule 13); `opts.publicNote` goes in PUBLIC_HEADER (§24.1).
* which a path cannot name. `opts.publicNote` goes in PUBLIC_HEADER (§24.1).
*
* The security area is of 32 KiB, whatever the capsule holds (rule 13):
* empty, or with the signature of `opts.authorKey` (alg 1, such as an
* AuthorKey of authorkey.ts) or of `opts.cmsSigner` (alg 2, with
* certificates), and the seal of `opts.sealer` (seal_type 2). The hooks are
* called once the control and the head are final and before anything is
* written: the signature commits to them, and the seal to the signature
* (§29.8, §29.11). The writer evaluates the area with the rules of the
* reader, in the context of the capsule, before it writes it: a signature
* that would not be F4, or F6, or a seal that would not be S4 or S5, makes it
* fail (rules 17, 19 and 21). With `opts.largeArea`, an area that does not
* fit in 32 KiB goes in one of 64 KiB.
*
* Without `opts.output` the .dkc is returned in memory, up to
* MAX_MEMORY_DKC; with it, the .dkc is streamed into the output, closed only

@ -5,9 +5,9 @@
// {0: "datekeys-security", 1: 1, ? 2: author-signature, ? 3: seal}, whose
// keys 2 and 3 hold CBOR encoded apart. In the context of a capsule it checks
// the signature of alg 1 with the strict profile of ed25519strict.ts, and the
// signature of alg 2 and the seal of seal_type 2 with securitycms.ts; the
// writer of this library does not sign, and writes the area empty. Its
// evaluation never throws: the security area never decides the opening, and
// signature of alg 2 and the seal of seal_type 2 with securitycms.ts. The
// writer encodes the area with encodeSecurityWith and the items of keys 2 and
// 3, and evaluates it here before it writes it. Its evaluation never throws: the security area never decides the opening, and
// its verdicts carry no error code. Internal: index.ts does not re-export it.
import { ALG_CMS, ALG_ED25519, authorMessage, signersDigest } from './author.ts';
@ -27,7 +27,7 @@ export const SECURITY_VERSION = 1;
const MAX_SECURITY_ITEM = 65536;
const MAX_ALG = 2 ** 32 - 1;
/** The seal_type of an RFC 3161 token (spec v0.11, §29.3, §29.11). */
const SEAL_TYPE_RFC3161 = 2;
export const SEAL_TYPE_RFC3161 = 2;
/**
* The verdict on the signature or on the seal of the security area (spec
@ -213,8 +213,32 @@ function encodeWire(e: Encoder, w: SecurityWire): void {
/** SECURITY_CBOR as a writer of this version writes it: empty, {0: "datekeys-security", 1: 1}, 22 bytes (spec §29.3). */
export function encodeSecurity(): Uint8Array {
return encodeSecurityWith(undefined, undefined);
}
/**
* SECURITY_CBOR with the given contents of keys 2 and 3, undefined when
* absent (spec §29.3), as EncodeSecurityWith of Go. The writer writes it with
* what this version defines, a signature of alg 1 or 2 and a seal of
* seal_type 2.
*/
export function encodeSecurityWith(signature: Uint8Array | undefined, seal: Uint8Array | undefined): Uint8Array {
const e = new Encoder();
encodeWire(e, { signature, seal });
return e.out();
}
/** The content of key 2, {0: alg, 1: key, 2: signature} (spec §29.3), as EncodeAuthorSignature of Go: alg 1 with a public key, or alg 2 with SIGNERS. */
export function encodeAuthorSignatureItem(alg: number, key: Uint8Array, signature: Uint8Array): Uint8Array {
const e = new Encoder();
encodeAuthorSignature(e, { alg, key, value: signature });
return e.out();
}
/** The content of key 3, {0: seal_type, 1: token} (spec §29.3), as EncodeSeal of Go. */
export function encodeSealItem(sealType: number, token: Uint8Array): Uint8Array {
const e = new Encoder();
encodeWire(e, { signature: undefined, seal: undefined });
encodeSeal(e, { sealType, token });
return e.out();
}

@ -79,6 +79,22 @@ function decodeSigners(b: Uint8Array): Uint8Array[] {
return out;
}
/**
* SIGNERS, the content of key 1 of a signature of alg 2: the SHA-256 of the
* certificate of each required signer, sorted in strictly ascending order of
* bytes, as EncodeSigners of Go, with its texts when there are none, more than
* 16 or two equal ones (spec §29.10).
*/
export function encodeSigners(hashes: readonly Uint8Array[]): Uint8Array {
if (hashes.length < 1 || hashes.length > MAX_SIGNERS) throw new Error(`capsule: SIGNERS holds from 1 to ${MAX_SIGNERS} certificates`);
const sorted = [...hashes].sort(compareBytes);
for (let i = 1; i < sorted.length; i++) if (equalBytes(sorted[i - 1]!, sorted[i]!)) throw new Error('capsule: SIGNERS names a certificate twice');
const e = new Encoder();
e.array(sorted.length);
for (const h of sorted) e.bstr(h);
return e.out();
}
// The number of code points of a well-formed string.
function codePointCount(s: string): number {
let n = 0;

File diff suppressed because one or more lines are too long

@ -1,7 +1,7 @@
// The core of the writers: a .dkc of capsule format 3, or of format 2 for a
// generator of test vectors, and, when asked, a portable .dkk (spec §61, §62,
// §62.1), in the order and with the texts of capsule.EncryptFiles and
// capsule.Encrypt of the Go reference at spec-v0.11, split as they are:
// capsule.Encrypt of the Go reference at spec-v0.12, split as they are:
// newSealer checks the options that do not depend on the content, and seal
// writes a capsule of a format around a content given in pieces. Its random
// values come from the caller (plan of phase 3, decision 4): encrypt.ts
@ -20,10 +20,11 @@ import { Decrypter, Encrypter, type ReadableStreamWithSize } from 'age-encryptio
import { ACCESS_TYPE_X25519, type AccessKey } from './accesskey.ts';
import { ACCESS_SLOTS, ageStanzas, checkAccessStanzas, checkTimeStanzas, parseAgeHeader } from './age.ts';
import { decryptAll } from './agefile.ts';
import { AREA_LEN, AREA_UNIT, BODY_FRAME_SIZE, bodyFrameBytes, contentLength, MAX_AREA_LEN, MAX_HEAD_LEN, parseBodyFrame } from './body.ts';
import { ALG_CMS, ALG_ED25519, authorMessage, controlCommit, headDigest, sealSubject, signersDigest } from './author.ts';
import { AREA_LEN, AREA_UNIT, BODY_FRAME_SIZE, bodyFrameBytes, contentLength, LARGE_AREA_LEN, MAX_AREA_LEN, MAX_HEAD_LEN, parseBodyFrame } from './body.ts';
import { checkNoteData, newNote } from './note.ts';
import { compareBytes, copyBytes, equalBytes, goQuote, toHex, utf8Bytes, utf8Length } from './bytes.ts';
import { decodeControl, encodeControl } from './control.ts';
import { type Control, decodeControl, encodeControl } from './control.ts';
import { compareInstants, type DateKey, formatRFC3339Nano, type Instant, isInstant, resolveDateKey, roundTime } from './datekey.ts';
import { sha256Hasher } from './digest.ts';
import { DateKeysError, withContext } from './errors.ts';
@ -38,7 +39,8 @@ import { checkAuthor, checkComment, checkPath, checkTree, MAX_AUTHOR_LEN, MAX_CO
import { cloneProfile, type Profile, validateProfile } from './profile.ts';
import { permute, type RandomWords } from './random.ts';
import { checkX25519Recipient, formatX25519Recipient } from './recipient.ts';
import { encodeSecurity, evaluateSecurity } from './security.ts';
import { encodeAuthorSignatureItem, encodeSealItem, encodeSecurity, encodeSecurityWith, evaluateSecurity, SEAL_TYPE_RFC3161, type Verdict } from './security.ts';
import { type Detail, encodeSigners } from './securitycms.ts';
import { timeRecipient } from './tlock.ts';
import { checkWords, wordKey } from './wordkey.ts';
import { x25519PublicKey } from './x25519.ts';
@ -71,6 +73,46 @@ export interface FileSource {
readonly open: () => ReadableStream<Uint8Array>;
}
/**
* The author key that signs a capsule with alg 1, a strict Ed25519 signature
* (spec §29.9), as the interface AuthorKey of the Go package capsule:
* encryptFiles signs AUTHOR_MESSAGE with it and checks the signature with the
* strict profile before it writes anything. AuthorKey of authorkey.ts is one.
*/
export interface AuthorSigner {
/** The public key A, 32 bytes. */
publicKey(): Uint8Array;
/** The Ed25519 signature of `message`, 64 bytes. It may be asynchronous, as a key held elsewhere would be. */
sign(message: Uint8Array): Uint8Array | Promise<Uint8Array>;
}
/**
* The signature of alg 2, a CMS signature with X.509 certificates (spec
* §29.10), as CMSSigner of Go: encryptFiles gives AUTHOR_MESSAGE to sign once
* the capsule is prepared, and waits while the person signs it outside, with
* her signing application.
*/
export interface CmsSigner {
/** The SHA-256 of the certificate of each required signer, from 1 to 16. */
signers(): readonly Uint8Array[];
/**
* The detached CMS signature of `message`, in DER, with a seal for each
* required signer. What it throws fails the writing: `capsule: signing: `
* and its message.
*/
sign(message: Uint8Array): Uint8Array | Promise<Uint8Array>;
}
/** The authority that seals a capsule with seal_type 2, an RFC 3161 time-stamp token (spec §29.11), as Sealer of Go. */
export interface Sealer {
/**
* The DER of the token over SHA-256 of `subject`, SEAL_SUBJECT, 32 bytes:
* the imprint that the token must hold. What it throws fails the writing:
* `capsule: sealing: ` and its message.
*/
seal(subject: Uint8Array): Uint8Array | Promise<Uint8Array>;
}
/** Options of encrypt, field by field those of capsule.EncryptOptions. */
export interface EncryptOptions {
/** The pinned Provider Profile. Required. */
@ -121,6 +163,31 @@ export interface EncryptOptions {
* with the date, it can identify someone.
*/
readonly publicNote?: string;
/**
* Signs the capsule with alg 1 (spec §29.9): encryptFiles signs
* AUTHOR_MESSAGE with it, checks the signature with the strict profile
* before it writes anything, and puts it in the security area. Exclusive
* with cmsSigner. encrypt, which writes format 2, takes none.
*/
readonly authorKey?: AuthorSigner;
/**
* Signs with alg 2, a CMS signature with X.509 certificates (spec §29.10):
* encryptFiles gives it AUTHOR_MESSAGE, and puts what it returns in the
* security area once it checks that it is complete, with a seal for each
* required signer, as F6. Exclusive with authorKey and sealer.
*/
readonly cmsSigner?: CmsSigner;
/** Asks for the seal of seal_type 2, an RFC 3161 token over SEAL_SUBJECT, after the signature, if there is one (spec §29.11). */
readonly sealer?: Sealer;
/**
* Lets encryptFiles widen the security area from 32 KiB to 64 KiB when what
* it holds does not fit in the common one (spec §29.2, §62.1 rule 13). It
* widens only then, after the signatures are made, which do not depend on
* the size of the area; without it, what does not fit makes encryptFiles
* fail, once the person has signed. Set it when a signature with
* certificates may be large.
*/
readonly largeArea?: boolean;
/** The clock. Required, and called once. */
readonly now: () => Instant;
/**
@ -213,7 +280,14 @@ export async function writeCapsule(src: EncryptSource, opts: EncryptOptions, dra
}
checkTypes(opts);
const length = sourceLength(src, opts.length);
if ((opts.publicNote ?? '') !== '' || vectors.areaLen !== undefined) {
if (
opts.authorKey !== undefined ||
opts.cmsSigner !== undefined ||
opts.sealer !== undefined ||
opts.largeArea === true ||
(opts.publicNote ?? '') !== '' ||
vectors.areaLen !== undefined
) {
throw new Error('capsule: format 2 has no security area or public note: AuthorKey, CMSSigner, Sealer, LargeArea and PublicNote are for EncryptFiles');
}
const s = await newSealer(opts, length);
@ -235,10 +309,13 @@ export async function writeFiles(files: readonly FileSource[], opts: EncryptOpti
if (opts.length !== undefined) throw new Error('capsule: EncryptOptions.Length is for Encrypt: EncryptFiles computes L from the files');
checkTypes(opts);
// Spec §62.1 rule 13: the area is 32 KiB, and only a generator of test vectors writes another.
const areaLen = vectors?.areaLen ?? AREA_LEN;
if (!Number.isInteger(areaLen) || areaLen < AREA_UNIT || areaLen > MAX_AREA_LEN || areaLen % AREA_UNIT !== 0) {
const common = vectors?.areaLen ?? AREA_LEN;
if (!Number.isInteger(common) || common < AREA_UNIT || common > MAX_AREA_LEN || common % AREA_UNIT !== 0) {
throw new Error(`capsule: the security area is ${AREA_LEN} bytes: another size is for a generator of test vectors, a multiple of ${AREA_UNIT} up to ${MAX_AREA_LEN} (spec §62.1 rule 13)`);
}
if (vectors?.areaLen !== undefined && opts.largeArea === true) {
throw new Error(`capsule: a test area of ${common} bytes: a multiple of ${AREA_UNIT} up to ${MAX_AREA_LEN}, without LargeArea`);
}
const sources = copySources(files);
const comment = checkText(opts.comment, 'comment');
const author = checkText(opts.author, 'author');
@ -252,9 +329,13 @@ export async function writeFiles(files: readonly FileSource[], opts: EncryptOpti
if (measured.length > MAX_HEAD_LEN) throw new Error(`capsule: the head is ${measured.length} bytes, more than ${MAX_HEAD_LEN}: fewer files or shorter paths`);
selfCheckHead(measured);
const content = head.files.at(-1)?.end ?? 0;
const length = BODY_FRAME_SIZE + areaLen + measured.length + content;
// This L is the first one, with the common area: the signature decides
// whether the area grows (spec §29.8), and prepare returns the final L.
let area = common;
const length = BODY_FRAME_SIZE + area + measured.length + content;
checkLength(length, s.code);
// In memory, the limit is known before reading anything.
// In memory, the limit is known before reading anything, but for a large
// area, which seal checks once it is final.
if (opts.output === undefined) {
const total = capsuleLength({
profileId: s.dateKey.profileId,
@ -274,29 +355,140 @@ export async function writeFiles(files: readonly FileSource[], opts: EncryptOpti
const sums: Uint8Array[] = [];
for (const [i, f] of head.files.entries()) sums.push(await readFile(sources[order[i]!]!, f.size));
// Step 12: the head, with a fresh salt, and SECURITY_CBOR, decoded with
// the rules of the reader; seal decodes CONTROL_CBOR.
// Step 12: the head, with a fresh salt, decoded with the rules of the
// reader; seal decodes CONTROL_CBOR.
const salt = copyBytes(drawn(draws.salt(), SALT_SIZE, 'the salt'));
const final: Head = { ...head, salt, files: head.files.map((f, i) => ({ ...f, sha256: sums[i]! })) };
const headBytes = encodeHead(final);
/* v8 ignore next -- @preserve: the salt and the SHA-256 do not change the length of the head */
if (headBytes.length !== measured.length) throw new Error(`capsule: internal error: the head is ${headBytes.length} bytes, measured ${measured.length}`);
selfCheckHead(headBytes);
// SECURITY_CBOR and the frame are final once the control is: the
// signature commits to it (spec §29.8). prepare builds them before
// anything is written, with the commitments of the control, which do not
// depend on L, and returns the final L.
let security: Uint8Array = new Uint8Array(0);
let frame: Uint8Array = new Uint8Array(0);
const prepare = async (c: Control): Promise<number> => {
security = await securityArea(s, c, headBytes);
// The area is the common one, or the large one only when what was
// signed does not fit and largeArea allows it (§62.1 rule 13).
if (security.length <= common) area = common;
else if (!s.largeArea) {
throw new Error(`capsule: SECURITY_CBOR of ${security.length} bytes does not fit in the area of ${common} bytes: LargeArea lets the writer widen it to ${LARGE_AREA_LEN}`);
} else if (security.length <= LARGE_AREA_LEN) area = LARGE_AREA_LEN;
else throw new Error(`capsule: SECURITY_CBOR of ${security.length} bytes does not fit in the area of ${LARGE_AREA_LEN} bytes, the largest`);
const total = BODY_FRAME_SIZE + area + headBytes.length + content;
checkLength(total, s.code);
frame = bodyFrameBytes({ areaLen: area, securityLen: security.length, headLen: headBytes.length });
selfCheck('capsule: self-check', () => checkHeadEnd(final, contentLength(parseBodyFrame(frame, total), total)));
return total;
};
// Step 16: BODY, and the second reading of each file.
const body = (): Content => {
const areaBytes = new Uint8Array(area);
areaBytes.set(security);
return bodyContent([frame, areaBytes, headBytes], final.files, sources, order);
};
const res = await seal(s, FORMAT_3, length, draws, state, body, prepare);
return { ...res, head: final };
});
}
// security of the reference: SECURITY_CBOR for the capsule whose final
// control is c and whose head is head, empty or with the signature of the
// author key or of the CMS signer, and the seal of the sealer (spec §29.3,
// §29.8 to §29.11). The signature is made first and the seal after it, which
// seals it. It decodes and evaluates what it returns with the rules of the
// reader, in the context of this capsule (§62.1 rules 17, 19 and 21). The
// caller decides the area from its length.
async function securityArea(s: SealState, c: Control, head: Uint8Array): Promise<Uint8Array> {
const { authorKey, cmsSigner, sealer } = s;
if (authorKey === undefined && cmsSigner === undefined && sealer === undefined) {
const security = encodeSecurity();
const v = evaluateSecurity(security);
/* v8 ignore next 3 -- @preserve: encodeSecurity writes the empty area, F0 and S0 */
if (v.signature !== 'F0' || v.seal !== 'S0') {
throw new Error(`capsule: self-check: the reader finds the verdicts ${v.signature} and ${v.seal} in this security area`);
}
const frame = bodyFrameBytes({ areaLen, securityLen: security.length, headLen: headBytes.length });
selfCheck('capsule: self-check', () => checkHeadEnd(final, contentLength(parseBodyFrame(frame, length), length)));
return security;
}
const hd = headDigest(head);
const cc = controlCommit(c, FORMAT_3);
let signature: Uint8Array | undefined;
let seal: Uint8Array | undefined;
let wantSig: Verdict = 'F0';
let key: Uint8Array | undefined;
if (authorKey !== undefined) {
const pub = bytesOf(authorKey.publicKey(), 'AuthorSigner.publicKey()');
if (pub.length !== 32) throw new Error(`capsule: the author key is ${pub.length} bytes, not 32`);
const msg = authorMessage(cc, hd, signersDigest(ALG_ED25519));
signature = encodeAuthorSignatureItem(ALG_ED25519, pub, bytesOf(await authorKey.sign(msg), 'AuthorSigner.sign()'));
wantSig = 'F4';
key = pub;
} else if (cmsSigner !== undefined) {
const hashes: unknown = cmsSigner.signers();
if (!Array.isArray(hashes) || !hashes.every((x) => x instanceof Uint8Array && x.length === 32)) {
throw new TypeError('encrypt: CmsSigner.signers() did not return an array of SHA-256 values of 32 bytes');
}
const list = encodeSigners(hashes as Uint8Array[]);
// AUTHOR_MESSAGE is what the person sees and signs elsewhere: the hook
// may take as long as she needs.
const msg = authorMessage(cc, hd, signersDigest(ALG_CMS, list));
let der: Uint8Array;
try {
der = bytesOf(await cmsSigner.sign(msg), 'CmsSigner.sign()');
} catch (err) {
throw hookFailure('capsule: signing', err);
}
signature = encodeAuthorSignatureItem(ALG_CMS, list, der);
wantSig = 'F6';
}
let wantSeal: Verdict = 'S0';
if (sealer !== undefined) {
const subject = sealSubject(cc, hd, signature);
let token: Uint8Array;
try {
token = bytesOf(await sealer.seal(subject), 'Sealer.seal()');
} catch (err) {
throw hookFailure('capsule: sealing', err);
}
seal = encodeSealItem(SEAL_TYPE_RFC3161, token);
wantSeal = 'S4';
}
const security = encodeSecurityWith(signature, seal);
const v = evaluateSecurity(security, { controlCommit: cc, headDigest: hd, roundTime: s.unlock });
// A seal that proves nothing before the round time (S5) is still a seal
// that verifies: the clock of the writer and that of the authority may
// differ.
const sealOK = v.seal === wantSeal || (wantSeal === 'S4' && v.seal === 'S5');
const sameKey = key === undefined ? v.authorKey === undefined : v.authorKey !== undefined && equalBytes(v.authorKey, key);
if (v.signature !== wantSig || !sealOK || !sameKey) {
throw new Error(`capsule: self-check: the reader finds the verdicts ${v.signature} and ${v.seal} in this security area, not ${wantSig} and ${wantSeal}${detailText(v.detail)}`);
}
return security;
}
// Step 16: BODY, and the second reading of each file.
const area = new Uint8Array(areaLen);
area.set(security);
const res = await seal(s, FORMAT_3, length, draws, state, () => bodyContent([frame, area, headBytes], final.files, sources, order));
return { ...res, head: final };
});
// What a hook returned, which must be bytes: a copy, which the hook cannot
// change afterwards.
function bytesOf(b: unknown, what: string): Uint8Array {
if (!(b instanceof Uint8Array)) throw new TypeError(`encrypt: ${what} did not return a Uint8Array`);
return b.slice();
}
// The failure of a hook of the person: its message after the prefix, and the
// error as its cause, as the %w of Go.
function hookFailure(prefix: string, err: unknown): Error {
return new Error(`${prefix}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
}
// detailText of the reference: the required signers that failed, for the
// error of a writer.
function detailText(d: Detail | undefined): string {
const parts = (d?.signers ?? []).filter((l) => l.result !== 'valid').map((l) => `${l.holder}: ${l.result}`);
return parts.length === 0 ? '' : ` (${parts.join('; ')})`;
}
// Copies of the sources, which must be of the types of FileSource.
@ -453,12 +645,26 @@ function checkTypes(opts: EncryptOptions): void {
if (opts.publicNote !== undefined && typeof opts.publicNote !== 'string') throw new TypeError('encrypt: EncryptOptions.publicNote is not a string');
if (opts.output !== undefined && !(opts.output instanceof WritableStream)) throw new TypeError('encrypt: EncryptOptions.output is not a WritableStream');
if (opts.progress !== undefined && typeof opts.progress !== 'function') throw new TypeError('encrypt: EncryptOptions.progress is not a function');
// A hook that is not one is a mistake of the caller, as the typed nil that
// Go refuses: taking it for none would write, without a word, a capsule
// without the signature or the seal that was asked for.
const hooks: [string, unknown, string[]][] = [
['authorKey', opts.authorKey, ['publicKey', 'sign']],
['cmsSigner', opts.cmsSigner, ['signers', 'sign']],
['sealer', opts.sealer, ['seal']],
];
for (const [name, hook, methods] of hooks) {
if (hook !== undefined && (typeof hook !== 'object' || hook === null || methods.some((m) => typeof (hook as Record<string, unknown>)[m] !== 'function'))) {
throw new TypeError(`encrypt: EncryptOptions.${name} has no ${methods.join(' and ')} methods: leave it undefined for none`);
}
}
if (opts.largeArea !== undefined && typeof opts.largeArea !== 'boolean') throw new TypeError('encrypt: EncryptOptions.largeArea is not a boolean');
}
// What the writers of both formats share, as the sealer of the reference:
// the options that do not depend on the content, copied and checked, and the
// DateKey resolved locally.
interface Sealer {
interface SealState {
readonly profile: Profile;
readonly policy: Policy;
/** The recipients, checked; I_ACCESS is drawn when the capsule is sealed. */
@ -476,6 +682,11 @@ interface Sealer {
readonly dateKey: DateKey;
/** The time of the round, never before the requested instant. */
readonly unlock: Instant;
/** The hooks of the security area, at most one signature, and the large area. */
readonly authorKey: AuthorSigner | undefined;
readonly cmsSigner: CmsSigner | undefined;
readonly sealer: Sealer | undefined;
readonly largeArea: boolean;
}
// newSealer of the reference: copies of the inputs, then, in the order of Go,
@ -484,7 +695,15 @@ interface Sealer {
// length, a first L, checked against L_MAX, the DateKey and the credentials
// (§15, §24.1, §62.1 rules 2, 3 and 8). `head` holds the copies of the head
// extensions of a capsule of format 3.
async function newSealer(opts: EncryptOptions, length: number, head: readonly [readonly Extension[], readonly Extension[]] = [[], []]): Promise<Sealer> {
async function newSealer(opts: EncryptOptions, length: number, head: readonly [readonly Extension[], readonly Extension[]] = [[], []]): Promise<SealState> {
// Step 1: the hooks, first, as in Go: a capsule has one signature, and with
// a CMS signature the seals go inside it.
const { authorKey, cmsSigner, sealer } = opts;
if (authorKey !== undefined && cmsSigner !== undefined) throw new Error('capsule: AuthorKey and CMSSigner are exclusive: a capsule has one signature');
if (cmsSigner !== undefined && sealer !== undefined) {
throw new Error('capsule: with CMSSigner the seal goes inside each signature (spec §29.10): Sealer must be nil');
}
const largeArea = opts.largeArea === true;
// Step 2: copies, since the caller could change its inputs during an await,
// each checked as a type as it is copied.
const recipients = (opts.recipients ?? []).map((r, i) => {
@ -553,7 +772,26 @@ async function newSealer(opts: EncryptOptions, length: number, head: readonly [r
}
}
const credentials = accessRecipients(policy, recipients, portable, words.length > 0);
return { profile, policy, credentials, portable, words, code, critical, noncritical, controlCritical, controlNoncritical, output, progress, dateKey, unlock };
return {
profile,
policy,
credentials,
portable,
words,
code,
critical,
noncritical,
controlCritical,
controlNoncritical,
output,
progress,
dateKey,
unlock,
authorKey,
cmsSigner,
sealer,
largeArea,
};
}
// The padding rule and L, as PaddedLength checks them.
@ -565,16 +803,24 @@ function checkLength(length: number, code: Padding): void {
// The write of the sealer of the reference: a capsule of `format` whose
// content, `length` bytes that `content` gives, goes into the plaintext of
// PAYLOAD_AGE, followed by the zeros of its padding up to P (§29.1).
// `prepare`, when given, receives the control, with I_PAYLOAD and the binding
// but with a first L, before anything is written: it is where a writer of
// format 3 signs, with the commitments that the control gives, which do not
// depend on L (spec §29.8). It returns the final L, which may be longer
// because the area had to grow to hold what was signed: the person never
// signs twice for that. Its error stops the writing.
async function seal(
s: Sealer,
s: SealState,
format: typeof FORMAT_2 | typeof FORMAT_3,
length: number,
first: number,
draws: Draws,
state: WriteState,
content: () => Content,
prepare?: (control: Control) => Promise<number>,
): Promise<Encrypted> {
const { profile, policy, code, dateKey, output, progress } = s;
const padded = paddedLength(length, code);
let length = first;
let padded = paddedLength(length, code);
const wipe: Uint8Array[] = [];
try {
// Step 7: I_ACCESS when asked, the last of the credentials.
@ -640,10 +886,14 @@ async function seal(
if (sealedLength > MAX_SEALED_CONTROL_LEN) {
throw new DateKeysError('ERR_INTEGRITY', `capsule: SEALED_CONTROL of ${sealedLength} bytes exceeds ${MAX_SEALED_CONTROL_LEN}`);
}
const total = DKC_PRELUDE_SIZE + header.length + sealedLength + payloadAgeLength(padded);
if (output === undefined && total > MAX_MEMORY_DKC) {
throw new TypeError(`encrypt: a capsule of ${total} bytes needs EncryptOptions.output; in memory the limit is ${MAX_MEMORY_DKC}`);
}
const totalOf = (p: number): number => {
const total = DKC_PRELUDE_SIZE + header.length + sealedLength + payloadAgeLength(p);
if (output === undefined && total > MAX_MEMORY_DKC) {
throw new TypeError(`encrypt: a capsule of ${total} bytes needs EncryptOptions.output; in memory the limit is ${MAX_MEMORY_DKC}`);
}
return total;
};
let total = totalOf(padded);
// Step 12: PRELUDE and header_binding over the exact bytes (§26).
const prelude = preludeBytes({ format, publicHeaderLen: header.length, sealedControlLen: sealedLength });
@ -652,13 +902,27 @@ async function seal(
// Step 13: CONTROL_CBOR, of the schema version of the format, checked
// with the decoder of the reader; the copy of I_PAYLOAD it decodes is
// wiped at once.
const control = encodeControl(
{ headerBinding: binding, payloadIdentity: payloadId, critical: s.controlCritical, noncritical: s.controlNoncritical, payloadLength: length, padding: code },
format,
);
const ctrl: Control = { headerBinding: binding, payloadIdentity: payloadId, critical: s.controlCritical, noncritical: s.controlNoncritical, payloadLength: length, padding: code };
let control = encodeControl(ctrl, format);
wipe.push(control);
selfCheck('capsule: self-check: the reader rejects this CONTROL_CBOR', () => decodeControl(control, format).payloadIdentity.fill(0));
// The signature and the seal, before anything is written, and the final
// L. L is the same eight bytes: the control has the length it had, and
// header_binding, which covers PRELUDE, still holds.
if (prepare !== undefined) {
const final = await prepare(ctrl);
if (final !== length) {
length = final;
padded = paddedLength(length, code);
total = totalOf(padded);
control.fill(0);
control = encodeControl({ ...ctrl, payloadLength: length }, format);
wipe.push(control);
selfCheck('capsule: self-check: the reader rejects this CONTROL_CBOR', () => decodeControl(control, format).payloadIdentity.fill(0));
}
}
// Step 14: SEALED_CONTROL. INNER_ACCESS_AGE for the 16 slots in their
// order, then OUTER_TIME_AGE with the tlock recipient alone: each age
// file draws its own file key, so the three are independent (§62).

Loading…
Cancel
Save

Powered by TurnKey Linux.