learn / 09·5 — under vfs · sealed sections

a key thatSTAYShome

A sealed section is part of a shipped disk that reads as ciphertext to everyone — its key never rides in the file. The key lives at the runtime and is handed out by one endpoint that runs the same access check as everything else. Delete the key and every shipped copy goes dark — at once, everywhere, for good.

sealed sections11 min read
A tiny figure stands before a colossal bright vault door set into the spine of an enormous open book, the door's seam glowing, a single key hovering far away on its own pedestal beyond the figure's reach — monumental 1970s sci-fi style, vivid

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

sealed section /siːld ˈsɛkʃən/ noun

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.

a wrapped key — the wbkw1 envelope
wbkw15-byte magic — this is a wrapped key
eph_pub32 bytes — the ephemeral X25519 public key
iv12 bytes — the nonce
tag16 bytes — the GCM tag
ciphertextthe wrapped content key
the twin of the wbseal1 figure — one layer up

The 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 attemptattacker holdsresult
flip one byte of iv / tag / ciphertextthe bundle{:error, :auth_failed}
strip the wbseal1 magic, feed the raw bytesthe bundle{:error, :not_sealed}
truncate the envelopethe bundle{:error, :malformed} — never a crash
broker returns entry A's key for entry Ba real released key{:error, {"b", :auth_failed}} — AAD welds key→section
wrong-size key from a buggy brokera 16-byte "key"error tuple, not a crash
unwrap with a different recipient's keypairthe wrapped key{:error, :unwrap_failed}
reuse a wrapped key for another sectiona 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 releasepatiencesealed 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.