42 KiB
DateKeys v0.15: design overview for a cryptographic reviewer
Informative. Every statement here summarises the Spanish specification DateKeys_Protocol_Specification_v0.15.md (tag spec-v0.15); the section sign (§) refers to it. Where this summary and the specification disagree, the specification wins. Reading time: about an hour.
Notation: || is concatenation; uint32_be, uint64_be are big-endian integers; SHA-256 is FIPS 180-4; "age" is the C2SP age v1 format; Quicknet is the drand network of §12.
1. Principle and layering
- Guiding principle (§3): never trust the server for a property the client can verify. The provider profile is pinned locally, date → round is computed locally, releases are verified locally, and
.dkc,.dkk, APIs and relays are untrusted inputs. - No new cryptography (§28, §30, §37): DateKeys composes three standard age files and the tlock IBE of drand. It defines framing, CBOR, bindings, stanza rules, padding, the format 3 content, the author signature profile and the verification flow.
- The protocol does not define storage, discovery, distribution or delivery of
.dkk(§6). - Since v0.15 the release of a round has a data format, the release object (§47.1), and long-term recovery rests on archives and cache services of all rounds, with an annex to open a capsule without DateKeys software (§50, §79). See section 8.
2. Provider Profile and root of trust
2.1 Profile (§10, §11, §12, §12.1, §13)
A Provider Profile is immutable; any cryptographically relevant change is a new profile (§10). It is a Deterministic CBOR map with integer keys (§11):
0 → "datekeys-provider-profile" 1 → 1 (schema version)
2 → profile_id "datekeys:quicknet:v1" 3 → provider "drand" 4 → network "quicknet"
5 → chain_hash (32 bytes) 6 → public_key 7 → period (3) 8 → genesis_time (1692803367)
9 → scheme "bls-unchained-g1-rfc9380" 10 → genesis_seed
profile_hash = SHA-256(exact deterministic CBOR bytes)
-
The SDK MUST pin the Quicknet parameters byte by byte (§12). V1 pins one profile and one scheme,
bls-unchained-g1-rfc9380: unchained rounds, signatures in G1 (48 bytes), public key in G2 (96 bytes). Since v0.14 the other unchained tlock schemes are rejected withERR_UNKNOWN_PROFILE(§12.1 item 2; §76 v0.14 change 7). -
Validation order (§12.1): type, version and CDDL; field rules (alphabets,
period≤ 2³² − 1,genesis_timerange, providerdrand, the scheme, a canonical non-identity G2 public key); then the chain-hash self-checkchain_hash = SHA-256(uint32_be(period) || int64_be(genesis_time) || public_key || genesis_seed || network)with
networkomitted when it isdefault(ERR_PROFILE_MISMATCHon mismatch); then the pin:profile_hashmust equal the one the client knows in advance. -
The client MUST NOT accept a public key or profile from the same endpoint that delivers the release (§13). A remote
verified = truehas no security value (§51).
2.2 Canonical BLS12-381 points (§12.2)
The public key, a release signature and the point U of a tlock stanza each have a single valid encoding: ZCash compressed (48 bytes in G1, 96 in G2; in G2, c1 then c0). Compression bit set; infinity bit only for the identity with every other bit zero; sort bit by the lexicographically larger y; coordinates below p; the point on the curve and in the prime-order subgroup. Every other string is rejected (other lengths, uncompressed forms, x + p, c0 + p, c1 + p, an identity with payload or sign). The public key, the signature and U MUST also differ from the identity. This rule was added after a second implementation found that tlock-js 0.9.0 on @noble/curves 1.9.7 accepted non-canonical U and signatures (§76, v0.8.2 amendment).
2.3 Date → round (§15 to §17)
round_time(r) = genesis_time + (r − 1) · period
The SDK chooses the first round with round_time ≥ requested_unlock_at, compared at full precision, never rounding backwards. Round times are capped at 9999-12-31T23:59:59Z (last Quicknet round 83903165811). At decryption, step 10 compares the release round with DateKey.round before the signature (ERR_ROUND_MISMATCH), to stop a valid signature of another round (§17).
2.4 Release verification: message and hash to G1 (§51, §63 step 10, paragraph «Mensaje de ronda y hash a G1»)
Step 10 first validates a release object in hand (its size, type and version, and schema; §47.1, section 8.1) and compares its chain_hash with the pinned profile's (ERR_PROFILE_MISMATCH, v0.15); then the round (ERR_ROUND_MISMATCH); then the signature (ERR_RELEASE_INVALID). For round n of Quicknet:
M = SHA-256(uint64_be(n)) ; 32 bytes; no previous signature
valid ⇔ e(H(M), public_key) == e(signature, G2)
- e is the pairing G1 × G2; G2 here is the generator of G2.
- H is RFC 9380 hash_to_curve to G1 with suite
BLS12381G1_XMD:SHA-256_SSWU_RO_and the 43-byte ASCII DSTBLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_(expand_message_xmd with SHA-256, hash_to_field to two Fp elements with L = 64, simplified SSWU to the 11-isogenous curve, the isogeny, and clearing by h_eff = 0xd201000000010001). - Signature and public key are decoded with §12.2 first; neither may be the identity.
- The same H(M) is the IBE identity of round n used by tlock (step 11).
- Vectors:
testdata/vectors/tlock_steps.jsongives M, H(M) and the signatures of rounds 1000, 1001, 1004 and 2000, and checks that the same signature fails with the G2 DST or with the round not hashed (§64).
2.5 tlock decryption: the IBE (§35, §63 step 11, paragraphs «Serialización de GT en H2» and «H3 y H4»)
The tlock stanza of OUTER_TIME_AGE has exactly two arguments after its type: the round, in canonical decimal, and the chain hash, in 64 lowercase hex characters, compared as exact byte strings (§35, §63 step 8). Its body is the IBE-CCA ciphertext (Boneh–Franklin, as in drand/kyber encrypt/ibe):
body = U || V || W |U| = 96 (a G2 point, canonical, not the identity), |V| = |W| = 16
sigma = V XOR H2(e(signature, U))
FK_TIME = W XOR H4(sigma)
r = H3(sigma, FK_TIME)
check r · G == U (G the generator of G2)
-
H2(x)= first 16 bytes of SHA-256("IBE-H2" || x), with x the 576-byte serialisation of the GT element: the kilic/bls12-381 order, highest-degree coefficient first at each level of the tower Fp2 = Fp[u]/(u² + 1), Fp6 = Fp2[v]/(v³ − (u + 1)), Fp12 = Fp6[w]/(w² − v), each Fp element in 48 bytes big-endian (Fp12 → c1 || c0; Fp6 → c2 || c1 || c0; Fp2 → c1 || c0). Vector: H2(e(G1, G2)) =cb87319f24560b5231579a09ad79f12e(tlock_ibe.json). noble'sFp12.toBytesorder gives a different H2. -
H4(sigma)= first 16 bytes of SHA-256("IBE-H4" || sigma). -
H3(sigma, FK_TIME):q = 0x73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001 base = SHA-256("IBE-H3" || sigma || FK_TIME) for i = 1, 2, …, 65534: d = SHA-256(uint16_le(i) || base) d[0] = d[0] >> 1 ; shift of the first byte only if int_be(d) < q: r = int_be(d); stop if no i succeeds, the check r·G == U failsThe tags are the raw ASCII bytes
IBE-H2,IBE-H3,IBE-H4, with no length or terminator. The shift is not the same as clearing the top bit of d; doing that gives another r and no capsule opens. One stanza oftlock_steps.jsonaccepts on its fourth attempt where clearing the top bit would accept the second. -
Any failure (body length, non-canonical or identity U, failed check, file key not 16 bytes) is
ERR_INTEGRITYat step 11. -
FK_TIME, 16 bytes, is the age file key of OUTER_TIME_AGE, untransformed; age then verifies the header MAC and derives the payload key (§35).
-
Nothing in the stanza selects the public key, the scheme, the DST or the tags: they come from the pinned profile and the specification (§35).
3. The .dkc capsule
3.1 Framing (§22, §23)
offset size field
0 4 MAGIC = "DKC1"
4 1 VERSION = 1, 2 or 3 (the capsule format)
5 1 FLAGS = 0
6 2 RESERVED = 0
8 4 PUBLIC_HEADER_LEN (1 .. 1 MiB)
12 4 SEALED_CONTROL_LEN (1 .. 64 MiB)
16 ... PUBLIC_HEADER (deterministic CBOR)
... ... SEALED_CONTROL = the bytes of OUTER_TIME_AGE
... EOF PAYLOAD_AGE (no length field: to end of file)
PRELUDE is the first 16 bytes. A writer MUST write format 3; a reader MUST accept 1, 2 and 3 (§22, §70). A change of capsule semantics that an older reader would only detect after fetching the release requires a new format number, so that the older reader rejects it at step 2 without network (§22).
| Format | INNER_ACCESS_AGE (time_and_key) | PAYLOAD_AGE plaintext | CONTROL_CBOR schema version |
|---|---|---|---|
| 1 (v0.8.2) | one or more stanzas | the content | 1 |
| 2 (v0.9) | exactly 16 stanzas | content + zero padding | 2 |
| 3 (v0.10+) | exactly 16 stanzas | BODY + zero padding | 3 |
3.2 PUBLIC_HEADER (§24, §24.1, §25)
0 → "datekeycap" 1 → 1 2 → capsule_id (16 random bytes, CSPRNG, §21)
3 → compact_datekey (canonical dk1_) 4 → access_policy (0 time_only, 1 time_and_key)
5 → critical_extensions 6 → noncritical_extensions
The profile comes only from the DateKey (§24). It contains nothing about padding, the number of credentials or the format 3 content. The public note (datekeys.note, §24.1) is an optional non-critical header extension: UTF-8 text, 1 to 1024 bytes, readable by anyone before the date, unverified, bound to the control only at step 15.
3.3 Three age files and three file keys (§28, §28.1)
PAYLOAD_AGE file key FK_PAYLOAD
INNER_ACCESS_AGE file key FK_ACCESS (time_and_key only)
OUTER_TIME_AGE file key FK_TIME
Each file generates its own random 16-byte file key; none is reused (§28), and they MUST be independent (§62). Each MUST be a complete age v1 file with the C2SP header grammar (at least one stanza; strict Base64; LF only; single spaces between arguments) (§28.1).
3.4 Policies and nesting (§32, §33, §34, §36)
time_only: OUTER_TIME_AGE = age(tlock recipient for the DateKey, plaintext = CONTROL_CBOR)
time_and_key: INNER_ACCESS_AGE = age(16 X25519 recipients, plaintext = CONTROL_CBOR)
OUTER_TIME_AGE = age(tlock recipient, plaintext = exact bytes of INNER_ACCESS_AGE)
SEALED_CONTROL = exact bytes of OUTER_TIME_AGE
- OUTER_TIME_AGE MUST have exactly one stanza, of type tlock (§32). PAYLOAD_AGE MUST have exactly one stanza, X25519, for R_PAYLOAD (§29).
time_and_keyis TIME AND KEY, one nested construction, not two parallel envelopes (§33).- After opening OUTER (§36): for
time_only, the result MUST be a canonical CONTROL_CBOR (a result starting with the age version line isERR_POLICY_STRUCTURE_MISMATCH); fortime_and_key, a well-formed age file with 16 X25519 stanzas in formats 2 and 3, and no two stanzas with the same single argument (the ephemeral share). - Cardinality rules are a MUST and are enforced inside the identity that unwraps each file, over the complete set of stanzas, not merely because age could unwrap a key (§27, §63). Pre-unlock inspection of the visible stanzas is a SHOULD (§27, §63 steps 5, 6, 8).
3.5 CONTROL_CBOR (§31)
0 → "datekeys-control" 1 → schema version = the format (1, 2 or 3)
2 → header_binding (32 bytes) 3 → payload_identity (I_PAYLOAD, 32 raw bytes)
4 → critical_extensions 5 → noncritical_extensions
6 → payload_length: L as an 8-byte big-endian byte string (versions 2 and 3)
7 → padding code: 1 bloque256, 2 reforzado (versions 2 and 3)
L has a fixed 8-byte encoding so that the length of CONTROL_CBOR, visible through SEALED_CONTROL_LEN, does not depend on L. Without extensions it is 103 bytes (§31). L ≤ L_MAX = 2⁵³ − 2⁴⁶. L and P are not frame lengths; a reader MUST NOT allocate by them before the plaintext arrives (§31, §57).
3.6 Bindings
- header_binding (§26):
SHA-256(PRELUDE || PUBLIC_HEADER_BYTES), over the exact stored bytes, never re-serialised. It is inside CONTROL_CBOR and checked at step 15 (ERR_HEADER_BINDING). It includes VERSION, so relabelling the format fails at step 14 or 15. It gives internal coherence, not authorship: anyone can compute it from public bytes and seal another control to the same DateKey (§36.1, §55.1). - Control ↔ payload (§29, §30.1): CONTROL_CBOR carries the X25519 identity I_PAYLOAD; PAYLOAD_AGE is encrypted to its public key R_PAYLOAD. CONTROL_A + PAYLOAD_AGE_B fails because I_PAYLOAD_A cannot unwrap FK_PAYLOAD of B. I_PAYLOAD MUST come from a CSPRNG, fresh per capsule, never reused or derived (from the content,
capsule_id, I_ACCESS or a master secret). The specification gives the reasons: a shared I_PAYLOAD lets the opening of one capsule open another early; a derived one lets content guesses be checked before the date (§29). - In formats 2 and 3, L and the padding code fix the plaintext length and padding (step 17): this gives determinism, not authenticity (§30.1).
- age's header MAC commits to the file key; it authenticates only against whoever does not know the file key. Once the round is published, anyone can compute FK_TIME and recompute the MAC of OUTER_TIME_AGE (§27, §55.1).
3.7 Slots and decoys (§37, §38, §39)
In formats 2 and 3, time_and_key:
- INNER_ACCESS_AGE MUST contain exactly 16 X25519 stanzas, one per slot; there are 1 to 16 credentials, no repeats.
- Each unused slot holds a decoy: the public key of a fresh X25519 identity generated with a CSPRNG and discarded at once. Its private key MUST NOT be stored, logged or delivered. A decoy wraps the same FK_ACCESS, so its private key would open the capsule while it exists (§39).
- The order of the 16 stanzas MUST be a uniformly random permutation from an unbiased CSPRNG (for example Fisher–Yates with rejection sampling), independent of which slots are credentials. The permutation and which slots are decoys MUST NOT be stored or delivered, except in official test vectors.
- Indistinguishability rests on age X25519 recipient anonymity under Diffie–Hellman in X25519: a stanza holds a fresh ephemeral share and the wrapped key; the recipient does not appear; each stanza is 98 bytes. Not even a holder of FK_ACCESS can tell a decoy from a credential (§39).
- A writer MUST reject X25519 recipients that are non-canonical (bit 255 set, or u ≥ 2²⁵⁵ − 19) or of low order; it MAY reject twist points (§37).
- Portable key:
I_ACCESS= 32 random bytes, generated for onecapsule_idonly and never reused (§38).
3.8 Payload padding (§29.1)
Formats 2 and 3: PAYLOAD_PLAINTEXT = content || 0x00^(P − L), P = rule(L), the code sealed in CONTROL_CBOR.
L ≤ 256: P = 256 (both rules)
bloque256: P = 256 · ceil(L / 256)
reforzado: P = max(bloque256(L), Padme(L))
E = bitlen(L) − 1; S = bitlen(E); lastBits = E − S; mask = 2^lastBits − 1
Padme(L) = (L + mask) AND NOT mask
L_MAX = 2^53 − 2^46
- Padmé is from Nikitin et al. (PURBs, PoPETs 2019). There is no "no padding" code; the SDK SHOULD use code 2. A new code requires a new format.
- Integer-only arithmetic is mandated: no floating log2, no 32-bit operations (the specification gives the first L where each fails).
- The visible length of PAYLOAD_AGE is
184 + P + 16·max(1, ⌈P / 65536⌉)and reveals P, not L. Above L = 8192, a P thatreforzadocannot produce revealsbloque256(§29.1, §55.2). - Step 17 checks that the plaintext is exactly P bytes and that bytes L to P − 1 are zero (
ERR_INTEGRITY).
4. Format 3 content (§29.2 to §29.7)
4.1 BODY (§29.2)
PAYLOAD_PLAINTEXT = BODY || 0x00^(P − L), L = |BODY|
BODY = AREA_LEN (uint32 BE) || SECURITY_LEN (uint32 BE) || HEAD_LEN (uint32 BE)
|| SECURITY_CBOR || 0x00^(AREA_LEN − SECURITY_LEN)
|| HEAD_CBOR
|| CONTENT (the files concatenated in head order)
AREA_LEN = 512·k, 1 ≤ k ≤ 128; 1 ≤ SECURITY_LEN ≤ AREA_LEN; 1 ≤ HEAD_LEN ≤ 16 MiB
- A writer of v0.14 or later MUST write AREA_LEN = 32768, signed or not, or 65536 only if the creator expressly widens it because signatures do not fit. So P does not depend on whether the capsule is signed, except with a widened area (§29.2, §55.2). v0.10 writers wrote 512. A reader accepts any valid AREA_LEN.
- The 32 KiB size is provisional until measured with real signatures (§74, §75 item 13).
4.2 Head (§29.4 to §29.6)
HEAD: 0 → "datekeys-head" 1 → 1 2 → salt (32 bytes, CSPRNG, fresh)
3 → comment 4 → declared_author 5 → files 6, 7 → extensions
file: 0 → path 1 → size 2 → start 3 → end (exclusive) 4 → sha256 5 → mtime (optional)
- The salt makes
SHA-256(HEAD_CBOR)a hiding commitment; per-file SHA-256 values are unsalted (§29.4). - 1 to 65 535 files, strictly ascending by UTF-8 bytes of
path; contiguous layout checked by subtraction; each file's SHA-256 checked at step 17.7. - Paths (§29.5, rules R1 to R10, R4b) prevent writing outside the target folder and collisions on Windows, macOS and Linux and in ZIP, using pinned Unicode 18.0.0 and WindowsBestFit tables (§29.5.1), never the platform's Unicode functions.
- Text rules (§29.6) forbid controls, bidi controls and invisible characters outside a whitelist in the comment and the declared author.
- The declared author, comment, paths and mtimes are creator text and prove nothing (§36.1, §55.1).
4.3 The security area (§29.3)
SECURITY_CBOR: 0 → "datekeys-security" 1 → 1
2 → author_signature (bstr holding a separately encoded map)
3 → seal (bstr holding a separately encoded map)
author-signature: 0 → alg 1 → public key (alg 1) or SIGNERS (alg 2) 2 → signature
seal: 0 → seal_type 1 → token
securitynever decides opening: no failure of it has an error code, stops step 17 or changes another code. Its only output is verdicts (§29.3, §29.7).- Defined:
alg1 (strict Ed25519),alg2 (CMS with X.509),seal_type2 (RFC 3161). Reserved:seal_type1 and 3;algandseal_type4294967295 for tests. Withalg2 the seal is inside the signature and key 3 MUST NOT exist. - Verdicts: X (area unreadable), F0–F6 for the signature, S0–S5 for the seal, with fixed texts the official SDK MUST use (§29.7). A reader MUST NOT say a capsule was signed before the date except by a valid seal with t + accuracy < round_time.
4.4 What the author signature covers (§29.8)
payload_commit = SHA-256("datekeys:dkc3:payload:v1" || 0x00 || I_PAYLOAD)
CONTROL_SIG = CONTROL_CBOR with the 32 bytes of key 3 replaced by payload_commit
and the 8 bytes of key 6 (L) set to zero
control_commit = SHA-256("datekeys:dkc3:control:v1" || 0x00 || CONTROL_SIG)
head_digest = SHA-256("datekeys:dkc3:head:v1" || 0x00 || HEAD_CBOR)
signers_digest = SHA-256("datekeys:dkc3:signers:v1" || 0x00 || uint32_be(alg) || SIGNERS)
D = SHA-256(control_commit || head_digest || signers_digest)
AUTHOR_MESSAGE = "datekeys:dkc3:author-signature:v1" || 0x0A || hex(D) || 0x0A ; 99 bytes
code = first 8 hex chars of D, as "xxxx-xxxx"
- CONTROL_SIG covers header_binding, hence PRELUDE and PUBLIC_HEADER (the public note included), the padding code and the control extensions, and hides I_PAYLOAD.
- L is excluded and the area is never covered, so the area can be widened after signing without re-signing. File sizes are covered through the head.
- head_digest fixes CONTENT byte by byte (sizes and SHA-256 per file). The head salt hides the content from whoever sees only AUTHOR_MESSAGE, such as an external signing application.
- SIGNERS is empty with
alg1. - Transplants:
control_commitcoverscapsule_id, the DateKey andpayload_commit, so a signature is not valid in another capsule; each value has its domain prefix. - All values are recomputed from the opened capsule; none is stored.
- What a valid signature proves: that its signers signed this head for this control and this signer list. Not when, unless sealed; not authorship of the content; not the declared author; not that no other capsule with the same
capsule_idexists (equivocation, §7.9).
4.5 alg 1: strict Ed25519 (§29.9, §29.12)
author-signature = {0: 1, 1: A (32 bytes), 2: R || S (64 bytes)}. Valid if and only if:
- A is canonical (y < p; if y is 1 or p − 1, the sign bit is 0);
- A is not of small order;
sig[63] & 0xE0 = 0and S < ℓ;- [S]B − [k]A encodes exactly R, with k = SHA-512(R || A || AUTHOR_MESSAGE) mod ℓ.
This is cofactorless RFC 8032 verification with canonical A and R; an implementation MUST NOT accept more. The specification notes that Go 1.26 ed25519.Verify and noble 2.4 verify each need extra checks. ed25519_strict.json gives the expected result for each case of "Taming the many EdDSAs". Author keys are bech32 (dkauthor1… public, DKAUTHOR-SECRET-KEY-… secret); a secret key file is age-encrypted with scrypt logN 16 by default. A reader learns the expected key out of band; it MUST NOT offer to save a key from a capsule (§29.12).
4.6 alg 2: CMS / CAdES with certificates (§29.10)
author-signature = {0: 2, 1: SIGNERS, 2: ContentInfo SignedData in DER}; detached signature of AUTHOR_MESSAGE; one or more co-signers.- SIGNERS: a CBOR array of 1 to 16 SHA-256 hashes of each signer's certificate, strictly ascending, all required signers listed (CMS does not order signers; a partial list would allow silent removal). It is closed before the first signature and lives in the area, not the control, because SEALED_CONTROL_LEN is visible.
- Form (F1 on failure), in order: DER throughout with sorted SET OF; detached
id-data; each SignerInfo identifies exactly one certificate meeting the certificate profile;signedAttrswith exactly onecontent-type,message-digestandsigning-certificate-v2; at most one unsignedsignature-time-stamp(CAdES-T). Further field-level rules on versions, attribute counting, ESSCertIDv2, RSASSA-PSS parameters and OID comparison by DER bytes. - Closed algorithm table: SHA-256/384/512; RSASSA-PKCS1-v1_5 and RSASSA-PSS (2048–4096-bit keys, odd modulus, odd exponent 3 to 2³¹ − 1); ECDSA on P-256, P-384, P-521, uncompressed keys. No brainpool, GOST or SM2.
- A field-by-field certificate profile; the certificate's own signature and extensions other than those listed decide nothing.
- Per required signer: absent → not verifiable → invalid → no seal → invalid seal → certificate not valid at seal time t → valid. Verdict: F2 if any is invalid; F5 if any is absent, not verifiable, unsealed, badly sealed or out of validity, or if key 3 exists; F6 if all valid.
- DateKeys never checks who issued a certificate or seal, revocation, or qualification; an official validator does. The capsule keeps the evidence (signature, certificate, seal, OCSP response) for later validation.
- The table and the certificate profile are provisional (§74).
4.7 RFC 3161 seal (§29.11)
| Capsule | Seal | Over |
|---|---|---|
with alg 2 |
CAdES-T inside the signature, one per signer | that signer's signature value |
unsigned or alg 1 |
optional, key 3, seal_type 2 |
SEAL_SUBJECT |
SIG_PART = 0x00 ; no key 2
| 0x01 || SHA-256("datekeys:dkc3:sig-part:v1" || 0x00 || content of key 2)
SEAL_SUBJECT = SHA-256("datekeys:dkc3:seal-subject:v1" || 0x00
|| control_commit || head_digest || SIG_PART)
messageImprint = SHA-256(SEAL_SUBJECT) ; the TSA never sees SEAL_SUBJECT
Token profile, in order: form (S2), algorithms (S1), verification (S3). t = genTime, precision = accuracy or 0. S4 if t + precision < round_time, S5 otherwise. A seal never changes the signature verdict. Seals outside the capsule (OpenTimestamps, archive re-seals RFC 4998) are not defined.
5. Key of words (§38.1)
A word key is an X25519 credential of time_and_key derived from words, one of the 16 credentials; the format does not change.
normalise: NFD (Unicode 18.0.0 tables) → drop U+0300..U+036F → simple lowercase per code point
→ split on a fixed list of space characters, no empty words
P = words in UTF-8 joined by U+0020
S = "DateKeys llave de palabras v2|" || hex(chain_hash) || "|" || decimal(round) || "|" || hex(capsule_id)
id = PBKDF2-HMAC-SHA256(P, S, 600000 iterations, 32 bytes) ; an X25519 identity; its public key is the recipient
- Writer MUST require at least 6 words after normalisation and reject controls, default-ignorable and unassigned code points. SHOULD count only distinct words of 3 or more characters, show the normalised words, warn against password reuse, and (since v0.14) offer by default random words from a public list (at least 6 from 2048 or more, about 66 bits) and say that human-chosen words are not suitable for valuable content.
capsule_idin the salt makes the same words give a different key per capsule, so each guess tests one capsule.- After the date, anyone holding the
.dkccan test words offline (§38.1, §55.2). - Vector: «perro luna casa verde tren mar», Quicknet chain hash, round 1000,
capsule_id000102…0f→id=fceec4d8ca8de86c85a1f26ed49f82a2b38431bd0ce36db995ae7dfd49b96e41.
6. The .dkk access key (§40 to §44)
DKK1 prelude: "DKK1" | VERSION 1 | FLAGS 0 | RESERVED 0 | BODY_LEN (1 .. 16 MiB) | BODY_CBOR
BODY_CBOR: 0 → "datekeys-access-key" 1 → 1 2 → credential_id (16 random bytes)
3 → capsule_id 4 → access_type "x25519" 5 → access_material (32 raw bytes)
6 → verification_metadata {0 → capsule_digest = SHA-256(exact .dkc)} 7, 8 → extensions
- The
.dkkhas no MAC or signature (§55.1).capsule_digestis for fast failure and UX, not a security property needed for access (§43). - Its errors are reported only at step 9.a, and never for
time_only(§63). - Extensions of a
.dkkare informative only: no security decision may rest on them (§44, §72).
6.1 Locator and envelope: datekeys.capsule (§44.1)
A non-critical .dkk extension says what the capsule is and, optionally, where it is:
data: 0 → note (copy of the public note) 1 → compact_datekey 2 → locator (bstr, optional)
locator: an age file with one tlock stanza for the DateKey's round and chain; plaintext:
0 → addresses (1..8, each {0 → URI, 1 → offset}) 1 → I_SOBRE (32-byte X25519 identity)
2 → envelope header (1..1024 bytes) 3 → rest_digest = SHA-256(rest) 4 → rest_size
5 → capsule_digest 6 → zero padding
plaintext length: exactly 4096, or the smallest multiple of 4096 that fits
- Envelope: the creator age-encrypts the
.dkcto the public key of a fresh I_SOBRE and splits the age file: the header (through the MAC line) goes into the locator; the rest (nonce and STREAM chunks) is the only thing stored outside. The rest has no marker and is indistinguishable from random. Nobody reads the locator before the date; the same release opens it. The rest may be appended to a host file (concealment, not steganography). - Addresses: ASCII RFC 3986 URIs,
httpsoripfs, read without decoding. A reader MUST reject percent-escapes in the authority,@, backslashes, bad ports, dot segments (also%2e), non-LDH hosts, IP literals that are not public (an explicit list of IANA special-purpose IPv4 and IPv6 blocks; IPv6 with embedded IPv4 rejected), numeric or hex-looking names, single-label and local names (localhost,local,home.arpa,internal,invalid,test,example,onion), and non-canonical CIDv1. - A reader MUST NOT download without the person asking and MUST show the host or CID first; MUST request only the rest's bytes; MUST NOT follow redirects to another scheme or a rejected host; MUST check on every connection that the resolved IP is public. Since v0.13 a resolved address in
64:ff9b::/96, or in the network's NAT64 prefix (RFC 6052, RFC 7050) under stated conditions, counts as public only if its embedded IPv4 is public; a NAT64 address written in the locator is still rejected. - The reader MUST check
rest_digest, decrypt with I_SOBRE and checkcapsule_digestbefore use. These digests protect against whoever stores the rest, not against whoever wrote the.dkk. A locator whose round or chain differs fromcompact_datekeyis unusable. A.dkkis matched to its capsule bycapsule_id(andcapsule_digest), never by the note or the extension's DateKey.
7. Decryption flow and error precedence (§63, §69.1)
1–2 PRELUDE: magic, version (format), flags, reserved, lengths
3–4 PUBLIC_HEADER: layers of §69.1; canonical DateKey; pinned profile; critical extensions
5 read SEALED_CONTROL (MUST); SHOULD inspect OUTER header: one tlock stanza
6 SHOULD inspect PAYLOAD_AGE header: one X25519 stanza
7 resolve the time condition locally, round_time bound
8 SHOULD check the tlock stanza arguments (round, chain hash) as exact strings
9 no network: (a) a .dkk as an object, then its link to this capsule; (b) time_and_key
without a credential → ERR_ACCESS_REQUIRED; (c) only before a network request:
now < round_time → ERR_RELEASE_UNAVAILABLE (a release in hand is not compared
with the clock, v0.15); then fetch; a network source verifies each response with
step 10 and discards bad ones; no release → ERR_RELEASE_UNAVAILABLE
10 release in hand: release object layers (ERR_NON_CANONICAL_CBOR, ERR_UNSUPPORTED_VERSION)
or unreadable drand JSON (ERR_RELEASE_INVALID); chain_hash vs pinned profile
(ERR_PROFILE_MISMATCH, v0.15); round (ERR_ROUND_MISMATCH); canonical signature
and BLS (ERR_RELEASE_INVALID)
11 open OUTER: exactly one tlock stanza; IBE decryption; r·G == U; age MAC
12 structure vs access_policy; 16 stanzas in formats 2 and 3
13 time_and_key: every identity against every stanza; an identity unwrapping two stanzas →
ERR_POLICY_STRUCTURE_MISMATCH; none → ERR_ACCESS_INVALID
14 CONTROL_CBOR: version = format; keys 6 and 7; critical extensions
15 header_binding → ERR_HEADER_BINDING
16 I_PAYLOAD, L, code, P
17 open PAYLOAD_AGE: exactly one X25519 stanza; length P; zero padding;
format 3: 17.1 decrypt to EOF, 17.2 frame, 17.3 security (no code), 17.4 head,
17.5 last end = C, 17.6 verdicts (no code), 17.7 file SHA-256, 17.8 padding
18 commit only if 17 passed (§56: atomic output; nothing partial presented as valid)
- Precedence (§69.1): within one object, the first failing layer decides: (1) frame; (2) type tag and schema version, read first; (3) CBOR profile and CDDL; (4) fields with their own code, in key order. Between objects, the order of steps decides. The goal is that the reported code does not depend on the implementation's internal validation strategy. Optional checks (pre-unlock inspection;
capsule_digest) may change when a failure is reported; official vectors assume they are done. - In format 3, a failure of age after its header or a plaintext length other than P prevails; a reader may stop early only with
ERR_INTEGRITYwhen that is already the final code; any other code of step 17 is reported only after reading PAYLOAD_AGE to EOF (§63 step 17). - 19 normative error codes (§69).
8. The release object and long-term recovery (v0.15: §45 to §50, §53, §62.1, §63 steps 9 and 10, §79)
8.1 The release object (§47.1)
The release of a round as data: the answer of the Release API (§45), the release_material of a Release Cache (§47), and a release the caller gives from a file or from a release archive. It has no file extension of its own; its type tag identifies it.
Deterministic CBOR (profile of §58), no frame, 1 to 1024 bytes:
0 → "datekeys-release" 1 → 1 2 → chain_hash (32 bytes)
3 → round (1 .. 2^53 − 1) 4 → signature (1..96 bytes; 48 in Quicknet)
round 1000 of Quicknet: 111 bytes (testdata/releases/1000.cbor)
- All five keys are required. The chain is named by its
chain_hash, drand's identifier, the same as in the tlock stanza; the object carries neitherprofile_hashnorprofile_id. - Validation, all at step 10, with the layers of §69.1: an empty input or one over 1024 bytes →
ERR_NON_CANONICAL_CBOR, before decoding (no valid encoding exceeds 165 bytes); another type tag →ERR_NON_CANONICAL_CBOR; a version other than 1 →ERR_UNSUPPORTED_VERSION; CBOR profile and schema →ERR_NON_CANONICAL_CBOR. There are no layer-4 fields:chain_hash, round and signature are compared in that order at step 10 (ERR_PROFILE_MISMATCH,ERR_ROUND_MISMATCH,ERR_RELEASE_INVALID). A signature of the wrong length for the scheme passes the schema and isERR_RELEASE_INVALID. - A reader MAY decode it on receipt, but MUST report its errors only at step 10, after steps 1 to 9: a capsule without credentials still gives
ERR_ACCESS_REQUIRED(§69.1). - Trust: the object is not signed and has no "verified" field. For one round and one public key there is a single valid BLS signature, so any copy that verifies at step 10 is the release, whatever its source.
- drand's JSON,
{"round": …, "signature": "…"}, SHOULD also be accepted as caller input (an input whose first non-whitespace byte is{). It names no chain, so step 10 compares nochain_hash;randomness, if present, MUST be the SHA-256 of the signature; an input over 8192 bytes or unreadable →ERR_RELEASE_INVALIDat step 10, before the round. A Release Cache and the Release API MUST store and serve the release object, never this JSON. - Vectors:
release.json,releases/<round>.cbor.
8.2 Two kinds of source, and step 9.c (§49, §63 step 9)
- Network source: a drand relay, the Release API, a cache service or a remote archive. Each request is observable and reveals the round. The source verifies every response with the rules of step 10 and discards bad ones; if none delivers,
ERR_RELEASE_UNAVAILABLEat step 9. It is not asked beforeround_time(step 9.c), unless the person expressly asks (MAY), because the clock may be wrong. - Release in hand: given by the caller without network: a release object or drand's JSON from a file, or the entry of a local archive. It is not compared with the local clock (v0.15). If its
round_timeis after the local time, the reader MAY warn that the clock may be behind. A release in hand that fails step 10 gets the codes of step 10. - The reasoning recorded in §76 (v0.15 change 3): the reasons for 9.c are network reasons (no useless or observable requests; fail early). A valid signature of a future round is only possible if drand is compromised (§7.6), and then the clock no longer protects confidentiality; the clock had been vetoing a verifiable release on a local value nobody can verify (for example a dead CMOS battery or a virtual machine).
- This is the one verdict v0.15 changes: a valid release in hand opens a capsule with a clock behind its round time, where v0.14 gave
ERR_RELEASE_UNAVAILABLEat step 9 (§70). With a network source nothing changes. A v0.14 reader does not read a release object but does read drand's JSON for the same round.
8.3 Long-term recovery: archives and cache services (§50, §53)
- A capsule decades ahead needs three things kept: the
.dkc(the protocol does not store it, §6), the rest of an envelope stored outside, if any (§44.1), and the release of its round. The release is the only one that does not exist at sealing time; and once it exists the capsule can already be opened, so a release saved next to one capsule only helps to reopen it. - So long-term recovery rests on release archives with all rounds of the chain, local or remote, and on cache services, from DateKeys or others, that keep the releases of all rounds continuously and serve them; not on the release of one capsule. Keeping only the rounds of known capsules would reveal their dates and is not recommended.
- The reader asks for its round and verifies it with the pinned key at step 10, without trusting who serves it. A remote archive or cache service is a network source and learns the round; a local archive does not.
- The specification promises no published archive or service and does not say where they are hosted; a project archive or cache service and its hosting are future work (§74).
- Archive format (informative, no error codes of its own): a Deterministic CBOR header
{0: "datekeys-release-archive", 1: 1, 2: chain_hash, 3: first round, 4: number of rounds}, followed by the signatures of consecutive rounds, n bytes each (48 in Quicknet); the signature of round r starts at |header| + (r − first round)·n; the file is exactly |header| + number·n bytes; a missing round is n zero bytes. An entry becomes a release object with the header'schain_hashand is verified at step 10. An archive of another chain, of another length, or without the round delivers nothing:ERR_RELEASE_UNAVAILABLEat step 9. Size for Quicknet: 10 512 000 rounds a year, about 505 MB of signatures a year, which do not compress, about 42 MB a month. Vector:releases/archive_1000_1004.bin(rounds 1002 and 1003 zero). - The rejected alternative (§76, v0.15 change 4): a
.dkrrelease file kept next to the capsule, and next to a.dkkwith a locator, with a writer rule to fetch and store it after the date. The author removed it on 7 October 2026 for the reasons above; the.dkkextensiondatekeys.releasewent with it (change 6).
8.4 Annex: opening a capsule without DateKeys software (§79, informative)
- It says how to open a Quicknet capsule if no DateKeys software exists: the Quicknet parameters (chain hash, public key, genesis time, period, q, DST), the
.dkcframe and header, the release (object, archive entry or drand JSON) and its BLS check, the tlock stanza and FK_TIME (H2, H3, H4 as in §63), opening an age file from its file key (HKDF, the header HMAC, STREAM with ChaCha20-Poly1305), the inner layers (age -dwith the identity of a.dkk, a recipient or a key of words; the Bech32 encoding of an X25519 identity), and the content of each format. - Needed: the
.dkc, the release, a credential intime_and_key, a BLS12-381 library with pairing and the RFC 9380 hash to G1, SHA-256, HMAC-SHA256, HKDF-SHA256, ChaCha20-Poly1305, a CBOR decoder, andage. drand's tools do not serve:tlefetches the release from the network and accepts no given release, andageaccepts no file key, which is why the annex describes those two steps in full. - What it does not check (§79.8): it checks what decides that the result is correct (the release signature, r·G2 == U, every age MAC and every file's SHA-256), but not canonical encodings,
header_binding, the 16 stanzas, the zero padding or the path rules. A capsule a conforming reader would reject may open by the annex; its content is what the age MACs sealed, without the guarantees of a conforming reader. - The official SDK SHOULD keep the annex text next to each
.dkc(§62.1 rule 27); it contains no data of any capsule. - Proof:
scripts/recovery_check.sh(part ofscripts/check.sh) opensformat3_single(time_only) andformat3_time_and_key_portable(time_and_key) withscripts/recovery, which imports no DateKeys package, nordrand/tlockordrand/drand(it usesdrand/kyberanddrand/kyber-bls12381for BLS,filippo.io/ageandgolang.org/x/crypto), and compares the recovered BODY with the fixtures (artifacts.mdsection 2).
9. Canonical CBOR and limits (§57, §58, §58.1)
- Deterministic CBOR (RFC 8949 §4.2.1) restricted to major types 0, 2, 3, 4, 5; unsigned integer keys in strictly ascending order; shortest forms; definite lengths; closed maps; valid UTF-8. Negative integers, tags, floats and simple values are rejected. Decoders re-encode and compare. Every integer ≤ 2⁵³ − 1.
- Absent optional fields MUST be omitted;
{},[],"",h''never mean absence (§58.1). - Extensions: one mechanism for PUBLIC_HEADER, CONTROL_CBOR,
.dkkand head;datais a non-empty opaquebstr; 1 to 64 per array, strictly ascending by UTF-8 bytes; unknown critical → reject; an extension outside the objects and arrays it is registered for is treated as unknown (§54, §72). A security-relevant extension MUST be in CONTROL_CBOR or covered by a signature extension (§72). - Frame limits are MUST for encoders and decoders (§57). Some field limits are implementation limits of the reference only, listed in §74.
10. Writer rules (§61, §62, §62.1)
Selected MUST rules: write format 3 only; reject an unlock instant not after the writer's clock (the specification notes that a clock running late can still seal to an already published round, rule 2); 1 to 16 credentials, canonical and not low order; 16 slots with decoys in random order; all randomness from a CSPRNG, fresh per capsule (capsule_id, I_PAYLOAD, I_ACCESS, credential_id, decoys, permutation); know L before sealing; write the exact SEALED_CONTROL_LEN (resolved by sealing a provisional control of the same length, rule 7); the 32 KiB area; a fresh head salt; self-decoding of CONTROL_CBOR, HEAD_CBOR and SECURITY_CBOR with reader rules (rule 17); verify every signature and seal before writing (rule 19); deliver AUTHOR_MESSAGE as text with its code before each signature (rule 20); never store I_PAYLOAD, CONTROL_CBOR or content on disk while waiting for signatures (rule 25). SHOULD: self-check after sealing (rule 11) and erase secrets (rule 12); since v0.15, warn when sealing that opening years later needs the .dkc, the .dkk in time_and_key, and the release of the round, which exists only after the date (rule 26), and keep the text of the recovery annex next to the .dkc (rule 27).
11. What the reader should check against the code
- The reference implementation implements no cryptography itself: age is
filippo.io/age, tlock isdrand/tlock(exported core), BLS is drand/kyber onkilic/bls12-381, signatures use the Go standard library; CBOR, DER and the CMS/X.509/RFC 3161 reader are the module's own code (datekeys-go/SECURITY.md). - The TypeScript and Dart implementations implement the tlock IBE themselves: TypeScript in
ibe.ts, derived fromtlock-js, on@noble/curves2.4.0; Dart in pure Dart onBigInt, not constant-time, a choice the author accepted (datekeys-dart/README.md). - The release object is the module's own code (
provider/release.go, on its CBOR codec); a release in hand reachescapsule.Openthroughprovider.Supplier, anddatekeys decrypt -releasereads a release object, drand's JSON or a local archive (docs/traceability.md, rows §47.1 and §49).scripts/recovery, the program behind the annex of §79, deliberately uses none of the module's code.