Compare commits

...

17 Commits

Author SHA1 Message Date
dev b6ff17a5fa Spec v0.16 approved: its date and its SHA-256
3 hours ago
dev 3fd0e9372b v0.16: the escapes of the JSON vectors, restored
3 hours ago
dev 4f7885495b v0.16: SpecVersion 0.16, the annex of the draft, and the tests in 76
4 hours ago
dev 7ef9e61c60 v0.16: the key of words in the recovery annex, and a full last chunk
4 hours ago
dev b57033813d v0.16: drand's JSON is read strictly
4 hours ago
dev 7e3b8104a6 v0.16: a seal without accuracy proves nothing before the opening date
5 hours ago
dev 4c7a48dc77 Merge branch 'v0.15' into v0.16
5 hours ago
dev 70d907b7d3 License of the specification: CC-BY-ND-4.0, also in the annex title
5 hours ago
dev 2c29625b47 Spec v0.16 draft: the ETSI clauses of the BTSP policy, checked
5 hours ago
dev a2b71c26c6 Spec v0.16 draft: Astra's review of v0.15
5 hours ago
dev 6158be2b2d CLI help: never a password used elsewhere as the words of a key
6 hours ago
dev aefc8f6dfe What the official SDK says when it seals: the recovery annex, §7.6 and §71
6 hours ago
dev 92e7154c23 Dice: the words of five dice each, encrypt -dice and datekeys wordlist
8 hours ago
dev e671032e04 The English list: the large wordlist of the EFF, and -dic en by default
9 hours ago
dev 96d89927a4 TestGenerate draws words: its seed gave only two indices
9 hours ago
dev 27a75eefcd The alphabet of a word list and the strength of the words drawn
10 hours ago
dev c49c67ce83 Random words for a key of words: wordkey.Generate and encrypt -new-words
20 hours ago

@ -3,6 +3,47 @@
All notable changes to this module are documented here. The project follows
semantic versioning; `v0.x` versions make no API stability promise.
## Unreleased — specification v0.16
Implements the DateKeys Protocol Specification v0.16, which its author
approved on 7 October 2026 with the recommendation of each of its decisions,
tagged `spec-v0.16`: the review of v0.15 by Astra. It changes no format; it
changes the verdict of a seal without accuracy and the reading of drand's
JSON.
- **Approval.** `SpecVersion` is 0.16, and so is the `spec` field of every
file of `testdata`, also of the frozen `security_cms.json` and
`locator.json`. The text approved is the draft with its date;
`spec/README.md` records its SHA-256, and the annex says it.
- **A seal without accuracy** (§29.7, §29.11). `cms.Token` gains
`HasAccuracy`, `Policy` and `BTSP`. A valid seal is S4 only with accuracy
and t plus the accuracy before the round time; otherwise S5, whose text
gives its reason, `capsule.SealReason`: sealed after or too close, no
accuracy under the BTSP policy of ETSI EN 319 421, or no accuracy.
`Detail.SealReason` and `SignerLine.Reason` carry it, and the line of a
signer of F6 whose seal does not prove it says «sin acreditar que fuera
antes de la fecha de apertura» and the reason. `EncryptFiles` returns the
verdicts of the area it wrote in `Result.Security`, so that a writer warns
of a seal without accuracy (§62.1 rule 19). `security_cms.json` is made
again: 143 cases with `seal_reason`.
- **drand's JSON, strict** (§47.1). `provider.ParseDrandJSON`, the one
reader of it, for a release in hand and for the answers of the relays:
no object repeats a name, names compared exactly once their escapes are
decoded, no lone surrogate, and round a number without sign, fraction or
exponent from 1 to 2^53 - 1. `encoding/json` kept the last of two repeated
names and matched `ROUND` to `round`. `release.json` gains 26 cases.
- **The key of words in the annex** (§79, 79.7). `scripts/recovery` opens
with `-words FILE`, with the normalization without tables of the annex or,
with `-unicodedata FILE`, the full one from `UnicodeData.txt` of Unicode
18.0.0, checked by its SHA-256. The fixture `format3_time_and_key_words`
opens with the text of the vector of the annex, kept in `words_text`.
- **A full last chunk** (79.5). The fixture `format3_full_chunk`, whose
`PAYLOAD_AGE` is one full STREAM chunk; `scripts/recovery_check.sh` opens
it, and the one with words.
- **The annex.** `annex/recovery.md` is §79 of the draft v0.16, with the
key of words in 79.7.
## Unreleased — specification v0.15
Implements the DateKeys Protocol Specification v0.15, which its author
@ -11,6 +52,60 @@ tagged `spec-v0.15`: the long-term recovery of capsules. It changes no format
of `.dkc` or `.dkk`, and one verdict: a valid release in the caller's hand
opens a capsule even when the clock is before the round time.
- **Random words.** `wordkey.Generate` draws a key of words uniformly from
a built-in list, and `encrypt -new-words FILE [-dic LIST] [-word-count N]`
writes them to a new file: 7 words by default, of the Spanish list
`wordkey/lists/es.txt`, 7776 words, a draft not yet reviewed (spec §38.1,
the SHOULD to offer random words). `wordkey.List` and `CheckList(lang,
words)` refuse a list of fewer than 2048 words, with two words that are
one once normalized, or with a character that is not a letter of the
alphabet of its language, which only the code gives (for `es`, `a` to `z`,
`á`, `é`, `í`, `ó`, `ú`, `ü` and `ñ`): a Cyrillic letter that looks like a
Latin one would be typed again with the Latin one, and the capsule would
not open. `wordkey.Bits` is the strength of the words drawn, which
`encrypt` prints. The list is CC BY-SA 4.0, an adaptation of
FrequencyWords; `wordkey/lists/README.md` records its source, method and
SHA-256. It changes no format and no derivation.
- **The English list.** `wordkey/lists/en.txt` is the large wordlist of the
EFF, 7776 words, CC BY 4.0, without its dice numbers and in its order, so
that the position of a word still gives them; `-dic` takes it by default.
The alphabet of `en` is `a` to `z` and the ASCII hyphen of its four
compound words, such as `t-shirt`.
- **What the official SDK says when it seals** (spec §7.6, §62.1 rules 26
and 27, §71). `encrypt` writes next to the `.dkc` the recovery annex,
`FILE.dkc.recuperacion.txt` (`datekeys.RecoveryAnnex`, §79 of the
specification under a title with its version and SHA-256, the same for
every capsule; `-no-recovery` leaves it out), and says what opening the
capsule years later will take: the `.dkc`, a credential of a
`time_and_key` capsule, and the release of its round, which an archive
of releases or a cache service must keep if drand no longer serves it.
Beyond one year, `time_only` gets the recommendation of `time_and_key`.
`profile.Status` and `StatusOf` give the state of a pinned profile in
the registry of §71, which DateKeys does not publish yet (Quicknet is
active); `encrypt` writes no capsule with a profile that is not active,
and `decrypt` and `inspect` warn when the profile of a capsule is
compromised.
- **The license of the specification** is CC-BY-ND-4.0, as its author
decided: it may be copied and shared unchanged, with credit, and a
modified version or a translation needs the written permission of its
author. The
title of the recovery annex says so, since the annex travels alone next
to every capsule. The code stays Apache-2.0, and the word lists keep
their own licenses.
- **Dice.** For whoever does not trust the random numbers of a computer,
as the author decided: five dice for each word give a number from 11111
to 66666, its position in a list of 7776 words. `wordkey.DiceNumber`,
`DiceWord`, `DiceWords` (at least 6 numbers, never the same word twice)
and `DiceList`, the list numbered for dice as the EFF publishes its own:
for `en` it is the file of the EFF, byte for byte. `encrypt -dice TEXT`
and `-dice-file FILE` take the words from dice and show them, and
`datekeys wordlist [-dic LIST]` writes the numbered list, to print it,
and its SHA-256, which `wordkey/lists/README.md` records.
- **Approval.** `SpecVersion` is 0.15, and so is the `spec` field of every
file of `testdata`: the records and the vectors, regenerated, and the
frozen `security_cms.json` and `locator.json`, whose `spec` field alone

@ -1,12 +1,13 @@
# datekeys-go
Implementación de referencia en Go de la **DateKeys Protocol Specification
v0.15** ([`spec/`](spec/DateKeys_Protocol_Specification_v0.15.md)), etiquetada
`spec-v0.15`,
sobre la recuperación de cápsulas a largo plazo: el objeto release, un
release en la mano que el reloj no detiene, archivos y servicios de caché que
guardan los releases de todas las rondas, y un anexo para abrir una cápsula
sin software de DateKeys.
v0.16** ([`spec/`](spec/DateKeys_Protocol_Specification_v0.16.md)), etiquetada
`spec-v0.16`: un sello sin precisión no acredita nada antes de la fecha de
apertura, el JSON de drand se lee de forma estricta y el anexo para abrir una
cápsula sin software de DateKeys deriva también una llave de palabras. La
v0.15 trajo la recuperación de cápsulas a largo plazo: el objeto release, un
release en la mano que el reloj no detiene, y archivos y servicios de caché
que guardan los releases de todas las rondas.
[English version](README.md).
DateKeys cifra datos de forma que solo puedan abrirse a partir de un instante
@ -66,13 +67,13 @@ Hay tres números de versión, cada uno con su significado:
| Versión | Dónde | Cambia cuando |
|---|---|---|
| Formato | Dentro de los objetos: el formato de la cápsula, el `VERSION` del prelude de DKC1, 3 al escribir y de 1 a 3 al leer, que es también la versión de schema de CONTROL_CBOR; y 1 en la trama de DKK1 y en el schema de los demás objetos, incluidos el head y `security` del formato 3 | Cambia el formato. Un lector rechaza una versión que no conoce (spec §22, §70) |
| Especificación | `datekeys.SpecVersion`, hoy `0.15`, y el tag `spec-v0.15`. Un borrador, como lo fue la v0.15, no tiene tag ni la cambia | Cambia el texto normativo. §76 del spec recoge cada cambio con su caso |
| Especificación | `datekeys.SpecVersion`, hoy `0.16`, y el tag `spec-v0.16`. Un borrador, como lo fue la v0.16, no tiene tag ni la cambia | Cambia el texto normativo. §76 del spec recoge cada cambio con su caso |
| Módulo | Los tags de este módulo Go, `vX.Y.Z`, y `datekeys.Version()` | Cambia la API o el comportamiento. Versionado semántico, sin promesa de estabilidad antes de v1.0.0 |
`datekeys version` imprime la versión del módulo, la del spec y la del toolchain de Go. Un binario compilado en un checkout muestra la pseudo-versión de su commit, por ejemplo `v0.0.0-20260928105528-9ac9cd952f04`.
Cada release dice qué cubre, aquí y en el [CHANGELOG](CHANGELOG.md). El código de esta rama, aún sin publicar, cubre:
- la especificación 0.15: escribe el formato 3 de cápsula y lee los formatos 1, 2 y 3;
- la especificación 0.16: escribe el formato 3 de cápsula y lee los formatos 1, 2 y 3;
- el perfil Quicknet pinneado, y cualquier perfil del scheme `bls-unchained-g1-rfc9380`, el único que admite la v0.14 del spec;
- cifrado, inspección y apertura, firmas de autor y sellos, la nota pública, la llave de palabras y el localizador, y la CLI;
- todos los vectores y fixtures compartidos de [`testdata/`](testdata).
@ -137,6 +138,8 @@ datekeys profile hash
datekeys version
```
`encrypt` escribe junto a la cápsula `FICHERO.dkc.recuperacion.txt`, el anexo de la especificación sobre cómo abrir una cápsula sin software de DateKeys (spec §79), salvo con `-no-recovery`; y dice qué hará falta para abrirla años después: el `.dkc`, una credencial si es `time_and_key` y el release de su ronda, que tendrá que conservar un archivo de releases o un servicio de caché si drand ya no lo sirve (spec §50). A más de un año, recomienda `time_and_key` a una cápsula `time_only` (spec §7.6). Con un perfil que no esté activo en el registro de §71 no escribe ninguna cápsula, y `decrypt` e `inspect` avisan si el perfil de una cápsula está comprometido.
`encrypt` nunca usa la red. Cada `-in` es un fichero o una carpeta; una
carpeta da su nombre como primer segmento de sus rutas, como hace un
navegador, y se recorre sin seguir enlaces, tomando solo ficheros regulares y
@ -180,7 +183,17 @@ creador que nadie ha comprobado; una nota que incumple las reglas de texto no
se muestra, y los dos lo dicen (`public_note_unusable` en `inspect -json`).
`-words` y `-words-file` dan a una cápsula `time_and_key` una llave de
palabras: al menos seis palabras distintas de tres letras o más, que la abren
con `decrypt -words-file` en lugar de una `.dkk` (spec §38.1).
con `decrypt -words-file` en lugar de una `.dkk` (spec §38.1). `-new-words
FICHERO` las sortea en su lugar, 7 por defecto, de una lista incluida
(`-dic en`, la de la EFF, por defecto, o `-dic es`; `-word-count N`), las
escribe en un fichero nuevo y dice su fuerza en bits: las palabras que elige
una persona son más débiles. `-dice` y `-dice-file` las sacan de unos dados,
para quien no se fíe del azar de un ordenador: cinco dados por palabra dan un
número del 11111 al 66666, su posición en la lista, y `datekeys wordlist`
escribe la lista numerada para los dados, para imprimirla, con su SHA-256;
la de `en` es el fichero de la EFF, byte a byte. `encrypt` muestra las
palabras de los dados: la cápsula la abren ellas, no los números. Una lista solo se usa si cada palabra es del alfabeto de su idioma.
Las listas y su licencia están en [`wordkey/lists`](wordkey/lists/README.md).
## Librería
@ -315,6 +328,6 @@ go test -tags integration ./capsule ./provider/drand # Quicknet en vivo
## Licencia
Código: Apache-2.0 ([LICENSE](LICENSE)). Especificación: CC-BY-4.0
Código: Apache-2.0 ([LICENSE](LICENSE)). Especificación: CC-BY-ND-4.0
([spec/README.md](spec/README.md)). `codec/bech32` se copia de age bajo su
propia licencia. "DateKeys" es un nombre reservado: ver [TRADEMARKS.md](TRADEMARKS.md).

@ -1,12 +1,13 @@
# datekeys-go
Reference implementation in Go of the **DateKeys Protocol Specification
v0.15** ([`spec/`](spec/DateKeys_Protocol_Specification_v0.15.md)), tagged
`spec-v0.15`,
on the long-term recovery of capsules: the release object, a release in
hand that the clock does not stop, archives and cache services that keep the
releases of all rounds, and an annex to open a capsule without DateKeys
software.
v0.16** ([`spec/`](spec/DateKeys_Protocol_Specification_v0.16.md)), tagged
`spec-v0.16`: a seal without accuracy proves nothing before the opening date,
drand's JSON is read strictly, and the annex to open a capsule without
DateKeys software derives a key of words too. v0.15 brought the long-term
recovery of capsules: the release object, a release in hand that the clock
does not stop, and archives and cache services that keep the releases of all
rounds.
[Versión en español](README.es.md).
DateKeys encrypts data so that it can only be opened after a chosen instant.
@ -66,13 +67,13 @@ Three version numbers, each with its own meaning:
| Version | Where | Changes when |
|---|---|---|
| Format | Inside the objects: the capsule format, the `VERSION` of the DKC1 prelude, 3 when written and 1 to 3 when read, which is also the schema version of CONTROL_CBOR; and 1 for the framing of DKK1 and the schema of the other objects, the head and security of format 3 included | The format changes. A reader rejects a version it does not know (spec §22, §70) |
| Specification | `datekeys.SpecVersion`, today `0.15`, and the tag `spec-v0.15`. A draft, such as v0.15 was, has no tag and does not change it | The normative text changes. Spec §76 records each change with its case |
| Specification | `datekeys.SpecVersion`, today `0.16`, and the tag `spec-v0.16`. A draft, such as v0.16 was, has no tag and does not change it | The normative text changes. Spec §76 records each change with its case |
| Module | The tags of this Go module, `vX.Y.Z`, and `datekeys.Version()` | The API or the behaviour changes. Semantic versioning, with no stability promise before v1.0.0 |
`datekeys version` prints the module version, the specification and the Go toolchain. A binary built in a checkout shows the pseudo-version of its commit, for example `v0.0.0-20260928105528-9ac9cd952f04`.
Each release states what it covers, here and in the [CHANGELOG](CHANGELOG.md). The code of this branch, not yet released, covers:
- specification 0.15: it writes capsule format 3 and reads formats 1, 2 and 3;
- specification 0.16: it writes capsule format 3 and reads formats 1, 2 and 3;
- the pinned Quicknet profile, and any profile of the scheme `bls-unchained-g1-rfc9380`, the only one spec v0.14 admits;
- encryption, inspection and opening, author signatures and seals, the public note, the key of words and the locator, and the CLI;
- every shared vector and fixture of [`testdata/`](testdata).
@ -126,6 +127,8 @@ datekeys encrypt -at 2030-01-01T00:00:00Z -in letter.txt -out letter.dkc
datekeys encrypt -at 2030-01-01T00:00:00Z -in photos -in letter.txt -comment "For Ana" -author "Juan" -out gift.dkc
datekeys encrypt -at 2030-01-01T00:00:00Z -policy time_and_key -dkk gift.dkk -in photos -out gift.dkc
datekeys encrypt -at 2030-01-01T00:00:00Z -policy time_and_key -words-file words.txt -in letter.txt -out letter.dkc
datekeys wordlist -dic en > words-for-dice.txt
datekeys encrypt -at 2030-01-01T00:00:00Z -policy time_and_key -dice-file dice.txt -in letter.txt -out letter.dkc
datekeys encrypt -at 2030-01-01T00:00:00Z -padding bloque256 -no-mtime -in letter.txt -out letter.dkc
datekeys author keygen -out author.key -pass-file pass.txt
datekeys encrypt -at 2030-01-01T00:00:00Z -in letter.txt -note "Letters from Lisbon" -sign author.key -sign-pass-file pass.txt -out letter.dkc
@ -137,6 +140,8 @@ datekeys profile hash
datekeys version
```
`encrypt` writes next to the capsule `FILE.dkc.recuperacion.txt`, the annex of the specification, in Spanish, on how to open a capsule without DateKeys software (spec §79), unless `-no-recovery`; and says what opening the capsule years later will take: the `.dkc`, a credential of a `time_and_key` capsule and the release of its round, which an archive of releases or a cache service must keep if drand no longer serves it (spec §50). Beyond one year, it recommends `time_and_key` to a `time_only` capsule (spec §7.6). A profile that is not active in the registry of §71 writes no capsule, and `decrypt` and `inspect` warn when the profile of a capsule is compromised.
`encrypt` never touches the network. Each `-in` is a file or a folder; a
folder gives its name as the first segment of its paths, as a browser does,
and is walked without following links, taking regular files only and leaving
@ -179,7 +184,18 @@ that nobody has checked; a note that breaks the rules of text is not shown,
and both say so (`public_note_unusable` in `inspect -json`).
`-words` and `-words-file` give a `time_and_key` capsule a key of words: at
least six different words of three or more letters, which open it with
`decrypt -words-file` instead of a `.dkk` (spec §38.1).
`decrypt -words-file` instead of a `.dkk` (spec §38.1). `-new-words FILE`
draws them at random instead, 7 by default, from a built-in list (`-dic
en`, the list of the EFF, by default, or `-dic es`; `-word-count N`), writes
them to a new file and says their strength in bits: words a person chooses
are weaker. `-dice` and `-dice-file` take them from dice instead, for
whoever does not trust the random numbers of a computer: five dice for each
word give a number from 11111 to 66666, its position in the list, and
`datekeys wordlist` writes the list numbered for dice, to print it, with its
SHA-256; for `en` it is the file of the EFF, byte for byte. `encrypt` shows
the words of the dice: they open the capsule, not the numbers. A
list is used only if each word is of the alphabet of its language. The lists
and their license are in [`wordkey/lists`](wordkey/lists/README.md).
## Library
@ -310,6 +326,6 @@ go test -tags integration ./capsule ./provider/drand # live Quicknet
## License
Code: Apache-2.0 ([LICENSE](LICENSE)). Specification: CC-BY-4.0
Code: Apache-2.0 ([LICENSE](LICENSE)). Specification: CC-BY-ND-4.0
([spec/README.md](spec/README.md)). `codec/bech32` is copied from age under its
own license. "DateKeys" is a reserved name: see [TRADEMARKS.md](TRADEMARKS.md).

@ -19,8 +19,8 @@ The module is pre-1.0 (`v0.x`). Only the latest `v0.x` release receives fixes.
## Scope and assumptions
In scope: every rule of the DateKeys Protocol Specification v0.15, tagged
`spec-v0.15`, that this module implements (see `docs/traceability.md`), the CLI, and the handling of
In scope: every rule of the DateKeys Protocol Specification v0.16, tagged
`spec-v0.16`, that this module implements (see `docs/traceability.md`), the CLI, and the handling of
untrusted input (`.dkc`, `.dkk`, relay responses, author key files). Among it:
the paths and texts of a format 3 head, which the CLI writes to disk and to a
terminal; the signatures, certificates and seals of the security area, whose

@ -1,7 +1,7 @@
# Trademarks
"DateKeys" is a reserved name of the DateKeys project. The Apache-2.0 license
of the code and the CC-BY-4.0 license of the specification do not grant any
of the code and the CC-BY-ND-4.0 license of the specification do not grant any
right to use it as a product, service or organisation name (Apache-2.0 §6).
Allowed without asking:

@ -0,0 +1,21 @@
package datekeys
import _ "embed"
// RecoveryAnnex is the text that the official SDK saves next to each .dkc
// (spec §62.1, rule 27): the informative annex of the specification on how
// to open a capsule without DateKeys software (spec §79), under a title that
// names the version of the specification and the SHA-256 of its text. It is
// the same for every capsule and holds nothing of one. TestRecoveryAnnex
// checks that it is §79 of spec/DateKeys_Protocol_Specification_v<SpecVersion>.md,
// and writes it again, when a new version is approved, with
//
// DATEKEYS_WRITE_ANNEX=1 go test -run TestRecoveryAnnex .
//
//go:embed annex/recovery.md
var RecoveryAnnex string
// RecoveryAnnexSuffix is what the official SDK appends to the name of a .dkc
// to name the file of its recovery annex: carta.dkc.recuperacion.txt. The
// annex is in Spanish, as the specification.
const RecoveryAnnexSuffix = ".recuperacion.txt"

@ -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.

@ -0,0 +1,55 @@
package datekeys
import (
"crypto/sha256"
"fmt"
"os"
"strings"
"testing"
)
// recoveryAnnex is the text of RecoveryAnnex for the specification spec:
// its §79, from its title to the end of the document, under a title of its
// own and a paragraph that names the version and the SHA-256 of spec.
func recoveryAnnex(spec []byte) (string, error) {
text := string(spec)
i := strings.Index(text, "\n## 79. ")
if i < 0 {
return "", fmt.Errorf("the specification %s has no §79", SpecVersion)
}
return fmt.Sprintf("# Cómo abrir una cápsula DateKeys sin software de DateKeys\n\n"+
"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 v%s, cuyo texto tiene el SHA-256 %x. 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.\n\n%s\n",
SpecVersion, sha256.Sum256(spec), strings.TrimRight(text[i+1:], " \n")), nil
}
func TestRecoveryAnnex(t *testing.T) {
spec, err := os.ReadFile("spec/DateKeys_Protocol_Specification_v" + SpecVersion + ".md")
if err != nil {
t.Fatal(err)
}
want, err := recoveryAnnex(spec)
if err != nil {
t.Fatal(err)
}
if os.Getenv("DATEKEYS_WRITE_ANNEX") == "1" {
if err := os.WriteFile("annex/recovery.md", []byte(want), 0o644); err != nil {
t.Fatal(err)
}
t.Skip("wrote annex/recovery.md; build the package again to embed it")
}
if RecoveryAnnex != want {
t.Fatalf("annex/recovery.md is not §79 of the specification %s: write it again with DATEKEYS_WRITE_ANNEX=1 go test -run TestRecoveryAnnex .", SpecVersion)
}
for _, s := range []string{"## 79. Anexo informativo: recuperación sin software DateKeys", "### 79.7 La llave de palabras", "### 79.9 Lo que el anexo no comprueba", "DateKeys v" + SpecVersion + ","} {
if !strings.Contains(RecoveryAnnex, s) {
t.Errorf("the annex lacks %q", s)
}
}
if !strings.HasSuffix(RecoveryAnnex, "conforme.\n") || strings.Contains(RecoveryAnnex, "\r") {
t.Errorf("the annex ends with %q", RecoveryAnnex[len(RecoveryAnnex)-20:])
}
}

