Compare commits

...

13 Commits

Author SHA1 Message Date
dev 2c305cf253 Release 0.5.0
1 day ago
dev c74a37e064 Testdata, wordlists and annex at spec-v0.16 of datekeys-go
1 day ago
dev 59d7607e81 Testdata, wordlists and annex at 3fd0e93 of datekeys-go
1 day ago
dev f79d8e2de8 Specification 0.16 draft: a seal without accuracy, drand's JSON read strictly
1 day ago
dev e8b5d35858 /create: own words are not enough for something valuable, and never a password
1 day ago
dev 728c3bbb53 What the official SDK says when it seals: the recovery annex, §7.6 and §71
1 day ago
dev f5044e3c46 Dice: the words of five dice each, in the library and in /create
1 day ago
dev 13df99b4db The English list of the EFF, pinned; testdata and wordlists at e671032
1 day ago
dev 4057607e94 The path check of /create no longer runs again without end
1 day ago
dev 323d920d91 /create offers random words by default
1 day ago
dev 704838016b Random words: readWordList, checkWordList, generateWords and wordBits
1 day ago
dev a077e85e47 Word lists from datekeys-go: sync-testdata copies wordkey/lists into wordlists/
1 day ago
dev 012cd74b42 Start 0.5.0-dev
1 day ago

4
.gitattributes vendored

@ -4,3 +4,7 @@
# Vendored Go fixtures and vectors must keep their exact bytes: their SHA-256 is
# recorded in testdata/SOURCE.json and in the fixtures themselves.
testdata/** -text
# So must the vendored word lists, whose SHA-256 src/lib/dkc/wordlist.ts pins.
wordlists/** -text
# And the recovery annex, a copy of the one of datekeys-go.
annex/** -text

@ -2,6 +2,34 @@
Cambios notables de la librería TypeScript y de la página. El proyecto usa versionado semántico; mientras sea 0.x, no hay promesa de estabilidad. La sección «Versiones» del [README](README.md) explica qué cubre cada número.
## 0.5.0 — 7 de octubre de 2026
La especificación 0.16, el tag `spec-v0.16` de `datekeys-go`: la revisión de Astra de la v0.15, con el sello sin `accuracy` y el JSON de drand estricto; y las palabras al azar de la llave de palabras, de una lista inglesa y una española, con el ordenador o con dados, y lo que dice el SDK oficial al sellar. El autor la cerró el 7 de octubre de 2026, con el tag `v0.5.0`.
### La especificación 0.16 (07-10-2026)
La v0.16 de `datekeys-go`, aprobada el 7 de octubre de 2026 con el tag `spec-v0.16` (`b6ff17a`): lo que encontró la revisión de Astra de la v0.15. No cambia ningún formato; cambian el veredicto de un sello sin `accuracy` y la lectura del JSON de drand (§76, «Cambios normativos de la v0.16»).
- `SPEC_VERSION` pasa a `0.16`. `testdata`, `wordlists` y `annex` se sincronizan con `b6ff17a`, el commit del tag (`node scripts/sync-testdata.mjs sync --commit b6ff17a`), cuyo anexo lleva el SHA-256 de la versión aprobada; antes, `3fd0e93` devolvió a `release.json` los escapes de sus casos `\u0072ound` y del par de sustitutos, que `4f78854` había perdido: el campo `spec` de cada fichero dice `0.16`; `vectors/security_cms.json` se hizo de nuevo, con 143 casos y `seal_reason`; `vectors/release.json` gana 26 casos del JSON de drand; `vectors/wordkey.json`, el texto del vector del anexo, también con sus marcas sueltas, y su llave; y llegan dos fixtures, `format3_time_and_key_words` y `format3_full_chunk`. El anexo de recuperación es el §79 del borrador v0.16, con la licencia de la especificación, CC BY-ND 4.0, en su título (`70d907b`) y la llave de palabras en 79.7.
- **El sello sin `accuracy`** (§29.7, §29.11, como `7e3b810` de Go). Un sello válido es S4 solo si el token lleva `accuracy` y t más la precisión es anterior a `round_time`; si no, S5, con el primer motivo que se cumple: `late`, sellado después o demasiado cerca; `no accuracy, BTSP`, sin `accuracy` en un token de la política BTSP de ETSI EN 319 421 (0.4.0.2023.1.1), que la exige; o `no accuracy`. Una `accuracy` de 0 segundos, o vacía, es una precisión de 0 y vale. `cms.ts`: el token gana `hasAccuracy` y `policy`, y `tokenIsBTSP`. `securitycms.ts`: `SealReason`, `Detail.sealReason` y `SignerLine.reason`. `security.ts`: S5 ya no tiene texto fijo; `verdictLines` escribe «No acredita que se sellara antes de la fecha de apertura: ‹motivo›.», y la línea de un firmante de F6 cuyo sello no lo acredita, «sin acreditar que fuera antes de la fecha de apertura: ‹motivo›», con los textos de `sealReasonText`, los de Go byte a byte.
- **El escritor avisa** (§62.1, regla 19). `encryptFiles` devuelve en `Encrypted.security` los veredictos del área que escribió, como `Result.Security` de Go: un sello sin `accuracy` se escribe, y quien escribe ve su S5, o la línea de su firmante, con el motivo. `/create` no sella, así que no cambia.
- **El JSON de drand, estricto** (§47.1, como `b570338` de Go). `releaseobject.ts` cambia el lector que imitaba `encoding/json` por `parseDrandJSON`, como `ParseDrandJSON` de Go: JSON de RFC 8259 en UTF-8 válido cuyo valor es un objeto, ningún nombre repetido en ningún objeto, nombres comparados exactos tras decodificar sus escapes (`"round"` es `round`, y `ROUND` otro nombre), un sustituto escapado sin su pareja es JSON mal formado, `round` es un número sin signo, fracción ni exponente de 1 a 2⁵³ − 1, `signature` y `randomness` son cadenas, y `randomness`, si viene, aunque sea vacía, es el SHA-256 de la firma. Los textos de error no cambian. Como la ronda ya no pasa de 2⁵³ − 1, `ParsedRelease` es un `Release`, sin `bigint`. `drand.ts` lee las respuestas de los relays con `parseDrandJSON`, como el cliente de Go, y `release-input.ts`, lo pegado, con `strictJSON` y `jsonRound`, para que la página no lea otra ronda que el paso 10.
- **Las pruebas.** `vectors.test.ts` compara `seal_reason` en cada caso de `security_cms.json` y en cada firmante, y corre los 38 JSON de `release.json`. `securitycms.test.ts` lleva los casos nuevos de `signature2_test.go` (sin `accuracy` años antes y después, BTSP con ella y sin ella, una `accuracy` de 0 segundos y vacía, y un firmante cuyo sello no la lleva), y `releaseobject.test.ts`, los de `drandjson_test.go`, con los escapes escritos como escapes, que el generador de `release.json` escribió sin escapar. `format3_time_and_key_words` abre con la identidad que dan las palabras de su `words_text` con `normalizeWords` y `wordKey`, en la librería y en la página, y `format3_full_chunk`, cuyo `PAYLOAD_AGE` acaba en un trozo completo, abre a su fichero. `check-build.mjs` cuenta `words_text` entre los secretos de los fixtures.
- **Los ficheros de `testing/` generados con Go**, de nuevo con `4f78854`: `mutation-texts.json` (solo cambia su campo `spec`); `ibe-vectors.json`, con los dos fixtures nuevos y el resto idéntico; y `signing-vectors.json`, en una exportación de `4f78854`, con las mismas cinco muestras, cuyo `ts` se volvió a leer: las cápsulas salen iguales, y como los tokens del sellador no llevan `accuracy`, sus sellos dan ahora S5.
### Las palabras al azar de la llave de palabras (07-10-2026)
El SHOULD de §38.1, ofrecer palabras al azar de una lista pública, como hace `datekeys encrypt -new-words` desde `c49c67c` de `datekeys-go`. No cambia ningún formato ni la derivación.
- **Las listas de `datekeys-go`.** `scripts/sync-testdata.mjs` copia del mismo commit `wordkey/lists` en `wordlists/`, con su `SOURCE.json`, y `check` comprueba las dos copias. `testdata` y `wordlists` están en `27a75ee` de `datekeys-go`, de la rama `v0.15` después del tag `spec-v0.15`: los ficheros de `testdata` son los del tag, y `wordlists` trae la lista española, de 7 776 palabras, un borrador aún sin revisar, con licencia CC BY-SA 4.0.
- **`wordlist.ts`**, como `wordkey.Generate`, `CheckList` y `Bits` de Go en `27a75ee`, con sus textos: `generateWords` sortea palabras distintas, 7 por defecto, con `crypto.getRandomValues`; `wordBits` da su fuerza, 90 bits para 7 de 7 776; `checkWordList` rechaza una lista de menos de 2 048 palabras, con dos que son una al normalizarlas o con un carácter que no es una letra del alfabeto de su idioma, que da el código y no la lista (para `es`, de la `a` a la `z`, `á`, `é`, `í`, `ó`, `ú`, `ü` y `ñ`); y `readWordList` solo acepta una lista con el SHA-256 fijado para su idioma, que sea UTF-8 y que `checkWordList` acepte. `wordkey.ts` gana `wordRules`, que carga una vez las tablas de Unicode para quien lee muchas palabras.
- **La página `/create`** ofrece palabras al azar por defecto. Con la política «con llave», una casilla abre la cápsula también con unas palabras: al marcarla, la página descarga del propio sitio la lista española (`create-words.ts`, el fichero que Vite publica con un nombre con hash), la acepta solo con su SHA-256 fijado, y sortea 7 palabras que no salen del navegador. Las muestra numeradas, con su fuerza calculada con la lista cargada y un botón para sacar otras. «Las elijo yo» deja escribir las propias, con el aviso de que son más débiles. En los dos casos hay que escribirlas otra vez para comprobar que se tienen; dan igual mayúsculas y tildes. `planCapsule` recibe `wordsKind` (`none`, `random` u `own`) y pide las palabras que falten.
- **Corregido en `/create`:** el efecto que comprueba las rutas escribía `pathCheck` y lo volvía a leer, así que Svelte lo relanzaba sin fin en cuanto se cargaban las reglas, con el primer fichero, comentario o autor: `effect_update_depth_exceeded` en la consola y `checkPaths` repetido sobre toda la lista. Lo lee ahora de una variable local. Venía de `7dc88ef` (1 de octubre) y está en la `0.4.0`.
- **La lista inglesa.** `testdata` y `wordlists` pasan a `e671032` de `datekeys-go`, con el mismo `testdata`: `wordlists/en.txt` es la lista grande de la EFF, de 7 776 palabras, CC BY 4.0, en su orden y sin los números de los dados. `WORD_LIST_SHA256` fija su SHA-256, y el alfabeto de `en` es de la `a` a la `z` y el guion de sus cuatro palabras compuestas, como `t-shirt`. `/create` sigue ofreciendo la española, y `check-build.mjs` exige que la página publique solo esa.
- **Los dados**, para quien no se fía del azar del ordenador, como `dice.go` de `datekeys-go` en `92e7154`, con sus textos: cinco dados por palabra dan un número del 11111 al 66666, la posición de la palabra en una lista de 7 776. `wordlist.ts` gana `DICE_LIST_SIZE`, `diceNumber`, `diceWord`, `diceWords` y `diceList`, la lista numerada como la publica la EFF (la de `en` es su fichero, byte a byte), y `wordkey.ts`, `goFields`, que separa por espacios como `strings.Fields` de Go. En `/create`, «Con dados» convierte los números en palabras a medida que se escriben, avisa de un número que no vale o de una palabra repetida, y ofrece la lista numerada para imprimirla, con su SHA-256; `planCapsule` recibe `dice` y `diceList`, y `readDice` lee los números uno a uno. `testdata` y `wordlists` pasan a `92e7154`, cuyo `README.md` de las listas recoge el SHA-256 de cada lista numerada.
- **Lo que dice el SDK oficial al sellar**, como `aefc8f6` de `datekeys-go` (§7.6, §62.1 reglas 26 y 27, §71). `sync-testdata.mjs` copia también `annex/` de Go, el anexo de recuperación (§79 bajo un título con la versión y el SHA-256 de la especificación), y `testdata`, `wordlists` y `annex` pasan a `aefc8f6`. En `/create`: con una fecha a más de un año y «solo fecha», la recomendación de la llave; tras crear la cápsula, «Para abrirla más adelante», con lo que hará falta y la descarga de las instrucciones para abrirla sin DateKeys, `<cápsula>.recuperacion.txt` (`annex.ts`); y `check-build.mjs` exige el anexo byte a byte. `profile.ts` gana `ProfileStatus`, `PROFILE_STATUS` y `profileStatusOf`, como `StatusOf` de Go: Quicknet está activo; `planCapsule` no escribe con un perfil que no lo esté, y `/inspect` avisa si el de una cápsula está comprometido (`buildReport` recibe `profileStatus`).
- **`licenses.txt`** lleva el `README.md` de `wordlists/`, con el origen, el método y la licencia de la lista (CC BY-SA 4.0), y `check-build.mjs` exige que esté y que la lista se publique byte a byte.
## 0.4.0 — 7 de octubre de 2026
La especificación 0.15, el tag `spec-v0.15` de `datekeys-go`: la recuperación a largo plazo, con el objeto release, los archivos de releases y el release en la mano. El autor la cerró el 7 de octubre de 2026, con el tag `v0.4.0`.

@ -21,22 +21,24 @@ Hay tres números de versión, cada uno con su significado, como en la referenci
| Versión | Dónde | Cambia cuando |
|---|---|---|
| Formato | Dentro de los objetos: el formato de la cápsula, el `VERSION` del prelude de DKC1, 1, 2 o 3 al leer, que fija también la versión de schema de CONTROL_CBOR; y 1 en la trama DKK1 y en el schema de los demás objetos | Cambia el formato. Un lector rechaza una versión que no conoce (§22, §70) |
| Especificación | `SPEC_VERSION` de `src/lib/dkc/version.ts`, hoy `0.15`: la del tag `spec-v0.15` de `datekeys-go`, que aprobó el autor el 7 de octubre de 2026. `testdata` está en ese tag (`fe50885`), y todos sus ficheros dicen `0.15` | Cambia el texto normativo |
| Especificación | `SPEC_VERSION` de `src/lib/dkc/version.ts`, hoy `0.16`: la v0.16 de `datekeys-go`, aprobada el 7 de octubre de 2026 con el tag `spec-v0.16`, en `b6ff17a`. `testdata` está en ese commit, y todos sus ficheros dicen `0.16` | Cambia el texto normativo |
| Librería | `VERSION` de `src/lib/dkc/version.ts`, igual al campo `version` de `package.json` | Cambia la API o el comportamiento. Versionado semántico, sin promesa de estabilidad antes de 1.0.0 |
`version.test.ts` comprueba que `VERSION` coincide con `package.json` y con su lockfile, y que `SPEC_VERSION` es la versión que nombran los vectores y fixtures compartidos; `vectors.test.ts` exige esa versión a cada fichero. El pie de la página muestra las dos.
La versión actual es la `0.4.0`, del 7 de octubre de 2026, con el tag `v0.4.0`. Cubre:
- la especificación 0.15 (el tag `spec-v0.15` de `datekeys-go`): lee los formatos de cápsula 1 a 3 y escribe el 3, y el 2 solo como generador de vectores (§62.1, regla 1);
- el objeto release y el release en la mano (§47.1, §49, §50, §63 pasos 9.c y 10): lee el objeto release y el JSON de drand con los textos de Go, busca la ronda en un archivo de releases local y abre con un release en la mano aunque el reloj vaya atrasado; `/inspect` acepta el release pegado o en un fichero;
La versión actual es la `0.5.0`, del 7 de octubre de 2026, con el tag `v0.5.0`. Cubre:
- la especificación 0.16 (el tag `spec-v0.16` de `datekeys-go`): lee los formatos de cápsula 1 a 3 y escribe el 3, y el 2 solo como generador de vectores (§62.1, regla 1);
- un sello sin `accuracy` no prueba nada antes de la fecha de apertura: los veredictos dicen por qué, y el escritor devuelve los del área que escribe (§29.7, §29.11, §62.1 regla 19);
- el objeto release y el release en la mano (§47.1, §49, §50, §63 pasos 9.c y 10): lee el objeto release, y el JSON de drand de forma estricta y con los textos de Go, busca la ronda en un archivo de releases local y abre con un release en la mano aunque el reloj vaya atrasado;
- la firma de autor de `alg` 1, con las claves `dkauthor1…`, y la de `alg` 2 con certificados, y el sello RFC 3161: las evalúa al abrir con los veredictos de Go y las escribe con los enganches del escritor;
- la llave de palabras, la nota pública y el localizador de `datekeys.capsule`, con su sellado, su sobre y la comprobación de la IP a la que resuelve una dirección, NAT64 incluido;
- la llave de palabras, con palabras al azar de la lista inglesa de la EFF o de la española, sorteadas en el dispositivo o con dados; la nota pública y el localizador de `datekeys.capsule`, con su sellado, su sobre y la comprobación de la IP a la que resuelve una dirección, NAT64 incluido;
- lo que dice el SDK oficial al sellar: el anexo de recuperación junto a la cápsula, lo que hará falta para abrirla y el estado del perfil (§7.6, §62.1 reglas 26 y 27, §71);
- solo el scheme de Quicknet (`bls-unchained-g1-rfc9380`), el único que admite §12.1: un perfil de otro scheme da `ERR_UNKNOWN_PROFILE`;
- la inspección de los pasos 1 a 8 y la apertura de los pasos 9 a 18, desde un `Uint8Array` o un `Blob` y hacia memoria o hacia un stream de salida, y las páginas `/inspect` y `/create`;
- todos los vectores y fixtures compartidos de `datekeys-go` en `fe50885` (`spec-v0.15`);
- todos los vectores y fixtures compartidos de `datekeys-go` en `b6ff17a` (`spec-v0.16`);
- navegadores con Web Crypto y Node 20 o posterior.
La `0.3.0`, del 6 de octubre de 2026, con el tag `v0.3.0`, cubría la especificación 0.14 sin el objeto release. La `0.2.0`, del mismo día, con el tag `v0.2.0`, cubría lo mismo para la especificación 0.13. La anterior es `0.1.0`, del 29 de septiembre de 2026, con el tag `v0.1.0`: la especificación 0.9, leer los formatos 1 y 2, y la página `/inspect`.
La `0.4.0`, del 7 de octubre de 2026, con el tag `v0.4.0`, cubría la especificación 0.15, sin las palabras al azar. La `0.3.0`, del 6 de octubre de 2026, con el tag `v0.3.0`, cubría la especificación 0.14 sin el objeto release. La `0.2.0`, del mismo día, con el tag `v0.2.0`, cubría lo mismo para la especificación 0.13. La anterior es `0.1.0`, del 29 de septiembre de 2026, con el tag `v0.1.0`: la especificación 0.9, leer los formatos 1 y 2, y la página `/inspect`.
[CHANGELOG.md](CHANGELOG.md) recoge los cambios de cada versión.
@ -53,13 +55,13 @@ La inspección (pasos 1 a 8) no importa ninguna dependencia. Funciona en navegad
| `cbor.ts` | El codec propio de la referencia, con las mismas lecturas, las mismas comprobaciones en el mismo orden y los mismos textos de error: `Encoder` con error persistente; `Decoder`, cursor estricto (`map`/`key`/`endMap`, `array`, `uint`/`uint64`, `bstr`, `text`, `done`); `unmarshal` (decodifica, reencodifica y compara; `onReject` para borrar secretos); `peek`/`checkSchema` (capa 2 de §69.1: tipo y versión antes que nada) y `walk` (lector genérico acotado en profundidad y longitud) | `codec` |
| `schema.ts` | Lo que comparten los decodificadores de PUBLIC_HEADER, CONTROL_CBOR y el cuerpo de la `.dkk`: `key N: ` en los errores, claves obligatorias y arrays de extensiones | `capsule/framing.go`, `accesskey` |
| `extension.ts` | Arrays de extensiones, leídos con su objeto (capa 3 de §69.1). Registros con ubicación opcional (`registeredIn`): una extensión conocida fuera de los objetos y arrays de su registro cuenta allí como desconocida (§54, §72), como `extension.Placement` en Go. Reglas del array: de 1 a 64, en orden estrictamente ascendente de los bytes UTF-8 de `extension_id` (nunca por unidades UTF-16), `extension_version` hasta 2³² − 1, `data` ausente o `bstr` no vacío, ningún id en los dos arrays; registros, críticas y no críticas (capa 4). `checkWrite` es la regla de los codificadores del §72 para las extensiones de la especificación (`NOTE_ID`, `CAPSULE_ID`): `datekeys.note` solo en el array no crítico de la cabecera y `datekeys.capsule` solo en el de una `.dkk`, con datos válidos; la aplican el escritor de cápsulas y el de `.dkk` | `extension` (`CheckWrite` y el registro `Standard`) |
| `profile.ts` | Provider Profile: CBOR exacto, `profile_hash`, reglas 1 a 4 de §12.1 en su orden (el límite de `period` de §74 en la capa del esquema; alfabetos, el único scheme que admite la v0.14, `bls-unchained-g1-rfc9380`, con los textos de `validateDrand` de Go, clave pública de G2 y fórmula de `chain_hash`), registro pinneado; Quicknet fijado por su CBOR y su hash | `profile` |
| `profile.ts` | Provider Profile: CBOR exacto, `profile_hash`, reglas 1 a 4 de §12.1 en su orden (el límite de `period` de §74 en la capa del esquema; alfabetos, el único scheme que admite la v0.14, `bls-unchained-g1-rfc9380`, con los textos de `validateDrand` de Go, clave pública de G2 y fórmula de `chain_hash`), registro pinneado; Quicknet fijado por su CBOR y su hash; `profileStatusOf`, el estado de un perfil en el registro de §71 tal como lo conoce esta versión (`PROFILE_STATUS`: Quicknet, activo), como `StatusOf` de Go | `profile` |
| `bls12381.ts` | Pertenencia de claves públicas BLS12-381 comprimidas (G1 y G2) al subgrupo, como `FromCompressed` de kilic | `kyber-bls12381` |
| `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 (`h3Base` y `h3Try`, que desplaza el primer byte de cada intento un bit a la derecha, como kyber) y H4; `roundIdentity` y `hashToG1`, el hash a G1 de RFC 9380 que usan también `release.ts` y el cifrado; 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, el único que admite un perfil desde la v0.14: un `Profile` de otro scheme, que `validateProfile` rechaza, falla aquí con `ERR_UNKNOWN_PROFILE` tras las comprobaciones de ronda (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. Desde la v0.15, antes de la ronda compara la cadena que nombra el release, si nombra una, con la del perfil fijado (`ERR_PROFILE_MISMATCH`), y reexporta `releaseobject.ts` | `provider` (`Verify`, `ReleaseSource`) |
| `releaseobject.ts` | El objeto release de la v0.15 (§47.1), sin noble, como `provider/release.go` y `provider/archive.go` de Go, con sus textos byte a byte:<br>- `encodeRelease`, `newReleaseObject` y `decodeRelease`, con las capas del paso 10: el tamaño, de 1 a 1024 bytes, antes de decodificar; el tipo y la versión; el schema (`ERR_NON_CANONICAL_CBOR` o `ERR_UNSUPPORTED_VERSION`);<br>- `parseRelease`, que lee además el JSON de drand cuando su primer byte que no es un espacio es `{`, como lo lee `encoding/json` de Go en su struct: claves sin distinguir mayúsculas, la última gana, `null` deja el campo sin poner, una ronda que no es un entero de 0 a 2⁶⁴ − 1 o un campo de otro tipo hacen fallar la entrada, y una ronda por encima de 2⁵³ − 1 se guarda como `bigint` para el texto de `ERR_ROUND_MISMATCH`; todo fallo es `ERR_RELEASE_INVALID`;<br>- `ReleaseSupplier`, el release en la mano (`provider.Supplier`), y `encodedRelease`;<br>- `ReleaseArchive`, el archivo de releases local, formato informativo de §50: de un `Blob` lee solo la cabecera y una firma, y cada fallo, una ronda a ceros incluida, es `ERR_RELEASE_UNAVAILABLE` | `provider` (`EncodeRelease`, `DecodeRelease`, `ParseRelease`, `Supplier`, `Encoded`, `Archive`) |
| `releaseobject.ts` | El objeto release de la v0.15 (§47.1), sin noble, como `provider/release.go`, `provider/drandjson.go` y `provider/archive.go` de Go, con sus textos byte a byte:<br>- `encodeRelease`, `newReleaseObject` y `decodeRelease`, con las capas del paso 10: el tamaño, de 1 a 1024 bytes, antes de decodificar; el tipo y la versión; el schema (`ERR_NON_CANONICAL_CBOR` o `ERR_UNSUPPORTED_VERSION`);<br>- `parseRelease`, que lee además el JSON de drand cuando su primer byte que no es un espacio es `{`, con `parseDrandJSON`, el lector estricto de la v0.16, como `ParseDrandJSON` de Go: como mucho 8 KiB de JSON de RFC 8259 en UTF-8 válido cuyo valor es un objeto; ningún objeto repite un nombre, y los nombres se comparan exactos, punto de código a punto de código, tras decodificar sus escapes, así que `"round"` es `round` y `ROUND` otro nombre, que se ignora; el escape de un sustituto sin su pareja es JSON mal formado; `round` es un número sin signo, fracción ni exponente, de 1 a 2⁵³ − 1; `signature` y `randomness` son cadenas, y `randomness`, si viene, el SHA-256 de la firma en hexadecimal, en cualquier caja; todo fallo es `ERR_RELEASE_INVALID`. `strictJSON` y `jsonRound` son el lector y la ronda, que usan también `drand.ts` y `release-input.ts` de la página;<br>- `ReleaseSupplier`, el release en la mano (`provider.Supplier`), y `encodedRelease`;<br>- `ReleaseArchive`, el archivo de releases local, formato informativo de §50: de un `Blob` lee solo la cabecera y una firma, y cada fallo, una ronda a ceros incluida, es `ERR_RELEASE_UNAVAILABLE` | `provider` (`EncodeRelease`, `DecodeRelease`, `ParseRelease`, `ParseDrandJSON`, `Supplier`, `Encoded`, `Archive`) |
| `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). El release llega de `source`, una fuente de red, a la que no se pide nada antes de `round_time`, o de `release`, un release en la mano (v0.15), que no se compara con el reloj: `Opened.clockBehind` dice si el reloj iba por detrás, y el paso 10 empieza por decodificarlo;<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 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` |
| `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). Desde la v0.16, `Encrypted.security` da los veredictos del área escrita, como `Result.Security` de Go: un sello sin `accuracy` se escribe, pero da S5, o la línea de su firmante en F6, con su motivo, para que quien escribe avise de él y ofrezca pedir otro (regla 19). 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` |
@ -71,16 +73,18 @@ 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` 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` |
| `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. Desde la v0.16, S5 no tiene texto fijo: «No acredita que se sellara antes de la fecha de apertura: ‹motivo›.», con el motivo de `sealReasonText`, y la línea de un firmante de F6 cuyo sello no lo acredita dice «sin acreditar que fuera antes de la fecha de apertura: ‹motivo›». `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. `encodeSigners` escribe `SIGNERS` para el escritor, ordenado y con los textos de Go | `capsule/signature2.go` |
| `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. Del token lee además, desde la v0.16, si lleva `accuracy` (`hasAccuracy`) y su política, y `tokenIsBTSP` dice si es la BTSP de ETSI EN 319 421 (0.4.0.2023.1.1) | `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. Desde la v0.16, un sello válido es S4, o la línea de un firmante dice «antes de la fecha de apertura», solo si el token lleva `accuracy` y t más la precisión es anterior a `round_time`; si no, S5, con el primer motivo que se cumple (`SealReason`, como en Go): `late`, sellado después o demasiado cerca; `no accuracy, BTSP`, sin `accuracy` en un token de la política BTSP, que la exige; o `no accuracy`. `encodeSigners` escribe `SIGNERS` para el escritor, ordenado y con los textos de Go | `capsule/signature2.go`, `capsule/format3.go` (`SealReason`) |
| `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`) |
| `wordkey.ts` | La llave de palabras (§38.1): `normalizeWords` (NFD con las tablas de Unicode 18.0.0, sin las marcas U+0300 a U+036F, la minúscula simple de cada punto de código, partido por los espacios de la lista), `checkWords` (al menos 6 palabras distintas de 3 letras o más, sin controles, invisibles ni puntos sin asignar), `wordRules` (las dos, con las tablas cargadas una vez) y `wordKey`, PBKDF2-SHA256 de 600 000 rondas de Web Crypto con la sal de la cadena, la ronda y `capsule_id`. `quickWords`, `hiddenCodePoint` y `countedWords` leen con las tablas de la plataforma, para un formulario que no puede esperar a las de Unicode 18.0.0 | `wordkey.Normalize`, `Check`, `Key` |
| `wordlist.ts` | Las palabras al azar de la llave de palabras (el SHOULD de §38.1), con los textos de Go: `generateWords` sortea `DEFAULT_WORD_COUNT` palabras distintas, 7, con `randomIndex` y `crypto.getRandomValues`; `wordBits` da su fuerza, 90 bits para 7 de 7 776; `checkWordList` rechaza una lista de menos de 2 048 palabras, con dos que son una al normalizarlas o con un carácter que no es una letra del alfabeto de su idioma, que da el código y no la lista (para `es`, de la `a` a la `z`, `á`, `é`, `í`, `ó`, `ú`, `ü` y `ñ`; para `en`, de la `a` a la `z` y el guion de las cuatro palabras compuestas de la lista de la EFF): una letra cirílica que parece latina se volvería a escribir con la latina, y la cápsula no se abriría; y `readWordList` solo acepta una lista con el SHA-256 fijado para su idioma en `WORD_LIST_SHA256`, que sea UTF-8 y que `checkWordList` acepte. Ninguna lista se da por buena, tampoco las de DateKeys. Los dados, para quien no se fía del azar del ordenador: `diceNumber` numera las palabras de una lista de 7 776 del 11111 al 66666, `diceWord` da la palabra de un número de cinco dados, `diceWords` las de varios (al menos 6 y ninguna repetida) y `diceList` es la lista numerada para imprimirla, como la publica la EFF: la de `en` es su fichero, byte a byte | `wordkey.Generate`, `CheckList`, `Bits`, `List`, `DiceNumber`, `DiceWord`, `DiceWords`, `DiceList` |
| `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` |
| `framing.ts` | Prelude DKC1 (16 bytes), con el formato de la cápsula, de 1 a 3, y DKK1 (12 bytes) en el orden de §23 y §40, longitudes de 1 byte hasta los límites de §57, y troceo de secciones | `capsule/framing.go` |
@ -155,7 +159,7 @@ Todo el texto leído de la cápsula pasa por interpolación de texto de Svelte (
Tras los pasos 1 a 8, si la cápsula es válida, la página ofrece abrirla: pasos 9 a 18 de §63 con `open` (fase 2, paso 8). Si según el reloj del dispositivo la fecha no ha llegado, lo dice, no ofrece pedir la firma a drand y deja dar un release que la persona ya tenga, porque el reloj puede ir atrasado (v0.15, paso 9.c); si ese release abre la cápsula, el resultado dice que el reloj parece ir atrasado.
- **El release lo da quien abre** (decisión 4 del plan de la fase 2, confirmada el 28-09-2026): la página nunca lo pide a la red. Se pega la respuesta JSON de drand o la firma sola en hexadecimal (`release-input.ts`), y solo se leen `round` y `signature`: cualquier otro campo, como una clave pública, se ignora, porque la raíz de confianza es el perfil fijado (§11, §13). La página enlaza la URL de drand de esa ronda (`https://api.drand.sh/<chain hash>/public/<ronda>`, con `rel="noopener noreferrer"`), que abre la persona en otra pestaña. Es un release que suministra quien llama: el paso 10 lo verifica y da sus códigos (`ERR_ROUND_MISMATCH`, `ERR_RELEASE_INVALID`). En los fixtures oficiales el campo viene relleno con el release de su registro, que es el que publicó drand. Desde la v0.15 también se puede elegir un fichero con el release: el objeto release, la respuesta de drand en JSON o un archivo de releases local. Va tal cual a `open` como release en la mano (`opener.ts`), y el paso 10 lo lee con los códigos de Go; un fichero de más de 8 KiB que no es un archivo de releases no se lee, y la página lo dice. Lo pegado va como el JSON de drand, que no nombra cadena.
- **El release lo da quien abre** (decisión 4 del plan de la fase 2, confirmada el 28-09-2026): la página nunca lo pide a la red. Se pega la respuesta JSON de drand o la firma sola en hexadecimal (`release-input.ts`), y solo se leen `round` y `signature`, con el lector estricto de la librería (v0.16, §47.1), para que la página nunca lea otra ronda que la del paso 10: un nombre repetido es JSON no válido, `ROUND` es otro campo y la ronda va de 1 a 2⁵³ − 1. Cualquier otro campo, como una clave pública, se ignora, porque la raíz de confianza es el perfil fijado (§11, §13). La página enlaza la URL de drand de esa ronda (`https://api.drand.sh/<chain hash>/public/<ronda>`, con `rel="noopener noreferrer"`), que abre la persona en otra pestaña. Es un release que suministra quien llama: el paso 10 lo verifica y da sus códigos (`ERR_ROUND_MISMATCH`, `ERR_RELEASE_INVALID`). En los fixtures oficiales el campo viene relleno con el release de su registro, que es el que publicó drand. Desde la v0.15 también se puede elegir un fichero con el release: el objeto release, la respuesta de drand en JSON o un archivo de releases local. Va tal cual a `open` como release en la mano (`opener.ts`), y el paso 10 lo lee con los códigos de Go; un fichero de más de 8 KiB que no es un archivo de releases no se lee, y la página lo dice. Lo pegado va como el JSON de drand, que no nombra cadena.
- **Credenciales**, solo en `time_and_key`: una `.dkk` (se leen como mucho 16 MiB + 13 bytes, `readAccessKey`) o identidades `AGE-SECRET-KEY-1…`, una por línea, como un fichero de identidades de `age`. La página explica de dónde sale la identidad: del fichero que se creó con `age-keygen`, que se puede pegar entero. Un error de una línea se da por su número, nunca por su contenido, y el foco va al campo; las identidades se borran tras usarlas. En `time_only` la página no las pide y `open` no las usa.
- **El código de la apertura se carga bajo demanda**: la página importa `opener.ts` con `import()` al pulsar "Abrir", y con él `open.ts`, noble y `age-encryption`. `opening.ts`, que construye lo que se muestra, solo importa tipos de `open.ts`. `check-build.mjs` comprueba que ninguna página carga noble, `@scure/base` ni `age-encryption` en la primera carga.
- **El texto en claro** se muestra, cuando la cápsula se abre y es texto (`plaintextPreview`): UTF-8 imprimible, hasta 100 000 caracteres de sus primeros 128 KiB. Se muestra el CR LF de Windows como salto de línea y se omite el BOM, como hace un editor; la descarga conserva los bytes exactos. Lo que no es texto solo se ofrece para descargar. El de un fixture se abre en memoria, con su SHA-256 comparado con el del registro. El contenido va justo debajo del veredicto, antes de los pasos 9 a 18.
@ -181,6 +185,7 @@ Medido en Chromium (el navegador de la app de escritorio) sobre la compilación
- **Las rutas se pueden editar**, y se comprueban a medida que se escriben con las reglas del lector (`create-check.ts`, bajo demanda con las tablas de Unicode): cada problema en su fila, en español y con su regla, y un choque de R7 en las dos rutas. Una prueba de propiedad exige que la página no vea ningún problema exactamente cuando `checkPath` y `checkTree` aceptan las rutas. Una lista de más de 2000 ficheros se vuelve a comprobar tras una pausa al escribir, con el problema de cada ruta y la clave de cada segmento en memoria; se muestran los 500 primeros y los que han tenido un problema. Cada fila da el tamaño, la fecha y la ruta original si cambió.
- **El comentario y el autor declarado** son opcionales, se comprueban igual (§29.6) y van cifrados en el head, con el comentario en LF. La casilla de la fecha de modificación, marcada por defecto, guarda la de cada fichero, que se omite si cae fuera de 1970 a 9999 (§62.1, regla 16). Una cápsula puede guardar solo un comentario (decisión 9).
- **El formulario** (`create-input.ts`) mide los ficheros una vez por cambio de la lista (`measureFiles`), y con ellos da el tamaño exacto del `.dkc` a cada cambio del comentario, del autor o de la fecha. Después, el día y la hora, en la zona del dispositivo, en otra de las que conoce el navegador o en UTC. Y la política: «solo con la fecha» (`time_only`, la de por defecto) o «con la fecha y una clave» (`time_and_key`). Esta lleva destinatarios `age1…`, uno por línea y comprobados mientras se escriben (`recipient.ts`, sin noble), y la casilla de la clave portable, marcada por defecto; como mucho 16 credenciales. Una ayuda plegable explica qué es un destinatario de `age` y cómo se consigue: quien abrirá la cápsula crea su par con `age-keygen` y envía solo su `age1…`. Los campos se comprueban en su orden, y el foco va al campo del primer problema: una ruta que no vale lleva el foco a su fila.
- **La llave de palabras**, con la política «con llave»: una casilla la añade a la `.dkk` y a las personas con `age`, con palabras al azar por defecto (el SHOULD de §38.1). La primera vez, la página descarga del propio sitio la lista española de `datekeys-go` (`create-words.ts`), la acepta solo con su SHA-256 fijado y si `checkWordList` la da por buena, y sortea 7 palabras con `crypto.getRandomValues`, que no salen del navegador. Las muestra numeradas, porque se escriben en ese orden, con su fuerza calculada con la lista cargada (unos 90 bits) y un botón para sacar otras. «Con dados», para quien no se fía del azar del ordenador, convierte los números de cinco dados que escribe la persona en las palabras de la lista, a medida que los escribe, con su problema si un número no vale o repite una palabra, y ofrece la lista numerada para imprimirla, con su SHA-256. «Las elijo yo» deja escribir las propias, con el aviso de que son más débiles. En todos los casos hay que escribirlas otra vez, y dan igual mayúsculas y tildes.
- **La hora local** (`localtime.ts`) se convierte al instante UTC con `Intl` y las reglas de zona que conoce hoy el navegador. Una hora que la zona se salta al adelantar los relojes se rechaza. De una que repite al atrasarlos se toma la más tardía, para no abrir nunca antes de lo querido. La cápsula no guarda la zona.
- **Antes de cifrar**, la página muestra el instante efectivo, que es el de la ronda, en la zona elegida y en UTC, y cuánto cae después del pedido. También la ronda, la `dk1_`, la hora del dispositivo junto a la UTC, el contenido, el tamaño exacto del `.dkc` (`capsuleLength` con `bodyLengthOf`) y lo que la cápsula deja ver hasta la fecha (§55.2). Y los avisos: el de protocolo preliminar (§74), siempre; los de §53 y §50, con sus textos, si el instante efectivo está a más de 365 días (`LONG_HORIZON_SECONDS`); y uno informativo si está a menos de una hora. La hora del dispositivo se lee cada segundo mientras la pestaña se ve y al crear la cápsula; la página no la pregunta a ningún servidor. Si el navegador no conoce la zona del dispositivo (V8 da entonces `Etc/Unknown`), la página empieza en UTC.
- **El writer se carga bajo demanda**: la página importa `creator.ts` con `import()` al pulsar «Crear la cápsula», y con él `encrypt.ts`, noble, `age-encryption` y las tablas de Unicode de las rutas. El relleno es siempre `reforzado`, y no hay extensiones. `encryptFiles` lee cada fichero dos veces, y la página muestra el progreso de las dos pasadas: la primera, en bytes de los ficheros, contada por `creator.ts` con un stream propio que lee a demanda; la segunda, en bytes del `.dkc`. «Cancelar» detiene cualquiera de las dos, y un fichero que cambia entre ellas hace fallar la escritura. Lo escrito no se ofrece si no mide lo que se mostró o si los pasos 1 a 8 lo rechazan, y ante cualquier fallo se borra la `.dkk`. Si la fecha llega mientras la página se prepara para escribir, el writer la rechaza con su propio reloj (§62.1, regla 2), y la página lo dice en el campo de la fecha.
@ -188,6 +193,7 @@ Medido en Chromium (el navegador de la app de escritorio) sobre la compilación
- **La `.dkk`** (152 bytes sin extensiones) vive solo en la memoria de la página: nunca en OPFS, y nunca se muestra como `AGE-SECRET-KEY-1…`. Sus bytes se borran al pulsar «Olvidar la clave», al crear otra cápsula y al salir, y con ellos las URL de sus descargas. Junto a ella va el aviso de §7.4. Si la cápsula solo se abre con su `.dkk` y no se ha descargado, la página pide confirmación antes de borrarla, al olvidarla o al crear otra, y avisa antes de salir: el navegador pregunta al cerrar o recargar, y la página, al seguir un enlace del sitio. Salir de la página también cancela una escritura en curso.
- **Las descargas** van por separado, con nombres que se pueden editar. Por defecto son `capsula-<apertura en UTC>.dkc` y `.dkk`, que no dicen cuándo se creó la cápsula. La URL `blob:` de cada descarga se revoca un minuto después del clic.
- **El resultado** muestra el `capsule_id`, la política, el contenido, el tamaño y el relleno, y el informe de los pasos 1 a 8 del `.dkc` escrito, con el componente del inspector.
- **Lo que pide el SDK oficial al sellar** (§7.6, §62.1 reglas 26 y 27, §71). Con una fecha a más de un año y la política «solo fecha», la página recomienda la llave, y la opción «solo fecha» lo recuerda para algo valioso. Tras crear la cápsula, «Para abrirla más adelante» dice qué hará falta: la cápsula, una de sus llaves si la lleva, y la firma de drand de su ronda, que tendrá que guardar un archivo de firmas o un servicio de caché si drand ya no la sirve; y ofrece las instrucciones para abrirla sin DateKeys (`annex.ts`), el anexo del §79 que `datekeys-go` copia en `annex/`, con el nombre de la cápsula y `.recuperacion.txt`. `planCapsule` no escribe con un perfil que no esté activo en el registro de §71, y `/inspect` avisa si el de una cápsula está comprometido.
Comprobado el 30-09-2026 en Chromium, con el formato 3:
- una lista de dos ficheros y una carpeta con `.DS_Store`, un fichero bajo `__MACOSX`, una ruta con «:», dos nombres que solo cambian en mayúsculas y una fecha de 1969: los dos del sistema, tachados; el problema de R4 y el choque de R7, en sus filas; la fecha de 1969, omitida;
@ -215,7 +221,7 @@ Rendimiento, informativo, en ese navegador con la ventana en segundo plano: una
| `src/lib/inspector/diagnostic.ts` | Notación de diagnóstico CBOR (RFC 8949 §8) de `walk`, acotada a 16 384 caracteres |
| `src/lib/dkc/prefix.ts` | Lectura por prefijo, que también usa la apertura: de un `.dkc` grande solo se leen 16 + PUBLIC_HEADER_LEN + SEALED_CONTROL_LEN + 2 MiB + 1 bytes, y solo 16 si los pasos 1 y 2 rechazan el prelude (otro tipo de fichero, un `.dkk`, longitudes fuera de §57), siempre con el mismo resultado que el fichero entero (lo comprueba `prefix.test.ts`) |
| `src/lib/inspector/fixtures.ts` | Los fixtures oficiales, empaquetados desde `testdata/fixtures` |
| `src/lib/inspector/release-input.ts` | Lee el release pegado (respuesta de drand o firma sola) y construye la URL de drand de la ronda; un fichero con el release lo lee `opener.ts` |
| `src/lib/inspector/release-input.ts` | Lee el release pegado (respuesta de drand, con `strictJSON` y `jsonRound` de la librería, o firma sola) y construye la URL de drand de la ronda; un fichero con el release lo lee `opener.ts` |
| `src/lib/inspector/opener.ts` | La apertura, cargada bajo demanda: identidades, `.dkk`, `open` con el release suministrado, SHA-256 del texto en claro |
| `src/lib/inspector/opening.ts` | `buildOpenReport`: el modelo de la apertura (pasos 9 a 18, release, extensiones de CONTROL_CBOR, texto en claro), sin DOM ni reloj; nombre del fichero descifrado |
| `src/lib/inspector/tempfile.ts` | El fichero temporal de OPFS, con un directorio y un Web Lock por pestaña y por zona (la apertura y crear), la cuota libre y la limpieza de lo que quedó. Su `writable` acepta además trozos con posición (`TempChunk`), como `FileSystemWritableFileStream`, para parchear el CRC-32 del ZIP |
@ -226,13 +232,15 @@ Rendimiento, informativo, en ese navegador con la ventana en segundo plano: una
| `src/lib/inspector/create-files.ts` | La lista de ficheros de `/create`, sin tablas: lo elegido y lo soltado, carpetas recorridas incluidas, los ficheros de un sistema fuera por defecto como en `collect.go`, las rutas editadas y lo que la cápsula guarda |
| `src/lib/inspector/create-check.ts` | Las reglas de las rutas y de los textos (§29.5, §29.6) como las explica `/create`: todos los problemas de todas las rutas, en español, con los de `pathrule.ts`. Se carga bajo demanda, con las tablas |
| `src/lib/inspector/create-input.ts` | El formulario de `/create`, sin DOM, reloj ni writer: `planCapsule` comprueba los campos en su orden y da lo que se muestra antes de cifrar, con los ficheros medidos una vez (`chooseFiles`); los destinatarios y los nombres de los ficheros |
| `src/lib/inspector/annex.ts` | El anexo de recuperación que `/create` ofrece junto a la cápsula: `annex/recovery.md`, que Vite publica con un nombre con hash, y el nombre de su fichero, el de la cápsula con `.recuperacion.txt`, como `RecoveryAnnexSuffix` de Go |
| `src/lib/inspector/create-words.ts` | La lista de las palabras al azar de `/create`: `wordListLoader` descarga una vez `wordlists/es.txt`, que Vite publica con un nombre con hash, y la lee con `readWordList`, con su SHA-256 fijado; un fallo no se guarda y la siguiente llamada lo vuelve a intentar |
| `src/lib/inspector/creator.ts` | La escritura, cargada bajo demanda: `encryptFiles` con los ficheros, el comentario y el autor hacia el fichero temporal o la memoria, con el progreso de las dos lecturas, la cancelación y la cuota, y los pasos 1 a 8 de lo escrito |
| `src/lib/components/` | `InspectionReport`, `OpenPanel`, `StepList`, `ExtensionList`, `DataView`, `Mark` |
| `src/routes/` | Layout, portada, inspector y crear |
### Fixtures
`fixtures.ts` importa con `import.meta.glob` los `.dkc` de `testdata/fixtures` como URL (`?url`) y, de cada registro JSON, solo tres campos públicos: `description`, `release` (la ronda y la firma que publicó drand, con las que la página abre el fixture) y `plaintext_sha256` (para comparar con lo descifrado). `testdata/` sigue siendo la única fuente: Vite copia cada `.dkc` como fichero con hash en `_app/immutable/assets/` y nunca lo incrusta como `data:` (`assetsInlineLimit: 0`), y no se copia nada más. Los `.dkk`, los textos en claro y los demás campos de los registros (`payload_identity`, `control_cbor`…) no llegan al sitio; `check-build.mjs` lo comprueba. En desarrollo, `server.fs.allow` deja que Vite sirva `testdata/fixtures`.
`fixtures.ts` importa con `import.meta.glob` los `.dkc` de `testdata/fixtures` como URL (`?url`) y, de cada registro JSON, solo tres campos públicos: `description`, `release` (la ronda y la firma que publicó drand, con las que la página abre el fixture) y `plaintext_sha256` (para comparar con lo descifrado). `testdata/` sigue siendo la única fuente: Vite copia cada `.dkc` como fichero con hash en `_app/immutable/assets/` y nunca lo incrusta como `data:` (`assetsInlineLimit: 0`), y no se copia nada más. Los `.dkk`, los textos en claro y los demás campos de los registros (`payload_identity`, `control_cbor`, `words_text`…) no llegan al sitio; `check-build.mjs` lo comprueba. La descripción de `format3_time_and_key_words` (v0.16) nombra sus palabras, las del vector público del anexo, 79.7, así que en la página se abre escribiéndolas. En desarrollo, `server.fs.allow` deja que Vite sirva `testdata/fixtures`.
### Sin red: la Content-Security-Policy
@ -244,18 +252,19 @@ img-src 'self'; manifest-src 'self'; object-src 'none'; script-src 'self' 'sha25
style-src 'self'; style-src-attr 'unsafe-hashes' 'sha256-…'; base-uri 'none'; form-action 'none'
```
- `connect-src`: `fetch` llega al propio origen, para los fixtures, y a los tres relays públicos de drand que usa la CLI de la referencia, solo cuando la persona pulsa «Pedir la firma a drand» en `/inspect` (`drand.ts`: en carrera, 6 s, como mucho 8 KiB, sin redirecciones, y la aleatoriedad comprobada contra la firma, que el paso 10 verifica con la clave fijada). El relay ve la IP y la ronda pedida. Tampoco llega a URL `blob:`: la descarga del texto en claro es una navegación.
- `connect-src`: `fetch` llega al propio origen, para los fixtures, y a los tres relays públicos de drand que usa la CLI de la referencia, solo cuando la persona pulsa «Pedir la firma a drand» en `/inspect` (`drand.ts`: en carrera, 6 s, como mucho 8 KiB, sin redirecciones, y la respuesta leída con `parseDrandJSON`, el lector estricto que usa desde la v0.16 el cliente de Go, con la aleatoriedad comprobada contra la firma, que el paso 10 verifica con la clave fijada). El relay ve la IP y la ronda pedida. Tampoco llega a URL `blob:`: la descarga del texto en claro es una navegación.
- `script-src`: los módulos del sitio y el hash SHA-256 del único script en línea, el arranque de SvelteKit (los nonces no sirven en HTML prerenderizado).
- `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 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.
`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, el texto de una llave de palabras, `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`
El JavaScript minimizado no conserva comentarios, así que el sitio publica `licenses.txt`, enlazado desde el pie de cada página. Lo escribe un plugin de `vite.config.ts` al compilar el cliente, con tres partes:
El JavaScript minimizado no conserva comentarios, así que el sitio publica `licenses.txt`, enlazado desde el pie de cada página. Lo escribe un plugin de `vite.config.ts` al compilar el cliente, con cuatro partes:
- los módulos de `src/` derivados de otros proyectos, con el aviso de su cabecera: `ibe.ts` (de `tlock-js`, MIT) y `bech32.ts` (de `age`, MIT);
- el fichero de licencia de cada paquete npm con algún módulo en el bundle;
- el `README.md` de `wordlists/`, con el origen, el método y la licencia de las listas de palabras que publica el sitio (CC BY-SA 4.0 la española);
- la licencia Apache-2.0 del sitio.
En un hosting estático basta con servir `build/`. Las directivas que solo funcionan como cabecera HTTP (`frame-ancestors`, `sandbox`, `report-to`) quedan para el servidor que la aloje. `crypto.subtle` exige contexto seguro: `https`, o `http` en `localhost`.
@ -295,12 +304,13 @@ Umbrales de cobertura (`vitest.config.ts`), al 100 % en líneas, ramas, funcione
- `testdata/vectors/padding.json`: P con las dos reglas y la longitud de `PAYLOAD_AGE` de cada L, incluidas las fronteras en que fallan las operaciones de 32 bits y un logaritmo en coma flotante, y las longitudes por encima de L_MAX, que se rechazan. `padding.test.ts` contrasta además `paddedLength` con el §29.1 escrito en `BigInt` sobre 20 000 longitudes de todo el rango.
- `testdata/vectors/tlock_ibe.json`: el vector de H2 del IBE de tlock (§63 paso 11). Hasta que llegue `ibe.ts` (fase 2), el test lo recalcula con `@noble/curves` 2.4.0: los puntos son canónicos para `bls12381.ts`, el pairing serializado en el orden de kilic es el GT del vector y su H2 coincide; el orden propio de noble (`Fp12.toBytes`) da otro hash.
- `testdata/vectors/tlock_steps.json`: los pasos 10 y 11 de §63 para Quicknet, valor a valor (v0.14), con el código de la librería: M con `roundIdentity`, H(M) con `hashToG1`, el release con `verifyRelease` y la ecuación de pairing; las tres partes del stanza con `ciphertextFromBody`, e(firma, U) con `gtBytes`, H2, sigma, H4 y la file key; la base de H3 con `h3Base`, cada intento con `h3Try` y su digest, el desplazamiento del primer byte y su aceptación, r con `h3`, y r·G2 = U con `proofHolds`; y `decryptOnG2` sobre el cuerpo entero. También las lecturas erróneas que descarta el generador: el DST de G2 o la ronda sin SHA-256 no verifican, y poner a cero el bit más alto en vez de desplazar el byte da otra r, que U no prueba; una firma de otra ronda y un V o un W editados no descifran.
- `testdata/vectors/release.json` y `releases/` (v0.15): los 36 objetos y los 12 JSON de drand pasan por `parseRelease` y `verifyRelease` frente a Quicknet y la ronda de cada caso, con el resultado, el texto de Go y lo que decodifican; un objeto se reescribe a sus bytes. Cada fichero de `releases/` es el objeto de su ronda, que verifica, y las 7 consultas del archivo dan lo que dice el fichero, en memoria y desde un `Blob`. La guarda de `testdata` exige cada fichero de `releases/`.
- `testdata/vectors/release.json` y `releases/` (v0.15): los 36 objetos y los 38 JSON de drand, 26 de ellos de la lectura estricta de la v0.16, pasan por `parseRelease` y `verifyRelease` frente a Quicknet y la ronda de cada caso, con el resultado, el texto de Go y lo que decodifican; un objeto se reescribe a sus bytes. Cada fichero de `releases/` es el objeto de su ronda, que verifica, y las 7 consultas del archivo dan lo que dice el fichero, en memoria y desde un `Blob`. La guarda de `testdata` exige cada fichero de `releases/`.
- `testdata/vectors/mutations.json`: se leen los 222 casos enteros (ediciones sobre un fixture o hex congelado, release, su fuente, reloj, registro, extensiones, `.dkk`, identidades y, en los once del formato 3 que abren, sus veredictos). Cada caso usa la fuente que dice su campo `source`, como `singleSource` de Go: `supplied`, un release en la mano que se da a `open` como objeto release, con la cadena de Quicknet salvo que el caso nombre otra, o `network`, una fuente que verifica el release y lo descarta. Los 222 pasan por `open` con su release, su reloj, su registro, sus extensiones, su `.dkk`, sus identidades y un `MemorySink`, y dan el mismo código en el mismo paso que Go y el mismo texto que `capsule.Open` (`testing/mutation-texts.json`); los cuatro de seguridad del formato 3 abren con los veredictos X, F1, F2 y S1 del registro, y siete de la lista de la v0.11 con los suyos. «round not reached yet», del formato 1, abre con el release en la mano y el reloj atrasado. También pasan como `Blob` con un stream de salida, que termina abortado en los 210 que fallan, y un sumidero que nunca se publica. Son los 178 del §64: las 33 mutaciones de las dos primeras listas en cada formato, las 23 de la lista del formato 2, las 48 de la del formato 3 y las 8 de la lista de la v0.11; y 44 más. Ninguno de los que fallan sin red pide un release. Los 57 de los pasos 1 a 8 pasan además por `inspect`, y un test fija los recuentos y el orden. Las `.dkk` ofrecidas se decodifican.
- `testdata/vectors/inspect_differential.json`: las 5 110 mutaciones de los catorce fixtures de base dan el mismo veredicto, código y paso que Go; los `bases` se comprueban por su SHA-256.
- `testdata/vectors/paths.json` y `path_fold.json`, con las tablas cuyo digest nombran: cada ruta pasa por las reglas de una entrada con el texto exacto, cada árbol por la decodificación de un head de ficheros de 0 bytes, y cada segmento da su NFD y su clave de R7.
- `testdata/vectors/head_schema.json` y `security.json`: cada head pasa por `decodeHead` con el código de la primera capa que falla y el texto exacto de `ERR_HEAD_INVALID`, y un head válido se reescribe a sus bytes; cada área de seguridad da, en el contexto del fichero, sus veredictos y sus líneas.
- `testdata/vectors/security_cms.json`: los 135 casos de `alg` 2 y de `seal_type` 2 dan, en su contexto, los veredictos, el resultado de cada firmante exigido y de cada ajeno, la autoridad y la hora del sello y las líneas de Go, byte a byte. `ed25519_strict.json` lo corre `ed25519strict.test.ts`.
- `testdata/vectors/security_cms.json`: los 143 casos de `alg` 2 y de `seal_type` 2, hechos de nuevo para la v0.16, dan, en su contexto, los veredictos, el resultado de cada firmante exigido y de cada ajeno, la autoridad y la hora del sello, el motivo (`seal_reason`) de un S5 o de un firmante cuyo sello no acredita la fecha, y las líneas de Go, byte a byte.
- `format3_time_and_key_words` y `format3_full_chunk` (v0.16): el primero abre con la identidad que dan las palabras de su `words_text`, normalizadas con `normalizeWords` y derivadas con `wordKey` con la cadena, la ronda y su `capsule_id`, que es la de `identities`, y también en la página con el texto tal cual y con sus marcas sueltas; el segundo, cuyo `PAYLOAD_AGE` acaba en un trozo STREAM completo, abre a su fichero. `ed25519_strict.json` lo corre `ed25519strict.test.ts`.
- `testdata/vectors/note.json`: cada nota pasa por `checkNoteData`, con su resultado y el texto exacto de la regla que incumple, y por `publicNote`, `unusableNote` y `newNote`.
- `testdata/vectors/locator.json`: se corre entero, como `TestLocatorVectors` de Go, con los textos de Go de `testing/locator-vectors.json` y `testing/locator-uris.json`: la extensión se lee, el localizador se abre con el release de su ronda y da su texto en claro de 4096 bytes y sus campos, el resto se encuentra en el host en su desplazamiento y abre el sobre; las 36 bases de relleno; las 247 direcciones, con el veredicto del fichero y el texto de Go; el localizador mixto, del que se usa solo la dirección que se acepta y que un escritor no escribe; los 5 restos, los 8 datos de la extensión, con `ERR_EXTENSION_DATA_INVALID` como único código, y los 16 textos en claro.
- `src/lib/dkc/testing/locator-uris.json` y `locator-vectors.json`: el localizador sin su escritura contra Go en `spec-v0.12`, que comprueban `locator.test.ts` y `envelope.test.ts`, todo con el resultado y el texto de Go. Los escribe `scripts/locator-go-vectors.go`, el generador de la etapa 7a de `datekeys-dart` con las semillas de este repositorio:
@ -312,7 +322,7 @@ Umbrales de cobertura (`vitest.config.ts`), al 100 % en líneas, ramas, funcione
- `src/lib/dkc/testing/locator-seal.json`: lo que escriben `Seal` y `NewEnvelope` de Go mientras `crypto/rand` lee el keystream de una semilla (ChaCha20 bajo el SHA-256 de la semilla, nonce a cero), con cada valor que saca. Lo escribe `scripts/locator-seal-go-vectors.go`, y `envelope.test.ts` lo comprueba con `seededFill` de `testing/seeded.ts`, la misma fuente: `seal` y `newEnvelope` sacan los mismos valores en el mismo orden y escriben los mismos bytes en los 10 sellados, de uno a tres bloques y de la ronda 1 a la última de Quicknet, en los 8 sobres, de 0 bytes a 1 MiB, y en el camino entero; y rechazan las 12 entradas que rechaza Go, con su texto y antes de sacar nada. Se regenera como el anterior, con `-source spec-v0.12`.
- `src/lib/dkc/testing/locator-interop.json`: Go abre lo que escribe esta librería. `scripts/locator-ts-samples.mjs` escribe los ficheros de las recetas de `testing/locator-interop.ts`, siete localizadores sellados con sus sobres, de un `.dkc` de 0 bytes a uno de 16 MiB y un byte, en el que el contador del nonce de STREAM pasa de un byte; `scripts/locator-go-verdicts.go` los abre con `locator.Open`, comprueba que `Marshal` da el texto sellado y que se usan todas sus direcciones, abre el sobre con `OpenEnvelope` y busca el resto escondido con `Hide` y `RestIn`. `locator.interop.test.ts` vuelve a escribir cada fichero de su receta, exige el SHA-256 que leyó Go y lo abre también. Para regenerarlo: `node scripts/locator-ts-samples.mjs DIR`, y desde la exportación de `datekeys-go`, `go run .../scripts/locator-go-verdicts.go -source spec-v0.12 -testdata .../testdata -samples DIR > locator-interop.json`.
- `src/lib/dkc/testing/zip-vectors.json`: cómo lee `archive/zip` de Go los ZIP de la página. `scripts/zip-ts-samples.mjs` escribe con `ZipSink` las muestras de `testing/zip.ts` en un directorio: ficheros en carpetas con nombres fuera de ASCII y todas las clases de fecha (1970, 2³¹ − 1, 2³¹, 9999 y ninguna, que toma la hora de la ronda), un fichero dentro de una carpeta, y 65 535 ficheros, que hacen el ZIP64 por número de entradas. `scripts/zip-go-read.go`, solo con la biblioteca estándar, registra el SHA-256 de cada ZIP y una línea por entrada: nombre, bit 11, método, CRC-32, tamaños, fecha leída de los campos extra y SHA-256 del contenido, leído con el CRC comprobado. `zipsink.test.ts` vuelve a escribir cada muestra, exige su SHA-256 y calcula las líneas que Go tiene que dar. Para regenerarlo: `node scripts/zip-ts-samples.mjs DIR > zip-samples.json` y `go run scripts/zip-go-read.go DIR zip-samples.json > zip-vectors.json`.
- `src/lib/dkc/testing/mutation-texts.json`: el texto del error de `capsule.Open` para cada caso de `mutations.json`, u `ok`. Lo escribe `scripts/mutation-go-texts.go`, que reproduce los casos como `internal/testkit` de la referencia, que un módulo de fuera no puede importar: el perfil de Quicknet o ninguno, una fuente con el release del caso, como `OpenOptions.Release` o como `OpenOptions.Source` según su campo `source` (v0.15), la `.dkk` ya decodificada, las identidades, las extensiones del caso con los textos de `testkit.KnownExtensions` y un sumidero que descarta. Comprueba cada código contra el corpus. Para regenerarlo, desde un módulo Go temporal como el de abajo: `go run mutation-go-texts.go ../datekeys-ts/testdata > mutation-texts.json`.
- `src/lib/dkc/testing/mutation-texts.json`: el texto del error de `capsule.Open` para cada caso de `mutations.json`, u `ok`. Lo escribe `scripts/mutation-go-texts.go`, que reproduce los casos como `internal/testkit` de la referencia, que un módulo de fuera no puede importar: el perfil de Quicknet o ninguno, una fuente con el release del caso, como `OpenOptions.Release` o como `OpenOptions.Source` según su campo `source` (v0.15), la `.dkk` ya decodificada, las identidades, las extensiones del caso con los textos de `testkit.KnownExtensions` y un sumidero que descarta. Comprueba cada código contra el corpus. Para regenerarlo, desde un módulo Go temporal como el de abajo: `go run mutation-go-texts.go ../datekeys-ts/testdata > mutation-texts.json`. Se regeneró con `4f78854` (v0.16): solo cambió su campo `spec`.
- `src/lib/dkc/testing/ibe-vectors.json`: los valores de referencia de `ibe.ts`. Los escribe `scripts/ibe-go-vectors.go` con kyber, tlock y `age`, las librerías de la referencia Go, y `ibe.test.ts` los comprueba todos:
- el GT de e(G1, G2) y de su cuadrado, con H2 de 16 y 32 bytes;
- H3 y H4 sobre entradas fijas, entre ellas una H3 aceptada en la segunda iteración y otra en la tercera;
@ -327,7 +337,7 @@ Umbrales de cobertura (`vitest.config.ts`), al 100 % en líneas, ramas, funcione
Para regenerarlo: `node scripts/tlock-ts-samples.mjs > ts-samples.json`, y desde el mismo módulo Go temporal, `go run tlock-go-vectors.go ts-samples.json > tlock-vectors.json`.
En `ibe-vectors.json`, H2, H3 y H4 no son públicas en kyber: el script las reescribe con sus etiquetas y las comprueba en cada fixture contra la file key de tlock y contra U = r·G2. Los cifrados de kyber usan un sigma aleatorio, así que el fichero se genera una vez y se congela. Para regenerarlo, desde un módulo Go temporal que requiera la referencia (`replace g.activething.com/go/DateKeys => ../datekeys-go`, `GOFLAGS=-mod=mod`, y la directiva `go` de la referencia, para que se use su toolchain): `go run ibe-go-vectors.go ../datekeys-ts/testdata/fixtures > ibe-vectors.json`. Los siete fixtures del formato 2 se añadieron el 29-09-2026 así, sobre `spec-v0.9`, tomando solo el bloque `fixtures`: los valores de los cinco anteriores salieron idénticos, y el resto del fichero no cambió. Los nueve del formato 3 se añadieron igual el 30-09-2026, sobre `spec-v0.10`, con los doce anteriores idénticos. El 01-10-2026, sobre `spec-v0.11`, se añadieron `format3_signed`, `format3_signed_cms` y `format3_sealed`, y se rehicieron los dos de `format3_signature_unsupported` y `format3_seal_unsupported`, que Go regeneró con `alg` 4294967295; los demás salieron idénticos.
En `ibe-vectors.json`, H2, H3 y H4 no son públicas en kyber: el script las reescribe con sus etiquetas y las comprueba en cada fixture contra la file key de tlock y contra U = r·G2. Los cifrados de kyber usan un sigma aleatorio, así que el fichero se genera una vez y se congela. Para regenerarlo, desde un módulo Go temporal que requiera la referencia (`replace g.activething.com/go/DateKeys => ../datekeys-go`, `GOFLAGS=-mod=mod`, y la directiva `go` de la referencia, para que se use su toolchain): `go run ibe-go-vectors.go ../datekeys-ts/testdata/fixtures > ibe-vectors.json`. Los siete fixtures del formato 2 se añadieron el 29-09-2026 así, sobre `spec-v0.9`, tomando solo el bloque `fixtures`: los valores de los cinco anteriores salieron idénticos, y el resto del fichero no cambió. Los nueve del formato 3 se añadieron igual el 30-09-2026, sobre `spec-v0.10`, con los doce anteriores idénticos. El 01-10-2026, sobre `spec-v0.11`, se añadieron `format3_signed`, `format3_signed_cms` y `format3_sealed`, y se rehicieron los dos de `format3_signature_unsupported` y `format3_seal_unsupported`, que Go regeneró con `alg` 4294967295; los demás salieron idénticos. El 07-10-2026, sobre `4f78854` (v0.16), se añadieron `format3_full_chunk` y `format3_time_and_key_words`, con los 26 anteriores y el resto del fichero idénticos.
- El writer (`encrypt.test.ts`, `encrypt.stream.test.ts`, `encrypt.internal.test.ts`, `encrypt.property.test.ts`):
- con los valores de su registro, reproduce byte a byte el PRELUDE, PUBLIC_HEADER, `header_binding` y CONTROL_CBOR de los siete fixtures de formato 2 de Go, con las mismas longitudes; cada credencial cae en el hueco del registro y la `.dkk` sale igual, salvo su `capsule_digest`;
- lo que escribe se abre con `open`: las dos políticas, de 1 a 16 credenciales, cada una sola y todas juntas; contenidos en todos los bordes de trozo y de relleno, hasta 5 000 000 de bytes, con las dos reglas; rondas 1000, 1001 y 2000;
@ -348,13 +358,15 @@ Umbrales de cobertura (`vitest.config.ts`), al 100 % en líneas, ramas, funcione
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`:
- `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 `datekeys-go`, hoy de `4f78854`:
- 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;
- 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, `late`), `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.
Se regeneró el 07-10-2026 en una exportación de `4f78854` (v0.16), con las mismas cinco muestras, cuyo `ts` se volvió a leer con esta librería. Las cápsulas y los errores salen byte a byte iguales; cambian lo que lee `capsule.Open` y un texto de error: los tokens del sellador de Go y los de las muestras no llevan `accuracy`, así que los cinco sellos válidos dan ahora S5, «el sello no dice su precisión», el posterior a la fecha da su motivo, `late`, y la firma de ceros con sello falla con «F2 and S5».
- `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;
@ -455,7 +467,7 @@ Todas las versiones se fijan exactas y `package-lock.json` se versiona. `.npmrc`
npm run testdata:sync
```
Copia `testdata/` de `../datekeys-go` en `HEAD`, leyendo los blobs con git para no arrastrar cambios sin commit, y escribe `testdata/SOURCE.json` con el commit completo y el SHA-256 de cada fichero. Para fijar otro commit: `node scripts/sync-testdata.mjs sync --commit <rev>`.
Copia `testdata/` de `../datekeys-go` en `HEAD`, leyendo los blobs con git para no arrastrar cambios sin commit, y escribe `testdata/SOURCE.json` con el commit completo y el SHA-256 de cada fichero. Del mismo commit copia `wordkey/lists` en `wordlists/`, con su `wordlists/SOURCE.json`: las listas de las palabras al azar, que publica la página; y `annex/`, el anexo de recuperación que la página ofrece junto a cada cápsula, con su `annex/SOURCE.json`. Para fijar otro commit: `node scripts/sync-testdata.mjs sync --commit <rev>`.
```bash
npm run testdata:check
@ -463,10 +475,12 @@ npm run testdata:check
Comprueba que los ficheros coinciden con `SOURCE.json`, sin faltantes ni sobrantes, y vuelve a leerlos del repositorio Go en el commit registrado. Sin la opción `--against`, `node scripts/sync-testdata.mjs check` verifica solo la copia local. Ambos comandos usan solo Node y git.
`.gitattributes` marca `testdata/**` como binario para que git no altere ningún byte.
`.gitattributes` marca `testdata/**` y `wordlists/**` como binarios para que git no altere ningún byte.
Copia actual: la de `testdata/SOURCE.json` (el tag `spec-v0.15` de `datekeys-go`, `fe50885`), que se sincroniza con `node scripts/sync-testdata.mjs sync --commit spec-v0.15`. Añade `vectors/release.json`, los ficheros de `releases/` y el campo `source` de `mutations.json`.
Copia actual: la de `testdata/SOURCE.json`, `datekeys-go` en `b6ff17a`, el commit del tag `spec-v0.16`, que se sincroniza con `node scripts/sync-testdata.mjs sync --commit b6ff17a`. Del mismo commit vienen `wordlists/` y `annex/`, el anexo de recuperación, que es ya el §79 de la v0.16, con la licencia CC BY-ND 4.0 de la especificación en su título y la llave de palabras en 79.7. Sobre el `testdata` de `spec-v0.15` (`fe50885`), el de la v0.16 cambia el campo `spec` de cada fichero a `0.16`, hace de nuevo `vectors/security_cms.json`, con 143 casos y `seal_reason`, añade a `vectors/release.json` los 26 casos de la lectura estricta del JSON de drand y a `vectors/wordkey.json` el vector del anexo, y trae dos fixtures, `format3_time_and_key_words` y `format3_full_chunk`. `wordkey/lists` trae la lista inglesa de la EFF y la española; la página solo publica la española.
## Licencia
Apache-2.0 ([LICENSE](LICENSE)), como la librería Go de referencia. El código que se derive de terceros conserva su aviso de copyright y licencia en el propio fichero: el núcleo IBE (`ibe.ts`), derivado de `tlock-js` (Apache-2.0 OR MIT, usado bajo MIT), y `bech32.ts`, portado de `age` (MIT). El sitio publica esos avisos y los de sus paquetes npm en `licenses.txt`.
Las listas de palabras de `wordlists/` no son código de este proyecto ni tienen su licencia: la inglesa es la de la EFF, CC BY 4.0, y la española es CC BY-SA 4.0, una adaptación de FrequencyWords de Hermit Dave. Su `README.md`, copiado de `datekeys-go`, dice de dónde sale, cómo se hizo y su licencia.

@ -0,0 +1,7 @@
{
"module": "g.activething.com/go/DateKeys",
"commit": "b6ff17a5fa5f119aa8125356437a09c657b15d0b",
"files": {
"recovery.md": "856f37efbc74a86af0db996e7c7678a8b506f839f0a8074389c0dbabc5dafa81"
}
}

@ -0,0 +1,187 @@
# Cómo abrir una cápsula DateKeys sin software de DateKeys
Este texto acompaña a una cápsula del tiempo de DateKeys, un fichero `.dkc`: dice cómo abrirla, llegada su fecha, sin ningún software de DateKeys, por si ya no existe. Es el anexo informativo §79 de la especificación del protocolo DateKeys v0.16, cuyo texto tiene el SHA-256 807d4fe85ac09ad6f97abc75ab3e2156bb2f3fb0dc589777f4420627fad545e1. Es el mismo para toda cápsula: no lleva ningún dato de esta. Su licencia es CC BY-ND 4.0, Atribución-SinDerivadas 4.0 Internacional (https://creativecommons.org/licenses/by-nd/4.0/deed.es): se puede copiar y compartir sin cambios, citando su origen.
## 79. Anexo informativo: recuperación sin software DateKeys
Este anexo no es normativo. Dice cómo abrir una cápsula de Quicknet sin ningún software de DateKeys, por si dentro de décadas no existe. Repite lo que fijan las secciones que cita, que deciden en caso de duda.
La regla 27 de §62.1 recomienda al SDK oficial guardar este anexo junto al `.dkc`. No contiene ningún dato de una cápsula.
Hace falta:
- el `.dkc`;
- el release de su ronda, de cualquier fuente: un relay de drand, un archivo de releases, un servicio de caché (§50) o cualquier copia. No hace falta confiar en quien lo da: se verifica con la clave pública de 79.1 (79.3);
- en `time_and_key`, una credencial: la `.dkk`, la identity `age` de un recipient o las palabras de una llave de palabras (§38.1);
- una librería de BLS12-381 con pairing y con el hash a G1 de RFC 9380, SHA-256, HMAC-SHA256, HKDF-SHA256 (RFC 5869), ChaCha20-Poly1305 (RFC 8439), un decodificador de CBOR y la herramienta `age` (§77) o una librería compatible;
- con una llave de palabras, PBKDF2-HMAC-SHA256 (RFC 8018) y, si las palabras llevan otras letras que las de 79.7, `UnicodeData.txt` de Unicode 18.0.0.
No sirven las herramientas de drand: `tle` pide el release a la red y no acepta uno dado, y `age` no acepta una file key, que es lo que da el stanza tlock (79.4). Por eso este anexo describe esos dos pasos enteros (79.4 y 79.5).
La implementación de referencia lo sigue en `scripts/recovery`, un programa que no importa ningún paquete de DateKeys, tlock ni drand: solo la librería estándar de Go, `golang.org/x/crypto`, `filippo.io/age` y la librería BLS12-381 `drand/kyber-bls12381`, con las interfaces de `drand/kyber`. `scripts/recovery_check.sh` abre con él una cápsula `time_only`, otra `time_and_key` con su `.dkk`, otra con una llave de palabras y otra cuyo `PAYLOAD_AGE` acaba en un bloque completo, de los fixtures oficiales. Las palabras se le dan en un fichero de texto, y `UnicodeData.txt`, si hace falta, en otro.
### 79.1 Parámetros de Quicknet
Son los de §12, y pueden no estar ya en ningún otro sitio:
```text
chain_hash 52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971
clave pública (G2, 96 bytes)
83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c
8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb
5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a
genesis_time 1692803367 (segundos Unix de la ronda 1)
period 3 segundos
round_time(r) = genesis_time + (r − 1)·3
q 0x73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001
DST BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_
```
Los puntos se codifican comprimidos, en el formato de ZCash (§12.2): 48 bytes en G1, la firma, y 96 en G2, la clave pública y U, con la coordenada c1 antes que c0.
### 79.2 La cápsula
El `.dkc` empieza por 16 bytes (§22):
```text
0 4 "DKC1"
4 1 VERSION: el formato, 1, 2 o 3
5 1 0
6 2 0
8 4 PUBLIC_HEADER_LEN, entero big-endian
12 4 SEALED_CONTROL_LEN, entero big-endian
```
Le siguen `PUBLIC_HEADER`, de `PUBLIC_HEADER_LEN` bytes; `SEALED_CONTROL`, de `SEALED_CONTROL_LEN` bytes; y `PAYLOAD_AGE`, desde el byte 16 + `PUBLIC_HEADER_LEN` + `SEALED_CONTROL_LEN` hasta el final del fichero.
`PUBLIC_HEADER` es un mapa CBOR (§24). Su clave 2 es `capsule_id`, 16 bytes; su clave 4, la política, 0 para `time_only` y 1 para `time_and_key`; y su clave 3, la DateKey, un texto `dk1_` seguido del Base64URL sin relleno de un JSON (§18):
```json
{"version":1,"network":"datekeys:quicknet:v1","round":1000}
```
`round` es la ronda de la cápsula.
### 79.3 El release
Un objeto release es un mapa CBOR (§47.1): la clave 0 es `"datekeys-release"`; la 2, el `chain_hash`, que ha de ser el de 79.1; la 3, la ronda, que ha de ser la de la DateKey; y la 4, la firma, de 48 bytes. Así lo sirven la Release API y un servicio de caché. En un archivo de releases (§50), la firma de la ronda r son los 48 bytes que empiezan en |cabecera| + (r − primera ronda)·48, y 48 ceros si el archivo no la tiene. Un relay de drand la entrega como JSON, `{"round": …, "signature": "…"}`, con la firma en hexadecimal. Hoy se pide así, aunque las direcciones pueden cambiar:
```text
GET https://api.drand.sh/v2/chains/<chain_hash>/rounds/<ronda>
```
La firma no necesita confianza: se verifica (§63, paso 10). Con M el SHA-256 de la ronda en 8 bytes big-endian, y H el hash a G1 de RFC 9380 con la suite `BLS12381G1_XMD:SHA-256_SSWU_RO_` y el DST de 79.1:
```text
e(H(M), clave_pública) == e(firma, G2)
```
con e el pairing de G1 × G2, el primer argumento en G1 y el segundo en G2, y G2 el generador de G2. La firma y la clave pública se decodifican como puntos comprimidos válidos del subgrupo, distintos del punto en el infinito. Para una ronda solo hay una firma válida: cualquier copia que verifique es el release.
### 79.4 El stanza tlock y FK_TIME
`SEALED_CONTROL` es un fichero `age` (79.5) cuya cabecera tiene un solo stanza:
```text
-> tlock <ronda en decimal> <chain_hash en hexadecimal en minúsculas>
<cuerpo en Base64 estándar sin relleno, en líneas de 64 caracteres>
```
El cuerpo mide 128 bytes, U ‖ V ‖ W: U, de 96 bytes, un punto de G2, y V y W, de 16 bytes. Con la firma del release (§63, paso 11):
```text
sigma = V XOR H2(e(firma, U))
FK_TIME = W XOR H4(sigma)
r = H3(sigma, FK_TIME)
comprobar r·G2 == U
```
- H2(x) son los 16 primeros bytes de SHA-256(`IBE-H2` ‖ x), con x los 576 bytes del elemento de GT en el orden de §63, «Serialización de GT en H2»: c1 antes que c0 en cada nivel de la torre, y c2, c1, c0 en Fp6, cada elemento de Fp en 48 bytes big-endian. Una librería que serializa con c0 primero da otro H2. El vector de `testdata/vectors/tlock_ibe.json` lo comprueba: H2(e(G1, G2)) = `cb87319f24560b5231579a09ad79f12e`, con G1 y G2 los generadores.
- H4(sigma) son los 16 primeros bytes de SHA-256(`IBE-H4` ‖ sigma).
- H3(sigma, FK_TIME): base = SHA-256(`IBE-H3` ‖ sigma ‖ FK_TIME); para i = 1, 2, … hasta 65534, d = SHA-256(uint16_le(i) ‖ base), con el contador en 2 bytes little-endian delante de base; se desplaza un bit a la derecha el primer byte de d, solo ese byte; y si d, como entero big-endian de 32 bytes, es menor que q, r = d.
Las etiquetas son los bytes ASCII, sin longitud ni terminador. FK_TIME, de 16 bytes, es la file key del fichero `age` de `SEALED_CONTROL`. `testdata/vectors/tlock_steps.json` da cada valor intermedio de cinco stanzas.
### 79.5 Abrir un fichero `age` con su file key
Es la especificación `age` v1 de C2SP (§77), resumida. Un fichero `age` es una cabecera de texto y un payload binario:
```text
age-encryption.org/v1
-> <tipo> <argumentos…>
<cuerpo del stanza en Base64 sin relleno, líneas de 64 caracteres, la última más corta, quizá vacía>
--- <MAC en Base64 sin relleno, 43 caracteres>
<payload>
```
Con la file key FK, de 16 bytes:
1. La cabecera: clave_mac = HKDF-SHA256(ikm = FK, salt = vacío, info = `header`), 32 bytes. El MAC es HMAC-SHA256(clave_mac, la cabecera desde `age-encryption.org/v1` hasta `---` inclusive, sin el espacio que lo sigue). Si no coincide con el de la línea `---`, la file key o la cabecera son otras.
2. El payload empieza tras el salto de línea del MAC por un nonce de 16 bytes. clave = HKDF-SHA256(ikm = FK, salt = nonce, info = `payload`), 32 bytes.
3. Lo demás son bloques de ChaCha20-Poly1305 de 65 536 bytes de texto, 65 552 cifrados; el último puede ser más corto, o estar completo: un plaintext de 65 536 bytes es un solo bloque, completo y marcado como último. El nonce de 12 bytes del bloque n, desde 0, es n en 11 bytes big-endian seguido de 0x01 en el último bloque y de 0x00 en los demás. No hay datos asociados. El último bloque solo puede estar vacío si es el único, y nada sigue al último bloque.
### 79.6 Las capas siguientes
El plaintext de `SEALED_CONTROL` es:
- en `time_only`, `CONTROL_CBOR`;
- en `time_and_key`, otro fichero `age`, `INNER_ACCESS_AGE`, con stanzas X25519, uno por credencial y señuelos hasta 16 en los formatos 2 y 3. Se abre con `age -d -i clave.txt`, con la identity de la credencial en `clave.txt`. La de una `.dkk` es su `access_material`. La `.dkk` empieza por 12 bytes, `DKK1`, `01`, `00`, `00 00` y `BODY_LEN` en 4 bytes big-endian (§40), y le sigue un mapa CBOR cuya clave 5 es ese `access_material`, 32 bytes (§41), y cuya clave 3 es el `capsule_id` de su cápsula. Una llave de palabras da la identity con 79.7.
`CONTROL_CBOR` es un mapa CBOR (§31). Su clave 3 es `I_PAYLOAD`, otra identity X25519 de 32 bytes, y en los formatos 2 y 3 su clave 6 es L, una cadena de 8 bytes con un entero big-endian, no un entero CBOR. Con `I_PAYLOAD`, `age -d -i payload.txt` abre `PAYLOAD_AGE`.
Una identity X25519 de 32 bytes se escribe para `age` en Bech32 (BIP 173, §77), no Bech32m: el prefijo `age-secret-key-`, los 32 bytes reagrupados de 8 en 5 bits con ceros al final, que dan 52 caracteres, y la suma de comprobación de BIP 173, calculada con el prefijo en minúsculas. Después, todo en mayúsculas: `AGE-SECRET-KEY-1…`.
### 79.7 La llave de palabras
Unas palabras dan la identity X25519 de una credencial (§38.1):
```text
P = las palabras normalizadas, en UTF-8, separadas por un espacio (0x20)
S = "DateKeys llave de palabras v2|" || chain_hash || "|" || ronda || "|" || capsule_id
id = PBKDF2-HMAC-SHA256(P, S, 600000 iteraciones, 32 bytes) ; RFC 8018
```
En S, `chain_hash` es el de 79.1 y `capsule_id` el de 79.2, en hexadecimal en minúsculas, y la ronda, la de la DateKey en decimal, sin ceros a la izquierda. `id` es la identity, que se escribe para `age` como dice 79.6.
**Normalización sin tablas.** Si el texto solo lleva caracteres ASCII imprimibles (U+0021 a U+007E), los espacios U+0009 a U+000D y U+0020, las letras á, é, í, ó, ú, ü y ñ y sus mayúsculas, y marcas de U+0300 a U+036F, que es lo que da cualquier palabra de las listas de DateKeys, las palabras normalizadas salen así:
1. se quitan las marcas de U+0300 a U+036F;
2. á y Á pasan a a; é y É, a e; í e Í, a i; ó y Ó, a o; ú, ü, Ú y Ü, a u; ñ y Ñ, a n;
3. A a Z pasan a a a z;
4. se parte el texto por los espacios, sin palabras vacías.
El resto se queda: la puntuación y las cifras cuentan, y «perro,» no es «perro».
**Normalización completa.** Para cualquier otro texto, en este orden: la NFD de UAX #15, con la descomposición canónica de cada punto de código (el campo 5 de `UnicodeData.txt`, sin las que llevan una etiqueta entre `<` y `>`, aplicada hasta el final), la de las sílabas Hangul, que se calcula, y la reordenación canónica por la clase de combinación (campo 3); se quitan los puntos de código de U+0300 a U+036F; cada punto de código pasa a su minúscula simple (campo 13), si la tiene; y se parte por los espacios U+0009 a U+000D, U+0020, U+0085, U+00A0, U+1680, U+2000 a U+200A, U+2028, U+2029, U+202F, U+205F y U+3000, sin palabras vacías. Sirve cualquier copia de `UnicodeData.txt` de Unicode 18.0.0 cuyo SHA-256 sea `0736451de439ae7baf1425136617da495e09ee5afbe6e394374db7009ea08950`; unicode.org la publica en `https://www.unicode.org/Public/18.0.0/ucd/UnicodeData.txt`. Otra versión de Unicode puede dar otras palabras: una que asigne un punto de código nuevo, o que cambie una descomposición o una minúscula.
Vectores, con el chain hash de Quicknet, la ronda 1000 y `capsule_id` = `000102030405060708090a0b0c0d0e0f`:
- «perro luna casa verde tren mar» da `id` = `fceec4d8ca8de86c85a1f26ed49f82a2b38431bd0ce36db995ae7dfd49b96e41`;
- el texto «Ñandú», dos espacios, «PINGÜINO», un tabulador (U+0009) y «camión árbol Éter ola» da «nandu pinguino camion arbol eter ola» e `id` = `273295d29370126a3be50b743132718d3cd9137fb3bb4cb20aa23163d2e19bb7`, lo mismo que ese texto con las tildes, la diéresis y la tilde de la eñe escritas como marcas sueltas detrás de su letra (U+0301, U+0308 y U+0303).
### 79.8 El contenido
El plaintext de `PAYLOAD_AGE` es:
- en formato 1, el contenido entero;
- en formato 2, el contenido en sus L primeros bytes, seguido de ceros;
- en formato 3, `BODY` en sus L primeros bytes, seguido de ceros (§29.2).
`BODY` empieza por tres enteros de 4 bytes big-endian: `AREA_LEN`, `SECURITY_LEN` y `HEAD_LEN`. Siguen el área de `security`, de `AREA_LEN` bytes, que se puede saltar: solo da los veredictos de la firma y del sello (§29.7); el head, un mapa CBOR de `HEAD_LEN` bytes que empieza en 12 + `AREA_LEN`; y los ficheros, desde 12 + `AREA_LEN` + `HEAD_LEN`, el origen de sus desplazamientos.
La clave 5 del head es la lista de ficheros (§29.4). Cada uno es un mapa:
```text
0 → ruta, con "/" entre carpetas
1 → tamaño
2 → start
3 → end
4 → SHA-256 de sus bytes
5 → mtime, en segundos Unix, opcional
```
Sus bytes van de start a end, sin incluir end, contados desde el origen. Las claves 3 y 4 del head son el comentario y el autor declarado: textos del creador que no prueban nada (§29.7). Una ruta que saldría de la carpeta de destino no se escribe.
### 79.9 Lo que el anexo no comprueba
Este anexo comprueba lo que decide que el resultado es el correcto: la firma del release, r·G2 == U, los MAC de cada fichero `age` y el SHA-256 de cada fichero. No comprueba, entre otras cosas, la codificación canónica de cada objeto, `header_binding` (§26), los 16 stanzas de `INNER_ACCESS_AGE` (§39), los ceros del relleno (§29.1) ni las reglas de las rutas (§29.5). Una cápsula que el lector de §63 rechazaría puede abrirse siguiendo este anexo; su contenido es el que sellaron las MAC de `age`, pero no tiene la garantía de un lector conforme.

4
package-lock.json generated

@ -1,12 +1,12 @@
{
"name": "datekeys-ts",
"version": "0.4.0",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "datekeys-ts",
"version": "0.4.0",
"version": "0.5.0",
"license": "Apache-2.0",
"dependencies": {
"@noble/ciphers": "2.4.0",

@ -1,6 +1,6 @@
{
"name": "datekeys-ts",
"version": "0.4.0",
"version": "0.5.0",
"private": true,
"description": "DateKeys in TypeScript: canonical CBOR codec, DKC1/DKK1 parsers, capsule inspector library and its static inspector page; later, browser encryption and decryption.",
"license": "Apache-2.0",

@ -17,9 +17,9 @@
// - a page has an inline style, an event handler attribute or a URL to
// another origin, or a stylesheet imports or references one;
// - the official .dkc fixtures are not shipped byte for byte, or a secret of
// the fixtures (.dkk files, plaintexts, identities, payload identities,
// access material, CONTROL_CBOR, and the heads, salts, comments and paths
// of format 3) is anywhere in the build;
// the fixtures (.dkk files, plaintexts, identities, the text of a key of
// words, payload identities, access material, CONTROL_CBOR, and the heads,
// salts, comments and paths of format 3) is anywhere in the build;
// - a page loads the Unicode tables of the paths of format 3 with its first
// load, not on demand;
// - a page loads the locator of datekeys.capsule (locator.ts, envelope.ts,
@ -38,9 +38,13 @@
// - 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;
// - the word list of /create is not shipped byte for byte, or another list
// of wordlists/ is shipped, or the recovery annex is not shipped byte for
// byte;
// - 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.
// another project, the README of the word lists, or the license of the
// site.
//
// It reports the JavaScript that each page loads, raw and gzip.
@ -232,6 +236,8 @@ for (const name of fixtureFiles) {
if (name.endsWith('.json')) {
const record = JSON.parse(bytes.toString('utf8'));
for (const id of record.identities ?? []) addSecret(`${name} identities`, id);
// The text of the words of a key of words (spec v0.16, annex 79.7), a credential.
addSecret(`${name} words_text`, record.words_text);
addSecret(`${name} payload_identity`, record.payload_identity);
addSecret(`${name} access_material`, record.access_material);
addSecret(`${name} control_cbor`, record.control_cbor);
@ -253,6 +259,32 @@ for (const f of files) {
if (/\.(dkk|plaintext)$/.test(f) || /fixtures?\/.*\.json$/.test(inBuild(f))) fail(`${rel(f)}: fixture file that must not be shipped`);
}
// ---------------------------------------------------------------------------
// The word lists of the random words of /create: the list that the page
// fetches (create-words.ts), the Spanish one, is shipped byte for byte, once,
// since the page takes it only with its pinned SHA-256, and the other lists
// of wordlists/ are not shipped; licenses.txt gives their source and license
// (below).
const WORDLISTS = join(ROOT, 'wordlists');
const PAGE_LISTS = ['es.txt'];
for (const name of readdirSync(WORDLISTS).filter((n) => n.endsWith('.txt')).sort()) {
const stem = name.slice(0, -'.txt'.length);
const copies = byHash.get(sha256(readFileSync(join(WORDLISTS, name)))) ?? [];
const shipped = copies.filter((p) => inBuild(p).startsWith('_app/immutable/assets/') && basename(p).startsWith(`${stem}.`));
const want = PAGE_LISTS.includes(name) ? 1 : 0;
if (shipped.length !== want) fail(`word list ${name}: ${shipped.length} byte-exact copies under build/_app/immutable/assets, want ${want}`);
}
// The recovery annex that /create offers next to a capsule (annex.ts), §79
// of the specification, shipped byte for byte, once.
const ANNEX = join(ROOT, 'annex', 'recovery.md');
const annexCopies = (byHash.get(sha256(readFileSync(ANNEX))) ?? []).filter(
(p) => inBuild(p).startsWith('_app/immutable/assets/') && basename(p).startsWith('recovery.'),
);
if (annexCopies.length !== 1) fail(`the recovery annex: ${annexCopies.length} byte-exact copies under build/_app/immutable/assets, want 1`);
// ---------------------------------------------------------------------------
// What the client bundle is made of.
@ -421,6 +453,8 @@ if (!existsSync(NOTICES)) {
}
const own = readFileSync(join(ROOT, 'LICENSE'), 'utf8').trim();
if (!notices.includes(own)) fail('licenses.txt lacks the license of the site');
const lists = readFileSync(join(WORDLISTS, 'README.md'), 'utf8').trim();
if (!notices.includes(lists)) fail('licenses.txt lacks wordlists/README.md, the source and the license of the word lists');
}
// ---------------------------------------------------------------------------

@ -27,11 +27,12 @@
// 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:
// datekeys-go made with git archive, which it does not change, never in the
// repository itself: at the tag spec-v0.12 when it was written, and at
// 4f78854, the draft v0.16, since. 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}')
// commit=$(git -C ../datekeys-go rev-parse '4f78854^{commit}')
// tmp=$(mktemp -d)
// git -C ../datekeys-go archive "$commit" | tar -x -C "$tmp"
// mkdir "$tmp/signingvectors"
@ -42,7 +43,10 @@
// 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.
// random, and frozen with what Go gives for them. For v0.16 the frozen
// samples were read again, with their "ts" as this library reads them now,
// and the tokens of the sealer, without accuracy, give S5 (spec v0.16,
// §29.11).
package signingvectors
import (

@ -1,17 +1,20 @@
#!/usr/bin/env node
// Vendors testdata/ from the Go reference implementation (g.activething.com/go/DateKeys)
// at one pinned commit and verifies it. The Go fixtures and vectors are the source of
// truth: this project never generates its own.
// truth: this project never generates its own. The word lists of the random words of a
// key of words (wordkey/lists of the Go repository) come from the same commit, into
// wordlists/, and so does the recovery annex that /create saves next to a capsule
// (annex/).
//
// node scripts/sync-testdata.mjs sync [--repo ../datekeys-go] [--commit HEAD]
// node scripts/sync-testdata.mjs check [--against ../datekeys-go]
//
// sync copies every blob under testdata/ at the commit (read with git, so uncommitted
// changes in the Go checkout are never picked up) and writes testdata/SOURCE.json with
// the full commit id and the SHA-256 of each file. check verifies that the files on disk
// match SOURCE.json exactly, with no missing or extra files; with --against it also
// re-reads every blob from the Go repository at the recorded commit.
// No dependencies: Node and git only.
// sync copies every blob under testdata/, wordkey/lists/ and annex/ at the commit (read with
// git, so uncommitted changes in the Go checkout are never picked up) and writes the
// SOURCE.json of each copy with the full commit id and the SHA-256 of each file. check
// verifies that the files on disk match SOURCE.json exactly, with no missing or extra
// files; with --against it also re-reads every blob from the Go repository at the
// recorded commit. No dependencies: Node and git only.
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
@ -21,10 +24,15 @@ import { fileURLToPath } from 'node:url';
const MODULE = 'g.activething.com/go/DateKeys';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const TESTDATA = join(ROOT, 'testdata');
const SOURCE = join(TESTDATA, 'SOURCE.json');
const DEFAULT_REPO = resolve(ROOT, '..', 'datekeys-go');
// The copies: the directory of the Go repository, and the local one with its SOURCE.json.
const TREES = [
{ name: 'testdata', from: 'testdata', dir: join(ROOT, 'testdata') },
{ name: 'wordlists', from: 'wordkey/lists', dir: join(ROOT, 'wordlists') },
{ name: 'annex', from: 'annex', dir: join(ROOT, 'annex') },
].map(tree => ({ ...tree, source: join(tree.dir, 'SOURCE.json') }));
function git(repo, args) {
return execFileSync('git', ['-C', repo, ...args], { encoding: 'buffer', maxBuffer: 1 << 30 });
}
@ -33,72 +41,87 @@ function sha256(buf) {
return createHash('sha256').update(buf).digest('hex');
}
// Files under testdata/ at commit, as paths relative to testdata/ with forward slashes.
function listBlobs(repo, commit) {
const out = git(repo, ['ls-tree', '-r', '-z', '--full-tree', commit, '--', 'testdata']).toString('utf8');
// Files under the directory `from` at commit, as paths relative to it with forward slashes.
function listBlobs(repo, commit, from) {
const out = git(repo, ['ls-tree', '-r', '-z', '--full-tree', commit, '--', from]).toString('utf8');
return out.split('\0').filter(Boolean).map(line => {
const [meta, path] = line.split('\t');
const [mode, type] = meta.split(' ');
if (type !== 'blob' || mode === '120000') throw new Error(`unsupported entry ${path} (${mode} ${type})`);
return path.slice('testdata/'.length);
return path.slice(from.length + 1);
}).sort();
}
function readBlob(repo, commit, path) {
return git(repo, ['cat-file', 'blob', `${commit}:testdata/${path}`]);
function readBlob(repo, commit, from, path) {
return git(repo, ['cat-file', 'blob', `${commit}:${from}/${path}`]);
}
// Files on disk under testdata/, excluding SOURCE.json.
function listLocal(dir = TESTDATA) {
// Files on disk under the directory of a copy, excluding its SOURCE.json.
function listLocal(tree, dir = tree.dir) {
let files = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) files = files.concat(listLocal(full));
else if (full !== SOURCE) files.push(relative(TESTDATA, full).split(sep).join('/'));
if (entry.isDirectory()) files = files.concat(listLocal(tree, full));
else if (full !== tree.source) files.push(relative(tree.dir, full).split(sep).join('/'));
}
return files.sort();
}
function sync(repo, rev) {
const commit = git(repo, ['rev-parse', '--verify', `${rev}^{commit}`]).toString('utf8').trim();
const paths = listBlobs(repo, commit);
if (paths.length === 0) throw new Error(`no testdata/ at ${commit}`);
// Read everything before touching the local copy, so a failed read leaves it intact.
const blobs = paths.map(path => [path, readBlob(repo, commit, path)]);
rmSync(TESTDATA, { recursive: true, force: true });
const files = {};
for (const [path, data] of blobs) {
const target = join(TESTDATA, ...path.split('/'));
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, data);
files[path] = sha256(data);
// Read everything before touching the local copies, so a failed read leaves them intact.
const read = TREES.map(tree => {
const paths = listBlobs(repo, commit, tree.from);
if (paths.length === 0) throw new Error(`no ${tree.from}/ at ${commit}`);
return { tree, blobs: paths.map(path => [path, readBlob(repo, commit, tree.from, path)]) };
});
for (const { tree, blobs } of read) {
rmSync(tree.dir, { recursive: true, force: true });
const files = {};
for (const [path, data] of blobs) {
const target = join(tree.dir, ...path.split('/'));
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, data);
files[path] = sha256(data);
}
writeFileSync(tree.source, JSON.stringify({ module: MODULE, commit, files }, null, 2) + '\n');
console.log(`${tree.name}: ${blobs.length} files from ${MODULE} at ${commit}`);
}
writeFileSync(SOURCE, JSON.stringify({ module: MODULE, commit, files }, null, 2) + '\n');
console.log(`testdata: ${paths.length} files from ${MODULE} at ${commit}`);
}
function check(against) {
const source = JSON.parse(readFileSync(SOURCE, 'utf8'));
// The differences between a copy and its SOURCE.json, and with --against between
// SOURCE.json and the Go repository at its commit.
function checkTree(tree, against) {
const source = JSON.parse(readFileSync(tree.source, 'utf8'));
const expected = Object.keys(source.files).sort();
const actual = listLocal();
const actual = listLocal(tree);
const errors = [];
for (const path of expected) {
if (!actual.includes(path)) { errors.push(`missing ${path}`); continue; }
if (sha256(readFileSync(join(TESTDATA, ...path.split('/')))) !== source.files[path]) errors.push(`modified ${path}`);
if (sha256(readFileSync(join(tree.dir, ...path.split('/')))) !== source.files[path]) errors.push(`modified ${path}`);
}
for (const path of actual) if (!(path in source.files)) errors.push(`extra ${path}`);
if (against) {
const upstream = listBlobs(against, source.commit);
const upstream = listBlobs(against, source.commit, tree.from);
if (upstream.join('\n') !== expected.join('\n')) errors.push(`file list differs from ${source.commit}`);
for (const path of upstream) {
if (sha256(readBlob(against, source.commit, path)) !== source.files[path]) errors.push(`differs from upstream ${path}`);
if (sha256(readBlob(against, source.commit, tree.from, path)) !== source.files[path]) errors.push(`differs from upstream ${path}`);
}
}
if (errors.length) {
for (const e of errors) console.error(`testdata: ${e}`);
process.exit(1);
return { source, files: expected.length, errors };
}
function check(against) {
const results = TREES.map(tree => ({ tree, ...checkTree(tree, against) }));
let failed = false;
for (const { tree, errors } of results) {
for (const e of errors) console.error(`${tree.name}: ${e}`);
failed ||= errors.length > 0;
}
if (failed) process.exit(1);
for (const { tree, source, files } of results) {
console.log(`${tree.name}: ${files} files match ${source.module} at ${source.commit}${against ? ' (verified against the repository)' : ''}`);
}
console.log(`testdata: ${expected.length} files match ${source.module} at ${source.commit}${against ? ' (verified against the repository)' : ''}`);
}
function option(args, name, fallback) {

@ -141,6 +141,13 @@
</div>
</div>
{#if report.profileStatus === 'compromised'}
<p class="compromised">
La red de esta cápsula, {report.profile?.network ?? report.capsule?.network}, figura como comprometida en el registro de
perfiles de DateKeys: su contenido pudo leerse antes de la fecha (§71).
</p>
{/if}
{#if report.note && 'text' in report.note}
<figure class="note">
<blockquote>{report.note.text}</blockquote>
@ -462,6 +469,13 @@
.note figcaption p {
margin: 0;
}
.compromised {
margin: -1.25rem 0 0;
padding: 0.5rem 0.75rem;
border-radius: var(--radius);
background: var(--fail-bg);
color: var(--fail);
}
.note-unusable {
margin: -1.25rem 0 0;
padding: 0.5rem 0.75rem;

@ -109,6 +109,9 @@ const OID = {
sigTimeStamp: oid('1.2.840.113549.1.9.16.2.14'),
tstInfo: oid('1.2.840.113549.1.9.16.1.4'),
riOCSP: oid('1.3.6.1.5.5.7.16.2'),
// The best practices time-stamp policy of ETSI EN 319 421, whose tokens
// carry accuracy (spec v0.16, §29.11).
btsp: oid('0.4.0.2023.1.1'),
sha256: oid('2.16.840.1.101.3.4.2.1'),
sha384: oid('2.16.840.1.101.3.4.2.2'),
sha512: oid('2.16.840.1.101.3.4.2.3'),
@ -950,9 +953,16 @@ export function checkSigner(s: SignerInfo, message: Uint8Array): CheckResult {
/** A time-stamp token of RFC 3161 read with the profile of spec §29.11. */
export interface Token {
/** t, and the precision of the token, zero when it has none. */
/**
* t, and the precision of the token, zero in the fields it does not carry.
* hasAccuracy is whether it carries the field at all: without it, the token
* does not say its precision (spec v0.16, §29.11).
*/
readonly genTime: Instant;
readonly accuracy: Instant;
readonly hasAccuracy: boolean;
/** The content of the object identifier of its policy, in hexadecimal, as this module compares them. */
readonly policy: string;
/** The algorithm of the messageImprint, and the hash. */
readonly imprintAlg: AlgID;
readonly imprint: Uint8Array;
@ -974,13 +984,13 @@ export function parseToken(b: Uint8Array): Token {
const imprintAlg = parseAlgID(info.imprintAlg);
const signer = sd.signers[0]!;
if (hashOfAlg(imprintAlg) === undefined || params(signer) === undefined || publicKey(signer.cert) === undefined) throw new CmsAlgorithmError();
return { genTime: info.genTime, accuracy: info.accuracy, imprintAlg, imprint: info.hash, tsa: signer.cert, data: sd };
return { genTime: info.genTime, accuracy: info.accuracy, hasAccuracy: info.hasAccuracy, policy: info.policy, imprintAlg, imprint: info.hash, tsa: signer.cert, data: sd };
}
// The TSTInfo of RFC 3161 3.2.1 in DER: the fields in order, each once, and
// nothing after the last. It returns the messageImprint algorithm, as the DER
// of its AlgorithmIdentifier, and the hash.
function parseTSTInfo(b: Uint8Array): { genTime: Instant; accuracy: Instant; imprintAlg: Uint8Array; hash: Uint8Array } {
function parseTSTInfo(b: Uint8Array): { genTime: Instant; accuracy: Instant; hasAccuracy: boolean; policy: string; imprintAlg: Uint8Array; hash: Uint8Array } {
const bad = (what: string): never => {
throw form(`the TSTInfo: ${what}`);
};
@ -999,9 +1009,11 @@ function parseTSTInfo(b: Uint8Array): { genTime: Instant; accuracy: Instant; imp
// The check of the DER read every time of the TSTInfo, genTime among them.
const genTime = parseTime(f[4]!).time;
let accuracy: Instant = { seconds: 0, nanos: 0 };
let hasAccuracy = false;
let rest = f.slice(5);
if (rest.length > 0 && rest[0]![0] === 0x30) {
accuracy = parseAccuracy(rest[0]!, bad);
hasAccuracy = true;
rest = rest.slice(1);
}
if (rest.length > 0 && rest[0]![0] === 0x01) {
@ -1013,7 +1025,7 @@ function parseTSTInfo(b: Uint8Array): { genTime: Instant; accuracy: Instant; imp
if (rest.length > 0 && rest[0]![0] === 0xa0) rest = rest.slice(1); // tsa
if (rest.length > 0 && rest[0]![0] === 0xa1) rest = rest.slice(1); // extensions
if (rest.length !== 0) bad('a field out of its place, or one that does not exist');
return { genTime, accuracy, imprintAlg: mi[0]!, hash: derContent(mi[1]!) };
return { genTime, accuracy, hasAccuracy, policy: oidOf(f[1]!)!, imprintAlg: mi[0]!, hash: derContent(mi[1]!) };
}
// Accuracy: seconds from 0 to 2^31 - 1, and millis and micros from 1 to 999,
@ -1056,6 +1068,15 @@ export function tokenImprintIsSHA256(t: Token): boolean {
return t.imprintAlg.oid === OID.sha256;
}
/**
* Whether the policy of the token is the best practices time-stamp policy of
* ETSI EN 319 421 (0.4.0.2023.1.1), compared by the bytes of its DER, which
* requires accuracy in every token (spec v0.16, §29.11).
*/
export function tokenIsBTSP(t: Token): boolean {
return t.policy === OID.btsp;
}
/**
* Verifies the token over `subject`, the bytes that it seals: the
* message-digest is the hash of the TSTInfo, the signature of the TSA

@ -325,12 +325,13 @@ describe('the hooks', () => {
files,
options({
authorKey: { publicKey: () => key.publicKey(), sign: (m) => ((message = m), key.sign(m)) },
sealer: { seal: (s) => ((subject = s), b.token(s, signedAt, {}, tsa)) },
sealer: { seal: (s) => ((subject = s), b.token(s, signedAt, { accuracy: b.accuracyOf(1) }, 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([w.security?.signature, w.security?.seal, w.security?.detail?.sealReason]).toEqual(['F4', 'S4', undefined]);
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!);
@ -345,6 +346,24 @@ describe('the hooks', () => {
expect(hx(subject)).not.toBe(hx(sealSubject(cc, headDigest(body.head), undefined)));
});
// Spec v0.16, §62.1 rule 19: a seal without accuracy is written, and the verdicts of the area that encryptFiles wrote
// say that it proves nothing before the opening date, so that the writer warns of it; the line of a signer of F6
// whose seal carries none says so too. encrypt, which writes format 2, has no area.
it('return the verdicts of the area they wrote, so that the writer warns of a seal without accuracy', async () => {
const tsa = await b.newECDSA('TSA', 'P-256', from, to);
const ana = await b.newECDSA('Ana', 'P-256', from, to);
const sealed = await encryptFiles(files, options({ sealer: { seal: (s) => b.token(s, signedAt, {}, tsa) } }));
expect([sealed.security?.signature, sealed.security?.seal, sealed.security?.detail?.sealReason]).toEqual(['F0', 'S5', 'no accuracy']);
expect((await openedOf(sealed.dkc!)).lines).toEqual(['Sin firma de autor.', 'No acredita que se sellara antes de la fecha de apertura: el sello no dice su precisión.']);
const btsp = await encryptFiles(files, options({ sealer: { seal: (s) => b.token(s, signedAt, { policy: b.BTSP_POLICY }, tsa) } }));
expect(btsp.security?.detail?.sealReason).toBe('no accuracy, BTSP');
const hash = await certHash(ana);
const cms = await encryptFiles(files, options({ cmsSigner: { signers: () => [hash], sign: (m) => b.signature(m, { token: (sig) => b.token(sig, signedAt, {}, tsa) }, ana) } }));
expect([cms.security?.signature, cms.security?.seal, cms.security?.detail?.signers.map((s) => [s.before, s.reason])]).toEqual(['F6', 'S0', [[false, 'no accuracy']]]);
expect((await encryptFiles(files, options())).security).toEqual({ signature: 'F0', seal: 'S0' });
expect((await encryptVectors(new Uint8Array(3), options())).security).toBeUndefined();
});
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);

@ -91,6 +91,7 @@ interface SignerResult {
result: string;
seal_time?: string;
before_round_time: boolean;
seal_reason?: string;
}
// A signer as the record of the reference writes it.
@ -100,6 +101,7 @@ const resultOf = (s: SignerLine): SignerResult => ({
result: s.result,
...(s.sealTime === undefined ? {} : { seal_time: formatRFC3339(s.sealTime) }),
before_round_time: s.before,
...(s.reason === undefined ? {} : { seal_reason: s.reason }),
});
interface DkkFixture {

@ -29,6 +29,7 @@ import { frame, split } from './testing/capsule.ts';
import { kinds } from './testing/verdicts.ts';
import { h, hx, listTestdata, readBytes, readJSON } from './testing/testdata.ts';
import { applyEdits, edits } from './testing/vectors.ts';
import { normalizeWords, wordKey } from './wordkey.ts';
import { parseX25519Identity, unwrapX25519, x25519PublicKey } from './x25519.ts';
interface Sidecar {
@ -47,6 +48,9 @@ interface Sidecar {
access_key_stanza?: number;
identities?: string[];
identity_stanzas?: number[];
capsule_id: string;
// The text of the words of a key of words, as the person types it (spec v0.16, annex 79.7).
words_text?: string;
// Format 3: the plaintext file is BODY.
area_len?: number;
content_offset?: number;
@ -780,3 +784,37 @@ describe('the identities of open', () => {
);
});
});
// Spec v0.16, annex 79.7 and §38.1: format3_time_and_key_words opens with the
// identity that its words give, the text of the second vector of the annex
// with capitals, accents, two spaces and a tab, as TestFixtureWords of Go.
describe('format3_time_and_key_words', () => {
it('opens with the identity that the words of its record give', async () => {
const f = fixture('format3_time_and_key_words');
expect([f.side.words_text, f.side.identities?.length, f.side.access_key_file]).toEqual(['Ñandú PINGÜINO camión árbol Éter ola', 1, undefined]);
const words = await normalizeWords(f.side.words_text!);
expect(words.join(' ')).toBe('nandu pinguino camion arbol eter ola');
const id = await wordKey(words, chainHashHex(quicknet()), f.release.round, h(f.side.capsule_id));
expect(hx(id)).toBe(hx(parseX25519Identity(f.side.identities![0]!)));
const sink = new MemorySink();
const r = await open(f.dkc, options(f, { identities: [id], sink }));
expect(r.error).toBeUndefined();
expectFiles(f, r, sink);
// The same text with its marks apart gives the same words.
expect(await normalizeWords(f.side.words_text!.normalize('NFD'))).toEqual(words);
});
});
// Spec v0.16, annex 79.5: the last STREAM chunk of age may be full. In
// format3_full_chunk, BODY and P measure 65536 bytes, so PAYLOAD_AGE is one
// full chunk, marked as the last.
describe('format3_full_chunk', () => {
it('opens a PAYLOAD_AGE that ends in a full chunk', async () => {
const f = fixture('format3_full_chunk');
expect([f.side.payload_length, f.side.padded_length, f.plaintext.length]).toEqual([65536, 65536, 65536]);
const sink = new MemorySink();
const r = await open(f.dkc, options(f, { sink }));
expect(r.error).toBeUndefined();
expectFiles(f, r, sink);
});
});

@ -11,13 +11,16 @@ import {
maxRound,
newRegistry,
type Profile,
PROFILE_STATUS,
profileHash,
profileStatusOf,
QUICKNET_CANONICAL_CBOR,
QUICKNET_PROFILE_HASH,
quicknet,
validateProfile,
validID,
} from './profile.ts';
import { toHex } from './bytes.ts';
import { b, bn, map, t, u } from './testing/cborhex.ts';
import { expectCode, expectCodeAsync, h, hx } from './testing/testdata.ts';
@ -294,3 +297,13 @@ describe('decodeProfile', () => {
for (const s of ['', 'A', ':a', 'a b', 'a/b', 'é', '0' + 'a'.repeat(128), 'a\n']) expect(validID(s), s).toBe(false);
});
});
describe('profileStatusOf', () => {
it('gives the state of a pinned profile in the registry of §71, as Go profile.StatusOf: Quicknet is active', async () => {
expect(toHex(await profileHash(quicknet()))).toBe(QUICKNET_PROFILE_HASH);
expect(profileStatusOf(QUICKNET_PROFILE_HASH)).toEqual({ status: 'active', known: true });
expect(profileStatusOf('00'.repeat(32))).toEqual({ status: 'active', known: false });
expect(Object.keys(PROFILE_STATUS)).toEqual([QUICKNET_PROFILE_HASH]);
expect(profileStatusOf('ab', { ab: 'compromised' })).toEqual({ status: 'compromised', known: true });
});
});

@ -501,3 +501,27 @@ export function defaultRegistry(): Promise<ProfileRegistry> {
});
return defaultRegistryPromise;
}
/** The state of a Provider Profile in the registry of profiles of DateKeys (spec §71), with the names of Go profile.Status. */
export type ProfileStatus = 'active' | 'read-only' | 'compromised';
/**
* The states of the profiles that this release of the library pins, by
* profile_hash in lower-case hexadecimal, as Go profile.StatusOf has them:
* DateKeys does not publish the signed registry of §71 yet, so a change of
* state comes with a new release. `active`: capsules are written and opened
* with the profile; `read-only`: no new capsule is written with it, and the
* capsules that exist still open; `compromised`: its confidentiality
* failed, the capsules that exist still open, and the official SDK warns
* that their content may have been read before their date.
*/
export const PROFILE_STATUS: Readonly<Record<string, ProfileStatus>> = { [QUICKNET_PROFILE_HASH]: 'active' };
/**
* The state of the profile whose profile_hash is `hash`, in lower-case
* hexadecimal, and whether `statuses`, PROFILE_STATUS by default, knows it:
* as Go profile.StatusOf, `active` for a profile it does not know.
*/
export function profileStatusOf(hash: string, statuses: Readonly<Record<string, ProfileStatus>> = PROFILE_STATUS): { status: ProfileStatus; known: boolean } {
return Object.hasOwn(statuses, hash) ? { status: statuses[hash]!, known: true } : { status: 'active', known: false };
}

@ -1,6 +1,8 @@
// Tests of releaseobject.ts, the release object, drand's JSON and the local
// archive of spec v0.15 (§47.1, §50): what vectors/release.json does not
// reach, with the texts of provider/release.go and provider/archive.go.
// archive of spec v0.15 (§47.1, §50), drand's JSON read strictly since v0.16:
// what vectors/release.json does not reach, with the texts of
// provider/release.go and provider/archive.go, and the cases of
// provider/drandjson_test.go.
import { describe, expect, it } from 'vitest';
import { concatBytes, toHex } from './bytes.ts';
@ -12,10 +14,13 @@ import {
encodeRelease,
isDrandJSON,
isReleaseArchive,
jsonRound,
newReleaseObject,
parseDrandJSON,
parseRelease,
type Release,
ReleaseArchive,
strictJSON,
verifyRelease,
} from './release.ts';
import { expectCode, h, readJSON } from './testing/testdata.ts';
@ -25,6 +30,8 @@ const sig1000 = signature('time_only');
const sig1001 = signature('empty_payload');
const rel = (round: number, sig: Uint8Array): Release => ({ round, signature: sig });
const S = toHex(sig1000);
// The randomness of round 1000, SHA-256 of its signature, as drand's API gives it.
const R = 'fe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd';
const enc = new TextEncoder();
const MALFORMED = 'provider: drand JSON: malformed, or without round or signature: ERR_RELEASE_INVALID';
@ -62,35 +69,155 @@ describe("parseRelease of drand's JSON", () => {
expect(isDrandJSON(enc.encode('{'))).toBe(false);
});
it('reads it as encoding/json reads it into the struct of the reference', async () => {
// Keys without case, the last one winning; other members ignored,
// nested values included; null unsets round and signature.
expect(await parsed(`{"ROUND":1,"Signature":"${S}","x":{"a":[1,"}",{"b":null}]},"y":[]}`)).toBe('ok 1 48');
expect(await parsed(`{"round":1,"round":2,"signature":"${S}"}`)).toBe('ok 2 48');
expect(await parsed(`{"round":1,"Round":null,"signature":"${S}"}`)).toBe(MALFORMED);
expect(await parsed(`{"round":1,"signature":"${S}","signature":null}`)).toBe(MALFORMED);
expect(await parsed(`{"round":1,"ſignature":"${S}"}`)).toBe('ok 1 48');
expect(await parsed(`{"round":1,"signature":"\\u0062${S.slice(1)}","randomness":null}`)).toBe('ok 1 48');
expect(await parsed(`{"round":1,"signature":"","randomness":""}`)).toBe('ok 1 0');
expect(await parsed(`{ "round" : 1 , "e" : "\\"}" , "signature" : "${S}" }`)).toBe('ok 1 48');
// Spec v0.16, §47.1: names compared exactly once their escapes are decoded, no name twice in any object, round a
// number of 1 to 2^53 - 1 without sign, fraction or exponent, and signature and randomness strings. release.json has
// the cases of Go; these are those whose escapes its generator wrote as plain text, and a few more.
it('reads it strictly', async () => {
expect(await parsed(`{"\\u0072ound":1,"signature":"${S}"}`)).toBe('ok 1 48');
expect(await parsed(`{"round":1,"\\u0072ound":1,"signature":"${S}"}`)).toBe(MALFORMED);
expect(await parsed(`{"round":1,"signature":"${S}","\\u0073ignature":"${S}"}`)).toBe(MALFORMED);
expect(await parsed(`{"ROUND":1,"Signature":"${S}"}`)).toBe(MALFORMED);
expect(await parsed(`{"round":1,"ſignature":"${S}"}`)).toBe(MALFORMED);
expect(await parsed(`{"round":1,"signature":"\\u0062${S.slice(1)}"}`)).toBe('ok 1 48');
expect(await parsed(`{"round":1,"signature":"${S.toUpperCase()}","randomness":"${R.toUpperCase()}"}`)).toBe('ok 1 48');
expect(await parsed(`{"round":1,"signature":""}`)).toBe('ok 1 0');
expect(await parsed(`{ "round" : 1 , "e" : "\\"}" , "signature" : "${S}" , "x":{"a":[1,"}",{"b":null}]},"y":[] }`)).toBe('ok 1 48');
expect(await parsed('{}')).toBe(MALFORMED);
expect(await parsed(`{"signature":"${S}"}`)).toBe(MALFORMED);
expect(await parsed('{"round":1}')).toBe(MALFORMED);
expect(await parsed('{"round":1,"signature":"abc"}')).toBe('provider: drand JSON: signature is not hex: ERR_RELEASE_INVALID');
expect(await parsed(`{"round":1,"signature":"${S}","randomness":"${R.slice(2)}"}`)).toBe(
'provider: drand JSON: randomness does not match the signature: ERR_RELEASE_INVALID',
);
// Not UTF-8, as Go's utf8.Valid: a byte that starts nothing, and a surrogate written in UTF-8.
for (const bad of [Uint8Array.of(0xff), Uint8Array.of(0xed, 0xa0, 0x80)]) {
const b = concatBytes(enc.encode(`{"round":1,"signature":"${S}","note":"`), bad, enc.encode('"}'));
await expect(parseDrandJSON(b)).rejects.toThrow(MALFORMED);
}
// A byte order mark is not a space of JSON.
await expect(parseDrandJSON(enc.encode(`{"round":1,"signature":"${S}"}`))).rejects.toThrow(MALFORMED);
});
it('fails a value of another type for a field it reads, and a round that is not a uint64', async () => {
for (const r of ['-1', '1.0', '1e3', '18446744073709551616', '"1"', 'true', '{}', '[]']) {
it('fails a value of another type for a field it reads, and a round that is not one', async () => {
for (const r of ['-1', '0', '1.0', '1e3', '01', '9007199254740992', '18446744073709551616', '"1"', 'true', 'null', '{}', '[]']) {
expect(await parsed(`{"round":${r},"signature":"${S}"}`), r).toBe(MALFORMED);
}
expect(await parsed('{"round":1,"signature":1}')).toBe(MALFORMED);
expect(await parsed('{"round":1,"signature":null}')).toBe(MALFORMED);
expect(await parsed(`{"round":1,"signature":"${S}","randomness":1}`)).toBe(MALFORMED);
expect(await parsed(`{"round":1,"signature":"${S}","randomness":null}`)).toBe(MALFORMED);
expect(await parsed(`{"round":1,"signature":"${S}"} x`)).toBe(MALFORMED);
});
it('keeps a round above 2^53-1 exact, for the text of ERR_ROUND_MISMATCH', async () => {
const big = await parseRelease(enc.encode(`{"round":18446744073709551615,"signature":"${S}"}`));
expect(big.round).toBe(18446744073709551615n);
expectCode(() => verifyRelease(quicknet(), 1000, big), 'ERR_ROUND_MISMATCH', /^provider: release for round 18446744073709551615, expected 1000: /);
expect((await parseRelease(enc.encode(`{"round":9007199254740991,"signature":"${S}"}`))).round).toBe(9007199254740991);
it('reads a round of 2^53 - 1 as a number, for the text of ERR_ROUND_MISMATCH', async () => {
const big = await parseRelease(enc.encode(`{"round":9007199254740991,"signature":"${S}"}`));
expect(big.round).toBe(9007199254740991);
expectCode(() => verifyRelease(quicknet(), 1000, big), 'ERR_ROUND_MISMATCH', /^provider: release for round 9007199254740991, expected 1000: /);
});
});
// Spec v0.16, §47.1: the strict reading of drand's JSON, at the edges of the grammar of RFC 8259 that release.json does
// not reach, as provider.TestStrictJSON of the reference, with its escapes written as escapes.
describe('strictJSON', () => {
it.each([
['{}', true],
[' {"a":1} ', true],
['\t{"a":1}\r\n', true],
['{"a":[]}', true],
['{"a":[1,2,[3,{}]]}', true],
['{"a":true,"b":false,"c":null}', true],
['{"a":-0,"b":0.5,"c":1E+2,"d":1e-2,"e":-12.25e3}', true],
['{"a":"\\"\\\\\\/\\b\\f\\n\\r\\té"}', true],
['{"\\u00e9":1,"é":2}', false], // one name, escaped and not
['{"\\u00E9":1,"\\u00e9":2}', false],
['{"a":1,"a":2}', false],
['{"a":{"b":1},"c":{"b":2}}', true], // the same name in two objects
['{"a":[{"b":1,"b":1}]}', false],
['{"a":{"b":1,"b":1}}', false],
['{"a":01}', false],
['{"a":1.}', false],
['{"a":.5}', false],
['{"a":1e}', false],
['{"a":1e+}', false],
['{"a":+1}', false],
['{"a":-}', false],
['{"a":tru}', false],
['{"a":fals}', false],
['{"a":nul}', false],
['{"a":x}', false],
['{"a":"\\x"}', false],
['{"a":"\\u12"}', false],
['{"a":"\\u12g4"}', false],
['{"a":"\\ud800"}', false],
['{"a":"\\ud800A"}', false],
['{"a":"\\ud800x"}', false],
['{"a":"\\ud800\\u0041"}', false],
['{"a":"\\ud800\\ud800"}', false],
['{"a":"\\ud800\\ue000"}', false],
['{"a":"\\ud800\\u12"}', false],
['{"a":"\\ue000\\u0041"}', true],
['{"a":"\\udfff\\ud800"}', false],
['{"a":"\\ud83d\\ude00"}', true],
['{"a":"\\uD83D\\uDE00"}', true],
['{"a":"😀"}', true],
['{"a":"\u0001"}', false],
['{"a":"a\tb"}', false],
['{"a":"\\', false],
['{"a":"b', false],
['{"a":1,}', false],
['{,"a":1}', false],
['{"a" 1}', false],
['{"a":1 "b":2}', false],
['{a:1}', false],
['{"a":[1,]}', false],
['{"a":[1 2]}', false],
['{"a":[1', false],
['{"a":"b"', false],
['{"a":', false],
['{"a"', false],
['{', false],
['{"a":1}x', false],
['[]', false],
['"a"', false],
['', false],
['{"a":1}\v', false], // not a space of JSON
['{"a":1} ', false],
])('%j: %s', (input, ok) => {
expect(strictJSON(enc.encode(input)) !== undefined).toBe(ok);
});
it('gives the members of the outer object, in their order, with their names and strings decoded', () => {
const m = strictJSON(enc.encode('{"round":1000,"no\\u0074e":"a\\ud83d\\ude00","n":-1.5,"o":{"p":[]},"t":true}'));
expect(m).toEqual([
{ name: 'round', kind: '0', raw: '1000', str: '' },
{ name: 'note', kind: '"', raw: '"a\\ud83d\\ude00"', str: 'a\u{1f600}' },
{ name: 'n', kind: '0', raw: '-1.5', str: '' },
{ name: 'o', kind: '{', raw: '{"p":[]}', str: '' },
{ name: 't', kind: 't', raw: 'true', str: '' },
]);
expect(strictJSON(Uint8Array.of(0x7b, 0x22, 0x61, 0x22, 0x3a, 0x22, 0xff, 0x22, 0x7d))).toBeUndefined();
});
});
describe('jsonRound', () => {
it.each([
['1', 1],
['1000', 1000],
['9007199254740991', 9007199254740991],
['0', undefined],
['9007199254740992', undefined],
['9999999999999999', undefined],
['99999999999999999', undefined],
['-1', undefined],
['1.0', undefined],
['1e3', undefined],
['01', undefined],
])('%s', (raw, want) => {
expect(jsonRound({ name: 'round', kind: '0', raw, str: '' })).toBe(want);
});
it('is not a string', () => {
expect(jsonRound({ name: 'round', kind: '"', raw: '"1"', str: '1' })).toBeUndefined();
});
});

@ -1,8 +1,9 @@
// The release object of spec v0.15, §47.1, as provider/release.go and
// provider/archive.go of the Go reference: the release of a round as data
// that is kept: an entry of a release cache or archive and the answer of a release cache
// or of the Release API; drand's JSON, which a reader accepts too as the
// input of the caller; the sources of a release in the caller's hand
// The release object of spec v0.15, §47.1, as provider/release.go,
// provider/drandjson.go and provider/archive.go of the Go reference: the
// release of a round as data that is kept: an entry of a release cache or
// archive and the answer of a release cache or of the Release API; drand's
// JSON, which a reader accepts too as the input of the caller, read strictly
// since spec v0.16; the sources of a release in the caller's hand
// (provider.Supplier); and a local release archive, the informative format
// of §50. The texts of the errors are those of the reference.
//
@ -10,7 +11,15 @@
// only decodes, so that the page can read what the person gives before
// loading the code that opens a capsule.
import { equalBytes, goQuote, sha256, toHex, utf8Length } from "./bytes.ts";
import {
decodeUtf8,
equalBytes,
fromHex,
goQuote,
sha256,
toHex,
utf8Length,
} from "./bytes.ts";
import {
checkSchema,
Decoder,
@ -63,16 +72,11 @@ export interface Release {
}
/**
* A release as the caller gives it, before step 10. Its round is a bigint
* only when drand's JSON names a round above 2^53-1, which no DateKey has:
* step 10 then reports ERR_ROUND_MISMATCH with its exact digits, as Go's
* uint64 does.
* A release as the caller gives it, before step 10. Since spec v0.16 the
* round of drand's JSON is at most 2^53 - 1, as that of a release object
* (§47.1), so it is a Release.
*/
export interface ParsedRelease {
readonly round: number | bigint;
readonly signature: Uint8Array;
readonly chainHash?: Uint8Array;
}
export type ParsedRelease = Release;
// ---------------------------------------------------------------------------
// The release object
@ -211,24 +215,259 @@ export function isDrandJSON(b: Uint8Array): boolean {
/**
* Reads a release that the caller supplies, as provider.ParseRelease:
* drand's JSON when isDrandJSON, or else a release object, with
* decodeRelease. drand's JSON is the answer of a relay, {"round": …,
* "signature": "…"}, with an optional "randomness" that must be SHA-256 of
* the signature; it does not name its chain, so the release has no chain
* hash, and any failure to read it is ERR_RELEASE_INVALID. It is accepted as
* input, never written.
* drand's JSON when isDrandJSON, read strictly by parseDrandJSON, or else a
* release object, with decodeRelease. drand's JSON is the answer of a relay,
* {"round": …, "signature": "…"}, with an optional "randomness" that must be
* SHA-256 of the signature; it does not name its chain, so the release has no
* chain hash, and any failure to read it is ERR_RELEASE_INVALID. It is
* accepted as input, never written.
*/
export async function parseRelease(b: Uint8Array): Promise<ParsedRelease> {
return isDrandJSON(b) ? parseDrandJSON(b) : decodeRelease(b);
}
// The JSON of a drand relay, read as Go's encoding/json reads it into
// {Round *uint64; Signature *string; Randomness string}: the members of the
// object in order, each matched to a field by its name without case, the
// last one winning; null leaves a field as it was for Randomness and unset
// for the other two; a value of another type, or a round that is not an
// integer of 0 to 2^64-1, fails the whole input.
async function parseDrandJSON(b: Uint8Array): Promise<ParsedRelease> {
// ---------------------------------------------------------------------------
// drand's JSON, read strictly
//
// The strict reading of drand's JSON (spec v0.16, §47.1), as
// provider/drandjson.go of the reference: JSON of RFC 8259, in UTF-8, whose
// value is an object; no object of the JSON repeats a name, and names are
// compared exactly, code point by code point, once their escapes are
// decoded, so that "\u0072ound" is round and Round is another name; and an
// escape of a surrogate that does not pair with the next one makes the JSON
// malformed. A common JSON reader keeps the last of two repeated names, or
// does not tell upper from lower case in them, and two readers would see two
// rounds in the same input.
/**
* A member of the outer object of drand's JSON: its name, decoded; the kind
* of its value, its first character ('"', '{', '[', 't', 'f' or 'n'), or '0'
* for a number; the text of the value as written; and for a string, the
* string decoded.
*/
export interface JSONMember {
readonly name: string;
readonly kind: string;
readonly raw: string;
readonly str: string;
}
/**
* Reads `b` as a JSON object with the strict rules of spec v0.16, §47.1, and
* returns the members of the outer object in their order, or undefined when
* `b` breaks one: it is not UTF-8, its value is not an object, an object
* repeats a name, or a string escapes a surrogate without its pair.
*/
export function strictJSON(b: Uint8Array): JSONMember[] | undefined {
const text = decodeUtf8(b);
if (text === undefined) return undefined;
const r = new JSONReader(text);
r.space();
if (!r.at("{")) return undefined;
const members = r.object(true);
r.space();
return members !== undefined && r.i === text.length ? members : undefined;
}
// Reads JSON from the text s at i. The text is decoded UTF-8, so it holds no
// lone surrogate, and a byte order mark at its start stays, as a character
// that is not a space of JSON.
class JSONReader {
i = 0;
private readonly s: string;
constructor(s: string) {
this.s = s;
}
at(c: string): boolean {
return this.s[this.i] === c;
}
// Skips the four spaces of JSON.
space(): void {
while (this.i < this.s.length && " \t\n\r".includes(this.s[this.i]!))
this.i++;
}
// One value of any kind, undefined when it breaks the grammar.
value(): Omit<JSONMember, "name"> | undefined {
const start = this.i;
const c = this.s[this.i];
let kind = c;
let str = "";
let ok = false;
if (c === "{") ok = this.object(false) !== undefined;
else if (c === "[") ok = this.array();
else if (c === '"') {
const v = this.string();
ok = v !== undefined;
str = v ?? "";
} else if (c === "t") ok = this.literal("true");
else if (c === "f") ok = this.literal("false");
else if (c === "n") ok = this.literal("null");
else if (c === "-" || (c !== undefined && c >= "0" && c <= "9")) {
kind = "0";
ok = this.number();
}
return ok
? { kind: kind!, raw: this.s.slice(start, this.i), str }
: undefined;
}
// An object, with no name twice; its members when keep is set.
object(keep: boolean): JSONMember[] | undefined {
this.i++; // {
this.space();
const out: JSONMember[] = [];
if (this.at("}")) {
this.i++;
return out;
}
const seen = new Set<string>();
for (;;) {
this.space();
if (!this.at('"')) return undefined;
const name = this.string();
if (name === undefined || seen.has(name)) return undefined;
seen.add(name);
this.space();
if (!this.at(":")) return undefined;
this.i++;
this.space();
const m = this.value();
if (m === undefined) return undefined;
if (keep) out.push({ name, ...m });
this.space();
if (this.at(",")) this.i++;
else if (this.at("}")) {
this.i++;
return out;
} else return undefined;
}
}
array(): boolean {
this.i++; // [
this.space();
if (this.at("]")) {
this.i++;
return true;
}
for (;;) {
this.space();
if (this.value() === undefined) return false;
this.space();
if (this.at(",")) this.i++;
else if (this.at("]")) {
this.i++;
return true;
} else return false;
}
}
// A string, with its escapes decoded; a surrogate escaped alone, without
// its pair, breaks it.
string(): string | undefined {
this.i++; // "
let out = "";
while (this.i < this.s.length) {
const c = this.s[this.i]!;
if (c === '"') {
this.i++;
return out;
}
if (c < " ") return undefined;
if (c !== "\\") {
out += c;
this.i++;
continue;
}
const e = this.s[this.i + 1];
this.i += 2;
const k = e === undefined ? -1 : '"\\/bfnrt'.indexOf(e);
if (k >= 0) {
out += '"\\/\b\f\n\r\t'[k];
continue;
}
if (e !== "u") return undefined;
let u = this.hex4();
if (u === undefined || (u >= 0xdc00 && u <= 0xdfff)) return undefined;
if (u >= 0xd800 && u <= 0xdbff) {
if (!this.s.startsWith("\\u", this.i)) return undefined;
this.i += 2;
const low = this.hex4();
if (low === undefined || low < 0xdc00 || low > 0xdfff)
return undefined;
u = 0x10000 + ((u - 0xd800) << 10) + (low - 0xdc00);
}
out += String.fromCodePoint(u);
}
return undefined;
}
// The four hexadecimal digits of an escape \u.
hex4(): number | undefined {
const h = this.s.slice(this.i, this.i + 4);
if (!/^[0-9a-fA-F]{4}$/.test(h)) return undefined;
this.i += 4;
return parseInt(h, 16);
}
literal(word: string): boolean {
if (!this.s.startsWith(word, this.i)) return false;
this.i += word.length;
return true;
}
// A number of the grammar of RFC 8259: a minus, an integer part without
// leading zeros, and an optional fraction and exponent.
number(): boolean {
const digits = (): number => {
const from = this.i;
while (this.i < this.s.length && /[0-9]/.test(this.s[this.i]!))
this.i++;
return this.i - from;
};
if (this.at("-")) this.i++;
if (this.at("0")) this.i++;
else if (digits() === 0) return false;
if (this.at(".")) {
this.i++;
if (digits() === 0) return false;
}
if (this.at("e") || this.at("E")) {
this.i++;
if (this.at("+") || this.at("-")) this.i++;
if (digits() === 0) return false;
}
return true;
}
}
/**
* The round of drand's JSON (spec v0.16, §47.1): a number without sign,
* fraction or exponent, from 1 to 2^53 - 1, as in the release object;
* undefined for any other member.
*/
export function jsonRound(m: JSONMember): number | undefined {
if (m.kind !== "0" || !/^[1-9][0-9]{0,15}$/.test(m.raw)) return undefined;
const n = Number(m.raw);
return n <= MAX_SAFE_UINT ? n : undefined;
}
/**
* Reads the JSON of a drand relay with the strict rules of spec v0.16, §47.1,
* as provider.ParseDrandJSON: at most MAX_RELEASE_JSON_SIZE bytes of JSON
* whose value is an object, with no repeated name and names compared exactly
* once their escapes are decoded; "round" a number without sign, fraction or
* exponent, from 1 to 2^53 - 1; "signature" a string of hexadecimal, in lower
* or upper case; and "randomness", when present, a string with SHA-256 of the
* signature in hexadecimal. Other members are ignored. Any failure is
* ERR_RELEASE_INVALID, with the texts of the reference. The release names no
* chain. The page reads the answers of the relays of drand with it too.
*/
export async function parseDrandJSON(b: Uint8Array): Promise<Release> {
if (b.length > MAX_RELEASE_JSON_SIZE) {
throw new DateKeysError(
"ERR_RELEASE_INVALID",
@ -240,45 +479,31 @@ async function parseDrandJSON(b: Uint8Array): Promise<ParsedRelease> {
"ERR_RELEASE_INVALID",
"provider: drand JSON: malformed, or without round or signature",
);
const text = new TextDecoder().decode(b);
const members = jsonMembers(text);
const members = strictJSON(b);
if (members === undefined) throw malformed();
let round: bigint | undefined;
let round: number | undefined;
let signature: string | undefined;
let randomness = "";
let typeError = false;
for (const [key, raw] of members) {
const name = foldName(key);
if (name === "round") {
if (raw === "null") round = undefined;
else if (/^(0|[1-9][0-9]*)$/.test(raw) && BigInt(raw) < 2n ** 64n)
round = BigInt(raw);
else typeError = true;
} else if (name === "signature" || name === "randomness") {
if (raw === "null") {
if (name === "signature") signature = undefined;
} else if (raw.startsWith('"')) {
const v = JSON.parse(raw) as string;
if (name === "signature") signature = v;
else randomness = v;
} else {
typeError = true;
}
let randomness: string | undefined;
for (const m of members) {
if (m.name === "round") {
round = jsonRound(m);
if (round === undefined) throw malformed();
} else if (m.name === "signature" || m.name === "randomness") {
if (m.kind !== '"') throw malformed();
if (m.name === "signature") signature = m.str;
else randomness = m.str;
}
}
if (typeError || round === undefined || signature === undefined)
throw malformed();
if (round === undefined || signature === undefined) throw malformed();
if (!/^([0-9a-fA-F]{2})*$/.test(signature)) {
throw new DateKeysError(
"ERR_RELEASE_INVALID",
"provider: drand JSON: signature is not hex",
);
}
const sig = Uint8Array.from(signature.match(/../g) ?? [], (h) =>
parseInt(h, 16),
);
const sig = fromHex(signature);
if (
randomness !== "" &&
randomness !== undefined &&
randomness.toLowerCase() !== toHex(await sha256(sig))
) {
throw new DateKeysError(
@ -286,81 +511,7 @@ async function parseDrandJSON(b: Uint8Array): Promise<ParsedRelease> {
"provider: drand JSON: randomness does not match the signature",
);
}
return {
round: round <= BigInt(MAX_SAFE_UINT) ? Number(round) : round,
signature: sig,
};
}
// Go's encoding/json matches a key to a field name without case: ASCII
// letters in lower case, and any other character as unicode.ToLower of
// unicode.ToUpper, so that U+017F (long s) is an s and U+212A (Kelvin) a k.
function foldName(s: string): string {
let out = "";
for (const c of s)
out +=
c.charCodeAt(0) < 0x80 ? c.toLowerCase() : c.toUpperCase().toLowerCase();
return out;
}
// The members of the object that `text` is, as [key, raw value text] in
// their order, or undefined when `text` is not one JSON object. JSON.parse
// checks the grammar, the same as Go's; the scan then only splits valid text.
function jsonMembers(text: string): [string, string][] | undefined {
// Its first byte other than a space is "{": valid JSON is an object.
try {
JSON.parse(text);
} catch {
return undefined;
}
const out: [string, string][] = [];
let i = 0;
const ws = (): void => {
while (i < text.length && " \t\n\r".includes(text[i]!)) i++;
};
const skipString = (): void => {
i++;
while (text[i] !== '"') i += text[i] === "\\" ? 2 : 1;
i++;
};
const skipValue = (): void => {
if (text[i] === '"') return skipString();
if (text[i] === "{" || text[i] === "[") {
let depth = 0;
do {
const c = text[i]!;
if (c === '"') {
skipString();
continue;
}
if (c === "{" || c === "[") depth++;
else if (c === "}" || c === "]") depth--;
i++;
} while (depth > 0);
return;
}
while (i < text.length && !",}] \t\n\r".includes(text[i]!)) i++;
};
ws();
i++; // {
ws();
while (text[i] !== "}") {
const k0 = i;
skipString();
const key = JSON.parse(text.slice(k0, i)) as string;
ws();
i++; // :
ws();
const v0 = i;
skipValue();
out.push([key, text.slice(v0, i)]);
ws();
if (text[i] === ",") {
i++;
ws();
}
}
return out;
return { round, signature: sig };
}
/**

@ -9,7 +9,7 @@
import { describe, expect, it } from 'vitest';
import { h, hx } from './testing/testdata.ts';
import { arr, b, bn, map, t, u } from './testing/cborhex.ts';
import { encodeSecurity, evaluateSecurity, type Verdict, verdictLines, verdictText } from './security.ts';
import { encodeSecurity, evaluateSecurity, sealReasonText, type Verdict, verdictLines, verdictText } from './security.ts';
const EMPTY = 'a20071646174656b6579732d73656375726974790101';
// An author-signature of alg 1 with a key of 32 zero bytes and a signature
@ -86,21 +86,27 @@ describe('verdictLines', () => {
expect(verdictText('S0')).toBe('');
});
// F6 and S4 write the names that the reader found (spec v0.12 §29.7, §29.10), each between « and »: a signer sealed
// before the round time or not, with the authority of its seal, the warning that DateKeys does not check who issued
// the seals, and a signer who does not count, with its result in Spanish.
// F6 and S4 write the names that the reader found (spec v0.16 §29.7, §29.10), each between « and »: a signer whose seal
// proves that it came before the round time or not, and then why not, with the authority of its seal, the warning
// that DateKeys does not check who issued the seals, and a signer who does not count, with its result in Spanish.
it('writes the lines of F6 and S4 from the signers and the authorities that were found', () => {
const t = { seconds: 1_790_000_000, nanos: 0 };
const line = (holder: string, before: boolean, result = 'valid') => ({ holder, issuer: `emisor de ${holder}`, result, sealHolder: `TSA de ${holder}`, sealTime: t, before });
const lines = verdictLines({
signature: 'F6',
seal: 'S4',
detail: { signers: [line('Ana', true), line('Luis', false)], foreign: [line('Otro', true, 'invalid')], sealHolder: 'TSA', sealTime: t },
detail: {
signers: [line('Ana', true), { ...line('Luis', false), reason: 'late' }, { ...line('Eva', false), reason: 'no accuracy, BTSP' }],
foreign: [line('Otro', true, 'invalid')],
sealHolder: 'TSA',
sealTime: t,
},
});
expect(lines).toEqual([
'Firmado con un certificado a nombre de «Ana», «Luis». DateKeys no comprueba quién lo emitió: para eso, exporta la firma a un validador oficial.',
'Firmado con un certificado a nombre de «Ana», «Luis», «Eva». DateKeys no comprueba quién lo emitió: para eso, exporta la firma a un validador oficial.',
' «Ana» (emisor según su certificado: «emisor de Ana»), sellado por «TSA de Ana» el 2026-09-21T14:13:20Z, antes de la fecha de apertura.',
' «Luis» (emisor según su certificado: «emisor de Luis»), sellado por «TSA de Luis» el 2026-09-21T14:13:20Z, no antes de la fecha de apertura.',
' «Luis» (emisor según su certificado: «emisor de Luis»), sellado por «TSA de Luis» el 2026-09-21T14:13:20Z, sin acreditar que fuera antes de la fecha de apertura: se selló después de esa fecha o demasiado cerca de ella.',
' «Eva» (emisor según su certificado: «emisor de Eva»), sellado por «TSA de Eva» el 2026-09-21T14:13:20Z, sin acreditar que fuera antes de la fecha de apertura: el sello no dice la precisión que exige su política.',
' DateKeys no comprueba quién emitió los sellos.',
' Otro firmante, «Otro»: inválida. No cuenta.',
'Según un sello a nombre de «TSA», existía el 2026-09-21T14:13:20Z, antes de que la cápsula pudiera abrirse. DateKeys no comprueba quién emitió el sello.',
@ -110,10 +116,10 @@ describe('verdictLines', () => {
// No line says "antes de la fecha de apertura": no warning. A time keeps the fraction of its seal (RFC 3339).
it('warns of who issued the seals only when a signer was sealed before the date, and writes the fraction of a time', () => {
const t = { seconds: 1_790_000_000, nanos: 250_000_000 };
const late = { holder: 'Ana', issuer: 'CA', result: 'valid', sealHolder: 'TSA', sealTime: t, before: false };
const late = { holder: 'Ana', issuer: 'CA', result: 'valid', sealHolder: 'TSA', sealTime: t, before: false, reason: 'no accuracy' } as const;
expect(verdictLines({ signature: 'F6', seal: 'S0', detail: { signers: [late], foreign: [] } })).toEqual([
'Firmado con un certificado a nombre de «Ana». DateKeys no comprueba quién lo emitió: para eso, exporta la firma a un validador oficial.',
' «Ana» (emisor según su certificado: «CA»), sellado por «TSA» el 2026-09-21T14:13:20.25Z, no antes de la fecha de apertura.',
' «Ana» (emisor según su certificado: «CA»), sellado por «TSA» el 2026-09-21T14:13:20.25Z, sin acreditar que fuera antes de la fecha de apertura: el sello no dice su precisión.',
]);
expect(verdictLines({ signature: 'F4', seal: 'S4', authorKey: new Uint8Array(32), detail: { signers: [], foreign: [], sealHolder: 'TSA', sealTime: { seconds: t.seconds, nanos: 1 } } })[1]).toBe(
'Según un sello a nombre de «TSA», existía el 2026-09-21T14:13:20.000000001Z, antes de que la cápsula pudiera abrirse. DateKeys no comprueba quién emitió el sello.',
@ -133,13 +139,26 @@ describe('verdictLines', () => {
expect(verdictLines({ signature: 'F5', seal: 'S0', detail: { signers: [], foreign } })).toEqual([verdictText('F5'), ` Otro firmante, «Otro»: ${text}. No cuenta.`]);
});
// The verdicts of spec v0.11 (§29.7) whose text is fixed, and those whose text names a key, a holder or a time, F3, F4, F6
// and S4, which verdictLines writes from what the reader found and verdictText leaves empty.
// The verdicts of spec v0.11 (§29.7) whose text is fixed, and those whose text names a key, a holder, a time or a
// reason, F3, F4, F6, S4 and, since v0.16, S5, which verdictLines writes from what the reader found and verdictText
// leaves empty.
it('has the text of the verdicts of v0.11 that do not name anything, and none for those that do', () => {
expect(verdictText('F2')).toBe('La firma no corresponde a este contenido.');
expect(verdictText('F5')).toBe('Faltan firmas o sellos que la propia cápsula exige: trátala como no firmada.');
expect(verdictText('S3')).toBe('El sello no corresponde a este contenido.');
expect(verdictText('S5')).toBe('Sellado después de la fecha de apertura: no prueba nada anterior.');
for (const v of ['F3', 'F4', 'F6', 'S4'] as const) expect(verdictText(v), v).toBe('');
for (const v of ['F3', 'F4', 'F6', 'S4', 'S5'] as const) expect(verdictText(v), v).toBe('');
});
// Spec v0.16, §29.7: S5 says why the seal does not prove that it came before the opening date, with the reason of the
// reader; without the detail of a valid seal it shows nothing, as in Go.
it('writes S5 with its reason', () => {
const t = { seconds: 1_790_000_000, nanos: 0 };
const s5 = (sealReason: 'late' | 'no accuracy' | 'no accuracy, BTSP'): string | undefined =>
verdictLines({ signature: 'F0', seal: 'S5', detail: { signers: [], foreign: [], sealHolder: 'TSA', sealTime: t, sealReason } })[1];
expect(s5('late')).toBe('No acredita que se sellara antes de la fecha de apertura: se selló después de esa fecha o demasiado cerca de ella.');
expect(s5('no accuracy')).toBe('No acredita que se sellara antes de la fecha de apertura: el sello no dice su precisión.');
expect(s5('no accuracy, BTSP')).toBe('No acredita que se sellara antes de la fecha de apertura: el sello no dice la precisión que exige su política.');
expect(verdictLines({ signature: 'F0', seal: 'S5' })).toEqual(['Sin firma de autor.']);
expect(sealReasonText(undefined)).toBe('');
});
});

@ -1,8 +1,8 @@
// The security area of a format 3 capsule (spec §29.3, §29.7), as
// EncodeSecurity, EvaluateSecurityIn and the verdicts of the Go package
// capsule give them at the draft v0.12 (format3.go, signature.go), the texts
// of the verdicts of a certificate included. SECURITY_CBOR is the map
// {0: "datekeys-security", 1: 1, ? 2: author-signature, ? 3: seal}, whose
// capsule give them at v0.16 (format3.go, signature.go), the texts of the
// verdicts of a certificate and the reasons of S5 included. SECURITY_CBOR is
// the map {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
@ -18,7 +18,7 @@ import { verifyStrict } from './ed25519strict.ts';
import { DateKeysError } from './errors.ts';
import { formatRFC3339Nano, type Instant } from './datekey.ts';
import { fieldOf, requireKeys } from './schema.ts';
import { type Detail, evaluateCMS, evaluateSeal } from './securitycms.ts';
import { type Detail, evaluateCMS, evaluateSeal, type SealReason } from './securitycms.ts';
export const SECURITY_TYPE_TAG = 'datekeys-security';
export const SECURITY_VERSION = 1;
@ -47,7 +47,10 @@ export const SEAL_TYPE_RFC3161 = 2;
* algorithm outside the table;
* - S2: a seal that does not decode or breaks its schema;
* - S3: a seal that does not correspond to this content;
* - S4 and S5: a valid seal, from before the time of the round or not.
* - S4: a valid seal with accuracy and t + accuracy < round_time (spec
* v0.16, §29.11);
* - S5: a valid seal that does not prove that it came before round_time;
* Detail.sealReason says why.
*/
export type Verdict = 'X' | 'F0' | 'F1' | 'F2' | 'F3' | 'F4' | 'F5' | 'F6' | 'S0' | 'S1' | 'S2' | 'S3' | 'S4' | 'S5';
@ -78,7 +81,11 @@ export interface SecurityContext {
readonly authorKeys?: ReadonlyMap<string, string>;
}
/** The text of a verdict that the official SDK shows, in Spanish (spec §29.7), and '' for S0, which shows nothing. */
/**
* The text of a verdict that the official SDK shows, in Spanish (spec §29.7),
* and '' for S0, which shows nothing, and for the verdicts whose text names a
* key, a holder, a time or a reason, which verdictLines writes.
*/
export function verdictText(v: Verdict): string {
switch (v) {
case 'X':
@ -97,18 +104,36 @@ export function verdictText(v: Verdict): string {
return 'Faltan firmas o sellos que la propia cápsula exige: trátala como no firmada.';
case 'S3':
return 'El sello no corresponde a este contenido.';
case 'S5':
return 'Sellado después de la fecha de apertura: no prueba nada anterior.';
// F3, F4, F6 and S4 name a key, a holder or a time: whoever shows them writes the text (spec §29.7).
// F3, F4, F6, S4 and S5 name a key, a holder, a time or a reason: whoever shows them writes the text (spec v0.16,
// §29.7).
case 'F3':
case 'F4':
case 'F6':
case 'S4':
case 'S5':
case 'S0':
return '';
}
}
/**
* The reason why a valid seal does not prove that it came before the opening
* date, as the texts of §29.7 write it (spec v0.16), and '' for none: that of
* S5, and of the line of a signer of F6 whose seal does not prove it.
*/
export function sealReasonText(r: SealReason | undefined): string {
switch (r) {
case 'late':
return 'se selló después de esa fecha o demasiado cerca de ella';
case 'no accuracy, BTSP':
return 'el sello no dice la precisión que exige su política';
case 'no accuracy':
return 'el sello no dice su precisión';
case undefined:
return '';
}
}
// The result of a signer in the texts of §29.7.
const RESULT_TEXT: Readonly<Record<string, string>> = {
valid: 'válida',
@ -126,12 +151,13 @@ const quoted = (name: string): string => `«${name}»`;
/**
* The verdicts as the official SDK shows them, in order: X alone, or the
* signature and then the seal, when it shows something (spec §29.7). F6 is
* followed by a line for each required signer, which names the authority of
* its seal, by the warning that DateKeys does not check who issued the seals
* when one of them says that it is before the opening date, and by the
* signers who do not count. A time is in RFC 3339 with the fraction of the
* seal.
* signature and then the seal, when it shows something (spec v0.16, §29.7).
* F6 is followed by a line for each required signer, which names the
* authority of its seal and says whether it proves that it came before the
* opening date, or why not, by the warning that DateKeys does not check who
* issued the seals when one of them says that it is before the opening date,
* and by the signers who do not count. S5 gives its reason. A time is in RFC
* 3339 with the fraction of the seal.
*/
export function verdictLines(v: Verdicts): string[] {
if (v.signature === 'X') return [verdictText('X')];
@ -144,7 +170,7 @@ export function verdictLines(v: Verdicts): string[] {
if (v.signature === 'F6' && d !== undefined) {
lines[0] = `Firmado con un certificado a nombre de ${d.signers.map((s) => quoted(s.holder)).join(', ')}. DateKeys no comprueba quién lo emitió: para eso, exporta la firma a un validador oficial.`;
for (const s of d.signers) {
const when = s.before ? 'antes de la fecha de apertura' : 'no antes de la fecha de apertura';
const when = s.before ? 'antes de la fecha de apertura' : `sin acreditar que fuera antes de la fecha de apertura: ${sealReasonText(s.reason)}`;
lines.push(` ${quoted(s.holder)} (emisor según su certificado: ${quoted(s.issuer)}), sellado por ${quoted(s.sealHolder!)} el ${formatRFC3339Nano(s.sealTime!)}, ${when}.`);
}
// §29.7: whoever says that a capsule was signed before the date says that it does not check who issued the seal.
@ -153,6 +179,8 @@ export function verdictLines(v: Verdicts): string[] {
for (const s of d?.foreign ?? []) lines.push(` Otro firmante, ${quoted(s.holder)}: ${RESULT_TEXT[s.result]!}. No cuenta.`);
if (v.seal === 'S4' && d?.sealHolder !== undefined) {
lines.push(`Según un sello a nombre de ${quoted(d.sealHolder)}, existía el ${formatRFC3339Nano(d.sealTime!)}, antes de que la cápsula pudiera abrirse. DateKeys no comprueba quién emitió el sello.`);
} else if (v.seal === 'S5' && d !== undefined) {
lines.push(`No acredita que se sellara antes de la fecha de apertura: ${sealReasonText(d.sealReason)}.`);
} else if (verdictText(v.seal) !== '') {
lines.push(verdictText(v.seal));
}
@ -335,7 +363,7 @@ function decodeAlg(d: Decoder): number {
// What the evaluation of the signature gives, and that of the seal.
type SignatureVerdicts = Pick<Verdicts, 'signature' | 'authorKey' | 'authorLabel' | 'detail'>;
type SealVerdicts = { seal: Verdict; sealHolder?: string; sealTime?: Instant };
type SealVerdicts = { seal: Verdict; sealHolder?: string; sealTime?: Instant; sealReason?: SealReason };
// Runs an evaluation whose failure is a verdict, never an error: its result,
// or `failed` when it throws, whatever it throws. The decoders throw a
@ -376,6 +404,7 @@ export function evaluateSecurity(b: Uint8Array, context?: SecurityContext): Verd
signers: sig.detail?.signers ?? [],
foreign: sig.detail?.foreign ?? [],
...(sealed.sealHolder === undefined ? {} : { sealHolder: sealed.sealHolder, sealTime: sealed.sealTime! }),
...(sealed.sealReason === undefined ? {} : { sealReason: sealed.sealReason }),
};
return { ...sig, seal: sealed.seal, ...(detail === undefined ? {} : { detail }) };
}

@ -1,8 +1,8 @@
// Tests of securitycms.ts, the verdicts of a signature of alg 2 and of a seal
// of seal_type 2 (spec v0.12 §29.7, §29.10, §29.11), through evaluateSecurity
// of seal_type 2 (spec v0.16 §29.7, §29.10, §29.11), through evaluateSecurity
// in the context of a capsule, on signatures and tokens that
// testing/cmsbuild.ts makes: the cases of signature2_test.go of the Go
// reference at the draft v0.12, what a signer line shows of a certificate, a
// reference at the draft v0.16, what a signer line shows of a certificate, a
// byte order mark at the start of a name or a time, and keys whose point is
// compressed. The hashes of the issuers below are those of the DER of their
// Names, which the reference shows in their place.
@ -13,7 +13,7 @@ import { ALG_CMS, ALG_ED25519, authorMessage, sealSubject, signersDigest } from
import { compareBytes, toHex } from './bytes.ts';
import { Encoder } from './cbor.ts';
import { ed25519 } from '@noble/curves/ed25519.js';
import { evaluateSecurity, type SecurityContext, type Verdicts, verdictLines, verdictText } from './security.ts';
import { evaluateSecurity, sealReasonText, type SecurityContext, type Verdicts, verdictLines, verdictText } from './security.ts';
import * as b from './testing/cmsbuild.ts';
const certFrom = new Date(Date.UTC(2025, 0, 1));
@ -83,11 +83,19 @@ function signersOf(...required: b.Signer[]): Uint8Array {
}
// The security area of a capsule with a signature of alg 2 by the signers, which SIGNERS requires with required, each
// signature sealed by `authority` at `when` with an accuracy of a second, and the seal of key 3 given.
async function cmsArea(required: b.Signer[], signers: b.Signer[], authority: b.Signer | undefined, when: Date, seal?: Uint8Array): Promise<Uint8Array> {
// signature sealed by `authority` at `when` with an accuracy of a second, or the options of the token given, and the
// seal of key 3 given.
async function cmsArea(
required: b.Signer[],
signers: b.Signer[],
authority: b.Signer | undefined,
when: Date,
seal?: Uint8Array,
tokenOptions: b.TokenOptions = { accuracy: b.accuracyOf(1) },
): Promise<Uint8Array> {
const key1 = signersOf(...required);
const msg = authorMessage(context.controlCommit, context.headDigest, signersDigest(ALG_CMS, key1));
const o: b.Options = authority === undefined ? {} : { token: (sig) => b.token(sig, when, { accuracy: b.accuracyOf(1) }, authority) };
const o: b.Options = authority === undefined ? {} : { token: (sig) => b.token(sig, when, tokenOptions, authority) };
return area(item(ALG_CMS, key1, await b.signature(msg, o, ...signers)), seal);
}
@ -96,9 +104,9 @@ async function signed(authority: b.Signer | undefined, ...signers: b.Signer[]):
return evaluateSecurity(await cmsArea(signers, signers, authority, signedAt), context);
}
// The verdicts of a seal of seal_type 2 by `authority`, without a signature.
// The verdicts of a seal of seal_type 2 by `authority`, without a signature, with an accuracy of a second.
async function sealed(authority: b.Signer): Promise<Verdicts> {
const token = await b.token(sealSubject(context.controlCommit, context.headDigest, undefined), signedAt, {}, authority);
const token = await b.token(sealSubject(context.controlCommit, context.headDigest, undefined), signedAt, { accuracy: b.accuracyOf(1) }, authority);
return evaluateSecurity(area(undefined, item(2, token)), context);
}
@ -125,12 +133,29 @@ describe('a signature of alg 2', () => {
});
it('warns of who issued the seals only when a line says before the opening date', async () => {
// A seal after the round time proves nothing before it, and then no line warns of who issued it (spec v0.16, §29.7).
const late = evaluateSecurity(await cmsArea([ana], [ana], tsa, new Date(roundTime.getTime() + 3_600_000)), context);
expect([late.signature, verdictLines(late).length]).toEqual(['F6', 2]);
expect(verdictLines(late)[1]).toBe(' «Ana López» (emisor según su certificado: «Ana López»), sellado por «TSA de prueba» el 2030-01-01T01:00:00Z, no antes de la fecha de apertura.');
expect([late.signature, verdictLines(late).length, late.detail!.signers[0]!.reason]).toEqual(['F6', 2, 'late']);
expect(verdictLines(late)[1]).toBe(
' «Ana López» (emisor según su certificado: «Ana López»), sellado por «TSA de prueba» el 2030-01-01T01:00:00Z, sin acreditar que fuera antes de la fecha de apertura: se selló después de esa fecha o demasiado cerca de ella.',
);
// t plus the accuracy of a second equal to the round time is not before it.
const edge = evaluateSecurity(await cmsArea([ana], [ana], tsa, new Date(roundTime.getTime() - 1000)), context);
expect([edge.signature, edge.detail!.signers[0]!.before, verdictLines(edge).length]).toEqual(['F6', false, 2]);
expect([edge.signature, edge.detail!.signers[0]!.before, edge.detail!.signers[0]!.reason, verdictLines(edge).length]).toEqual(['F6', false, 'late', 2]);
// A seal without accuracy, years before the round time, does not prove it either; under BTSP, with its own reason.
for (const [o, reason] of [
[{}, 'no accuracy'],
[{ policy: b.BTSP_POLICY }, 'no accuracy, BTSP'],
] as const) {
const v = evaluateSecurity(await cmsArea([ana], [ana], tsa, signedAt, undefined, o), context);
expect([v.signature, v.detail!.signers[0]!.before, v.detail!.signers[0]!.reason, verdictLines(v).length], reason).toEqual(['F6', false, reason, 2]);
expect(verdictLines(v)[1], reason).toBe(
` «Ana López» (emisor según su certificado: «Ana López»), sellado por «TSA de prueba» el 2026-09-30T12:00:00Z, sin acreditar que fuera antes de la fecha de apertura: ${sealReasonText(reason)}.`,
);
}
// With BTSP and its accuracy, it proves it.
const btsp = evaluateSecurity(await cmsArea([ana], [ana], tsa, signedAt, undefined, { policy: b.BTSP_POLICY, accuracy: b.accuracyOf(1) }), context);
expect([btsp.detail!.signers[0]!.before, btsp.detail!.signers[0]!.reason, verdictLines(btsp).length]).toEqual([true, undefined, 3]);
// A seal with a fraction of a second shows it.
const fraction = evaluateSecurity(await cmsArea([ana], [ana], tsa, new Date(signedAt.getTime() + 250)), context);
expect(verdictLines(fraction)[1]).toBe(' «Ana López» (emisor según su certificado: «Ana López»), sellado por «TSA de prueba» el 2026-09-30T12:00:00.25Z, antes de la fecha de apertura.');
@ -311,14 +336,15 @@ describe('a seal of seal_type 2', () => {
const sig = item(ALG_ED25519, ed25519.getPublicKey(seed), ed25519.sign(msg, seed));
const subject = sealSubject(context.controlCommit, context.headDigest, sig);
const withSeal = (token: Uint8Array): Uint8Array => area(sig, item(2, token));
const v = evaluateSecurity(withSeal(await b.token(subject, signedAt, {}, authority)), context);
expect([v.signature, v.seal, v.detail!.sealHolder, v.detail!.sealTime]).toEqual(['F4', 'S4', 'Autoridad de Sellado', at(signedAt)]);
const second = { accuracy: b.accuracyOf(1) };
const v = evaluateSecurity(withSeal(await b.token(subject, signedAt, second, authority)), context);
expect([v.signature, v.seal, v.detail!.sealHolder, v.detail!.sealTime, v.detail!.sealReason]).toEqual(['F4', 'S4', 'Autoridad de Sellado', at(signedAt), undefined]);
expect(verdictLines(v)[1]).toBe('Según un sello a nombre de «Autoridad de Sellado», existía el 2026-09-30T12:00:00Z, antes de que la cápsula pudiera abrirse. DateKeys no comprueba quién emitió el sello.');
// Sealed with its own signature part: without key 2 the subject differs.
const alone = await sealed(authority);
expect([alone.signature, alone.seal]).toEqual(['F0', 'S4']);
const cases: [string, Promise<Uint8Array> | Uint8Array, string][] = [
['after the round time', b.token(subject, new Date(roundTime.getTime() + 60_000), {}, authority), 'S5'],
['after the round time', b.token(subject, new Date(roundTime.getTime() + 60_000), second, authority), 'S5'],
['the accuracy reaches it', b.token(subject, new Date(roundTime.getTime() - 1000), { accuracy: b.accuracyOf(2) }, authority), 'S5'],
['another subject', b.token(te.encode('other'), signedAt, {}, authority), 'S3'],
['an imprint of 33 bytes', b.token(subject, signedAt, { imprint: new Uint8Array(33) }, authority), 'S3'],
@ -337,4 +363,27 @@ describe('a seal of seal_type 2', () => {
// Without a context, as a reader of v0.10: S1.
expect(evaluateSecurity(withSeal(await b.token(subject, signedAt, {}, authority))).seal).toBe('S1');
});
// Spec v0.16, §29.7 and §29.11: a valid seal proves that it came before the round time only with accuracy; without
// it, S5 and its reason, the first that holds. An accuracy of 0 seconds, or an empty one, is a precision of 0.
it.each([
['no accuracy, years before', {}, signedAt, 'S5', 'no accuracy'],
['no accuracy under BTSP', { policy: b.BTSP_POLICY }, signedAt, 'S5', 'no accuracy, BTSP'],
['no accuracy, after the round time', { policy: b.BTSP_POLICY }, roundTime, 'S5', 'late'],
['an accuracy of 0 seconds', { accuracy: b.seq(b.int(0)) }, new Date(roundTime.getTime() - 1), 'S4', undefined],
['an empty accuracy', { accuracy: b.seq() }, signedAt, 'S4', undefined],
['BTSP with accuracy', { policy: b.BTSP_POLICY, accuracy: b.accuracyOf(1) }, signedAt, 'S4', undefined],
['an accuracy of 0 at the round time', { accuracy: b.seq(b.int(0)) }, roundTime, 'S5', 'late'],
] as const)('%s', async (_name, o, when, seal, reason) => {
const token = await b.token(sealSubject(context.controlCommit, context.headDigest, undefined), when, o, tsa);
const v = evaluateSecurity(area(undefined, item(2, token)), context);
expect([v.seal, v.detail?.sealReason]).toEqual([seal, reason]);
const last = verdictLines(v).at(-1);
if (seal === 'S5') expect(last).toBe(`No acredita que se sellara antes de la fecha de apertura: ${sealReasonText(reason)}.`);
else expect(last).toMatch(/^Según un sello a nombre de «TSA de prueba», existía el /);
});
it('has no text of its own for S5: verdictLines writes it with its reason', () => {
expect([verdictText('S5'), sealReasonText(undefined)]).toEqual(['', '']);
});
});

@ -1,8 +1,9 @@
// The verdicts of a signature of alg 2 (CMS with certificates) and of a seal of
// seal_type 2 (RFC 3161), as the Go package capsule gives them (signature2.go,
// spec v0.12 §29.7, §29.10, §29.11): F1, F2, F5 and F6 with the signers named,
// and S1 to S5 with the authority of a valid seal. Internal: index.ts does not
// re-export it.
// spec v0.16 §29.7, §29.10, §29.11): F1, F2, F5 and F6 with the signers named,
// and S1 to S5 with the authority of a valid seal and, for S5, the reason why
// it does not prove that it came before the opening date. Internal: index.ts
// does not re-export it.
import { ALG_CMS, authorMessage, sealSubject, signersDigest } from './author.ts';
import { compareBytes, equalBytes, toHex } from './bytes.ts';
@ -22,6 +23,7 @@ import {
type SignerInfo,
type Token,
tokenImprintIsSHA256,
tokenIsBTSP,
} from './cms.ts';
import { compareInstants, type Instant } from './datekey.ts';
import { DateKeysError } from './errors.ts';
@ -44,18 +46,40 @@ export interface SignerLine {
/** The holder of the certificate of the authority of its seal, with the same rules, and t; undefined without a seal that verifies. */
readonly sealHolder?: string;
readonly sealTime?: Instant;
/** Whether t plus the accuracy of the seal is before round_time. */
/**
* Whether the seal proves that it came before round_time: it carries
* accuracy and t plus the accuracy is before round_time (spec v0.16,
* §29.11). For a valid signer whose seal does not, reason says why.
*/
readonly before: boolean;
readonly reason?: SealReason;
}
/**
* Why a valid seal does not prove that it came before the opening date (spec
* v0.16, §29.7): the reason of S5, and of the line of a signer of F6 that does
* not say «antes de la fecha de apertura», the first that holds, as
* SealReason of Go:
* - 'late': t plus the accuracy, 0 without one, is not before round_time;
* - 'no accuracy, BTSP': the token carries no accuracy, and its policy is
* the BTSP of ETSI EN 319 421, which requires it;
* - 'no accuracy': the token carries no accuracy.
*/
export type SealReason = 'late' | 'no accuracy, BTSP' | 'no accuracy';
/** What the texts of F6, S4 and S5 name (spec §29.7, §29.10). */
export interface Detail {
/** The required signers, in the order of SIGNERS, and the SignerInfo of other certificates, which never count. */
readonly signers: readonly SignerLine[];
readonly foreign: readonly SignerLine[];
/** The holder of the certificate of the authority of a valid seal, as §29.7 writes it, and t. */
/**
* The holder of the certificate of the authority of a valid seal, as §29.7
* writes it, and t. sealReason is why it does not prove that it came before
* round_time (S5), undefined when it does (S4).
*/
readonly sealHolder?: string;
readonly sealTime?: Instant;
readonly sealReason?: SealReason;
}
// SIGNERS: a CBOR array of 1 to 16 strings of 32 bytes in strictly ascending order of bytes (spec §29.10). Throws a DateKeysError when it is not.
@ -124,9 +148,13 @@ function holderText(name: string, hash: Uint8Array): string {
return toHex(hash);
}
// The time of a seal, plus its accuracy, against round_time: whether it precedes it.
function before(tok: Token, roundTime: Instant | undefined): boolean {
return roundTime !== undefined && compareInstants(addInstants(tok.genTime, tok.accuracy), roundTime) < 0;
// The reason of a token that verifies, the first that holds (spec v0.16,
// §29.7): late, then without accuracy under BTSP, then without accuracy;
// undefined when it proves that it came before round_time.
function sealReason(tok: Token, roundTime: Instant | undefined): SealReason | undefined {
if (roundTime === undefined || compareInstants(addInstants(tok.genTime, tok.accuracy), roundTime) >= 0) return 'late';
if (!tok.hasAccuracy) return tokenIsBTSP(tok) ? 'no accuracy, BTSP' : 'no accuracy';
return undefined;
}
// One SignerInfo as §29.10 orders: not verifiable, invalid, without seal, with an invalid seal, out of validity, or valid.
@ -145,7 +173,9 @@ function signerLine(s: SignerInfo, msg: Uint8Array, roundTime: Instant | undefin
}
if (tok === undefined || !checkToken(tok, s.signature)) return { ...base, result: 'invalid seal' };
if (!certValidAt(s.cert, tok.genTime)) return { ...base, result: 'out of validity' };
return { ...base, result: 'valid', sealHolder: holderText(certHolder(tok.tsa), tok.tsa.hash), sealTime: tok.genTime, before: before(tok, roundTime) };
const reason = sealReason(tok, roundTime);
const line = { ...base, result: 'valid', sealHolder: holderText(certHolder(tok.tsa), tok.tsa.hash), sealTime: tok.genTime };
return reason === undefined ? { ...line, before: true } : { ...line, reason };
}
/**
@ -195,9 +225,11 @@ export function evaluateCMS(
}
/**
* The verdict of a seal of seal_type 2 (spec §29.11): S2 or S1 for the form and
* the algorithms, S3 when it does not verify, and S4 or S5 when it does, with
* the authority and t. `signature` is the content of key 2, undefined without it.
* The verdict of a seal of seal_type 2 (spec v0.16, §29.11): S2 or S1 for the
* form and the algorithms, S3 when it does not verify, and S4 or S5 when it
* does, with the authority and t: S4 only when the token carries accuracy and
* t plus the accuracy is before round_time, and otherwise S5 with its reason.
* `signature` is the content of key 2, undefined without it.
*/
export function evaluateSeal(
token: Uint8Array,
@ -205,7 +237,7 @@ export function evaluateSeal(
controlCommit: Uint8Array,
headDigest: Uint8Array,
roundTime: Instant | undefined,
): { seal: 'S1' | 'S2' | 'S3' | 'S4' | 'S5'; sealHolder?: string; sealTime?: Instant } {
): { seal: 'S1' | 'S2' | 'S3' | 'S4' | 'S5'; sealHolder?: string; sealTime?: Instant; sealReason?: SealReason } {
let tok;
try {
tok = parseToken(token);
@ -216,5 +248,7 @@ export function evaluateSeal(
}
if (!tokenImprintIsSHA256(tok)) return { seal: 'S1' };
if (!checkToken(tok, sealSubject(controlCommit, headDigest, signature))) return { seal: 'S3' };
return { seal: before(tok, roundTime) ? 'S4' : 'S5', sealHolder: holderText(certHolder(tok.tsa), tok.tsa.hash), sealTime: tok.genTime };
const valid = { sealHolder: holderText(certHolder(tok.tsa), tok.tsa.hash), sealTime: tok.genTime };
const reason = sealReason(tok, roundTime);
return reason === undefined ? { seal: 'S4', ...valid } : { seal: 'S5', ...valid, sealReason: reason };
}

@ -7,11 +7,17 @@ import { describe, expect, it } from 'vitest';
// rather than pass with fewer cases. The check is the one of
// scripts/sync-testdata.mjs: every file matches the SHA-256 recorded in
// testdata/SOURCE.json for the pinned datekeys-go commit, with none missing
// and none extra.
// and none extra; and so do the word lists of wordlists/ and the recovery
// annex of annex/, from the same commit.
describe('testdata', () => {
it('matches testdata/SOURCE.json byte for byte, with no missing or extra file', () => {
it('matches testdata/SOURCE.json, wordlists/SOURCE.json and annex/SOURCE.json byte for byte, with no missing or extra file', () => {
const root = fileURLToPath(new URL('../../../', import.meta.url));
const out = execFileSync(process.execPath, ['scripts/sync-testdata.mjs', 'check'], { cwd: root, encoding: 'utf8' });
expect(out).toMatch(/^testdata: \d+ files match g\.activething\.com\/go\/DateKeys at [0-9a-f]{40}/);
const match =
/^testdata: \d+ files match g\.activething\.com\/go\/DateKeys at ([0-9a-f]{40})\nwordlists: \d+ files match g\.activething\.com\/go\/DateKeys at ([0-9a-f]{40})\nannex: \d+ files match g\.activething\.com\/go\/DateKeys at ([0-9a-f]{40})\n$/.exec(
out,
);
expect(match, out).not.toBeNull();
expect([match![2], match![3]]).toEqual([match![1], match![1]]);
});
});

@ -554,6 +554,8 @@ export interface TokenOptions {
readonly accuracy?: Uint8Array;
/** The version of the TSTInfo, 1 by default. */
readonly version?: number;
/** The policy of the TSTInfo, 1.2.3.4 by default; BTSP_POLICY is that of ETSI EN 319 421. */
readonly policy?: string;
/** Written as the hashed message instead of the hash of the subject, of any length. */
readonly imprint?: Uint8Array;
/** Written as genTime instead of the GeneralizedTime of the time given. */
@ -572,10 +574,19 @@ export function genTimeOf(t: Date): Uint8Array {
return generalizedTime(`${p(t.getUTCFullYear(), 4)}${p(t.getUTCMonth() + 1)}${p(t.getUTCDate())}${p(t.getUTCHours())}${p(t.getUTCMinutes())}${p(t.getUTCSeconds())}${frac}Z`);
}
/** The best practices time-stamp policy of ETSI EN 319 421, whose tokens carry accuracy (spec v0.16, §29.11). */
export const BTSP_POLICY = '0.4.0.2023.1.1';
/** The TSTInfo of a token over `subject` at `genTime`. */
export async function tstInfo(subject: Uint8Array, genTime: Date, o: TokenOptions = {}): Promise<Uint8Array> {
const hash = o.hash ?? 'SHA-256';
const fields = [int(o.version ?? 1), oid('1.2.3.4'), seq(hashAlg(hash), octets(o.imprint ?? (await digest(hash, subject)))), int(42), o.genTimeRaw ?? genTimeOf(genTime)];
const fields = [
int(o.version ?? 1),
oid(o.policy ?? '1.2.3.4'),
seq(hashAlg(hash), octets(o.imprint ?? (await digest(hash, subject)))),
int(42),
o.genTimeRaw ?? genTimeOf(genTime),
];
return seq(...fields, ...(o.accuracy === undefined ? [] : [o.accuracy]), ...(o.after ?? []));
}

@ -204,6 +204,16 @@
"r": "5ab044fad730f0470dfe574e1edd192065ff12ee04909e8cfe90ebc762cf5df7",
"file_key": "b6a845d405e6a47733b42802de903b6c"
},
{
"name": "format3_full_chunk",
"round": 1000,
"signature": "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39",
"body": "a3316bc4eb855ba46266a6de32e066c9db948af603f96bedb97d0fb82251cf3f6ff6d605837ee4deda36d486256f5ea40af6d6442706b7c128c27a14207fe55632e1226e1c423a539d3854af06b456372cd9002130d1e6e9cdaa735d35251cb91c291d2e9d448d96076508758e7fb5bdca29568439c58864ee13a042569992ea",
"gt": "191a12f6ac1061ad64fc2741b81c2cd3b59ea5b23272b41cac5a55a25454c3303f6eb0e53bf35395cfc64b9ddb7e67a911050b711837947705a7ed8ac2b6541f45c8be7b93014384b771c6b936df77fd420f3043c14b20d09d520794ea2f00150e7a164d9ed593582ef15c791316317b34908384b4b39cc48a6cd0b195d980f77adf7b983e34c61d7c6e1efe2c26eb08015d91a1234bad16c5c1cc6afa91392e426b7c706d4255ff7c21c0b60afeacd1304d3695658f799f2c1d9db937f9439f0578aeefd983cdb9f7f499e54cf5e091cbec90d1acf4c32dc557327b44fcaca078535b8e75038016b89688026196ce3d088d49cb58032cfb80ebaf66fdb861d1f461b658c24d1a012dcf8d7bda33f90f90c97e72a2feffaf442ad4baac8f6685023935250b48dbaaf44d59967968d46510d909ba9e6d3afa0706c71579ea59adc4c683b3172b4777c909d60699c07baa0d27e7a603e6c0619358014ea5a52ec149dd9f5fc9ab1a5b99fae39fd717d21d084daea95794e7d06edd23571e613f5a0bb70efdd00506460eb4067a7ffa95f3fac70db226b60201f12d7e3125b22211fcc71d3eb5175520d81595644bc78f42116f37390e30d4df6e58b40ad43ad120138fec50180e2f39bda8b56bd1669cc8498a39fb838269a3eab4c00650d851530119f4a185228f9189afe6f36b02d3a32ed5cc990869811c2a38e1f84b6a50f9aa8a1810296c16eed15b6527f48133b40fb7e4416cf55c450e51ba99b8f020c353dbc698cd88b6c191b3500a1cf6e9cf6fc1339e678cfeec715ccf40c30e29c1",
"sigma": "8792444c092fbf9d716911f5af1915de",
"r": "02c90283781a9a2f73c0b49483dd17d2dc57026f7892e67aac365b978e8f98e0",
"file_key": "ffa78141f5ad27edd0bb5a7886c050f6"
},
{
"name": "format3_note",
"round": 1000,
@ -294,6 +304,16 @@
"r": "2bd166cc5a3e8664ed5b4cc2932e13f53c313180dea70ca9a10b420d4e83a202",
"file_key": "916c47b45f39c0b4df425f1a248028e4"
},
{
"name": "format3_time_and_key_words",
"round": 1000,
"signature": "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39",
"body": "b451299668bf0607d4d6839784ae811b006a96ef77067ec33c7b9b8bfa9a50975dc223bafaa7a2a4b1eb7c09ae24807b13e9e5cc851e6fc7bc1f951890da76a86776e81278a30b808b08e4ed253261a5409647d785de1c9bb4202dbbab26707fea345f2c8a6048a26a9d1431174337a8b5707668bf4c81e11bfdb2895311fc2d",
"gt": "052db5b2caf451f8edeeb2eaa662ea40749f3de2b2118dfe23eafa7f30028e31180ab716dfd5b9e9c7283b762f97cad110ed15d491d3b596c0e43cbaf15fab6a39bd5d8ebc5ce62f206dcb86a7d5714e7bb1768862c6033368cd06c06f330d01103c7fd0aa2229906e03085f5563f024dbab25e3642a71ba1281fa7f0fae0258a3e47a7f4a41a2f6da2cfd4250884b7105b2993530eb40e45efe5065fd6e121fb36cf3178818aea92b8749958058fbe9449f67e1a426250aa2281de7843b94170d045409e95ae1b204ae8b37b0c869dbfc3e1ac95fc6656a6c087838044573709ee6a7753098cce4adb602e636f5d3560d17796d1cfe809a9aa8ccfaa78de2b5803fbdec6d45ba7fed0b35a5e0736ef80b9940ad5c659d2987efdc214f4fbb4c0d41c08cf52359b6b13a82b978bf9af6ca51d2b5a3bec6ea9289fc0657a95f23709b3b75ea2237d958f04a069d0b70c70326d4ea127d56c5504aa6043185a8d35356076250d861ff0bc2975dc40f51a81ff329bad1dbcc367b403d5b46bdb42c00bd2c3e6489ed849f0dbb3fccb1cf08cfd6785f66dbf306f74ac0448881bc9b348ec64b684e8613a4cb880ef574d7510d7bcc004c12d27bbefccf21459b0ac23bf239b3062f1c53e83ebb0373c95de1e6e682e2daf273db4c242c8e3b05435f171770affc2f7e784414099c76b938a1d60df53f0137681db610ea2b02dcc28abe697d5485fce2eb5f910fa702217bcc1107836f0c9ce72cdcf885925e01c8bf9508963686c515bfe03c5c2b0127ae0fe4e89ac5df779493493912a96e4e7f46",
"sigma": "e8c96c522ff4fb100bdbb290f607f654",
"r": "6916b5c1fd08176844ef3649f3a8653b2a79ca37008b14aeff9fe2eeb9599901",
"file_key": "7b1a19733d0d4eb54baf209550bbc7e0"
},
{
"name": "format3_tree",
"round": 1001,

@ -1,7 +1,7 @@
{
"description": "The text of the error of capsule.Open for every case of testdata/vectors/mutations.json, or ok for a capsule that opens; see the header of scripts/mutation-go-texts.go.",
"generator": "scripts/mutation-go-texts.go",
"spec": "0.15",
"spec": "0.16",
"cases": [
{
"name": "PUBLIC_HEADER_A + SEALED_CONTROL_B",

@ -227,12 +227,12 @@
"length": 32989,
"lines": [
"Firmado con la clave dkauthor1pdrcy0n3p9watxl83tp8r3tkauuflpakg4s6kp70nf8te5pdypqszvracp. No prueba quién la tiene.",
"Según un sello a nombre de «TSA de prueba», existía el 2023-08-23T15:09:27Z, antes de que la cápsula pudiera abrirse. DateKeys no comprueba quién emitió el sello."
"No acredita que se sellara antes de la fecha de apertura: el sello no dice su precisión."
],
"result": "ok",
"verdicts": [
"F4",
"S4"
"S5"
]
},
"opened_saved": {
@ -254,12 +254,12 @@
"length": 32989,
"lines": [
"Firmado con la clave que guardaste como mi clave de 2026.",
"Según un sello a nombre de «TSA de prueba», existía el 2023-08-23T15:09:27Z, antes de que la cápsula pudiera abrirse. DateKeys no comprueba quién emitió el sello."
"No acredita que se sellara antes de la fecha de apertura: el sello no dice su precisión."
],
"result": "ok",
"verdicts": [
"F3",
"S4"
"S5"
]
},
"recipe": {
@ -363,12 +363,12 @@
"length": 32893,
"lines": [
"Sin firma de autor.",
"Según un sello a nombre de «TSA de prueba», existía el 2023-08-23T15:09:27Z, antes de que la cápsula pudiera abrirse. DateKeys no comprueba quién emitió el sello."
"No acredita que se sellara antes de la fecha de apertura: el sello no dice su precisión."
],
"result": "ok",
"verdicts": [
"F0",
"S4"
"S5"
]
},
"recipe": {
@ -941,7 +941,7 @@
"length": 32893,
"lines": [
"Firmado con la clave dkauthor1pdrcy0n3p9watxl83tp8r3tkauuflpakg4s6kp70nf8te5pdypqszvracp. No prueba quién la tiene.",
"Sellado después de la fecha de apertura: no prueba nada anterior."
"No acredita que se sellara antes de la fecha de apertura: se selló después de esa fecha o demasiado cerca de ella."
],
"result": "ok",
"verdicts": [
@ -963,7 +963,7 @@
"length": 32893,
"lines": [
"Firmado con la clave que guardaste como mi clave de 2026.",
"Sellado después de la fecha de apertura: no prueba nada anterior."
"No acredita que se sellara antes de la fecha de apertura: se selló después de esa fecha o demasiado cerca de ella."
],
"result": "ok",
"verdicts": [
@ -1208,7 +1208,7 @@
"n": 16
}
],
"error": "capsule: self-check: the reader finds the verdicts F2 and S4 in this security area, not F4 and S4",
"error": "capsule: self-check: the reader finds the verdicts F2 and S5 in this security area, not F4 and S4",
"hooks": {
"author_message": "646174656b6579733a646b63333a617574686f722d7369676e61747572653a76310a303730376433653332303735303339653232633637333430626431643439326234343365666461663935656236633339356630313330636162363033386631630a",
"author_public": "0b47823e71095dd59be78ac271c576ef389f87b64561ab07cf9a4ebcd02d2041",
@ -1863,12 +1863,12 @@
"length": 32990,
"lines": [
"Firmado con la clave dkauthor1xes558a6zzqw4urrz8c80u33znlv2yzc9t9f5ywa8a0c8ysq9atqt2k706. No prueba quién la tiene.",
"Según un sello a nombre de «Autoridad de sellado de prueba», existía el 2023-08-23T15:09:27Z, antes de que la cápsula pudiera abrirse. DateKeys no comprueba quién emitió el sello."
"No acredita que se sellara antes de la fecha de apertura: el sello no dice su precisión."
],
"result": "ok",
"verdicts": [
"F4",
"S4"
"S5"
]
},
"opened_saved": {
@ -1890,23 +1890,23 @@
"length": 32990,
"lines": [
"Firmado con la clave que guardaste como la clave de prueba.",
"Según un sello a nombre de «Autoridad de sellado de prueba», existía el 2023-08-23T15:09:27Z, antes de que la cápsula pudiera abrirse. DateKeys no comprueba quién emitió el sello."
"No acredita que se sellara antes de la fecha de apertura: el sello no dice su precisión."
],
"result": "ok",
"verdicts": [
"F3",
"S4"
"S5"
]
},
"ts": {
"area_len": 32768,
"lines": [
"Firmado con la clave que guardaste como la clave de prueba.",
"Según un sello a nombre de «Autoridad de sellado de prueba», existía el 2023-08-23T15:09:27Z, antes de que la cápsula pudiera abrirse. DateKeys no comprueba quién emitió el sello."
"No acredita que se sellara antes de la fecha de apertura: el sello no dice su precisión."
],
"verdicts": [
"F3",
"S4"
"S5"
]
}
},
@ -1931,23 +1931,23 @@
"length": 32990,
"lines": [
"Sin firma de autor.",
"Según un sello a nombre de «Autoridad de sellado de prueba», existía el 2023-08-23T15:09:27Z, antes de que la cápsula pudiera abrirse. DateKeys no comprueba quién emitió el sello."
"No acredita que se sellara antes de la fecha de apertura: el sello no dice su precisión."
],
"result": "ok",
"verdicts": [
"F0",
"S4"
"S5"
]
},
"ts": {
"area_len": 32768,
"lines": [
"Sin firma de autor.",
"Según un sello a nombre de «Autoridad de sellado de prueba», existía el 2023-08-23T15:09:27Z, antes de que la cápsula pudiera abrirse. DateKeys no comprueba quién emitió el sello."
"No acredita que se sellara antes de la fecha de apertura: el sello no dice su precisión."
],
"verdicts": [
"F0",
"S4"
"S5"
]
}
},
@ -2040,5 +2040,5 @@
}
}
],
"source": "fe405e2348744f50e54c72e35ae10470a01dc552"
"source": "4f7885495bd6ea666cb444519090fb5f3ea46752"
}

@ -107,6 +107,7 @@ import {
verifyRelease,
} from './release.ts';
import { evaluateSecurity, type Verdict, verdictLines } from './security.ts';
import type { SignerLine } from './securitycms.ts';
import { MemorySink } from './sink.ts';
import { readVectors as readLocatorVectors } from './testing/locator.ts';
import { kinds } from './testing/verdicts.ts';
@ -1964,8 +1965,9 @@ describe('vectors/release.json', () => {
});
if (f === undefined) return;
it('has the 36 objects, 12 JSON inputs and 7 archive lookups of README, every code of step 10 among them', () => {
expect([f.objects.length, f.json.length, f.archive.lookups.length]).toEqual([36, 12, 7]);
// The JSON inputs are the 12 of v0.15 and the 26 of the strict reading of v0.16 (§47.1).
it('has the 36 objects, 38 JSON inputs and 7 archive lookups, every code of step 10 among them', () => {
expect([f.objects.length, f.json.length, f.archive.lookups.length]).toEqual([36, 38, 7]);
expect([...new Set([...f.objects, ...f.json].map((c) => c.result))].sort()).toEqual([
'ERR_NON_CANONICAL_CBOR',
'ERR_PROFILE_MISMATCH',
@ -2097,14 +2099,20 @@ describe('testdata/', () => {
});
describe('vectors of v0.11', () => {
// The vectors of the draft v0.12 for the verification of the signature
// with certificates and of the seal (§29.7, §29.10, §29.11): every area is
// The vectors of v0.16 for the verification of the signature with
// certificates and of the seal (§29.7, §29.10, §29.11): every area is
// read in the context of its capsule and must give the verdicts of the
// reference, the result of each signer, of those who do not count and the
// authority of a valid seal, each time in RFC 3339 with the fraction of
// its token, and the lines of §29.7, byte for byte.
// its token, the reason why a valid seal does not prove that it came
// before the round time, and the lines of §29.7, byte for byte.
describe('security_cms.json', () => {
const RESULTS = ['valid', 'invalid', 'absent', 'without seal', 'invalid seal', 'out of validity', 'not verifiable'];
// The reasons of README, the first that holds (spec v0.16, §29.7).
const sealReason = (v: unknown, at: string): string => {
if (!['late', 'no accuracy', 'no accuracy, BTSP'].includes(str(v, at))) throw new FormatError(at, `"${String(v)}" is not a reason of README`);
return v as string;
};
// The results of the signers of a case, [] when the key is absent, as Go omits an empty list.
const results = (v: unknown, at: string): unknown[] =>
v === undefined
@ -2112,10 +2120,11 @@ describe('testdata/', () => {
: array(v, at).map((s, i) => {
const w = `${at}[${i}]`;
const o = object(s, w);
keys(o, w, ['holder', 'issuer', 'result', 'before_round_time'], ['seal_time']);
keys(o, w, ['holder', 'issuer', 'result', 'before_round_time'], ['seal_time', 'seal_reason']);
if (!RESULTS.includes(str(o.result, `${w}.result`))) throw new FormatError(w, `"${String(o.result)}" is not a result of README`);
const t = o.seal_time === undefined ? {} : { seal_time: str(o.seal_time, `${w}.seal_time`) };
return { holder: str(o.holder, `${w}.holder`), issuer: str(o.issuer, `${w}.issuer`), result: o.result, ...t, before_round_time: bool(o.before_round_time, w) };
const reason = o.seal_reason === undefined ? {} : { seal_reason: sealReason(o.seal_reason, `${w}.seal_reason`) };
return { holder: str(o.holder, `${w}.holder`), issuer: str(o.issuer, `${w}.issuer`), result: o.result, ...t, before_round_time: bool(o.before_round_time, w), ...reason };
});
const f = load('vectors/security_cms.json', (json) => {
const o = object(json, 'security_cms.json');
@ -2124,7 +2133,7 @@ describe('testdata/', () => {
return array(o.cases, 'cases').map((v, i) => {
const at = `cases[${i}]`;
const c = object(v, at);
keys(c, at, ['name', 'security_cbor', 'context', 'signature', 'seal', 'lines'], ['signers', 'foreign_signers', 'seal_holder', 'seal_time']);
keys(c, at, ['name', 'security_cbor', 'context', 'signature', 'seal', 'lines'], ['signers', 'foreign_signers', 'seal_holder', 'seal_time', 'seal_reason']);
const want = {
signature: verdict(c.signature, `${at}.signature`),
seal: verdict(c.seal, `${at}.seal`),
@ -2132,6 +2141,7 @@ describe('testdata/', () => {
foreign_signers: results(c.foreign_signers, `${at}.foreign_signers`),
seal_holder: c.seal_holder === undefined ? '' : str(c.seal_holder, `${at}.seal_holder`),
seal_time: c.seal_time === undefined ? '' : str(c.seal_time, `${at}.seal_time`),
seal_reason: c.seal_reason === undefined ? '' : sealReason(c.seal_reason, `${at}.seal_reason`),
lines: linesOf(c.lines, `${at}.lines`),
};
return { name: str(c.name, at), area: hexOf(c.security_cbor), context: securityContext(c.context, `${at}.context`), want };
@ -2139,20 +2149,23 @@ describe('testdata/', () => {
});
if (f === undefined) return;
it('has the 135 cases of README, which reach every verdict but X and F3', () => {
expect(f).toHaveLength(135);
it('has the 143 cases of README, which reach every verdict but X and F3, and every reason', () => {
expect(f).toHaveLength(143);
const reached = new Set(f.flatMap((c) => [c.want.signature, c.want.seal]));
expect([...reached].sort()).toEqual(['F0', 'F1', 'F2', 'F4', 'F5', 'F6', 'S0', 'S1', 'S2', 'S3', 'S4', 'S5']);
const reasons = (c: (typeof f)[number]): unknown[] => [c.want.seal_reason, ...c.want.signers.map((s) => (s as { seal_reason?: string }).seal_reason)];
expect([...new Set(f.flatMap(reasons))].filter((r) => r !== '' && r !== undefined).sort()).toEqual(['late', 'no accuracy', 'no accuracy, BTSP']);
});
it.each(f.map((c) => [c.name, c] as const))('%s', (_n, c) => {
const v = evaluateSecurity(c.area, c.context);
const result = (s: { holder: string; issuer: string; result: string; sealTime?: Instant; before: boolean }): unknown => ({
const result = (s: SignerLine): unknown => ({
holder: s.holder,
issuer: s.issuer,
result: s.result,
...(s.sealTime === undefined ? {} : { seal_time: formatRFC3339Nano(s.sealTime) }),
before_round_time: s.before,
...(s.reason === undefined ? {} : { seal_reason: s.reason }),
});
expect({
...kinds(v),
@ -2160,6 +2173,7 @@ describe('testdata/', () => {
foreign_signers: v.detail?.foreign.map(result) ?? [],
seal_holder: v.detail?.sealHolder ?? '',
seal_time: v.detail?.sealTime === undefined ? '' : formatRFC3339Nano(v.detail.sealTime),
seal_reason: v.detail?.sealReason ?? '',
lines: verdictLines(v),
}).toEqual(c.want);
});

@ -9,13 +9,16 @@
* reads formats 1 to 3 and writes format 3, signed and sealed, with the key
* of words, the public note and the locator of datekeys.capsule; 0.3.0
* implements spec 0.14: one drand scheme and the root of trust byte for byte;
* 0.4.0 implements spec 0.15: the release object and a release in hand.
* 0.4.0 implements spec 0.15: the release object and a release in hand;
* 0.5.0 implements spec 0.16: a seal without accuracy proves nothing before
* the opening date and drand's JSON is read strictly; it also draws the
* random words of a key of words, from a computer or from dice.
*/
export const VERSION = '0.4.0';
export const VERSION = '0.5.0';
/**
* The version of the DateKeys Protocol Specification that this library
* implements: v0.15 of the Go reference, whose shared vectors
* implements: the draft v0.16 of the Go reference, whose shared vectors
* and fixtures (testdata/) all name it.
*/
export const SPEC_VERSION = '0.15';
export const SPEC_VERSION = '0.16';

@ -42,6 +42,49 @@ function split(text: string, nfd: (s: string) => string, lower: (ch: string) =>
return words;
}
/** The fields of text separated by white space, as Go strings.Fields: the spaces of unicode.IsSpace, the list of §38.1, and nothing else changed. */
export function goFields(text: string): string[] {
const fields: string[] = [];
let field = '';
for (const ch of text) {
if (SPACES.has(ch.codePointAt(0)!)) {
if (field !== '') fields.push(field);
field = '';
} else field += ch;
}
if (field !== '') fields.push(field);
return fields;
}
/** The reading and the checks of words that need the tables of Unicode 18.0.0, once they have loaded (wordRules). */
export interface WordRules {
/** The words of a text, as normalizeWords returns them. */
readonly normalize: (text: string) => string[];
/**
* The error of Go wordkey for the first code point of a word that a
* person cannot see: a control, a Default_Ignorable_Code_Point or a code
* point unassigned in Unicode 18.0.0; undefined when there is none.
*/
readonly hidden: (word: string) => string | undefined;
}
/** The rules of the words, with the tables of pathrule.ts loaded once, for a caller that reads many words. */
export async function wordRules(): Promise<WordRules> {
const [{ isAssigned, isDefaultIgnorable, lower, nfd }, { UNICODE_VERSION }] = await Promise.all([import('./pathrule.ts'), import('./pathrule-tables.ts')]);
return {
normalize: (text) => split(text, nfd, (ch) => String.fromCodePoint(lower(ch.codePointAt(0)!))),
hidden: (word) => {
for (const ch of word) {
const r = ch.codePointAt(0)!;
if (r <= 0x1f || (r >= 0x7f && r <= 0x9f)) return `wordkey: the words hold the control character U+${hex4(r)}`;
if (isDefaultIgnorable(r)) return `wordkey: the words hold the invisible character U+${hex4(r)}`;
if (!isAssigned(r)) return `wordkey: the words hold U+${hex4(r)}, unassigned in Unicode ${UNICODE_VERSION}`;
}
return undefined;
},
};
}
/**
* The words of a text, as Go wordkey.Normalize reads them: its NFD by the
* tables of pathrule.ts, without the combining marks U+0300 to U+036F, each
@ -49,8 +92,7 @@ function split(text: string, nfd: (s: string) => string, lower: (ch: string) =>
* white space.
*/
export async function normalizeWords(text: string): Promise<string[]> {
const { nfd, lower } = await import('./pathrule.ts');
return split(text, nfd, (ch) => String.fromCodePoint(lower(ch.codePointAt(0)!)));
return (await wordRules()).normalize(text);
}
/**
@ -94,14 +136,10 @@ export function countedWords(words: readonly string[]): number {
* wordkey.Check.
*/
export async function checkWords(words: readonly string[]): Promise<void> {
const [{ isAssigned, isDefaultIgnorable }, { UNICODE_VERSION }] = await Promise.all([import('./pathrule.ts'), import('./pathrule-tables.ts')]);
const { hidden } = await wordRules();
for (const w of words) {
for (const ch of w) {
const r = ch.codePointAt(0)!;
if (r <= 0x1f || (r >= 0x7f && r <= 0x9f)) throw new Error(`wordkey: the words hold the control character U+${hex4(r)}`);
if (isDefaultIgnorable(r)) throw new Error(`wordkey: the words hold the invisible character U+${hex4(r)}`);
if (!isAssigned(r)) throw new Error(`wordkey: the words hold U+${hex4(r)}, unassigned in Unicode ${UNICODE_VERSION}`);
}
const problem = hidden(w);
if (problem !== undefined) throw new Error(problem);
}
const n = countedWords(words);
if (n < MIN_WORDS) throw new Error(`wordkey: a key of words needs at least ${MIN_WORDS} different words of ${MIN_LETTERS} or more letters, not ${n}`);

@ -0,0 +1,255 @@
// Tests of wordlist.ts: the cases of generate_test.go and dice_test.go of Go
// wordkey, with their texts; the lists of datekeys-go, read with the SHA-256
// that their README records; draws that are uniform and never repeat a word;
// and the words of dice, with the lists numbered for them.
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { toHex, utf8Bytes } from './bytes.ts';
import type { RandomWords } from './random.ts';
import { checkWords, goFields, normalizeWords } from './wordkey.ts';
import {
checkWordList,
DEFAULT_WORD_COUNT,
DICE_LIST_SIZE,
diceList,
diceNumber,
diceWord,
diceWords,
generateWords,
MIN_LIST_SIZE,
readWordList,
WORD_LIST_SHA256,
wordBits,
} from './wordlist.ts';
const listFile = (name: string): Uint8Array => new Uint8Array(readFileSync(fileURLToPath(new URL(`../../../wordlists/${name}`, import.meta.url))));
const cp = (r: number): string => String.fromCodePoint(r);
const hash = async (b: Uint8Array): Promise<string> => toHex(new Uint8Array(await crypto.subtle.digest('SHA-256', b as Uint8Array<ArrayBuffer>)));
// pal followed by three letters: palaaa, palaab, …, as in Go.
const base = Array.from({ length: MIN_LIST_SIZE }, (_, i) => `pal${String.fromCharCode(97 + Math.floor(i / 676), 97 + (Math.floor(i / 26) % 26), 97 + (i % 26))}`);
const withWord = (i: number, w: string): string[] => base.map((x, j) => (j === i ? w : x));
const fileOf = (words: readonly string[]): Uint8Array => utf8Bytes(`${words.join('\n')}\n`);
// A source of 32-bit words that repeats `seed`.
function repeating(seed: readonly number[]): RandomWords {
let i = 0;
return () => seed[i++ % seed.length]!;
}
describe('readWordList', () => {
it('reads the lists en and es of datekeys-go, with the SHA-256 that its README records', async () => {
const readme = new TextDecoder().decode(listFile('README.md'));
expect(Object.keys(WORD_LIST_SHA256).sort()).toEqual(['en', 'es']);
for (const lang of ['en', 'es']) expect(readme).toContain(`| \`${lang}.txt\` | 7776 | \`${WORD_LIST_SHA256[lang]}\` |`);
const es = await readWordList('es', listFile('es.txt'));
expect(es).toHaveLength(7776);
expect([es[0], es.at(-1)]).toEqual(['abad', 'útil']);
// The list of the EFF in its order: the dice 11111 give its first word
// and 66666 its last.
const en = await readWordList('en', listFile('en.txt'));
expect(en).toHaveLength(7776);
expect([en[0], en[1], en.at(-1)]).toEqual(['abacus', 'abdomen', 'zoom']);
expect(en.filter((w) => w.includes('-'))).toEqual(['drop-down', 'felt-tip', 't-shirt', 'yo-yo']);
});
it('refuses a list of another SHA-256 and a language without a list, with the texts of Go', async () => {
const changed = listFile('es.txt');
changed[0]! ^= 1;
const refused = `wordkey: the list "es" has the SHA-256 ${await hash(changed)}, not ${WORD_LIST_SHA256['es']}`;
await expect(readWordList('es', changed)).rejects.toThrow(refused);
await expect(readWordList('xx', listFile('es.txt'))).rejects.toThrow(/^wordkey: no word list for "xx"; the lists are en, es$/);
await expect(readWordList('es', fileOf(base), { fr: '00', de: '11' })).rejects.toThrow(/^wordkey: no word list for "es"; the lists are de, fr$/);
});
it('refuses a list with its pin that is not UTF-8, or that checkWordList refuses, as Go wordkey.List', async () => {
const pin = async (lang: string, b: Uint8Array): Promise<Record<string, string>> => ({ [lang]: await hash(b) });
const ok = fileOf(base);
await expect(readWordList('es', ok, await pin('es', ok))).resolves.toEqual(base);
// Without the end of its last line, too.
const unended = ok.slice(0, -1);
await expect(readWordList('es', unended, await pin('es', unended))).resolves.toEqual(base);
const cyrillic = fileOf(withWord(5, `${cp(0x441)}asa`));
const err = (await readWordList('es', cyrillic, await pin('es', cyrillic)).catch((e: unknown) => e)) as Error;
expect(err.message).toBe(`wordkey: the list "es": line 6, "${cp(0x441)}asa", holds U+0441, which is not in the alphabet of "es"`);
expect((err.cause as Error).message).toBe(`line 6, "${cp(0x441)}asa", holds U+0441, which is not in the alphabet of "es"`);
// A BOM stays: it is an invisible character of the first word.
const bom = utf8Bytes(`${cp(0xfeff)}${base.join('\n')}\n`);
await expect(readWordList('es', bom, await pin('es', bom))).rejects.toThrow(/^wordkey: the list "es": line 1: wordkey: the words hold the invisible character U\+FEFF$/);
const latin1 = fileOf(base);
latin1[3] = 0xe1;
await expect(readWordList('es', latin1, await pin('es', latin1))).rejects.toThrow(/^wordkey: the list "es" is not UTF-8$/);
await expect(readWordList('xx', ok, await pin('xx', ok))).rejects.toThrow(/^wordkey: the list "xx": no alphabet for the language "xx"$/);
});
});
describe('checkWordList', () => {
it('accepts a list of 2048 different words of the alphabet, and refuses the cases of Go wordkey.TestCheckList', async () => {
await expect(checkWordList('es', base)).resolves.toBeUndefined();
await expect(checkWordList('xx', base)).rejects.toThrow(/^no alphabet for the language "xx"$/);
const cases: [readonly string[], string][] = [
[base.slice(0, MIN_LIST_SIZE - 1), '2047 words, fewer than 2048'],
[withWord(5, 'dos palabras'), 'line 6, "dos palabras", is not one word'],
[withWord(5, ' '), 'is not one word'],
[withWord(5, 'mi'), '"mi", is not one word of 3 or more letters'],
[withWord(5, `casa${cp(0x200b)}`), 'invisible character U+200B'],
// Only the letters of the alphabet of the language, as the list writes
// them: no capitals, no Cyrillic U+0441 that looks like a Latin c, no
// digits, no carriage return of a file with CRLF lines.
[withWord(5, 'Palaaf'), 'line 6, "Palaaf", holds U+0050, which is not in the alphabet of "es"'],
[withWord(5, `${cp(0x441)}asa`), 'holds U+0441, which is not in the alphabet'],
[withWord(5, 'pal1'), 'holds U+0031'],
[withWord(5, `palaaf${cp(0x0d)}`), `line 6, "palaaf${cp(0x5c)}r", holds U+000D`],
[withWord(5, base[4]!), 'line 6, "palaae", is the same word as "palaae"'],
[withWord(5, 'palaáe'), 'line 6, "palaáe", is the same word as "palaae"'],
[[...withWord(0, 'papá'), 'papa'], '"papa", is the same word as "papá"'],
];
for (const [list, want] of cases) await expect(checkWordList('es', list)).rejects.toThrow(want);
});
it('takes the hyphen as a letter of en and not of es, and an accent as one of es and not of en, as Go', async () => {
await expect(checkWordList('en', withWord(5, 't-shirt'))).resolves.toBeUndefined();
await expect(checkWordList('es', withWord(5, 't-shirt'))).rejects.toThrow(/^line 6, "t-shirt", holds U\+002D, which is not in the alphabet of "es"$/);
await expect(checkWordList('en', withWord(5, 'palaáf'))).rejects.toThrow(/^line 6, "palaáf", holds U\+00E1, which is not in the alphabet of "en"$/);
});
});
describe('generateWords', () => {
it('draws different words of the list that make a key, by default 7 with crypto.getRandomValues', async () => {
const list = await readWordList('es', listFile('es.txt'));
const words = generateWords(list);
expect(words).toHaveLength(DEFAULT_WORD_COUNT);
expect(new Set(words).size).toBe(DEFAULT_WORD_COUNT);
for (const w of words) expect(list).toContain(w);
await expect(checkWords(await normalizeWords(words.join(' ')))).resolves.toBeUndefined();
});
it('reads nothing but its random words, and draws a word only once', () => {
const seed = [7, 1, 200, 33, 0x9e3779b9, 12345];
expect(generateWords(base, 6, repeating(seed))).toEqual(generateWords(base, 6, repeating(seed)));
// Index 0 comes twice: the second is drawn again.
expect(generateWords(base, 6, repeating([0, 0, 1, 2, 3, 4, 5]))).toEqual(base.slice(0, 6));
expect(() =>
generateWords(base, 6, () => {
throw new Error('no more randomness');
}),
).toThrow('no more randomness');
});
it('refuses fewer than 6 words and more than half the list, with the texts of Go', () => {
expect(() => generateWords(base, 5)).toThrow(/^wordkey: a key of words needs at least 6 words, not 5$/);
expect(() => generateWords(base, 6.5)).toThrow(/^wordkey: a key of words needs at least 6 words, not 6\.5$/);
expect(() => generateWords(base, 1025)).toThrow(/^wordkey: 1025 words of a list of 2048$/);
expect(generateWords(base, 1024)).toHaveLength(1024);
});
// As TestGenerateUniform of Go: over 7776·40 draws of one word, each index
// falls in its bucket of 64 between 0.8 and 1.2 times the mean.
it('draws every word about as often', async () => {
const list = await readWordList('es', listFile('es.txt'));
const index = new Map(list.map((w, i) => [w, i]));
const buckets = 64;
const count = new Array<number>(buckets).fill(0);
let draws = 0;
while (draws < list.length * 40) {
for (const w of generateWords(list, 6)) {
count[Math.floor((index.get(w)! * buckets) / list.length)]!++;
draws++;
}
}
const mean = draws / buckets;
for (const c of count) {
expect(c).toBeGreaterThan(0.8 * mean);
expect(c).toBeLessThan(1.2 * mean);
}
});
});
describe('wordBits', () => {
it('is log2 of the draws in order, as Go wordkey.Bits', () => {
expect(wordBits(7776, 1)).toBe(Math.log2(7776));
expect(wordBits(7776, 0)).toBe(0);
// 7 words of 7776 are a little under 90.5 bits, and 6 of 2048, the
// fewest, a little under 66.
expect(wordBits(7776, 7)).toBeCloseTo(90.4698, 4);
expect(wordBits(7776, 8)).toBeCloseTo(103.3933, 4);
expect(wordBits(2048, 6)).toBeCloseTo(65.9894, 4);
});
});
describe('the dice', () => {
it('number the positions of a list of 7776 words from 11111 to 66666, as Go wordkey.DiceNumber', () => {
const cases: [number, string][] = [
[0, '11111'],
[1, '11112'],
[5, '11116'],
[6, '11121'],
[35, '11166'],
[36, '11211'],
[1295, '16666'],
[1296, '21111'],
[7775, '66666'],
];
for (const [i, want] of cases) expect(diceNumber(i)).toBe(want);
for (const i of [-1, 7776, 1.5]) expect(() => diceNumber(i)).toThrow(new RegExp(`^wordkey: no dice give position ${i} of a list of 7776 words$`));
expect(DICE_LIST_SIZE).toBe(7776);
});
it('give the word of their number, as Go wordkey.DiceWord, and refuse what is not five dice', async () => {
const en = await readWordList('en', listFile('en.txt'));
const es = await readWordList('es', listFile('es.txt'));
for (let i = 0; i < en.length; i++) expect(diceWord(en, diceNumber(i))).toBe(en[i]);
const cases: [readonly string[], string, string][] = [
[en, '11111', 'abacus'],
[en, '11112', 'abdomen'],
[en, '35214', 'jovial'],
[en, '66666', 'zoom'],
[es, '11111', 'abad'],
[es, '35214', 'glaciar'],
[es, '66666', 'útil'],
];
for (const [list, dice, want] of cases) expect(diceWord(list, dice)).toBe(want);
for (const dice of ['', '1111', '111111', '11110', '11117', 'a1111', '1111 ', `${cp(0xff11)}1111`, `11111\n`]) {
// Go's %q of these is their JSON.
expect(() => diceWord(en, dice)).toThrow(`wordkey: ${JSON.stringify(dice)} is not five dice: five digits from 1 to 6`);
}
expect(() => diceWord(en.slice(0, 2048), '11111')).toThrow(/^wordkey: dice draw from a list of 7776 words, not 2048$/);
});
it('give the words of several numbers, as Go wordkey.DiceWords: at least 6, and no word twice', async () => {
const en = await readWordList('en', listFile('en.txt'));
const words = diceWords(en, ` 11111 11112\t11113\n11114 11115 11116 11121 `);
expect(words).toEqual(['abacus', 'abdomen', 'abdominal', 'abide', 'abiding', 'ability', 'ablaze']);
await expect(checkWords(await normalizeWords(words.join(' ')))).resolves.toBeUndefined();
const cases: [string, string][] = [
['11111 11112 11113 11114 11115', 'wordkey: a key of words needs at least 6 words, not 5'],
['', 'wordkey: a key of words needs at least 6 words, not 0'],
['11111 11112 11113 11114 11115 1116', 'wordkey: "1116" is not five dice: five digits from 1 to 6'],
['11111 11112 11113 11114 11115 11111', 'wordkey: the dice 11111 give "abacus" a second time; roll them again'],
['11111,11112 11113 11114 11115 11116 11121', 'wordkey: "11111,11112" is not five dice: five digits from 1 to 6'],
];
for (const [dice, want] of cases) expect(() => diceWords(en, dice)).toThrow(want);
expect(() => diceWords(en.slice(1), '11111 11112 11113 11114 11115 11116')).toThrow(/^wordkey: dice draw from a list of 7776 words, not 7775$/);
});
it('number a whole list to print it, as Go wordkey.DiceList: for en, the file of the EFF', async () => {
const want: Record<string, string> = {
en: 'addd35536511597a02fa0a9ff1e5284677b8883b83e986e43f15a3db996b903e',
es: '611f779a33df74587e9dfb486bb0185a9dfd4d1384e75993a21247ad31cefddb',
};
for (const lang of ['en', 'es']) {
const list = await readWordList(lang, listFile(`${lang}.txt`));
const text = diceList(list);
expect(await hash(utf8Bytes(text))).toBe(want[lang]);
expect(text.startsWith(`11111\t${list[0]}\n`) && text.endsWith(`66666\t${list[7775]}\n`)).toBe(true);
}
expect(() => diceList(new Array<string>(2048).fill('x'))).toThrow(/^wordkey: dice draw from a list of 7776 words, not 2048$/);
});
it('split numbers at white space as Go strings.Fields, without changing them', () => {
expect(goFields(` 1\u0301 2\t3\u00a0 4\u3000`)).toEqual([`1${cp(0x301)}`, '2', '3', '4']);
expect(goFields('')).toEqual([]);
});
});

@ -0,0 +1,194 @@
// The random words of a key of words (spec §38.1, the SHOULD to offer them by
// default): Go wordkey.Generate, CheckList and Bits, with their texts. A list
// is public, and its strength is its number of words, never its secrecy: a
// list whose words are one once normalized cuts that strength without a sign,
// and a word with a letter of another script that looks like one of the
// language, a Cyrillic U+0430 for a Latin a, is typed again with the letter
// of the keyboard and leaves the capsule shut. So no list is trusted, not
// even one of DateKeys: readWordList takes a list only with its pinned
// SHA-256, and checkWordList checks it against the alphabet of its language,
// which only this code gives. The lists are those of datekeys-go,
// wordkey/lists, which scripts/sync-testdata.mjs copies into wordlists/ with
// the test data. The words are drawn on the device, with
// crypto.getRandomValues by default, and never travel.
import { decodeUtf8, goQuote, sha256, toHex } from './bytes.ts';
import { cryptoWords, randomIndex, type RandomWords } from './random.ts';
import { goFields, MIN_LETTERS, MIN_WORDS, wordRules } from './wordkey.ts';
/** The number of words drawn when the caller does not ask for more: 7 of a list of 7776 are about 90 bits. */
export const DEFAULT_WORD_COUNT = 7;
/** The fewest words of a list (spec §38.1: at least 6 words of a list of 2048 or more). */
export const MIN_LIST_SIZE = 2048;
/**
* The SHA-256 of each list of datekeys-go, by language, as
* wordkey/lists/README.md records it: a list changes only with its hash
* here, so that the change is never silent.
*/
export const WORD_LIST_SHA256: Readonly<Record<string, string>> = {
en: '6d557f0693958fb5e650b68b5bee585eb82cf4da32965505c789e924743bc522',
es: 'ff77b487765c000da97cca58fe94a2cdb947303e7a07460614d7d95d800034fe',
};
// The letters that a word of a list of each language may hold, as the list
// writes it, lower case and in NFC: the alphabets of Go wordkey. The English
// one has the ASCII hyphen of the four compound words of the list of the EFF,
// such as t-shirt.
const ALPHABETS: Readonly<Record<string, string>> = {
en: 'abcdefghijklmnopqrstuvwxyz-',
es: 'abcdefghijklmnopqrstuvwxyzáéíóúüñ',
};
const hex4 = (r: number): string => r.toString(16).toUpperCase().padStart(4, '0');
/**
* The words of the list of the language `lang` in `file`, its bytes as
* datekeys-go has them: UTF-8, one word per line, the last one ended. The
* list must have the SHA-256 that `pins` gives for `lang`, WORD_LIST_SHA256
* by default, be UTF-8 and pass checkWordList: otherwise it throws, with the
* texts of Go wordkey.List for a language without a list and for a list
* refused.
*/
export async function readWordList(lang: string, file: Uint8Array, pins: Readonly<Record<string, string>> = WORD_LIST_SHA256): Promise<string[]> {
if (!Object.hasOwn(pins, lang)) throw new Error(`wordkey: no word list for ${goQuote(lang)}; the lists are ${Object.keys(pins).sort().join(', ')}`);
const got = toHex(await sha256(file));
if (got !== pins[lang]) throw new Error(`wordkey: the list ${goQuote(lang)} has the SHA-256 ${got}, not ${pins[lang]}`);
// A BOM stays, and checkWordList refuses it as an invisible character.
const text = decodeUtf8(file);
if (text === undefined) throw new Error(`wordkey: the list ${goQuote(lang)} is not UTF-8`);
const words = (text.endsWith('\n') ? text.slice(0, -1) : text).split('\n');
try {
await checkWordList(lang, words);
} catch (err) {
throw new Error(`wordkey: the list ${goQuote(lang)}: ${(err as Error).message}`, { cause: err });
}
return words;
}
/**
* Why `words` cannot be a list of the language `lang` for generateWords,
* with the errors of Go wordkey.CheckList and in its order: a language
* without an alphabet here; fewer than MIN_LIST_SIZE words; a word that is
* not one word of MIN_LETTERS characters or more once normalized, that holds
* a character that checkWords refuses or one that is not a letter of the
* alphabet of `lang`; or a word that is the same as another once normalized.
* Two words such as «papa» and «papá» would be one word with less entropy
* than the list promises.
*/
export async function checkWordList(lang: string, words: readonly string[]): Promise<void> {
if (!Object.hasOwn(ALPHABETS, lang)) throw new Error(`no alphabet for the language ${goQuote(lang)}`);
const alphabet = new Set(ALPHABETS[lang]);
if (words.length < MIN_LIST_SIZE) throw new Error(`${words.length} words, fewer than ${MIN_LIST_SIZE}`);
const { normalize, hidden } = await wordRules();
const seen = new Map<string, string>();
for (const [i, w] of words.entries()) {
const n = normalize(w);
if (n.length !== 1 || [...n[0]!].length < MIN_LETTERS) throw new Error(`line ${i + 1}, ${goQuote(w)}, is not one word of ${MIN_LETTERS} or more letters`);
const problem = hidden(n[0]!);
if (problem !== undefined) throw new Error(`line ${i + 1}: ${problem}`);
for (const ch of w) {
if (!alphabet.has(ch)) throw new Error(`line ${i + 1}, ${goQuote(w)}, holds U+${hex4(ch.codePointAt(0)!)}, which is not in the alphabet of ${goQuote(lang)}`);
}
const prev = seen.get(n[0]!);
if (prev !== undefined) throw new Error(`line ${i + 1}, ${goQuote(w)}, is the same word as ${goQuote(prev)} once normalized`);
seen.set(n[0]!, w);
}
}
/**
* `n` different words of `list`, drawn uniformly with `random`,
* crypto.getRandomValues by default, as Go wordkey.Generate: n must be
* MIN_WORDS or more, and at most half the list. Each word adds
* log2(list.length) bits, a little less for each word already drawn
* (wordBits).
*/
export function generateWords(list: readonly string[], n: number = DEFAULT_WORD_COUNT, random: RandomWords = cryptoWords()): string[] {
if (!Number.isSafeInteger(n) || n < MIN_WORDS) throw new Error(`wordkey: a key of words needs at least ${MIN_WORDS} words, not ${n}`);
if (n > Math.floor(list.length / 2)) throw new Error(`wordkey: ${n} words of a list of ${list.length}`);
const picked = new Set<number>();
const words: string[] = [];
while (words.length < n) {
const i = randomIndex(list.length, random);
if (picked.has(i)) continue;
picked.add(i);
words.push(list[i]!);
}
return words;
}
/**
* The strength of `count` words that generateWords draws from a list of
* `size` words, as Go wordkey.Bits: log2 of the number of draws in order,
* size·(size−1)·…, which whoever knows the list must search, before the
* rounds of PBKDF2.
*/
export function wordBits(size: number, count: number): number {
let bits = 0;
for (let i = 0; i < count; i++) bits += Math.log2(size - i);
return bits;
}
/**
* The size of a list that dice draw from: five dice, each from 1 to 6, give
* 6^5 positions. Whoever does not trust the random numbers of a computer
* rolls them, and looks the words up in the list numbered for dice
* (diceList).
*/
export const DICE_LIST_SIZE = 7776;
const diceSize = (list: readonly string[]): void => {
if (list.length !== DICE_LIST_SIZE) throw new Error(`wordkey: dice draw from a list of ${DICE_LIST_SIZE} words, not ${list.length}`);
};
/**
* The dice of the word at position `i` of a list of DICE_LIST_SIZE words, as
* Go wordkey.DiceNumber: five digits from 1 to 6, each one more than a digit
* of `i` in base 6, the first the most significant, so that 11111 is the
* first word and 66666 the last, as in the list of the EFF.
*/
export function diceNumber(i: number): string {
if (!Number.isSafeInteger(i) || i < 0 || i >= DICE_LIST_SIZE) throw new Error(`wordkey: no dice give position ${i} of a list of ${DICE_LIST_SIZE} words`);
let dice = '';
for (let k = 0; k < 5; k++, i = Math.floor(i / 6)) dice = String((i % 6) + 1) + dice;
return dice;
}
/** The word of `list` that the dice give, as Go wordkey.DiceWord: five digits from 1 to 6, numbered as diceNumber numbers them. */
export function diceWord(list: readonly string[], dice: string): string {
diceSize(list);
if (!/^[1-6]{5}$/.test(dice)) throw new Error(`wordkey: ${goQuote(dice)} is not five dice: five digits from 1 to 6`);
let i = 0;
for (const c of dice) i = i * 6 + (c.charCodeAt(0) - 0x31);
return list[i]!;
}
/**
* The words that dice give, in their order, as Go wordkey.DiceWords: one
* number of five dice for each word, separated by white space, at least
* MIN_WORDS of them, and never the same word twice, which whoever rolls rolls
* again. Fair dice draw each word as generateWords does, so the words are as
* strong.
*/
export function diceWords(list: readonly string[], dice: string): string[] {
diceSize(list);
const numbers = goFields(dice);
if (numbers.length < MIN_WORDS) throw new Error(`wordkey: a key of words needs at least ${MIN_WORDS} words, not ${numbers.length}`);
const words: string[] = [];
for (const n of numbers) {
const w = diceWord(list, n);
if (words.includes(w)) throw new Error(`wordkey: the dice ${n} give ${goQuote(w)} a second time; roll them again`);
words.push(w);
}
return words;
}
/**
* `list` numbered for dice, to print it, as Go wordkey.DiceList: a line for
* each word, its dice, a tab and the word, as the EFF publishes its list.
* For the English list it is the file of the EFF, byte for byte.
*/
export function diceList(list: readonly string[]): string {
diceSize(list);
return list.map((w, i) => `${diceNumber(i)}\t${w}\n`).join('');
}

@ -39,7 +39,16 @@ 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 { encodeAuthorSignatureItem, encodeSealItem, encodeSecurity, encodeSecurityWith, evaluateSecurity, SEAL_TYPE_RFC3161, type Verdict } from './security.ts';
import {
encodeAuthorSignatureItem,
encodeSealItem,
encodeSecurity,
encodeSecurityWith,
evaluateSecurity,
SEAL_TYPE_RFC3161,
type Verdict,
type Verdicts,
} from './security.ts';
import { type Detail, encodeSigners } from './securitycms.ts';
import { timeRecipient } from './tlock.ts';
import { checkWords, wordKey } from './wordkey.ts';
@ -218,6 +227,14 @@ export interface Encrypted {
* paths, with their layout and SHA-256, and the comment as written.
*/
readonly head?: Head;
/**
* The verdicts of the security area that encryptFiles wrote, as a reader of
* this capsule finds them; undefined for encrypt. A valid seal that does
* not prove that it came before the opening date, S5 or the line of a
* signer of F6 with a reason, is written all the same: the writer warns of
* it, and offers to ask another authority (spec v0.16, §62.1 rule 19).
*/
readonly security?: Verdicts;
/** The size of the .dkc. */
readonly size: number;
/** The .dkc, only without an output. */
@ -367,11 +384,13 @@ export async function writeFiles(files: readonly FileSource[], opts: EncryptOpti
// 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.
// depend on L, and returns the final L, keeping the verdicts of the area
// it built last for the result (§62.1 rule 19).
let security: Uint8Array = new Uint8Array(0);
let verdicts: Verdicts | undefined;
let frame: Uint8Array = new Uint8Array(0);
const prepare = async (c: Control): Promise<number> => {
security = await securityArea(s, c, headBytes);
({ security, verdicts } = 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;
@ -393,7 +412,7 @@ export async function writeFiles(files: readonly FileSource[], opts: EncryptOpti
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 };
return { ...res, head: final, security: verdicts! };
});
}
@ -402,9 +421,11 @@ export async function writeFiles(files: readonly FileSource[], opts: EncryptOpti
// 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> {
// reader, in the context of this capsule (§62.1 rules 17, 19 and 21), and
// returns the verdicts too, so that the writer warns of a seal that does not
// prove that it came before the opening date (rule 19). The caller decides the
// area from its length.
async function securityArea(s: SealState, c: Control, head: Uint8Array): Promise<{ security: Uint8Array; verdicts: Verdicts }> {
const { authorKey, cmsSigner, sealer } = s;
if (authorKey === undefined && cmsSigner === undefined && sealer === undefined) {
const security = encodeSecurity();
@ -413,7 +434,7 @@ async function securityArea(s: SealState, c: Control, head: Uint8Array): Promise
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`);
}
return security;
return { security, verdicts: v };
}
const hd = headDigest(head);
const cc = controlCommit(c, FORMAT_3);
@ -462,13 +483,14 @@ async function securityArea(s: SealState, c: Control, head: Uint8Array): Promise
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.
// differ, or the token may carry no accuracy. Encrypted.security lets the
// caller warn of it (§62.1 rule 19).
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;
return { security, verdicts: v };
}
// What a hook returned, which must be bytes: a copy, which the hook cannot

@ -0,0 +1,21 @@
// Tests of annex.ts: the annex that /create offers is the copy of the one of
// datekeys-go, and its file is named after the capsule as Go names it.
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { ANNEX_URL, annexName, RECOVERY_ANNEX_SUFFIX } from './annex.ts';
describe('the recovery annex', () => {
it('is annex/recovery.md, §79 of the specification', () => {
expect(ANNEX_URL).toMatch(/annex\/recovery\.md$/);
const text = readFileSync(fileURLToPath(new URL('../../../annex/recovery.md', import.meta.url)), 'utf8');
expect(text.startsWith('# Cómo abrir una cápsula DateKeys sin software de DateKeys\n')).toBe(true);
expect(text).toContain('## 79. Anexo informativo: recuperación sin software DateKeys');
});
it('is saved next to the .dkc, as Go datekeys.RecoveryAnnexSuffix names it', () => {
expect(RECOVERY_ANNEX_SUFFIX).toBe('.recuperacion.txt');
expect(annexName('carta.dkc')).toBe('carta.dkc.recuperacion.txt');
});
});

@ -0,0 +1,20 @@
/// <reference types="vite/client" />
// The recovery annex that /create offers next to a capsule (spec §62.1, rule
// 27): annex/recovery.md, a copy of the one of datekeys-go, §79 of the
// specification on how to open a capsule without DateKeys software. Vite
// ships it as a same-origin file with a hashed name, never inlined
// (vite.config.ts); the page links to it for download. It is the same for
// every capsule and holds nothing of one.
import annexURL from '../../../annex/recovery.md?url';
/** The same-origin URL of the annex. */
export const ANNEX_URL: string = annexURL;
/** What is appended to the name of the .dkc to name the file of its annex, as Go datekeys.RecoveryAnnexSuffix. */
export const RECOVERY_ANNEX_SUFFIX = '.recuperacion.txt';
/** The name of the file of the annex of the capsule saved as `dkcName`: carta.dkc.recuperacion.txt. */
export function annexName(dkcName: string): string {
return `${dkcName}${RECOVERY_ANNEX_SUFFIX}`;
}

@ -2,6 +2,8 @@
// field in its order, and what the page shows before encrypting: the files,
// measured once, the comment and the declared author, and the size.
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { parseDateKey } from '../dkc/datekey.ts';
import { TIME_AND_KEY, TIME_ONLY } from '../dkc/header.ts';
@ -12,7 +14,7 @@ import { formatX25519Recipient } from '../dkc/recipient.ts';
import { recipientKeys, sampleIdentity } from '../dkc/testing/interop.ts';
import { x25519PublicKey } from '../dkc/x25519.ts';
import { formatByteCount } from './format.ts';
import { chooseFiles, type CreateInput, defaultFileNames, downloadName, planCapsule, readRecipients, SOON_MS } from './create-input.ts';
import { chooseFiles, type CreateInput, defaultFileNames, downloadName, planCapsule, readDice, readRecipients, SOON_MS } from './create-input.ts';
// Round 1000 of Quicknet opens at 2023-08-23T15:59:24Z.
const ROUND_1000_MS = Date.UTC(2023, 7, 23, 15, 59, 24);
@ -27,10 +29,14 @@ const input = (extra: Partial<CreateInput> = {}): CreateInput => ({
policy: TIME_ONLY,
recipients: '',
portable: true,
wordsKind: 'none',
words: '',
dice: '',
wordsAgain: '',
...extra,
});
// The Spanish list of datekeys-go, whose words the dice give.
const es = readFileSync(fileURLToPath(new URL('../../../wordlists/es.txt', import.meta.url)), 'utf8').slice(0, -1).split('\n');
const recipient = (i: number): string => formatX25519Recipient(x25519PublicKey(sampleIdentity(i)));
const problem = (extra: Partial<CreateInput>, nowMs = GENESIS_MS): [string, string] => {
const r = planCapsule(input(extra), nowMs);
@ -199,37 +205,104 @@ describe('planCapsule', () => {
const many = `Una cápsula admite como mucho 16 llaves, contando el fichero .dkk y las palabras.`;
expect(problem({ policy: TIME_AND_KEY, recipients: lines(16), portable: true })).toEqual(['recipients', `${many} Hay 16 personas.`]);
expect(problem({ policy: TIME_AND_KEY, recipients: lines(17), portable: false })).toEqual(['recipients', `${many} Hay 17 personas.`]);
expect(problem({ policy: TIME_AND_KEY, recipients: lines(15), portable: true, words: 'perro luna casa verde tren mar', wordsAgain: 'perro luna casa verde tren mar' })).toEqual(['recipients', `${many} Hay 15 personas.`]);
expect(problem({ policy: TIME_AND_KEY, recipients: lines(15), portable: true, wordsKind: 'own', words: 'perro luna casa verde tren mar', wordsAgain: 'perro luna casa verde tren mar' })).toEqual(['recipients', `${many} Hay 15 personas.`]);
expect(problem({ policy: TIME_AND_KEY, recipients: `${recipient(1)}\nage1nope`, portable: true })).toEqual([
'recipients',
'La línea 2 no es un destinatario de age (age1…).',
]);
expect(problem({ policy: TIME_AND_KEY, recipients: '# nobody', portable: false })).toEqual([
'portable',
'Sin personas ni palabras, el fichero de la llave es la única forma de abrir la cápsula: déjalo marcado, escribe tus palabras o añade una persona.',
'Sin personas ni palabras, el fichero de la llave es la única forma de abrir la cápsula: déjalo marcado, ábrela también con unas palabras o añade una persona.',
]);
// Words are a key of their own, of at least six different words of three
// letters or more, written twice, and with nothing a person cannot see.
const typed = ' Perro LUNA casa verde trén mar ';
const worded = planCapsule(input({ policy: TIME_AND_KEY, portable: false, words: typed, wordsAgain: 'perro luna casa verde tren mar' }), GENESIS_MS);
const worded = planCapsule(input({ policy: TIME_AND_KEY, portable: false, wordsKind: 'own', words: typed, wordsAgain: 'perro luna casa verde tren mar' }), GENESIS_MS);
expect(worded.ok && [worded.plan.words, worded.plan.wordsText]).toEqual([['perro', 'luna', 'casa', 'verde', 'tren', 'mar'], typed]);
const few = 'Escribe al menos 6 palabras distintas de 3 letras o más: con menos, cualquiera puede adivinarlas.';
expect(problem({ policy: TIME_AND_KEY, words: 'uno dos tres' })).toEqual(['words', few]);
expect(problem({ policy: TIME_AND_KEY, words: 'de la casa al mar en tren verde' })).toEqual(['words', few]);
expect(problem({ policy: TIME_AND_KEY, words: 'perro luna casa verde tren mar', wordsAgain: 'perro luna' })).toEqual([
expect(problem({ policy: TIME_AND_KEY, wordsKind: 'own', words: 'uno dos tres' })).toEqual(['words', few]);
expect(problem({ policy: TIME_AND_KEY, wordsKind: 'own', words: 'de la casa al mar en tren verde' })).toEqual(['words', few]);
expect(problem({ policy: TIME_AND_KEY, wordsKind: 'own', words: 'perro luna casa verde tren mar', wordsAgain: 'perro luna' })).toEqual([
'wordsAgain',
'Escribe otra vez las mismas palabras, para comprobar que las recuerdas.',
]);
expect(problem({ policy: TIME_AND_KEY, words: `perro luna casa verde tren mar${String.fromCodePoint(0x200b)}` })).toEqual([
expect(problem({ policy: TIME_AND_KEY, wordsKind: 'own', words: `perro luna casa verde tren mar${String.fromCodePoint(0x200b)}` })).toEqual([
'words',
'Las palabras llevan un carácter que no se ve (U+200B): escríbelas a mano, sin pegarlas.',
]);
const timeOnly = planCapsule(input({ words: 'uno dos' }), GENESIS_MS);
const timeOnly = planCapsule(input({ wordsKind: 'own', words: 'uno dos' }), GENESIS_MS);
expect(timeOnly.ok && [timeOnly.plan.words, timeOnly.plan.wordsText]).toEqual([[], '']);
// time_only ignores both.
const plain = planCapsule(input({ recipients: 'not a recipient', portable: true }), GENESIS_MS);
expect(plain.ok && [plain.plan.recipients, plain.plan.portable]).toEqual([[], false]);
});
it('plans a key of random words, written again with or without their accents, and asks for the words it lacks', () => {
const drawn = 'abadía jovial deliberar cigüeña útil niño ventana';
const r = planCapsule(input({ policy: TIME_AND_KEY, wordsKind: 'random', words: drawn, wordsAgain: 'ABADIA jovial deliberar ciguena util nino ventana' }), GENESIS_MS);
expect(r.ok && [r.plan.words, r.plan.wordsText]).toEqual([['abadia', 'jovial', 'deliberar', 'ciguena', 'util', 'nino', 'ventana'], drawn]);
expect(problem({ policy: TIME_AND_KEY, wordsKind: 'random', words: drawn, wordsAgain: 'jovial abadía deliberar cigüeña útil niño ventana' })).toEqual([
'wordsAgain',
'Escribe las palabras de la lista, en su orden, para comprobar que las tienes apuntadas.',
]);
// The list has not loaded yet, or the person has written nothing.
expect(problem({ policy: TIME_AND_KEY, wordsKind: 'random' })).toEqual(['words', 'Las palabras al azar aún no están listas.']);
expect(problem({ policy: TIME_AND_KEY, wordsKind: 'own', words: ' ' })).toEqual(['words', 'Escribe tus palabras: al menos 6 distintas de 3 letras o más.']);
// Without a key of words, the words of the form are not read.
const none = planCapsule(input({ policy: TIME_AND_KEY, wordsKind: 'none', words: 'uno', wordsAgain: 'dos' }), GENESIS_MS);
expect(none.ok && [none.plan.words, none.plan.wordsText]).toEqual([[], '']);
});
it('plans a key of words of dice, the words that the list gives to their numbers, and asks for the numbers it lacks', () => {
const dice = ' 35214 11111\t64253 11112 11113 66666 11121 ';
const keyed = (extra: Partial<CreateInput>) => input({ policy: TIME_AND_KEY, wordsKind: 'dice', dice, diceList: es, ...extra });
const r = planCapsule(keyed({ wordsAgain: 'Glaciar abad tocar abadia abandonar util abdomen' }), GENESIS_MS);
expect(r.ok && [r.plan.words, r.plan.wordsText]).toEqual([
['glaciar', 'abad', 'tocar', 'abadia', 'abandonar', 'util', 'abdomen'],
'glaciar abad tocar abadía abandonar útil abdomen',
]);
expect(problem(keyed({ diceList: undefined }))).toEqual(['words', 'La lista de palabras aún no está lista.']);
expect(problem(keyed({ dice: '35214 11111 64253 11112 11113' }))).toEqual(['words', 'Escribe al menos 6 números de cinco dados, uno por palabra.']);
expect(problem(keyed({ dice: '' }))).toEqual(['words', 'Escribe al menos 6 números de cinco dados, uno por palabra.']);
expect(problem(keyed({ dice: '35214 11111 64253 11112 11113 66667' }))).toEqual(['words', '«66667» no vale: cada palabra son cinco dados, cinco cifras del 1 al 6.']);
expect(problem(keyed({ dice: '35214 11111 64253 11112 11113 35214' }))).toEqual(['words', '«35214» da otra vez «glaciar»: vuelve a tirar esos dados.']);
expect(problem(keyed({ wordsAgain: 'abad glaciar tocar abadía abandonar útil abdomen' }))).toEqual([
'wordsAgain',
'Escribe las palabras de la lista, en su orden, para comprobar que las tienes apuntadas.',
]);
});
});
describe('planCapsule and the registry of profiles', () => {
it('writes no capsule with a profile that is not active (§71)', () => {
const q = '4147645109798ecbc9f630c2f709835bb5846fe7911a6bd5c5755e25ade2ada4';
expect(planCapsule(input(), GENESIS_MS, { [q]: 'active' }).ok).toBe(true);
const plan = (status: 'read-only' | 'compromised') => {
const r = planCapsule(input(), GENESIS_MS, { [q]: status });
return r.ok ? undefined : [r.field, r.problem];
};
expect(plan('read-only')).toEqual([
'date',
'DateKeys ya no escribe cápsulas con la red Quicknet de drand: su registro de perfiles la tiene en «solo lectura» (§71). Las cápsulas que ya existen se siguen abriendo.',
]);
expect(plan('compromised')).toEqual([
'date',
'DateKeys ya no escribe cápsulas con la red Quicknet de drand: su registro de perfiles la tiene por comprometida (§71). Las cápsulas que ya existen se siguen abriendo, pero su contenido pudo leerse antes de la fecha.',
]);
});
});
describe('readDice', () => {
it('reads each number of five dice with its word, or why it gives none', () => {
expect(readDice(` 35214 1111\u200b1\n11111 35214 66666 `, es)).toEqual([
{ dice: '35214', word: 'glaciar' },
{ dice: `1111\u200b1`, problem: '«1111\\u200b1» no vale: cada palabra son cinco dados, cinco cifras del 1 al 6.' },
{ dice: '11111', word: 'abad' },
{ dice: '35214', word: 'glaciar', problem: '«35214» da otra vez «glaciar»: vuelve a tirar esos dados.' },
{ dice: '66666', word: 'útil' },
]);
expect(readDice(' ', es)).toEqual([]);
});
});
describe('readRecipients', () => {

@ -13,11 +13,12 @@ import { MAX_HEAD_LEN } from '../dkc/body.ts';
import { utf8Length } from '../dkc/bytes.ts';
import { bodyLengthOf, capsuleLength, headComment, headLengthOf, type MeasuredFiles, measureFiles } from '../dkc/lengths.ts';
import { MAX_PAYLOAD_LENGTH, paddedLength, REFORZADO } from '../dkc/padding.ts';
import { quicknet } from '../dkc/profile.ts';
import { type ProfileStatus, profileStatusOf, QUICKNET_PROFILE_HASH, quicknet } from '../dkc/profile.ts';
import { parseRecipientList, type RecipientLineProblem, RecipientListError } from '../dkc/recipient.ts';
import { MAX_CAPSULE_FILES } from './create-files.ts';
import { formatByteCount, formatInteger, safeFileName } from './format.ts';
import { countedWords, hiddenCodePoint, MIN_LETTERS, MIN_WORDS, quickWords } from '../dkc/wordkey.ts';
import { escapeInvisible, formatByteCount, formatInteger, safeFileName } from './format.ts';
import { countedWords, goFields, hiddenCodePoint, MIN_LETTERS, MIN_WORDS, quickWords } from '../dkc/wordkey.ts';
import { diceWord } from '../dkc/wordlist.ts';
import { localToEpochMs } from './localtime.ts';
// The limits of the texts of a head, as pathrule.ts has them: that module
@ -31,6 +32,9 @@ export const SOON_MS = 3600_000;
/** The inputs of the form. */
export type CreateField = 'files' | 'comment' | 'author' | 'date' | 'time' | 'zone' | 'recipients' | 'portable' | 'words' | 'wordsAgain';
/** The key of words that the person asks for: none, words drawn at random from a list (wordlist.ts), words of dice, or their own. */
export type WordsKind = 'none' | 'random' | 'dice' | 'own';
/** A file of the capsule as encryptFiles takes it: its path, its size and its mtime in milliseconds (File.lastModified). */
export interface PlannedFile {
readonly path: string;
@ -66,9 +70,14 @@ export interface CreateInput {
/** For time_and_key: the recipients, age1… one per line, and whether to generate a portable .dkk. */
readonly recipients: string;
readonly portable: boolean;
/** For time_and_key: the words of a key of words (wordkey.ts), '' for none. */
/** For time_and_key: whether the capsule opens with a key of words too (wordkey.ts), and whose words. */
readonly wordsKind: WordsKind;
/** The words of that key: those drawn, joined by spaces, or those the person typed. */
readonly words: string;
/** The words written again, to check that the person remembers them. */
/** For words of dice: the numbers of five dice that the person typed, and the list they give words of, once loaded. */
readonly dice: string;
readonly diceList?: readonly string[] | undefined;
/** The words written again, to check that the person has them. */
readonly wordsAgain: string;
}
@ -125,6 +134,30 @@ const LINE_PROBLEMS: Readonly<Record<RecipientLineProblem, string>> = {
duplicate: 'repite un destinatario de una línea anterior.',
};
/** A number of five dice as the form reads it: its word, or why it gives none. */
export interface DiceRoll {
readonly dice: string;
readonly word?: string;
readonly problem?: string;
}
/**
* The numbers of five dice of `text`, separated by white space as Go
* wordkey.DiceWords separates them, each with its word of `list` (diceWord),
* or its problem: not five digits from 1 to 6, or a word that an earlier
* number already gave. The page shows each word as it is typed.
*/
export function readDice(text: string, list: readonly string[]): DiceRoll[] {
const seen = new Set<string>();
return goFields(text).map((dice) => {
if (!/^[1-6]{5}$/.test(dice)) return { dice, problem: `«${escapeInvisible(dice)}» no vale: cada palabra son cinco dados, cinco cifras del 1 al 6.` };
const word = diceWord(list, dice);
if (seen.has(word)) return { dice, word, problem: `«${dice}» da otra vez «${word}»: vuelve a tirar esos dados.` };
seen.add(word);
return { dice, word };
});
}
/**
* The recipients of the text of the form, or the problem of its first bad
* line, by number and never by content (recipient.ts).
@ -141,9 +174,11 @@ export function readRecipients(text: string): { ok: true; keys: Uint8Array[] } |
/**
* The capsule that the form asks for at `nowMs`, or the first problem, in
* the order of the form. The requested instant must be after `nowMs` (§62.1,
* rule 2), and the page checks it again with the clock when encrypting.
* rule 2), and the page checks it again with the clock when encrypting. The
* capsule is written with the pinned Quicknet profile only while `statuses`,
* the registry of §71 as this release knows it, has it active.
*/
export function planCapsule(input: CreateInput, nowMs: number): Planned {
export function planCapsule(input: CreateInput, nowMs: number, statuses?: Readonly<Record<string, ProfileStatus>>): Planned {
const fail = (field: CreateField, problem: string): Planned => ({ ok: false, field, problem });
const { list, measured } = input.files;
// A comment or an author over its limit is the problem of its own field
@ -168,6 +203,15 @@ export function planCapsule(input: CreateInput, nowMs: number): Planned {
if (length > MAX_PAYLOAD_LENGTH) {
return fail('files', `Los ficheros ocupan más de ${formatByteCount(MAX_PAYLOAD_LENGTH - (length - measured.content))}, el máximo de una cápsula.`);
}
const status = profileStatusOf(QUICKNET_PROFILE_HASH, statuses).status;
if (status !== 'active') {
return fail(
'date',
status === 'read-only'
? 'DateKeys ya no escribe cápsulas con la red Quicknet de drand: su registro de perfiles la tiene en «solo lectura» (§71). Las cápsulas que ya existen se siguen abriendo.'
: 'DateKeys ya no escribe cápsulas con la red Quicknet de drand: su registro de perfiles la tiene por comprometida (§71). Las cápsulas que ya existen se siguen abriendo, pero su contenido pudo leerse antes de la fecha.',
);
}
if (input.date === '') return fail('date', 'Elige el día de apertura.');
if (input.time === '') return fail('time', 'Elige la hora de apertura.');
const local = localToEpochMs(input.date, input.time, input.timeZone);
@ -202,14 +246,29 @@ export function planCapsule(input: CreateInput, nowMs: number): Planned {
let recipients: Uint8Array[] = [];
let portable = false;
let words: string[] = [];
// The words of the key: those drawn or typed, or the words of the dice.
let text = '';
if (policy === TIME_AND_KEY) {
const read = readRecipients(input.recipients);
if (!read.ok) return fail('recipients', read.problem);
recipients = read.keys;
portable = input.portable;
words = quickWords(input.words);
const kind = input.wordsKind;
text = input.words;
if (kind === 'dice') {
if (input.diceList === undefined) return fail('words', 'La lista de palabras aún no está lista.');
const rolls = readDice(input.dice, input.diceList);
const bad = rolls.find((r) => r.problem !== undefined);
if (bad !== undefined) return fail('words', bad.problem!);
if (rolls.length < MIN_WORDS) return fail('words', `Escribe al menos ${MIN_WORDS} números de cinco dados, uno por palabra.`);
text = rolls.map((r) => r.word).join(' ');
}
words = kind === 'none' ? [] : quickWords(text);
if (kind !== 'none' && words.length === 0) {
return fail('words', kind === 'random' ? 'Las palabras al azar aún no están listas.' : `Escribe tus palabras: al menos ${MIN_WORDS} distintas de ${MIN_LETTERS} letras o más.`);
}
if (words.length > 0) {
const hidden = hiddenCodePoint(input.words);
const hidden = hiddenCodePoint(text);
if (hidden !== undefined) {
const name = `U+${hidden.toString(16).toUpperCase().padStart(4, '0')}`;
return fail('words', `Las palabras llevan un carácter que no se ve (${name}): escríbelas a mano, sin pegarlas.`);
@ -219,14 +278,19 @@ export function planCapsule(input: CreateInput, nowMs: number): Planned {
}
const again = quickWords(input.wordsAgain);
if (again.length !== words.length || again.some((w, i) => w !== words[i])) {
return fail('wordsAgain', 'Escribe otra vez las mismas palabras, para comprobar que las recuerdas.');
return fail(
'wordsAgain',
kind === 'own'
? 'Escribe otra vez las mismas palabras, para comprobar que las recuerdas.'
: 'Escribe las palabras de la lista, en su orden, para comprobar que las tienes apuntadas.',
);
}
}
const keys = recipients.length + (portable ? 1 : 0) + (words.length > 0 ? 1 : 0);
if (keys === 0) {
return fail(
'portable',
'Sin personas ni palabras, el fichero de la llave es la única forma de abrir la cápsula: déjalo marcado, escribe tus palabras o añade una persona.',
'Sin personas ni palabras, el fichero de la llave es la única forma de abrir la cápsula: déjalo marcado, ábrela también con unas palabras o añade una persona.',
);
}
if (keys > ACCESS_SLOTS) {
@ -254,7 +318,7 @@ export function planCapsule(input: CreateInput, nowMs: number): Planned {
recipients,
portable,
words,
wordsText: words.length > 0 ? input.words : '',
wordsText: words.length > 0 ? text : '',
files: list,
comment: headComment(input.comment),
author: input.author,

@ -0,0 +1,57 @@
// Tests of create-words.ts: the list of the page, fetched once, with its
// pinned SHA-256, and fetched again after a failure.
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { type Fetcher, wordListLoader, WORDS_LANGUAGE } from './create-words.ts';
const list = new Uint8Array(readFileSync(fileURLToPath(new URL('../../../wordlists/es.txt', import.meta.url))));
describe('wordListLoader', () => {
it('fetches the Spanish list of the site once, and reads it with its pinned SHA-256', async () => {
const urls: string[] = [];
const load = wordListLoader(async (url) => {
urls.push(url);
return new Response(list.slice());
});
const [a, b] = await Promise.all([load(), load()]);
expect(WORDS_LANGUAGE).toBe('es');
expect(a).toHaveLength(7776);
expect(b).toBe(a);
expect(await load()).toBe(a);
expect(urls).toHaveLength(1);
expect(urls[0]).toMatch(/wordlists\/es\.txt$/);
});
it('tries again after a failure: an answer that is not 200, another list, a fetch that fails', async () => {
const changed = list.slice();
changed[0]! ^= 1;
const answers: (() => Promise<Response>)[] = [
async () => new Response('missing', { status: 404 }),
async () => new Response(changed),
async () => {
throw new TypeError('Failed to fetch');
},
async () => new Response(list.slice()),
];
let calls = 0;
const fetcher: Fetcher = () => answers[calls++]!();
const load = wordListLoader(fetcher);
await expect(load()).rejects.toThrow(/^the site answered 404 for the word list$/);
await expect(load()).rejects.toThrow(/^wordkey: the list "es" has the SHA-256 [0-9a-f]{64}, not ff77b487/);
await expect(load()).rejects.toThrow('Failed to fetch');
expect(await load()).toHaveLength(7776);
expect(calls).toBe(4);
});
it('fetches with the fetch of the page by default', async () => {
const real = globalThis.fetch;
globalThis.fetch = async () => new Response(list.slice());
try {
expect(await wordListLoader()()).toHaveLength(7776);
} finally {
globalThis.fetch = real;
}
});
});

@ -0,0 +1,36 @@
/// <reference types="vite/client" />
// The list of the random words of the create page: the Spanish list of
// datekeys-go, which Vite ships from wordlists/ as a same-origin file with a
// hashed name, never inlined (vite.config.ts). The page reads it when the
// person asks for random words, and takes it only with the SHA-256 that
// wordlist.ts pins and once checkWordList accepts it. The words are drawn in
// this browser and go nowhere: the page only shows them.
import listURL from '../../../wordlists/es.txt?url';
import { readWordList } from '../dkc/wordlist.ts';
/** The language of the list: Spanish, the only list of datekeys-go for now. */
export const WORDS_LANGUAGE = 'es';
/** How the page fetches a file of the site; the tests pass their own. */
export type Fetcher = (url: string) => Promise<Response>;
/**
* A loader of the list: the first call fetches it and checks it, and the
* later ones share that reading. A failure is not kept, so that the next
* call tries again.
*/
export function wordListLoader(fetcher: Fetcher = (url) => fetch(url)): () => Promise<string[]> {
let loading: Promise<string[]> | undefined;
return () => {
loading ??= (async () => {
const res = await fetcher(listURL);
if (!res.ok) throw new Error(`the site answered ${res.status} for the word list`);
return readWordList(WORDS_LANGUAGE, new Uint8Array(await res.arrayBuffer()));
})().catch((err: unknown) => {
loading = undefined;
throw err;
});
return loading;
};
}

@ -46,7 +46,7 @@ const one = (size: number, mtime?: number) => chooseFiles([{ path: 'nota.txt', s
// A plan for round 1000, whose release is published.
function plan(extra: Partial<CreateInput> = {}, nowMs = GENESIS_MS): CapsulePlan {
const r = planCapsule(
{ files: one(0), comment: '', author: '', date: '2023-08-23', time: '15:59:24', timeZone: 'UTC', policy: 0, recipients: '', portable: true, words: '', wordsAgain: '', ...extra },
{ files: one(0), comment: '', author: '', date: '2023-08-23', time: '15:59:24', timeZone: 'UTC', policy: 0, recipients: '', portable: true, wordsKind: 'none', words: '', dice: '', wordsAgain: '', ...extra },
nowMs,
);
if (!r.ok) throw new Error(r.problem);
@ -162,7 +162,7 @@ describe('createCapsule', () => {
});
it('adds the key of the words, which opens the capsule without a .dkk', async () => {
const p = plan({ files: one(4), policy: TIME_AND_KEY, portable: false, words: 'Perro luna casa verde trén mar', wordsAgain: 'perro luna casa verde tren mar' });
const p = plan({ files: one(4), policy: TIME_AND_KEY, portable: false, wordsKind: 'own', words: 'Perro luna casa verde trén mar', wordsAgain: 'perro luna casa verde tren mar' });
const c = await createCapsule({ files: [blob(content(4))], plan: p, cancelled: () => false, now: genesis });
expect(c.dkk).toBeUndefined();
// Salted with the capsule_id that the writer drew (§38.1).

@ -67,11 +67,32 @@ describe('fetchRelease', () => {
'api3.drand.sh': new TypeError('blocked'),
};
await expect(fetchRelease(CHAIN, ROUND, relays(odd))).rejects.toThrow(
'Ningún relay de drand dio la firma: api.drand.sh respondió algo que no es una firma; api2.drand.sh dio la ronda undefined, no la 32668196; api3.drand.sh no se pudo conectar.',
'Ningún relay de drand dio la firma: api.drand.sh respondió algo que no es una firma; api2.drand.sh respondió algo que no es una firma; api3.drand.sh no se pudo conectar.',
);
});
// The client of the reference reads the body with json.Unmarshal, which refuses a byte order mark before the JSON.
// Spec v0.16, §47.1: the client of the reference reads the answers of the relays with the strict reader of drand's
// JSON, as a release that the caller gives: a name twice is malformed, and ROUND is another name, ignored.
it('reads an answer with the strict reader of drand JSON', async () => {
const text = (body: string): Response => new Response(body, { status: 200 });
const answers: Record<string, Answer> = {
'api.drand.sh': text(`{"round":${ROUND},"round":${ROUND},"signature":"${SIG}"}`),
'api2.drand.sh': text(`{"round":${ROUND},"signature":""}`),
'api3.drand.sh': text(`{"round":${ROUND},"signature":"${SIG}","randomness":null}`),
};
await expect(fetchRelease(CHAIN, ROUND, relays(answers))).rejects.toThrow(
'Ningún relay de drand dio la firma: api.drand.sh respondió algo que no es una firma; api2.drand.sh dio una firma que no es hexadecimal; api3.drand.sh respondió algo que no es una firma.',
);
const other = await fetchRelease(
CHAIN,
ROUND,
relays({ 'api.drand.sh': text(`{"\\u0072ound":${ROUND},"ROUND":${ROUND + 1},"signature":"${SIG}"}`), 'api2.drand.sh': 'hang', 'api3.drand.sh': 'hang' }),
);
expect(other).toEqual({ round: ROUND, signature: SIG, relay: 'https://api.drand.sh' });
});
// The client of the reference reads the body with provider.ParseDrandJSON, for which a byte order mark before the JSON
// is not a space of JSON.
it('refuses an answer that starts with a byte order mark, as the client of the reference does', async () => {
const body = new TextEncoder().encode(JSON.stringify({ round: ROUND, signature: SIG, randomness: RANDOMNESS }));
const bom = new Response(new Uint8Array([0xef, 0xbb, 0xbf, ...body]), { status: 200 });

@ -2,10 +2,15 @@
// person asks for it: the one connection the page makes to another site.
// The relays are those of the CLI of the reference, raced as its client
// races them (provider/drand/client.go): the same path, a timeout of 6 s, at
// most 8 KiB an answer, no redirects, and the randomness checked against the
// signature. The signature itself is verified in step 10 with the pinned
// public key, so a relay cannot make the page accept a false one. A relay
// sees the address of the person and the round asked for.
// most 8 KiB an answer, no redirects, and the answer read as that client
// reads it since spec v0.16, with the strict reader of drand's JSON
// (releaseobject.ts, §47.1), the randomness checked against the signature.
// The signature itself is verified in step 10 with the pinned public key, so
// a relay cannot make the page accept a false one. A relay sees the address
// of the person and the round asked for.
import { toHex } from '../dkc/bytes.ts';
import { parseDrandJSON, type Release } from '../dkc/releaseobject.ts';
/** The relays the page may reach, as the Content-Security-Policy lists them. */
export const RELAYS = ['https://api.drand.sh', 'https://api2.drand.sh', 'https://api3.drand.sh'] as const;
@ -55,20 +60,18 @@ export async function fetchRelease(chainHash: string, round: number, fetcher: ty
}
if (res.status === 404) throw new RelayError('aún no la ha publicado', true);
if (res.status !== 200) throw new RelayError(`respondió HTTP ${res.status}`);
// The answer is read as a release that the person gives, with the strict
// rules of spec v0.16, §47.1, randomness included; then its round.
const body = await bounded(res);
let wire: unknown;
let release: Release;
try {
wire = JSON.parse(body);
} catch {
throw new RelayError('respondió algo que no es una firma');
}
const { round: got, signature, randomness } = (typeof wire === 'object' && wire !== null ? wire : {}) as Record<string, unknown>;
if (got !== round) throw new RelayError(`dio la ronda ${String(got)}, no la ${round}`);
if (typeof signature !== 'string' || !/^(?:[0-9a-f]{2})+$/i.test(signature)) throw new RelayError('dio una firma que no es hexadecimal');
if (randomness !== undefined && (typeof randomness !== 'string' || randomness.toLowerCase() !== (await sha256Hex(signature)))) {
throw new RelayError('dio una aleatoriedad que no es la de su firma');
release = await parseDrandJSON(body);
} catch (err) {
throw new RelayError(problemOf(err as Error));
}
return { round, signature: signature.toLowerCase(), relay };
if (release.round !== round) throw new RelayError(`dio la ronda ${release.round}, no la ${round}`);
if (release.signature.length === 0) throw new RelayError('dio una firma que no es hexadecimal');
return { round, signature: toHex(release.signature), relay };
};
try {
return await Promise.any(RELAYS.map(one));
@ -82,14 +85,20 @@ export async function fetchRelease(chainHash: string, round: number, fetcher: ty
}
}
// The body of an answer as text, a leading U+FEFF kept: JSON.parse refuses it,
// as json.Unmarshal does in the client of the reference.
const BODY_TEXT = new TextDecoder('utf-8', { ignoreBOM: true });
// What the strict reader of drand's JSON found wrong in an answer, by the text
// of the reference that it gives, in the words of the page. A leading U+FEFF
// is not a space of JSON: the answer is malformed, as for the client of the
// reference.
function problemOf(err: Error): string {
if (err.message.includes('signature is not hex')) return 'dio una firma que no es hexadecimal';
if (err.message.includes('randomness does not match the signature')) return 'dio una aleatoriedad que no es la de su firma';
return 'respondió algo que no es una firma';
}
// The body of an answer, read up to MAX_RESPONSE bytes and not one more.
async function bounded(res: Response): Promise<string> {
async function bounded(res: Response): Promise<Uint8Array> {
const reader = res.body?.getReader();
if (reader === undefined) return '';
if (reader === undefined) return new Uint8Array(0);
const parts: Uint8Array[] = [];
let n = 0;
for (let r = await reader.read(); !r.done; r = await reader.read()) {
@ -106,11 +115,5 @@ async function bounded(res: Response): Promise<string> {
all.set(p, at);
at += p.length;
}
return BODY_TEXT.decode(all);
}
async function sha256Hex(hex: string): Promise<string> {
const bytes = Uint8Array.from(hex.match(/../g)!, (b) => Number.parseInt(b, 16));
const sum = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
return Array.from(sum, (b) => b.toString(16).padStart(2, '0')).join('');
return all;
}

@ -20,6 +20,8 @@ interface Record {
access_policy: string;
access_key_file?: string;
identities?: string[];
capsule_id: string;
words_text?: string;
plaintext_file: string;
plaintext_sha256: string;
format: number;
@ -311,4 +313,20 @@ describe('openCapsule with words', () => {
const elsewhere = await openCapsule(withWords('perro luna casa verde tren mar', '00'.repeat(16)));
expect(elsewhere.ok && elsewhere.opened.error !== undefined).toBe(true);
});
// Spec v0.16, annex 79.7: the official fixture of a key of words opens with its text as typed, and with its marks
// apart, to the files of its record.
it('opens format3_time_and_key_words with the text of its record', async () => {
const f = fixture('format3_time_and_key_words');
const words = { chainHash: toHex(quicknet().chainHash), round: f.record.release.round, capsuleId: f.record.capsule_id };
for (const text of [f.record.words_text!, f.record.words_text!.normalize('NFD')]) {
const r = await openCapsule({ ...f.request, words: { text, ...words } });
if (!r.ok) throw new Error(r.problem);
expect(r.opened.error).toBeUndefined();
const files = r.files!;
expect(await Promise.all(files.head.files.map(async (_, i) => toHex(await sha256(new Uint8Array(await files.file(i).arrayBuffer())))))).toEqual(
(f.record.files ?? []).map((x) => x.sha256),
);
}
});
});

@ -26,6 +26,31 @@ describe('parseReleaseText', () => {
expect(r).toEqual({ ok: true, release: { round: 5, signature: new Uint8Array([0xab]) }, form: 'json' });
});
// Spec v0.16, §47.1: the strict reader of drand's JSON, so that the page reads the round that step 10 would.
it("reads drand's JSON strictly, as step 10 reads it", () => {
const round = (s: string): number | undefined => {
const r = parseReleaseText(s, 7);
return r.ok ? r.release.round : undefined;
};
expect(round('{"\\u0072ound": 1000, "signature": "ab"}')).toBe(1000);
expect(round('{"round": 1000, "ROUND": 1001, "Round": 1002, "signature": "ab"}')).toBe(1000);
expect(round('{"round": 9007199254740991, "signature": "ab"}')).toBe(9007199254740991);
const problem = (s: string): string => {
const r = parseReleaseText(s, 1000);
if (r.ok) throw new Error(`accepted ${s}`);
return r.problem;
};
expect(problem('{"round": 1000, "round": 1001, "signature": "ab"}')).toMatch(/no es JSON válido/);
expect(problem('{"round": 1000, "\\u0072ound": 1000, "signature": "ab"}')).toMatch(/no es JSON válido/);
expect(problem('{"round": 1000, "signature": "ab", "x": {"a": 1, "a": 2}}')).toMatch(/no es JSON válido/);
expect(problem('{"round": 1000, "signature": "ab", "x": "\\ud800"}')).toMatch(/no es JSON válido/);
expect(problem('{"ROUND": 1000, "signature": "ab"}')).toMatch(/round/);
expect(problem('{"round": 0, "signature": "ab"}')).toMatch(/round/);
expect(problem('{"round": 1e3, "signature": "ab"}')).toMatch(/round/);
expect(problem('{"round": 01000, "signature": "ab"}')).toMatch(/no es JSON válido/);
expect(problem('{"round": 1000, "signature": null}')).toMatch(/signature/);
});
it('keeps a round other than the capsule one, for step 10 to reject', () => {
const r = parseReleaseText(DRAND_1000, 1001);
expect(r.ok && r.release.round).toBe(1000);

@ -5,14 +5,18 @@
// at step 10 like any release the caller supplies (plan of phase 2,
// decision 4, confirmed by the author on 28-09-2026).
//
// Of what is pasted only the round and the signature are read. drand's
// answer also carries `randomness`, and other drand endpoints carry a public
// key, a period or a chain hash: none of them is read, because the root of
// trust is the pinned profile and never a remote input (spec §11, §13).
// Of what is pasted only the round and the signature are read, with the
// strict reader of drand's JSON of the library (spec v0.16, §47.1), so that
// the page never reads another round than step 10 would: no name twice, names
// exact, the round a number of 1 to 2^53 - 1. drand's answer also carries
// `randomness`, and other drand endpoints carry a public key, a period or a
// chain hash: none of them is read, because the root of trust is the pinned
// profile and never a remote input (spec §11, §13).
//
// No noble here: this module is part of the page's initial bundle.
import { fromHex } from '../dkc/index.ts';
import { jsonRound, strictJSON } from '../dkc/releaseobject.ts';
/** A release as the caller supplies it, before any verification. */
export interface SuppliedRelease {
@ -52,22 +56,23 @@ export function parseReleaseText(text: string, round: number): ReleaseInput {
}
function fromJSON(s: string): ReleaseInput {
// JSON text that starts with { and parses is an object.
let v: object;
try {
v = JSON.parse(s) as object;
} catch {
// JSON text that starts with { and that the strict reader reads is an
// object without a name twice.
const members = strictJSON(new TextEncoder().encode(s));
if (members === undefined) {
return { ok: false, problem: 'Empieza por { pero no es JSON válido. Copia la respuesta de drand entera.' };
}
const round: unknown = Object.hasOwn(v, 'round') ? (v as { round: unknown }).round : undefined;
const signature: unknown = Object.hasOwn(v, 'signature') ? (v as { signature: unknown }).signature : undefined;
if (typeof round !== 'number' || !Number.isSafeInteger(round) || round < 0) {
const member = (name: string) => members.find((m) => m.name === name);
const r = member('round');
const round = r === undefined ? undefined : jsonRound(r);
if (round === undefined) {
return { ok: false, problem: 'El campo round falta o no es un número entero de ronda.' };
}
if (typeof signature !== 'string' || !/^[0-9a-fA-F]*$/.test(signature) || signature.length % 2 !== 0 || signature === '') {
const signature = member('signature');
if (signature?.kind !== '"' || !/^(?:[0-9a-fA-F]{2})+$/.test(signature.str)) {
return { ok: false, problem: 'El campo signature falta o no es hexadecimal.' };
}
return { ok: true, release: { round, signature: fromHex(signature) }, form: 'json' };
return { ok: true, release: { round, signature: fromHex(signature.str) }, form: 'json' };
}
/**

@ -42,6 +42,13 @@ interface FixtureRecord {
}
describe('buildReport', () => {
it('carries the state of the profile that the caller gives, from the registry of §71', () => {
const dkc = readBytes('fixtures/time_only.dkc');
const inspection = inspectWith(dkc, registry);
expect(buildReport({ fileName: 'x.dkc', bytes: dkc, size: dkc.length, inspection }).profileStatus).toBeUndefined();
expect(buildReport({ fileName: 'x.dkc', bytes: dkc, size: dkc.length, inspection, profileStatus: 'compromised' }).profileStatus).toBe('compromised');
});
it('shows every official fixture as its JSON record', () => {
for (const f of listTestdata('fixtures', '.dkc')) {
const dkc = readBytes(f);

@ -16,6 +16,7 @@ import {
inspectView,
type InspectView,
policyName,
type ProfileStatus,
STANZA_TLOCK,
type StanzaInfo,
toHex,
@ -119,6 +120,8 @@ export interface Report {
readonly readLength: number;
readonly valid: boolean;
readonly failure?: { readonly step: number; readonly code: ErrorCode; readonly message: string };
/** The state of the profile of the capsule in the registry of §71, when the caller gave it: `compromised` gets a warning. */
readonly profileStatus?: ProfileStatus;
/** Always the eight steps, in order. */
readonly steps: readonly StepRow[];
/** The view of `datekeys inspect -json`. */
@ -153,6 +156,8 @@ export interface ReportInput {
readonly inspection: Inspection;
/** The extension registry given to the inspection, if any. */
readonly extensions?: ExtensionRegistry;
/** The state of the profile of the capsule in the registry of §71 (profileStatusOf), which the caller works out from its profile_hash. */
readonly profileStatus?: ProfileStatus;
}
const hex8 = (v: number): string => `0x${v.toString(16).padStart(2, '0')}`;
@ -171,6 +176,7 @@ export function buildReport(input: ReportInput): Report {
view,
json: cliJSON(view),
};
if (input.profileStatus !== undefined) out.profileStatus = input.profileStatus;
if (r.error !== undefined) {
const last = r.checks.at(-1)!;
out.failure = { step: last.step, code: r.error.code, message: r.error.message };

@ -14,7 +14,7 @@
import { onDestroy, onMount, tick, untrack } from 'svelte';
import { beforeNavigate } from '$app/navigation';
import { resolve } from '$app/paths';
import { type Policy, SPEC_VERSION, TIME_AND_KEY, TIME_ONLY } from '$lib/dkc/index.ts';
import { type Policy, sha256, SPEC_VERSION, TIME_AND_KEY, TIME_ONLY, toHex, utf8Bytes } from '$lib/dkc/index.ts';
import {
addPicked,
droppedFiles,
@ -31,7 +31,19 @@
withPath,
} from '$lib/inspector/create-files.ts';
import { quickWords } from '$lib/dkc/wordkey.ts';
import { type CapsulePlan, chooseFiles, type CreateField, downloadName, planCapsule, readRecipients } from '$lib/inspector/create-input.ts';
import { diceList, generateWords, wordBits } from '$lib/dkc/wordlist.ts';
import {
type CapsulePlan,
chooseFiles,
type CreateField,
downloadName,
planCapsule,
readDice,
readRecipients,
type WordsKind,
} from '$lib/inspector/create-input.ts';
import { wordListLoader, WORDS_LANGUAGE } from '$lib/inspector/create-words.ts';
import { ANNEX_URL, annexName } from '$lib/inspector/annex.ts';
import { escapeInvisible, formatByteCount, formatDateTime, formatInteger, formatRelative, unexpectedProblem, viewerTimeZone } from '$lib/inspector/format.ts';
import { isTimeZone, localParts, supportedTimeZones, timeZoneList, UTC } from '$lib/inspector/localtime.ts';
import { buildReport, type Report } from '$lib/inspector/report.ts';
@ -93,8 +105,22 @@
let policy: Policy = $state(TIME_ONLY);
let recipients = $state('');
let portable = $state(true);
// The key of words: whether the capsule opens with words too, drawn at
// random by default, given by dice, or typed by the person.
let withWords = $state(false);
let wordsChoice = $state<'random' | 'dice' | 'own'>('random');
let words = $state('');
let dice = $state('');
let wordsAgain = $state('');
// The list of the words, loaded the first time the person asks for random
// words or dice, the words drawn from it in this browser, which go nowhere,
// and the SHA-256 of the list numbered for dice.
const loadWords = wordListLoader();
let wordList: readonly string[] | undefined = $state.raw();
let drawn: readonly string[] = $state.raw([]);
let listLoading = $state(false);
let listProblem: string | undefined = $state();
let diceListHash: string | undefined = $state();
// Read on mount, every second while the tab is visible and nothing is
// being written, and when the person creates the capsule: the page is
// prerendered, so never at build time.
@ -156,7 +182,19 @@
// What happened with the last files chosen or dropped, shown by the list:
// the status line only reaches screen readers.
let notice = $state('');
const planned = $derived(planCapsule({ files: chosen, comment, author, date, time, timeZone, policy, recipients, portable, words, wordsAgain }, nowMs));
const wordsKind: WordsKind = $derived(withWords ? wordsChoice : 'none');
// The numbers of the dice with their words, as they are typed.
const rolls = $derived(wordsKind === 'dice' && wordList !== undefined ? readDice(dice, wordList) : []);
const diceDone = $derived(rolls.length > 0 && rolls.every((r) => r.problem === undefined));
const wordsText = $derived(
wordsKind === 'random' ? drawn.join(' ') : wordsKind === 'dice' ? (diceDone ? rolls.map((r) => r.word).join(' ') : '') : words,
);
const planned = $derived(
planCapsule(
{ files: chosen, comment, author, date, time, timeZone, policy, recipients, portable, wordsKind, words: wordsText, dice, diceList: wordList, wordsAgain },
nowMs,
),
);
const plan = $derived(planned.ok ? planned.plan : undefined);
const commentCheck = $derived(checker?.commentProblem(comment));
const authorCheck = $derived(checker?.authorProblem(author));
@ -181,6 +219,59 @@
if (entries.length > 0 || comment !== '' || author !== '') void loadChecker().catch(() => undefined);
});
// The first time the person asks for random words, the list loads and the
// words are drawn, and for dice the list loads; after a failure, only the
// button tries again.
$effect(() => {
if (policy !== TIME_AND_KEY || listLoading || listProblem !== undefined) return;
if (wordsKind === 'random' && drawn.length === 0) void drawWords();
else if (wordsKind === 'dice' && wordList === undefined) void loadList();
});
// The list, which loads the first time and only with its pinned SHA-256,
// and the SHA-256 of the list numbered for dice; undefined after a failure,
// which listProblem tells. A list already loaded changes no state: the
// effect above calls this, and a state it reads, written while it runs,
// would run it again without end.
async function loadList(): Promise<readonly string[] | undefined> {
if (wordList !== undefined) return wordList;
listLoading = true;
listProblem = undefined;
try {
const list = await loadWords();
diceListHash = toHex(await sha256(utf8Bytes(diceList(list))));
return (wordList = list);
} catch (err) {
listProblem = unexpectedProblem('cargar la lista de palabras', err);
return undefined;
} finally {
listLoading = false;
}
}
// Draws new words from the list, once at a time: the words written to
// check the old ones no longer apply.
let drawing = false;
async function drawWords(): Promise<void> {
if (drawing) return;
drawing = true;
try {
const list = await loadList();
if (list === undefined) return;
drawn = generateWords(list);
wordsAgain = '';
} finally {
drawing = false;
}
}
// Offers the list numbered for dice, to print it and look the words up
// without the computer.
function saveDiceList(): void {
if (wordList === undefined) return;
save(new Blob([diceList(wordList)], { type: 'text/plain;charset=utf-8' }), `palabras-dados-${WORDS_LANGUAGE}.txt`);
}
// The paths are checked again at every change of the list: at once for a
// short one, and after a pause in the typing for a long one, which takes
// a fraction of a second.
@ -189,13 +280,17 @@
const list = includedEntries(entries);
if (c === undefined) return;
const run = (): void => {
pathCheck = checkList(c, list);
// The check is read from a local: reading pathCheck after writing it
// would make this effect depend on what it writes, and run again
// without end.
const check = checkList(c, list);
pathCheck = check;
const index = new Map(untrack(() => entries).map((e, i) => [e.id, i]));
const shownBefore = untrack(() => pinned);
// At most LISTED more rows: a folder whose name breaks a rule gives a
// problem to every file below it.
const room = Math.max(0, LISTED - shownBefore.size);
const beyond = [...pathCheck.problems.keys()].filter((id) => index.get(id)! >= LISTED && !shownBefore.has(id)).slice(0, room);
const beyond = [...check.problems.keys()].filter((id) => index.get(id)! >= LISTED && !shownBefore.has(id)).slice(0, room);
if (beyond.length > 0) pinned = new Set([...shownBefore, ...beyond]);
};
if (list.length <= LONG_LIST) {
@ -479,7 +574,10 @@
// reader for the paths and the texts.
nowMs = Date.now();
const files = includedEntries(entries);
const p = planCapsule({ files: chosen, comment, author, date, time, timeZone, policy, recipients, portable, words, wordsAgain }, nowMs);
const p = planCapsule(
{ files: chosen, comment, author, date, time, timeZone, policy, recipients, portable, wordsKind, words: wordsText, dice, diceList: wordList, wordsAgain },
nowMs,
);
if (!p.ok && p.field === 'files') {
await fail(p.problem, p.field);
return;
@ -641,7 +739,11 @@
announcement = '';
await tick();
announcement = message;
document.getElementById(focus ?? (field === undefined ? 'create-problem' : FIELD_IDS[field]))?.focus();
// The random words have no input: their box takes the focus; the
// numbers of the dice have their own.
const wordsInput: Partial<Record<WordsKind, string>> = { random: 'words-drawn', dice: 'dice-input' };
const id = field === 'words' ? (wordsInput[wordsKind] ?? FIELD_IDS.words) : field === undefined ? 'create-problem' : FIELD_IDS[field];
document.getElementById(focus ?? id)?.focus();
}
// What the capsule holds, in words.
@ -943,7 +1045,7 @@
<input type="radio" name="policy" value={TIME_ONLY} bind:group={policy} />
<span>
<span class="choice-title">Cualquiera que tenga la cápsula</span>
<span class="hint">Lo más sencillo: desde la fecha, basta con el fichero <code>.dkc</code>.</span>
<span class="hint">Lo más sencillo: desde la fecha, basta con el fichero <code>.dkc</code>. Para algo valioso, mejor con llave.</span>
</span>
</label>
<label class="choice">
@ -960,36 +1062,142 @@
<input id="portable-input" type="checkbox" bind:checked={portable} aria-invalid={invalid('portable')} aria-describedby={described('portable')} />
<span>Crear una llave: un fichero <code>.dkk</code> que abre esta cápsula y ninguna otra.</span>
</label>
<div class="field">
<label for="words-input">O unas palabras que solo sepas tú <span class="optional">(opcional)</span></label>
<input
id="words-input"
type="text"
bind:value={words}
autocomplete="off"
spellcheck="false"
placeholder="seis palabras o más"
aria-invalid={invalid('words')}
aria-describedby={described('words', 'words-hint')}
/>
<p id="words-hint" class="hint">
Quien las escriba al abrirla podrá abrir la cápsula, sin guardar ningún fichero. Al menos 6 distintas de 3 letras o más, y que no formen una frase
conocida: pasada la fecha, quien tenga la cápsula puede probar palabras. Dan igual mayúsculas, acentos y espacios.
</p>
</div>
{#if words.trim() !== ''}
<div class="field">
<label for="words-again-input">Escríbelas otra vez</label>
<input
id="words-again-input"
type="text"
bind:value={wordsAgain}
autocomplete="off"
spellcheck="false"
aria-invalid={invalid('wordsAgain')}
aria-describedby={described('wordsAgain', 'words-again-hint')}
/>
<p id="words-again-hint" class="hint">Se guardan así: {quickWords(words).join(' ')}</p>
<label class="check">
<input type="checkbox" bind:checked={withWords} />
<span>Abrirla también con unas palabras: quien las escriba al abrirla podrá abrir la cápsula, sin ningún fichero.</span>
</label>
{#if withWords}
<div class="words">
<div class="kinds" role="radiogroup" aria-label="Qué palabras">
<label class="kind">
<input type="radio" name="words-kind" value="random" bind:group={wordsChoice} onchange={() => (wordsAgain = '')} />
<span>Al azar</span>
</label>
<label class="kind">
<input type="radio" name="words-kind" value="dice" bind:group={wordsChoice} onchange={() => (wordsAgain = '')} />
<span>Con dados</span>
</label>
<label class="kind">
<input type="radio" name="words-kind" value="own" bind:group={wordsChoice} onchange={() => (wordsAgain = '')} />
<span>Las elijo yo</span>
</label>
</div>
{#if wordsChoice === 'random'}
<div id="words-drawn" class="drawn" tabindex="-1" aria-describedby={described('words', 'words-drawn-hint')}>
{#if drawn.length > 0}
<ol class="slip" aria-label="Las palabras, en su orden">
{#each drawn as w, i (i)}
<li>{w}</li>
{/each}
</ol>
{:else if listProblem !== undefined}
<p class="inline-problem">{listProblem}</p>
{:else}
<p class="hint">Preparando la lista de palabras…</p>
{/if}
</div>
<div class="actions">
<button class="button quiet" type="button" onclick={drawWords} disabled={listLoading}>
{drawn.length === 0 && listProblem !== undefined ? 'Volver a intentarlo' : 'Otras palabras'}
</button>
</div>
{#if wordList !== undefined && drawn.length > 0}
<p id="words-drawn-hint" class="hint">
{drawn.length} palabras al azar de una lista pública de {formatInteger(wordList.length)}, unos {Math.floor(wordBits(wordList.length, drawn.length))}
bits: nadie puede adivinarlas. Las sortea este navegador y no salen de él. Apúntalas a mano y en este orden; dan igual mayúsculas
y acentos.
</p>
{/if}
{:else if wordsChoice === 'dice'}
<div class="field">
<label for="dice-input">Los números de tus dados</label>
<input
id="dice-input"
type="text"
inputmode="numeric"
bind:value={dice}
onchange={() => (wordsAgain = '')}
autocomplete="off"
spellcheck="false"
placeholder="35214 11632 …"
aria-invalid={invalid('words') ?? (rolls.some((r) => r.problem !== undefined) ? 'true' : undefined)}
aria-describedby={described('words', 'dice-hint')}
/>
<p id="dice-hint" class="hint">
Para no fiarte del azar del ordenador. Tira cinco dados por palabra y escribe sus cifras en orden, de izquierda a derecha:
un número del 11111 al 66666 por palabra, al menos 6 y mejor 7, separados por espacios. Cada número es una palabra de la
lista pública, que puedes descargar numerada para buscarlas en papel.
</p>
</div>
{#if rolls.length > 0}
<ol class="slip" aria-label="Las palabras de tus dados, en su orden">
{#each rolls as r, i (i)}
<li class={[r.problem !== undefined && 'bad']}>{r.problem === undefined ? r.word : r.dice}</li>
{/each}
</ol>
{#each rolls.filter((r) => r.problem !== undefined) as r, i (i)}
<p class="inline-problem">{r.problem}</p>
{/each}
{/if}
{#if listProblem !== undefined}
<p class="inline-problem">{listProblem}</p>
{/if}
<div class="actions">
{#if listProblem !== undefined}
<button class="button quiet" type="button" onclick={loadList} disabled={listLoading}>Volver a intentarlo</button>
{:else}
<button class="button quiet" type="button" onclick={saveDiceList} disabled={wordList === undefined}>Descargar la lista numerada</button>
{/if}
</div>
{#if wordList !== undefined && diceListHash !== undefined}
<p class="hint">
{#if diceDone && rolls.length >= 6}
{rolls.length} palabras de tus dados en una lista de {formatInteger(wordList.length)}, unos {Math.floor(wordBits(wordList.length, rolls.length))}
bits si los dados no están trucados. Apúntalas a mano y en este orden: la cápsula la abren las palabras, no los números.
{/if}
La lista numerada es un fichero de texto, una palabra por línea con su número. Su SHA-256 es
<code class="hash">{diceListHash}</code>.
</p>
{/if}
{:else}
<div class="field">
<label for="words-input">Tus palabras</label>
<input
id="words-input"
type="text"
bind:value={words}
autocomplete="off"
spellcheck="false"
placeholder="seis palabras o más"
aria-invalid={invalid('words')}
aria-describedby={described('words', 'words-hint')}
/>
<p id="words-hint" class="hint">
Al menos 6 distintas de 3 letras o más, y que no formen una frase conocida. Son más débiles que las del azar y no bastan para
algo valioso: pasada la fecha, quien tenga la cápsula puede probar palabras. No uses una contraseña que uses en otro sitio: la
cápsula serviría para probarla. Dan igual mayúsculas, acentos y espacios, pero no el orden.
</p>
</div>
{/if}
{#if wordsText.trim() !== ''}
<div class="field">
<label for="words-again-input">
{wordsChoice === 'own' ? 'Escríbelas otra vez' : 'Escríbelas aquí, en su orden, para comprobar que las tienes apuntadas'}
</label>
<input
id="words-again-input"
type="text"
bind:value={wordsAgain}
autocomplete="off"
spellcheck="false"
aria-invalid={invalid('wordsAgain')}
aria-describedby={described('wordsAgain', wordsChoice === 'own' ? 'words-again-hint' : undefined)}
/>
{#if wordsChoice === 'own'}
<p id="words-again-hint" class="hint">Se guardan así: {quickWords(words).join(' ')}</p>
{/if}
</div>
{/if}
</div>
{/if}
<details class="help" open={recipients.trim() !== ''}>
@ -1056,6 +1264,12 @@
<ul class="notices">
<li>DateKeys {SPEC_VERSION} es una versión de prueba: una versión futura podría no abrir esta cápsula.</li>
{#if plan?.longHorizon && plan.policy === TIME_ONLY}
<li>
Para una fecha tan lejana, mejor «Solo con una llave»: si alguien consiguiera antes de tiempo la firma de drand, sin la
llave no le serviría de nada (§7.6).
</li>
{/if}
{#if plan?.longHorizon}
<li>
Falta más de un año. Para abrirla hará falta la firma que la red drand publique ese día, y si nadie la conserva, la
@ -1194,6 +1408,22 @@
</section>
{/if}
<section class="block" aria-labelledby="later-title">
<h3 id="later-title">Para abrirla más adelante</h3>
<p>
Hará falta {r.plan.policy === TIME_AND_KEY ? 'la cápsula y una de sus llaves' : 'la cápsula'}, y la firma que la red drand
publique el {when.day} a las {when.hour}, que la abre. Si alguien la abre años después y drand ya no sirve esa firma, tendrá
que haberla guardado un archivo de firmas de drand o un servicio que las conserve (§50).
</p>
<p class="hint">
Guarda con la cápsula las instrucciones para abrirla sin DateKeys, por si ya no existe. Son las mismas para toda cápsula y
no dicen nada de la tuya.
</p>
<div class="actions">
<a class="button quiet" href={ANNEX_URL} download={annexName(downloadName(dkcName, '.dkc', r.plan.names.dkc))}>Descargar las instrucciones</a>
</div>
</section>
<details class="tech">
<summary>Informe técnico de la cápsula</summary>
<dl>
@ -1485,6 +1715,70 @@
margin-top: 1rem;
}
/* The key of words, under its check box and aligned with its text. */
.words {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.9rem;
padding-left: 1.75rem;
}
.kinds {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 1.5rem;
}
.kind {
display: inline-flex;
gap: 0.5rem;
align-items: center;
font-weight: 400;
cursor: pointer;
}
.kind input {
accent-color: var(--accent);
}
.drawn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
/* The words drawn, in the voice of the page, numbered because they are
typed in this order. */
.slip {
display: flex;
flex-wrap: wrap;
gap: 0.3rem 1.5rem;
margin: 0;
padding: 0.9rem 1.1rem;
list-style: none;
counter-reset: word;
border: 1px solid var(--rule);
border-radius: 8px;
background: var(--paper);
}
.slip li {
counter-increment: word;
font-family: var(--serif);
font-size: 1.4rem;
line-height: 1.5;
color: var(--ink);
overflow-wrap: anywhere;
}
.slip li::before {
content: counter(word);
margin-right: 0.3rem;
font-size: var(--t-small);
color: var(--ink-faint);
font-variant-numeric: tabular-nums;
}
/* A number of the dice that gives no word. */
.slip li.bad {
color: var(--fail);
text-decoration: line-through;
}
.hash {
overflow-wrap: anywhere;
}
.hint {
font-size: var(--t-small);
color: var(--ink-muted);

@ -1,6 +1,6 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { type CapsuleBytes, inspect, readCapsule } from '$lib/dkc/index.ts';
import { type CapsuleBytes, inspect, profileHash, profileStatusOf, readCapsule, toHex } from '$lib/dkc/index.ts';
import { FIXTURES, type Fixture, fetchFixture } from '$lib/inspector/fixtures.ts';
import { buildReport, type Report } from '$lib/inspector/report.ts';
import { displayText, unexpectedProblem, viewerTimeZone } from '$lib/inspector/format.ts';
@ -73,8 +73,11 @@
// The base protocol V1: no extension registry, so every extension is
// unknown to this reader, as in `datekeys inspect`.
const inspection = await inspect(bytes);
// The state of its profile in the registry of §71: a compromised one
// gets a warning.
const status = inspection.profile === undefined ? undefined : profileStatusOf(toHex(await profileHash(inspection.profile))).status;
if (id !== loadId) return;
report = buildReport({ fileName: name, bytes, size, inspection });
report = buildReport({ fileName: name, bytes, size, inspection, ...(status === undefined ? {} : { profileStatus: status }) });
capsule = c;
nowMs = Date.now();
timeZone = viewerTimeZone();

60
testdata/README.md vendored

@ -1,7 +1,7 @@
# DateKeys test data
Official vectors, fixtures and corpora of the DateKeys Protocol Specification
v0.15, generated by the reference implementation.
v0.16, generated by the reference implementation.
Another implementation consumes them as they are: this file documents every
format, so that no Go code has to be read. The rules that decide each verdict
are in the specification; this file points to them, and states only what
@ -21,10 +21,14 @@ added since. The local gate (`scripts/check.sh`) and CI run it and fail if any
committed file changes: every file below is exactly what the implementation
computes today.
The `spec` field of every file is `"0.15"`, the version this module declares.
The `spec` field of every file is `"0.16"`, the version this module declares.
v0.15 adds the release object and a release in the caller's hand (§47.1,
§63 step 9.c): `vectors/release.json`, the files of `releases/`, and the
field `source` of `mutations.json`.
field `source` of `mutations.json`. v0.16 adds the reason of a seal that
proves nothing before the opening date, `seal_reason`, to
`security_cms.json`, made again; the strict reading of drand's JSON to
`release.json`; and the fixtures `format3_time_and_key_words`, with
`words_text`, and `format3_full_chunk`.
What v0.12 changes from v0.11, the texts of the verdicts of a certificate and
of a seal, the profile of a certificate and the rules of the addresses and of
the padding of a locator, is in the files: the verdicts and the lines of
@ -94,7 +98,7 @@ extension and a noncritical CONTROL_CBOR extension. The release that opens each
capsule, a published Quicknet signature, is in its `<name>.json`, so they all
decrypt offline.
Fourteen are in format 3. Their plaintext file is BODY, L bytes: the frame, the
Sixteen are in format 3. Their plaintext file is BODY, L bytes: the frame, the
security area, the head and the files (spec §29.2).
| Fixture | Policy | Files | Comment | L | Padding code | P | Area | Verdicts |
@ -113,6 +117,8 @@ security area, the head and the files (spec §29.2).
| `format3_signed` | `time_only` | 1, `nota.txt`, with mtime | — | 32915 | 2 | 34816 | 32768 | F4, S0 |
| `format3_signed_cms` | `time_only` | 1, `nota.txt`, with mtime | — | 32915 | 2 | 34816 | 32768 | F6, S0 |
| `format3_sealed` | `time_only` | 1, `nota.txt`, with mtime | — | 32915 | 2 | 34816 | 32768 | F4, S4 |
| `format3_time_and_key_words` | `time_and_key`, a key of words and 15 dummies | 1, `secreto.txt`, with mtime | — | 32944 | 2 | 34816 | 32768 | F0, S0 |
| `format3_full_chunk` | `time_only` | 1 of 32637 bytes, with mtime | — | 65536 | 1 | 65536 | 32768 | F0, S0 |
The first five were written by a writer of v0.10, with the area of 512 bytes.
The next four only a generator of test vectors may write (spec §62.1 rule 13):
@ -153,7 +159,20 @@ their record has a `signature` object, and `seal` in the third:
`SEAL_SUBJECT`. The record has `seal`: `seal_subject`, the `token` in
hexadecimal, the `holder` of the authority as §29.7 shows it, and the time.
In the three, the record gives the commitments `control_commit`, `head_digest`
The last two are of v0.16, for the annex of recovery (spec §79):
- `format3_time_and_key_words` opens with a key of words (spec §38.1): the
text of the second vector of the annex, 79.7, «Ñandú», two spaces,
«PINGÜINO», a tab and «camión árbol Éter ola», whose words are «nandu
pinguino camion arbol eter ola». The record gives the text in `words_text`
and the identity it derives with this capsule_id in `identities`, with its
stanza in `identity_stanzas`, so that a reader without words opens it too.
- `format3_full_chunk`: BODY and P measure 65536 bytes, so PAYLOAD_AGE ends
in a full STREAM chunk of age, the last one, which the annex of v0.16
allows (79.5). `scripts/recovery_check.sh` opens both following only the
annex.
In the three signed ones, the record gives the commitments `control_commit`, `head_digest`
and `signers_digest`, the text `author_message` and its `author_code`, and the
exact content of key 2 of `SECURITY_CBOR`. An implementation checks them from
the control, the head and the security area of the fixture, and the verdicts
@ -358,7 +377,13 @@ one of CBOR (RFC 8949).
case, and may have `randomness`, which must then be SHA-256 of the
signature; it names no chain, so its `release` has no `chain_hash`. Any
failure to read it is `ERR_RELEASE_INVALID`, and so is one of more than
8192 bytes; then the round and the signature, as for an object.
8192 bytes; then the round and the signature, as for an object. Since
v0.16 it is read strictly: no object of the JSON repeats a name, names are
compared exactly once their escapes are decoded (`"\u0072ound"` is
`round`, and `ROUND` another name, which is ignored), an escape of a lone
surrogate is malformed, `round` is a number without sign, fraction or
exponent from 1 to 2^53 − 1, and `signature` and `randomness` are strings.
The cases of v0.16 follow those of v0.15 in the list.
- `archive`: the lookups of the local archive `releases/archive_1000_1004.bin`,
an informative format (spec v0.15, §50). It is the `header`, the
deterministic CBOR map `{0: "datekeys-release-archive", 1: 1, 2: chain_hash,
@ -381,7 +406,7 @@ flow).
```json
{
"spec": "0.15",
"spec": "0.16",
"description": "…",
"profile": "datekeys:quicknet:v1",
"scheme": "bls-unchained-g1-rfc9380",
@ -598,9 +623,11 @@ in `security_cms.json`.
## `vectors/security_cms.json`
Security areas with an author signature of `alg` 2, a CMS signature with
certificates, or a time seal of `seal_type` 2, an RFC 3161 token: 135 cases,
certificates, or a time seal of `seal_type` 2, an RFC 3161 token: 143 cases,
each with the context of its capsule, the verdicts, the result of each signer
and the lines of v0.12, §29.7, §29.10 and §29.11. They complete
and the lines of v0.16, §29.7, §29.10 and §29.11. Made again for v0.16, when a
seal without `accuracy` stopped proving that it came before the round time:
the cases about something else carry an accuracy of a second. They complete
`security.json`, whose areas have no valid signature or seal of these kinds.
The file is frozen: the certificates and the tokens are made once, with test
keys, so a second implementation reads them and must reach the same verdicts
@ -621,10 +648,13 @@ and write the same lines. Delete the file to make it again.
count. Each has the `holder` and the `issuer` as §29.7 shows them, its
`result` (`valid`, `invalid`, `absent`, `without seal`, `invalid seal`,
`out of validity` or `not verifiable`), the `seal_time` of its CAdES-T when
it has one, and `before_round_time`, whether that time plus its accuracy
precedes the round time.
it has one, and `before_round_time`, whether its seal proves that it came
before the round time: it carries `accuracy` and that time plus its accuracy
precedes the round time (v0.16). When it does not, `seal_reason` says why:
`late`, `no accuracy`, or `no accuracy, BTSP` for a token of the ETSI
policy 0.4.0.2023.1.1, which requires it; the first that holds.
- `seal_holder` and `seal_time`: the authority and the time of a valid seal
of key 3.
of key 3, and `seal_reason` the reason of S5, as for a signer.
- `lines`: the verdicts as the official SDK shows them (§29.7), byte for byte:
the names between « and », the line of each signer with its authority, the
warning that DateKeys does not check who issued the seals, and the times in
@ -644,7 +674,11 @@ table, RSASSA-PSS with and without `trailerField`, an attribute with an arc of
each string type and against each rule, `givenName` and `surname` before a
`commonName` with its NIF included; and, over an `alg` 1 signature, the seals
S1 to S5 at the edges of the token: its accuracy, its `genTime`, `ordering`,
a field after the last, the imprint, `crls` and the authority.
a field after the last, the imprint, `crls` and the authority. For v0.16: a
token without `accuracy` years before the round time and after it, one of the
BTSP policy without it and with it, an `accuracy` of 0 seconds and an empty
one, which are a precision of 0, and a signer of `alg` 2 whose seal carries
none, also under BTSP.
## `vectors/locator.json`

116
testdata/SOURCE.json vendored

@ -1,148 +1,156 @@
{
"module": "g.activething.com/go/DateKeys",
"commit": "fe5088549186465e08d55f89680be986076bf165",
"commit": "b6ff17a5fa5f119aa8125356437a09c657b15d0b",
"files": {
"README.md": "d26b3f507edf5afe89600f063cb509b9ef62a908b93ed476bff964b5a443bf4b",
"README.md": "a7b2d6810928ccb8be33e39ede053a2cada38cd74b2888dc8655f3d5c0bb8375",
"fixtures/empty_payload.dkc": "871e9bf05b52bbae17f3adfbbf97b46e7f0e53aa8f57bcaa506e43f36f53a9d4",
"fixtures/empty_payload.inspect.json": "373e5d012b023ad58bbb54cbdffe0bed9e50c637438a4083ddb74d5414c59f59",
"fixtures/empty_payload.json": "d1fc459ab76d4ee0231a8c6b7dfc212fcf8da90e7a392b30133f646b3a9df4db",
"fixtures/empty_payload.json": "4ef16c75b3a7fe543ab37ff2c08e9e5061dc2d22f4b2724fe572c658af41c72a",
"fixtures/empty_payload.plaintext": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"fixtures/format2_empty_payload.dkc": "7aea2b5aa48b1a46053716f733d50fab9cd0b80b1be67631bcc06c5bb765dc21",
"fixtures/format2_empty_payload.inspect.json": "d0bb7356d3970986e6b197640f0b3b38abe9fabf1740b745171b358aa28903ff",
"fixtures/format2_empty_payload.json": "699cc67dd448ba69ecc52ad97a1947175b46f9d64859a6fd018ecae8fbe12508",
"fixtures/format2_empty_payload.json": "98743e5fe3833290035bc764d05e2b69d56efb3ca6563be8c9f4a0d3575f55ae",
"fixtures/format2_empty_payload.plaintext": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"fixtures/format2_time_and_key_portable.dkc": "600892659fe4890223e895876275f656995d170fda42b07fb2bec0ca51ce4b43",
"fixtures/format2_time_and_key_portable.dkk": "095b7bc516a22bf0c2366f0af3cd48bfe857a2354d6e2a9b285278b95e450fe0",
"fixtures/format2_time_and_key_portable.dkk.json": "0f95e43881b140330415ca98a284913c22dab9af3b9e16da0f4d2b1b5229d768",
"fixtures/format2_time_and_key_portable.dkk.json": "6b0879f3710ee534bc29b448e22190272d1286d096961143be29cd0905c87a10",
"fixtures/format2_time_and_key_portable.inspect.json": "522c9a98e5911c86f5f24f278971cf7c7588f6c88aaede3dd1129ee4e042868a",
"fixtures/format2_time_and_key_portable.json": "960bd78f54c91d0f8afcb9a1fdc18a7c7a7d74363ac1b77aa4ebef183d258dc6",
"fixtures/format2_time_and_key_portable.json": "c4302561ed04a182602b0bcb686d7e40472a8b8eb9458cb9a38883b00c3ac505",
"fixtures/format2_time_and_key_portable.plaintext": "937492203d207d6fe36161b8696bf1f05b8b4cc56d855c44853f4b76aad3a05b",
"fixtures/format2_time_and_key_recipients.dkc": "1a44fd8708c92e2e0a10cfcb1d864a71331ea9af25d97e1a42e969dc898959e3",
"fixtures/format2_time_and_key_recipients.dkk": "2ad99b1556086ec311d7f0b3bd3aaba05e75f45c4fa22490b0d5e8bb0b1a222e",
"fixtures/format2_time_and_key_recipients.dkk.json": "9d7e89e390e253bc1a432fa36ca2c37abbd6e88a0ac2fc25e87c1b7bb442e7d1",
"fixtures/format2_time_and_key_recipients.dkk.json": "a765cffd2532bf794a03d2da53680d8d4aa43c2721368cc8f474d3fe10e10bd0",
"fixtures/format2_time_and_key_recipients.inspect.json": "d975a9eddd45f5d59618ea2455d574d807e57daf08585e840d07986f261bf099",
"fixtures/format2_time_and_key_recipients.json": "34abdf930940ff7ac7b28de597bda3fdcadc94a074912e5220153bd85f9e096c",
"fixtures/format2_time_and_key_recipients.json": "909efe909032db2405c458952f221496751848166f9f2d23d01e7c55c286f555",
"fixtures/format2_time_and_key_recipients.plaintext": "0e9fd50e98a85953aa9cf07a11ee3c62bb3d7622f344f1c6ce744d1ed111659f",
"fixtures/format2_time_and_key_sixteen.dkc": "7aaac5c18f216bf53df326ecc817179640a53408cf25dfd50488910a762dc381",
"fixtures/format2_time_and_key_sixteen.inspect.json": "492cd0b0dca0030df9332b098d22b4f6aa4adfb57d5540de5325e3e6d5aa3667",
"fixtures/format2_time_and_key_sixteen.json": "30b8103e730e3cea5b8b39078b61022c8e3e168dec70c27a7aa69c83046853ee",
"fixtures/format2_time_and_key_sixteen.json": "a0fcad6094b3633d0d89acdcacf30f1e8b2dfd79745157455b3a1c41a4b733a0",
"fixtures/format2_time_and_key_sixteen.plaintext": "e5abfb7b5fdbf297277b6cc4729c15d85435e890b653c2e2342b7031ecd9eab9",
"fixtures/format2_time_only.dkc": "f5a40ac6b8a08a0c12db6114c2bca23522d6a77b512b509a217fb15f367813c4",
"fixtures/format2_time_only.inspect.json": "40a8683f5204c7b6369558e4775ae6bb6fed978097e9e660de64c06c167b043e",
"fixtures/format2_time_only.json": "4a3c76f1a75fbc39399c5bd7f8c355565c2ea826ad1284dd056953af8aedb009",
"fixtures/format2_time_only.json": "1ab9b7327c87b443c411beb9abf42bc4024fa5bbca6fc94a32054754bd087ed1",
"fixtures/format2_time_only.plaintext": "53b8ee821fb7b678e89d4f93da1812339f6cc1ab83aac6ed1432db99df784be5",
"fixtures/format2_time_only_bloque256.dkc": "aae769c30d04920801d8b293d30864fbe223c9c9353ec2b4907a1ee1996e39f9",
"fixtures/format2_time_only_bloque256.inspect.json": "767766414ad547f0ba95b40059e14b62c81b5489a2afcbc683bf227334d61be7",
"fixtures/format2_time_only_bloque256.json": "7dbd8dc320dbed995be9cb5830ca9a50d4fb8e956b1d468408f6199c97fc26f4",
"fixtures/format2_time_only_bloque256.json": "777b1ed31e0232c3790b4bc856005c1d9ab04f2d537ae339fd4fcbc23a93bce0",
"fixtures/format2_time_only_bloque256.plaintext": "53b8ee821fb7b678e89d4f93da1812339f6cc1ab83aac6ed1432db99df784be5",
"fixtures/format2_time_only_extensions.dkc": "fb406100d5703a2e888983b3175ed34a09a34469cc722256e5cf535dd728fbe9",
"fixtures/format2_time_only_extensions.inspect.json": "1595d793c1d35bfdaa36576b75f53d734a9e07c2a8a12036295dbaee7f5a7f5a",
"fixtures/format2_time_only_extensions.json": "bfe30126441ea1b1b2e9b7cb0cbbce71d5f26f98b77004893b46ebf4be6b573f",
"fixtures/format2_time_only_extensions.json": "ab38f1ab6ddd03dad33414ba00c1413c355b145c12ac081616c4f1390a9ca8e0",
"fixtures/format2_time_only_extensions.plaintext": "1129768e195e2f1e50b7a6f926b6eebef120212c29b5642c8a662c503b2a9131",
"fixtures/format3_area_1024.dkc": "41ea2eed0293e4fef7f4a307b7f16aaf1339f5bf6f4ded7a6a9ae1aebeb0133c",
"fixtures/format3_area_1024.inspect.json": "06e6b347926242ae5540f16a053f6a3545743986989bc0be8c39918bce968686",
"fixtures/format3_area_1024.json": "06319a474d9e6c545185aa282be407908f98cb97a1ad5128fc0d557adc45695d",
"fixtures/format3_area_1024.json": "ef1cca654c906616fc6aa0ed94346846b25fb41c5240035752fb9b930c56ec4f",
"fixtures/format3_area_1024.plaintext": "043830350a287cba1fd50f6c063f70a74209895ff0cf03147bb4e5ccdfc206b5",
"fixtures/format3_bloque256.dkc": "ff18444f434164ba8e7b26d38c76c7855dc6b0593b2fc8b4e9a95dbf9252d55d",
"fixtures/format3_bloque256.inspect.json": "d0007080da5ce079c6ffa3a56bf8ce519d2846a31cc1082fd027f401e4f7bade",
"fixtures/format3_bloque256.json": "e551a2224454cd5a416d6e91e2ab0947249a906dac3ef028a3dd45ee441e62b9",
"fixtures/format3_bloque256.json": "68c34409d7c992f318aae9d46dd6fed0a2b971dc6c6373a1cf9790ddf31e2bd1",
"fixtures/format3_bloque256.plaintext": "9ff2843e40bc1280dbfea8dce9386a42d06e8b43742c2cc6257540770cb53c73",
"fixtures/format3_comment_only.dkc": "7f98a89413f08655bbbab28b96585dfa6173c1705dd81a900deba2100d19f2ef",
"fixtures/format3_comment_only.inspect.json": "fb56eca8bf42c8c47fde4a1d6b2580fcff386f2f58b566820731dce388542196",
"fixtures/format3_comment_only.json": "1aff9f9c3531e269fc48b25c4e224e6b547ba1d5eba187ed6afa3eaff82c26f8",
"fixtures/format3_comment_only.json": "2ce37e9c76b4d01d32563757dc1bdf8f235ba88f4d0f3eae2cca7eba9f8d00a1",
"fixtures/format3_comment_only.plaintext": "bc5b05885e608f036d8a14fde8738a8c53b395b71c3bcee99c1eab37ea23e80e",
"fixtures/format3_full_chunk.dkc": "af658967b0b2c79379e25edfe3a785095e9aa2686203e93e9f30dacf27a38684",
"fixtures/format3_full_chunk.inspect.json": "1f07e80039804157b800c8db3d5d680948a3180684792ae6ac71d84190f03923",
"fixtures/format3_full_chunk.json": "353cac92a051a22db3a6e576cfa3b88e3f125436d9a8a8b4fcefd6add8136ff2",
"fixtures/format3_full_chunk.plaintext": "ec5bfd307b2e36c1b8e232031167401a1f06d205ccade7a054d39914ebf5c9f8",
"fixtures/format3_note.dkc": "da1bee54231252a0fd98439e24588125c5521f6e5a2c6641b499e6b22192c0eb",
"fixtures/format3_note.inspect.json": "dcebb62407097c757bb62552be5d315155ba8a35dec175e95c3f6fddd6e81869",
"fixtures/format3_note.json": "70bae49ca3ee84dcd4fecc572d500ce61cb014cc1e43385ae172cf414e10fa41",
"fixtures/format3_note.json": "15ab206c7444c204bb26c94f48ae53328fca63bb4d587211526dd1664b0e8dc4",
"fixtures/format3_note.plaintext": "0468737c5141be936f59d2823122e87661d4ae155a1df036b4cad1ea6620c17b",
"fixtures/format3_seal_unsupported.dkc": "ae3219fbdbd1de4cef6fade1a3fb3f6e5d5e2e8af54d9516b05f0a48136913ad",
"fixtures/format3_seal_unsupported.inspect.json": "b3a7a1038192394c1f844fed994011646f3e78d1cf311c18cb5c936249b95f14",
"fixtures/format3_seal_unsupported.json": "fedb92f17376cb90d91bce6777e152739d63bee884809ec9458dd5610d939d66",
"fixtures/format3_seal_unsupported.json": "6506de0008f936d6c5f6eedd2e951ead0d465df1b4d91016039f26d120824345",
"fixtures/format3_seal_unsupported.plaintext": "0f865221d26545762712271faf835cb2e9980f8fb15c9d3b94fb6747cf16c1df",
"fixtures/format3_sealed.dkc": "dde5a072d8783227d28279d06d3d226a1fb967c766da626f889d1c6fd76ac9c7",
"fixtures/format3_sealed.inspect.json": "b984a0755332bad838025e47f8e917b9f18b9bb5c068c2d1ef0070db8e42849c",
"fixtures/format3_sealed.json": "b925f7daae6d21c139b529a10900b9f67adb121b18670682969da3d1faa4e382",
"fixtures/format3_sealed.json": "7aafa982a3a244322b1bbf1bce71b9251b946573de164c4477233f3db0ec7eff",
"fixtures/format3_sealed.plaintext": "aea0f5feb40acd81ca3b02dd21ea15510234da3ab52b374322f3206e7632d47b",
"fixtures/format3_security_v2.dkc": "3d02b39ace010d74604554e378d22fe5ce00cecd998c0f797d657b17620b8912",
"fixtures/format3_security_v2.inspect.json": "dba4d21f1d4e228a17c761bae9a4b8c5a91cd9c0123e3141d3782a43c139321e",
"fixtures/format3_security_v2.json": "47bf9cf26e04f1c87eaf669d3d3811846bffd188dc6a90b3ac96cc45ea1ff256",
"fixtures/format3_security_v2.json": "1dc33fe75085054921365eb023c64e57b99d30b27bbcd5ef37eb44b8126ecdca",
"fixtures/format3_security_v2.plaintext": "0c58ef40e4b1c7afde0f6e0a1f4ed7e3d45405757c5143a095f0c2b58042669f",
"fixtures/format3_signature_unsupported.dkc": "e8e3106d8d73bb7b845062e0fe42af21df7d7cd8f63c335cab8dedb3e690df31",
"fixtures/format3_signature_unsupported.inspect.json": "6db653db27604cb07e2cb2c23545fb68542c121e85002762f26c6d39e63bf00c",
"fixtures/format3_signature_unsupported.json": "1e2e173ede53f38fb368289fc616325b18c7e07fd87d5eda28ace180087f151e",
"fixtures/format3_signature_unsupported.json": "052a9f2929144eaca3ca01a1f34cbe914da41a17f82356df8b3f1a1d5b856bb7",
"fixtures/format3_signature_unsupported.plaintext": "9fe05e6b3a463371b33fc6a81b81d538e572789a8d03ace9f752a931f4ca4728",
"fixtures/format3_signed.dkc": "3c7d3c9e24c02853a0c7761b93bea1120b27fce396468d8d0f68e53aeb668c5e",
"fixtures/format3_signed.inspect.json": "7c37054d542869e766147350e2fa72695f209d39a03726757008e2c2291b0e97",
"fixtures/format3_signed.json": "725b6cdb41ad9a5d34ec5327f7b0f1b667510fea6a9b1fb714be582ed55eaaed",
"fixtures/format3_signed.json": "d018866c235ccc43e2c95683c989dc0d3873d7fc16f60951a81299cc09790206",
"fixtures/format3_signed.plaintext": "3de3ccab0ac74f95a76aa45c0f85e1749d4b4a051d87e81828eff6bf24372000",
"fixtures/format3_signed_cms.dkc": "d658f8d5ac2c5550c07b8f8fd6883b2f6dc02ceafc47d436ea02d8950b2548d2",
"fixtures/format3_signed_cms.inspect.json": "afadf530e8146687b25c03f26100ebff18e7f481e0ef09378816bad582270de5",
"fixtures/format3_signed_cms.json": "eac7b9c2d2470ef140f41cf7c94e32657e2949a40f621a07340f7d2342ee7dc1",
"fixtures/format3_signed_cms.json": "af34910ff4f1ad245fb19b190b878c3eaf03c8e0e639378df37ed3488f96c12e",
"fixtures/format3_signed_cms.plaintext": "31c35eeeee856277b605fe44203a8f4786bb8f591eda3b6ee58252df5b3cf2f0",
"fixtures/format3_single.dkc": "9f68664af8733255084be9036a100b75d27bd16106bf0acff94ce469dd1d1743",
"fixtures/format3_single.inspect.json": "7878da921c17aada50e00d5911ea97e8558633a1684fb96acbd00d6f1b117529",
"fixtures/format3_single.json": "0a31c6d416ec3e6acd891172881f4f5738c36e01c9583be48f0376324641d17b",
"fixtures/format3_single.json": "5ac7e261a24c1591afc4406d444f0f7f92af810dbcc17454f3f076d22deab333",
"fixtures/format3_single.plaintext": "74f9dd84d07e95a31e6dc063bf65ce414197acf84aac445eabc76fa4e3f24936",
"fixtures/format3_time_and_key_portable.dkc": "680d29962e575689a31543df28433dae7737abd9a793e9cae92ef40920d09636",
"fixtures/format3_time_and_key_portable.dkk": "54cc64d849395234b3e093e47f432b72781ccc13f455c9ef394e3554ab566751",
"fixtures/format3_time_and_key_portable.dkk.json": "a3aff664132ec3d6da8f016c44978733ad8b9e500dec08d5c08a1a1c39a09071",
"fixtures/format3_time_and_key_portable.dkk.json": "968c5d8cde65ae066671635c9f5679233e00e982977fcaadcedfa1483a5ef246",
"fixtures/format3_time_and_key_portable.inspect.json": "f269af86f5bf84c22a1038fd78db146af93166150755eb1ca35cf15e224035b4",
"fixtures/format3_time_and_key_portable.json": "0a545aab8299c5af039b57092276cf5db62e73ef74e6e1046834f6886a34bbf0",
"fixtures/format3_time_and_key_portable.json": "8bc86ac3455f77d2996e952f4b3430e002111a731b2d8d12217cffc07cc5256f",
"fixtures/format3_time_and_key_portable.plaintext": "e6684cf607c102bfa4d6977742d5a7520b0e09483282181bd6d8f5f4ba5f7726",
"fixtures/format3_time_and_key_words.dkc": "64a11824630b6134892087a4d4ad3ee6e27941513507ad17fc87a7a2b4421e33",
"fixtures/format3_time_and_key_words.inspect.json": "838b2fe32b73bfd2ed45b2104170ec8532909d03ed0772e9baacf2c254fa4c32",
"fixtures/format3_time_and_key_words.json": "083e84c42ea89544fcc7c39665ec8815d272727bbc0907932412a43402396c92",
"fixtures/format3_time_and_key_words.plaintext": "2ff49df00ad9a37446c626be6d0353e94bd3bf41d73a6141e10f77b586abcb67",
"fixtures/format3_tree.dkc": "217f378faaf795f6a9c416b564fb8931bb2e896918aee870120fd14f9a5da7d1",
"fixtures/format3_tree.inspect.json": "643a9dfdc2d0c44b8a1636909c66ed81bfd8c50df2a4cad6566832a8e47ba938",
"fixtures/format3_tree.json": "1d2ed3e28c5075ead9898757b971298273c4b20acee27911286dc250aa7d0143",
"fixtures/format3_tree.json": "3a97244352fffae5cc980bb278ecd3e201d6c89ac867656e1ca4956e71b10c1a",
"fixtures/format3_tree.plaintext": "f69ac5f450966f7d0e9161aa37451d4260b194a750e3e132c02e8a15ba561cfa",
"fixtures/format3_unsigned.dkc": "317ab722ae3812a25ddd78b4c98c586363e5587c8d3634c881ce7c421af19168",
"fixtures/format3_unsigned.inspect.json": "2f52f7286d6bd4c846ddd63b10b4989b25d17501a989a2e9deb25e4db0859e1a",
"fixtures/format3_unsigned.json": "bce5d7fcf60ddb553e2bbe5b501892ae7a13488b4095fdad598dc3207690dfb3",
"fixtures/format3_unsigned.json": "cac5778e7e8d5cf86bcba0c7893ae45387cba43b93a8a7877ffc60d6e469b346",
"fixtures/format3_unsigned.plaintext": "25527e5e2e1ce02056d4419fb89f7b0ce35e4920f93217f58dcf62a4377f8af5",
"fixtures/time_and_key_portable.dkc": "2e97878078bae6358037a9c264f379a3cbe839f767d69836b0343f35657b2972",
"fixtures/time_and_key_portable.dkk": "e528fa2c832c91119f0684bb9d6fb3c4c2d0d55183482890e7c4fe92f668426a",
"fixtures/time_and_key_portable.dkk.json": "4535028d6559cd368a44a6f03ebf8bcbcc2bdac4fcf13374aae541618b81c99f",
"fixtures/time_and_key_portable.dkk.json": "6f1c5c6fae50d37e7afd9731992353063e2c9ed4ef3b736c840a10f60d43ef0e",
"fixtures/time_and_key_portable.inspect.json": "238c1f8ca6a6bf69f20bf26f5676e89a0b07e83b4362628560fc2f7522a202c9",
"fixtures/time_and_key_portable.json": "389f36834ffb86a4c60950872a540caf8a8a94f65d02f56ce5f7922e06bf933c",
"fixtures/time_and_key_portable.json": "9c80f7a9ace3e5cbb180d9f6109626ec6c8ae3a8b131e26aa3feda60f35600db",
"fixtures/time_and_key_portable.plaintext": "937492203d207d6fe36161b8696bf1f05b8b4cc56d855c44853f4b76aad3a05b",
"fixtures/time_and_key_portable_extension.dkk": "0bf463a7c65627b7dda2234d728df89ec5b835816a2a37b91497d8fecc5ea548",
"fixtures/time_and_key_portable_extension.dkk.json": "e6f4015f10403926a8e2d3399f78ba99a48ce6c4760e24174c1521046c23efb2",
"fixtures/time_and_key_portable_extension.dkk.json": "9f931c77aa5e98b51d21f3b875b307fd647496390c295abd98a0c9a4e6825286",
"fixtures/time_and_key_recipients.dkc": "69ac110380f5d768b5b6afaa157a50ed17d8ceccfbd4604ffa5b6da38539b635",
"fixtures/time_and_key_recipients.dkk": "19f6c47150c3194712d454f43c7392b7344e6b4e7b074d83e9ca5f563a8e072f",
"fixtures/time_and_key_recipients.dkk.json": "f206ed1a51fc6aac7b2faabd2e3519224f68f7b9b9643653dbdf5b6139f042e0",
"fixtures/time_and_key_recipients.dkk.json": "9bd11c87bf1789872d6f0989ecebdd07958544e159ac462d799096220ce378c5",
"fixtures/time_and_key_recipients.inspect.json": "4b32c63d18febe0772837fbcd75a0c971e31378bf799201b720a9d32bdcd8c2b",
"fixtures/time_and_key_recipients.json": "2cdbc03ef027879b36c68de56e205960e774858a3ff170aba66d630de27c7303",
"fixtures/time_and_key_recipients.json": "733da9ee7d1122a254f0ad45e5c2e530884fe75fc85648ad0df093ad5dbb42b2",
"fixtures/time_and_key_recipients.plaintext": "0e9fd50e98a85953aa9cf07a11ee3c62bb3d7622f344f1c6ce744d1ed111659f",
"fixtures/time_only.dkc": "99e915810d595f1092700b728f5e5081d78efe83f5343e76325b1bcc2c33ccf2",
"fixtures/time_only.inspect.json": "a4d45f945d6ba6616c01e120ac1133785e5279fea7dcab706b5feee287be8884",
"fixtures/time_only.json": "9e66fedbfcf6ffec45648d8afdb7592eecd645751703051d1f60af1d0b7f9f01",
"fixtures/time_only.json": "a1d321bee1f31fb70affb32327cb360c7134c9dda3879519e683e5a6606d4add",
"fixtures/time_only.plaintext": "53b8ee821fb7b678e89d4f93da1812339f6cc1ab83aac6ed1432db99df784be5",
"fixtures/time_only_extensions.dkc": "0446c9b73e267adcb24e5cc89afba2544a386ec9a050016e06517a4a57aa2085",
"fixtures/time_only_extensions.inspect.json": "6f957b028da8a4a495b5e951ced0b91e0678128dac4e962b02d024b9439a0ba1",
"fixtures/time_only_extensions.json": "4bb636805cc681ba1c74aa426f5afda72580f4350929a720da49eeccee3eb2be",
"fixtures/time_only_extensions.json": "5dd015716747d6b8b3b9ab7aa6eccdb6edde4bd5023c1f15e75b1cd3961d9fd9",
"fixtures/time_only_extensions.plaintext": "1129768e195e2f1e50b7a6f926b6eebef120212c29b5642c8a662c503b2a9131",
"releases/1000.cbor": "5d2e86210d2e8d64ce36e55edf3ff6c8997fda4bc06fec7fd5feb0dd6cab8293",
"releases/1001.cbor": "2b55dcc09dfe8fa97192aa6d9a85f9fc50206142d52f66329261cfe9e03de755",
"releases/1004.cbor": "aabdffe0fb944d8796528a6682fdbafb448b1e5da164ee039b6c3ee7f354e766",
"releases/2000.cbor": "9e37be0004850faaa8541658a90ee8aba0832fccf2b695df42c89cb3f7de29ff",
"releases/archive_1000_1004.bin": "bb53d542abd704f3af9f6436c9178f65bf812a06630607b3aad3a09eaed0cce1",
"vectors/cbor.json": "715c8e7ca88d17c4e68a8764350217f108e96484e59282d384a032169d36bfb8",
"vectors/dk1.json": "e2b849b1f606e7961a8571c305dcd0c4374f03c8943a3a715b20a9a43002ea44",
"vectors/ed25519_strict.json": "eb47ac6b5e879ca3e115f6551b81e5b6fe5bf5050aa7b829804e915705c3a1ad",
"vectors/head_schema.json": "29493360d4cf97c3ad488a8ce58221804b17511523756cea1efb87e6fbc13444",
"vectors/inspect_differential.json": "bdc5210415084cde71aa84fbaa7ebbcd81e0f1800b411c6dbacc5e553b4021dd",
"vectors/locator.json": "02476c2f5e421bfc35e2f7b2498ab1d395fd7ef5971fb46dd16fe0abfb6f7a11",
"vectors/mutations.json": "e3ce6a57a57e49bfdb726fb8e9e6e36711c65a23b08e751aab00f8c4dbfcade3",
"vectors/note.json": "f02feea92b22c98227c21e725b3d3e8b303ec647c4cab3fd9b4b71261273818e",
"vectors/padding.json": "7cd6ac71fd978e21c5f93870a211035f98028b9020422f727e407c477e4514bb",
"vectors/path_fold.json": "75e4fa473da4b394d32ac107a5e9559b0d3358ce1b39e5d7403366aa35c2efff",
"vectors/paths.json": "a33ecdbd6a191d693600e18879e15a5813d77b133d46f30c5535aae72eeaeb04",
"vectors/profile_quicknet.json": "e291d5167cdce9b9e24993571a7f349e35fbbcfea793665883ec9ec8d479b498",
"vectors/quicknet_rounds.json": "bf990896dddc51a91e309143fede773adeae918ef47433e0ab6132a4456ce9a8",
"vectors/release.json": "97bd46e055024a00f6a765f840b47ebfea5dad1e08a88c70d2cf42a77df6d2ba",
"vectors/resolved_ip.json": "7c554e7c3f272a62a89c3f3e97203dc7d5dc50290bbe356f4a8efd44a19eea70",
"vectors/security.json": "6045492767cbeb02fce5b6faf6cd0e179ffe96c898c8c023793e4a138b0277f0",
"vectors/security_cms.json": "4915fa3cfe93b1ad92e91a68bba7517c3b2dc3e33fd9def7340b3a045eff99cf",
"vectors/tlock_ibe.json": "5c1def934c89c1638187058e9dcb76ac9fffd148885a9869a1dc7b19d2df76b6",
"vectors/tlock_steps.json": "661c5214c30ea3ea08e7f54f779346b6e838e68a0732ea2110ddcbf07a25cd35",
"vectors/wordkey.json": "1ef9f07d84e7d99d68433a89371c79a407c4e10f33547210bfdf1d7378956d6b"
"vectors/cbor.json": "d2aacec9423d6e52dd199dee10989f161f271cfdf016f64d8ac6e0ed0a2f6719",
"vectors/dk1.json": "68192059df37af531601a814f9f34b39f226d44df6118375dcc4a960219346aa",
"vectors/ed25519_strict.json": "0469a6516423ffe9380fa41a358b208a583f22ed632e4b6b8768a172f061ed0d",
"vectors/head_schema.json": "7e9039aee039fa8ca09e26f73c45a740161db2ced5a667e48a9a2a692d8188bf",
"vectors/inspect_differential.json": "5f2b768a6b17c059fda779f7894115c16bdec30f5645800e2796c45c1733e64f",
"vectors/locator.json": "b1cbd347664faf15dfd3974f046675a6a315a1c4dedf67c3fc9f5d42ae6d3e57",
"vectors/mutations.json": "2f86da3018e68ec0cb263c608e0f7db719fd23f2bb2450f9aa054b71f958e53d",
"vectors/note.json": "af8806904f5f7cd5b672f00043918bd86a9576fe3d1b3724186ac1de18b15c9b",
"vectors/padding.json": "77504260c4d1db0e7ab3da4b9b97b3416be774ceb7813a41e61bd236b44310ef",
"vectors/path_fold.json": "6adcdbeec02b4082e23433c2b8ad9653a1febce9f19df18ace8668bf0254e362",
"vectors/paths.json": "23f9f809e17d335a4b307ec6fee3b8af8f69e7f3ad697f3f7a94ae559d1efd33",
"vectors/profile_quicknet.json": "ac0afa6019b232055375af6e5630c2fff505ca0a88774f095f4c1f519f6c2427",
"vectors/quicknet_rounds.json": "9e0630a03f839ab2152dfd9150b462e6faff77fad9fd8fe3dfcf18f0944bdedb",
"vectors/release.json": "7d3b67d18d22ca7f416a1b39e935bfc2228e3915754becd0b911d6df891190b1",
"vectors/resolved_ip.json": "ca3dd96afdb767f57111fc7985fc1f70aa380939df7048a5b55c81172a14807f",
"vectors/security.json": "b45492a3559cebb6fb41c809e61a64f16d23521870e7a26f8cba92c8332bff31",
"vectors/security_cms.json": "b06a252fd0e72312c0ae65be30981d0651b0d76f471f651f3aaa8cc7678ec177",
"vectors/tlock_ibe.json": "e853eebad87722995901b063404de45383a26748299e5c493b8eb6c420de53b4",
"vectors/tlock_steps.json": "7e0b77593466aa213217ea03f4a344db8181759ab26d094dfc299d4ef7ef33d2",
"vectors/wordkey.json": "89494f886361bdbee7274f21356a814b606ef3b1716ee66117e4f3feca733dff"
}
}

@ -1,6 +1,6 @@
{
"description": "time_only capsule with an empty payload",
"spec": "0.15",
"spec": "0.16",
"format": 1,
"file": "empty_payload.dkc",
"sha256": "871e9bf05b52bbae17f3adfbbf97b46e7f0e53aa8f57bcaa506e43f36f53a9d4",

@ -1,6 +1,6 @@
{
"description": "format 2 time_only capsule with an empty content: L = 0, P = 256",
"spec": "0.15",
"spec": "0.16",
"format": 2,
"file": "format2_empty_payload.dkc",
"sha256": "7aea2b5aa48b1a46053716f733d50fab9cd0b80b1be67631bcc06c5bb765dc21",

@ -1,6 +1,6 @@
{
"description": "portable X25519 .dkk of format2_time_and_key_portable.dkc",
"spec": "0.15",
"spec": "0.16",
"file": "format2_time_and_key_portable.dkk",
"sha256": "095b7bc516a22bf0c2366f0af3cd48bfe857a2354d6e2a9b285278b95e450fe0",
"credential_id": "e3c7be83cbf1fbd6b115c96411b3bd01",

@ -1,6 +1,6 @@
{
"description": "format 2 time_and_key capsule with one credential, a portable .dkk, and 15 dummies",
"spec": "0.15",
"spec": "0.16",
"format": 2,
"file": "format2_time_and_key_portable.dkc",
"sha256": "600892659fe4890223e895876275f656995d170fda42b07fb2bec0ca51ce4b43",

@ -1,6 +1,6 @@
{
"description": "portable X25519 .dkk of format2_time_and_key_recipients.dkc",
"spec": "0.15",
"spec": "0.16",
"file": "format2_time_and_key_recipients.dkk",
"sha256": "2ad99b1556086ec311d7f0b3bd3aaba05e75f45c4fa22490b0d5e8bb0b1a222e",
"credential_id": "93cedf68421710e83908ec683b104436",

@ -1,6 +1,6 @@
{
"description": "format 2 time_and_key capsule for three known X25519 recipients and a portable .dkk, and 12 dummies",
"spec": "0.15",
"spec": "0.16",
"format": 2,
"file": "format2_time_and_key_recipients.dkc",
"sha256": "1a44fd8708c92e2e0a10cfcb1d864a71331ea9af25d97e1a42e969dc898959e3",

@ -1,6 +1,6 @@
{
"description": "format 2 time_and_key capsule for sixteen known X25519 recipients, without dummies",
"spec": "0.15",
"spec": "0.16",
"format": 2,
"file": "format2_time_and_key_sixteen.dkc",
"sha256": "7aaac5c18f216bf53df326ecc817179640a53408cf25dfd50488910a762dc381",

@ -1,6 +1,6 @@
{
"description": "format 2 time_only capsule, padding code 2 (reforzado): L = 78000, P = 79872, two STREAM chunks",
"spec": "0.15",
"spec": "0.16",
"format": 2,
"file": "format2_time_only.dkc",
"sha256": "f5a40ac6b8a08a0c12db6114c2bca23522d6a77b512b509a217fb15f367813c4",

@ -1,6 +1,6 @@
{
"description": "format 2 time_only capsule with the content of format2_time_only and padding code 1 (bloque256): L = 78000, P = 78080",
"spec": "0.15",
"spec": "0.16",
"format": 2,
"file": "format2_time_only_bloque256.dkc",
"sha256": "aae769c30d04920801d8b293d30864fbe223c9c9353ec2b4907a1ee1996e39f9",

@ -1,6 +1,6 @@
{
"description": "format 2 time_only capsule with a noncritical PUBLIC_HEADER extension and a noncritical CONTROL_CBOR extension",
"spec": "0.15",
"spec": "0.16",
"format": 2,
"file": "format2_time_only_extensions.dkc",
"sha256": "fb406100d5703a2e888983b3175ed34a09a34469cc722256e5cf535dd728fbe9",

@ -1,6 +1,6 @@
{
"description": "format 3 time_only capsule with a security area of 1024 bytes, as a later version may write it, holding the empty security",
"spec": "0.15",
"spec": "0.16",
"format": 3,
"file": "format3_area_1024.dkc",
"sha256": "41ea2eed0293e4fef7f4a307b7f16aaf1339f5bf6f4ded7a6a9ae1aebeb0133c",

@ -1,6 +1,6 @@
{
"description": "format 3 time_only capsule with padding code 1 (bloque256) and one file of 20000 bytes",
"spec": "0.15",
"spec": "0.16",
"format": 3,
"file": "format3_bloque256.dkc",
"sha256": "ff18444f434164ba8e7b26d38c76c7855dc6b0593b2fc8b4e9a95dbf9252d55d",

@ -1,6 +1,6 @@
{
"description": "format 3 time_only capsule with a comment of two lines, the second one with a TAB, a declared author and no files",
"spec": "0.15",
"spec": "0.16",
"format": 3,
"file": "format3_comment_only.dkc",
"sha256": "7f98a89413f08655bbbab28b96585dfa6173c1705dd81a900deba2100d19f2ef",

Binary file not shown.

@ -0,0 +1,61 @@
{
"file": "format3_full_chunk.dkc",
"format": 3,
"capsule_id": "9c672412223e65667407568b2ffab62d",
"datekey": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMH0",
"profile": "datekeys:quicknet:v1",
"round": 1000,
"unlock_at": "2023-08-23T15:59:24Z",
"access_policy": "time_only",
"valid": true,
"checks": [
{
"step": 1,
"name": "parse DKC1",
"ok": true,
"detail": "magic DKC1"
},
{
"step": 2,
"name": "prelude",
"ok": true,
"detail": "DKC1 v3, PUBLIC_HEADER_LEN=121, SEALED_CONTROL_LEN=458"
},
{
"step": 3,
"name": "public header",
"ok": true,
"detail": "121 bytes"
},
{
"step": 4,
"name": "header validation",
"ok": true,
"detail": "capsule_id=9c672412223e65667407568b2ffab62d datekey=dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMH0 policy=time_only profile=datekeys:quicknet:v1"
},
{
"step": 5,
"name": "sealed control structure",
"ok": true,
"detail": "one tlock stanza"
},
{
"step": 6,
"name": "payload structure",
"ok": true,
"detail": "one X25519 stanza"
},
{
"step": 7,
"name": "condition",
"ok": true,
"detail": "round 1000, unlock at 2023-08-23T15:59:24Z"
},
{
"step": 8,
"name": "tlock stanza",
"ok": true,
"detail": "round 1000, chain 52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
}
]
}

@ -0,0 +1,152 @@
{
"description": "format 3 time_only capsule with padding code 1 (bloque256) and one file, whose BODY and P are 65536 bytes: PAYLOAD_AGE ends in a full STREAM chunk, which the annex of spec v0.16 (79.5) allows",
"spec": "0.16",
"format": 3,
"file": "format3_full_chunk.dkc",
"sha256": "af658967b0b2c79379e25edfe3a785095e9aa2686203e93e9f30dacf27a38684",
"release": {
"round": 1000,
"signature": "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39"
},
"prelude": "444b43310300000000000079000001ca",
"public_header": "a5006a646174656b6579636170010102509c672412223e65667407568b2ffab62d037853646b315f65794a325a584a7a61573975496a6f784c434a755a58523362334a72496a6f695a4746305a57746c65584d3663585670593274755a585136646a45694c434a79623356755a4349364d5441774d48300400",
"datekey": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMH0",
"capsule_id": "9c672412223e65667407568b2ffab62d",
"access_policy": "time_only",
"structure": "time_only",
"unlock_at": "2023-08-23T15:59:24Z",
"header_binding": "ea2cd41f6216135cd349fceb1891772252e3f90ed945fc71fd73852a982fbf1f",
"outer_stanzas": [
{
"type": "tlock",
"args": [
"1000",
"52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
]
}
],
"payload_stanzas": [
{
"type": "X25519",
"args": [
"QsnJmO1LaffXIjpp0ms0Rh2IXta9VzX38GP6TMYlAHA"
]
}
],
"control_cbor": "a60070646174656b6579732d636f6e74726f6c0103025820ea2cd41f6216135cd349fceb1891772252e3f90ed945fc71fd73852a982fbf1f0358205be72d0295b21d37b6c8380a91d7c875a2dcab7257fa7ea44dcee5ae6280c95a064800000000000100000701",
"payload_identity": "5be72d0295b21d37b6c8380a91d7c875a2dcab7257fa7ea44dcee5ae6280c95a",
"payload_length": 65536,
"padding": 1,
"padded_length": 65536,
"plaintext_file": "format3_full_chunk.plaintext",
"plaintext_sha256": "ec5bfd307b2e36c1b8e232031167401a1f06d205ccade7a054d39914ebf5c9f8",
"area_len": 32768,
"security_cbor": "a20071646174656b6579732d73656375726974790101",
"head_cbor": "a4006d646174656b6579732d68656164010102582029068855227bf0d314be5b3b5e16392b7157581cf62b0c8d156f74017e2f19bf0581a6006a626c6f7175652e62696e01197f7d020003197f7d045820b84764fc9aa813643c9b76145bfdc87673a4b83ccb3cdfaa3c07bbbc5dba560d051a6abcf9c0",
"salt": "29068855227bf0d314be5b3b5e16392b7157581cf62b0c8d156f74017e2f19bf",
"content_offset": 32899,
"files": [
{
"path": "bloque.bin",
"size": 32637,
"start": 0,
"end": 32637,
"sha256": "b84764fc9aa813643c9b76145bfdc87673a4b83ccb3cdfaa3c07bbbc5dba560d",
"mtime": 1790769600
}
],
"verdicts": {
"signature": "F0",
"seal": "S0",
"lines": [
"Sin firma de autor."
]
},
"stages": [
{
"step": 1,
"name": "parse DKC1",
"ok": true
},
{
"step": 2,
"name": "prelude",
"ok": true
},
{
"step": 3,
"name": "public header",
"ok": true
},
{
"step": 4,
"name": "header validation",
"ok": true
},
{
"step": 5,
"name": "sealed control structure",
"ok": true
},
{
"step": 6,
"name": "payload structure",
"ok": true
},
{
"step": 7,
"name": "condition",
"ok": true
},
{
"step": 8,
"name": "tlock stanza",
"ok": true
},
{
"step": 9,
"name": "release",
"ok": true
},
{
"step": 10,
"name": "release verification",
"ok": true
},
{
"step": 11,
"name": "open sealed control",
"ok": true
},
{
"step": 12,
"name": "policy structure",
"ok": true
},
{
"step": 14,
"name": "control",
"ok": true
},
{
"step": 15,
"name": "header binding",
"ok": true
},
{
"step": 16,
"name": "payload identity",
"ok": true
},
{
"step": 17,
"name": "open payload",
"ok": true
},
{
"step": 18,
"name": "commit",
"ok": true
}
]
}

Binary file not shown.

@ -1,6 +1,6 @@
{
"description": "format 3 time_only capsule with a single file, nota.txt, and the public note «Cartas del viaje a Lisboa» in the noncritical array of PUBLIC_HEADER (spec v0.11, §24.1)",
"spec": "0.15",
"spec": "0.16",
"format": 3,
"file": "format3_note.dkc",
"sha256": "da1bee54231252a0fd98439e24588125c5521f6e5a2c6641b499e6b22192c0eb",

@ -1,6 +1,6 @@
{
"description": "format 3 time_only capsule with an author-signature of alg 4294967295, as in format3_signature_unsupported, and a seal of seal_type 4294967295, reserved for tests, with a random token of 32 bytes: verdicts F1 and S1",
"spec": "0.15",
"spec": "0.16",
"format": 3,
"file": "format3_seal_unsupported.dkc",
"sha256": "ae3219fbdbd1de4cef6fade1a3fb3f6e5d5e2e8af54d9516b05f0a48136913ad",

@ -1,6 +1,6 @@
{
"description": "format 3 time_only capsule with a single file, nota.txt, signed with alg 1 by the test key of format3_signed and sealed with seal_type 2 by a test time-stamping authority before the round time: verdicts F4 and S4, with SEAL_SUBJECT and the token in the record",
"spec": "0.15",
"spec": "0.16",
"format": 3,
"file": "format3_sealed.dkc",
"sha256": "dde5a072d8783227d28279d06d3d226a1fb967c766da626f889d1c6fd76ac9c7",

@ -1,6 +1,6 @@
{
"description": "format 3 time_only capsule whose security is of version 2: verdict X",
"spec": "0.15",
"spec": "0.16",
"format": 3,
"file": "format3_security_v2.dkc",
"sha256": "3d02b39ace010d74604554e378d22fe5ce00cecd998c0f797d657b17620b8912",

@ -1,6 +1,6 @@
{
"description": "format 3 time_only capsule with an author-signature of alg 4294967295, a random key of 32 bytes and a random signature of 64: verdicts F1 and S0",
"spec": "0.15",
"spec": "0.16",
"format": 3,
"file": "format3_signature_unsupported.dkc",
"sha256": "e8e3106d8d73bb7b845062e0fe42af21df7d7cd8f63c335cab8dedb3e690df31",

@ -1,6 +1,6 @@
{
"description": "format 3 time_only capsule with a single file, nota.txt, signed with alg 1 by a test key whose seed the record gives: verdict F4, and the commitments and the message of the signature",
"spec": "0.15",
"spec": "0.16",
"format": 3,
"file": "format3_signed.dkc",
"sha256": "3c7d3c9e24c02853a0c7761b93bea1120b27fce396468d8d0f68e53aeb668c5e",

@ -1,6 +1,6 @@
{
"description": "format 3 time_only capsule with a single file, nota.txt, signed with alg 2 by two test certificates, an ECDSA P-256 one and an RSA 2048 one, each sealed by a test time-stamping authority before the round time: verdict F6, with the certificates, the commitments, SIGNERS and the result of each signer in the record",
"spec": "0.15",
"spec": "0.16",
"format": 3,
"file": "format3_signed_cms.dkc",
"sha256": "d658f8d5ac2c5550c07b8f8fd6883b2f6dc02ceafc47d436ea02d8950b2548d2",

@ -1,6 +1,6 @@
{
"description": "format 3 time_only capsule with a single file, nota.txt, with its mtime",
"spec": "0.15",
"spec": "0.16",
"format": 3,
"file": "format3_single.dkc",
"sha256": "9f68664af8733255084be9036a100b75d27bd16106bf0acff94ce469dd1d1743",

@ -1,6 +1,6 @@
{
"description": "portable X25519 .dkk of format3_time_and_key_portable.dkc",
"spec": "0.15",
"spec": "0.16",
"file": "format3_time_and_key_portable.dkk",
"sha256": "54cc64d849395234b3e093e47f432b72781ccc13f455c9ef394e3554ab566751",
"credential_id": "bdb483fba42daf0b409f44d23033f362",

@ -1,6 +1,6 @@
{
"description": "format 3 time_and_key capsule with one credential, a portable .dkk, and 15 dummies",
"spec": "0.15",
"spec": "0.16",
"format": 3,
"file": "format3_time_and_key_portable.dkc",
"sha256": "680d29962e575689a31543df28433dae7737abd9a793e9cae92ef40920d09636",

@ -0,0 +1,61 @@
{
"file": "format3_time_and_key_words.dkc",
"format": 3,
"capsule_id": "30e865a5c1e148c14410817a65eecfd1",
"datekey": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMH0",
"profile": "datekeys:quicknet:v1",
"round": 1000,
"unlock_at": "2023-08-23T15:59:24Z",
"access_policy": "time_and_key",
"valid": true,
"checks": [
{
"step": 1,
"name": "parse DKC1",
"ok": true,
"detail": "magic DKC1"
},
{
"step": 2,
"name": "prelude",
"ok": true,
"detail": "DKC1 v3, PUBLIC_HEADER_LEN=121, SEALED_CONTROL_LEN=2128"
},
{
"step": 3,
"name": "public header",
"ok": true,
"detail": "121 bytes"
},
{
"step": 4,
"name": "header validation",
"ok": true,
"detail": "capsule_id=30e865a5c1e148c14410817a65eecfd1 datekey=dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMH0 policy=time_and_key profile=datekeys:quicknet:v1"
},
{
"step": 5,
"name": "sealed control structure",
"ok": true,
"detail": "one tlock stanza"
},
{
"step": 6,
"name": "payload structure",
"ok": true,
"detail": "one X25519 stanza"
},
{
"step": 7,
"name": "condition",
"ok": true,
"detail": "round 1000, unlock at 2023-08-23T15:59:24Z"
},
{
"step": 8,
"name": "tlock stanza",
"ok": true,
"detail": "round 1000, chain 52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
}
]
}

@ -0,0 +1,267 @@
{
"description": "format 3 time_and_key capsule with one credential, a key of words, and 15 dummies: the text of the vector of the annex of spec v0.16 (79.7), «Ñandú», two spaces, «PINGÜINO», a tab and «camión árbol Éter ola», whose words are «nandu pinguino camion arbol eter ola»",
"spec": "0.16",
"format": 3,
"file": "format3_time_and_key_words.dkc",
"sha256": "64a11824630b6134892087a4d4ad3ee6e27941513507ad17fc87a7a2b4421e33",
"release": {
"round": 1000,
"signature": "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39"
},
"prelude": "444b4331030000000000007900000850",
"public_header": "a5006a646174656b65796361700101025030e865a5c1e148c14410817a65eecfd1037853646b315f65794a325a584a7a61573975496a6f784c434a755a58523362334a72496a6f695a4746305a57746c65584d3663585670593274755a585136646a45694c434a79623356755a4349364d5441774d48300401",
"datekey": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMH0",
"capsule_id": "30e865a5c1e148c14410817a65eecfd1",
"access_policy": "time_and_key",
"structure": "time_and_key",
"unlock_at": "2023-08-23T15:59:24Z",
"header_binding": "4bc6ecdcd80e37d0ed1f51ef781db6800564c309e222ba6151665ddd1fe4c99d",
"outer_stanzas": [
{
"type": "tlock",
"args": [
"1000",
"52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
]
}
],
"payload_stanzas": [
{
"type": "X25519",
"args": [
"y+DAEDO0p07P4YDE2pHXFhIpzVKiv+YUku15lbotTjM"
]
}
],
"inner_stanzas": [
{
"type": "X25519",
"args": [
"MSeAnfrz4I+Pn3yhL+/+hkNASJPwQzNQLxeiYu4YeBc"
]
},
{
"type": "X25519",
"args": [
"5VwR2r6e5MknAz//TJrRNRYOtQOeqCaTDdJ+A8QV+S8"
]
},
{
"type": "X25519",
"args": [
"X09hnKn4HOIFwf6H4GfJe3vSG5f5/EqDtV6Bx+OPgFY"
]
},
{
"type": "X25519",
"args": [
"GhhTyyFwZItGXCDKsG3sIcDYJrD9QU45ybqQxjUQUQ4"
]
},
{
"type": "X25519",
"args": [
"SS7ZyiY/U4O6PokUavgTMRMghx4lHDiJ+k+K/rsC8FU"
]
},
{
"type": "X25519",
"args": [
"zchoCmsfxz/dR7YbYGOEoMkSjrDVzOLt5al0CprXG2E"
]
},
{
"type": "X25519",
"args": [
"xg6iz12nCO/jm/rpa4k6aUM1mu2MUm7CwLJsF8qDwUA"
]
},
{
"type": "X25519",
"args": [
"NfHtp8ATUtya6UcudC1FTfiL2SMfrKCFj8V3/slMfEw"
]
},
{
"type": "X25519",
"args": [
"89gYRmkwISvkX1BNb/opcezkVOmNkj7mh89WkniGkWU"
]
},
{
"type": "X25519",
"args": [
"yBQHKKHENrGZfD9qysIoZ/2sMcSXvKClt6VUL4Sx6Sc"
]
},
{
"type": "X25519",
"args": [
"POtK0+b9IiRSRxlNBYm7DPzApiULuJaVWWOSHap4C00"
]
},
{
"type": "X25519",
"args": [
"2dGTxtsPQRUHGAros0o30jqTpEa4+6d5KRt4U86Qx2o"
]
},
{
"type": "X25519",
"args": [
"EKRbRpTDe5KZJgGgsfvnwb0iaHXkzVsqplsmCj3HzHY"
]
},
{
"type": "X25519",
"args": [
"+wHShSAq5PhGXD9ZHs+AwjXxq0TRbcpXwjf46fwW1wc"
]
},
{
"type": "X25519",
"args": [
"HRclh6xyQdsMOSV2MBP0ewe7JB+6EvgTod/4BOYXNAo"
]
},
{
"type": "X25519",
"args": [
"ZIfAt94wDxyQ4Y3WWw54v7H55b26Ye19HFn7USqSwnE"
]
}
],
"identity_stanzas": [
1
],
"identities": [
"AGE-SECRET-KEY-1CWYUF8E9RJ4SYWL8D7WN43M30XHFFFXD6LSZDRYS2WZ8CMUWWENSEXCGDP"
],
"words_text": "Ñandú PINGÜINO\tcamión árbol Éter ola",
"control_cbor": "a60070646174656b6579732d636f6e74726f6c01030258204bc6ecdcd80e37d0ed1f51ef781db6800564c309e222ba6151665ddd1fe4c99d035820a5f41e788e692ea55a3931bc5e25c7e6180ecc1d14235994198f8e00edb7420a064800000000000080b00702",
"payload_identity": "a5f41e788e692ea55a3931bc5e25c7e6180ecc1d14235994198f8e00edb7420a",
"payload_length": 32944,
"padding": 2,
"padded_length": 34816,
"plaintext_file": "format3_time_and_key_words.plaintext",
"plaintext_sha256": "2ff49df00ad9a37446c626be6d0353e94bd3bf41d73a6141e10f77b586abcb67",
"area_len": 32768,
"security_cbor": "a20071646174656b6579732d73656375726974790101",
"head_cbor": "a4006d646174656b6579732d686561640101025820702740f9850339d6907640e1de069200437bf8688288ed4cc735b37bd875eb260581a6006b7365637265746f2e74787401182e020003182e045820937492203d207d6fe36161b8696bf1f05b8b4cc56d855c44853f4b76aad3a05b051a6abcf9c0",
"salt": "702740f9850339d6907640e1de069200437bf8688288ed4cc735b37bd875eb26",
"content_offset": 32898,
"files": [
{
"path": "secreto.txt",
"size": 46,
"start": 0,
"end": 46,
"sha256": "937492203d207d6fe36161b8696bf1f05b8b4cc56d855c44853f4b76aad3a05b",
"mtime": 1790769600
}
],
"verdicts": {
"signature": "F0",
"seal": "S0",
"lines": [
"Sin firma de autor."
]
},
"stages": [
{
"step": 1,
"name": "parse DKC1",
"ok": true
},
{
"step": 2,
"name": "prelude",
"ok": true
},
{
"step": 3,
"name": "public header",
"ok": true
},
{
"step": 4,
"name": "header validation",
"ok": true
},
{
"step": 5,
"name": "sealed control structure",
"ok": true
},
{
"step": 6,
"name": "payload structure",
"ok": true
},
{
"step": 7,
"name": "condition",
"ok": true
},
{
"step": 8,
"name": "tlock stanza",
"ok": true
},
{
"step": 9,
"name": "access credential",
"ok": true
},
{
"step": 9,
"name": "release",
"ok": true
},
{
"step": 10,
"name": "release verification",
"ok": true
},
{
"step": 11,
"name": "open sealed control",
"ok": true
},
{
"step": 12,
"name": "policy structure",
"ok": true
},
{
"step": 13,
"name": "open access layer",
"ok": true
},
{
"step": 14,
"name": "control",
"ok": true
},
{
"step": 15,
"name": "header binding",
"ok": true
},
{
"step": 16,
"name": "payload identity",
"ok": true
},
{
"step": 17,
"name": "open payload",
"ok": true
},
{
"step": 18,
"name": "commit",
"ok": true
}
]
}

@ -1,6 +1,6 @@
{
"description": "format 3 time_only capsule with five files in three folders, one of them over two STREAM chunks and one without mtime, a comment of two lines and a declared author",
"spec": "0.15",
"spec": "0.16",
"format": 3,
"file": "format3_tree.dkc",
"sha256": "217f378faaf795f6a9c416b564fb8931bb2e896918aee870120fd14f9a5da7d1",

@ -1,6 +1,6 @@
{
"description": "format 3 time_only capsule with a single file, nota.txt, as format3_signed, without a signature: the area of 32 KiB of spec v0.11 holds the empty security, and P is the one of format3_signed",
"spec": "0.15",
"spec": "0.16",
"format": 3,
"file": "format3_unsigned.dkc",
"sha256": "317ab722ae3812a25ddd78b4c98c586363e5587c8d3634c881ce7c421af19168",

@ -1,6 +1,6 @@
{
"description": "portable X25519 .dkk of time_and_key_portable.dkc",
"spec": "0.15",
"spec": "0.16",
"file": "time_and_key_portable.dkk",
"sha256": "e528fa2c832c91119f0684bb9d6fb3c4c2d0d55183482890e7c4fe92f668426a",
"credential_id": "3955e944a3c60cfa1fd6485e9693c77d",

@ -1,6 +1,6 @@
{
"description": "time_and_key capsule whose only recipient is a portable .dkk",
"spec": "0.15",
"spec": "0.16",
"format": 1,
"file": "time_and_key_portable.dkc",
"sha256": "2e97878078bae6358037a9c264f379a3cbe839f767d69836b0343f35657b2972",

@ -1,6 +1,6 @@
{
"description": "portable X25519 .dkk of time_and_key_portable.dkc with a noncritical extension: the credential of time_and_key_portable.dkk re-issued with org.example.delivery",
"spec": "0.15",
"spec": "0.16",
"file": "time_and_key_portable_extension.dkk",
"sha256": "0bf463a7c65627b7dda2234d728df89ec5b835816a2a37b91497d8fecc5ea548",
"credential_id": "3955e944a3c60cfa1fd6485e9693c77d",

@ -1,6 +1,6 @@
{
"description": "portable X25519 .dkk of time_and_key_recipients.dkc",
"spec": "0.15",
"spec": "0.16",
"file": "time_and_key_recipients.dkk",
"sha256": "19f6c47150c3194712d454f43c7392b7344e6b4e7b074d83e9ca5f563a8e072f",
"credential_id": "b89292aedf6d05d584cec9a871ce8735",

@ -1,6 +1,6 @@
{
"description": "time_and_key capsule for two known X25519 recipients and a portable .dkk",
"spec": "0.15",
"spec": "0.16",
"format": 1,
"file": "time_and_key_recipients.dkc",
"sha256": "69ac110380f5d768b5b6afaa157a50ed17d8ceccfbd4604ffa5b6da38539b635",

@ -1,6 +1,6 @@
{
"description": "time_only capsule, two STREAM chunks, no extensions",
"spec": "0.15",
"spec": "0.16",
"format": 1,
"file": "time_only.dkc",
"sha256": "99e915810d595f1092700b728f5e5081d78efe83f5343e76325b1bcc2c33ccf2",

@ -1,6 +1,6 @@
{
"description": "time_only capsule with a noncritical PUBLIC_HEADER extension and a noncritical CONTROL_CBOR extension",
"spec": "0.15",
"spec": "0.16",
"format": 1,
"file": "time_only_extensions.dkc",
"sha256": "0446c9b73e267adcb24e5cc89afba2544a386ec9a050016e06517a4a57aa2085",

@ -1,5 +1,5 @@
{
"spec": "0.15",
"spec": "0.16",
"description": "CBOR profile of spec §58 and the schemas of spec/datekeys.cddl, generated by the reference implementation. accept and reject are walked as one data item of the profile with the limits of walk; schemas are decoded with the decoder of their schema. See testdata/README.md.",
"walk": {
"max_depth": 3,

@ -1,5 +1,5 @@
{
"spec": "0.15",
"spec": "0.16",
"description": "Canonical dk1_ strings and rejected encodings (spec §18, §19, §66), generated by the reference implementation.",
"vectors": [
{

@ -1,5 +1,5 @@
{
"spec": "0.15",
"spec": "0.16",
"description": "Ed25519 signatures and the result of the strict profile of the author signature (spec v0.11, §29.9), after the cases of «Taming the many EdDSAs»; stdlib is the result of crypto/ed25519 of Go, for the record. Generated by the reference implementation. See testdata/README.md.",
"vectors": [
{

@ -1,5 +1,5 @@
{
"spec": "0.15",
"spec": "0.16",
"description": "HEAD_CBOR of format 3 (spec §29.4 to §29.6) and the result of decoding it with no extension known, generated by the reference implementation: layer 2 (type tag and version), layer 3 (the CDDL with R1 and R8), then layer 4 in key order (spec §69.1). See testdata/README.md.",
"heads": [
{

@ -1,5 +1,5 @@
{
"spec": "0.15",
"spec": "0.16",
"description": "Differential corpus of the pre-unlock checks (spec §63 steps 1 to 8): deterministic mutations of the official .dkc fixtures with the verdict of the reference implementation. See testdata/README.md.",
"format": "Each mutation is bases[base].file (in testdata/fixtures) with its edits applied. An edit is [at, delete, insert]: the delete bytes at offset at of the base are replaced by the bytes of the hex string insert. The edits of one mutation refer to offsets of the unmodified base, are sorted by offset and do not overlap. result is the verdict of steps 1 to 8 of spec §63 (capsule.Inspect, the Quicknet profile pinned, no extension known, no network, no secret): ok, or the normative error code, with step the step that failed. kind names the generator of the mutation and is informative.",
"seed": 20260925,

@ -1,6 +1,6 @@
{
"description": "The extension datekeys.capsule of a .dkk and what it points to (spec v0.12, 44.1): an envelope of age with its header apart from its rest, the rest hidden in a host file, the locator sealed with tlock for round 1000, and the data of the extension. On the same envelope, what a reader rejects and what it uses (64): addresses, a locator with rejected and usable addresses, resources of the rest, data of the extension and plaintexts of the locator. Frozen. See testdata/README.md.",
"spec": "0.15",
"spec": "0.16",
"round": 1000,
"datekey": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMH0",
"note": "Cartas del viaje a Lisboa",

@ -1,5 +1,5 @@
{
"spec": "0.15",
"spec": "0.16",
"description": "Mutation corpus of spec §64 and further cases of capsule.TestMutationCorpus, generated by the reference implementation: each case is a .dkc and what the reader is given, with the normative error and the step of spec §63 at which capsule.Open fails. See testdata/README.md.",
"cases": [
{

@ -1,5 +1,5 @@
{
"spec": "0.15",
"spec": "0.16",
"description": "The data of the public note, datekeys.note version 1 in the noncritical array of PUBLIC_HEADER (spec §24.1): the text in UTF-8, from 1 to 1024 bytes, that meets the rules of the declared author of §29.6. See testdata/README.md.",
"notes": [
{

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save

Powered by TurnKey Linux.