10 KiB
datekeys-go
Reference implementation in Go of the DateKeys Protocol Specification
v0.9 (spec/).
Versión en español.
DateKeys encrypts data so that it can only be opened after a chosen instant. The time condition comes from the drand Quicknet randomness beacon: data is sealed with timelock encryption to a future round, and the round's BLS signature, published by drand when the round arrives, is the key. Everything that can be verified locally is verified locally; relays, caches and APIs are untrusted transports.
Status: v0.x, pre-standard. The specification is a draft and the API may change before v1.0.0. The code has not had an external cryptographic review yet (spec §75). Do not rely on it for high-value secrets.
What it implements
| Object | Spec | Package |
|---|---|---|
DateKey: date → round, canonical dk1_… string |
§14–§19 | datekey |
Provider Profile, pinned Quicknet profile, profile_hash |
§10–§13 | profile |
| Release sources, local BLS verification, drand relays | §45–§52 | provider, provider/drand |
DateKeyCap .dkc: time_only and time_and_key |
§20–§39, §61–§63 | capsule |
DateKeys Access Key .dkk |
§40–§44 | accesskey |
| Extensions | §54 | extension |
| Deterministic CBOR | §58 | codec |
| Normative errors | §69 | errors.go |
| CLI | — | cmd/datekeys |
No cryptography is implemented here. Encryption is age
(filippo.io/age); the timelock is tlock
(its exported core only); BLS verification is drand's. This module adds
framing, CBOR, bindings, verification rules and the flow, and it enforces the
stanza rules of the protocol inside the age identities, so that a file is never
accepted just because age could unwrap a key.
Not implemented on purpose: the Release API server and queue, storage and delivery, concrete extensions, and the TypeScript client (plan §2).
Versions
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, 2 when written and 1 or 2 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 format changes. A reader rejects a version it does not know (spec §22, §70) |
| Specification | datekeys.SpecVersion, today 0.9, and the tag spec-v0.9 |
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. The code on main, not yet released, covers:
- specification 0.9: it writes capsule format 2 and reads formats 1 and 2;
- the pinned Quicknet profile, and any profile on the three drand schemes that tlock supports;
- encryption, inspection and opening, and the CLI;
- every shared vector and fixture of
testdata/.
The first tag, v0.1.0, comes once go get works from a clean machine.
A capsule, in one picture
.dkc = PRELUDE (16 B) || PUBLIC_HEADER (CBOR) || SEALED_CONTROL (age) || PAYLOAD_AGE (age, to EOF)
time_only: SEALED_CONTROL = age(tlock round R → CONTROL_CBOR)
time_and_key: SEALED_CONTROL = age(tlock round R → age(16 X25519 stanzas → CONTROL_CBOR))
CONTROL_CBOR = { header_binding = SHA-256(PRELUDE || PUBLIC_HEADER), I_PAYLOAD, extensions, L, padding rule }
PAYLOAD_AGE = age(X25519 R_PAYLOAD → your data || zeros up to P = rule(L)), streamed
This is format 2, the one encrypt writes (spec §22). Its 16 stanzas hold
from 1 to 16 credentials and a dummy in each slot left, in a random order, and
its payload is padded to P, so that until the unlock date nobody learns the
number of credentials or the exact length L (spec §29.1, §39, §55.2). Format
1, that of v0.8.2, has one stanza per credential and no padding; readers
still open it.
CLI
go install g.activething.com/go/DateKeys/cmd/datekeys@latest
The module is served by the project's own Gitea, whose certificate Go does not
trust by default. Set GOPRIVATE=g.activething.com so that the Go proxy and
checksum database are bypassed for it, and either install the server's
certificate or, on a trusted network, set GOINSECURE=g.activething.com.
datekeys datekey resolve -at 2030-01-01T00:00:00Z
datekeys encrypt -at 2030-01-01T00:00:00Z -in letter.txt -out letter.dkc
datekeys encrypt -at 2030-01-01T00:00:00Z -policy time_and_key -dkk letter.dkk -in letter.txt -out letter.dkc
datekeys encrypt -at 2030-01-01T00:00:00Z -padding bloque256 -in letter.txt -out letter.dkc
datekeys inspect -in letter.dkc
datekeys decrypt -in letter.dkc -out letter.txt -dkk letter.dkk
datekeys profile hash
datekeys version
encrypt never touches the network. It pads the content with the rule
reforzado, or bloque256 if asked (spec §29.1). inspect runs only the
pre-unlock checks (spec §63 steps 1–8): it never requests a release and never
uses a secret.
decrypt fetches the release from public drand relays, verifies it locally
and publishes the plaintext only after age authenticated all of it. Outputs are
never overwritten.
Library
reg, err := profile.Default() // pinned Quicknet profile, checked against its profile_hash
// Encrypt: no network, the round is resolved locally. Format 2 seals the
// content length before the content: src must deliver exactly Length bytes.
res, err := capsule.Encrypt(dst, src, capsule.EncryptOptions{
Profile: profile.Quicknet(),
UnlockAt: time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC),
Policy: capsule.TimeAndKey,
NewPortableKey: true, // res.PortableKey is the .dkk; encode it with accesskey.Encode
Length: size,
Now: time.Now,
})
// Inspect: steps 1–8, no network, no secrets.
in, err := capsule.Inspect(f, capsule.InspectOptions{Registry: reg})
// Open: steps 1–18; the release is verified locally.
opened, err := capsule.Open(ctx, tmp, f, capsule.OpenOptions{
Registry: reg,
Source: drand.New(),
AccessKey: key, // or Identities: []age.Identity{...}
Now: time.Now,
})
if errors.Is(err, datekeys.ErrReleaseUnavailable) { /* not yet */ }
Open streams the content and never the padding; on error, discard what was
written and never present it as valid (spec §56). opened.Format is the
format of the capsule: format 1 hides neither the number of credentials nor
the exact length of the content, so show it (spec §70).
Every protocol failure wraps one of the 17 normative errors of spec §69, so
errors.Is and datekeys.Code(err) identify it.
Security properties and limits
- Time confidentiality holds under drand's threshold assumption. The Quicknet timelock is not post-quantum: ciphertexts kept for years are exposed to harvest-now, decrypt-later (spec §7.7, §53).
- No trust in servers: the profile is pinned in the binary, the round is computed locally, releases are BLS-verified locally, and a valid signature of another round is rejected (spec §13, §17, §51).
- Integrity: framing, header, control and payload are authenticated against whoever does not know the file keys: a third party's change makes opening fail (fixtures and the mutation corpus). Once the round is published anyone can compute the time file key, and anyone can seal a new control for a public header; who can write each part, and from which step it is bound, is the trust model of spec §27 and §55.1.
- Metadata privacy (format 2): until the date, the exact length of the
content and the number of credentials stay hidden; the date, the access
policy,
capsule_id, the header extensions and the padded size P do not (spec §55.2). - No authorship:
time_onlygives internal coherence, not proof of who created a capsule, before or after it matures;time_and_keyadds an access barrier, not a signature (spec §36.1). - Recovery years later needs the historical release: from a drand relay that still serves it or from any cache, re-verified locally (spec §50).
See SECURITY.md.
Conformance and tests
go test ./... # unit, golden vectors, fixtures, mutation corpus
go test -race -cover ./...
go test -fuzz=FuzzInspect ./capsule # one fuzz target at a time
go test -tags interop ./capsule # official age and tle CLIs open our files
go test -tags integration ./capsule ./provider/drand # live Quicknet
testdata/vectors: profile hash, date→round anddk1_vectors (spec §65, §66), CBOR profile and schema vectors, padding vectors (spec §29.1), the exported mutation corpus and a differential corpus of the pre-unlock checks; formats intestdata/README.md.testdata/fixtures: official.dkc/.dkkfixtures over published rounds, with the BLS signature embedded and every intermediate value (spec §67, §68); they decrypt offline. Seven are of format 2; the five of format 1, from v0.8.2, are kept for compatibility. Each.dkchas its frozendatekeys inspect -jsonoutput.internal/testkit.Mutations: the mutations of spec §64, the 33 of its first two lists in each format and the 22 of its third list, and 37 more, each with its exact error and step, and a check that pre-unlock failures never cause a release request; exported totestdata/vectors/mutations.json.docs/traceability.md: spec section → code → test.spec/datekeys.cddl: CBOR schemas.
License
Code: Apache-2.0 (LICENSE). Specification: CC-BY-4.0
(spec/README.md). codec/bech32 is copied from age under its
own license. "DateKeys" is a reserved name: see TRADEMARKS.md.