@ -17,13 +17,13 @@ import (
// testdata/vectors/security_cms.json is frozen: each case is evaluated again,
// with its context, and must give its verdicts, the result of each signer,
// the authority and the time of a valid seal, and its lines, byte for byte
// (spec v0.12 §29.7, §29.10, §29.11).
// (spec v0.16 §29.7, §29.10, §29.11).
func TestCMSVectors(t *testing.T) {
var f testkit.CMSVectorFile
if err := testkit.ReadJSON(filepath.Join("..", "testdata", "vectors", "security_cms.json"), &f); err != nil {
t.Fatal(err)
}
if f.Spec != datekeys.SpecVersion || !strings.Contains(f.Description, "v0.12") || len(f.Cases) < 100 {
if f.Spec != datekeys.SpecVersion || !strings.Contains(f.Description, "v0.16") || len(f.Cases) < 140 {
t.Fatalf("spec %q, %d cases: %s", f.Spec, len(f.Cases), f.Description)
}
seen := map[string]bool{}
@ -56,15 +56,16 @@ func TestCMSVectors(t *testing.T) {
t.Fatalf("verdicts %s and %s, want %s and %s", v.Signature, v.Seal, c.Signature, c.Seal)
}
var signers, foreign []testkit.FixtureSignerResult
var holder, when string
var holder, when, reason string
if d := v.Detail; d != nil {
signers, foreign = vectorSignerResults(d.Signers), vectorSignerResults(d.Foreign)
if !d.SealTime.IsZero() {
holder, when = d.SealHolder, d.SealTime.UTC().Format(time.RFC3339Nano)
}
reason = string(d.SealReason)
}
if !reflect.DeepEqual(signers, c.Signers) || !reflect.DeepEqual(foreign, c.Foreign) || holder != c.SealHolder || when != c.SealTime {
t.Errorf("signers %+v foreign %+v seal %q %q; want %+v %+v %q %q", signers, foreign, holder, when, c.Signers, c.Foreign, c.SealHolder, c.SealTime)
if !reflect.DeepEqual(signers, c.Signers) || !reflect.DeepEqual(foreign, c.Foreign) || holder != c.SealHolder || when != c.SealTime || reason != c.SealReason {
t.Errorf("signers %+v foreign %+v seal %q %q %q; want %+v %+v %q %q %q", signers, foreign, holder, when, reason, c.Signers, c.Foreign, c.SealHolder, c.SealTime, c.SealReason)
}
if lines := v.Lines(); !slices.Equal(lines, c.Lines) {
t.Errorf("lines %q, want %q", lines, c.Lines)
@ -97,7 +98,7 @@ func TestCMSVectors(t *testing.T) {
func vectorSignerResults(lines []capsule.SignerLine) []testkit.FixtureSignerResult {
var out []testkit.FixtureSignerResult
for _, l := range lines {
r := testkit.FixtureSignerResult{Holder: l.Holder, Issuer: l.Issuer, Result: l.Result, Before: l.Before}
r := testkit.FixtureSignerResult{Holder: l.Holder, Issuer: l.Issuer, Result: l.Result, Before: l.Before, SealReason: string(l.Reason)}
if !l.SealTime.IsZero() {
r.SealTime = l.SealTime.UTC().Format(time.RFC3339Nano)
}

@ -11,6 +11,7 @@ import (
"path/filepath"
"reflect"
"slices"
"strings"
"testing"
"time"
@ -24,6 +25,7 @@ import (
"g.activething.com/go/DateKeys/internal/testkit"
"g.activething.com/go/DateKeys/profile"
"g.activething.com/go/DateKeys/provider"
"g.activething.com/go/DateKeys/wordkey"
)
const fixtureDir = "../testdata/fixtures"
@ -429,7 +431,7 @@ func checkSignatureRecord(t *testing.T, s *testkit.FixtureSignature, security []
func signerResults(lines []capsule.SignerLine) []testkit.FixtureSignerResult {
var out []testkit.FixtureSignerResult
for _, l := range lines {
r := testkit.FixtureSignerResult{Holder: l.Holder, Issuer: l.Issuer, Result: l.Result, Before: l.Before}
r := testkit.FixtureSignerResult{Holder: l.Holder, Issuer: l.Issuer, Result: l.Result, Before: l.Before, SealReason: string(l.Reason)}
if !l.SealTime.IsZero() {
r.SealTime = l.SealTime.UTC().Format(time.RFC3339)
}
@ -600,6 +602,33 @@ func TestFixtureRecipients(t *testing.T) {
}
}
// format3_time_and_key_words opens with the identity that its words give
// (spec §38.1): the text of the annex vector of v0.16, 79.7, with capitals,
// accents, two spaces and a tab.
func TestFixtureWords(t *testing.T) {
f := loadFixture(t, "format3_time_and_key_words")
if f.WordsText == "" || len(f.ids) != 1 || f.AccessKeyFile != "" {
t.Fatalf("the record: words %q, %d identities, .dkk %q", f.WordsText, len(f.ids), f.AccessKeyFile)
}
words := wordkey.Normalize(f.WordsText)
if strings.Join(words, " ") != "nandu pinguino camion arbol eter ola" {
t.Fatalf("words %q", words)
}
capsuleID, err := hex.DecodeString(f.CapsuleID)
if err != nil {
t.Fatal(err)
}
id, err := wordkey.Identity(words, profile.Quicknet().ChainHash[:], f.Release.Round, capsuleID)
if err != nil || id.String() != f.Identities[0] {
t.Fatalf("the identity of the words: %v", err)
}
o := f.openOptions(t)
o.Identities = []age.Identity{id}
if _, err := capsule.Open(context.Background(), io.Discard, bytes.NewReader(f.dkc), o); err != nil {
t.Fatalf("open with the words: %v", err)
}
}
// A non-seekable reader works too: only the capsule_digest shortcut is skipped.
func TestOpenFromPlainReader(t *testing.T) {
for _, name := range []string{"time_only", "time_and_key_portable", "format2_time_only", "format2_time_and_key_portable"} {

@ -135,6 +135,13 @@ type Result struct {
// PortableKey is the .dkk generated when NewPortableKey is set. Encode it
// with accesskey.Encode and treat it as a sensitive capability.
PortableKey *accesskey.AccessKey
// Security are the verdicts of the security area that EncryptFiles
// wrote, as a reader of this capsule finds them; zero for Encrypt. A
// valid seal whose reason is not ReasonNone, S5 or the line of a signer
// of F6, will not prove that it came before the opening date: the writer
// warns of it, and offers to ask another authority (spec v0.16, §62.1
// rule 19).
Security Verdicts
}
// Encrypt writes a format 2 .dkc for the content read from src (spec §61 and
@ -188,6 +195,8 @@ type sealer struct {
unlock time.Time
credentials []age.Recipient
portable *age.X25519Identity
// verdicts are those of the security area that security wrote last.
verdicts Verdicts
}
// newSealer validates the options that do not depend on the content, with

@ -188,7 +188,7 @@ func EncryptFiles(dst io.Writer, sources []Source, opts EncryptOptions) (*Result
if err != nil {
return nil, err
}
res.Head = h
res.Head, res.Security = h, s.verdicts
return res, nil
}
@ -204,9 +204,11 @@ func (s *sealer) security(c *Control, head []byte) ([]byte, error) {
sc := &SecurityContext{HeadDigest: HeadDigest(head), RoundTime: s.unlock}
if o.AuthorKey == nil && o.CMSSigner == nil && o.Sealer == nil {
security := EncodeSecurity()
if v := EvaluateSecurityIn(security, sc); v != (Verdicts{Signature: VerdictNoSignature, Seal: VerdictNoSeal}) {
v := EvaluateSecurityIn(security, sc)
if v != (Verdicts{Signature: VerdictNoSignature, Seal: VerdictNoSeal}) {
return nil, fmt.Errorf("capsule: self-check: the reader finds the verdicts %s and %s in this security area", v.Signature, v.Seal)
}
s.verdicts = v
return security, nil
}
var err error
@ -264,11 +266,14 @@ func (s *sealer) security(c *Control, head []byte) ([]byte, error) {
}
v := EvaluateSecurityIn(security, sc)
// A seal that proves nothing before the round time (S5) is still a seal
// that verifies: the writer's clock and the authority's may differ.
// that verifies: the writer's clock and the authority's may differ, or
// the token may carry no accuracy. Result.Security lets the caller warn
// of it (§62.1 rule 19).
sealOK := v.Seal == wantSeal || wantSeal == VerdictSealed && v.Seal == VerdictSealedLate
if v.Signature != wantSig || !sealOK || v.AuthorKey != key {
return nil, fmt.Errorf("capsule: self-check: the reader finds the verdicts %s and %s in this security area, not %s and %s%s", v.Signature, v.Seal, wantSig, wantSeal, detailText(v.Detail))
}
s.verdicts = v
return security, nil
}

@ -10,6 +10,7 @@ import (
"g.activething.com/go/DateKeys/codec"
"g.activething.com/go/DateKeys/codec/bech32"
"g.activething.com/go/DateKeys/extension"
"g.activething.com/go/DateKeys/internal/cms"
"g.activething.com/go/DateKeys/internal/pathrule"
)
@ -153,15 +154,64 @@ const (
VerdictSignedComplete Verdict = "F6"
// VerdictSealInvalid (S3): a seal that does not verify.
VerdictSealInvalid Verdict = "S3"
// VerdictSealed (S4): a valid seal with t + accuracy < round_time.
// VerdictSealed (S4): a valid seal with accuracy and t + accuracy <
// round_time (spec v0.16, §29.11).
VerdictSealed Verdict = "S4"
// VerdictSealedLate (S5): a valid seal without margin before round_time.
// VerdictSealedLate (S5): a valid seal that does not prove that it came
// before round_time. Detail.SealReason says why.
VerdictSealedLate Verdict = "S5"
)
// SealReason is 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».
type SealReason string
const (
// ReasonNone: the seal proves it (S4, or the line of a signer that says
// so).
ReasonNone SealReason = ""
// ReasonLate: t plus the accuracy, 0 without one, is not before
// round_time.
ReasonLate SealReason = "late"
// ReasonNoAccuracyBTSP: the token carries no accuracy, and its policy is
// the BTSP of ETSI EN 319 421, which requires it.
ReasonNoAccuracyBTSP SealReason = "no accuracy, BTSP"
// ReasonNoAccuracy: the token carries no accuracy.
ReasonNoAccuracy SealReason = "no accuracy"
)
// Text is the reason as the texts of §29.7 write it, "" for ReasonNone.
func (r SealReason) Text() string {
switch r {
case ReasonLate:
return "se selló después de esa fecha o demasiado cerca de ella"
case ReasonNoAccuracyBTSP:
return "el sello no dice la precisión que exige su política"
case ReasonNoAccuracy:
return "el sello no dice su precisión"
}
return ""
}
// sealReason is 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; ReasonNone when it proves that it came before roundTime.
func sealReason(tok *cms.Token, roundTime time.Time) SealReason {
switch {
case roundTime.IsZero() || !tok.GenTime.Add(tok.Accuracy).Before(roundTime):
return ReasonLate
case !tok.HasAccuracy && tok.BTSP():
return ReasonNoAccuracyBTSP
case !tok.HasAccuracy:
return ReasonNoAccuracy
}
return ReasonNone
}
// Text returns the text of the 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, which Verdicts.Lines writes.
// verdicts whose text names a key or a reason, which Verdicts.Lines writes.
func (v Verdict) Text() string {
switch v {
case VerdictUnreadable:
@ -180,8 +230,6 @@ func (v Verdict) Text() string {
return "Faltan firmas o sellos que la propia cápsula exige: trátala como no firmada."
case VerdictSealInvalid:
return "El sello no corresponde a este contenido."
case VerdictSealedLate:
return "Sellado después de la fecha de apertura: no prueba nada anterior."
}
return ""
}
@ -204,9 +252,11 @@ type Detail struct {
// the SignerInfo of other certificates, which never count.
Signers, Foreign []SignerLine
// SealHolder and SealTime are the holder of the certificate of the
// authority of a valid seal, as §29.7 writes it, and t.
// 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).
SealHolder string
SealTime time.Time
SealReason SealReason
}
// SignerLine is a signer of an alg 2 signature.
@ -222,10 +272,13 @@ type SignerLine struct {
Result string
// SealHolder is the holder of the certificate of the authority of its
// seal, and SealTime t, both zero without a seal that verifies. Before is
// true when t plus the accuracy of the seal is before round_time.
// true when 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); Reason says why not, for a valid signer.
SealHolder string
SealTime time.Time
Before bool
Reason SealReason
}
// resultText is the result of a signer in the texts of §29.7.
@ -295,7 +348,7 @@ func (v Verdicts) Lines() []string {
". DateKeys no comprueba quién lo emitió: para eso, exporta la firma a un validador oficial."
before := false
for _, s := range v.Detail.Signers {
when := "no antes de la fecha de apertura"
when := "sin acreditar que fuera antes de la fecha de apertura: " + s.Reason.Text()
if s.Before {
when, before = "antes de la fecha de apertura", true
}
@ -316,6 +369,8 @@ func (v Verdicts) Lines() []string {
case v.Seal == VerdictSealed && v.Detail != nil:
lines = append(lines, "Según un sello a nombre de "+quoted(v.Detail.SealHolder)+", existía el "+instant(v.Detail.SealTime)+
", antes de que la cápsula pudiera abrirse. DateKeys no comprueba quién emitió el sello.")
case v.Seal == VerdictSealedLate && v.Detail != nil:
lines = append(lines, "No acredita que se sellara antes de la fecha de apertura: "+v.Detail.SealReason.Text()+".")
case t != "":
lines = append(lines, t)
}

@ -194,7 +194,8 @@ func signerLine(s *cms.SignerInfo, msg []byte, roundTime time.Time) SignerLine {
return l
}
l.Result, l.SealTime, l.SealHolder = "valid", tok.GenTime, holderText(tok.TSA.Holder(), tok.TSA.Hash)
l.Before = !roundTime.IsZero() && tok.GenTime.Add(tok.Accuracy).Before(roundTime)
l.Reason = sealReason(tok, roundTime)
l.Before = l.Reason == ReasonNone
return l
}
@ -220,8 +221,9 @@ func evaluateSeal(v *Verdicts, s *seal, signature []byte, c *SecurityContext) {
v.Detail = &Detail{}
}
v.Detail.SealHolder, v.Detail.SealTime = holderText(tok.TSA.Holder(), tok.TSA.Hash), tok.GenTime
v.Detail.SealReason = sealReason(tok, c.RoundTime)
v.Seal = VerdictSealedLate
if !c.RoundTime.IsZero() && tok.GenTime.Add(tok.Accuracy).Before(c.RoundTime) {
if v.Detail.SealReason == ReasonNone {
v.Seal = VerdictSealed
}
}

@ -102,7 +102,9 @@ func TestEvaluateCMS(t *testing.T) {
// A seal after the round time proves nothing before it, and then no line
// warns of who issued it.
late := capsule.EvaluateSecurityIn(cmsArea(t, c, []cmstest.Signer{ana}, []cmstest.Signer{ana}, tsa, roundTime.Add(time.Hour), nil), c)
if late.Signature != capsule.VerdictSignedComplete || len(late.Lines()) != 2 || !strings.Contains(late.Lines()[1], "no antes de la fecha de apertura") {
if late.Signature != capsule.VerdictSignedComplete || len(late.Lines()) != 2 ||
!strings.HasSuffix(late.Lines()[1], ", sin acreditar que fuera antes de la fecha de apertura: se selló después de esa fecha o demasiado cerca de ella.") ||
late.Detail.Signers[0].Reason != capsule.ReasonLate {
t.Errorf("a late seal: %+v %q", late, late.Lines())
}
@ -289,7 +291,8 @@ func TestEvaluateSeal(t *testing.T) {
return a
}
v := capsule.EvaluateSecurityIn(area(cmstest.Token(subject[:], signedAt, cmstest.TokenOptions{}, tsa)), c)
second := cmstest.TokenOptions{Accuracy: time.Second}
v := capsule.EvaluateSecurityIn(area(cmstest.Token(subject[:], signedAt, second, tsa)), c)
if v.Signature != capsule.VerdictSignedOther || v.Seal != capsule.VerdictSealed || v.Detail == nil || v.Detail.SealHolder != "Autoridad de Sellado" {
t.Fatalf("a valid seal: %+v", v)
}
@ -298,11 +301,42 @@ func TestEvaluateSeal(t *testing.T) {
}
// Sealed with its own signature part: without key 2 the subject differs.
noSig := capsule.SealSubject(c.ControlCommit, c.HeadDigest, capsule.SigPart(nil))
a, _ := capsule.EncodeSecurityWith(nil, mustSeal(t, capsule.SealTypeRFC3161, cmstest.Token(noSig[:], signedAt, cmstest.TokenOptions{}, tsa)))
a, _ := capsule.EncodeSecurityWith(nil, mustSeal(t, capsule.SealTypeRFC3161, cmstest.Token(noSig[:], signedAt, second, tsa)))
if v := capsule.EvaluateSecurityIn(a, c); v.Signature != capsule.VerdictNoSignature || v.Seal != capsule.VerdictSealed {
t.Errorf("a seal without a signature: %+v", v)
}
// 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.
for name, tc := range map[string]struct {
o cmstest.TokenOptions
when time.Time
seal capsule.Verdict
reason capsule.SealReason
}{
"no accuracy, years before": {cmstest.TokenOptions{}, signedAt, capsule.VerdictSealedLate, capsule.ReasonNoAccuracy},
"no accuracy under BTSP": {cmstest.TokenOptions{Policy: cmstest.BTSPPolicy}, signedAt, capsule.VerdictSealedLate, capsule.ReasonNoAccuracyBTSP},
"no accuracy, after the round time": {cmstest.TokenOptions{Policy: cmstest.BTSPPolicy}, roundTime, capsule.VerdictSealedLate, capsule.ReasonLate},
"an accuracy of 0 seconds": {cmstest.TokenOptions{AccuracyRaw: cmstest.Seq(cmstest.Int(0))}, roundTime.Add(-time.Microsecond), capsule.VerdictSealed, capsule.ReasonNone},
"an empty accuracy": {cmstest.TokenOptions{AccuracyRaw: cmstest.Seq()}, signedAt, capsule.VerdictSealed, capsule.ReasonNone},
"BTSP with accuracy": {cmstest.TokenOptions{Policy: cmstest.BTSPPolicy, Accuracy: time.Second}, signedAt, capsule.VerdictSealed, capsule.ReasonNone},
"an accuracy of 0 at the round time": {cmstest.TokenOptions{AccuracyRaw: cmstest.Seq(cmstest.Int(0))}, roundTime, capsule.VerdictSealedLate, capsule.ReasonLate},
} {
v := capsule.EvaluateSecurityIn(area(cmstest.Token(subject[:], tc.when, tc.o, tsa)), c)
if v.Seal != tc.seal || v.Detail == nil || v.Detail.SealReason != tc.reason {
t.Errorf("%s: %+v", name, v)
continue
}
last := v.Lines()[len(v.Lines())-1]
if tc.seal == capsule.VerdictSealedLate && last != "No acredita que se sellara antes de la fecha de apertura: "+tc.reason.Text()+"." {
t.Errorf("%s: line %q", name, last)
}
}
if capsule.ReasonNone.Text() != "" || capsule.VerdictSealedLate.Text() != "" {
t.Error("S5 has no text of its own: Lines writes it with its reason")
}
for name, tc := range map[string]struct {
token []byte
ctx *capsule.SecurityContext

@ -216,14 +216,20 @@ func (c *cmsSigner) Sign(message []byte) ([]byte, error) {
return cmstest.Signature(message, opts, c.signers...), nil
}
// sealer is a Sealer that asks the authority tsa.
// sealer is a Sealer that asks the authority tsa, whose tokens carry an
// accuracy of a second unless noAccuracy.
type sealer struct {
tsa cmstest.Signer
when time.Time
tsa cmstest.Signer
when time.Time
noAccuracy bool
}
func (s sealer) Seal(subject [32]byte) ([]byte, error) {
return cmstest.Token(subject[:], s.when, cmstest.TokenOptions{}, s.tsa), nil
o := cmstest.TokenOptions{Accuracy: time.Second}
if s.noAccuracy {
o = cmstest.TokenOptions{}
}
return cmstest.Token(subject[:], s.when, o, s.tsa), nil
}
// Spec v0.11 §29.10, §29.11, §62.1 rules 19 and 21: EncryptFiles gives
@ -268,7 +274,7 @@ func TestEncryptFilesCMSAndSeal(t *testing.T) {
// A seal over the signature of an author key.
key, _ := authorkey.Generate()
opts.CMSSigner, opts.AuthorKey, opts.Sealer = nil, key, sealer{tsa, when}
opts.CMSSigner, opts.AuthorKey, opts.Sealer = nil, key, sealer{tsa: tsa, when: when}
dkc.Reset()
if _, err := capsule.EncryptFiles(&dkc, []capsule.Source{source("nota.txt", "Hola.\n")}, opts); err != nil {
t.Fatal(err)
@ -280,12 +286,25 @@ func TestEncryptFilesCMSAndSeal(t *testing.T) {
// And a seal over a capsule without a signature.
opts.AuthorKey = nil
dkc.Reset()
if _, err := capsule.EncryptFiles(&dkc, []capsule.Source{source("nota.txt", "Hola.\n")}, opts); err != nil {
res, err := capsule.EncryptFiles(&dkc, []capsule.Source{source("nota.txt", "Hola.\n")}, opts)
if err != nil {
t.Fatal(err)
}
if o = openSigned(t, dkc.Bytes(), nil); o.Verdicts.Signature != capsule.VerdictNoSignature || o.Verdicts.Seal != capsule.VerdictSealed {
t.Errorf("verdicts %+v", o.Verdicts)
if o = openSigned(t, dkc.Bytes(), nil); o.Verdicts.Signature != capsule.VerdictNoSignature || o.Verdicts.Seal != capsule.VerdictSealed || res.Security.Seal != capsule.VerdictSealed {
t.Errorf("verdicts %+v, written %+v", o.Verdicts, res.Security)
}
// A seal without accuracy is written, and Result.Security says that it
// proves nothing before the opening date, so that the writer warns of it
// (spec v0.16, §62.1 rule 19).
opts.Sealer = sealer{tsa: tsa, when: when, noAccuracy: true}
dkc.Reset()
if res, err = capsule.EncryptFiles(&dkc, []capsule.Source{source("nota.txt", "Hola.\n")}, opts); err != nil {
t.Fatal(err)
}
if res.Security.Seal != capsule.VerdictSealedLate || res.Security.Detail.SealReason != capsule.ReasonNoAccuracy {
t.Errorf("a seal without accuracy: written %+v", res.Security)
}
opts.Sealer = sealer{tsa: tsa, when: when}
// The exclusions.
opts.AuthorKey, opts.CMSSigner = key, signer

@ -24,6 +24,7 @@ package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
@ -51,13 +52,14 @@ import (
)
const usage = `usage:
datekeys encrypt -at TIME -in FILE|FOLDER... -out FILE.dkc [-comment TEXT] [-author TEXT] [-no-mtime] [-policy time_only|time_and_key] [-recipient age1...]... [-dkk FILE.dkk] [-words TEXT|-words-file FILE] [-padding reforzado|bloque256] [-note TEXT] [-sign KEY [-sign-pass-file FILE]] [-large-area]
datekeys encrypt -at TIME -in FILE|FOLDER... -out FILE.dkc [-comment TEXT] [-author TEXT] [-no-mtime] [-policy time_only|time_and_key] [-recipient age1...]... [-dkk FILE.dkk] [-words TEXT|-words-file FILE|-new-words FILE [-dic LIST] [-word-count N]|-dice TEXT|-dice-file FILE [-dic LIST]] [-padding reforzado|bloque256] [-note TEXT] [-sign KEY [-sign-pass-file FILE]] [-large-area] [-no-recovery]
datekeys decrypt -in FILE.dkc -out PATH [-dkk FILE.dkk] [-identity FILE]... [-words TEXT|-words-file FILE] [-expect-author dkauthor1...] [-relay URL]... [-release FILE]
datekeys author keygen -out FILE (-pass-file FILE|-plain)
datekeys author public -key FILE [-pass-file FILE]
datekeys inspect -in FILE.dkc [-json]
datekeys datekey resolve -at TIME
datekeys profile hash [-in PROFILE.cbor]
datekeys wordlist [-dic LIST]
datekeys version
TIME is RFC 3339 with a time zone, for example 2030-01-01T00:00:00Z.
@ -79,7 +81,35 @@ was published (spec v0.15, §63 step 9.c).
least 6 different words of 3 or more letters that open it with decrypt,
instead of a .dkk
(wordkey). Case, accents and extra spaces do not matter. -words leaves them
in the shell history; -words-file reads them from a file.
in the shell history; -words-file reads them from a file. Never use a
password used anywhere else: once the date has come, the capsule lets
whoever holds it test guesses of it.
-new-words FILE draws the words at random instead, and writes them to the new
file FILE: -word-count words, 7 by default, of the list -dic of 7776 words,
en by default, the list of the EFF, or es. Words a person chooses are weaker
than random ones: whoever holds the .dkc can try them offline once the date
has come (spec §38.1).
-dice TEXT and -dice-file FILE take the words from dice instead, for whoever
does not trust the random numbers of a computer: five dice for each word,
read in a fixed order, give a number from 11111 to 66666, the position of a
word in the list -dic, at least 6 numbers and no word twice. datekeys
wordlist writes the list -dic numbered for dice, to print it, and its
SHA-256: for en it is the list of the EFF, byte for byte. encrypt shows the
words: keep the words, which open the capsule; the numbers give them only
with that list.
encrypt also writes FILE.dkc.recuperacion.txt next to the capsule, unless
-no-recovery: the annex of the specification, in Spanish, on how to open a
capsule without DateKeys software (spec §79), the same for every capsule.
Opening it years later needs the .dkc, a credential of a time_and_key
capsule, and the release of its round, which drand publishes at the date:
if drand no longer serves it then, an archive of releases or a cache service
must have kept it (spec §50). For a long horizon or a valuable content,
time_and_key adds a credential that drand does not hold (spec §7.6). A
profile that is not active writes no capsule, and decrypt and inspect warn
when the profile of a capsule is compromised (spec §71).
-note puts a public note in the capsule, in clear: anyone who has the .dkc
reads it before the date, nobody can check who wrote it, and with the date it
@ -103,6 +133,18 @@ var errUsage = errors.New("invalid command line; run 'datekeys help'")
// decrypt-later warning (spec §53).
const longHorizon = 365 * 24 * time.Hour
// profileStatus is the state of a profile in the registry of profiles
// (spec §71), as this release of the module knows it; the tests replace it
// to see a profile that is not active.
var profileStatus = func(p *profile.Profile) profile.Status {
h, err := p.Hash()
if err != nil {
return profile.Active
}
s, _ := profile.StatusOf(h)
return s
}
func main() {
if err := run(os.Args[1:], os.Stdout, os.Stderr, time.Now); err != nil {
if errors.Is(err, errUsage) {
@ -147,6 +189,8 @@ func run(args []string, stdout, stderr io.Writer, now func() time.Time) error {
return errUsage
}
return profileHash(args[2:], stdout)
case "wordlist":
return wordList(args[1:], stdout, stderr)
case "version", "-version", "--version":
if len(args) != 1 {
return errUsage
@ -203,9 +247,15 @@ func encrypt(args []string, stderr io.Writer, now func() time.Time) error {
fs.Var(&recipients, "recipient", "time_and_key: X25519 recipient age1... (repeatable)")
words := fs.String("words", "", "time_and_key: at least 6 words that open the capsule; they stay in the shell history")
wordsFile := fs.String("words-file", "", "time_and_key: file with the words that open the capsule")
newWords := fs.String("new-words", "", "time_and_key: new file with words drawn at random from a list, that open the capsule")
dice := fs.String("dice", "", "time_and_key: numbers of five dice, one for each word of the list -dic; they stay in the shell history")
diceFile := fs.String("dice-file", "", "time_and_key: file with the numbers of five dice of -dice")
dic := fs.String("dic", "en", "list of -new-words and -dice: "+strings.Join(wordkey.Languages(), ", "))
wordCount := fs.Int("word-count", wordkey.DefaultCount, "number of words of -new-words, 6 or more")
note := fs.String("note", "", "public note of the capsule: one line that anyone with the .dkc reads before the date, and that can identify someone with it")
sign := fs.String("sign", "", "file with the author key that signs the capsule")
signPass := fs.String("sign-pass-file", "", "file with the passphrase of the author key, or - for the standard input")
noRecovery := fs.Bool("no-recovery", false, "do not write the recovery annex of the specification next to the .dkc (spec §79)")
largeArea := fs.Bool("large-area", false, "let the security area grow to 64 KiB if a signature does not fit in 32 KiB (an author key always fits)")
if err := parse(fs, args); err != nil {
return err
@ -232,6 +282,9 @@ func encrypt(args []string, stderr io.Writer, now func() time.Time) error {
}
opts := capsule.EncryptOptions{Profile: profile.Quicknet(), UnlockAt: unlock, Policy: pol, NewPortableKey: *dkk != "", Padding: code,
Comment: *comment, Author: *author, Now: now}
if s := profileStatus(opts.Profile); s != profile.Active {
return fmt.Errorf("encrypt: the profile %s is %s: no new capsule is written with it (spec §71)", opts.Profile.ID, s)
}
for _, r := range recipients {
x, err := age.ParseX25519Recipient(r)
if err != nil {
@ -245,9 +298,47 @@ func encrypt(args []string, stderr io.Writer, now func() time.Time) error {
if err != nil {
return err
}
diceText, err := flagText("encrypt", "dice", *dice, *diceFile)
if err != nil {
return err
}
if diceText != "" && (text != "" || *newWords != "") {
return errors.New("encrypt: -dice and -dice-file exclude -words, -words-file and -new-words")
}
var listSize int
var fromDice []string
if *newWords != "" {
if text != "" {
return errors.New("encrypt: -new-words excludes -words and -words-file")
}
if err := checkNew(*newWords); err != nil {
return err
}
list, err := wordkey.List(*dic)
if err != nil {
return fmt.Errorf("encrypt: %w", err)
}
drawn, err := wordkey.Generate(list, *wordCount, nil)
if err != nil {
return fmt.Errorf("encrypt: %w", err)
}
text = strings.Join(drawn, " ")
listSize = len(list)
}
if diceText != "" {
list, err := wordkey.List(*dic)
if err != nil {
return fmt.Errorf("encrypt: %w", err)
}
if fromDice, err = wordkey.DiceWords(list, diceText); err != nil {
return fmt.Errorf("encrypt: %w", err)
}
text = strings.Join(fromDice, " ")
listSize = len(list)
}
if text != "" {
if pol != capsule.TimeAndKey {
return errors.New("encrypt: -words and -words-file need -policy time_and_key")
return errors.New("encrypt: -words, -words-file, -new-words and -dice need -policy time_and_key")
}
w := wordkey.Normalize(text)
if err := wordkey.Check(w); err != nil {
@ -260,6 +351,12 @@ func encrypt(args []string, stderr io.Writer, now func() time.Time) error {
return err
}
}
annex := *out + datekeys.RecoveryAnnexSuffix
if !*noRecovery {
if err := checkNew(annex); err != nil {
return err
}
}
if *signPass != "" && *sign == "" {
return errors.New("encrypt: -sign-pass-file needs -sign")
}
@ -294,6 +391,16 @@ func encrypt(args []string, stderr io.Writer, now func() time.Time) error {
return fmt.Errorf("the capsule was written to %s but its .dkk could not be: %w", *out, err)
}
}
if *newWords != "" {
if err := writeAtomic(*newWords, func(w io.Writer) error { _, err := io.WriteString(w, text+"\n"); return err }); err != nil {
return fmt.Errorf("the capsule was written to %s but its words could not be: %w", *out, err)
}
}
if !*noRecovery {
if err := writeAtomic(annex, func(w io.Writer) error { _, err := io.WriteString(w, datekeys.RecoveryAnnex); return err }); err != nil {
return fmt.Errorf("the capsule was written to %s but its recovery annex could not be: %w", *out, err)
}
}
fmt.Fprintf(stderr, "Encrypted locally for %s (round %d)\n datekey %s\n capsule_id %x\n format %d: %d files, a payload of %d bytes, padded to %d (%s)\n",
res.UnlockAt.Format(time.RFC3339), res.DateKey.Round, res.DateKey.Compact(), res.CapsuleID, res.Format, len(res.Head.Files), res.Length, res.PaddedLength, res.Padding)
for _, p := range skipped {
@ -302,9 +409,32 @@ func encrypt(args []string, stderr io.Writer, now func() time.Time) error {
if res.PortableKey != nil {
fmt.Fprintf(stderr, " access key %s: keep it secret; it is valid for this capsule only\n", *dkk)
}
if *newWords != "" {
fmt.Fprintf(stderr, " words %s: %d words of the list %s, %d bits; keep them secret, or write them down and delete the file\n",
*newWords, *wordCount, *dic, int(wordkey.Bits(listSize, *wordCount)))
}
if fromDice != nil {
fmt.Fprintf(stderr, " words %s: the %d words of the dice in the list %s, %d bits; keep these words, which open the capsule: the numbers give them only with this list\n",
text, len(fromDice), *dic, int(wordkey.Bits(listSize, len(fromDice))))
}
if !*noRecovery {
fmt.Fprintf(stderr, " recovery %s: how to open the capsule without DateKeys software (spec §79); keep it with the .dkc\n", annex)
}
// What opening it years later will take (spec §62.1, rule 26).
needs := "the .dkc"
if pol == capsule.TimeAndKey {
needs = "the .dkc, one of its credentials"
}
fmt.Fprintf(stderr, " to open %s and the release of round %d, which drand publishes at %s: years later, if drand no longer\n"+
" serves it, an archive of releases or a cache service must have kept it (spec §50)\n",
needs, res.DateKey.Round, res.UnlockAt.Format(time.RFC3339))
if res.UnlockAt.Sub(now()) > longHorizon {
fmt.Fprintln(stderr, "warning: Quicknet V1 timelock is not post-quantum. The ciphertext may stay available for years,\n"+
" and its future confidentiality depends on the provider and on the underlying cryptography (spec §53).")
if pol == capsule.TimeOnly {
fmt.Fprintln(stderr, "note: for a horizon this long, or a valuable content, -policy time_and_key adds a credential that drand does\n"+
" not hold: an early signature of the round would not open the capsule (spec §7.6).")
}
}
return nil
}
@ -447,6 +577,10 @@ func decrypt(args []string, stdout, stderr io.Writer, now func() time.Time) erro
}
fmt.Fprintf(stderr, "Decrypted capsule %s (round %d, unlocked at %s); release verified locally\n",
opened.Inspection.Header.CapsuleIDHex(), opened.Release.Round, opened.Inspection.UnlockAt.Format(time.RFC3339))
if profileStatus(opened.Inspection.Profile) == profile.Compromised {
fmt.Fprintf(stderr, "warning: the profile %s is compromised: the content of this capsule may have been read before its date (spec §71)\n",
opened.Inspection.Profile.ID)
}
if opened.ClockBehind {
fmt.Fprintf(stderr, "warning: the release proves that round %d was published at %s, and this clock says %s: it may be behind\n",
opened.Release.Round, opened.Inspection.UnlockAt.Format(time.RFC3339), now().UTC().Format(time.RFC3339))
@ -505,11 +639,17 @@ func releaseInHand(path string) (provider.Supplier, io.Closer, error) {
// wordsText is the text of the words of -words or of -words-file, at most
// 4 KiB, or "" when neither is given.
func wordsText(cmd, words, file string) (string, error) {
if words != "" && file != "" {
return "", fmt.Errorf("%s: -words and -words-file are exclusive", cmd)
return flagText(cmd, "words", words, file)
}
// flagText is the text of -NAME or of the file of -NAME-file, at most 4 KiB,
// or "" when neither is given.
func flagText(cmd, name, text, file string) (string, error) {
if text != "" && file != "" {
return "", fmt.Errorf("%s: -%s and -%s-file are exclusive", cmd, name, name)
}
if file == "" {
return words, nil
return text, nil
}
f, err := os.Open(file)
if err != nil {
@ -521,11 +661,35 @@ func wordsText(cmd, words, file string) (string, error) {
return "", err
}
if len(b) > 4<<10 {
return "", fmt.Errorf("%s: %s is longer than 4 KiB: it is not a list of words", cmd, file)
return "", fmt.Errorf("%s: %s is longer than 4 KiB: it is not a list of %s", cmd, file, name)
}
return string(b), nil
}
// wordList writes the list -dic numbered for dice to stdout, as the EFF
// publishes its list, to print it, and its SHA-256 to stderr, to compare it
// with the one that wordkey/lists/README.md records.
func wordList(args []string, stdout, stderr io.Writer) error {
fs := newFlags("wordlist")
dic := fs.String("dic", "en", "list: "+strings.Join(wordkey.Languages(), ", "))
if err := parse(fs, args); err != nil {
return err
}
list, err := wordkey.List(*dic)
if err != nil {
return fmt.Errorf("wordlist: %w", err)
}
text, err := wordkey.DiceList(list)
if err != nil {
return fmt.Errorf("wordlist: %w", err)
}
if _, err := io.WriteString(stdout, text); err != nil {
return err
}
fmt.Fprintf(stderr, "the list %s numbered for dice, %d words, SHA-256 %x\n", *dic, len(list), sha256.Sum256([]byte(text)))
return nil
}
func readIdentities(path string) ([]age.Identity, error) {
f, err := os.Open(path)
if err != nil {
@ -569,6 +733,10 @@ func inspect(args []string, stdout io.Writer) error {
} else {
v.WriteText(stdout)
showNote(stdout, result)
if result != nil && result.Profile != nil && profileStatus(result.Profile) == profile.Compromised {
fmt.Fprintf(stdout, "warning: the profile %s is compromised: the content of this capsule may have been read before its date (spec §71)\n",
result.Profile.ID)
}
}
return inspectErr
}

@ -2,6 +2,7 @@ package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
@ -25,6 +26,7 @@ import (
"g.activething.com/go/DateKeys/internal/inspectview"
"g.activething.com/go/DateKeys/internal/testkit"
"g.activething.com/go/DateKeys/profile"
"g.activething.com/go/DateKeys/wordkey"
)
const fixtures = "../../testdata/fixtures"
@ -518,8 +520,8 @@ func TestInspectJSONGoldens(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(names) != len(dkcs) || len(dkcs) != 26 {
t.Fatalf("%d frozen inspect outputs, want one per official .dkc (%d, 26)", len(names), len(dkcs))
if len(names) != len(dkcs) || len(dkcs) != 28 {
t.Fatalf("%d frozen inspect outputs, want one per official .dkc (%d, 28)", len(names), len(dkcs))
}
t.Chdir(fixtures)
for _, path := range names {
@ -636,12 +638,239 @@ func TestKeyOfWords(t *testing.T) {
{[]string{"-policy", "time_and_key", "-words", "perro luna casa verde tren mar" + string(rune(0x200B))}, "invisible character U+200B"},
{[]string{"-words", "uno dos tres cuatro cinco seis"}, "need -policy time_and_key"},
{[]string{"-policy", "time_and_key", "-words", "a", "-words-file", words}, "are exclusive"},
{[]string{"-policy", "time_and_key", "-words", "a", "-new-words", filepath.Join(dir, "n.txt")}, "-new-words excludes -words and -words-file"},
{[]string{"-policy", "time_and_key", "-new-words", words}, "already exists"},
{[]string{"-policy", "time_and_key", "-new-words", filepath.Join(dir, "n.txt"), "-dic", "xx"}, `no word list for "xx"`},
{[]string{"-policy", "time_and_key", "-new-words", filepath.Join(dir, "n.txt"), "-word-count", "5"}, "at least 6 words, not 5"},
{[]string{"-new-words", filepath.Join(dir, "n.txt")}, "-words, -words-file, -new-words and -dice need -policy time_and_key"},
{[]string{"-dice", "11111 11112 11113 11114 11115 11116"}, "-words, -words-file, -new-words and -dice need -policy time_and_key"},
{[]string{"-policy", "time_and_key", "-dice", "11111", "-dice-file", words}, "-dice and -dice-file are exclusive"},
{[]string{"-policy", "time_and_key", "-dice", "11111", "-words", "a"}, "-dice and -dice-file exclude -words, -words-file and -new-words"},
{[]string{"-policy", "time_and_key", "-dice-file", words, "-new-words", filepath.Join(dir, "n.txt")}, "-dice and -dice-file exclude -words, -words-file and -new-words"},
{[]string{"-policy", "time_and_key", "-dice", "11111 11112 11113 11114 11115"}, "at least 6 words, not 5"},
{[]string{"-policy", "time_and_key", "-dice", "11111 11112 11113 11114 11115 11171"}, `"11171" is not five dice: five digits from 1 to 6`},
{[]string{"-policy", "time_and_key", "-dice", "11111 11112 11113 11114 11115 11111"}, `the dice 11111 give "abacus" a second time; roll them again`},
{[]string{"-policy", "time_and_key", "-dice", "11111 11112 11113 11114 11115 11116", "-dic", "xx"}, `no word list for "xx"`},
} {
args := append([]string{"encrypt", "-at", at, "-in", in, "-out", filepath.Join(dir, "x.dkc")}, tc.args...)
if _, _, err := cli(t, genesis, args...); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("%v: %v, want %q", tc.args, err, tc.want)
}
}
if _, err := os.Stat(filepath.Join(dir, "n.txt")); err == nil {
t.Error("a refused encrypt left its words file")
}
}
// Spec §62.1, rules 26 and 27: encrypt writes the recovery annex next to the
// .dkc, never over another file, and says what opening it years later will
// take; for a long horizon and time_only, it recommends time_and_key (§7.6).
func TestRecoveryAndNotices(t *testing.T) {
dir := t.TempDir()
in := filepath.Join(dir, "carta.txt")
os.WriteFile(in, []byte("para dentro de mucho"), 0o600)
p := profile.Quicknet()
genesis := time.Unix(p.GenesisTime, 0)
at := time.Unix(p.GenesisTime+999*3, 0).UTC().Format(time.RFC3339) // round 1000
dkc := filepath.Join(dir, "carta.dkc")
_, stderr, err := cli(t, genesis, "encrypt", "-at", at, "-in", in, "-out", dkc)
if err != nil {
t.Fatalf("encrypt: %v\n%s", err, stderr)
}
if b, err := os.ReadFile(dkc + ".recuperacion.txt"); err != nil || string(b) != datekeys.RecoveryAnnex {
t.Fatalf("the annex next to the .dkc: %v", err)
}
for _, want := range []string{
" recovery " + dkc + ".recuperacion.txt: how to open the capsule without DateKeys software (spec §79); keep it with the .dkc",
" to open the .dkc and the release of round 1000, which drand publishes at 2023-08-23T",
"an archive of releases or a cache service must have kept it (spec §50)",
} {
if !strings.Contains(stderr, want) {
t.Errorf("stderr lacks %q:\n%s", want, stderr)
}
}
if strings.Contains(stderr, "(spec §7.6)") || strings.Contains(stderr, "(spec §53)") {
t.Errorf("a short horizon got a warning:\n%s", stderr)
}
// time_and_key, without the annex.
keyed := filepath.Join(dir, "llave.dkc")
_, stderr, err = cli(t, genesis, "encrypt", "-at", at, "-policy", "time_and_key", "-dkk", filepath.Join(dir, "llave.dkk"), "-no-recovery", "-in", in, "-out", keyed)
if err != nil {
t.Fatalf("encrypt -no-recovery: %v\n%s", err, stderr)
}
if _, err := os.Stat(keyed + ".recuperacion.txt"); err == nil || strings.Contains(stderr, "recovery ") {
t.Error("-no-recovery wrote the annex")
}
if !strings.Contains(stderr, " to open the .dkc, one of its credentials and the release of round 1000") {
t.Errorf("stderr:\n%s", stderr)
}
// A file where the annex goes: nothing is written.
taken := filepath.Join(dir, "otra.dkc")
os.WriteFile(taken+".recuperacion.txt", []byte("mine"), 0o600)
if _, _, err := cli(t, genesis, "encrypt", "-at", at, "-in", in, "-out", taken); err == nil || !strings.Contains(err.Error(), "already exists") {
t.Errorf("encrypt over an annex: %v", err)
}
if _, err := os.Stat(taken); err == nil {
t.Error("a refused encrypt wrote its .dkc")
}
// A long horizon: the warning of §53, and the note of §7.6 for time_only.
far := genesis.Add(400 * 24 * time.Hour).Format(time.RFC3339)
_, stderr, err = cli(t, genesis, "encrypt", "-at", far, "-in", in, "-out", filepath.Join(dir, "lejos.dkc"))
if err != nil || !strings.Contains(stderr, "(spec §53)") || !strings.Contains(stderr, "note: for a horizon this long, or a valuable content, -policy time_and_key adds a credential") {
t.Errorf("a long horizon, time_only: %v\n%s", err, stderr)
}
_, stderr, err = cli(t, genesis, "encrypt", "-at", far, "-policy", "time_and_key", "-dkk", filepath.Join(dir, "lejos2.dkk"), "-in", in, "-out", filepath.Join(dir, "lejos2.dkc"))
if err != nil || !strings.Contains(stderr, "(spec §53)") || strings.Contains(stderr, "(spec §7.6)") {
t.Errorf("a long horizon, time_and_key: %v\n%s", err, stderr)
}
}
// Spec §71: a profile that is not active writes no capsule, and a
// compromised one gets a warning when a capsule of it is opened or inspected.
func TestProfileStatus(t *testing.T) {
saved := profileStatus
t.Cleanup(func() { profileStatus = saved })
dir := t.TempDir()
in := filepath.Join(dir, "carta.txt")
os.WriteFile(in, []byte("abierta"), 0o600)
p := profile.Quicknet()
genesis := time.Unix(p.GenesisTime, 0)
at := time.Unix(p.GenesisTime+999*3, 0).UTC().Format(time.RFC3339) // round 1000
dkc := filepath.Join(dir, "carta.dkc")
profileStatus = func(*profile.Profile) profile.Status { return profile.ReadOnly }
if _, _, err := cli(t, genesis, "encrypt", "-at", at, "-in", in, "-out", dkc); err == nil ||
err.Error() != "encrypt: the profile datekeys:quicknet:v1 is read-only: no new capsule is written with it (spec §71)" {
t.Fatalf("encrypt with a read-only profile: %v", err)
}
if _, err := os.Stat(dkc); err == nil {
t.Fatal("a read-only profile wrote a capsule")
}
profileStatus = saved
if _, stderr, err := cli(t, genesis, "encrypt", "-at", at, "-in", in, "-out", dkc); err != nil {
t.Fatalf("encrypt: %v\n%s", err, stderr)
}
const warning = "warning: the profile datekeys:quicknet:v1 is compromised: the content of this capsule may have been read before its date (spec §71)"
if stdout, _, err := cli(t, later, "inspect", "-in", dkc); err != nil || strings.Contains(stdout, warning) {
t.Errorf("inspect with an active profile: %v\n%s", err, stdout)
}
profileStatus = func(*profile.Profile) profile.Status { return profile.Compromised }
if stdout, _, err := cli(t, later, "inspect", "-in", dkc); err != nil || !strings.Contains(stdout, warning) {
t.Errorf("inspect with a compromised profile: %v\n%s", err, stdout)
}
if _, stderr, err := cli(t, later, "decrypt", "-in", dkc, "-out", filepath.Join(dir, "abierta"), "-relay", relay(t)); err != nil || !strings.Contains(stderr, warning) {
t.Errorf("decrypt with a compromised profile: %v\n%s", err, stderr)
}
}
// The words of dice, as the list numbered for dice gives them: the capsule
// opens with them.
func TestDice(t *testing.T) {
dir := t.TempDir()
in := filepath.Join(dir, "carta.txt")
os.WriteFile(in, []byte("abierta con dados"), 0o600)
p := profile.Quicknet()
unlock := time.Unix(p.GenesisTime+999*3, 0).UTC() // round 1000
dkc := filepath.Join(dir, "carta.dkc")
dice := filepath.Join(dir, "dados.txt")
os.WriteFile(dice, []byte("35214 11111\n64253 11112 11113\n66666 11121\n"), 0o600)
_, stderr, err := cli(t, time.Unix(p.GenesisTime, 0), "encrypt", "-at", unlock.Format(time.RFC3339), "-policy", "time_and_key",
"-dice-file", dice, "-dic", "es", "-in", in, "-out", dkc)
if err != nil {
t.Fatalf("encrypt: %v\n%s", err, stderr)
}
const words = "glaciar abad tocar abadía abandonar útil abdomen"
if !strings.Contains(stderr, "words "+words+": the 7 words of the dice in the list es, 90 bits; keep these words") {
t.Errorf("stderr: %s", stderr)
}
out := filepath.Join(dir, "abierta")
if _, stderr, err := cli(t, later, "decrypt", "-in", dkc, "-out", out, "-words", "Glaciar abad tocar abadia abandonar util abdomen", "-relay", relay(t)); err != nil {
t.Fatalf("decrypt: %v\n%s", err, stderr)
}
if b, err := os.ReadFile(filepath.Join(out, "carta.txt")); err != nil || string(b) != "abierta con dados" {
t.Fatalf("carta.txt = %q, %v", b, err)
}
}
// datekeys wordlist writes the list numbered for dice, and its SHA-256: for
// en, the file of the EFF.
func TestWordList(t *testing.T) {
for lang, want := range map[string]string{
"en": "addd35536511597a02fa0a9ff1e5284677b8883b83e986e43f15a3db996b903e",
"es": "611f779a33df74587e9dfb486bb0185a9dfd4d1384e75993a21247ad31cefddb",
} {
stdout, stderr, err := cli(t, time.Now(), "wordlist", "-dic", lang)
if err != nil {
t.Fatalf("wordlist -dic %s: %v", lang, err)
}
if got := fmt.Sprintf("%x", sha256.Sum256([]byte(stdout))); got != want {
t.Errorf("wordlist -dic %s: SHA-256 %s, want %s", lang, got, want)
}
if stderr != "the list "+lang+" numbered for dice, 7776 words, SHA-256 "+want+"\n" {
t.Errorf("wordlist -dic %s: stderr %q", lang, stderr)
}
}
if stdout, _, err := cli(t, time.Now(), "wordlist"); err != nil || !strings.HasPrefix(stdout, "11111\tabacus\n11112\tabdomen\n") {
t.Errorf("wordlist: %.40q, %v", stdout, err)
}
if _, _, err := cli(t, time.Now(), "wordlist", "-dic", "xx"); err == nil || !strings.Contains(err.Error(), `wordlist: wordkey: no word list for "xx"`) {
t.Errorf("wordlist -dic xx: %v", err)
}
}
func TestNewWords(t *testing.T) {
dir := t.TempDir()
in := filepath.Join(dir, "carta.txt")
os.WriteFile(in, []byte("abierta con palabras al azar"), 0o600)
p := profile.Quicknet()
unlock := time.Unix(p.GenesisTime+999*3, 0).UTC() // round 1000
dkc := filepath.Join(dir, "carta.dkc")
words := filepath.Join(dir, "palabras.txt")
_, stderr, err := cli(t, time.Unix(p.GenesisTime, 0), "encrypt", "-at", unlock.Format(time.RFC3339), "-policy", "time_and_key",
"-new-words", words, "-word-count", "8", "-in", in, "-out", dkc)
if err != nil {
t.Fatalf("encrypt: %v\n%s", err, stderr)
}
// The list of the EFF by default.
if !strings.Contains(stderr, "words "+words+": 8 words of the list en, 103 bits; keep them secret") {
t.Errorf("stderr: %s", stderr)
}
inList := func(lang, file string, n int) {
t.Helper()
b, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
list, _ := wordkey.List(lang)
known := map[string]bool{}
for _, w := range list {
known[w] = true
}
drawn := strings.Fields(string(b))
if len(drawn) != n || !strings.HasSuffix(string(b), "\n") {
t.Fatalf("words file %q", b)
}
for _, w := range drawn {
if !known[w] {
t.Errorf("%q is not in the list %s", w, lang)
}
}
}
inList("en", words, 8)
// The Spanish list with -dic es.
spanish := filepath.Join(dir, "espanol.txt")
_, stderr, err = cli(t, time.Unix(p.GenesisTime, 0), "encrypt", "-at", unlock.Format(time.RFC3339), "-policy", "time_and_key",
"-new-words", spanish, "-dic", "es", "-in", in, "-out", filepath.Join(dir, "espanol.dkc"))
if err != nil || !strings.Contains(stderr, "words "+spanish+": 7 words of the list es, 90 bits;") {
t.Fatalf("encrypt -dic es: %v\n%s", err, stderr)
}
inList("es", spanish, 7)
out := filepath.Join(dir, "abierta")
if _, stderr, err := cli(t, later, "decrypt", "-in", dkc, "-out", out, "-words-file", words, "-relay", relay(t)); err != nil {
t.Fatalf("decrypt: %v\n%s", err, stderr)
}
if b, err := os.ReadFile(filepath.Join(out, "carta.txt")); err != nil || string(b) != "abierta con palabras al azar" {
t.Fatalf("carta.txt = %q, %v", b, err)
}
}
// Spec v0.11 §24.1: decrypt does not show a public note that breaks the rules

@ -1,4 +1,4 @@
# Traceability: DateKeys Protocol Specification v0.15 ↔ datekeys-go
# Traceability: DateKeys Protocol Specification v0.16 ↔ datekeys-go
This table maps every normative section of the specification to the code that
implements it and to the tests that exercise it. It is updated in the same
@ -7,9 +7,10 @@ reviewer together with the specification, the fixtures and the mutation corpus
(plan §10).
Paths are relative to the repository root. `§` numbers refer to
`spec/DateKeys_Protocol_Specification_v0.15.md`, tagged `spec-v0.15`; v0.14,
v0.13, v0.12 and v0.11 number their sections the same, but for §7.10, new in
v0.14, and §47.1 and §79, new in v0.15. A case of §64 that is not in the repository yet is marked
`spec/DateKeys_Protocol_Specification_v0.16.md`, tagged `spec-v0.16`; v0.15,
v0.14, v0.13, v0.12 and v0.11 number their sections the same, but for §7.10,
new in v0.14, §47.1 and §79, new in v0.15, and 79.7, new in v0.16, which
moves the next two subsections of §79 one place. A case of §64 that is not in the repository yet is marked
*pending*.
## Section map
@ -18,7 +19,7 @@ v0.14, and §47.1 and §79, new in v0.15. A case of §64 that is not in the repo
|---|---|---|---|
| 3 | Guiding principle: verify locally | `profile.Registry`, `datekey.Resolve`, `provider.Verify`, `capsule.Inspect` | `capsule.TestMutationCorpus` |
| 4 | Security goals, among them goal 8, the privacy of metadata in format 2 | whole module | whole suite; goal 8: the tests of rows 29.1, 39 and 55.2 |
| 7 | Threat model | creator model in `internal/testkit.Build`, `RewriteAge`; third-party edits in the mutation corpus | `agewrap.TestTimeIdentityStrictness`, `TestPayloadIdentityStrictness`, `TestAccessIdentityStrictness`, `capsule.TestMutationCorpus` |
| 7 | Threat model; §7.6, SHOULD: recommend `time_and_key` for long horizons or valuable content | creator model in `internal/testkit.Build`, `RewriteAge`; third-party edits in the mutation corpus; `cmd/datekeys` recommends `-policy time_and_key` beyond one year with `time_only` (`TestRecoveryAndNotices`) | `agewrap.TestTimeIdentityStrictness`, `TestPayloadIdentityStrictness`, `TestAccessIdentityStrictness`, `capsule.TestMutationCorpus` |
| 9 | Provider abstraction | `provider.Condition`, `provider.Release`, `provider.ReleaseSource` | `provider/*` |
| 10 | Provider Profile | `profile.Profile`, `Profile.Validate` | `profile.TestValidateRejectsTamperedProfiles` |
| 11 | Canonical profile encoding, `profile_hash`; `period` in 1..2^53−1, `genesis_time` in 0..2^53−1 | `Profile.CanonicalCBOR`, `Profile.Hash`, `profile.Decode` (hand-written `wire` encode and decode: keys 0 to 10, all required, in order; unsigned `genesis_time`, `codec.MaxSafeUint`; `period` limited to 1..86400 s, an implementation limit marked in `spec/datekeys.cddl`, `ERR_NON_CANONICAL_CBOR`) | `profile.TestQuicknetMatchesGoldenVector`, `TestQuicknetCBORLayout`, `TestDecodeRoundTrip`, `TestDecodeStructure`, `TestIntegerRanges`, `FuzzDecode`; `testdata/vectors/profile_quicknet.json`; the `provider_profile` block of `testdata/vectors/cbor.json` (*period of one day, the implementation limit*, *… above the implementation limit*) |
@ -51,11 +52,11 @@ v0.14, and §47.1 and §79, new in v0.15. A case of §64 that is not in the repo
| 29.5 | Paths: R1 and R8 in layer 3; R2 to R6c, R4b and R10 for each entry, then R7 and R9 over the tree, in layer 4; the key of R7; errors that name the rule and the character, never the text | `internal/pathrule` (`CheckPath`, `CheckTree`, `Key`, `NFD`, `Fold`, `Error`) | `pathrule.TestCheckPath`, `TestCheckTree`, `TestKey`, `TestNFD`; `testdata/vectors/paths.json` and `path_fold.json` (`internal/testkit.PathVectors`, `PathFoldVectors`, `TestFormat3VectorFiles`); the path mutations of §64 |
| 29.5.1 | Tables: Unicode 18.0.0 and the 15 WindowsBestFit tables, pinned by their SHA-256, never the Unicode functions of the platform | `internal/pathrule/gen`, which checks the 19 pinned files in `.cache` and writes `tables.go`, and with `-ts` and `-dart` the same tables for `datekeys-ts` and `datekeys-dart`; `pathrule.UnicodeVersion`, `TablesDigest` | `pathrule.TestTablesDigest`, `TestProperties`; NFD and folding compared with `golang.org/x/text` outside this module |
| 29.6 | Text of the comment and of the declared author: no control but TAB and LF in the comment, no bidirectional control, separator, byte order mark or noncharacter, the invisibles rule with R4b for each line, no space at the ends of the author; the writer turns CR LF and a lone CR into LF | `pathrule.CheckComment`, `CheckAuthor`; `capsule.EncryptFiles` (`newHead`) | `pathrule.TestTexts`; `testdata/vectors/head_schema.json`; `capsule.TestEncryptFilesRoundTrip`, `TestEncryptFilesRejects` |
| 29.7 | Verdicts X, F0 to F6 and S0 to S5, for each part the first row of the table that holds, with the texts of the table; the name of a certificate between « and », shown when it meets the rules of the declared author, has at most 64 code points and no two spaces in a row, and its SHA-256 otherwise; the holder by `givenName` and `surname` before `commonName` when both have text that is not empty, the issuer by `commonName` or, without one that has text, `organizationName`; after F6, a line for each required signer with the authority of its seal, and «DateKeys no comprueba quién emitió los sellos.» when one says before the date; a foreign signer apart, with its result in Spanish; before the date only by a valid seal with t + accuracy < round_time, with its warning; an mtime later than a valid seal shown as an inconsistency (SHOULD); the presentation: the verdicts first and last, the labels of the text of the creator, TABs expanded, pieces of at most W − 3 columns behind `│ ` with the width counted by excess, the lines of the verdicts in rows of at most W − 3 columns, broken at the last space that fits, each row after the first behind ` ↳ `, and warnings of risky names | `capsule.Verdict`, `Verdict.Text`, `Verdicts.Lines`, `Verdicts.SealedAt`, `Detail`, `SignerLine` (`quoted`, `instant`, `resultText`), `holderText`, `MaxNameLen`; `internal/cms` `Cert.Holder`, `Cert.IssuerName`; `capsule.Open` step 17.6 (`newSecurityContext`, `openBody`), `OpenOptions.AuthorKeys` (F3), `OpenOptions.Accept` (the verdicts before step 18); `cmd/datekeys.present` (`writeVerdicts`, `rows`, `contMark`, `writeCreator`, `pieces`, `expandTabs`, `outputWidth`, `termWidth`, `risks`) | `capsule.TestSecurityVerdicts`, `TestEvaluateSecurityIn` (the lines of F3 and F4), `TestEvaluateCMS` (the lines of F6 with the authority of each seal and the warning, a late seal without it, a foreign signer), `TestEvaluateSeal` (the line of S4), `TestIssuerTextFiltered`, `TestCMSVectors`; the lines of the records of the fixtures (`capsule.TestConformanceFixtures`) and of `testdata/vectors/security.json` (`internal/testkit.TestFormat3VectorFiles`); `cmd/datekeys.TestRows`, `TestPresent`, `TestMTimeAfterSeal`, `TestAuthorSignRoundTrip`, `TestEncryptDecryptRoundTrip`, `TestDecryptFormat3Fixtures`; the names of §64 of v0.12 (two spaces, more than 64 code points, ESC, U+202E, a byte order mark, `givenName` and `surname` with a NIF in `commonName`, an issuer without text): `security_cms.json` |
| 29.7 | Verdicts X, F0 to F6 and S0 to S5, for each part the first row of the table that holds, with the texts of the table; the name of a certificate between « and », shown when it meets the rules of the declared author, has at most 64 code points and no two spaces in a row, and its SHA-256 otherwise; the holder by `givenName` and `surname` before `commonName` when both have text that is not empty, the issuer by `commonName` or, without one that has text, `organizationName`; after F6, a line for each required signer with the authority of its seal, and «DateKeys no comprueba quién emitió los sellos.» when one says before the date; a foreign signer apart, with its result in Spanish; before the date only by a valid seal that carries accuracy, with t + accuracy < round_time, with its warning, and S5 otherwise with its reason, which the line of a signer of F6 gives too (v0.16); an mtime later than a valid seal shown as an inconsistency (SHOULD); the presentation: the verdicts first and last, the labels of the text of the creator, TABs expanded, pieces of at most W − 3 columns behind `│ ` with the width counted by excess, the lines of the verdicts in rows of at most W − 3 columns, broken at the last space that fits, each row after the first behind ` ↳ `, and warnings of risky names | `capsule.Verdict`, `Verdict.Text`, `Verdicts.Lines`, `Verdicts.SealedAt`, `Detail`, `SignerLine`, `SealReason` (`quoted`, `instant`, `resultText`), `holderText`, `MaxNameLen`; `internal/cms` `Cert.Holder`, `Cert.IssuerName`; `capsule.Open` step 17.6 (`newSecurityContext`, `openBody`), `OpenOptions.AuthorKeys` (F3), `OpenOptions.Accept` (the verdicts before step 18); `cmd/datekeys.present` (`writeVerdicts`, `rows`, `contMark`, `writeCreator`, `pieces`, `expandTabs`, `outputWidth`, `termWidth`, `risks`) | `capsule.TestSecurityVerdicts`, `TestEvaluateSecurityIn` (the lines of F3 and F4), `TestEvaluateCMS` (the lines of F6 with the authority of each seal and the warning, a late seal without it, a foreign signer), `TestEvaluateSeal` (the line of S4, and S5 with each reason), `TestIssuerTextFiltered`, `TestCMSVectors`; the lines of the records of the fixtures (`capsule.TestConformanceFixtures`) and of `testdata/vectors/security.json` (`internal/testkit.TestFormat3VectorFiles`); `cmd/datekeys.TestRows`, `TestPresent`, `TestMTimeAfterSeal`, `TestAuthorSignRoundTrip`, `TestEncryptDecryptRoundTrip`, `TestDecryptFormat3Fixtures`; the names of §64 of v0.12 (two spaces, more than 64 code points, ESC, U+202E, a byte order mark, `givenName` and `surname` with a NIF in `commonName`, an issuer without text): `security_cms.json` |
| 29.8 | Author signature, what is signed: `payload_commit`; `CONTROL_SIG`, the control with `payload_commit` in place of `I_PAYLOAD` and L at zero; `control_commit`, `head_digest`, `signers_digest`, and `AUTHOR_MESSAGE`, ASCII of 99 bytes, with its code of 8 characters; never the area or the padding, so the area can grow after signing; every value recomputed from the opened capsule | `capsule.PayloadCommit`, `ControlCommit`, `HeadDigest`, `SignersDigest`, `AuthorMessage`, `AuthorMessagePrefix`, `AuthorMessageSize`, `AuthorCode`; `capsule.Open` step 17.6 (`newSecurityContext`); the writer signs once the control is final, in the `prepare` that `EncryptFiles` gives `sealer.write` (`sealer.security`) | `capsule.TestAuthorMessage` (99 bytes, the code, `control_commit` without L and with `I_PAYLOAD`), `TestSignedFixtureVerdicts` (another control or head: F2), `TestAreaChosenAfterSigning` (signed once); `TestConformanceFixtures` (`checkSignature3`: the commitments, `AUTHOR_MESSAGE` and its code in the records of `format3_signed`, `format3_signed_cms` and `format3_sealed`); mutations *the signature of alg 1 transplanted to another capsule …*, *the area widened to 64 KiB after signing …* |
| 29.9 | Signature with a key of one's own, `alg` 1: Ed25519 of `AUTHOR_MESSAGE`; a key or a signature of another length is F1; valid only with A canonical and not of small order, `sig[63] & 0xE0` = 0, S < ℓ and the equation without the cofactor, F2 otherwise; F4, or F3 with a key the person saved | `internal/ed25519strict` (`Verify`, `Canonical`, `SmallOrder`, `SmallOrderPoints`, `OnCurve`), around `crypto/ed25519`; `capsule.evaluateSignature`; `EncryptOptions.AuthorKey`, the interface `capsule.AuthorKey` | `ed25519strict.TestVectors`, `TestVerifyRejectsWhatStdlibAccepts`, `TestCanonical`, `TestSmallOrderTable`, `TestOnCurve`; `testdata/vectors/ed25519_strict.json`, the cases of «Taming the many EdDSAs» (`internal/testkit.Ed25519StrictVectors`); `capsule.TestEvaluateSecurityIn`, `TestEncryptFilesSigned`, `TestEncryptFilesSignatureChecked`, `TestSignedFixtureVerdicts`; fixture `format3_signed`, whose signature `TestConformanceFixtures` makes again from its seed; mutations *a signature of alg 1 that does not verify …* and those of `alg` 1 of the list of v0.11: altered, removed, made again with another key, transplanted, a key of 31 bytes and a signature of 65 |
| 29.10 | Signature with certificates, `alg` 2: a detached CMS signature with the CAdES profile; `SIGNERS`, 1 to 16 SHA-256 of certificates in strictly ascending order; the form in its order (F1), with the version of a `SignerInfo` by its `sid`, attributes counted by attribute, a `signing-certificate` beside the v2 that decides nothing, an `ESSCertIDv2` with SHA-256 written, the parameters of PSS, object identifiers compared by the bytes of their DER and a SET OF that repeats an element; the closed table of algorithms; the profile of the certificate field by field, its key RSA with NULL parameters and an odd modulus, or EC uncompressed on P-256, P-384 or P-521; the result of each required signer in its order: absent, not verifiable, invalid (a key of another scheme than the algorithm included), without seal, invalid seal, out of validity, valid; F2, F5 or F6; no key 3; never who issued a certificate or whether it was revoked | `internal/der` (`Check`, `Split`, `Content`, `SetOfSorted`, `ParseTime`); `internal/cms` (`ParseSignature`, `SignedData`, `SignerInfo`, `SignerInfo.Check`, `ParseCert`, `Cert`, `Cert.ValidAt`, `ErrForm`, `ErrAlgorithm`), with the standard library only; `capsule.EncodeSigners`, `decodeSigners`, `MaxSigners`, `evaluateCMS`, `signerLine`; `EncryptOptions.CMSSigner`, the interface `capsule.CMSSigner`; `internal/cms/cmstest`, which makes certificates, signatures and tokens for the tests | `cms.TestSignatureAlgorithms` (RSA PKCS #1 v1.5 and PSS, SHA-256 to SHA-512, ECDSA on P-256, P-384 and P-521, a `sid` by `subjectKeyIdentifier`), `TestCoSignature`, `TestSignatureNotVerifiable`, `TestSignatureForm`, `TestSignatureStrictness`; `der.TestCheck`, `TestSetOfSorted`, `TestDepth`, `TestParseTime`, `TestSplit`; `capsule.TestEvaluateCMS` (a required signer absent, without seal, a key 3 beside it, another head, `SIGNERS` out of order or empty), `TestEncodeSigners`, `TestEncryptFilesCMSAndSeal`, `TestCMSVectors` (`testdata/vectors/security_cms.json`); fixture `format3_signed_cms`; the cases of `alg` 2 of §64 of v0.12 (a certificate out of validity with a valid TSA, the profile of the certificate, identifiers, repetitions, keys of another scheme or compressed): `security_cms.json` |
| 29.11 | Time seal, RFC 3161: the CAdES-T of each signer of `alg` 2, over its signature value, or `seal_type` 2 in key 3, over `SEAL_SUBJECT`, without a signature or with `alg` 1; `SIG_PART`; the profile of the token in its order: the form (S2), with the `TSTInfo` in DER field by field, `genTime` in UTC with Z, `accuracy` of 0 to 2³¹ − 1 seconds and minimal millis and micros, `ordering` only TRUE, nothing after the last field, and `crls` deciding nothing; the algorithms (S1); the verification (S3), a `messageImprint` of another length included; S4 when t + accuracy < round_time, S5 otherwise | `internal/cms` (`ParseToken`, `Token`, `Token.Check`, `Token.ImprintIsSHA256`, `parseTSTInfo`, `parseAccuracy`); `capsule.SigPart`, `SealSubject`, `SealTypeRFC3161`, `evaluateSeal`, `signerLine`; `EncryptOptions.Sealer`, the interface `capsule.Sealer` | `cms.TestTokenOverSignature`, `TestTokenFailures`, `TestTSTInfoStrict` (a negative accuracy, millis of 0, a `genTime` with an offset or a trailing zero, `ordering` FALSE written, a field after the last); `capsule.TestEvaluateSeal`, `TestEncryptFilesCMSAndSeal`, `TestCMSVectors`; fixture `format3_sealed`; the cases of `seal_type` 2 of §64 of v0.12: `security_cms.json` |
| 29.11 | Time seal, RFC 3161: the CAdES-T of each signer of `alg` 2, over its signature value, or `seal_type` 2 in key 3, over `SEAL_SUBJECT`, without a signature or with `alg` 1; `SIG_PART`; the profile of the token in its order: the form (S2), with the `TSTInfo` in DER field by field, `genTime` in UTC with Z, `accuracy` of 0 to 2³¹ − 1 seconds and minimal millis and micros, `ordering` only TRUE, nothing after the last field, and `crls` deciding nothing; the algorithms (S1); the verification (S3), a `messageImprint` of another length included; S4 when the token carries accuracy and t + accuracy < round_time, S5 otherwise, with its reason, the first that holds: late, no accuracy under the BTSP policy of ETSI EN 319 421, no accuracy (v0.16) | `internal/cms` (`ParseToken`, `Token`, `Token.Check`, `Token.ImprintIsSHA256`, `Token.HasAccuracy`, `Token.Policy`, `Token.BTSP`, `parseTSTInfo`, `parseAccuracy`); `capsule.SigPart`, `SealSubject`, `SealTypeRFC3161`, `evaluateSeal`, `signerLine`, `sealReason`; `EncryptOptions.Sealer`, the interface `capsule.Sealer` | `cms.TestTokenOverSignature`, `TestTokenFailures`, `TestTSTInfoStrict` (a negative accuracy, millis of 0, a `genTime` with an offset or a trailing zero, `ordering` FALSE written, a field after the last); `capsule.TestEvaluateSeal`, `TestEncryptFilesCMSAndSeal`, `TestCMSVectors`; fixture `format3_sealed`; the cases of `seal_type` 2 of §64 of v0.12: `security_cms.json` |
| 29.12 | Author keys of `alg` 1: a seed of Ed25519; the public key `dkauthor1…`, 67 characters in lower case, and the secret key `DKAUTHOR-SECRET-KEY-1…`, 79 in upper case, refused in another case, length or prefix, or with padding bits that are not zero; a public key canonical, a point of the curve and not of small order; a file of a secret key encrypted with age and a passphrase, scrypt of logN 16; a key that the person saved gives F3 with her label | `authorkey` (`Generate`, `NewFromSeed`, `Key.Public`, `Key.Sign`, `Key.Secret`, `Key.String` and `Key.GoString`, which hide the secret key, `Key.Clear`, `PublicString`, `ParsePublic`, `ParseSecret`, `Marshal`, `Encrypt`, `Read`, `WorkFactor`); `capsule.OpenOptions.AuthorKeys`; `cmd/datekeys`: `author keygen` and `author public` (`authorKeygen`, `authorPublic`, `loadAuthorKey`, `readPass`: the passphrase from a file or the standard input), `encrypt -sign` (`announced`), `decrypt -expect-author` | `authorkey.TestStrings` (a printed key never shows its secret), `TestParseRejects` (y = 2, off the curve), `TestFiles`; `ed25519strict.TestOnCurve`; `capsule.TestEncryptFilesSigned` (F3 with the key saved); `cmd/datekeys.TestAuthorSignRoundTrip` |
| 30 | PAYLOAD_AGE is a complete age file | `filippo.io/age` public API only | `capsule.TestInteropAgeOpensPayload` (`-tags interop`, official `age` CLI) |
| 30.1 | CONTROL_CBOR ↔ PAYLOAD_AGE binding; in format 2, L and the code fix the length and the padding of the plaintext, which adds determinism, not authenticity | `agewrap.PayloadIdentity` | mutations *SEALED_CONTROL_A + PAYLOAD_AGE_B*, *padding code 2 changed to 1, with L = 78000*, *payload_length L - 1, the last byte of the content not zero*; `capsule.TestTrustModel` (another L of the same P); `agewrap.TestPayloadIdentityStrictness` |
@ -68,7 +69,7 @@ v0.14, and §47.1 and §79, new in v0.15. A case of §64 that is not in the repo
| 36.1 | Authenticity semantics | documented in `README.md`, `SECURITY.md` | — (a property the protocol does not provide) |
| 37 | X25519 recipient V1; the writer rejects a recipient that is not canonical (bit 255 set, or u ≥ p) or of low order, and MAY reject a point of the twist | `age.X25519Recipient`; `agewrap.X25519IdentityFromRaw`, `agewrap.CheckX25519Recipient` (run by `capsule.Encrypt`); the twist check is not implemented | `agewrap.TestRawKeys`, `TestNonCanonicalRecipients`; `capsule.TestEncryptRejectsInvalidOptions` |
| 38 | Portable Access Key | `EncryptOptions.NewPortableKey` (fresh `I_ACCESS` per capsule; no API accepts an existing one); `accesskey.AccessKey` | `capsule.TestPortableKeysAreNeverReused` |
| 38.1 | Key of words: one more X25519 credential of `time_and_key`, among the 16; the normalization: NFD with the tables of Unicode 18.0.0, without U+0300 to U+036F, the simple lower case of each code point, split by the spaces of the list; PBKDF2-HMAC-SHA256 of 600 000 rounds, salted with the chain hash, the round and `capsule_id`, into a raw X25519 identity; the writer requires at least 6 words, counting only different words of 3 or more letters, and refuses controls, ignorables and unassigned code points; a reader may ask for the words instead of a `.dkk` | `wordkey` (`Normalize`, `Check`, `Key`, `Identity`, `Rounds`, `MinWords`, `MinLetters`), with `pathrule.NFD`, `Lower`, `DefaultIgnorable` and `Assigned`; `capsule.EncryptOptions.Words` (`accessRecipients`; `sealer.write` derives the identity once `capsule_id` is drawn); `cmd/datekeys`: `-words` and `-words-file` of `encrypt` and `decrypt` (`wordsText`), the words of `decrypt` salted with what `capsule.Inspect` gives | `wordkey.TestNormalize`, `TestCheck`, `TestKeyVector` (the vector of §38.1); `testdata/vectors/wordkey.json` (`internal/testkit.WordKeyVectors`, `TestVectorFilesAreCurrent`): the words of a text, what a writer refuses and the identities, the cases of §64 of v0.11; `capsule.TestEncryptFilesWords` (the words of another `capsule_id` do not open); `cmd/datekeys.TestKeyOfWords` |
| 38.1 | Key of words: one more X25519 credential of `time_and_key`, among the 16; the normalization: NFD with the tables of Unicode 18.0.0, without U+0300 to U+036F, the simple lower case of each code point, split by the spaces of the list; PBKDF2-HMAC-SHA256 of 600 000 rounds, salted with the chain hash, the round and `capsule_id`, into a raw X25519 identity; the writer requires at least 6 words, counting only different words of 3 or more letters, and refuses controls, ignorables and unassigned code points; a reader may ask for the words instead of a `.dkk`; SHOULD: random words of a public list by default, at least 6 of 2048 or more | `wordkey` (`Normalize`, `Check`, `Key`, `Identity`, `Rounds`, `MinWords`, `MinLetters`; `Generate`, `List`, `CheckList` with the alphabet of each language, `Bits`, `DefaultCount`, `MinListSize`, the lists `lists/en.txt` and `lists/es.txt`; the dice: `DiceListSize`, `DiceNumber`, `DiceWord`, `DiceWords`, `DiceList`), with `pathrule.NFD`, `Lower`, `DefaultIgnorable` and `Assigned`; `capsule.EncryptOptions.Words` (`accessRecipients`; `sealer.write` derives the identity once `capsule_id` is drawn); `cmd/datekeys`: `-words` and `-words-file` of `encrypt` and `decrypt` (`wordsText`), `-new-words`, `-dic`, `-word-count`, `-dice` and `-dice-file` of `encrypt`, the command `wordlist`, the words of `decrypt` salted with what `capsule.Inspect` gives | `wordkey.TestNormalize`, `TestCheck`, `TestKeyVector` (the vector of §38.1), `TestBuiltInLists`, `TestCheckList`, `TestGenerate`, `TestBits`, `TestGenerateUniform`, `TestDiceNumber`, `TestDiceWord`, `TestDiceWords`, `TestDiceList`; `testdata/vectors/wordkey.json` (`internal/testkit.WordKeyVectors`, `TestVectorFilesAreCurrent`): the words of a text, what a writer refuses and the identities, the cases of §64 of v0.11; `capsule.TestEncryptFilesWords` (the words of another `capsule_id` do not open); `cmd/datekeys.TestKeyOfWords`, `TestNewWords`, `TestDice`, `TestWordList` |
| 39 | Recipients of INNER_ACCESS_AGE: in formats 2 and 3 from 1 to 16 credentials, a dummy in each slot left (a fresh public key whose private key is dropped at once), the 16 in a uniformly random order; which slots are dummies is recorded only in the official vectors | `capsule/encrypt.go` (`accessRecipients`, `fillSlots`, `permute`); `agewrap.AccessIdentity` | `capsule.TestInnerHasSixteenStanzas`, `TestDummyRecipients`, `TestStanzaOrderIsUniform`, `TestCredentialBounds`, `TestFixtureRecipients`, `TestEncryptRoundTripBothPolicies`; `TestConformanceFixtures` (the stanza each credential opens, `access_key_stanza` and `identity_stanzas` in the records) |
| 40 | `.dkk` framing; `BODY_LEN` in 1..16 MiB (0 is `ERR_INTEGRITY`); order of the frame checks | `accesskey.Encode`, `accesskey.Decode` (the body buffer grows with the data read; every buffer holding the body is wiped) | `accesskey.TestDecodeRejects`, `TestDecodePrecedence`, `TestDecodeShortBodyAllocatesLittle`, `TestEncodeAndDecodeLeaveNoStaleMaterial`, `FuzzDecode` |
| 41 | `.dkk` BODY_CBOR | `AccessKey.MarshalBody`, `accesskey.DecodeBody` (hand-written `bodyWire` encode and decode) | `accesskey.TestFixtures`, `TestDecodeBodyStructure` |
@ -79,13 +80,13 @@ v0.14, and §47.1 and §79, new in v0.15. A case of §64 that is not in the repo
| 45 | Release API: its answer is the release object of §47.1 (v0.15), and its user a network source; the HTTP form is informative | the object: `provider.EncodeRelease`, `provider.DecodeRelease`; the API itself is out of scope (server, plan §2) | `provider.TestReleaseVectors` |
| 46 | Release Queue | out of scope (server) | — |
| 47 | Release Cache | every release is verified again: `capsule.Open` step 10 and `agewrap.TimeIdentity`; `release_material` is the release object (v0.15): `provider.EncodeRelease` | mutations *release of another round* |
| 47.1 | Release object (v0.15): deterministic CBOR without a frame, `{0: "datekeys-release", 1: 1, 2: chain_hash, 3: round, 4: signature}`; size from 1 to 1024 bytes, then type and version, then schema (`ERR_NON_CANONICAL_CBOR`, a version other than 1 `ERR_UNSUPPORTED_VERSION`); the chain hash, the round and the signature at step 10; drand's JSON accepted as the input of the caller, `ERR_RELEASE_INVALID` when unreadable; a Release Cache and the Release API keep and serve the object | `provider/release.go`: `EncodeRelease`, `DecodeRelease` (`releaseWire` with `codec.CheckSchema` and `codec.Unmarshal`), `ParseRelease`, `NewReleaseObject`, `MaxReleaseObjectSize`, `MaxReleaseJSONSize`; `cmd/datekeys` `releaseInHand` | `provider.TestReleaseVectors`, `TestEncodeRelease`, `FuzzDecodeRelease`; `testdata/vectors/release.json`, `testdata/releases/<round>.cbor`; `internal/testkit.TestVectorFilesAreCurrent` |
| 47.1 | Release object (v0.15): deterministic CBOR without a frame, `{0: "datekeys-release", 1: 1, 2: chain_hash, 3: round, 4: signature}`; size from 1 to 1024 bytes, then type and version, then schema (`ERR_NON_CANONICAL_CBOR`, a version other than 1 `ERR_UNSUPPORTED_VERSION`); the chain hash, the round and the signature at step 10; drand's JSON accepted as the input of the caller, `ERR_RELEASE_INVALID` when unreadable, and since v0.16 read strictly: no repeated name, names compared exactly once their escapes are decoded, no lone surrogate, round an integer from 1 to 2^53 − 1; a Release Cache and the Release API keep and serve the object | `provider/release.go`: `EncodeRelease`, `DecodeRelease` (`releaseWire` with `codec.CheckSchema` and `codec.Unmarshal`), `ParseRelease`, `ParseDrandJSON`, `NewReleaseObject`, `MaxReleaseObjectSize`, `MaxReleaseJSONSize`; `provider/drandjson.go` (`strictJSON`, `jsonRound`), which `provider/drand` uses for the answers of the relays too; `cmd/datekeys` `releaseInHand` | `provider.TestReleaseVectors`, `TestEncodeRelease`, `FuzzDecodeRelease`, `TestStrictJSON`, `TestJSONRound`; `testdata/vectors/release.json`, `testdata/releases/<round>.cbor`; `internal/testkit.TestVectorFilesAreCurrent` |
| 48 | Multi-relay; a network source verifies every response with the rules of §63 step 10 and discards the invalid ones: none valid is `ERR_RELEASE_UNAVAILABLE` at step 9, and so is any other failure of a source, with no other code | `provider/drand.Client` (race, first *verified* release wins; the failure of each relay kept as text only, a context that ended detectable with `errors.Is`), the `provider.ReleaseSource` contract, `capsule.Open` (step 9 keeps only the text of a source error with another code or none) | `drand.TestRaceWaitsForAValidSignature`, `TestRejectMalformedRelayResponses`, `TestFetchErrorHasOneCode`, `TestUnavailabilityAndCancellation`; `capsule.TestReleaseFromANetworkSource`, `TestReleaseSourceErrorsAtStep9` |
| 49 | Direct recovery from the provider | `provider/drand`; v0.15: a release in hand, `provider.Supplier` (`provider.Encoded`, `provider.Archive`) in `capsule.OpenOptions.Release`; `datekeys decrypt -release` | `drand.TestLiveRelays`, `capsule.TestLiveLifecycle` (`-tags integration`); `capsule.TestReleaseInHand`, `TestReleaseInHandErrors`; `cmd/datekeys.TestDecryptWithReleaseInHand` |
| 50 | Historical release dependency; v0.15: long-term recovery on archives of all rounds and on cache services that serve them, with no hosting promise, the release archive as an informative format | documented in `README.md`; `provider.Archive`, `provider.EncodeArchiveHeader`; `datekeys decrypt -release` with a local archive | `provider.TestArchive`; `cmd/datekeys.TestDecryptWithReleaseInHand` (*a local archive*); the `archive` block of `testdata/vectors/release.json`, `testdata/releases/archive_1000_1004.bin` |
| 51 | Quicknet release verification; order and codes of §63 step 10: the round (`ERR_ROUND_MISMATCH`), then the signature, the canonical encoding of a point of G1 other than the identity (§12.2) that verifies as the BLS signature of the round (`ERR_RELEASE_INVALID`); those codes for a release supplied directly, a network source discarding an invalid one at step 9 (`ERR_RELEASE_UNAVAILABLE`) | `provider.Verify`; v0.15: first the chain hash a release object names, `ERR_PROFILE_MISMATCH` (`provider.Verify`) | `provider.TestVerifyPublishedReleases`, `TestVerifyRejects` (x + p, the point at infinity alone, with a payload or with the sort flag, no compression flag, the negated signature), `TestVerifyUsesThePinnedKeyOnly`; `capsule.TestReleaseFromANetworkSource`; mutations *DateKey A + release of round B*, *release of another round*, *release signature …*; the `objects` of `testdata/vectors/release.json`; mutations *release object of another chain* |
| 52 | DNS / MITM | `provider/drand` (no redirects, bounded responses, BLS) | `drand.TestRedirectsAreNotFollowed`, `TestRejectMalformedRelayResponses`, `TestRandomnessMustMatchWhenPresent` |
| 53 | Harvest now, decrypt later | `cmd/datekeys` warning beyond one year; v0.15: the release among what a long horizon needs (text) | `cmd/datekeys.TestLongHorizonWarning` |
| 53 | Harvest now, decrypt later | `cmd/datekeys` warning beyond one year; v0.15: the release among what a long horizon needs (text) | `cmd/datekeys.TestLongHorizonWarning`, `TestRecoveryAndNotices` |
| 54 | Extensions: data absent or a non-empty opaque byte string, never decoded; 1 to 64 per array; `extension_id` of at least 1 byte; `extension_version` ≤ 2^32−1; elements in strictly ascending unsigned bytewise order of the UTF-8 bytes of `extension_id` (a proper prefix first, never UTF-16 code units or a collation), so one `extension_id` per array, and none in both arrays; a known extension in an object or array it is not registered for is unknown there, its data never interpreted | `extension.New`, `Canonical`, `EncodeArray` (refuses, through `codec.Encoder.Fail`, an array that `DecodeArray` rejects), `DecodeArray` (64 entries checked on the array head, explicit key 2 check), `CheckDisjoint` (linear merge), `CheckCritical`, `CheckNoncritical`, `Unusable`; `Object`, `Array`, the optional `Placement` of a `Registry`, `KnownIn`, and `CheckCriticalIn` and `CheckNoncriticalIn`, which `capsule` runs at steps 4, 9.a and 14 | `extension.TestNew`, `TestData`, `TestCanonicalSorts`, `TestOrderIsUnsignedBytewise`, `TestCanonicalRejects`, `TestEncodeArrayRejects`, `TestDecodeArrayRejects`, `TestCheckDisjoint`, `TestCheckDisjointIsLinear`, `TestCheckCritical`, `TestCheckNoncritical`, `TestPlacement`, `FuzzDecodeArray`; `capsule.TestKnownCriticalExtensions`, `TestUnusableNoncriticalExtensions`, `TestExtensionPlacement`; mutations *unknown critical … extension*, *known critical … extension with invalid data*, *extension_version above 2^32-1*, *null extension data* |
| 55 | Auxiliary integrity | `capsule_digest` treated as UX only | — |
| 55.1 | Trust model: who writes each section, from which step and by what it is bound, what it never proves | no code of its own: `header_binding` (step 15), the age header MACs (steps 11, 13 and 17), `capsule_id` and `capsule_digest` (step 9.a) | `capsule.TestTrustModel` (a capsule forged from the public bytes of `time_only.dkc` or `format2_time_only.dkc` opens; a control that declares L + 1 over a zero padding byte opens to another content; edited PUBLIC_HEADER data passes steps 1 to 8 and fails step 15; other `.dkk` extension data opens the capsule) |
@ -98,22 +99,22 @@ v0.14, and §47.1 and §79, new in v0.15. A case of §64 that is not in the repo
| 60 | Conceptual Go interfaces | `provider.ReleaseSource`, `provider.Verify`, `datekey.Resolve`, `datekey.RoundTime` | — |
| 61 | `time_only` encryption flow, format 3: the files measured, hashed, sealed and read again | `capsule.EncryptFiles` | `capsule.TestEncryptFilesRoundTrip`, `TestEncryptFilesLengths`, `TestEncryptFilesChangedFile`, `TestSealedControlLength`; the live test (`-tags integration`) |
| 62 | `time_and_key` encryption flow, format 3: 16 recipients, and the SEALED_CONTROL_LEN of an INNER_ACCESS_AGE of 16 stanzas | `capsule.EncryptFiles` | `capsule.TestEncryptFilesTimeAndKey`, `TestEncryptRoundTripBothPolicies`, `TestPortableKeysAreNeverReused`, `TestSealedControlLength` |
| 62.1 | Writer rules: format 3 only, formats 1 and 2 being written only by a generator of test vectors; an instant after the clock of the writer; from 1 to 16 credentials, none twice, canonical and not of low order; dummies and a random order; `capsule_id`, `I_PAYLOAD`, `I_ACCESS`, `credential_id`, dummies and order from a CSPRNG, `I_PAYLOAD` and dummies never reused or derived; L known before sealing, at most L_MAX, code 1 or 2; SEALED_CONTROL_LEN exact, measured by a provisional seal and checked; limits; on error, the output is discarded; in format 3, the area of 32768 bytes, signed or not, and 65536 only when the creator widens it once the signatures are made, without signing again for it, a capsule refused when they do not fit, `SECURITY_CBOR` always and empty without a signature or a seal, another area or `SECURITY_CBOR` only from a generator of test vectors (rule 13), a head with a fresh salt, a file or a comment, the order of R8, the layout and the SHA-256 of what is written, at most 16 MiB, paths and texts refused with the rule and the character, the mtime taken at load and omitted out of range, the three CBOR objects decoded with the rules of the reader (MUST), and files that must not change between the two readings; with a signature or a seal, each verified with the rules of the reader before writing, never one that gives F1, F2, F5, S1, S2 or S3 (rule 19), `AUTHOR_MESSAGE` given as text and its code shown before each signature, `SIGNERS` closed before the first (rule 20), a CAdES-T for each signer of `alg` 2 (rule 21), the seal of `seal_type` 2 over `SEAL_SUBJECT` after the signature (rule 22), and no secret on disk while waiting for them (rule 25); the public note only when asked for, with the rules of the declared author and a warning (rule 23); the rules of §38.1 for a key of words, and for a `.dkk` with a locator the rest stored before the `.dkk` is written (rule 24). SHOULD: code 2 by default, self-checks, wiping | `capsule.EncryptFiles` (`newHead`, `readSource`, `selfCheckHead`) and `capsule.Encrypt`, through their sealer (`EncryptOptions.Padding`, `accessRecipients`, `fillSlots`, `copyExactly`, `selfCheckHeader`, `selfCheckControl`, `selfCheckInner`, `selfCheckPayload`); `agewrap.CheckX25519Recipient`; `EncryptOptions.TestVectors` for `Encrypt` of format 2, and `internal/testkit.Build`, generators of test vectors (§70); in format 3, `capsule.AreaLen`, `LargeAreaLen` and `EncryptOptions.LargeArea`, the area decided by `EncryptFiles`, in the `prepare` that it gives `sealer.write`, once `sealer.security` has signed and sealed and checked both with `EvaluateSecurityIn` (rules 13, 19, 21 and 22), `EncryptOptions.AuthorKey`, `CMSSigner` and `Sealer`, a typed nil in one of them an error (`newSealer`, `isNil`), nothing written to `dst` before they return and the control and `I_PAYLOAD` kept in memory (rule 25); `EncryptOptions.TestAreaLen`, with `TestVectors`, the area of 512 bytes of the fixtures of v0.10 (rule 13); `EncryptOptions.PublicNote` and `extension.CheckWrite` (rule 23, §72); `EncryptOptions.Words` and `wordkey.Check`, and `locator.NewEnvelope`, `Locator.Marshal` and `Info.Extension` (rule 24); `cmd/datekeys`: `announced` (rule 20) and the warning of `-note` (rule 23); v0.15, rules 26 and 27 (SHOULD of the SDK): the warning and the annex next to the `.dkc` are not implemented by the CLI | `capsule.TestEncryptFilesRejects`, `TestEncryptFilesChangedFile`, `TestEncryptIsForTestVectors`, `TestEncryptFilesHeadCritical`, `TestEncryptRejectsInvalidOptions`, `TestCredentialBounds`, `TestEncryptSourceLength`, `TestEncryptSelfCheck`, `TestSealedControlLength`, `TestPayloadIdentityReuse`, `TestStanzaOrderIsUniform`, `TestDummyRecipients`, `TestPortableKeysAreNeverReused`; `cmd/datekeys.TestEncryptRefusesPaths`; rules 13 and 19 to 25: `capsule.TestEncryptFilesSigned`, `TestEncryptFilesSignatureChecked` (nothing written), `TestEncryptFilesCMSAndSeal` (a signature without a required signer, or without seals, is not written), `TestAreaChosenAfterSigning` (the area widened once signed, signing once; without a signature, 32 KiB with `LargeArea`), `TestWriterOptionsChecked`, `TestPublicNoteRules`, `TestRegisteredExtensionsWhereRegistered`, `TestEncryptFilesWords`; `wordkey.TestCheck`; `locator.TestUsableAddresses`, `TestInfo`; `cmd/datekeys.TestAuthorSignRoundTrip` (the key and the code before the signature), `TestPublicNoteCLI`, `TestKeyOfWords`; rule 25 has no test of its own |
| 62.1 | Writer rules: format 3 only, formats 1 and 2 being written only by a generator of test vectors; an instant after the clock of the writer; from 1 to 16 credentials, none twice, canonical and not of low order; dummies and a random order; `capsule_id`, `I_PAYLOAD`, `I_ACCESS`, `credential_id`, dummies and order from a CSPRNG, `I_PAYLOAD` and dummies never reused or derived; L known before sealing, at most L_MAX, code 1 or 2; SEALED_CONTROL_LEN exact, measured by a provisional seal and checked; limits; on error, the output is discarded; in format 3, the area of 32768 bytes, signed or not, and 65536 only when the creator widens it once the signatures are made, without signing again for it, a capsule refused when they do not fit, `SECURITY_CBOR` always and empty without a signature or a seal, another area or `SECURITY_CBOR` only from a generator of test vectors (rule 13), a head with a fresh salt, a file or a comment, the order of R8, the layout and the SHA-256 of what is written, at most 16 MiB, paths and texts refused with the rule and the character, the mtime taken at load and omitted out of range, the three CBOR objects decoded with the rules of the reader (MUST), and files that must not change between the two readings; with a signature or a seal, each verified with the rules of the reader before writing, never one that gives F1, F2, F5, S1, S2 or S3, and the verdicts of the area returned in `Result.Security`, so that a seal without accuracy is warned of (rule 19, v0.16), `AUTHOR_MESSAGE` given as text and its code shown before each signature, `SIGNERS` closed before the first (rule 20), a CAdES-T for each signer of `alg` 2 (rule 21; the chains without roots and the OCSP responses of v0.16 are what the `CMSSigner` returns: this module adds and removes no certificate, *pending* until a writer asks an authority), the seal of `seal_type` 2 over `SEAL_SUBJECT` after the signature (rule 22), and no secret on disk while waiting for them (rule 25); the public note only when asked for, with the rules of the declared author and a warning (rule 23); the rules of §38.1 for a key of words, and for a `.dkk` with a locator the rest stored before the `.dkk` is written (rule 24). SHOULD: code 2 by default, self-checks, wiping | `capsule.EncryptFiles` (`newHead`, `readSource`, `selfCheckHead`) and `capsule.Encrypt`, through their sealer (`EncryptOptions.Padding`, `accessRecipients`, `fillSlots`, `copyExactly`, `selfCheckHeader`, `selfCheckControl`, `selfCheckInner`, `selfCheckPayload`); `agewrap.CheckX25519Recipient`; `EncryptOptions.TestVectors` for `Encrypt` of format 2, and `internal/testkit.Build`, generators of test vectors (§70); in format 3, `capsule.AreaLen`, `LargeAreaLen` and `EncryptOptions.LargeArea`, the area decided by `EncryptFiles`, in the `prepare` that it gives `sealer.write`, once `sealer.security` has signed and sealed and checked both with `EvaluateSecurityIn` (rules 13, 19, 21 and 22), `EncryptOptions.AuthorKey`, `CMSSigner` and `Sealer`, a typed nil in one of them an error (`newSealer`, `isNil`), nothing written to `dst` before they return and the control and `I_PAYLOAD` kept in memory (rule 25); `EncryptOptions.TestAreaLen`, with `TestVectors`, the area of 512 bytes of the fixtures of v0.10 (rule 13); `EncryptOptions.PublicNote` and `extension.CheckWrite` (rule 23, §72); `EncryptOptions.Words` and `wordkey.Check`, and `locator.NewEnvelope`, `Locator.Marshal` and `Info.Extension` (rule 24); `cmd/datekeys`: `announced` (rule 20) and the warning of `-note` (rule 23); v0.15, rules 26 and 27 (SHOULD of the SDK): the warning and the annex next to the `.dkc` are not implemented by the CLI | `capsule.TestEncryptFilesRejects`, `TestEncryptFilesChangedFile`, `TestEncryptIsForTestVectors`, `TestEncryptFilesHeadCritical`, `TestEncryptRejectsInvalidOptions`, `TestCredentialBounds`, `TestEncryptSourceLength`, `TestEncryptSelfCheck`, `TestSealedControlLength`, `TestPayloadIdentityReuse`, `TestStanzaOrderIsUniform`, `TestDummyRecipients`, `TestPortableKeysAreNeverReused`; `cmd/datekeys.TestEncryptRefusesPaths`; rules 13 and 19 to 25: `capsule.TestEncryptFilesSigned`, `TestEncryptFilesSignatureChecked` (nothing written), `TestEncryptFilesCMSAndSeal` (a signature without a required signer, or without seals, is not written), `TestAreaChosenAfterSigning` (the area widened once signed, signing once; without a signature, 32 KiB with `LargeArea`), `TestWriterOptionsChecked`, `TestPublicNoteRules`, `TestRegisteredExtensionsWhereRegistered`, `TestEncryptFilesWords`; `wordkey.TestCheck`; `locator.TestUsableAddresses`, `TestInfo`; `cmd/datekeys.TestAuthorSignRoundTrip` (the key and the code before the signature), `TestPublicNoteCLI`, `TestKeyOfWords`; rule 25 has no test of its own |
| 63 | Decryption flow; step 2 accepts the formats 1, 2 and 3, and the steps after it apply the rules of the format: in formats 2 and 3, 16 stanzas at step 12, a control of schema version 2 at step 14, L, the code and P at step 16, a plaintext of P bytes with a zero padding at step 17 (`ERR_INTEGRITY` whenever it is found), the first L bytes at step 18; in format 3, step 17 in its substeps, a failure of age or a plaintext whose length is not P prevailing and a code other than `ERR_INTEGRITY` reported only after reading to EOF, and a caller without a `Sink` stopped right after step 2; steps 4 and 14 validate critical extensions (unknown, then invalid data); step 5 reads SEALED_CONTROL, a MUST (`ERR_INTEGRITY`), and SHOULD inspect its age header; step 8 argument rules; step 9 order: the `.dkk` as an object (decoded there when still encoded), its `capsule_id` and `capsule_digest`, credentials (nil identities are none) before the clock, round time, request, and nothing of the credentials under `time_only`; a network source verifies each response with the rules of step 10 and discards the invalid ones (none valid: `ERR_RELEASE_UNAVAILABLE`, step 9), and any failure of a source is `ERR_RELEASE_UNAVAILABLE` alone, whatever code its error carries; step 10: round, then signature, a canonical point other than the identity (§12.2), the codes of a release supplied directly; step 11: the tlock stanza body `U \|\| V \|\| W` of \|U\| + 32 bytes (128 in Quicknet), U canonical and not the identity, the IBE check r·G == U, every failure `ERR_INTEGRITY`, H2, H3 and H4 those of drand/kyber `encrypt/ibe`, H2 over the element of GT serialized in the order of kilic/bls12-381 (c1 before c0 at every level of the tower), with the frozen vector H2(e(G1, G2)) = `cb87319f24560b5231579a09ad79f12e`; the codes of the identities at steps 11, 13 (malformed X25519 stanza `ERR_INTEGRITY`, an identity that unwraps two stanzas `ERR_POLICY_STRUCTURE_MISMATCH` whatever the order, none `ERR_ACCESS_INVALID`) and 17; step 15 `ERR_HEADER_BINDING` | `capsule.Inspect` (steps 1–8), `capsule.Open` (steps 9–18; `openBody`, `drain`, `ErrSinkRequired`; `OpenOptions.AccessKeyFile`, `checkAccessKey`, `checkCapsuleDigest`), the `provider.ReleaseSource` contract, `provider/drand.Client` and `capsule.sourceFailure` (step 9), `tlock.TimeUnlock` with the kyber-bls12381 pairing (step 11), MUST rules inside `agewrap` identities (`AccessIdentity` tries every identity on every stanza; `TimeIdentity` checks the length of the tlock stanza body and U before `tlock.TimeUnlock`); no error copies the text of an error of age, tlock, kyber or drand (`agewrap`, `capsule.classify`), since kyber's IBE error carries the candidate plaintext and r; `cmd/datekeys` hands the `.dkk` over encoded; `datekeys inspect -json` rendered by `internal/inspectview`; v0.15: step 9.c only for `OpenOptions.Source`, a release in hand (`OpenOptions.Release`) not compared with the clock and `Opened.ClockBehind`; step 10 starts with `provider.ParseRelease`, then `provider.Verify` with the chain hash | `capsule.TestConformanceFixtures` (stage by stage), `TestOpen3`, `TestOpen3Substeps`, `TestFormatDispatch`, `TestFormatRelabel`, `TestPaddingChecksAtStep17`, `TestTlockFailureDiagnosticsCarryNoSecrets`, `TestPlaintextWriterFailureKeepsItsText`, `TestAccessKeyCheckOrder`, `TestAccessKeyFileAtStep9`, `TestPrecedenceAcrossSteps`, `TestControlCriticalBeforeHeaderBinding`, `TestReleaseFromANetworkSource`, `TestReleaseSourceErrorsAtStep9`, `agewrap.TestAccessIdentityStrictness`, `TestMalformedX25519Stanzas`, `TestTlockH2Vector` (`testdata/vectors/tlock_ibe.json`, generated by `internal/testkit.IBEVectors`, and step 11 recomputed with H2 and H4 against the file key tlock unwraps), `cmd/datekeys.TestDecryptAccessKeyOrder`, `TestMutationCorpus`, `TestInspectDifferentialCorpus` (`testdata/vectors/inspect_differential.json`: 5110 deterministic mutations of 14 fixtures, two of them of format 3, the 1825 of the format 1 ones first with the verdict of steps 1–8, generated by `internal/testkit.InspectDifferential`); `cmd/datekeys.TestInspectJSONGoldens` (`testdata/fixtures/*.inspect.json`); `capsule.TestReleaseInHand`, `TestReleaseInHandErrors`; mutations *round not reached yet* (opens) and *round not reached yet, from a network source* |
| 64 | Mandatory mutation tests: the first two lists in the three formats, the list of format 2 in format 2, and that of format 3, four of whose cases open with their verdicts, F2 for a signature of `alg` 1 that does not verify among them; the lists of v0.11 and v0.12: the signature of `alg` 1 and of `alg` 2, the area widened after signing, the seal, `alg` and `seal_type` 4294967295, the same P with a signature and without, the public note, the key of words and `datekeys.capsule` | `internal/testkit.Mutations` (the corpus: `specMutations` for each format, `furtherMutations`, `format2Mutations`, `format3Mutations` with `LoadedFixture.WithBody`, among them those of the list of v0.11 from `format3_signed`, `format3_unsigned` and `format3_note`, `signed1`), `internal/testkit.MutationCorpus` (its export); the cases of formats 2 and 3 derived without randomness, by sealing the fixtures again with their known file keys and nonces (`internal/testkit/reseal.go`, `mutations3.go`), exported as edits of their fixture (`internal/testkit.Splice`); the cases of `alg` 2, `seal_type` 2 and `datekeys.capsule`, vectors of `security_cms.json` and `locator.json`, frozen once written (`internal/testkit/genfixtures`, `frozenVectors`) | `capsule.TestMutationCorpus`: the 178 listed mutations, 33 in each format, the 23 of the list of format 2, the 48 of that of format 3 and 8 of that of v0.11 (`internal/testkit.SpecMutationsPerFormat`, `Format2SpecMutations`, `Format3SpecMutations`, `V011SpecMutations`), plus 44 more, built afresh; `capsule.TestExportedMutationCorpus`: `testdata/vectors/mutations.json`, the same 222 cases as frozen data (capsule, `.dkk`, identities, recorded release and its source, clock, registry, known extensions), replayed with the recorded error and step, or the recorded verdicts; `capsule.TestPointMutationsChangeOnlyTheEncoding`: the ten point mutations keep a valid header MAC, and a decoder that reduces coordinates modulo p opens the c0 + p and x + p cases; `internal/testkit.TestResealReproducesFixtures`, `TestFixedX25519Stanza`; the cases of the lists of v0.11 and v0.12 outside the corpus: `ed25519strict.TestVectors` (`ed25519_strict.json`), `capsule.TestCMSVectors` (`security_cms.json`), `locator.TestLocatorVectors` (`locator.json`), `wordkey.TestKeyVector`, `TestNormalize` and `TestCheck`, `testdata/vectors/note.json`, and the same P of the fixtures `format3_unsigned` and `format3_signed` (`capsule.TestConformanceFixtures`); those of `alg` 2, `seal_type` 2 and `datekeys.capsule` of the lists of v0.11 and v0.12, in `security_cms.json` and `locator.json` |
| 65 | Quicknet vectors | `internal/testkit.RoundVectors` | `datekey.TestGoldenRoundVectors` |
| 66 | `dk1_` vectors | `internal/testkit.DK1Vectors` | `datekey.TestGoldenDK1Vectors` |
| 67 | `.dkc` vectors: the format 1 fixtures of v0.8.2, kept for compatibility, and format 2 fixtures for both policies, both codes, L = 0, one, several and 16 credentials, and extensions; the format 3 fixtures: one file, a tree, a comment alone, both codes, `time_and_key` with a portable key, an area of 1024 bytes, security of version 2, a signature of `alg` 4294967295 and that with a seal of `seal_type` 4294967295, the area of 32 KiB without a signature, a signature of `alg` 1, that with a seal of `seal_type` 2 and a file whose mtime is later than the seal, and a signature of `alg` 2 of two signers, ECDSA P-256 and RSA 2048, each with its CAdES-T; the records give the format, L, the code, P and the stanza each credential opens, and in format 3 the head, security, each file and the verdicts with their lines, and for a signature or a seal the commitments, `AUTHOR_MESSAGE` and its code, the key or `SIGNERS` and the certificates, the result of each signer, `SEAL_SUBJECT` and the token; the padding vectors, and those of paths, keys of R7, heads, security, `security_cms.json`, `ed25519_strict.json`, `note.json` and `locator.json` | `testdata/fixtures/*.dkc` + `*.json`, `internal/testkit/genfixtures`, which never regenerates a format 1 fixture, gives the five fixtures that `EncryptFiles` wrote in v0.10 their area of 512 bytes (`EncryptOptions.TestAreaLen`) and recomputes the derived fields of every record; `format3_note`, with a public note, for the mutations of §64; the frozen `datekeys inspect -json` output of each, `*.inspect.json`; `testdata/vectors/padding.json`; `security_cms.json` and `locator.json`, frozen once written (`frozenVectors`); formats in `testdata/README.md` | `capsule.TestConformanceFixtures` (`checkBody3`, `checkSignature3`), `TestPaddingAcrossChunks` (a capsule generated at run time), `TestCMSVectors`; `locator.TestLocatorVectors`; `ed25519strict.TestVectors`; `internal/testkit.TestFormat3VectorFiles`, `TestVectorFilesAreCurrent`; `cmd/datekeys.TestInspectJSONGoldens`, `TestDecryptFixtures`, `TestDecryptFormat3Fixtures`, `TestMTimeAfterSeal` |
| 67 | `.dkc` vectors: the format 1 fixtures of v0.8.2, kept for compatibility, and format 2 fixtures for both policies, both codes, L = 0, one, several and 16 credentials, and extensions; the format 3 fixtures: one file, a tree, a comment alone, both codes, `time_and_key` with a portable key, an area of 1024 bytes, security of version 2, a signature of `alg` 4294967295 and that with a seal of `seal_type` 4294967295, the area of 32 KiB without a signature, a signature of `alg` 1, that with a seal of `seal_type` 2 and a file whose mtime is later than the seal, and a signature of `alg` 2 of two signers, ECDSA P-256 and RSA 2048, each with its CAdES-T; and since v0.16 a key of words, `format3_time_and_key_words`, with the text in `words_text`, and a `PAYLOAD_AGE` that ends in a full chunk, `format3_full_chunk`; the records give the format, L, the code, P and the stanza each credential opens, and in format 3 the head, security, each file and the verdicts with their lines, and for a signature or a seal the commitments, `AUTHOR_MESSAGE` and its code, the key or `SIGNERS` and the certificates, the result of each signer, `SEAL_SUBJECT` and the token; the padding vectors, and those of paths, keys of R7, heads, security, `security_cms.json`, `ed25519_strict.json`, `note.json` and `locator.json` | `testdata/fixtures/*.dkc` + `*.json`, `internal/testkit/genfixtures`, which never regenerates a format 1 fixture, gives the five fixtures that `EncryptFiles` wrote in v0.10 their area of 512 bytes (`EncryptOptions.TestAreaLen`) and recomputes the derived fields of every record; `format3_note`, with a public note, for the mutations of §64; the frozen `datekeys inspect -json` output of each, `*.inspect.json`; `testdata/vectors/padding.json`; `security_cms.json` and `locator.json`, frozen once written (`frozenVectors`); formats in `testdata/README.md` | `capsule.TestConformanceFixtures` (`checkBody3`, `checkSignature3`), `TestPaddingAcrossChunks` (a capsule generated at run time), `TestCMSVectors`; `locator.TestLocatorVectors`; `ed25519strict.TestVectors`; `internal/testkit.TestFormat3VectorFiles`, `TestVectorFilesAreCurrent`; `cmd/datekeys.TestInspectJSONGoldens`, `TestDecryptFixtures`, `TestDecryptFormat3Fixtures`, `TestMTimeAfterSeal` |
| 68 | `.dkk` vectors, with the exact extension data; one carries an extension with data, two accompany a format 2 capsule and one a format 3 capsule | `testdata/fixtures/*.dkk` + `*.dkk.json`; `time_and_key_portable_extension.dkk` derived by `genfixtures`; `format2_time_and_key_portable.dkk`, `format2_time_and_key_recipients.dkk`, `format3_time_and_key_portable.dkk` | `accesskey.TestFixtures`, `TestFixtureWithExtension`; `capsule.TestAccessKeyFixtureWithExtension` |
| 69 | Normative errors, including `ERR_EXTENSION_DATA_INVALID` and `ERR_HEAD_INVALID`; every error of the module wraps exactly one | `errors.go`; `provider/drand.Client` and step 9 of `capsule.Open` keep another code of a failure as text only | `datekeys.TestCatalogueMatchesSpec`, `TestCode`; `drand.TestFetchErrorHasOneCode`; `capsule.TestReleaseSourceErrorsAtStep9` |
| 69.1 | Error precedence: the first failing layer of each object (frame, a truncated prelude before the version; type tag and schema version; CBOR profile and CDDL, except the rules with codes of their own; fields with codes of their own in ascending key order, an extension unknown in its object or array before invalid data), the step order of §63 across objects and steps; only the optional inspection of steps 5, 6 and 8 and the `capsule_digest` check can change the code; the codes of step 10 are those of a release supplied directly, one from a network source being discarded at step 9; in format 2, the 16 stanzas belong to step 12, the version of CONTROL_CBOR against the format to layer 2 of step 14, the rules of keys 6 and 7 to its layer 3, and the length and padding of the plaintext to step 17 | `capsule.ParsePrelude`, `capsule.DecodeHeader`, `capsule.DecodeControl`, `accesskey.Decode`, `accesskey.DecodeBody`, `profile.Decode`, `codec.CheckSchema`, `codec.Unmarshal`, `extension.CheckCriticalIn`, `capsule.checkAccessKey`, `OpenOptions.AccessKeyFile`, `agewrap.AccessIdentity`, `provider/drand.Client` | `capsule.TestPrecedenceWithinPublicHeader`, `TestPrecedenceAcrossSteps` (with the examples of format 2), `TestDecodeHeaderReportsTheCDDLFirst`, `TestAccessKeyCheckOrder`, `TestAccessKeyFileAtStep9`, `TestControlCriticalBeforeHeaderBinding`, `TestReleaseFromANetworkSource`, `TestExtensionPlacement`; `accesskey.TestDecodePrecedence`; `agewrap.TestAccessIdentityStrictness`; `profile.TestDecodePrecedence`, `TestPinPathMatchesDecode`; `cmd/datekeys.TestDecryptAccessKeyOrder`; `extension.TestCheckCritical`, `TestPlacement`; `codec.TestCheckSchemaVersionForms`; `testdata/vectors/cbor.json`, `inspect_differential.json` |
| 70 | Compatibility: a reader accepts the three formats and opens formats 1 and 2 with the semantics of v0.8.2 and v0.9; an implementation that writes capsules writes format 3, and only a generator of test vectors writes formats 1 and 2; a reader should report the format; the format is neither the version of the specification (`datekeys.SpecVersion`) nor that of the module (`datekeys.Version`, `datekeys version`); a reader of v0.10 opens the capsules of v0.11, with an area of 32 or 64 KiB, F1 for `alg` 1 and 2, S1 for `seal_type` 2, and the public note and `datekeys.capsule` ignored, and a reader of v0.11 those of v0.10, with their area of 512 bytes; v0.12 changes no format, only texts of the verdicts and how a certificate and a token are read | magic and version checks; `capsule.Format`; `codec.Peek` and `codec.CheckSchema` read keys 0 and 1 only, before strict decoding, with a type tag of at most `codec.MaxTypeTagLen` bytes; `Inspection.Prelude.Format`, `Opened.Format`; `internal/testkit.Build`; `version.go`, where `SpecVersion` stays 0.11 until v0.12 is approved; `capsule.EvaluateSecurity`, which reads security without a context, as a reader of v0.10; `ParseBodyFrame`, which accepts any area of the frame; v0.15: a valid release in hand opens a capsule with a clock behind its round time | mutations; `capsule.TestFormatDispatch`, `TestFormatRelabel`, `TestFormat1Compatibility`; `codec.TestPeek`, `TestCheckSchema`, `TestCheckSchemaVersionForms`, `FuzzPeek`; `capsule.TestDecodeSchemaVersion`; `datekeys.TestSpecVersionNamesTheSpecification`, `TestVersion`; `cmd/datekeys.TestVersion`; `capsule.TestEvaluateSecurityIn` and `TestEvaluateCMS` (no context: F1), `TestEvaluateSeal` (no context: S1), `TestCMSVectors` (the cases without a context); the fixtures of v0.10, with their area of 512 bytes, among those of v0.11 (`TestConformanceFixtures`); mutation *round not reached yet* |
| 71 | Profile registry | `profile.Decode` + `profile.NewRegistry` with pinned hashes | `profile.TestRegistry` |
| 71 | Profile registry; the states `activo`, `solo lectura` and `comprometido`; SHOULD: warn of a compromised profile | `profile.Decode` + `profile.NewRegistry` with pinned hashes; `profile.Status`, `StatusOf` (the states that this release of the module knows, by profile_hash: Quicknet active); `cmd/datekeys`: `encrypt` writes no capsule with a profile that is not active, and `decrypt` and `inspect` warn when the profile of a capsule is compromised | `profile.TestRegistry`, `TestStatus`; `cmd/datekeys.TestProfileStatus` |
| 72 | Extension registry and registration rules, among them the objects and arrays where each extension may appear, and an encoder never writes one elsewhere; the encoder decodes its own output before sealing; security-relevant claims in CONTROL_CBOR or under a signature extension, `.dkk` extension data advisory; the registered extensions, `datekeys.note` in the noncritical array of PUBLIC_HEADER and `datekeys.capsule` in the noncritical array of a `.dkk`, both informative | `extension.Registry`, `extension.Set`, `extension.DataValidator`, `extension.Placement` (optional: a `Registry` without it knows its extensions in every object and array); `extension.Standard`, the registry of the extensions of the specification (`NoteID`, `CapsuleID`), and `locator.Standard`, which validates the data of `datekeys.capsule`; `extension.CheckWrite`, which the writers of capsules and `.dkk` files apply with `extension.Standard` (`capsule.newSealer`, `accesskey.AccessKey.MarshalBody`); self-checks in `capsule.Encrypt`, `capsule.EncryptFiles`, `accesskey.MarshalBody` and `locator.Info.Extension`; these writers take no `Registry`: the application writes each extension of its own only where it is registered | `capsule.TestKnownCriticalExtensions`, `TestUnusableNoncriticalExtensions`, `TestExtensionPlacement`, `TestNestedDataSealsAndOpens`, `TestRegisteredExtensionsWhereRegistered`, `TestPublicNoteRules`, `FuzzEncodeImpliesDecode`; `extension.TestPlacement`, `TestCheckWrite`; `locator.TestInfo` |
| 74 | Provisional aspects; the implementation limits of the reference (name lengths, `public_key`, `period`, maximum `extension_id` length, `dk1_` length, age parser limits, `ERR_POLICY_STRUCTURE_MISMATCH` for INNER_ACCESS_AGE) | `profile.ValidID`, `validName`, `maxPublicKeyLen`, `maxPeriod`; `extension.MaxIDLen`; `datekey.MaxEncodedLen`; `filippo.io/age` | `profile.TestValidateRejectsTamperedProfiles`, `TestIntegerRanges`; `extension.TestNew`; the vectors of `cbor.json` named after the implementation limit |
| 75 | Blocking requirements before v1.0 | items 1–9 and 11 above, with fixtures and mutations in the three formats; item 10 (external review) pending | — |
| 76 | Change policy; the normative changes of v0.8.2: the extension change and its reproducible cases; the refinements and theirs; the amendment on point canonicality and its case (a second implementation on `tlock-js` and `@noble/curves` 1.9.7 accepted U with c0 + p and a signature with x + p); the corrections of the formal review (an invalid release from a network source, the objects and arrays of each extension, the serialization of GT in H2) and their cases, and those of its second round (the encoder rule of §72, the codes of step 10 in §17 and §51 for a release supplied directly, one code for any failure of a source at step 9); the normative changes of v0.9, capsule format 2, and their cases; those of v0.10, capsule format 3, and theirs; those of v0.11, the area of 32 KiB, what is signed, the signatures of `alg` 1 and 2, the seal of `seal_type` 2, the key of words, the public note and `datekeys.capsule`, and theirs; and those of the draft v0.12, which change no format: the names of certificates and the seal of each signer of F6 in the verdicts, the holder without its identifier, the profile of the certificate, identifiers, repetitions and edge cases, the addresses and the padding of the locator, errata, and the vectors of v0.11 | `extension`, `codec`, fixture `time_only_extensions` regenerated; refinements: the order of `capsule.checkAccessKey`, `BODY_LEN` 0 in `accesskey.Decode`, CR and LF and invalid UTF-8 in `datekey.Parse`, the `.dkk` decoded at step 9.a (`OpenOptions.AccessKeyFile`, the CLI), nil identities in `capsule.Open`, every identity tried in `agewrap.AccessIdentity`, `profile.NewRegistry` through `Decode`, `Profile.Validate` rule 1 first; four new `dk1.json` vectors; corrections: `extension.Placement` and the object-aware checks, the `provider.ReleaseSource` contract, `testdata/vectors/tlock_ibe.json`; second round: the error of `provider/drand.Client` and of step 9 in `capsule.Open`; v0.9: rows 22, 29, 29.1, 31, 33, 36, 37, 39, 55.2, 56, 57, 61, 62, 62.1, 63 and 70; v0.10: rows 22, 23, 29 to 29.7, 31, 56, 57, 61 to 64 and 67 to 70; v0.11: rows 24.1, 29.2, 29.3, 29.7 to 29.12, 38.1, 44.1, 62.1, 64, 67, 70 and 72; v0.12: rows 29.3, 29.7, 29.10, 29.11, 44.1, 64, 67 and 70, and the sizes of the locator in `spec/datekeys.cddl` | case 2: `extension.TestNew`; case 3: `capsule.TestNaNKeyedDataHasOneVerdict`; case 4: `capsule.TestExtensionFixtureData`; case 5: `capsule.TestNestedDataSealsAndOpens`; case 6: `capsule.TestHugeExtensionArraysAreRejected`, `extension.TestCheckDisjointIsLinear`; refinements: the tests of rows 12.1, 15, 17, 19, 22, 28.1, 35, 36, 40, 51, 55.1, 63 and 69.1, and `extension.TestOrderIsUnsignedBytewise`; amendment: the tests of rows 12.2 and 64; corrections: `capsule.TestReleaseFromANetworkSource`, `TestExtensionPlacement`, `extension.TestPlacement`, `agewrap.TestTlockH2Vector`; second round: `capsule.TestReleaseSourceErrorsAtStep9`, `TestExtensionPlacement` (the noncritical array of a `.dkk`), `drand.TestFetchErrorHasOneCode`, `TestUnavailabilityAndCancellation`, `datekeys.TestCode`; v0.9: the tests that §76 names for each change, in rows 22, 29.1, 31, 37, 39, 55.2, 57, 62.1, 64 and 70; v0.10: those of the rows it changed; v0.11: those of the rows it added and changed; v0.12: changes 1 and 2, `capsule.TestEvaluateCMS`, `TestIssuerTextFiltered`, `cmd/datekeys.TestRows` and the record of `format3_signed_cms`; change 5, `der.TestSetOfSorted`, `TestCheck` and `cms.TestTSTInfoStrict`; changes 6 and 7, `locator.TestAddresses`, `TestUsableAddresses`, `TestLeastMultiple` and `TestPaddingBoundaries`; change 9, `testdata/vectors/security.json` and the fixture `format3_seal_unsupported`; the cases of changes 1 and 3 to 5 in `security_cms.json`, and those of changes 6 and 7 in `locator.json` (`TestLocatorVectors`); the changes of v0.15, each with its case: the release object (`release.json`, `testdata/releases`), its chain hash at step 10 and step 9.c (`mutations.json`, field `source`), the recovery (`scripts/recovery_check.sh`) |
| 79 | Informative annex: recovery without DateKeys software (v0.15) | `scripts/recovery` (no package of this module, tlock or drand: Go, `golang.org/x/crypto`, `filippo.io/age`, `drand/kyber-bls12381`), `scripts/recovery_check.sh`, run by `scripts/check.sh` | `scripts/recovery.TestImports`, `TestRecoverFixtures` and the other tests of the package, over eight fixtures of the three formats; `scripts/recovery_check.sh` on `format3_single` and `format3_time_and_key_portable` |
| 79 | Informative annex: recovery without DateKeys software (v0.15), with the key of words in 79.7 and a last chunk that may be full (v0.16); §62.1 rule 27, the annex next to each `.dkc` | `datekeys.RecoveryAnnex` (`annex/recovery.md`, §79 under a title with the version and the SHA-256 of the specification) and `RecoveryAnnexSuffix`; `encrypt` writes it as `FILE.dkc.recuperacion.txt` unless `-no-recovery`; `scripts/recovery` (no package of this module, tlock or drand: Go, `golang.org/x/crypto`, `filippo.io/age`, `drand/kyber-bls12381` and the interfaces of `drand/kyber`), which opens with `-words`, by the normalization without tables or, with `-unicodedata`, the full one, and PBKDF2 of the standard library, `scripts/recovery_check.sh`, run by `scripts/check.sh` | `scripts/recovery.TestImports`, `TestRecoverFixtures` and the other tests of the package, over ten fixtures of the three formats, `TestAnnexWordVectors`, `TestNormalizeVectors` (both normalizations against every case of `wordkey.json`), `TestRecoverWithWords`; `datekeys.TestRecoveryAnnex` (the annex is §79 of the specification of `SpecVersion`), `cmd/datekeys.TestRecoveryAndNotices`; `scripts/recovery_check.sh` on `format3_single`, `format3_time_and_key_portable`, `format3_time_and_key_words` and `format3_full_chunk` |
## Error mapping

@ -86,6 +86,9 @@ var (
oidSigTimeStamp = oid("1.2.840.113549.1.9.16.2.14")
oidTSTInfo = oid("1.2.840.113549.1.9.16.1.4")
oidRIOCSP = oid("1.3.6.1.5.5.7.16.2")
// oidBTSP is the best practices time-stamp policy of ETSI EN 319 421,
// whose tokens carry accuracy (spec v0.16, §29.11).
oidBTSP = oid("0.4.0.2023.1.1")
oidSHA256 = oid("2.16.840.1.101.3.4.2.1")
oidSHA384 = oid("2.16.840.1.101.3.4.2.2")

@ -595,6 +595,9 @@ type TokenOptions struct {
AccuracyRaw []byte
// Version is the version of the TSTInfo, 1 by default.
Version int
// Policy is the policy of the TSTInfo, 1.2.3.4 by default; BTSPPolicy
// is that of ETSI EN 319 421.
Policy asn1.ObjectIdentifier
// Imprint, when not nil, is written as the hashed message instead of the
// hash of the subject, of any length.
Imprint []byte
@ -619,6 +622,10 @@ type TokenOptions struct {
CMS Options
}
// BTSPPolicy is the best practices time-stamp policy of ETSI EN 319 421,
// 0.4.0.2023.1.1, whose tokens carry accuracy.
var BTSPPolicy = asn1.ObjectIdentifier{0, 4, 0, 2023, 1, 1}
// AccuracyOf is the Accuracy element of d: its seconds, millis and micros,
// each only when it is not zero.
func AccuracyOf(d time.Duration) []byte {
@ -651,7 +658,10 @@ func TSTInfo(subject []byte, genTime time.Time, o TokenOptions) []byte {
if o.GenTimeRaw != nil {
gt = o.GenTimeRaw
}
info := [][]byte{Int(int64(o.Version)), OID(asn1.ObjectIdentifier{1, 2, 3, 4}), Seq(HashAlg(o.Hash), Octets(imprint)), Int(42), gt}
if o.Policy == nil {
o.Policy = asn1.ObjectIdentifier{1, 2, 3, 4}
}
info := [][]byte{Int(int64(o.Version)), OID(o.Policy), Seq(HashAlg(o.Hash), Octets(imprint)), Int(42), gt}
switch {
case o.AccuracyRaw != nil:
info = append(info, o.AccuracyRaw)

@ -264,10 +264,15 @@ func hashBytes(h crypto.Hash, b []byte) []byte {
// Token is a time-stamp token of RFC 3161 read with the profile of spec
// §29.11.
type Token struct {
// GenTime is t, and Accuracy the precision of the token, zero when it
// has none.
GenTime time.Time
Accuracy time.Duration
// GenTime is t, and Accuracy the precision of the token, zero in the
// fields it does not carry. HasAccuracy reports whether it carries the
// field at all: without it, the token does not say its precision (spec
// v0.16, §29.11).
GenTime time.Time
Accuracy time.Duration
HasAccuracy bool
// Policy is the content of the object identifier of its policy.
Policy []byte
// ImprintAlg is the hash of the messageImprint, and Imprint the hash.
ImprintAlg algID
Imprint []byte
@ -344,12 +349,17 @@ func parseTSTInfo(b []byte) (*Token, []byte, []byte, error) {
if err != nil {
return bad("genTime: " + err.Error())
}
t := &Token{GenTime: gen}
policy, err := der.Content(f[1])
if err != nil {
return bad("policy")
}
t := &Token{GenTime: gen, Policy: policy}
rest := f[5:]
if len(rest) > 0 && rest[0][0] == 0x30 {
if t.Accuracy, err = parseAccuracy(rest[0]); err != nil {
return bad(err.Error())
}
t.HasAccuracy = true
rest = rest[1:]
}
if len(rest) > 0 && rest[0][0] == 0x01 {
@ -421,6 +431,12 @@ func parseAccuracy(b []byte) (time.Duration, error) {
// seal of seal_type 2 requires (spec §29.11).
func (t *Token) ImprintIsSHA256() bool { return bytes.Equal(t.ImprintAlg.OID, oidSHA256) }
// BTSP reports 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).
func (t *Token) BTSP() bool { return bytes.Equal(t.Policy, oidBTSP) }
// Check verifies the token over subject, the bytes that it seals: the
// message-digest is the hash of the TSTInfo, the signature of the TSA
// verifies, the messageImprint is the hash of subject, of any length, and the

@ -3,7 +3,7 @@ package testkit
// CMSVectorFile is testdata/vectors/security_cms.json: SECURITY_CBOR areas
// with an author signature of alg 2 (CMS with certificates) or a time seal
// of seal_type 2 (RFC 3161), each with the context of its capsule and the
// verdicts, the results and the lines that spec v0.12 §29.7, §29.10 and
// verdicts, the results and the lines that spec v0.16 §29.7, §29.10 and
// §29.11 give. The certificates and the tokens are made once, with test
// keys, and the file is frozen: a second implementation reads them and must
// reach the same verdicts and write the same lines, byte for byte.
@ -36,6 +36,9 @@ type CMSVectorCase struct {
Foreign []FixtureSignerResult `json:"foreign_signers,omitempty"`
SealHolder string `json:"seal_holder,omitempty"`
SealTime string `json:"seal_time,omitempty"`
// SealReason is the reason of S5 (spec v0.16, §29.7), as
// FixtureSignerResult writes it.
SealReason string `json:"seal_reason,omitempty"`
// Lines are the verdicts as the official SDK shows them (§29.7):
// Verdicts.Lines.
Lines []string `json:"lines"`

@ -60,8 +60,12 @@ type DKCFixture struct {
IdentityStanzas []int `json:"identity_stanzas,omitempty"`
AccessKeyFile string `json:"access_key_file,omitempty"`
Identities []string `json:"identities,omitempty"`
ControlCBOR string `json:"control_cbor"`
PayloadIdentity string `json:"payload_identity"`
// WordsText is the text of the words of a key of words, as the person
// types it (spec §38.1, annex 79.7 of v0.16); the identity it gives is
// in Identities.
WordsText string `json:"words_text,omitempty"`
ControlCBOR string `json:"control_cbor"`
PayloadIdentity string `json:"payload_identity"`
// PayloadLength is L, the length of the content: the plaintext the
// reader delivers in formats 1 and 2, and BODY in format 3, whose files
// Files describes. In formats 2 and 3 the plaintext of PAYLOAD_AGE is
@ -157,6 +161,10 @@ type FixtureSignerResult struct {
Result string `json:"result"`
SealTime string `json:"seal_time,omitempty"`
Before bool `json:"before_round_time"`
// SealReason is why a valid seal does not prove that it came before the
// round time (spec v0.16, §29.7): "late", "no accuracy" or "no
// accuracy, BTSP"; empty when it does.
SealReason string `json:"seal_reason,omitempty"`
}
// FixtureSeal describes the seal of seal_type 2 of a format 3 fixture:

@ -55,13 +55,13 @@ var (
)
// cmsVectorSpec labels security_cms.json, as every file of testdata, with
// SpecVersion. Its verdicts are those of v0.12, with the profile of the
// certificate of §29.10 and the texts of §29.7.
// SpecVersion. Its verdicts are those of v0.16, with the profile of the
// certificate of §29.10, the texts of §29.7 and the accuracy of §29.11.
const cmsVectorSpec = testkit.SpecVersion
func hex32(b [32]byte) string { return hex.EncodeToString(b[:]) }
// The texts of the verdicts, copied from the table of spec v0.12 §29.7: the
// The texts of the verdicts, copied from the table of spec v0.16 §29.7: the
// lines of each case are checked against them, not against Verdicts.Lines.
const (
textF0 = "Sin firma de autor."
@ -71,9 +71,16 @@ const (
textS1 = "Lleva un sello de tiempo que esta versión no sabe comprobar: aquí no prueba nada."
textS2 = "El sello de tiempo es ilegible: no prueba nada."
textS3 = "El sello no corresponde a este contenido."
textS5 = "Sellado después de la fecha de apertura: no prueba nada anterior."
textS5 = "No acredita que se sellara antes de la fecha de apertura: "
)
// reasonTexts are the reasons of S5 in the texts of §29.7 (v0.16).
var reasonTexts = map[capsule.SealReason]string{
capsule.ReasonLate: "se selló después de esa fecha o demasiado cerca de ella",
capsule.ReasonNoAccuracyBTSP: "el sello no dice la precisión que exige su política",
capsule.ReasonNoAccuracy: "el sello no dice su precisión",
}
// resultTexts are the results of a signer in the lines of §29.7.
var resultTexts = map[string]string{
"valid": "válida", "invalid": "inválida", "not verifiable": "no verificable", "without seal": "sin sello",
@ -100,14 +107,15 @@ func hashOfCert(s cmstest.Signer) string { return hex32(sha256.Sum256(s.Cert.R
func hashOfIssuer(s cmstest.Signer) string { return hex32(sha256.Sum256(s.Cert.RawIssuer)) }
// want is the result that §29.10 gives a signer: for a valid one, the
// authority of its seal, t, and whether t plus the accuracy is before
// round_time.
// authority of its seal, t, and whether its seal proves that it came before
// round_time, or why not (§29.7, §29.11).
type want struct {
s vsigner
result string
tsa vsigner
t time.Time
before bool
reason capsule.SealReason
}
// vcase is a case of security_cms.json with what spec v0.12 gives for it.
@ -122,10 +130,12 @@ type vcase struct {
absent []vsigner
foreign []want
// sealTSA and sealTime are the authority and t of a valid seal (S4, S5),
// and authorKey the key of a valid signature of alg 1 (F4).
sealTSA vsigner
sealTime time.Time
authorKey string
// sealReason the reason of S5, and authorKey the key of a valid signature
// of alg 1 (F4).
sealTSA vsigner
sealTime time.Time
sealReason capsule.SealReason
authorKey string
}
// cmsGen builds the cases over a common context and checks each against
@ -211,6 +221,7 @@ func (g *cmsGen) add(c vcase) {
if !d.SealTime.IsZero() {
rec.SealHolder, rec.SealTime = d.SealHolder, d.SealTime.UTC().Format(time.RFC3339Nano)
}
rec.SealReason = string(d.SealReason)
}
if err := c.check(v, rec); err != nil {
g.errs = append(g.errs, fmt.Errorf("%s: %w", c.name, err))
@ -228,7 +239,7 @@ func (g *cmsGen) add(c vcase) {
func cmsSignerResults(lines []capsule.SignerLine) []testkit.FixtureSignerResult {
var out []testkit.FixtureSignerResult
for _, l := range lines {
r := testkit.FixtureSignerResult{Holder: l.Holder, Issuer: l.Issuer, Result: l.Result, Before: l.Before}
r := testkit.FixtureSignerResult{Holder: l.Holder, Issuer: l.Issuer, Result: l.Result, Before: l.Before, SealReason: string(l.Reason)}
if !l.SealTime.IsZero() {
r.SealTime = l.SealTime.UTC().Format(time.RFC3339Nano)
}
@ -241,6 +252,9 @@ func (w want) record() testkit.FixtureSignerResult {
r := testkit.FixtureSignerResult{Holder: w.s.holder, Issuer: w.s.issuer, Result: w.result}
if w.result == "valid" {
r.SealTime, r.Before = w.t.UTC().Format(time.RFC3339Nano), w.before
if !w.before {
r.SealReason = string(w.reason)
}
}
return r
}
@ -288,6 +302,9 @@ func (c vcase) check(v capsule.Verdicts, rec testkit.CMSVectorCase) error {
if rec.SealHolder != sealHolder || rec.SealTime != sealTime {
return fmt.Errorf("the seal of %q at %s, want %q at %s", rec.SealHolder, rec.SealTime, sealHolder, sealTime)
}
if want := c.sealReason; c.seal != capsule.VerdictSealedLate && want != capsule.ReasonNone || rec.SealReason != string(want) {
return fmt.Errorf("the reason of the seal %q, want %q", rec.SealReason, want)
}
// The lines: the signature, the foreign signers apart, and the seal.
q := func(s string) string { return "«" + s + "»" }
at := func(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) }
@ -308,7 +325,7 @@ func (c vcase) check(v capsule.Verdicts, rec testkit.CMSVectorCase) error {
before := false
for _, r := range reqs {
names = append(names, q(r.s.holder))
when := "no antes de la fecha de apertura"
when := "sin acreditar que fuera antes de la fecha de apertura: " + reasonTexts[r.w.reason]
if r.w.before {
when, before = "antes de la fecha de apertura", true
}
@ -334,7 +351,7 @@ func (c vcase) check(v capsule.Verdicts, rec testkit.CMSVectorCase) error {
case capsule.VerdictSealInvalid:
lines = append(lines, textS3)
case capsule.VerdictSealedLate:
lines = append(lines, textS5)
lines = append(lines, textS5+reasonTexts[c.sealReason]+".")
case capsule.VerdictSealed:
lines = append(lines, "Según un sello a nombre de "+q(c.sealTSA.holder)+", existía el "+at(c.sealTime)+", antes de que la cápsula pudiera abrirse. DateKeys no comprueba quién emitió el sello.")
default:
@ -355,7 +372,7 @@ func securityCMSVectors() (any, error) {
g.file = testkit.CMSVectorFile{
Spec: cmsVectorSpec,
Description: "SECURITY_CBOR with an author signature of alg 2 or a time seal of seal_type 2, the context of its capsule, " +
"and the verdicts, the result of each signer and the lines of spec v0.12 29.7, 29.10 and 29.11. " +
"and the verdicts, the result of each signer and the lines of spec v0.16 29.7, 29.10 and 29.11. " +
"Certificates and tokens are made once with test keys and the file is frozen. See testdata/README.md.",
}
g.signatureCases()
@ -405,11 +422,19 @@ func (g *cmsGen) signatureCases() {
sig: capsule.VerdictSignedComplete, seal: capsule.VerdictNoSeal, signers: []want{valid(ana, tsa), valid(luis, tsa)}})
late := cmstest.Options{Token: sealedBy(tsa, vecRound.Add(time.Hour), cmstest.TokenOptions{Accuracy: time.Second})}
g.add(vcase{name: "alg 2: sealed after the round time: F6, not before the opening date", area: g.area(g.signed(only(ana), late, ana), nil),
sig: capsule.VerdictSignedComplete, seal: capsule.VerdictNoSeal, signers: []want{{s: ana, result: "valid", tsa: tsa, t: vecRound.Add(time.Hour)}}})
sig: capsule.VerdictSignedComplete, seal: capsule.VerdictNoSeal, signers: []want{{s: ana, result: "valid", tsa: tsa, t: vecRound.Add(time.Hour), reason: capsule.ReasonLate}}})
// t + accuracy equal to round_time is not before it (§29.7).
edge := cmstest.Options{Token: sealedBy(tsa, vecRound.Add(-time.Second), cmstest.TokenOptions{Accuracy: time.Second})}
g.add(vcase{name: "alg 2: t plus the accuracy of the seal equals the round time: F6, not before the opening date", area: g.area(g.signed(only(ana), edge, ana), nil),
sig: capsule.VerdictSignedComplete, seal: capsule.VerdictNoSeal, signers: []want{{s: ana, result: "valid", tsa: tsa, t: vecRound.Add(-time.Second)}}})
sig: capsule.VerdictSignedComplete, seal: capsule.VerdictNoSeal, signers: []want{{s: ana, result: "valid", tsa: tsa, t: vecRound.Add(-time.Second), reason: capsule.ReasonLate}}})
// Spec v0.16, §29.7: a seal without accuracy does not prove that it came
// before the opening date, and its line gives the reason.
bare := cmstest.Options{Token: sealedBy(tsa, vecSigned, cmstest.TokenOptions{})}
g.add(vcase{name: "alg 2: a seal without accuracy: F6, not proven before the opening date", area: g.area(g.signed(only(ana), bare, ana), nil),
sig: capsule.VerdictSignedComplete, seal: capsule.VerdictNoSeal, signers: []want{{s: ana, result: "valid", tsa: tsa, t: vecSigned, reason: capsule.ReasonNoAccuracy}}})
btsp := cmstest.Options{Token: sealedBy(tsa, vecSigned, cmstest.TokenOptions{Policy: cmstest.BTSPPolicy})}
g.add(vcase{name: "alg 2: a seal of the BTSP policy without accuracy: F6, not proven before the opening date, by its policy", area: g.area(g.signed(only(ana), btsp, ana), nil),
sig: capsule.VerdictSignedComplete, seal: capsule.VerdictNoSeal, signers: []want{{s: ana, result: "valid", tsa: tsa, t: vecSigned, reason: capsule.ReasonNoAccuracyBTSP}}})
g.add(vcase{name: "alg 2: a signer who is not required shows apart and does not count: F6", area: g.area(g.signed(only(ana), sealed, ana, otro), nil),
sig: capsule.VerdictSignedComplete, seal: capsule.VerdictNoSeal, signers: []want{valid(ana, tsa)}, foreign: []want{valid(otro, tsa)}})
g.add(vcase{name: "alg 2: a required signer is absent: F5", area: g.area(g.signed(both, sealed, ana), nil),
@ -465,7 +490,7 @@ func (g *cmsGen) signatureCases() {
g.add(vcase{name: "alg 2: a key 3 of seal_type 4294967295 beside it: F5 and S1", area: g.area(key2, g.must(capsule.EncodeSeal(capsule.SealTypeTest, []byte{1}))),
sig: capsule.VerdictSignedIncomplete, seal: capsule.VerdictSealUnsupported, signers: []want{valid(ana, tsa)}})
subject := capsule.SealSubject(g.ctx.ControlCommit, g.ctx.HeadDigest, capsule.SigPart(key2))
g.add(vcase{name: "alg 2: a valid seal of seal_type 2 in key 3 beside it: F5 and S4", area: g.area(key2, g.must(capsule.EncodeSeal(capsule.SealTypeRFC3161, cmstest.Token(subject[:], vecSigned, cmstest.TokenOptions{}, tsa.Signer)))),
g.add(vcase{name: "alg 2: a valid seal of seal_type 2 in key 3 beside it: F5 and S4", area: g.area(key2, g.must(capsule.EncodeSeal(capsule.SealTypeRFC3161, cmstest.Token(subject[:], vecSigned, cmstest.TokenOptions{Accuracy: time.Second}, tsa.Signer)))),
sig: capsule.VerdictSignedIncomplete, seal: capsule.VerdictSealed, signers: []want{valid(ana, tsa)}, sealTSA: tsa, sealTime: vecSigned})
other := *g.ctx
other.HeadDigest[5] ^= 9
@ -703,28 +728,41 @@ func (g *cmsGen) sealCases() {
sealedAt := func(name string, token []byte, s vsigner, t time.Time, verdict capsule.Verdict) {
g.add(vcase{name: name, area: sealArea(token), sig: capsule.VerdictSignedOther, seal: verdict, authorKey: pub, sealTSA: s, sealTime: t})
}
sealedAt("seal: before the round time: S4", tok(vecSigned, cmstest.TokenOptions{}, tsa), tsa, vecSigned, capsule.VerdictSealed)
sealedAt("seal: after the round time: S5", tok(vecRound.Add(time.Minute), cmstest.TokenOptions{}, tsa), tsa, vecRound.Add(time.Minute), capsule.VerdictSealedLate)
late := func(name string, token []byte, s vsigner, t time.Time, reason capsule.SealReason) {
g.add(vcase{name: name, area: sealArea(token), sig: capsule.VerdictSignedOther, seal: capsule.VerdictSealedLate, authorKey: pub, sealTSA: s, sealTime: t, sealReason: reason})
}
// The tokens of the cases about something else carry an accuracy of a
// second: without it, a valid seal proves nothing before the round time
// (spec v0.16, §29.11).
second := cmstest.TokenOptions{Accuracy: time.Second}
sealedAt("seal: before the round time: S4", tok(vecSigned, second, tsa), tsa, vecSigned, capsule.VerdictSealed)
late("seal: after the round time, without accuracy: S5, sealed after", tok(vecRound.Add(time.Minute), cmstest.TokenOptions{}, tsa), tsa, vecRound.Add(time.Minute), capsule.ReasonLate)
early := vecRound.Add(-time.Second)
sealedAt("seal: t plus the accuracy past the round time: S5", tok(early, cmstest.TokenOptions{Accuracy: 2 * time.Second}, tsa), tsa, early, capsule.VerdictSealedLate)
sealedAt("seal: t plus the accuracy equal to the round time: S5", tok(early, cmstest.TokenOptions{Accuracy: time.Second}, tsa), tsa, early, capsule.VerdictSealedLate)
late("seal: t plus the accuracy past the round time: S5", tok(early, cmstest.TokenOptions{Accuracy: 2 * time.Second}, tsa), tsa, early, capsule.ReasonLate)
late("seal: t plus the accuracy equal to the round time: S5", tok(early, cmstest.TokenOptions{Accuracy: time.Second}, tsa), tsa, early, capsule.ReasonLate)
late("seal: without accuracy, years before the round time: S5, it does not say its precision", tok(vecSigned, cmstest.TokenOptions{}, tsa), tsa, vecSigned, capsule.ReasonNoAccuracy)
late("seal: of the BTSP policy of ETSI, without accuracy: S5, it does not say the precision its policy requires", tok(vecSigned, cmstest.TokenOptions{Policy: cmstest.BTSPPolicy}, tsa), tsa, vecSigned, capsule.ReasonNoAccuracyBTSP)
late("seal: of the BTSP policy, without accuracy, after the round time: S5, sealed after", tok(vecRound, cmstest.TokenOptions{Policy: cmstest.BTSPPolicy}, tsa), tsa, vecRound, capsule.ReasonLate)
sealedAt("seal: of the BTSP policy, with accuracy: S4", tok(vecSigned, cmstest.TokenOptions{Policy: cmstest.BTSPPolicy, Accuracy: time.Second}, tsa), tsa, vecSigned, capsule.VerdictSealed)
sealedAt("seal: an accuracy of 0 seconds, a microsecond before the round time: S4", tok(vecRound.Add(-time.Microsecond), cmstest.TokenOptions{AccuracyRaw: cmstest.Seq(cmstest.Int(0))}, tsa), tsa, vecRound.Add(-time.Microsecond), capsule.VerdictSealed)
sealedAt("seal: an empty accuracy, a precision of 0: S4", tok(vecSigned, cmstest.TokenOptions{AccuracyRaw: cmstest.Seq()}, tsa), tsa, vecSigned, capsule.VerdictSealed)
sealedAt("seal: t plus an accuracy of 999 ms and 999 µs, a microsecond before the round time: S4",
tok(early, cmstest.TokenOptions{Accuracy: 999*time.Millisecond + 999*time.Microsecond}, tsa), tsa, early, capsule.VerdictSealed)
sealedAt("seal: an accuracy of seconds, millis and micros: S4", tok(vecSigned, cmstest.TokenOptions{Accuracy: time.Second + 5*time.Millisecond + 7*time.Microsecond}, tsa), tsa, vecSigned, capsule.VerdictSealed)
fraction := vecSigned.Add(250 * time.Millisecond)
sealedAt("seal: a genTime with a fraction of a second: S4, t with its fraction", tok(fraction, cmstest.TokenOptions{}, tsa), tsa, fraction, capsule.VerdictSealed)
sealedAt("seal: the certificate of the authority twice: S4", tok(vecSigned, cmstest.TokenOptions{TSATwice: true}, tsa), tsa, vecSigned, capsule.VerdictSealed)
sealedAt("seal: a CRL in the token decides nothing: S4", tok(vecSigned, cmstest.TokenOptions{CRL: cmstest.Seq(cmstest.Seq(cmstest.Int(1)), cmstest.Seq(cmstest.OID(cmstest.OIDECDSA256)), cmstest.BitString([]byte{0}))}, tsa), tsa, vecSigned, capsule.VerdictSealed)
sealedAt("seal: signing-certificate-v2 in the token: S4", tok(vecSigned, cmstest.TokenOptions{SigCertV2: true}, tsa), tsa, vecSigned, capsule.VerdictSealed)
sealedAt("seal: a genTime with a fraction of a second: S4, t with its fraction", tok(fraction, second, tsa), tsa, fraction, capsule.VerdictSealed)
sealedAt("seal: the certificate of the authority twice: S4", tok(vecSigned, cmstest.TokenOptions{TSATwice: true, Accuracy: time.Second}, tsa), tsa, vecSigned, capsule.VerdictSealed)
sealedAt("seal: a CRL in the token decides nothing: S4", tok(vecSigned, cmstest.TokenOptions{CRL: cmstest.Seq(cmstest.Seq(cmstest.Int(1)), cmstest.Seq(cmstest.OID(cmstest.OIDECDSA256)), cmstest.BitString([]byte{0})), Accuracy: time.Second}, tsa), tsa, vecSigned, capsule.VerdictSealed)
sealedAt("seal: signing-certificate-v2 in the token: S4", tok(vecSigned, cmstest.TokenOptions{SigCertV2: true, Accuracy: time.Second}, tsa), tsa, vecSigned, capsule.VerdictSealed)
tsaName := cmstest.TLV(0xa0, cmstest.TLV(0xa4, cmstest.Name(cmstest.ATV(cmstest.OIDCommonName, cmstest.UTF8("Autoridad de Sellado de prueba")))))
exts := cmstest.TLV(0xa1, cmstest.Extension([]int{1, 2, 3, 4}, false, cmstest.Null()))
sealedAt("seal: ordering TRUE, a nonce, a tsa and extensions: S4", tok(vecSigned, cmstest.TokenOptions{After: [][]byte{cmstest.Bool(true), cmstest.Int(99), tsaName, exts}}, tsa), tsa, vecSigned, capsule.VerdictSealed)
sealedAt("seal: ordering TRUE, a nonce, a tsa and extensions: S4", tok(vecSigned, cmstest.TokenOptions{After: [][]byte{cmstest.Bool(true), cmstest.Int(99), tsaName, exts}, Accuracy: time.Second}, tsa), tsa, vecSigned, capsule.VerdictSealed)
rsaTSA := named(cmstest.NewRSA("Autoridad RSA de prueba", 2048, certFrom, certTo), "Autoridad RSA de prueba")
sealedAt("seal: an authority of RSA with RSASSA-PSS and SHA-512: S4", tok(vecSigned, cmstest.TokenOptions{CMS: cmstest.Options{PSS: true, Hash: crypto.SHA512}}, rsaTSA), rsaTSA, vecSigned, capsule.VerdictSealed)
sealedAt("seal: an authority of RSA with RSASSA-PSS and SHA-512: S4", tok(vecSigned, cmstest.TokenOptions{CMS: cmstest.Options{PSS: true, Hash: crypto.SHA512}, Accuracy: time.Second}, rsaTSA), rsaTSA, vecSigned, capsule.VerdictSealed)
// The case of §76, change 1: a name that lines up a text of its own.
spaced := cmstest.NewECDSA("TSA"+strings.Repeat(" ", 50)+"Firmado con la clave que guardaste como Banco", elliptic.P256(), certFrom, certTo)
vspaced := vsigner{spaced, hashOfCert(spaced), ""}
sealedAt("seal: an authority named with 50 spaces and the text of F3: S4, by its SHA-256", tok(vecSigned, cmstest.TokenOptions{}, vspaced), vspaced, vecSigned, capsule.VerdictSealed)
sealedAt("seal: an authority named with 50 spaces and the text of F3: S4, by its SHA-256", tok(vecSigned, second, vspaced), vspaced, vecSigned, capsule.VerdictSealed)
// S3: it reads, and does not verify (§29.11, step 3).
seal("seal: over another subject: S3", cmstest.Token([]byte("other"), vecSigned, cmstest.TokenOptions{}, tsa.Signer), capsule.VerdictSealInvalid)
@ -773,11 +811,11 @@ func (g *cmsGen) sealCases() {
// a signature of an alg that the reader does not implement, whose bytes it
// seals all the same (§29.11, SIG_PART).
noSig := capsule.SealSubject(g.ctx.ControlCommit, g.ctx.HeadDigest, capsule.SigPart(nil))
g.add(vcase{name: "seal: over a capsule without a signature: F0 and S4", area: g.area(nil, g.must(capsule.EncodeSeal(capsule.SealTypeRFC3161, cmstest.Token(noSig[:], vecSigned, cmstest.TokenOptions{}, tsa.Signer)))),
g.add(vcase{name: "seal: over a capsule without a signature: F0 and S4", area: g.area(nil, g.must(capsule.EncodeSeal(capsule.SealTypeRFC3161, cmstest.Token(noSig[:], vecSigned, second, tsa.Signer)))),
sig: capsule.VerdictNoSignature, seal: capsule.VerdictSealed, sealTSA: tsa, sealTime: vecSigned})
unknown := g.must(capsule.EncodeAuthorSignature(capsule.AlgTest, []byte{1}, []byte{1}))
beside := capsule.SealSubject(g.ctx.ControlCommit, g.ctx.HeadDigest, capsule.SigPart(unknown))
g.add(vcase{name: "seal: beside a signature of alg 4294967295, which it seals all the same: F1 and S4", area: g.area(unknown, g.must(capsule.EncodeSeal(capsule.SealTypeRFC3161, cmstest.Token(beside[:], vecSigned, cmstest.TokenOptions{}, tsa.Signer)))),
g.add(vcase{name: "seal: beside a signature of alg 4294967295, which it seals all the same: F1 and S4", area: g.area(unknown, g.must(capsule.EncodeSeal(capsule.SealTypeRFC3161, cmstest.Token(beside[:], vecSigned, second, tsa.Signer)))),
sig: capsule.VerdictSignatureUnchecked, seal: capsule.VerdictSealed, sealTSA: tsa, sealTime: vecSigned})
}

@ -189,7 +189,7 @@ func commitmentsOf(f *testkit.DKCFixture, head []byte) (cc, hd [32]byte, err err
func signerResults(lines []capsule.SignerLine) []testkit.FixtureSignerResult {
var out []testkit.FixtureSignerResult
for _, l := range lines {
r := testkit.FixtureSignerResult{Holder: l.Holder, Issuer: l.Issuer, Result: l.Result, Before: l.Before}
r := testkit.FixtureSignerResult{Holder: l.Holder, Issuer: l.Issuer, Result: l.Result, Before: l.Before, SealReason: string(l.Reason)}
if !l.SealTime.IsZero() {
r.SealTime = l.SealTime.UTC().Format(time.RFC3339)
}

@ -58,6 +58,7 @@ import (
"g.activething.com/go/DateKeys/internal/testkit"
"g.activething.com/go/DateKeys/profile"
"g.activething.com/go/DateKeys/provider"
"g.activething.com/go/DateKeys/wordkey"
)
func main() {
@ -422,7 +423,11 @@ type spec struct {
// configure, when not nil, sets what only this fixture needs in the
// options of EncryptFiles: a signer with certificates, or a sealer.
configure func(o *capsule.EncryptOptions) error
body func() ([]byte, error)
// words, when not empty, is the text of a key of words, a credential of
// a time_and_key fixture (spec §38.1): its identity goes in the record
// with the identities.
words string
body func() ([]byte, error)
// area, when not 0, is the security area of a fixture of an earlier
// version, which EncryptFiles writes only for test vectors: 512 bytes in
// the fixtures of v0.10, which are compatibility fixtures (spec §67).
@ -476,6 +481,10 @@ func specs() []spec {
{path: "música/canción.txt", content: []byte("La, la, la.\n"), mtime: when},
}
report := []file3{{path: "informe.txt", content: []byte(strings.Repeat("Informe trimestral, sin cifras.\n", 625)), mtime: when}}
// fullChunk makes BODY 65536 bytes with the area of 32 KiB, so that the
// plaintext of PAYLOAD_AGE, P = 65536 with bloque256, is one full STREAM
// chunk, the last one (spec v0.16, 79.5).
fullChunk := []file3{{path: "bloque.bin", content: patterned("bloque", fullChunkFile), mtime: when}}
secret := []file3{{path: "secreto.txt", content: []byte("DateKeys fixture opened with a portable .dkk.\n"), mtime: when}}
return []spec{
{name: "time_only", format: f1, description: "time_only capsule, two STREAM chunks, no extensions", round: 1000, policy: capsule.TimeOnly, plaintext: large},
@ -537,6 +546,8 @@ func specs() []spec {
}},
{name: "format3_unsigned", format: f3, 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", round: 1000, policy: capsule.TimeOnly, padding: capsule.Reforzado, files: note},
{name: "format3_signed", format: f3, 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", round: 1000, policy: capsule.TimeOnly, padding: capsule.Reforzado, files: note, signer: signerSeed},
{name: "format3_time_and_key_words", format: f3, 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»", round: 1000, policy: capsule.TimeAndKey, padding: capsule.Reforzado, files: secret, words: annexWords},
{name: "format3_full_chunk", format: f3, 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", round: 1000, policy: capsule.TimeOnly, padding: capsule.Bloque256, files: fullChunk},
{name: "format3_signed_cms", format: f3, 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", round: 1000, policy: capsule.TimeOnly, padding: capsule.Reforzado, files: note, configure: configureCMS},
{name: "format3_note", format: f3, 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)", round: 1000, policy: capsule.TimeOnly, padding: capsule.Reforzado, files: note,
configure: func(o *capsule.EncryptOptions) error { o.PublicNote = "Cartas del viaje a Lisboa"; return nil }},
@ -696,6 +707,9 @@ func write(s spec) (*written, error) {
return nil, err
}
}
if s.words != "" {
opts.Words = wordkey.Normalize(s.words)
}
if s.signer != nil {
k, err := authorkey.NewFromSeed(s.signer)
if err != nil {
@ -713,9 +727,30 @@ func write(s spec) (*written, error) {
return nil, err
}
w.dkc, w.portable = dkc.Bytes(), res.PortableKey
if s.words != "" {
id, err := wordkey.Identity(opts.Words, p.ChainHash[:], s.round, res.CapsuleID[:])
if err != nil {
return nil, err
}
w.ids = append(w.ids, id)
}
if s.name == "format3_full_chunk" && (res.Length != fullChunkBody || res.PaddedLength != fullChunkBody) {
return nil, fmt.Errorf("format3_full_chunk: L = %d and P = %d, not %d: change fullChunkFile", res.Length, res.PaddedLength, fullChunkBody)
}
return w, nil
}
// annexWords is the text of the second vector of the annex of spec v0.16,
// 79.7: «Ñandú», two spaces, «PINGÜINO», a tab and «camión árbol Éter ola».
const annexWords = "\u00d1and\u00fa PING\u00dcINO\tcami\u00f3n \u00e1rbol \u00c9ter ola"
// fullChunkBody is the length of BODY and of P in format3_full_chunk, one
// STREAM chunk, and fullChunkFile the length of its file that gives it.
const (
fullChunkBody = 65536
fullChunkFile = 32637
)
// generate writes the fixture s and records every intermediate value,
// recovered by opening it layer by layer.
func generate(dir string, s spec) error {
@ -750,7 +785,7 @@ func generate(dir string, s spec) error {
return err
}
control := inner
f := testkit.DKCFixture{Description: s.description, File: s.name + ".dkc", PlaintextFile: s.name + ".plaintext"}
f := testkit.DKCFixture{Description: s.description, File: s.name + ".dkc", PlaintextFile: s.name + ".plaintext", WordsText: s.words}
var dkkFile string
var dkkBytes []byte
if s.policy == capsule.TimeAndKey {

@ -252,6 +252,35 @@ func ReleaseVectors() (ReleaseVectorFile, error) {
{"8193 bytes", `{"round":1000,"signature":"` + s1000 + `"}` + strings.Repeat(" ", 8193-len(`{"round":1000,"signature":"`+s1000+`"}`)), 1000, invalid},
{"round 1001 for a DateKey of round 1000", `{"round":1001,"signature":"` + s1001 + `"}`, 1000, roundMismatch},
{"the signature of round 1001 as round 1000", `{"round":1000,"signature":"` + s1001 + `"}`, 1000, invalid},
// The strict reading of spec v0.16, §47.1: no repeated name, names
// compared exactly once their escapes are decoded, and the round an
// integer from 1 to 2^53 - 1 without fraction or exponent.
{"round twice", `{"round":1000,"round":1001,"signature":"` + s1000 + `"}`, 1000, invalid},
{`round twice, once escaped as \u0072ound`, `{"round":1000,"\u0072ound":1000,"signature":"` + s1000 + `"}`, 1000, invalid},
{"round twice, the second null", `{"round":1000,"round":null,"signature":"` + s1000 + `"}`, 1000, invalid},
{`round escaped as \u0072ound`, `{"\u0072ound":1000,"signature":"` + s1000 + `"}`, 1000, ok},
{"Round instead of round", `{"Round":1000,"signature":"` + s1000 + `"}`, 1000, invalid},
{"ROUND beside round: another name, ignored", `{"round":1000,"ROUND":1001,"signature":"` + s1000 + `"}`, 1000, ok},
{"round null", `{"round":null,"signature":"` + s1000 + `"}`, 1000, invalid},
{"round 1000.0", `{"round":1000.0,"signature":"` + s1000 + `"}`, 1000, invalid},
{"round 1e3", `{"round":1e3,"signature":"` + s1000 + `"}`, 1000, invalid},
{"round -1000", `{"round":-1000,"signature":"` + s1000 + `"}`, 1000, invalid},
{"round 0", `{"round":0,"signature":"` + s1000 + `"}`, 1000, invalid},
{"round 2^53", `{"round":9007199254740992,"signature":"` + s1000 + `"}`, 1000, invalid},
{"round 2^53 - 1 for a DateKey of round 1000", `{"round":9007199254740991,"signature":"` + s1000 + `"}`, 1000, roundMismatch},
{"round with a leading zero", `{"round":01000,"signature":"` + s1000 + `"}`, 1000, invalid},
{"signature twice", `{"round":1000,"signature":"` + s1000 + `","signature":"` + s1000 + `"}`, 1000, invalid},
{"signature null", `{"round":1000,"signature":null}`, 1000, invalid},
{"randomness empty", `{"round":1000,"randomness":"","signature":"` + s1000 + `"}`, 1000, invalid},
{"randomness null", `{"round":1000,"randomness":null,"signature":"` + s1000 + `"}`, 1000, invalid},
{"another name twice", `{"round":1000,"signature":"` + s1000 + `","note":1,"note":2}`, 1000, invalid},
{"a name twice in a nested object", `{"round":1000,"signature":"` + s1000 + `","meta":{"a":1,"a":2}}`, 1000, invalid},
{"nested objects and arrays, ignored", `{"round":1000,"signature":"` + s1000 + `","meta":{"a":[1,{"a":2}],"b":{},"c":[]}}`, 1000, ok},
{"a lone surrogate in another name", `{"round":1000,"signature":"` + s1000 + `","\ud800":1}`, 1000, invalid},
{"a lone low surrogate in a value", `{"round":1000,"signature":"` + s1000 + `","note":"\udc00"}`, 1000, invalid},
{"a surrogate pair in a value", `{"round":1000,"signature":"` + s1000 + `","note":"\ud83d\ude00"}`, 1000, ok},
{"a tab inside a string", `{"round":1000,"signature":"` + s1000 + `","note":"a` + "\t" + `b"}`, 1000, invalid},
{"something after the object", `{"round":1000,"signature":"` + s1000 + `"}{}`, 1000, invalid},
}
for _, c := range jsons {
v := ReleaseVector{Name: c.name, Input: c.input, Round: c.round}

@ -67,6 +67,15 @@ var wordSpaces = []rune{
// The six words of the vector of spec §38.1.
const sixWords = "perro luna casa verde tren mar"
// The second vector of the annex of spec v0.16, 79.7: «Ñandú», two spaces,
// «PINGÜINO», a tab and «camión árbol Éter ola», also with the acute accent,
// the diaeresis and the tilde as marks after their letter, and its words.
const (
annexWordsText = "\u00d1and\u00fa PING\u00dcINO\tcami\u00f3n \u00e1rbol \u00c9ter ola"
annexWordsMarks = "N\u0303andu\u0301 PINGU\u0308INO\tcamio\u0301n a\u0301rbol E\u0301ter ola"
annexWords = "nandu pinguino camion arbol eter ola"
)
// WordKeyVectors computes testdata/vectors/wordkey.json, and fails if a
// vector does not get the words, the result or the key it is written for.
func WordKeyVectors() (WordKeyVectorFile, error) {
@ -103,6 +112,10 @@ func WordKeyVectors() (WordKeyVectorFile, error) {
{"a zero width space is not a space", "a" + cp(0x200b) + "b", []string{"a" + cp(0x200b) + "b"}},
{"U+180E is not a space", "a" + cp(0x180e) + "b", []string{"a" + cp(0x180e) + "b"}},
{"a byte order mark is not a space", "a" + cp(0xfeff) + "b", []string{"a" + cp(0xfeff) + "b"}},
// The second vector of the annex of spec v0.16, 79.7, which its
// normalization without tables gives too.
{"the text of the annex of v0.16", annexWordsText, strings.Fields(annexWords)},
{"the text of the annex of v0.16, with its marks apart", annexWordsMarks, strings.Fields(annexWords)},
}
for _, s := range wordSpaces {
norm = append(norm, struct {
@ -171,6 +184,7 @@ func WordKeyVectors() (WordKeyVectorFile, error) {
capsuleID, want string
}{
{"the vector of §38.1", sixWords, quicknet, 1000, id0, "fceec4d8ca8de86c85a1f26ed49f82a2b38431bd0ce36db995ae7dfd49b96e41"},
{"the second vector of the annex of v0.16, 79.7", annexWordsText, quicknet, 1000, id0, "273295d29370126a3be50b743132718d3cd9137fb3bb4cb20aa23163d2e19bb7"},
{"the next round", sixWords, quicknet, 1001, id0, ""},
{"another capsule_id", sixWords, quicknet, 1000, "000102030405060708090a0b0c0d0e0e", ""},
{"another chain hash", sixWords, mainnet, 1000, id0, ""},

@ -0,0 +1,46 @@
package profile
import "encoding/hex"
// Status is the state of a Provider Profile in the registry of profiles of
// DateKeys (spec §71).
type Status int
const (
// Active: capsules are written and opened with the profile.
Active Status = iota
// ReadOnly: no new capsule is written with the profile, because its
// provider announces its end or is suspected; the capsules that exist
// still open.
ReadOnly
// Compromised: there is proof that the confidentiality of the profile
// failed. The capsules that exist still open, and the official SDK warns
// that their content may have been read before their date.
Compromised
)
func (s Status) String() string {
switch s {
case Active:
return "active"
case ReadOnly:
return "read-only"
case Compromised:
return "compromised"
}
return "unknown"
}
// statuses are the states of the profiles that this release of the module
// pins, by profile_hash: DateKeys does not publish the signed registry of
// §71 yet, so a change of state comes with a new release of the module.
var statuses = map[string]Status{
QuicknetProfileHash: Active,
}
// StatusOf returns the state of the profile whose profile_hash is hash, and
// whether this release of the module knows it.
func StatusOf(hash [32]byte) (Status, bool) {
s, ok := statuses[hex.EncodeToString(hash[:])]
return s, ok
}

@ -0,0 +1,21 @@
package profile
import "testing"
func TestStatus(t *testing.T) {
h, err := Quicknet().Hash()
if err != nil {
t.Fatal(err)
}
if s, ok := StatusOf(h); !ok || s != Active {
t.Errorf("StatusOf(Quicknet) = %v, %v", s, ok)
}
if s, ok := StatusOf([32]byte{1}); ok || s != Active {
t.Errorf("StatusOf of an unknown profile = %v, %v", s, ok)
}
for s, want := range map[Status]string{Active: "active", ReadOnly: "read-only", Compromised: "compromised", 7: "unknown"} {
if s.String() != want {
t.Errorf("%d: %q, want %q", s, s.String(), want)
}
}
}

@ -11,9 +11,6 @@ package drand
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
@ -146,29 +143,14 @@ func (c *Client) fetch(ctx context.Context, relay string, p *profile.Profile, co
if len(b) > maxResponseSize {
return provider.Release{}, fmt.Errorf("%s: response larger than %d bytes: %w", relay, maxResponseSize, datekeys.ErrReleaseInvalid)
}
var wire struct {
Round uint64 `json:"round"`
Signature string `json:"signature"`
Randomness string `json:"randomness"`
}
if err := json.Unmarshal(b, &wire); err != nil {
return provider.Release{}, fmt.Errorf("%s: malformed response: %w", relay, datekeys.ErrReleaseInvalid)
}
sig, err := hex.DecodeString(wire.Signature)
// The answer is read as a release the caller gives, with the strict
// rules of spec v0.16, §47.1, randomness included.
release, err := provider.ParseDrandJSON(b)
if err != nil {
return provider.Release{}, fmt.Errorf("%s: signature is not hex: %w", relay, datekeys.ErrReleaseInvalid)
return provider.Release{}, fmt.Errorf("%s: %w", relay, err)
}
release := provider.Release{Round: wire.Round, Signature: sig}
if err := provider.Verify(p, cond, release); err != nil {
return provider.Release{}, fmt.Errorf("%s: %w", relay, err)
}
// The v2 API omits randomness; if a relay supplies it, it must be
// SHA-256 of the verified signature.
if wire.Randomness != "" {
sum := sha256.Sum256(sig)
if !strings.EqualFold(wire.Randomness, hex.EncodeToString(sum[:])) {
return provider.Release{}, fmt.Errorf("%s: randomness does not match the signature: %w", relay, datekeys.ErrReleaseInvalid)
}
}
return release, nil
}

@ -0,0 +1,298 @@
package provider
import (
"strings"
"unicode/utf8"
)
// The strict reading of drand's JSON (spec v0.16, §47.1): 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.
// jsonMember is a member of the outer object: its name, decoded, the kind of
// its value (a byte of `"`, `0`, `{`, `[`, `t`, `f` or `n`), the text of the
// value as written, and for a string, the string decoded.
type jsonMember struct {
name string
kind byte
raw []byte
str string
}
// strictJSON reads b as a JSON object with the rules above, and returns the
// members of the outer object in their order, or false when b breaks one.
func strictJSON(b []byte) ([]jsonMember, bool) {
if !utf8.Valid(b) {
return nil, false
}
r := &jsonReader{b: b}
r.space()
if !r.at('{') {
return nil, false
}
members, ok := r.object(true)
r.space()
return members, ok && r.i == len(b)
}
// jsonReader reads JSON from b at i.
type jsonReader struct {
b []byte
i int
}
func (r *jsonReader) at(c byte) bool { return r.i < len(r.b) && r.b[r.i] == c }
// space skips the four spaces of JSON.
func (r *jsonReader) space() {
for r.i < len(r.b) && strings.IndexByte(" \t\n\r", r.b[r.i]) >= 0 {
r.i++
}
}
// value reads one value of any kind.
func (r *jsonReader) value() (jsonMember, bool) {
if r.i >= len(r.b) {
return jsonMember{}, false
}
start := r.i
m := jsonMember{kind: r.b[r.i]}
ok := false
switch c := r.b[r.i]; {
case c == '{':
_, ok = r.object(false)
case c == '[':
ok = r.array()
case c == '"':
m.str, ok = r.string()
case c == 't':
ok = r.literal("true")
case c == 'f':
ok = r.literal("false")
case c == 'n':
ok = r.literal("null")
case c == '-' || c >= '0' && c <= '9':
m.kind, ok = '0', r.number()
}
m.raw = r.b[start:r.i]
return m, ok
}
// object reads an object, with no name twice, and returns its members when
// keep is set.
func (r *jsonReader) object(keep bool) ([]jsonMember, bool) {
r.i++ // {
r.space()
if r.at('}') {
r.i++
return nil, true
}
var out []jsonMember
seen := map[string]bool{}
for {
r.space()
if !r.at('"') {
return nil, false
}
name, ok := r.string()
if !ok || seen[name] {
return nil, false
}
seen[name] = true
r.space()
if !r.at(':') {
return nil, false
}
r.i++
r.space()
m, ok := r.value()
if !ok {
return nil, false
}
if keep {
m.name = name
out = append(out, m)
}
r.space()
switch {
case r.at(','):
r.i++
case r.at('}'):
r.i++
return out, true
default:
return nil, false
}
}
}
func (r *jsonReader) array() bool {
r.i++ // [
r.space()
if r.at(']') {
r.i++
return true
}
for {
r.space()
if _, ok := r.value(); !ok {
return false
}
r.space()
switch {
case r.at(','):
r.i++
case r.at(']'):
r.i++
return true
default:
return false
}
}
}
// string reads a string and decodes its escapes; a surrogate escaped alone,
// without its pair, breaks it.
func (r *jsonReader) string() (string, bool) {
r.i++ // "
var sb strings.Builder
for r.i < len(r.b) {
c := r.b[r.i]
switch {
case c == '"':
r.i++
return sb.String(), true
case c < 0x20:
return "", false
case c != '\\':
_, n := utf8.DecodeRune(r.b[r.i:])
sb.Write(r.b[r.i : r.i+n])
r.i += n
continue
}
if r.i+1 >= len(r.b) {
return "", false
}
e := r.b[r.i+1]
r.i += 2
if k := strings.IndexByte(`"\/bfnrt`, e); k >= 0 {
sb.WriteByte("\"\\/\b\f\n\r\t"[k])
continue
}
if e != 'u' {
return "", false
}
u, ok := r.hex4()
switch {
case !ok || u >= 0xdc00 && u <= 0xdfff:
return "", false
case u >= 0xd800 && u <= 0xdbff:
if r.i+1 >= len(r.b) || r.b[r.i] != '\\' || r.b[r.i+1] != 'u' {
return "", false
}
r.i += 2
low, ok := r.hex4()
if !ok || low < 0xdc00 || low > 0xdfff {
return "", false
}
u = 0x10000 + (u-0xd800)<<10 + (low - 0xdc00)
}
sb.WriteRune(rune(u))
}
return "", false
}
// hex4 reads the four hexadecimal digits of an escape \u.
func (r *jsonReader) hex4() (int, bool) {
if r.i+4 > len(r.b) {
return 0, false
}
u := 0
for _, c := range r.b[r.i : r.i+4] {
var d int
switch {
case c >= '0' && c <= '9':
d = int(c - '0')
case c >= 'a' && c <= 'f':
d = int(c-'a') + 10
case c >= 'A' && c <= 'F':
d = int(c-'A') + 10
default:
return 0, false
}
u = u<<4 | d
}
r.i += 4
return u, true
}
func (r *jsonReader) literal(word string) bool {
if !strings.HasPrefix(string(r.b[r.i:]), word) {
return false
}
r.i += len(word)
return true
}
// number reads a number of the grammar of RFC 8259: a minus, an integer
// part without leading zeros, and an optional fraction and exponent.
func (r *jsonReader) number() bool {
digits := func() int {
n := 0
for r.i < len(r.b) && r.b[r.i] >= '0' && r.b[r.i] <= '9' {
r.i++
n++
}
return n
}
if r.at('-') {
r.i++
}
switch {
case r.at('0'):
r.i++
case digits() == 0:
return false
}
if r.at('.') {
r.i++
if digits() == 0 {
return false
}
}
if r.at('e') || r.at('E') {
r.i++
if r.at('+') || r.at('-') {
r.i++
}
if digits() == 0 {
return false
}
}
return true
}
// maxJSONRound is the largest round of drand's JSON, 2^53 - 1, as in the
// release object.
const maxJSONRound = 1<<53 - 1
// jsonRound reads the round of drand's JSON: a number without sign, fraction
// or exponent, from 1 to 2^53 - 1.
func jsonRound(m jsonMember) (uint64, bool) {
if m.kind != '0' || len(m.raw) == 0 || len(m.raw) > 16 || m.raw[0] == '0' {
return 0, false
}
var n uint64
for _, c := range m.raw {
if c < '0' || c > '9' {
return 0, false
}
n = n*10 + uint64(c-'0')
}
return n, n <= maxJSONRound
}

@ -0,0 +1,86 @@
package provider
import (
"strings"
"testing"
)
// 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.
func TestStrictJSON(t *testing.T) {
for _, tc := range []struct {
in string
ok bool
}{
{`{}`, 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\u00e9"}`, true},
{`{"\u00e9":1,"é":2}`, false}, // one name, escaped and not
{`{"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":01}`, false},
{`{"a":1.}`, false},
{`{"a":.5}`, false},
{`{"a":1e}`, false},
{`{"a":+1}`, false},
{`{"a":-}`, false},
{`{"a":tru}`, false},
{`{"a":nul}`, false},
{`{"a":"\x"}`, false},
{`{"a":"\u12"}`, false},
{`{"a":"\u12g4"}`, false},
{`{"a":"\ud800"}`, false},
{`{"a":"\ud800\u0041"}`, false},
{`{"a":"\ud800x"}`, false},
{`{"a":"\udfff\ud800"}`, false},
{`{"a":"\uD83D\uDE00"}`, true},
{`{"a":"` + "\x01" + `"}`, false},
{`{"a":"` + "\xff" + `"}`, false},
{"{\"a\":\"\xed\xa0\x80\"}", false}, // a surrogate in UTF-8
{`{"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":"b"`, false},
{`{"a":1}x`, false},
{`[]`, false},
{`"a"`, false},
{``, false},
{"{\"a\":1}\v", false}, // not a space of JSON
} {
if _, ok := strictJSON([]byte(tc.in)); ok != tc.ok {
t.Errorf("%q: %v, want %v", tc.in, ok, tc.ok)
}
}
m, ok := strictJSON([]byte(`{"\u0072ound":1000,"note":"a\ud83d\ude00","n":-1.5}`))
if !ok || len(m) != 3 || m[0].name != "round" || string(m[0].raw) != "1000" || m[1].str != "a\U0001F600" || m[2].kind != '0' || string(m[2].raw) != "-1.5" {
t.Errorf("members %+v", m)
}
}
func TestJSONRound(t *testing.T) {
for raw, want := range map[string]bool{
"1": true, "1000": true, "9007199254740991": true,
"0": false, "9007199254740992": false, "99999999999999999": false,
"-1": false, "1.0": false, "1e3": false, "01": false,
} {
if _, ok := jsonRound(jsonMember{kind: '0', raw: []byte(raw)}); ok != want {
t.Errorf("%s: %v, want %v", raw, ok, want)
}
}
if _, ok := jsonRound(jsonMember{kind: '"', raw: []byte(`"1"`)}); ok {
t.Error("a string is not a round")
}
if _, err := ParseDrandJSON([]byte(`{"round":1000,"signature":"` + strings.Repeat("ab", 48) + `","note":"` + "\xff" + `"}`)); err == nil {
t.Error("invalid UTF-8 is malformed")
}
}

@ -3,7 +3,6 @@ package provider
import (
"bytes"
"encoding/hex"
"encoding/json"
"fmt"
datekeys "g.activething.com/go/DateKeys"
@ -146,32 +145,59 @@ func DecodeRelease(b []byte) (Release, error) {
// ErrReleaseInvalid. It is accepted as input, never written.
func ParseRelease(b []byte) (Release, error) {
if t := bytes.TrimLeft(b, " \t\r\n"); len(t) > 0 && t[0] == '{' {
return parseDrandJSON(b)
return ParseDrandJSON(b)
}
return DecodeRelease(b)
}
// parseDrandJSON reads the JSON of a drand relay.
func parseDrandJSON(b []byte) (Release, error) {
// ParseDrandJSON reads the JSON of a drand relay with the strict rules of
// spec v0.16, §47.1: at most MaxReleaseJSONSize 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
// ErrReleaseInvalid. provider/drand reads the answers of the relays with it.
func ParseDrandJSON(b []byte) (Release, error) {
if len(b) > MaxReleaseJSONSize {
return Release{}, fmt.Errorf("provider: drand JSON of %d bytes, larger than %d: %w", len(b), MaxReleaseJSONSize, datekeys.ErrReleaseInvalid)
}
var wire struct {
Round *uint64 `json:"round"`
Signature *string `json:"signature"`
Randomness string `json:"randomness"`
malformed := fmt.Errorf("provider: drand JSON: malformed, or without round or signature: %w", datekeys.ErrReleaseInvalid)
members, ok := strictJSON(b)
if !ok {
return Release{}, malformed
}
if err := json.Unmarshal(b, &wire); err != nil || wire.Round == nil || wire.Signature == nil {
return Release{}, fmt.Errorf("provider: drand JSON: malformed, or without round or signature: %w", datekeys.ErrReleaseInvalid)
var round uint64
var signature, randomness *string
for _, m := range members {
switch m.name {
case "round":
if round, ok = jsonRound(m); !ok {
return Release{}, malformed
}
case "signature", "randomness":
if m.kind != '"' {
return Release{}, malformed
}
v := m.str
if m.name == "signature" {
signature = &v
} else {
randomness = &v
}
}
}
if round == 0 || signature == nil {
return Release{}, malformed
}
sig, err := hex.DecodeString(*wire.Signature)
sig, err := hex.DecodeString(*signature)
if err != nil {
return Release{}, fmt.Errorf("provider: drand JSON: signature is not hex: %w", datekeys.ErrReleaseInvalid)
}
if wire.Randomness != "" && !randomnessMatches(wire.Randomness, sig) {
if randomness != nil && !randomnessMatches(*randomness, sig) {
return Release{}, fmt.Errorf("provider: drand JSON: randomness does not match the signature: %w", datekeys.ErrReleaseInvalid)
}
return Release{Round: *wire.Round, Signature: sig}, nil
return Release{Round: round, Signature: sig}, nil
}
// Supplier hands over a release that the caller has in hand (spec v0.15,

@ -4,11 +4,17 @@
// software DateKeys" of the specification: everything it needs is a generic
// BLS12-381 library (drand/kyber-bls12381), the age library (filippo.io/age)
// for the X25519 layers, the Go standard library and golang.org/x/crypto for
// ChaCha20-Poly1305. It does not import the DateKeys module, nor drand or
// ChaCha20-Poly1305, and PBKDF2 of the standard library for a key of words.
// It does not import the DateKeys module, nor drand or
// tlock. The tlock layer and the age file that it protects, whose file key no
// age tool accepts, are written out here step by step.
//
// go run ./scripts/recovery -dkc FILE.dkc -release FILE [-dkk FILE.dkk] -out PATH [-body FILE]
// go run ./scripts/recovery -dkc FILE.dkc -release FILE [-dkk FILE.dkk | -words FILE [-unicodedata FILE]] -out PATH [-body FILE]
//
// A time_and_key capsule opens with its .dkk (-dkk) or with the words of a
// key of words, read from the text file -words (annex 79.7). Words of the
// DateKeys lists normalize without tables; any other text needs
// UnicodeData.txt of Unicode 18.0.0 (-unicodedata), checked by its SHA-256.
//
// FILE is the release object of the round of the capsule (spec §47.1), from
// any source: an archive, a cache service or any copy. Its signature is
@ -987,7 +993,16 @@ type result struct {
body *body // format 3 only
}
func recoverCapsule(dkc, relObj, dkk []byte, log io.Writer) (*result, error) {
// credential is what opens the access layer of a time_and_key capsule: a
// .dkk, or the text of the words of a key of words, with UnicodeData.txt
// when the text needs it.
type credential struct {
dkk []byte
words *string
ucd *unicodeData
}
func recoverCapsule(dkc, relObj []byte, key credential, log io.Writer) (*result, error) {
c, err := parseCapsule(dkc)
if err != nil {
return nil, err
@ -1016,17 +1031,29 @@ func recoverCapsule(dkc, relObj, dkk []byte, log io.Writer) (*result, error) {
ctl := inner
if c.policy == 1 {
if dkk == nil {
return nil, errors.New("time_and_key capsule: it needs its .dkk (-dkk)")
}
mat, err := readAccessKey(dkk, c.capsuleID)
if err != nil {
return nil, err
var id []byte
from := "the .dkk"
switch {
case key.words != nil:
words, err := normalizeWords(*key.words, key.ucd)
if err != nil {
return nil, err
}
if id, err = wordKey(words, c.round, c.capsuleID); err != nil {
return nil, err
}
from = fmt.Sprintf("the %d words", len(words))
case key.dkk != nil:
if id, err = readAccessKey(key.dkk, c.capsuleID); err != nil {
return nil, err
}
default:
return nil, errors.New("time_and_key capsule: it needs its .dkk (-dkk) or the words of its key (-words)")
}
if ctl, err = ageDecryptX25519(inner, mat); err != nil {
if ctl, err = ageDecryptX25519(inner, id); err != nil {
return nil, fmt.Errorf("access layer: %w", err)
}
fmt.Fprintln(log, "access layer: opened with the .dkk")
fmt.Fprintln(log, "access layer: opened with "+from)
}
cc, err := parseControl(ctl, c.format)
@ -1059,13 +1086,15 @@ func run(args []string, log io.Writer) error {
dkcPath := fs.String("dkc", "", "the capsule (.dkc)")
relPath := fs.String("release", "", "the release object of its round")
dkkPath := fs.String("dkk", "", "the access key (.dkk), for time_and_key")
wordsPath := fs.String("words", "", "a text file with the words of a key of words, for time_and_key")
ucdPath := fs.String("unicodedata", "", "UnicodeData.txt of Unicode 18.0.0, for words outside the normalization without tables")
out := fs.String("out", "", "output: a file in formats 1 and 2, a directory in format 3")
bodyPath := fs.String("body", "", "optional: write the L bytes (content, or BODY in format 3)")
if err := fs.Parse(args); err != nil {
return err
}
if *dkcPath == "" || *relPath == "" || *out == "" {
return errors.New("usage: recovery -dkc FILE.dkc -release FILE [-dkk FILE.dkk] -out PATH [-body FILE]")
return errors.New("usage: recovery -dkc FILE.dkc -release FILE [-dkk FILE.dkk | -words FILE [-unicodedata FILE]] -out PATH [-body FILE]")
}
dkc, err := os.ReadFile(*dkcPath)
if err != nil {
@ -1075,13 +1104,30 @@ func run(args []string, log io.Writer) error {
if err != nil {
return err
}
var dkk []byte
var key credential
if *dkkPath != "" {
if dkk, err = os.ReadFile(*dkkPath); err != nil {
if key.dkk, err = os.ReadFile(*dkkPath); err != nil {
return err
}
}
if *wordsPath != "" {
b, err := os.ReadFile(*wordsPath)
if err != nil {
return err
}
text := string(b)
key.words = &text
}
if *ucdPath != "" {
b, err := os.ReadFile(*ucdPath)
if err != nil {
return err
}
if key.ucd, err = parseUnicodeData(b); err != nil {
return err
}
}
res, err := recoverCapsule(dkc, relObj, dkk, log)
res, err := recoverCapsule(dkc, relObj, key, log)
if err != nil {
return err
}

@ -35,6 +35,7 @@ type fixtureRecord struct {
PlaintextFile string `json:"plaintext_file"`
PlaintextSHA256 string `json:"plaintext_sha256"`
AccessKeyFile string `json:"access_key_file"`
WordsText string `json:"words_text"`
Files []struct {
Path string `json:"path"`
Size uint64 `json:"size"`
@ -136,7 +137,9 @@ func TestRecoverFixtures(t *testing.T) {
"format3_time_and_key_portable",
"format3_tree",
"format3_signed",
"format2_time_only", // two STREAM chunks
"format3_time_and_key_words", // opened with its words (annex 79.7)
"format3_full_chunk", // PAYLOAD_AGE ends in a full chunk (79.5)
"format2_time_only", // two STREAM chunks
"format2_time_and_key_portable",
"format2_time_and_key_sixteen", // round 2000; no .dkk, so only its release is checked below
"time_only",
@ -145,13 +148,17 @@ func TestRecoverFixtures(t *testing.T) {
t.Run(name, func(t *testing.T) {
rec, dkc, relObj, dkk := loadFixture(t, name)
if name == "format2_time_and_key_sixteen" {
_, err := recoverCapsule(dkc, relObj, nil, io.Discard)
_, err := recoverCapsule(dkc, relObj, credential{}, io.Discard)
if err == nil || !strings.Contains(err.Error(), "needs its .dkk") {
t.Fatalf("got %v, want the .dkk to be required", err)
}
return
}
res, err := recoverCapsule(dkc, relObj, dkk, io.Discard)
key := credential{dkk: dkk}
if rec.WordsText != "" {
key.words = &rec.WordsText
}
res, err := recoverCapsule(dkc, relObj, key, io.Discard)
if err != nil {
t.Fatal(err)
}
@ -238,7 +245,7 @@ func TestBadReleases(t *testing.T) {
{"wrong type tag", "type tag", bytes.Replace(encodeRelease(chain, 1000, sig), []byte("release"), []byte("relaxed"), 1)},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := recoverCapsule(dkc, tc.relObj, nil, io.Discard)
_, err := recoverCapsule(dkc, tc.relObj, credential{}, io.Discard)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("got %v, want an error with %q", err, tc.want)
}
@ -388,7 +395,7 @@ func TestTampered(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
bad := bytes.Clone(dkc)
bad[tc.at] ^= 0x01
if _, err := recoverCapsule(bad, relObj, nil, io.Discard); err == nil {
if _, err := recoverCapsule(bad, relObj, credential{}, io.Discard); err == nil {
t.Fatal("a tampered capsule opened")
}
})

@ -0,0 +1,220 @@
package main
import (
"bufio"
"bytes"
"crypto/pbkdf2"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
)
// ---------------------------------------------------------------------------
// The key of words (annex 79.7, spec §38.1): words give the X25519 identity
// of a credential of a time_and_key capsule.
//
// P = the words, normalized, in UTF-8, separated by a space
// S = "DateKeys llave de palabras v2|" || chain_hash || "|" || round || "|" || capsule_id
// id = PBKDF2-HMAC-SHA256(P, S, 600000 iterations, 32 bytes)
//
// with the chain hash and capsule_id in lower-case hexadecimal and the round
// in decimal.
const wordKeyIterations = 600000
// wordKey derives the identity of the words of the capsule.
func wordKey(words []string, round uint64, capsuleID []byte) ([]byte, error) {
p := strings.Join(words, " ")
s := "DateKeys llave de palabras v2|" + quicknetChainHash + "|" + strconv.FormatUint(round, 10) + "|" + hex.EncodeToString(capsuleID)
return pbkdf2.Key(sha256.New, p, []byte(s), wordKeyIterations, 32)
}
// ---------------------------------------------------------------------------
// The normalization without tables (79.7): for a text of printable ASCII,
// the ASCII spaces, á, é, í, ó, ú, ü, ñ and their capitals, and the marks
// U+0300 to U+036F, which is what any word of the DateKeys lists gives. It
// is exactly the normalization of §38.1 for that text.
// latinBase maps the letters of the recipe to their base letter, in lower
// case.
var latinBase = map[rune]rune{
0x00e1: 'a', 0x00c1: 'a', // á Á
0x00e9: 'e', 0x00c9: 'e', // é É
0x00ed: 'i', 0x00cd: 'i', // í Í
0x00f3: 'o', 0x00d3: 'o', // ó Ó
0x00fa: 'u', 0x00fc: 'u', 0x00da: 'u', 0x00dc: 'u', // ú ü Ú Ü
0x00f1: 'n', 0x00d1: 'n', // ñ Ñ
}
func asciiSpace(r rune) bool { return r == ' ' || r >= 0x09 && r <= 0x0d }
// normalizeSimple applies the recipe without tables, and returns false when
// the text holds a character outside it.
func normalizeSimple(text string) ([]string, bool) {
var sb strings.Builder
for _, r := range text {
switch {
case r >= 0x300 && r <= 0x36f: // 1. the marks go
case latinBase[r] != 0: // 2. the letters with marks, to their base
sb.WriteRune(latinBase[r])
case r >= 'A' && r <= 'Z': // 3. A to Z, to a to z
sb.WriteRune(r + 'a' - 'A')
case r >= 0x21 && r <= 0x7e || asciiSpace(r):
sb.WriteRune(r)
default:
return nil, false
}
}
return strings.FieldsFunc(sb.String(), asciiSpace), true // 4.
}
// ---------------------------------------------------------------------------
// The full normalization (79.7), for any other text: NFD of UAX #15, without
// U+0300 to U+036F, the simple lower case of each code point, and the split
// at the spaces of §38.1. It reads UnicodeData.txt of Unicode 18.0.0, which
// the annex names by its SHA-256.
const unicodeDataSHA256 = "0736451de439ae7baf1425136617da495e09ee5afbe6e394374db7009ea08950"
// unicodeData holds what the normalization takes from UnicodeData.txt: the
// canonical decomposition (field 5, without the tagged ones), the canonical
// combining class (field 3) and the simple lower case (field 13).
type unicodeData struct {
decomposition map[rune][]rune
class map[rune]int
lower map[rune]rune
}
// parseUnicodeData reads UnicodeData.txt, which must be that of Unicode
// 18.0.0: another version can give other words.
func parseUnicodeData(b []byte) (*unicodeData, error) {
sum := sha256.Sum256(b)
if hex.EncodeToString(sum[:]) != unicodeDataSHA256 {
return nil, fmt.Errorf("UnicodeData.txt has the SHA-256 %x, not that of Unicode 18.0.0, %s", sum, unicodeDataSHA256)
}
u := &unicodeData{decomposition: map[rune][]rune{}, class: map[rune]int{}, lower: map[rune]rune{}}
sc := bufio.NewScanner(bytes.NewReader(b))
for sc.Scan() {
f := strings.Split(sc.Text(), ";")
if len(f) != 15 {
return nil, fmt.Errorf("UnicodeData.txt: a line of %d fields", len(f))
}
cp, err := codePoint(f[0])
if err != nil {
return nil, err
}
// A range, <…, First> to <…, Last>, has no decomposition, class or
// lower case: its two lines say nothing more.
if c, _ := strconv.Atoi(f[3]); c != 0 {
u.class[cp] = c
}
if f[5] != "" && !strings.HasPrefix(f[5], "<") {
for _, h := range strings.Fields(f[5]) {
d, err := codePoint(h)
if err != nil {
return nil, err
}
u.decomposition[cp] = append(u.decomposition[cp], d)
}
}
if f[13] != "" {
if u.lower[cp], err = codePoint(f[13]); err != nil {
return nil, err
}
}
}
return u, sc.Err()
}
func codePoint(h string) (rune, error) {
n, err := strconv.ParseUint(h, 16, 32)
if err != nil || n > 0x10ffff {
return 0, fmt.Errorf("UnicodeData.txt: %q is not a code point", h)
}
return rune(n), nil
}
// The Hangul syllables decompose by computation (Unicode, §3.12).
const (
hangulS, hangulL, hangulV, hangulT = 0xac00, 0x1100, 0x1161, 0x11a7
hangulVCount, hangulTCount = 21, 28
hangulCount = 19 * hangulVCount * hangulTCount
)
// decompose appends the full canonical decomposition of r.
func (u *unicodeData) decompose(out []rune, r rune) []rune {
if s := r - hangulS; s >= 0 && s < hangulCount {
out = append(out, hangulL+s/(hangulVCount*hangulTCount), hangulV+s%(hangulVCount*hangulTCount)/hangulTCount)
if t := s % hangulTCount; t != 0 {
out = append(out, hangulT+t)
}
return out
}
d, ok := u.decomposition[r]
if !ok {
return append(out, r)
}
for _, x := range d {
out = u.decompose(out, x)
}
return out
}
// nfd is the NFD of text: the full canonical decomposition, then the
// canonical ordering of each run of marks by their combining class.
func (u *unicodeData) nfd(text string) []rune {
var out []rune
for _, r := range text {
out = u.decompose(out, r)
}
for i := 1; i < len(out); i++ {
for j := i; j > 0; j-- {
a, b := u.class[out[j-1]], u.class[out[j]]
if b == 0 || a <= b {
break
}
out[j-1], out[j] = out[j], out[j-1]
}
}
return out
}
// wordSpace is a space of §38.1, step 4: those of Go's unicode.IsSpace.
func wordSpace(r rune) bool {
switch {
case asciiSpace(r), r == 0x85, r == 0xa0, r == 0x1680, r >= 0x2000 && r <= 0x200a,
r == 0x2028, r == 0x2029, r == 0x202f, r == 0x205f, r == 0x3000:
return true
}
return false
}
// normalize applies the full normalization.
func (u *unicodeData) normalize(text string) []string {
var sb strings.Builder
for _, r := range u.nfd(text) {
if r >= 0x300 && r <= 0x36f {
continue
}
if l, ok := u.lower[r]; ok {
r = l
}
sb.WriteRune(r)
}
return strings.FieldsFunc(sb.String(), wordSpace)
}
// normalizeWords normalizes text without tables when it can, and with u
// otherwise.
func normalizeWords(text string, u *unicodeData) ([]string, error) {
if words, ok := normalizeSimple(text); ok {
return words, nil
}
if u == nil {
return nil, errors.New("the words hold a character outside the normalization without tables of the annex (79.7): give UnicodeData.txt of Unicode 18.0.0 with -unicodedata")
}
return u.normalize(text), nil
}

@ -0,0 +1,154 @@
package main
import (
"encoding/hex"
"io"
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
// unicodeDataPath is the copy of UnicodeData.txt of Unicode 18.0.0 that
// internal/pathrule/gen reads; it is not committed. The tests of the full
// normalization skip without it.
const unicodeDataPath = "../../.cache/unicode/18.0.0/UnicodeData.txt"
func loadUnicodeData(t *testing.T) *unicodeData {
t.Helper()
b, err := os.ReadFile(unicodeDataPath)
if err != nil {
t.Skipf("no UnicodeData.txt of Unicode 18.0.0 in %s: %v", unicodeDataPath, err)
}
u, err := parseUnicodeData(b)
if err != nil {
t.Fatal(err)
}
return u
}
// The vectors of the annex, 79.7: the words without tables and their key,
// for the chain hash of Quicknet, round 1000 and capsule_id 00 to 0f.
func TestAnnexWordVectors(t *testing.T) {
id := unhex(t, "000102030405060708090a0b0c0d0e0f")
for _, c := range []struct{ text, words, key string }{
{"perro luna casa verde tren mar", "perro luna casa verde tren mar", "fceec4d8ca8de86c85a1f26ed49f82a2b38431bd0ce36db995ae7dfd49b96e41"},
{"\u00d1and\u00fa PING\u00dcINO\tcami\u00f3n \u00e1rbol \u00c9ter ola", "nandu pinguino camion arbol eter ola", "273295d29370126a3be50b743132718d3cd9137fb3bb4cb20aa23163d2e19bb7"},
{"N\u0303andu\u0301 PINGU\u0308INO\tcamio\u0301n a\u0301rbol E\u0301ter ola", "nandu pinguino camion arbol eter ola", "273295d29370126a3be50b743132718d3cd9137fb3bb4cb20aa23163d2e19bb7"},
} {
words, ok := normalizeSimple(c.text)
if !ok || strings.Join(words, " ") != c.words {
t.Fatalf("%q: %q, %v", c.text, words, ok)
}
key, err := wordKey(words, 1000, id)
if err != nil || hex.EncodeToString(key) != c.key {
t.Fatalf("%q: key %x, %v", c.text, key, err)
}
}
}
// The recipe without tables refuses what it does not cover, and then the
// words need UnicodeData.txt.
func TestNormalizeSimpleRefuses(t *testing.T) {
for _, text := range []string{"\u00e7a va", "stra\u00dfe", "\u00e0 la", "\u03c3\u03b1\u03c3", "a\u00a0b", "\u212b", "a\u200bb"} {
if _, ok := normalizeSimple(text); ok {
t.Errorf("%q: the recipe without tables does not cover it", text)
}
if _, err := normalizeWords(text, nil); err == nil || !strings.Contains(err.Error(), "-unicodedata") {
t.Errorf("%q: %v", text, err)
}
}
// The whole of printable ASCII stays, and only A to Z change.
var ascii []rune
for r := rune(0x21); r <= 0x7e; r++ {
ascii = append(ascii, r)
}
words, ok := normalizeSimple(string(ascii))
if !ok || len(words) != 1 || words[0] != strings.ToLower(string(ascii)) {
t.Errorf("printable ASCII: %q, %v", words, ok)
}
}
// The full normalization gives the words of every case of wordkey.json, and
// the recipe without tables, where it applies, the same.
func TestNormalizeVectors(t *testing.T) {
u := loadUnicodeData(t)
var f struct {
Normalize []struct {
Name string `json:"name"`
Text string `json:"text"`
Words []string `json:"words"`
} `json:"normalize"`
}
readJSON(t, filepath.Join(vectorsDir, "wordkey.json"), &f)
if len(f.Normalize) < 40 {
t.Fatalf("%d cases of normalize", len(f.Normalize))
}
simple := 0
for _, c := range f.Normalize {
got := u.normalize(c.Text)
if got == nil {
got = []string{}
}
if !slices.Equal(got, c.Words) {
t.Errorf("%s: %q, want %q", c.Name, got, c.Words)
}
if words, ok := normalizeSimple(c.Text); ok {
simple++
if words == nil {
words = []string{}
}
if !slices.Equal(words, c.Words) {
t.Errorf("%s, without tables: %q, want %q", c.Name, words, c.Words)
}
}
}
if simple < 8 {
t.Errorf("only %d cases without tables", simple)
}
}
// Only UnicodeData.txt of Unicode 18.0.0 is accepted: another version can
// give other words.
func TestUnicodeDataVersion(t *testing.T) {
b, err := os.ReadFile(unicodeDataPath)
if err != nil {
t.Skip(err)
}
b[len(b)-2] ^= 1
if _, err := parseUnicodeData(b); err == nil || !strings.Contains(err.Error(), "not that of Unicode 18.0.0") {
t.Fatalf("a changed UnicodeData.txt: %v", err)
}
}
// format3_time_and_key_words opens with the text of its words, which needs
// no tables, and with UnicodeData.txt too.
func TestRecoverWithWords(t *testing.T) {
rec, dkc, relObj, _ := loadFixture(t, "format3_time_and_key_words")
if rec.WordsText == "" {
t.Fatal("the record has no words_text")
}
text := rec.WordsText
res, err := recoverCapsule(dkc, relObj, credential{words: &text}, io.Discard)
if err != nil || res.format != 3 || len(res.body.files) != 1 {
t.Fatalf("%v", err)
}
other := "nandu pinguino camion arbol eter mar"
if _, err := recoverCapsule(dkc, relObj, credential{words: &other}, io.Discard); err == nil || !strings.Contains(err.Error(), "access layer") {
t.Fatalf("other words: %v", err)
}
if b, err := os.ReadFile(unicodeDataPath); err == nil {
u, err := parseUnicodeData(b)
if err != nil {
t.Fatal(err)
}
if _, err := recoverCapsule(dkc, relObj, credential{words: &text, ucd: u}, io.Discard); err != nil {
t.Fatal(err)
}
}
}
type ioDiscard struct{}
func (ioDiscard) Write(p []byte) (int, error) { return len(p), nil }

@ -28,4 +28,14 @@ recover_one() {
recover_one format3_single 1000
recover_one format3_time_and_key_portable 1000 -dkk "$fx/format3_time_and_key_portable.dkk"
# The key of words of the annex (79.7): the text of its second vector,
# "Ñandú", two spaces, "PINGÜINO", a tab and "camión árbol Éter ola", which
# normalizes without tables.
printf '\xc3\x91and\xc3\xba PING\xc3\x9cINO\tcami\xc3\xb3n \xc3\xa1rbol \xc3\x89ter ola' >"$tmp/words.txt"
recover_one format3_time_and_key_words 1000 -words "$tmp/words.txt"
# PAYLOAD_AGE of 65536 bytes of plaintext: one full STREAM chunk, the last
# one (79.5).
recover_one format3_full_chunk 1000
echo "recovery check passed"

File diff suppressed because it is too large Load Diff

@ -67,7 +67,7 @@
the tlock IBE. Its §76 records each change with its case.
- `DateKeys_Protocol_Specification_v0.15.md`: frozen copy of the normative
draft v0.15 (7 October 2026), approved by its author on that date and
tagged `spec-v0.15`; this module implements it. SHA-256:
tagged `spec-v0.15`. SHA-256:
`45105e693be4187af4dd30f4d254402612587b6427c746f5d29f07a541c1e3f3`.
It covers the
long-term recovery of capsules: the release object, the release of a
@ -80,14 +80,33 @@
software. It changes no format of `.dkc` or `.dkk`, and one verdict: a
valid release in hand opens a capsule with a clock behind its round time.
Its §76 records each change with its case.
- `datekeys.cddl`: the CBOR schemas of v0.15, those of v0.12, v0.13 and v0.14 with the rule `release` added, the three control
- `DateKeys_Protocol_Specification_v0.16.md`: frozen copy of the normative
draft v0.16 (7 October 2026), approved by its author on that date and
tagged `spec-v0.16`; this module implements it. SHA-256:
`807d4fe85ac09ad6f97abc75ab3e2156bb2f3fb0dc589777f4420627fad545e1`. It fixes what Astra's
review of v0.15 found: a valid seal without `accuracy` no longer proves
that it came before the unlock date (S5, with its reason); the recovery
annex derives a key of words without DateKeys software, with a recipe
without tables for the letters of the DateKeys lists and `UnicodeData.txt`
of Unicode 18.0.0, named by its SHA-256, for any other text; the last
chunk of an `age` file may be full; a signature with certificates keeps
the chains without their roots and the OCSP responses that fit, and the
writer says what it leaves out; and drand's JSON is read strictly, with no
repeated names, exact names and an integer round. It changes no format.
Its §76 records each change with its case.
- `datekeys.cddl`: the CBOR schemas of v0.16, the same as those of v0.15: those of v0.12, v0.13 and v0.14 with the rule `release` added, the three control
versions and the security and head objects of format 3 included, with the
encoding rules CDDL cannot express. Those of v0.9 and v0.8.2 are at the tags
`spec-v0.9` and `spec-v0.8.2`.
The specification is licensed under the Creative Commons Attribution 4.0
International License (CC-BY-4.0): <https://creativecommons.org/licenses/by/4.0/>.
The code of this repository is licensed separately under Apache-2.0.
The specification is licensed under the Creative Commons
Attribution-NoDerivatives 4.0 International License (CC-BY-ND-4.0):
<https://creativecommons.org/licenses/by-nd/4.0/>. It may be copied and shared
unchanged, with credit; a modified version or a translation needs the written
permission of its author. The recovery annex, `annex/recovery.md`, is §79 under
a title and carries the same license. The code of this repository is licensed
separately under Apache-2.0, and the word lists of `wordkey/lists` keep their
own licenses (`wordkey/lists/README.md`).
Changes to the specification follow its §76: a normative change should answer a
reproducible case found through the reference implementation, the CDDL, a

@ -1,9 +1,8 @@
; DateKeys Protocol Specification v0.15 - CBOR schemas (RFC 8610 CDDL).
; DateKeys Protocol Specification v0.16 - CBOR schemas (RFC 8610 CDDL).
;
; Normative companion of spec/DateKeys_Protocol_Specification_v0.15.md, tagged
; spec-v0.15. The
; version adds the release object (spec section 47.1); the other
; schemas have not changed since v0.12. They include the three control
; Normative companion of spec/DateKeys_Protocol_Specification_v0.16.md, tagged
; spec-v0.16. v0.16 changes no schema; v0.15 added the release object (spec
; section 47.1); the other schemas have not changed since v0.12. They include the three control
; versions: 1, of capsule format 1 (v0.8.2), 2, of format 2 (v0.9), and 3, of
; format 3, and the security and head objects of format 3.
;

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`

@ -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": [
{

@ -1,5 +1,5 @@
{
"spec": "0.15",
"spec": "0.16",
"description": "Padding rules of the payload of a format 2 capsule (spec §29.1): for each content length L, P with code 1 (bloque256) and code 2 (reforzado), and the length of PAYLOAD_AGE for each. e, s and last_bits are informative. Generated by the reference implementation. See testdata/README.md.",
"l_max": 8936830510563328,
"vectors": [

@ -1,5 +1,5 @@
{
"spec": "0.15",
"spec": "0.16",
"description": "The key of R7 (spec §29.5) of segments, with the Unicode 18.0.0 tables of §29.5.1, generated by the reference implementation: nfd is NFD(segment) and key is NFD(fold(NFD(s'))), s' the segment without ZWNJ, ZWJ, VS15 and VS16. See testdata/README.md.",
"unicode_version": "18.0.0",
"tables_digest": "07cf5d54aea1cd13a3ecef14a06976cc49a3cdad755cf9bc10395178b93aeb07",

@ -1,5 +1,5 @@
{
"spec": "0.15",
"spec": "0.16",
"description": "Paths of a format 3 head (spec §29.5) with the Unicode 18.0.0 and best-fit tables of §29.5.1, generated by the reference implementation. paths: one path and the rules of one entry, R2 to R6c and R10; trees: the paths of a head, of 0 bytes each, and the result of decoding it. See testdata/README.md.",
"unicode_version": "18.0.0",
"tables_digest": "07cf5d54aea1cd13a3ecef14a06976cc49a3cdad755cf9bc10395178b93aeb07",

@ -1,5 +1,5 @@
{
"spec": "0.15",
"spec": "0.16",
"description": "Quicknet Provider Profile V1: exact Deterministic CBOR and profile_hash (spec §11, §12, §75 item 2), generated by the reference implementation.",
"profile_id": "datekeys:quicknet:v1",
"provider": "drand",

@ -1,5 +1,5 @@
{
"spec": "0.15",
"spec": "0.16",
"profile": "datekeys:quicknet:v1",
"description": "Quicknet date to round resolution (spec §15, §16, §65), generated by the reference implementation.",
"vectors": [

@ -1,5 +1,5 @@
{
"spec": "0.15",
"spec": "0.16",
"description": "The release object (spec v0.15, §47.1), and drand's JSON as the input of the caller, each checked against the pinned Quicknet profile and the round of a DateKey as step 10 of spec §63 checks a release that the caller supplies; and the lookups of a local release archive (spec v0.15, §50). See testdata/README.md.",
"profile": "datekeys:quicknet:v1",
"objects": [
@ -432,6 +432,204 @@
},
"result": "ERR_RELEASE_INVALID",
"text": "provider: the signature is not a canonical point encoding, or does not verify as the BLS signature of round 1000 under datekeys:quicknet:v1: ERR_RELEASE_INVALID"
},
{
"name": "round twice",
"input": "{\"round\":1000,\"round\":1001,\"signature\":\"b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39\"}",
"round": 1000,
"result": "ERR_RELEASE_INVALID",
"text": "provider: drand JSON: malformed, or without round or signature: ERR_RELEASE_INVALID"
},
{
"name": "round twice, once escaped as \\u0072ound",
"input": "{\"round\":1000,\"\\u0072ound\":1000,\"signature\":\"b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39\"}",
"round": 1000,
"result": "ERR_RELEASE_INVALID",
"text": "provider: drand JSON: malformed, or without round or signature: ERR_RELEASE_INVALID"
},
{
"name": "round twice, the second null",
"input": "{\"round\":1000,\"round\":null,\"signature\":\"b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39\"}",
"round": 1000,
"result": "ERR_RELEASE_INVALID",
"text": "provider: drand JSON: malformed, or without round or signature: ERR_RELEASE_INVALID"
},
{
"name": "round escaped as \\u0072ound",
"input": "{\"\\u0072ound\":1000,\"signature\":\"b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39\"}",
"round": 1000,
"release": {
"round": 1000,
"signature": "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39"
},
"result": "ok"
},
{
"name": "Round instead of round",
"input": "{\"Round\":1000,\"signature\":\"b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39\"}",
"round": 1000,
"result": "ERR_RELEASE_INVALID",
"text": "provider: drand JSON: malformed, or without round or signature: ERR_RELEASE_INVALID"
},
{
"name": "ROUND beside round: another name, ignored",
"input": "{\"round\":1000,\"ROUND\":1001,\"signature\":\"b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39\"}",
"round": 1000,
"release": {
"round": 1000,
"signature": "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39"
},
"result": "ok"
},
{
"name": "round null",
"input": "{\"round\":null,\"signature\":\"b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39\"}",
"round": 1000,
"result": "ERR_RELEASE_INVALID",
"text": "provider: drand JSON: malformed, or without round or signature: ERR_RELEASE_INVALID"
},
{
"name": "round 1000.0",
"input": "{\"round\":1000.0,\"signature\":\"b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39\"}",
"round": 1000,
"result": "ERR_RELEASE_INVALID",
"text": "provider: drand JSON: malformed, or without round or signature: ERR_RELEASE_INVALID"
},
{
"name": "round 1e3",
"input": "{\"round\":1e3,\"signature\":\"b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39\"}",
"round": 1000,
"result": "ERR_RELEASE_INVALID",
"text": "provider: drand JSON: malformed, or without round or signature: ERR_RELEASE_INVALID"
},
{
"name": "round -1000",
"input": "{\"round\":-1000,\"signature\":\"b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39\"}",
"round": 1000,
"result": "ERR_RELEASE_INVALID",
"text": "provider: drand JSON: malformed, or without round or signature: ERR_RELEASE_INVALID"
},
{
"name": "round 0",
"input": "{\"round\":0,\"signature\":\"b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39\"}",
"round": 1000,
"result": "ERR_RELEASE_INVALID",
"text": "provider: drand JSON: malformed, or without round or signature: ERR_RELEASE_INVALID"
},
{
"name": "round 2^53",
"input": "{\"round\":9007199254740992,\"signature\":\"b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39\"}",
"round": 1000,
"result": "ERR_RELEASE_INVALID",
"text": "provider: drand JSON: malformed, or without round or signature: ERR_RELEASE_INVALID"
},
{
"name": "round 2^53 - 1 for a DateKey of round 1000",
"input": "{\"round\":9007199254740991,\"signature\":\"b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39\"}",
"round": 1000,
"release": {
"round": 9007199254740991,
"signature": "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39"
},
"result": "ERR_ROUND_MISMATCH",
"text": "provider: release for round 9007199254740991, expected 1000: ERR_ROUND_MISMATCH"
},
{
"name": "round with a leading zero",
"input": "{\"round\":01000,\"signature\":\"b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39\"}",
"round": 1000,
"result": "ERR_RELEASE_INVALID",
"text": "provider: drand JSON: malformed, or without round or signature: ERR_RELEASE_INVALID"
},
{
"name": "signature twice",
"input": "{\"round\":1000,\"signature\":\"b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39\",\"signature\":\"b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39\"}",
"round": 1000,
"result": "ERR_RELEASE_INVALID",
"text": "provider: drand JSON: malformed, or without round or signature: ERR_RELEASE_INVALID"
},
{
"name": "signature null",
"input": "{\"round\":1000,\"signature\":null}",
"round": 1000,
"result": "ERR_RELEASE_INVALID",
"text": "provider: drand JSON: malformed, or without round or signature: ERR_RELEASE_INVALID"
},
{
"name": "randomness empty",
"input": "{\"round\":1000,\"randomness\":\"\",\"signature\":\"b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39\"}",
"round": 1000,
"result": "ERR_RELEASE_INVALID",
"text": "provider: drand JSON: randomness does not match the signature: ERR_RELEASE_INVALID"
},
{
"name": "randomness null",
"input": "{\"round\":1000,\"randomness\":null,\"signature\":\"b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39\"}",
"round": 1000,
"result": "ERR_RELEASE_INVALID",
"text": "provider: drand JSON: malformed, or without round or signature: ERR_RELEASE_INVALID"
},
{
"name": "another name twice",
"input": "{\"round\":1000,\"signature\":\"b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39\",\"note\":1,\"note\":2}",
"round": 1000,
"result": "ERR_RELEASE_INVALID",
"text": "provider: drand JSON: malformed, or without round or signature: ERR_RELEASE_INVALID"
},
{
"name": "a name twice in a nested object",
"input": "{\"round\":1000,\"signature\":\"b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39\",\"meta\":{\"a\":1,\"a\":2}}",
"round": 1000,
"result": "ERR_RELEASE_INVALID",
"text": "provider: drand JSON: malformed, or without round or signature: ERR_RELEASE_INVALID"
},
{
"name": "nested objects and arrays, ignored",
"input": "{\"round\":1000,\"signature\":\"b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39\",\"meta\":{\"a\":[1,{\"a\":2}],\"b\":{},\"c\":[]}}",
"round": 1000,
"release": {
"round": 1000,
"signature": "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39"
},
"result": "ok"
},
{
"name": "a lone surrogate in another name",
"input": "{\"round\":1000,\"signature\":\"b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39\",\"\\ud800\":1}",
"round": 1000,
"result": "ERR_RELEASE_INVALID",
"text": "provider: drand JSON: malformed, or without round or signature: ERR_RELEASE_INVALID"
},
{
"name": "a lone low surrogate in a value",
"input": "{\"round\":1000,\"signature\":\"b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39\",\"note\":\"\\udc00\"}",
"round": 1000,
"result": "ERR_RELEASE_INVALID",
"text": "provider: drand JSON: malformed, or without round or signature: ERR_RELEASE_INVALID"
},
{
"name": "a surrogate pair in a value",
"input": "{\"round\":1000,\"signature\":\"b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39\",\"note\":\"\\ud83d\\ude00\"}",
"round": 1000,
"release": {
"round": 1000,
"signature": "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39"
},
"result": "ok"
},
{
"name": "a tab inside a string",
"input": "{\"round\":1000,\"signature\":\"b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39\",\"note\":\"a\tb\"}",
"round": 1000,
"result": "ERR_RELEASE_INVALID",
"text": "provider: drand JSON: malformed, or without round or signature: ERR_RELEASE_INVALID"
},
{
"name": "something after the object",
"input": "{\"round\":1000,\"signature\":\"b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39\"}{}",
"round": 1000,
"result": "ERR_RELEASE_INVALID",
"text": "provider: drand JSON: malformed, or without round or signature: ERR_RELEASE_INVALID"
}
],
"archive": {

@ -1,5 +1,5 @@
{
"spec": "0.15",
"spec": "0.16",
"description": "The IP address that the name of an https address of a locator resolves to, and whether a reader may connect (spec v0.13, 44.1): a public address, or an address of NAT64 (RFC 6052) of 64:ff9b::/96 or of the NAT64 prefix of the network, whose IPv4 address inside is public. See testdata/README.md.",
"cases": [
{

@ -1,5 +1,5 @@
{
"spec": "0.15",
"spec": "0.16",
"description": "SECURITY_CBOR of format 3, exactly its SECURITY_LEN bytes, the verdicts of the signature and of the seal in the context of the file, and their lines (spec §29.3, §29.7, §29.9). See testdata/README.md.",
"context": {
"control_commit": "0101010101010101010101010101010101010101010101010101010101010101",

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

Loading…
Cancel
Save

Powered by TurnKey Linux.