learn / 09·9 — under vfs · escrow

who holdsTHEkey

The bundles lesson told you part of a shipped disk can be ciphertext — a lock the recipient can't open. Escrow is the other half of that sentence: where the key actually lives, who decides to release it, and why handing over a key is not a file operation but an access decision. The bundle can sit on a USB stick in Minsk; the decision still happens at the engine, against a real identity.

the key11 min read
A single small figure standing before a monumental bright vault door set into a mountainside, one enormous brass key suspended in a beam of light high above and out of reach, the figure looking up — 1970s sci-fi style, warm and luminous

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

es·crow /ˈes·kroʊ/ noun

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:

calldoesnote
put(key_id, key)store a content keyoverwrites — last write wins
get(key_id){:ok, key} or {:error, :no_such_key}what the release endpoint calls
delete(key_id)revocationidempotent — deleting twice is harmless
keyfn(prefix)mint + escrow a key per pathcalled 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:

a wrapped key — the wbkw1 envelope
wbkw15-byte magic — this is a wrapped key, not a sealed entry
eph_pub32 bytes — the ephemeral X25519 public key
iv12 bytes — the GCM nonce
tag16 bytes — the GCM authentication tag
ciphertextthe wrapped content key
deliberate rhyme with the wbseal1 card — same grammar, different job

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:

ingredientattack it forecloses
domain tag "wbkw1-kek"cross-protocol reuse of the same hash
shared ECDH secretanyone without the private key — the basic lock
both public keys bound inunknown-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 aloneciphertext. The key is not in the file — the test asserts the manifest never carries a key field.
the bundle + the wirestill ciphertext, assuming transport is encrypted. (We assume TLS; this page won't overclaim what's outside it.)
a breach of the broker, escrow modethe plaintext keys the runtime currently holds in memory — this is the honeypot the wrap lane removes.
a breach of the broker, wrapped modeciphertext + wrapped keys, which are more ciphertext. No content. No plaintext key anywhere to steal.
a stolen recipient devicethat 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.