sharing shares CONTENTS
The bundles lesson ended on a warning worth picking back up as a wound: sharing a bundle shares its contents. Anyone you send the file to can unzip it. That's exactly what you want when the whole point is to hand someone a working thing — and exactly what you don't want the moment part of the disk shouldn't be readable.
Picture the cases. A free demo carrying a paid dataset. A shared dashboard whose numbers are public but whose salary column isn't. A client deliverable where the result ships but the workings stay yours. The project should travel; one section of it shouldn't be legible to whoever holds the file.
Every familiar answer is theater. A password field drawn over data the file already contains. A "hidden" sheet. An obfuscated blob. They all share one fatal assumption — that the client will enforce the gate. But the attacker owns the client and owns the bytes. Any check the client runs, the client can skip; any data the file contains, the holder can extract. A gate the client enforces is not a gate. It's a suggestion.
the DEFINITION
1. a region of a shipped disk that is AES-256-GCM ciphertext to everyone, whose content key never rides in the artifact — escrowed at the runtime, released only after a posture check, and optionally wrapped to a recipient identity.
The envelope itself — the wbseal1 magic, the IV, the GCM tag,
the per-entry fresh key — was covered in bundles.
This page is everything above the envelope: the binding that welds a
key to its section, the release flow with its real identity rules, what escrow
actually is, and the wrap layer that removes the last plaintext honeypot. The
envelope is the lock; this is the locksmith.
a key welded to its SECTION
Start with a subtle attack, because killing it explains the design. A sealed entry is just ciphertext with a tag. What stops a holder from taking the ciphertext for the cheap section, relabelling it as the expensive one, and opening it with the expensive section's key the moment a broker hands that key out? Or a confused — or malicious — broker that simply returns the wrong key for the wrong section?
The answer is in the GCM tag. When the engine seals an entry, it binds the
entry's key id into the authentication tag as additional authenticated
data — seal(plaintext, key, key_id). Opening checks the same
binding. So a key is welded to exactly one section's identity. Move the
ciphertext, mismatch the label, hand the right key to the wrong entry — the tag
fails. Not with wrong plaintext. With a clean, uniform error.
flowchart TD
brokerL["broker — legit pairing"]
brokerX["broker — confused or hostile"]
subgraph entries["two sealed entries"]
direction TB
a["entry A
aad = key_id A"]
b["entry B
aad = key_id B"]
end
brokerL -- "key A → entry A" --> a
a -- "tag matches" --> okA["plaintext A"]
brokerX -- "key A → entry B" --> b
b -- "tag fails — aad mismatch" --> fail["error · auth_failed"]
style entries fill:#fbfaf6,stroke:#121316
style a fill:#a8d4f0,stroke:#121316
style b fill:#a8d4f0,stroke:#121316
style okA fill:#13d943,stroke:#121316,stroke-width:2.5px
style fail fill:#f3c5a3,stroke:#121316,stroke-width:2.5px
style brokerL fill:#ffffff,stroke:#121316
style brokerX fill:#ffffff,stroke:#121316
Read the graph as two hands reaching for two locks. The honest hand gives key A to entry A; the tag matches and plaintext A falls out. The other hand — confused or hostile — gives key A to entry B; the tag fails because the key id baked into entry B's tag is not A's, and the result is the same closed error, not a wrong decrypt. This isn't a claim — it's a cross-wiring test in the engine's suite, a broker that maliciously returns key A for entry B, asserting the failure.
The key id, meanwhile, is no secret. It's mechanical:
prefix:url-base64(path). Seal vfs.sqlite in a workbook
named shop and the key id is shop:dmZzLnNxbGl0ZQ —
the path, encoded, nothing about it hidden. The name of the lock is public.
What stayed home is the 32 bytes that open it.
the RELEASE
If the key isn't in the file, where is it, and who decides you get it? The
key is escrowed at the runtime, and one endpoint hands it out:
POST /rcp/key/:key_id — the same HTTP surface as any other runtime
call, with the auth plug running first. A sealed entry is gated by definition,
so the endpoint enforces the strictest posture it has:
enforce(:gated_data, :data, identity). That demand requires a real,
authenticated identity — and it is picky about what "real" means.
sequenceDiagram
participant O as the opener
participant A as runtime auth
participant X as access gate
participant E as escrow
O->>A: POST /rcp/key/shop:dmZzLnNxbGl0ZQ
A->>A: resolve identity from JWT
alt no real identity (nil · "" · "dev")
A-->>O: 401 — same envelope as any unauthorized call
else authenticated
A->>X: enforce(:gated_data, :data, identity)
X->>E: get(key_id)
alt key present
E-->>O: 200 {key_id, algo, key}
else unknown key
E-->>O: 404 not_found — only authed callers learn this
end
end
Walk the sequence as one request with two endings. The opener posts the key
id. Auth resolves an identity from a verified token. If there's no real
identity behind it — nil, the empty string, or the literal
dev-fallback user id "dev" — the request dies at 401
with the same unauthorized envelope as any other rejected call. No
oracle: a denied key release looks identical to a denied anything-else. If the
identity is real, the gate enforces the gated-data posture, escrow is consulted,
and an authorized caller gets 200 with
{key_id, algo: "aes-256-gcm", key} — the raw content key, base64,
for a client-side decrypt. A missing key, post-auth, is an honest
404 — because existence is only ever disclosed to someone already
through the door.
Here are both verdicts as transcripts, straight from the router test:
POST /rcp/key/shop:dmZzLnNxbGl0ZQ (no credential)
→ 401 {"error":{"code":"unauthorized", …}}
the same envelope as ANY unauthorized call —
the body provably never contains the key
POST /rcp/key/shop:dmZzLnNxbGl0ZQ
Authorization: Bearer <JWT for user-1 @ org-acme>
→ 200 {"key_id":"shop:dmZzLnNxbGl0ZQ",
"algo":"aes-256-gcm",
"key":"<base64, 32 bytes>"}
The single-tenant dev fallback — an x-tenant header, or the
bare "dev" user — is refused by the gate on purpose. In
multi-tenant mode the identity comes from a verified JWT, and only that opens a
sealed section. The end-to-end path — auth plug, dispatch, allow returns the
exact escrowed key, no-credential gets 401 with the key's base64
provably absent from the body — runs through the real router in the test suite.
the dead-man SWITCH
Escrow is not a new database. It's the same lightweight, runtime-held state
the host already keeps — an in-memory table on the running node. Three verbs
live on it: put mints and stores a key when you seal, get
releases it after the gate, and delete is revocation — idempotent,
one line.
That delete is the whole point. Keys are per section, so revoking one doesn't unlock the rest. And every shipped copy of a bundle — the one you emailed, the one on a USB stick, the one a stranger forked — dials the same escrow for the same key id. Delete that one row and all of them go dark at once.
flowchart TD e["escrow row
shop:dmZzLnNxbGl0ZQ"] c1["shipped copy · email"] c2["shipped copy · USB"] c3["shipped copy · a fork"] e -. "release on posture check" .-> c1 e -. "release on posture check" .-> c2 e -. "release on posture check" .-> c3 del["Escrow.delete(key_id)"] --> e e -- "row gone" --> dark["all three sections — permanent noise"] style e fill:#a8d4f0,stroke:#121316,stroke-width:2.5px style del fill:#f3c5a3,stroke:#121316,stroke-width:2.5px style dark fill:#121316,stroke:#121316,color:#ffffff,stroke-width:2.5px style c1 fill:#d9dbd3,stroke:#121316 style c2 fill:#d9dbd3,stroke:#121316 style c3 fill:#d9dbd3,stroke:#121316
Read the diagram as one row feeding three mouths. Three shipped copies each
reach back to the same escrow row to be fed their key after the check. Call
Escrow.delete("shop:dmZzLnNxbGl0ZQ") and the row is gone — so all
three copies, wherever they are, become permanent noise simultaneously. There's
no key to retrieve, no decrypt to attempt. This is the real dead-man switch the
envelope was built to support: not a remote wipe of files you no longer hold,
but the quieter, stronger thing — the files stay exactly where they are and
stop meaning anything.
removing the HONEYPOT
depth rung · skippable — the wrap layer
An honest objection: so the runtime holds all the plaintext keys? On the release path described so far, yes — escrow stores raw content keys, and a breach of the broker would leak them. That's a honeypot, and the wrap layer exists to remove it.
The trick is a sealed-box wrap. Generate an ephemeral X25519 keypair, do an ECDH against the recipient's X25519 public key, and the shared secret derives a key-encryption key that AES-256-GCM-wraps the content key. The runtime then stores only wrapped keys — one per authorized recipient — never the plaintext content key. A breach now leaks ciphertext and wrapped keys: still nothing readable. Reading takes two factors — holding the recipient private key and the broker releasing your wrapped key after the posture check.
wbseal1 figure — one layer upThe binding is what makes a wrapped key serve exactly one section. The KEK
is derived as SHA-256("wbkw1-kek" || shared || eph_pub || recipient_pub
|| info) — folding both public keys in defends against key-reuse and
unknown-key-share attacks. And info is the key id, bound into
both the KEK derivation and the GCM AAD. So a wrapped key minted for
one section cannot be repurposed for another: the info won't match, and unwrap
fails closed. That repurpose-fails property is a test, not a hope.
one identity, two CURVES
depth rung · skippable — the DID bridge math
Wrapping needs the recipient's X25519 public key. But a did:key
identity — the did:key:z6Mk… string — is Ed25519 signing
material. Ed25519 signs; it doesn't do key agreement. The W3C spec keeps the
two separate. So does the recipient have to publish a second key just to receive
sealed sections? No — and not publishing one is the elegant part.
The signing key and the agreement key are birationally related. From the
Ed25519 public point you can compute the X25519 public key directly:
decompress the point, recover y, and apply the map
u = (1 + y) / (1 − y) mod 2²⁵⁵−19 — exactly libsodium's
crypto_sign_ed25519_pk_to_curve25519. The recipient derives the
private side from their own seed: SHA-512(seed)[0..32], clamped
per RFC 7748. The host only ever needs the public map; the recipient keeps the
seed. One identity, both curves, nothing new to publish.
flowchart LR did["did:key:z6Mk…"] --> dec["base58btc decode
0xED01 || ed25519_pub"] dec --> edpub["Ed25519 public point"] edpub --> map["birational map
u = (1+y)/(1−y) mod 2²⁵⁵−19"] map --> xpub["X25519 public key"] xpub --> wrap["wrap target"] seed["recipient seed"] --> sha["SHA-512(seed)[0..32]"] sha --> clamp["RFC 7748 clamp"] clamp --> xpriv["X25519 private key
— recipient side only"] style did fill:#a8d4f0,stroke:#121316 style xpub fill:#aee5c2,stroke:#121316,stroke-width:2.5px style wrap fill:#13d943,stroke:#121316,stroke-width:2.5px style xpriv fill:#f2ddb0,stroke:#121316 style seed fill:#ffffff,stroke:#121316
Trace the two lanes. The top lane is public and runs on the host: take the
did:key string, base58btc-decode it to the multicodec-prefixed
Ed25519 public key, recover the point, run the birational map, and you have the
X25519 public key to wrap to. The bottom lane is private and runs only on the
recipient: their seed through SHA-512 and an RFC 7748 clamp yields the matching
X25519 private key. It's pure Elixir field arithmetic — no libsodium NIF; the
runtime carries only :crypto and :jason.
One honesty note this lesson will not bury: the public map is not yet
verified against published libsodium vectors. The candidate vector didn't
match and its provenance is unconfirmed. The seed-derived round trip — wrap then
unwrap with the derived keypair — does pass. So the rule holds firm: the
key-release endpoint stays escrow-only until a libsodium reference
confirms the public map. The DID-wrapped release is built and adversarially
tested as a primitive — but it is deliberately not wired into the
endpoint. Today POST /rcp/key/:key_id returns the raw escrowed key
to a posture-approved caller, full stop. We won't oversell a number the math
hasn't been checked against.
the attack TRANSCRIPT
The trust here is built by showing every illegitimate path and its exact result. Each row below is a test in the engine's adversarial suite, and every failure is the same shape on purpose — there is no oracle to probe, only a key you don't have:
| the attempt | attacker holds | result |
|---|---|---|
| flip one byte of iv / tag / ciphertext | the bundle | {:error, :auth_failed} |
strip the wbseal1 magic, feed the raw bytes | the bundle | {:error, :not_sealed} |
| truncate the envelope | the bundle | {:error, :malformed} — never a crash |
| broker returns entry A's key for entry B | a real released key | {:error, {"b", :auth_failed}} — AAD welds key→section |
| wrong-size key from a buggy broker | a 16-byte "key" | error tuple, not a crash |
| unwrap with a different recipient's keypair | the wrapped key | {:error, :unwrap_failed} |
| reuse a wrapped key for another section | a legit wrapped key | {:error, :unwrap_failed} — info mismatch |
garbage ephemeral point (crafted wbkw1) | nothing | {:error, :unwrap_failed} — the raise is caught |
| denied release, then approved release | patience | sealed stays sealed; then opens to identical bytes |
The verdict of the table in one sentence: every illegitimate path lands on a
closed, uniform error, and the one legitimate path at the bottom — wait for the
posture to approve — opens to the exact original bytes. The byte-flip rows track
the envelope layout precisely: the magic occupies bytes zero through six, the IV
seven through eighteen, the tag nineteen through thirty-four, the ciphertext
thirty-five on — and the test flips a byte in each region, getting
auth_failed every time. A malformed or truncated envelope returns a
clean error rather than crashing. A non-envelope binary is recognized as
not-sealed rather than mis-opened. There is nothing to learn from a failure
except that it failed.
sealing the disk ITSELF
Here's the tie back to the parent lesson. The sealable parts of an engine
ship are the workbook's HTML shell and its vfs.sqlite — and
sealing the VFS seals the entire disk as one entry. Ship a workbook with
gated: ["vfs.sqlite"] and the shell stays plaintext while the whole
disk becomes ciphertext:
blob = Workbooks.Bundle.ship("shop", html, vfs_bytes,
gated: ["vfs.sqlite"])
-- unzipped, the recipient sees:
workbook.html opens fine — the shell is plaintext
vfs.sqlite begins "wbseal1" + iv + tag + noise;
`sqlite3 vfs.sqlite` says "not a database"
manifest.json carries the only trace of the gate:
"key_refs": {
"vfs.sqlite": {
"key_id": "shop:dmZzLnNxbGl0ZQ",
"algo": "aes-256-gcm"
}
}
The manifest names which entry, which key id, which algorithm — and never
the key. The key id is just shop: plus the url-base64 of the path;
nothing about the name is secret. The secret is the 32 bytes that stayed home.
This is proven: the ship-sealed test asserts the HTML stays plaintext while the
VFS is a wbseal1 envelope.
And it composes with the privacy strip, which is a different wall entirely.
The strip runs before sealing: unless you opt into private volumes,
only the public workspace volume ships at all — memory
and tmp never leave. So there are two walls in one sentence: the
strip protects what never ships; the seal protects what ships,
locked.
where the seal ENDS
Honesty section — and it's the real trust-builder, so none of it is buried.
Granularity is the bundle entry, not a path inside the VFS. Want one private folder while the rest of the disk stays open? Today you seal the whole disk, or you restructure what ships. The unit of sealing is the entry, and the VFS is one entry.
Escrow is in-memory. The keys live in runtime-held state on a running node; they do not survive a runtime restart, and nothing in the system re-escrows them automatically. Revocation-by-default is the upside of that — but if you need durable escrow, that's your job today, not the system's.
DID-wrapped release is built and tested, but not wired. Release is escrow-only — the raw key to a posture-approved caller — until the curve map is independently confirmed against libsodium vectors. The wrap layer is a primitive with passing adversarial tests, deliberately held back from the endpoint.
This is access control, not DRM. A recipient who legitimately decrypts a section can keep the plaintext. The seal decides whether you get the key, not what you do once you have it. Anyone who tells you software can do the latter is selling you the password-field theater from the top of this page.
Never bake a gated workbook into a public static file. Client-side gating is theater, which is exactly why the publish path carries an anti-leak guard — a sealed, gated workbook must not be flattened into a static artifact that ships the data it was supposed to gate. The posture taxonomy is its own lesson; here it's enough to say the publish step refuses to let a gate become a suggestion.
questions people actually ASK
Is this DRM?
No — and that's the honest answer, not a dodge. It's access control: it decides whether you receive the key, after a posture check. Once a legitimate recipient decrypts a section, they hold the plaintext and can keep it. Software that promised to control what you do after you can read the bytes would be back to enforcing a gate on a client the holder owns — the theater this whole page argues against.
What if the runtime disappears?
Sealed sections go dark — that's the dead-man switch's other face. The key
lives at the runtime, and escrow is in-memory today, so if
the runtime is gone the key is gone and the ciphertext is permanent noise. Keep
an unsealed copy of anything you can't afford to lose. The same property that
lets you cut someone off in one delete means you can cut yourself
off too.
Can I seal just one folder?
Not today. The unit of sealing is the bundle entry, and the VFS ships as one entry — seal it and you've sealed the whole disk. For one private folder you either seal the whole disk or restructure what ships into separate entries. It's in the honesty section above because it's a real limit, not a footnote.
Why can't I just put a password on it?
Because a password gate is enforced by the client, and the key the password "unlocks" would have to ride inside the file for the client to check it. The holder owns the file and the client — they can read the key or skip the check. A sealed section's key never ships. There's nothing in the file to bypass, only ciphertext, and the key is somewhere else entirely.
Does the recipient need an account?
For the release path, yes — a real authenticated identity, because the gate
refuses nil, empty, and the dev fallback. Once the DID rung is
wired, the same did:key identity used for signing
becomes the wrap target, and the key arrives wrapped to that identity — but that
step is built, not yet live.
What does the runtime see when it releases a key?
On the escrow path — the one that's live today — the raw content key. That's exactly the honeypot the wrap layer removes: with wrapping, the runtime stores only keys wrapped to each recipient and never sees plaintext, so a breach leaks ciphertext and wrapped keys, nothing readable. Until DID-wrap is wired, releasing means the runtime briefly holds and hands out the raw key — and we'd rather you know that than assume otherwise.
keep GOING
The seal makes the most sense once you've met the carton it locks and the disk it locks down.