Status: Draft
Date: 2026-07-17
Editors: Untilum project — hello@untilum.com
Reference implementation: packages/core (TypeScript) for the v1 wire format,
specified in capsule-v1.md, which this document incorporates by
reference and generalizes into a platform-independent protocol. The v2 direction
(§10.1) has a draft reference implementation: the Untilum SDK
(packages/untilum-*, npm scope @untilum/*).
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.
Protocol-level design documents — goals, threat model, trust model, substrate
independence, identity, verification, governance, and compatibility policy — live in
the RFC series (doc/rfc/). This specification defines the wire
format; the RFCs define the policies it serves.
Untilum is a trustless delayed-disclosure infrastructure: a protocol and API that allows data to be encrypted today and cryptographically unlocked only after a chosen future time, without relying on any central server or operator.
The Untilum Protocol (TSP) defines how arbitrary byte payloads are encrypted so that they become decryptable no earlier than a chosen future instant, and then only with a user-held password. TSP is trustless with respect to its operators: no server, platform, or key-escrow party can open the data early, and opening does not require any party that participated in sealing to still exist.
TSP is a composition of five layers:
| Layer | What it defines | Section |
|---|---|---|
| Encryption | Symmetric encryption of the payload and key derivation | §3 |
| TimeLock | Binding a secret to a future beacon round (IBE / tlock) | §4 |
| Beacon | The public randomness chain that emits round signatures (drand) | §5 |
| Capsule | The self-describing container document and its metadata | §6 |
| Proof | Detached, third-party-verifiable claims about a capsule | §7 |
§8 gives the normative algorithms (Seal / Open / Verify) end to end.
Opening requires two independent factors:
R. Before round R is
emitted, this signature does not exist anywhere in the universe; it cannot be
guessed, precomputed, or coerced out of any party.Neither factor alone suffices. Trust assumptions: honesty of the beacon threshold
(violation ⇒ early opening, not loss); availability of the round-R signature at
opening time (drand liveness or a mirror); honesty of the Sealer software itself.
BLS12-381 pairings are not post-quantum: a cryptographically relevant quantum computer
enables early opening of the time factor — the password factor then remains the
sole barrier. See §9.
CSPRNG(n) — n bytes from a cryptographically secure random source.a || b — byte concatenation."capsule-v1", "capsule-v1-meta", …) are their ASCII bytes.R — the beacon round whose signature unlocks the capsule.(round, signature) for one round of the chain.TSP uses an envelope scheme: the payload is encrypted once under a symmetric key that is derived from both factors, so the expensive/large payload never touches the timelock layer.
tlsecret = CSPRNG(32) # time factor carrier
salt = CSPRNG(16)
pwKey = KDF(password, salt) -> 32 bytes # §3.2
fileKey = HKDF-SHA256(IKM = tlsecret || pwKey, # §3.3
salt = salt,
info = "capsule-v1", L = 32)
metaKey = HKDF-SHA256(same IKM, same salt,
info = "capsule-v1-meta", L = 32) # §6.2
iv = CSPRNG(12)
ciphertext = AES-256-GCM(key = fileKey, iv = iv, plaintext) # 128-bit tag APPENDED
tl = TimeLock(round = R, payload = tlsecret) # §4
Rationale (informative): timelocking a fixed 32-byte secret rather than the payload
keeps the timelock blob constant-size, allows streaming payload encryption, and cleanly
separates the two factors — the beacon signature yields tlsecret, the password yields
pwKey, and only their combination yields fileKey.
Requirements:
tlsecret, pwKey, fileKey, metaKey, and the password are secrets:
implementations MUST NOT log, persist, transmit, or embed them in the Capsule or in
error messages, and SHOULD zeroize buffers after use.The Capsule self-describes its KDF (alg.kdf, alg.kdfParams). Two KDFs are defined:
alg.kdf |
Algorithm | Params | Status |
|---|---|---|---|
argon2id |
Argon2id (RFC 9106) | { m, t, p, dkLen }, m in KiB |
Current. Sealers MUST emit this. |
pbkdf2 |
PBKDF2-HMAC-SHA256 (RFC 8018) | { c, dkLen } |
Legacy. Openers MUST accept; Sealers MUST NOT emit. |
Production Argon2id parameters: m = 65536 (64 MiB), t = 3, p = 1, dkLen = 32.
Anti-downgrade floors. Because the ciphertext is public forever and the password is
the only remaining factor after round R, an Opener MUST reject — before running the
KDF — any Capsule whose parameters fall below:
argon2id: m ≥ 65536, t ≥ 3, 1 ≤ p ≤ 4, dkLen = 32;pbkdf2: c ≥ 300000, dkLen = 32;alg.kdf: reject.HKDF-SHA256 (RFC 5869): HKDF salt = the Capsule’s 16-byte salt, IKM =
tlsecret || pwKey (64 bytes, tlsecret first), output 32 bytes. Distinct info
strings give domain separation between derived keys ("capsule-v1" → fileKey,
"capsule-v1-meta" → metaKey); ciphertexts under different derived keys can never be
substituted for one another.
tl = tlock_encrypt(R, tlsecret) — the tlock construction (Gailly–Melissaris–Romailler,
IACR ePrint 2023/189) as implemented by tlock-js:
IBE on BLS12-381. The 32-byte tlsecret is encrypted with Boneh–Franklin
identity-based encryption to the identity of round R, under the beacon network’s
group public key. The identity is:
id(R) = SHA-256(R as 8-byte big-endian integer)
age framing. The IBE ciphertext is carried inside an age v1 file
(ChaCha20-Poly1305 payload wrapping) with a tlock recipient stanza, ASCII-armored
(-----BEGIN AGE ENCRYPTED FILE-----).
Decryption: given the round-R beacon signature σ_R (a G1 point under
bls-unchained-g1-rfc9380), σ_R is the IBE private key for identity id(R) —
tlock_decrypt(tl, σ_R) recovers tlsecret.
Why this locks time (informative): the beacon’s group public key was fixed at chain
genesis, so anyone can encrypt to a future round offline; but the matching private
key σ_R is the threshold BLS signature that the network only produces when round R
arrives. Sealing therefore MUST be possible fully offline (the group key is pinned,
§5); opening requires exactly one beacon fetch (or a mirror, or manual paste).
Before use, every round signature — regardless of source (live API, relay, mirror,
manual paste) — MUST be BLS-verified against the pinned group public key of §5, and its
round number MUST equal the Capsule’s round. Implementations MUST NOT expose an
option to skip verification. A signature for any other round MUST be rejected (this is
what defeats forged-time attacks).
TSP v1 uses exactly one beacon chain: drand quicknet (“unchained” mode — each round’s signature is independent, which is what makes per-round IBE identities work).
| Constant | Value |
|---|---|
| chain hash | 52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971 |
| group public key (G2, 96 B) | 83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a |
| scheme | bls-unchained-g1-rfc9380 (signatures on G1, pubkey on G2) |
| period | 3 s |
| genesis | 1692803367 unix s (2023-08-23 15:09:27 UTC) |
Round arithmetic (normative):
R(T) = max(1, floor((T_unix − genesis) / period) + 1) # date → round
T(R) = genesis + (R − 1) · period # round → emission instant (UTC)
Dates MUST be normalized to UTC before round computation, and Sealers SHOULD display
T(R) back to the user (the chosen date is rounded to a 3-second grid).
Beacon retrieval (advisory order; each result verified identically per §4.2):
https://api.drand.sh/<chainHash>/public/<round>https://drand.cloudflare.com/<chainHash>/public/<round>The deprecated fastnet chain (dbd506d6…) MUST be rejected everywhere. Chains
other than quicknet are out of scope for TSP v1 (multi-beacon is a v2 direction, §10).
A Capsule is a single UTF-8 JSON object — the complete, self-contained record
needed to open the payload (plus a beacon and the password). The full normative format,
field shapes, and pre-crypto validation rules are specified in
capsule-v1.md §3; summary:
{
"v": 1,
"alg": {
"sym": "AES-256-GCM",
"kdf": "argon2id",
"kdfParams": { "m": 65536, "t": 3, "p": 1, "dkLen": 32 },
"combine": "HKDF-SHA256",
"timelock": "tlock-quicknet"
},
"round": 123456789, // beacon round R
"chainHash": "52db9ba7…e971", // MUST equal the quicknet chain hash
"salt": "<16 B hex>",
"iv": "<12 B hex>",
"ciphertext": "<hex>", // AES-GCM body || tag — OR "ar://<txid>" (§6.4)
"tl": "-----BEGIN AGE ENCRYPTED FILE-----…",
"metaEnc": { "iv": "<12 B hex>", "ct": "<hex>" } // §6.2
}
Unknown top-level fields MUST NOT be emitted and MUST be rejected by strict validators. Openers MUST validate the document (version, chain hash, KDF floors, field shapes) before performing any cryptography.
Descriptive metadata ({ name, mime, createdAt } as UTF-8 JSON) is encrypted under
metaKey (§3.3) with AES-256-GCM and carried as metaEnc. Because metaKey derives
from the same two-factor IKM, metadata requires both factors to read — exactly like
the payload. Sealers MUST NOT emit plaintext metadata; Openers MUST additionally accept
legacy pre-v1.1 capsules carrying a plaintext meta object instead (exactly one of
metaEnc / meta present).
Implementations MUST NOT place sensitive names/titles into any plaintext location (capsule fields, storage tags, file names on public storage): permanent storage makes such leaks unrepairable.
A Capsule is storage-agnostic: any byte-faithful channel (permanent storage, a file on disk, a QR code) is conforming. Storage providers only ever see ciphertext and public parameters. No Capsule field may reference a platform-specific opening service.
App-Name: Untilum, Capsule-Version: 1, Open-Round: <R>,
Content-Type: application/json.Content-Type: application/octet-stream, same other tags) and the Capsule’s ciphertext field is
rewritten to the reference ar://<txid>. Openers that do not support references
MUST fail closed with a clear “not supported” error.arweave.net,
gateway.irys.xyz) before failing; every retrieved document goes through §6.1
validation.Tags are advisory (indexing/discovery only) — they are not authenticated and MUST NOT be trusted over the Capsule’s own fields.
Status: Draft. This layer is specified here for TSP v1 but is not yet implemented in the reference implementation (
capsule-v1.md§6 reserves it). Field names below are normative for future implementations; expect refinement before the first release ships them.
A Proof is a detached JSON document making a verifiable claim about a Capsule. Proofs are detached (not embedded) so that (a) the v1 Capsule format — which rejects unknown fields — is untouched, and (b) proofs can be added after sealing (e.g. a storage receipt) without mutating immutable storage.
Every Proof binds to its Capsule by digest:
capsuleDigest = SHA-256( canonical bytes of the Capsule JSON )
where “canonical bytes” are the exact bytes as stored (the serialized document is the canonical form — Capsules are write-once; implementations MUST NOT re-serialize before hashing). Represented as lowercase hex.
{
"tsp": 1, // protocol version
"type": "creator-sig", // §7.3 registry
"capsuleDigest": "<32 B hex>", // §7.1
"capsuleRef": "ar://<txid>", // OPTIONAL locator hint, advisory only
"createdAt": "2026-07-07T12:00:00Z",
"body": { … } // type-specific, §7.3
}
A Verifier MUST (1) fetch/receive the Capsule bytes, (2) recompute capsuleDigest,
(3) check it equals the Proof’s value, (4) verify the type-specific body. A Proof whose
digest does not match MUST be rejected regardless of body validity.
creator-sig — the sealer’s key signed this capsule (authorship / integrity).
"body": {
"alg": "ed25519",
"publicKey": "<32 B hex>",
"signature": "<64 B hex>" // Ed25519 over ASCII("tsp-v1-creator-sig" ) || capsuleDigest bytes
}
The signed message is the ASCII bytes tsp-v1-creator-sig followed by the 32 raw
digest bytes (domain separation against cross-protocol signature reuse). Binding the
public key to a real-world identity is out of scope (informative: publish the key, or
anchor it in DNS/DID/PGP).
storage-receipt — the capsule bytes exist at a storage location.
"body": {
"provider": "arweave",
"txId": "<txid>",
"blockHeight": 1234567, // OPTIONAL
"observedAt": "2026-07-07T12:00:00Z"
}
Verification = fetch txId, byte-compare digest. The storage network’s own consensus
(e.g. Arweave block inclusion) is the actual authority; this document is a pointer.
time-anchor — the capsule existed no later than a point in time
(proof-of-creation-time), e.g. an L2 notarization transaction or an OpenTimestamps
attestation containing capsuleDigest.
"body": {
"method": "ethereum-l2" | "opentimestamps",
"chainId": 8453, // when method = ethereum-l2
"txHash": "<hex>",
"attestation": "<base64>" // when the method carries a proof blob (OTS)
}
beacon-mirror — a signed copy of the round-R beacon stored durably (the
drand-to-Arweave mirror). body: { "round": R, "signature": "<G1 hex>", "txId": … }.
Verification is §4.2 BLS verification — the mirror adds availability, never authority.
New types MUST use a new type string; Verifiers MUST ignore (not fail on) unknown
proof types when verifying a collection, but MUST NOT report an unknown type as
verified.
Proofs are claims about a Capsule, never inputs to opening it. No Proof type may carry key material, weaken either factor, or make opening depend on a Verifier. An Opener MUST be able to ignore all Proofs entirely.
Seal(plaintext, meta, password, openDate) -> Capsule
1. R = R(normalize_to_UTC(openDate)) # §5
2. tlsecret = CSPRNG(32); salt = CSPRNG(16)
3. pwKey = Argon2id(password, salt, m=65536, t=3, p=1, dkLen=32)
4. fileKey = HKDF-SHA256(tlsecret || pwKey, salt, "capsule-v1", 32)
metaKey = HKDF-SHA256(tlsecret || pwKey, salt, "capsule-v1-meta", 32)
5. iv = CSPRNG(12); ciphertext = AES-256-GCM(fileKey, iv, plaintext)
6. metaEnc = { iv: CSPRNG(12), ct: AES-256-GCM(metaKey, iv', UTF8(JSON(meta))) }
7. tl = tlock_encrypt(R, tlsecret) # offline; pinned chain info (§5)
8. emit Capsule JSON (§6.1); zeroize tlsecret, pwKey, fileKey, metaKey
Sealing MUST work fully offline. Storage upload (§6.4) and Proof creation (§7) are separate, optional steps that see only the finished Capsule.
Open(capsule, password, beaconSource) -> plaintext, meta
1. validate capsule (§6.1) — version, chainHash == quicknet, KDF floors, shapes
2. beacon = fetch(round) from any source; BLS-verify against pinned pubkey (§4.2)
3. tlsecret = tlock_decrypt(capsule.tl, beacon.signature)
4. pwKey = KDF per capsule.alg (post-floor-check)
5. fileKey, metaKey = HKDF as in Seal
6. plaintext = AES-256-GCM-decrypt(fileKey, iv, ciphertext)
— tag failure ⇒ report "wrong password or corrupted capsule"
(cryptographically indistinguishable; MUST NOT be disambiguated)
7. meta = AES-256-GCM-decrypt(metaKey, metaEnc.iv, metaEnc.ct) # or legacy plaintext
8. zeroize secrets
Failure modes map to factors: missing/future/forged round ⇒ step 2–3 failure (time factor); wrong password ⇒ step 6 tag failure (password factor).
Verify(capsuleBytes, proof) -> ok | fail
1. digest = SHA-256(capsuleBytes); require digest == proof.capsuleDigest
2. dispatch on proof.type (§7.3); verify body cryptographically
3. never derive or touch any key material
Normative test vectors (KDFs, HKDF, AES-GCM, round computation) are in
capsule-v1.md §7. A conforming implementation MUST reproduce them
exactly and SHOULD embed them as a startup self-test in any offline Opener. The tlock
layer is randomized (no byte-exact KAT); interoperability is demonstrated by
cross-implementation round-trips.
Every implementation MUST pass, at minimum:
R signature, opening is impossible;@noble/*, hash-wasm. TSP
composes existing constructions; implementations MUST NOT substitute homemade
primitives.R, v1 capsules are stranded
until mitigations outside the format (beacon mirrors help only for past rounds;
Shamir custodians and multi-beacon are v2+ directions, §10).v field and the Proof tsp field version their layers independently.v: 2.Five changes define TSP v2. A hard constraint on all of them: none may weaken the two-factor invariant, add a trusted party, or reintroduce dependence on a platform for opening. A draft reference implementation exists as six packages:
| Package | Layer |
|---|---|
@untilum/core |
Capsule v2 format, envelope, unlock policy, Untilum facade |
@untilum/beacons |
Beacon adapters (drand quicknet) |
@untilum/identity |
Creator identity (Ed25519; WebAuthn planned) |
@untilum/storage |
Pluggable storage adapters (Arweave, in-memory) |
@untilum/verify |
Third-party verification (verify()) |
@untilum/receipts |
Capsule receipts (cryptographic evidence packages) |
{
"v": 2,
"kind": "untilum.capsule",
"alg": { "sym": "AES-256-GCM", "kdf": "argon2id",
"kdfParams": { "m": 65536, "t": 3, "p": 1, "dkLen": 32 },
"combine": "HKDF-SHA256" },
"salt": "<16 B hex>",
"payload": {
"iv": "<12 B hex>",
"ciphertext": "<hex>", // or "<scheme>://<ref>" (by reference)
"hash": "sha256:<hex>" // over the ciphertext bytes — public integrity
},
"unlock": {
"at": "2036-01-01T00:00:00.000Z", // informative; rounds are authoritative
"policy": "anyOf" | "allOf",
"beacons": [
{ "id": "drand-quicknet", "type": "drand",
"chainHash": "52db9ba7…", "round": 123456789, "tl": "-----BEGIN AGE…" }
]
},
"metaEnc": { "iv": "…", "ct": "…" }, // optional; both factors required, as in v1.1
"creator": { // optional, §10.1.2
"type": "ed25519", "publicKey": "<hex>", "signature": "<hex>",
"signedFields": ["v","kind","alg","salt","payload","unlock","metaEnc"]
},
"storage": [ { "type": "arweave", "ref": "ar://<txid>", "txId": "…",
"timestamp": "…", "dataHash": "sha256:…" } ], // MUTABLE, advisory
"proofs": [] // MUTABLE, advisory
}
The v2 envelope is the v1 construction (§3) with HKDF info strings untilum-v2 /
untilum-v2-meta (domain separation from v1 capsules). Two zones are distinguished:
the immutable core (everything except storage and proofs) — covered by
capsuleHash = sha256(canonicalJson(immutable fields)) and by the creator signature —
and the mutable advisory zone (storage, proofs), appendable after sealing.
Validation mirrors v1: quicknet pinning and KDF floors are enforced at parse time,
before any cryptography; unknown top-level fields are rejected. Openers MUST verify
payload.hash against the actual ciphertext bytes before decryption (this is what
makes by-reference ciphertext safe: the locator is advisory, the digest is the
authority).
The unlock block makes the timelock layer plural. Two policies:
anyOf (liveness): the full 32-byte tlsecret is independently timelocked to
an equivalent instant on each beacon; any one surviving beacon opens the capsule.
Early-opening resistance degrades to the weakest chain.allOf (strict time): tlsecret is split into n-of-n XOR shares (information-
theoretic secret sharing; Shamir generalizes to k-of-n later), one share
timelocked per beacon. Early opening requires every chain; the death of one chain
strands the capsule, so allOf SHOULD be combined with custodial fallbacks.Each beacon entry is verified independently per §4.2 by its adapter; verification is
not skippable. Beacon ids MUST be unique within a capsule (one lock per network).
Round-equivalence across chains with different genesis/period MUST be computed from
the same target UTC instant. Implementation status: both policies implemented and
covered by the critical tests; drand/quicknet is the only beacon family in v2.0 —
validators MUST reject unknown beacon types until they are specified (an Ethereum
beacon adapter is the next candidate).
A capsule MAY carry an embedded creator block (in addition to the detached
creator-sig proof of §7.3). The signed message is:
UTF8("untilum-v2-creator-sig") || SHA-256(canonicalJson(pick(capsule, signedFields)))
Canonical JSON — sorted keys, no whitespace, integers only — is REQUIRED: signing a
raw serialization would break on any re-serialization. signedFields MUST include the
immutable core (v, kind, alg, salt, payload, unlock) and MUST NOT include creator,
storage, or proofs — the mutable zone stays outside the signature so storage
receipts can be appended without invalidating it.
type |
Status | Notes |
|---|---|---|
ed25519 |
Implemented | Raw keypair; works in CLI/backend/mobile, no browser binding, easy to test |
webauthn |
Planned | FIDO2 assertion, challenge = capsuleHash; hardware-backed, phishing-resistant |
Identity→person binding stays out of scope (DNS/DID/PGP layer on top).
verify() is the SDK’s central trust function:
verify(capsule, { storage? }) -> {
valid: boolean, // no check failed
checks: { // "pass" | "fail" | "skipped"
format, algorithms, kdfFloors, unlockDate,
beacons, payloadHash, creatorSignature, storageProof
},
warnings: [], errors: []
}
Rules (normative): a check with nothing to verify reports "skipped" plus a warning —
never a silent "pass" (“not claimed” must be distinguishable from “verified”);
unlockDate re-derives the round↔UTC arithmetic from pinned chain constants;
payloadHash recomputes the digest over the actual ciphertext bytes (fetching them
via a storage adapter when by-reference); the API is runnable by any third party
without the platform, touches no key material (§7.4), and everything except
payloadHash-by-reference and storageProof works fully offline.
Implementation status: implemented (@untilum/verify); importing it wires
Untilum.verify.
A Receipt is a cryptographic evidence package — deliberately not a “legal
document”. It is a detached JSON projection of a capsule (capsuleId/capsuleHash,
payloadHash, unlock instant + policy + beacon rounds, storage entries, creator key
and signature) plus a deterministic human-readable rendering, designed to be handed
to a third party such as a court, notary, or counterparty.
Normative core: a Receipt has no authority of its own. Every statement MUST be
re-derivable from the capsule bytes (verifyReceipt(receipt, capsule) is exactly that
re-derivation); a receipt that mismatches its capsule is worthless. Receipts are
detached, never embedded in the capsule — the receipt contains the capsule’s hash, so
embedding would be circular. Every rendering MUST carry verbatim:
This receipt provides cryptographic evidence of capsule creation, integrity, storage reference, and creator signature. It is not a notarization unless issued by an authorized notarial provider. It does not and cannot attest to the payload’s content before the unlock date.
Implementation status: implemented (@untilum/receipts); an altered notice
fails verification.
Storage is fully pluggable — Arweave is an adapter, not the protocol’s foundation:
interface StorageAdapter {
type: string; // "arweave" | "memory" | …
scheme: string; // ref scheme it serves, e.g. "ar" for "ar://<txid>"
put(data, opts?): Promise<StorageEntry>; // entry includes dataHash
get(ref): Promise<Uint8Array>;
verify(entry): Promise<{ ok, error? }>; // fetch + digest comparison
}
ar://, memory://, later ipfs://, https://, S3/R2/
local/custom) and always advisory; the digest is the authority — every fetch is
byte-hash-checked against payload.hash / dataHash.storage: [] list lives in the mutable zone (appendable), and §6.4’s
tag profile becomes one adapter’s detail rather than protocol core.Implementation status: interface + Arweave (Turbo) and in-memory adapters implemented; ciphertext above 5 MiB is stored by reference automatically.
doc/spec/capsule-v1.md| Date | Change |
|---|---|
| 2026-07-07 | Initial draft (incorporates capsule-v1 by reference; adds the v2 direction §10.1). |
| 2026-07-17 | Publication pass for the series Last Call: BCP 14 boilerplate, editor contact. Specification remains Draft — spec versions finalize with protocol version releases (RFC-0008 §4), not with the RFC Last Call. |