who holds the KEY?
The bundles lesson ended with a small bombshell: an entry inside a shipped disk can be ciphertext, and the manifest carries only a reference to a key, never the key itself. Which raises the one question every DRM scheme in history has answered badly — fine: so where is the key, and what stops the wrong person getting it?
Every naive answer is a honeypot. Put the key in the file, and you've shipped the lock taped to its own key — client-side gating is theater. Put it on a server in plaintext, and one breach of that server is every customer's content at once. Draw a login screen over the data and hope nobody looks underneath, and you've forgotten that the attacker owns the client. Three failure modes, one shared mistake: treating the key as a thing that lives somewhere instead of a decision made by someone.
This page is the release side of sealing. The sealing side — how an entry becomes ciphertext, the envelope layout, the cryptography of locking — has its own lesson. Here we answer the harder question: once it's locked, who turns the key, and how is that decision made safe even when the runtime itself can't be trusted?
the DEFINITION
1. the runtime-held content-key store and its posture-gated release: plaintext keys never travel with the sealed bytes; a key is handed over only by an access decision at the engine, against a real identity — a thing a server decides to give you, not a thing you find.
The engine's own source calls the store "the real dead-man switch": the
plaintext content key is never written into the .wbundle; it's
escrowed under a key_id, and the bundle carries only the
key_refs reference you met in the
bundles lesson. Pull the key from the store and every
copy of the bundle, everywhere, goes dark at once. That's the switch.
the release PATH
Here is the whole flow, happy path and denial in one breath. A client
opens a sealed bundle, reads the key_ref for the locked entry,
and asks the engine for the key by its id. The request runs the
auth ladder to resolve an identity, then the posture
gate decides. Nothing about this is a file read — it is the same machinery
that gates serving a whole workbook, pointed at a single key.
sequenceDiagram participant C as client (sealed bundle) participant A as auth ladder participant G as Access.enforce participant E as Escrow (ETS) C->>A: POST /rcp/key/shop:dmZzLnNxbGl0ZQ
Authorization: Bearer … A->>G: identity = {user_id, tenant_id} Note over G: posture :gated_data · demand :data G-->>G: authed? real user, not user_id "dev" alt allow G->>E: get(key_id) E-->>C: 200 {key_id, algo, key: base64} else deny G-->>C: 401 {error:{code:"unauthorized", retryable:false}} end Note over C: authed + unknown id → 404 not_found
existence disclosed only after auth
Walk the branches. A sealed entry is gated by definition, so the
posture is :gated_data with a :data demand —
Access.enforce(:gated_data, :data, identity). Allow
returns the raw content key, base64-encoded, in a small JSON body.
Deny returns the uniform error envelope —
{"error":{"code","message","retryable"}} — the exact same
shape an unauthorized RCP call of any kind produces. A denied key
request and a denied page request are indistinguishable to the caller,
which is the point: a refusal leaks nothing, not even what was refused. And
a genuine 404 is reserved for after auth — an authenticated caller
asking for a key id that doesn't exist gets not_found, so
existence is disclosed only once you've proven who you are.
$ curl -X POST https://engine.example/rcp/key/shop:dmZzLnNxbGl0ZQ \
-H "Authorization: Bearer $TOKEN"
200 {"key_id":"shop:dmZzLnNxbGl0ZQ","algo":"aes-256-gcm","key":"<44-char base64>"}
# no credential
401 {"error":{"code":"unauthorized","message":"unauthorized","retryable":false}}
# authed, but no such key
404 {"error":{"code":"not_found","message":"not_found","retryable":false}}
The gate has teeth where it counts: it allows release only if the caller
is genuinely authenticated — the anonymous dev fallback,
user_id: "dev", is explicitly refused. And the anti-leak
invariant runs deeper than the endpoint: gated workbooks must never be baked
into public static artifacts, because client-side gating is theater. The
publish guard refuses to bake a gated workbook into a public bundle in the
first place — the decision can only ever happen live, at the engine.
what the store actually IS
depth rung · skippable — the store and its four verbs
No mystery, and deliberately no new persistence layer. The store is a single named ETS table — in-memory, owned by a GenServer that starts itself lazily on the first write. It is explicitly not a new database; it's the smallest thing that can hold a key while the runtime is alive. The whole surface is four verbs:
| call | does | note |
|---|---|---|
put(key_id, key) | store a content key | overwrites — last write wins |
get(key_id) | {:ok, key} or {:error, :no_such_key} | what the release endpoint calls |
delete(key_id) | revocation | idempotent — deleting twice is harmless |
keyfn(prefix) | mint + escrow a key per path | called during sealing |
The clever part is keyfn. When a bundle is sealed, the seal
step asks keyfn for a key per entry; keyfn mints a
fresh 32-byte key, computes a key_id, and escrows the key in
one motion — the very act of sealing populates the runtime store. The
id is readable on purpose:
key_id = prefix <> ":" <> base64url(path), with the
prefix set to the workbook id. So a shop sealing vfs.sqlite
produces shop:dmZzLnNxbGl0ZQ — decode the base64 tail and you
literally read back vfs.sqlite. The prefix is why two different
bundles' key ids never collide.
# key_id = "shop:" + base64url("vfs.sqlite")
"shop:" <> Base.url_encode64("vfs.sqlite", padding: false)
# => "shop:dmZzLnNxbGl0ZQ"
One honest consequence sits right here, and the lesson states it plainly rather than later: ETS is runtime memory. Keys do not survive a runtime restart. This is a v1 store, not a hardware security module — covered squarely in the honesty section.
one key opens one DOOR
A released key opens exactly one entry — even if the broker handing it
out is buggy or malicious. That guarantee doesn't live in the access code;
it lives in the cryptography. When an entry is sealed, its key_id
(which encodes the path) is bound into the GCM authentication tag as
additional authenticated data. So a sealed entry can't be silently
relabelled or moved, and a key minted for entry A cannot open entry B —
the label rode inside the lock.
flowchart LR
kA(["key A"]) --> eA{"open entry A
aad = id A"}
kA --> eB{"open entry B
aad = id B"}
eA -- "match" --> ok["plaintext ✓"]
eB -- "aad mismatch" --> no["{:error, :auth_failed}"]
style kA fill:#a8d4f0,stroke:#121316,stroke-width:2.5px
style eA fill:#ffffff,stroke:#121316
style eB fill:#ffffff,stroke:#121316
style ok fill:#aee5c2,stroke:#121316
style no fill:#f3c5a3,stroke:#121316
The adversarial test suite makes this concrete. Two sealed entries,
a and b; a deliberately broken broker that returns
key A for both. Opening halts with
{:error, {"b", :auth_failed}} — because each key id rode inside
its entry's GCM tag as aad, so key A simply doesn't authenticate against
entry B. The cryptography double-checks the broker.
And every failure mode collapses to the same answer on purpose. Wrong
key, tampered bytes, mismatched label — all surface as
:auth_failed, indistinguishably. Flip any single byte of the
nonce, the tag, or the ciphertext and you get :auth_failed; a
truncated envelope is :malformed; something that isn't a sealed
envelope at all is :not_sealed. It fails closed, it never
crashes, and it never tells an attacker which thing was wrong. Each
entry gets its own key precisely so that revoking one doesn't unlock the
rest.
sealing the key ITSELF
The escrow store solves custody, but a careful reader notices it's still a central place holding plaintext keys. Breach the runtime's memory and you have every key it holds. Can we do better — can the broker hold something it can't use even if it's stolen?
Yes: sealed-box key wrap. Instead of storing the plaintext content key, the broker stores the content key encrypted to a specific recipient. The mechanism is ephemeral Diffie-Hellman: a throwaway X25519 keypair does ECDH against the recipient's public key to derive a key encryption key, which AES-256-GCM-wraps the content key. Anyone holding the recipient's public key can wrap to them; only the recipient's private key can unwrap. The runtime stores only wrapped keys — one per authorized recipient — never the plaintext.
The consequence is the whole argument: there is no central plaintext-key honeypot. A breach of the broker leaks ciphertext plus wrapped keys — more ciphertext — not content. The wrapped key carries its own magic, distinct from a sealed entry's, so the two lanes never confuse each other:
Unwrap fails closed exactly like seal does: wrong recipient, tamper, or
wrong label all give :unwrap_failed indistinguishably;
truncated is :malformed; non-magic is :not_wrapped;
and a malformed ephemeral point is guarded so it can never crash the
runtime. The release lane composes cleanly: the posture-gated broker
releases the wrapped key, and the recipient unwraps it locally on
their own machine. The broker never touches plaintext at any point.
how the KEK is DERIVED
depth rung · skippable — the key-encryption-key recipe
The key encryption key is a single SHA-256 over a domain tag, the shared
ECDH secret, both public keys, and the entry's id —
SHA-256("wbkw1-kek" || shared || eph_pub || recipient_pub || info).
It's a hash-concatenation KDF, not HKDF — named plainly because honesty is
this page's register. What matters is what each ingredient forecloses:
| ingredient | attack it forecloses |
|---|---|
domain tag "wbkw1-kek" | cross-protocol reuse of the same hash |
| shared ECDH secret | anyone without the private key — the basic lock |
| both public keys bound in | unknown-key-share and key-reuse attacks |
info (the key id) | repurposing a wrapped key for a different entry |
The id, info, is bound into both the KEK derivation
and the GCM aad — belt and braces. Bind a wrapped key to its entry in two
places and a stolen wrapped key can't be retargeted at a different door,
even by someone who understands the format. Binding both public keys is the
defense against unknown-key-share: an attacker can't trick a recipient into
decrypting under a key the attacker secretly also knows, because the
recipient's own public key is baked into the derivation.
one identity, two CURVES
depth rung · skippable — the Ed25519 → X25519 bridge
Sealed-box wrap needs the recipient's X25519 public key. But a tenant
already has an identity — a did:key:z6Mk… they sign with, minted
from their Ed25519 signing key. The W3C did:key spec separates
verification material (signing) from key-agreement material (encryption), and
the elegant fact is that one can be derived from the other. So a
tenant gets an encryption identity for free, with no extra published key.
flowchart TD did(["did:key:z6Mk…"]) --> dec["base58btc + multicodec decode
expect 0xED 0x01 prefix"] dec --> ed["ed25519 public key (32 bytes)"] ed --> map["public map
u = (1+y)/(1-y) mod 2^255−19"] map --> x["x25519 public key"] x --> wrap["KeyWrap target — wrap to recipient"] seed(["recipient's ed25519 seed
(their machine only)"]) --> clamp["x_priv = clamp( SHA-512(seed)[0..32] )"] clamp --> unwrap["unwrap locally"] style did fill:#a8d4f0,stroke:#121316,stroke-width:2.5px style wrap fill:#aee5c2,stroke:#121316 style seed fill:#f2ddb0,stroke:#121316 style unwrap fill:#aee5c2,stroke:#121316
The public map is one line of field arithmetic: the birational map from
the Edwards y-coordinate to the Montgomery u-coordinate,
u = (1+y)/(1-y) mod 2^255−19 — exactly what libsodium's
crypto_sign_ed25519_pk_to_curve25519 does. The private map
clamps the first 32 bytes of SHA-512(seed) per RFC 7748. And the
whole thing is pure Elixir bignum arithmetic in roughly 150 lines —
the runtime carries only :crypto and :jason, no
libsodium NIF. Critically, the host only ever needs the public map:
a remote recipient derives their private X25519 key client-side from their
own seed, so the runtime never sees it. The runtime needs the private map
only for self-escrow.
The DID is minted in the form Radicle and the W3C use:
"did:key:z" <> base58btc(<<0xED, 0x01>> <> ed_pub).
Non-canonical points are rejected; a did:web: or other method
returns {:error, {:unsupported_did, _}}. One identity does both
jobs — it signs, and now it receives keys — with zero extra material
published. There is a real catch here, and it's the best story on the page;
it's told straight in the honesty section.
what an attacker GETS
The question a security-minded reader actually wants answered: at each point an attacker might get a foothold, what do they walk away with? Here is the threat model, point by point.
| attacker has… | …and gets |
|---|---|
| the bundle alone | ciphertext. The key is not in the file — the test asserts the manifest never carries a key field. |
| the bundle + the wire | still ciphertext, assuming transport is encrypted. (We assume TLS; this page won't overclaim what's outside it.) |
| a breach of the broker, escrow mode | the plaintext keys the runtime currently holds in memory — this is the honeypot the wrap lane removes. |
| a breach of the broker, wrapped mode | ciphertext + wrapped keys, which are more ciphertext. No content. No plaintext key anywhere to steal. |
| a stolen recipient device | that recipient's private key, hence whatever was wrapped to them — and nothing more. Other recipients are untouched. |
The deny path is tested literally: in the key-release test, the denial response is asserted not to contain the base64 key string anywhere. The key bytes provably never appear in a refused response — not in an error message, not in a header, nowhere. The shape of "no" is the same shape as every other "no," and it carries nothing the attacker can use.
where escrow ENDS
Four honest limits, stated rather than buried.
The store is in-memory. ETS lives in runtime memory; restart the runtime and the keys are gone. This is a v1 store, not an HSM, and there's no rehydration path in the code today. It's the smallest thing that works, and we'd rather call it that than dress it up.
The DID-wrapped release lane is built but not wired — on purpose,
and this is the story we're proudest of. The KeyWrap machinery and the
Ed25519→X25519 bridge exist and are tested as primitives: the round-trip
test (wrap, then unwrap with a seed-derived keypair) passes. But the public
map has not been confirmed against a verified libsodium reference
vector. A candidate vector was found — 3d4017c3… mapping to
efc6c9d0… — but its provenance couldn't be confirmed, the
numbers didn't line up, and the test for it sits in the source marked
@tag :skip with the doubts written into the file. So the release
endpoint stays escrow-only — it hands back the raw key to an
authenticated caller — until a verified vector lands. We refused to trust
math we couldn't verify against a known-good source. That restraint is the
feature.
The KDF is hash-concat, not HKDF, and the release gate is binary — authenticated or not, not yet per-key ACLs. Both are honest current shapes, not the final word.
And none of this is DRM. Escrow gates access. A recipient who is legitimately authorized, gets the key, and then leaks the plaintext is outside what this protects — by design. This is access control over a key, not a claim that a recipient can be stopped from doing what they're allowed to do.
questions people actually ASK
Is the key ever in the bundle?
No — and it's not a promise, it's a test. The ship code adds
key_refs only, and the suite asserts the manifest never has a
key field. The carton carries a reference to a keyring, never
a key.
What happens when release is denied?
You get the uniform error envelope —
{"error":{"code":"unauthorized","retryable":false}} — the same
shape any unauthorized RCP call returns. A test asserts the key bytes are
provably absent from the deny body. A refusal reveals nothing, not even
what was refused.
Can one leaked key unlock everything?
No. Every entry gets its own key, and the entry's id is bound into the
GCM tag as aad — so a key for one entry won't authenticate against another.
Revoking one is Escrow.delete, idempotent, and it leaves the
rest sealed.
How do I revoke?
Escrow.delete(key_id). Idempotent — safe to call twice —
and per-entry by construction. Pull the key and every copy of that bundle,
everywhere, can no longer be opened. That's the dead-man switch.
Why does the wrapped key have its own magic, wbkw1 vs wbseal1?
So the two lanes can never be confused. A sealed entry and a
wrapped key are different things with different layouts; distinct
magic bytes mean an opener can tell instantly which it's holding and refuse
a mismatch as :not_sealed or :not_wrapped rather
than misparsing.
Why X25519 if my DID is Ed25519?
Signing and key-agreement use different curve forms. The W3C
did:key spec separates them, and the runtime derives your
encryption (X25519) key from your signing (Ed25519) key with a birational
map — so one DID does both jobs with no extra published material.
keep GOING
Escrow is the release side of sealing. The pages around it own the other halves — the lock itself, the gate's grammar, and the credential you present.