Status: Draft (normative for this repository; the wire format of the open Untilum Protocol — see tsp-v1.md)
Date: 2026-07-03 (v1.1: encrypted metadata added the same day)
Reference implementation: packages/core (TypeScript). A conforming independent
implementation MUST interoperate with the reference: capsules sealed by one MUST open in
the other.
The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY in this document are to be interpreted as described in BCP 14 (RFC 2119, RFC 8174) when, and only when, they appear in all capitals.
A capsule is a self-contained JSON document that encrypts a byte payload such that decryption requires both, independently:
R, which does not
exist anywhere in the universe before round R is emitted; andNeither factor alone suffices. The platform that created a capsule is not required for opening it: a capsule plus a beacon source plus the password is sufficient. Any implementation feature that makes opening depend on a specific server or domain is non-conforming.
R signature at opening time (liveness of drand or a mirror).tlsecret = CSPRNG(32 bytes) # time factor carrier
salt = CSPRNG(16 bytes)
pwKey = KDF(password, salt) -> 32 bytes # §2.2
fileKey = HKDF-SHA256(IKM = tlsecret || pwKey,
salt = salt,
info = "capsule-v1", L = 32)
metaKey = HKDF-SHA256(same IKM, same salt,
info = "capsule-v1-meta", L = 32) # §2.5
iv = CSPRNG(12 bytes)
ciphertext = AES-256-GCM(key = fileKey, iv = iv, plaintext) # 16-byte tag APPENDED
metaEnc = { iv: CSPRNG(12), ct: AES-256-GCM(metaKey, iv, UTF-8 JSON of meta) }
tl = tlock_encrypt(round = R, payload = tlsecret) # §2.4
Opening reverses the derivation. A wrong password manifests as an AES-GCM tag failure. A missing, future, or forged round manifests as a tlock/BLS verification failure.
|| is byte concatenation; tlsecret comes first.info is the ASCII bytes of capsule-v1.tlsecret, pwKey, fileKey, and the password are secrets: implementations MUST NOT
log, persist, transmit, or include them in the capsule or in error messages, and
SHOULD zeroize buffers after use.The capsule self-describes its KDF in alg.kdf / alg.kdfParams. Two KDFs are defined:
alg.kdf |
Algorithm | Params (alg.kdfParams) |
Status |
|---|---|---|---|
argon2id |
Argon2id (RFC 9106) | { m, t, p, dkLen }, m in KiB |
Current. All new capsules MUST use it. |
pbkdf2 |
PBKDF2-HMAC-SHA256 | { c, dkLen } |
Legacy. Openers MUST support; sealers MUST NOT emit. |
Production Argon2id parameters: m = 65536 (64 MiB), t = 3, p = 1, dkLen = 32.
Legacy PBKDF2 parameters: c = 300000, dkLen = 32.
Anti-downgrade floors. An opener MUST reject (before running the KDF) any capsule whose parameters fall below the minima ever emitted by a conforming sealer:
argon2id: m ≥ 65536, t ≥ 3, 1 ≤ p ≤ 4, dkLen = 32pbkdf2: c ≥ 300000, dkLen = 32alg.kdf value: reject.Rationale: the ciphertext is public forever (permanent storage); after round R the
password is the only remaining factor. A crafted capsule with weakened parameters must
not be able to trick an opener into deriving a cheap-to-brute-force key.
HKDF-SHA256 (RFC 5869) with the capsule’s 16-byte salt as HKDF salt and
info = "capsule-v1", output 32 bytes. The IKM is tlsecret || pwKey (64 bytes).
tl is the output of tlock (age v1 / ChaCha20-Poly1305 payload wrapping, IBE on
BLS12-381 per the tlock paper, IACR 2023/189) encrypting the 32-byte tlsecret to the
identity of round R on drand quicknet only:
| Constant | Value |
|---|---|
| chain hash | 52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971 |
| group public key (G2, 96 B) | 83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a |
| scheme | bls-unchained-g1-rfc9380 (signatures on G1) |
| period | 3 s |
| genesis | 1692803367 (unix s) |
Round identity: SHA-256(round as 8-byte big-endian).
Round from time: R = floor((T_unix − genesis) / period) + 1, clamped to ≥ 1.
The deprecated fastnet chain (dbd506d6…) MUST be rejected everywhere.
Beacon verification. Before use, every round signature — regardless of source
(drand HTTP API, relay, mirror, manual paste) — MUST be BLS-verified against the
embedded quicknet group public key, and its round number MUST equal the capsule’s
round. Implementations MUST NOT expose an option to skip verification.
Metadata ({ name, mime, createdAt } as UTF-8 JSON) is encrypted with
AES-256-GCM under metaKey, a key derived from the same IKM and salt as
fileKey but with the distinct HKDF info string capsule-v1-meta. The
domain separation guarantees the file ciphertext and the metadata ciphertext
can never be substituted for one another. Metadata therefore requires BOTH
factors to read — exactly like the payload.
Sealers MUST emit metaEnc and MUST NOT emit plaintext meta. Openers MUST
support legacy capsules that carry plaintext meta instead (pre-v1.1).
The metadata JSON is application-extensible and never validated structurally. The standard field names (all OPTIONAL, all advisory — shared by every reference application so that any opener can render them; v2 capsules use the same names):
| Field | Meaning |
|---|---|
name |
original file name, with extension (restored on open) |
mime |
payload MIME type |
title |
human-readable capsule title |
createdAt |
sealing instant, ISO-8601 UTC |
location |
where the capsule was created — free text, as the creator wants it remembered |
device |
device / application the capsule was created on |
owner |
display name of the capsule’s creator/owner |
Sensitive values belong exactly here: metaEnc requires both factors to read.
None of these fields may ever appear in the plaintext capsule document
(security invariant: no sensitive metadata in the open payload).
A capsule is a single JSON object, UTF-8 encoded. Unknown top-level fields MUST be rejected by strict validators (the reference backend does) and MUST NOT be emitted.
{
"v": 1, // format version, integer, MUST be 1
"alg": {
"sym": "AES-256-GCM", // exactly this string
"kdf": "argon2id", // or "pbkdf2" (legacy) — §2.2
"kdfParams": { "m": 65536, "t": 3, "p": 1, "dkLen": 32 },
"combine": "HKDF-SHA256",
"timelock": "tlock-quicknet"
},
"round": 123456789, // drand quicknet round R, integer ≥ 1
"chainHash": "52db9ba7…e971", // MUST equal the quicknet chain hash
"salt": "<32 hex chars>", // 16 bytes, lowercase hex
"iv": "<24 hex chars>", // 12 bytes, lowercase hex
"ciphertext": "<hex>", // AES-GCM body || 16-byte tag, lowercase hex
// OR an "ar://<txid>" reference (reserved, §6)
"tl": "-----BEGIN AGE ENCRYPTED FILE-----…", // age-armored tlock blob (ASCII)
"metaEnc": { // v1.1 — encrypted metadata (§2.5)
"iv": "<24 hex chars>", // 12 bytes
"ct": "<hex>" // AES-GCM(metaKey, iv, JSON {name,mime,createdAt}) || tag
}
// LEGACY (pre-v1.1) capsules carry instead:
// "meta": { "name": "photo.jpg", "mime": "image/jpeg", "createdAt": "…" }
// — plaintext, publicly readable. Exactly ONE of metaEnc / meta MUST be present.
}
Validation an opener MUST perform before any cryptography:
v === 1; all fields above present.chainHash equals the quicknet hash (constant-string comparison).round is a positive integer.alg.sym === "AES-256-GCM", alg.timelock === "tlock-quicknet".alg.kdf ∈ {argon2id, pbkdf2} and kdfParams meet the floors of §2.2.salt, iv, ciphertext, tl are non-empty strings of the stated shapes.metaEnc (well-formed iv/ct hex) or legacy meta (string
name/mime/createdAt) is present.Hex is lowercase on emission; parsers SHOULD accept uppercase.
R from the user’s open date (§2.4). Normalize dates to UTC; show the
user the exact UTC instant R maps back to.tlsecret (32 B), salt (16 B) from a CSPRNG.pwKey = Argon2id(password, salt, m=65536, t=3, p=1, dkLen=32).fileKey = HKDF-SHA256(tlsecret || pwKey, salt, "capsule-v1", 32).tl = tlock_encrypt(R, tlsecret) using the pinned chain info (no network needed).tlsecret, pwKey, fileKey.Sealing MUST be possible fully offline (the quicknet public key is pinned).
round from any source; BLS-verify it (§2.4).tlsecret = tlock_decrypt(tl, beacon).pwKey per the capsule’s alg (after the floor check), then fileKey, then
AES-256-GCM-decrypt. Tag failure ⇒ report “wrong password or corrupted capsule” —
the two are cryptographically indistinguishable.An opener SHOULD attempt beacon sources in order: live drand API → relay → mirror → manual paste, and MUST verify each identically.
meta.name/meta.mime on public permanent storage forever — this cannot be
repaired retroactively. Since v1.1 metadata is encrypted (§2.5); UIs MUST still
NOT put sensitive titles into any plaintext field (e.g. a UI display name kept
outside the capsule).R is emitted, the capsule
becomes unopenable until a cryptographically relevant quantum computer exists.
There is no in-format mitigation in v1.ciphertext: "ar://<txid>" pointing to a separate raw
transaction (binary, not hex), enabling streaming and halving storage cost. The
ar:// form is reserved in v1 (openers reject it as “not yet supported”) so v1
validators already pass such documents through schema checks.storage: [] — an appendable list of known locations of the ciphertext
(gateways, mirrors), never authoritative, purely advisory.proofs: [] — creator signature, creation-time anchoring, and storage receipts;
the basis for third-party verify(capsule).tlsecret across two independent beacon networks
(“any-of” for liveness or “all-of” for stricter time enforcement) so the death of one
chain does not strand future-dated capsules.A conforming implementation MUST reproduce these exactly. (Recomputed in CI by
packages/core/src/vectors.test.ts; embedded in the offline decryptor’s self-test.)
password = "correct horse battery staple" (UTF-8)
salt = 000102030405060708090a0b0c0d0e0f
iterations= 300000, dkLen = 32
output = 48a269a707488c7db1ce99c1e4ba8d3204cd7a8020e6928a193c47e6e5d0dbaa
password = "correct horse battery staple" (UTF-8)
salt = 000102030405060708090a0b0c0d0e0f
m = 65536 KiB, t = 3, p = 1, dkLen = 32
output = 0d1a3c6523c8f06e4e0af9c515aa5b5448cfebd6838f2d52c3d8b6ef8ddc3c2e
IKM = aa×32 || bb×32 (64 bytes)
salt = 000102030405060708090a0b0c0d0e0f
info = "capsule-v1", L = 32
output= 74881992e7f20fa3c8a3c561e332dc59bc70c7855b36192b9c1b2106103dd9e4
key = 2b7e151628aed2a6abf7158809cf4f3c2b7e151628aed2a6abf7158809cf4f3c
iv = cafebabefacedbaddecaf888
plaintext = 54696d652043617073756c65204b4154207631 ("Time Capsule KAT v1")
ciphertext||tag =
86bb3ceae723f01007c794e8a631b520051563e3529503cbd650500a195c906522d45f
date = 2030-01-01T00:00:00Z
round = 66884212
The timelock layer itself is non-deterministic (random sigma, ephemeral age key), so tlock has no byte-exact KAT; interop is tested by round-tripping capsules between implementations instead.
| Date | Change |
|---|---|
| 2026-07-03 | Initial version; v1.1 (encrypted metadata) added the same day. |
| 2026-07-17 | Publication pass for the series Last Call: BCP 14 boilerplate. Specification remains Draft; the v1 wire format itself is stable and covered by published test vectors. |