Where the keys live — the confidential tier, split keys and vault mode
By default a room's key is made in your page and kept there — in its link, and in whatever you save. Three choices change that: the confidential tier seals the key to a verified enclave for the room's turns, a split-key room leaves half of the key with your own server, and vault mode keeps every key out of your page.
Part of the SDK · every call: the reference.
The confidential tier
A confidential room's turns run inside an attested AWS Nitro enclave, and the room key is sealed to that enclave rather than handed to Witbitz's server:
const space = await wb.createSpace({ agent: { name: 'Vault' }, tier: 'enclave' })- What the create does. It checks the live enclave's attestation first, makes the room, waits for the enclave, and gives it the room's sealed settings before any turn may run — so the first message already reaches the assistant you asked for. It takes longer than a plain create: it is waiting on real hardware.
- The build is checked, not advisory. If the enclave that answers is not a build Witbitz publishes, the SDK refuses
to seal the room key to it and throws
enclave_not_acceptedrather than falling back. Treat that as "send nothing", not as a passing error. - The published build is read from the
viewer's origin (/enclave-claim.json);enclave_claim_unavailablemeans it could not be read, and aviewerof your own must serve it (or setenclaveClaim). - The tier is chosen when a room is made; an existing room cannot be moved into it. How the tier works: the confidential tier.
What a room can prove
await space.certificates()→{ verified, failed, unchecked, before, builds, calls, tools }. In a confidential room every reply is signed inside the enclave with the build's measurement, each model call's verified receipt and each tool call. This checks the history loaded on the device: the attestation, the published audit verdict, each signature, and each reply's text.failedlists{ id, reason };uncheckedcounts replies whose attestation rides an earlier reply this handle has not loaded, which is not a failure. A standard room has none.await space.sealedProof(), in any room: what the server actually stores for it — the opaque, sealed history, its size and hash, and who can open it (never the operator).nullbefore the first message. The Confidential Room example shows it live.
Checking an enclave with a standard token (EAT)
Witbitz's enclaves can prove themselves with an Entity Attestation Token (RFC 9711): a JWT signed by a key the enclave made, carrying the AWS Nitro attestation that binds that key to the enclave's measured build, for a nonce you choose.
import { fetchAndVerifyEat } from 'https://witbitz-spaces.pages.dev/sdk/3.10.0/witbitz-sdk.js'
const r = await fetchAndVerifyEat('https://agents.witbitz.chat/eat', { expectedPCRs: { 0: [/* the build you trust */] } })
// r.ok, r.claims (eat_profile, swname, …), r.doc.pcrs, r.userData.siri: the door's TLS key pin- A token you already hold — one a peer gave you:
await verifyEat(token, { expectedPCRs, nonce })runs the same checks without the fetch.EAT_PROFILEis the profile the token must name. - Where tokens appear. Every A2A lane's Agent Card names this endpoint (
capabilities.extensions, urihttps://witbitz.chat/eat/nitro-v1), so an outside agent can check it is talking to the measured enclave rather than take our word. A confidential reply's turn certificate is itself an EAT (certToEat), and the audit's verdicts are published as attestation results at/audit/<subject>.results.json. - Without the SDK:
node tools/eat-verify.mjs https://agents.witbitz.chat. - The profile: an ES256 JWT;
measurements: [[18, <the Nitro document, base64url>]](COSE_Sign1); the document'snonceiseat_nonce, and itspublic_keysigned the token;user_datacarries the door's TLS key pin.
Split-key rooms
const wb = new Witbitz({ appShare: '/api/appshare' }) // your function, on your origin
const room = await wb.createSpace({ key: 'split', admission: { emails: [...] } })
room.shareLink // …#us=<half a key>&spc=<commitment>: the link alone does not open the roomA split room's key is two halves. The link carries one; your function holds the other. The function seals its half
under a key only you have (K_eco) before Witbitz stores it, and unseals it only for a page on your own origin.
openSpace(link) fetches that half, combines the two, and checks them against the commitment in the link before using
the key.
- Witbitz stores a half it cannot open: it never sees
K_eco. - A leaked link pasted into another website opens nothing, because a browser cannot fake its origin. A script can, so for an open room this is a policy gate; a gated room still needs an allowed sign-in on every operation.
- Deleting the room ends every link at once: the stored half goes with it.
- It does not hide the room from your own page, which puts the key together to use the room.
- The account backup keeps the whole key for split rooms, so a restore never depends on the stored half. A split room's lanes are split too, through your function.
- Limits: standard tier only for now (
tier_mismatchotherwise). It is chosen per room; rooms withoutkey: 'split'are unaffected.
Your function. Copy the template — appshare.js (a plain Request → Response handler), e2ee.js beside it, and
the wrapper for your host (cloudflare-pages.js or node-express.js) — and give it two secrets:
| Secret | What it is |
|---|---|
WITBITZ_TENANT_KEY |
your tenant secret key (the one your server passes as tenantKey): it registers the room and reads back the sealed half |
WITBITZ_ECO_KEY |
K_eco: 32 random bytes, base64 (node -e "console.log(require('crypto').randomBytes(32).toString('base64'))") |
- Back up
K_eco. If it is lost, a split room opens only from a member's account backup. - The function answers its own origin only, with no CORS header; rate-limit the route in your host. It answers
{ op: 'create', room, commit, appShare, … }, which seals the half and registers the room, and{ op: 'get', room }→{ appShare, commit }, which the SDK sends on open. - Errors reach your code by name:
cross_origin,no_appshare,appshare_unconfigured,unwrap_failed, andsplit_mismatchwhen the halves do not match the commitment. On the page,openSpacethrowsorigin_not_declared,split_unopenableorsplit_mismatch.
If you cannot host a function, leave appShare out and name your tenant (new Witbitz({ tenant })). Witbitz then
holds K_eco for your tenant — ask us to turn it on — and serves the half to your declared origins.
Everything above holds except the first point: Witbitz could open the half. With neither, the create is refused
(tenant_required, split_unavailable or seal_unconfigured) rather than making a room that cannot open.
Vault mode: no key in your page
In vault mode the room keys live in a vault: a small site you host on a second origin of your own (say
vault.acme.com), which shows your app (app.acme.com) inside it. Your app uses the SDK as usual and never holds a
key.
// your app page, served on app.acme.com and opened inside the vault
import { Witbitz } from 'https://witbitz-spaces.pages.dev/sdk/3.10.0/witbitz-sdk.js'
const wb = await Witbitz.inVault({ vault: 'https://vault.acme.com' }) // not_in_vault when not framed by it
const space = await wb.createSpace({ agent: { name: 'Assistant' } }) // the key is made in the vault and stays there
space.on('message', (m) => show(m))
await space.send('Hello')
await wb.vault.share(space) // the vault shows the invite link, which carries the key
const rooms = await wb.vault.rooms() // [{ room, title, at }]: no links
const again = await wb.vault.openRoom(rooms[0].room)
await wb.vault.signIn() // the vault's sign-in screen; your page learns the email, never the tokenwb.vault is a VaultSession: the vault's origin, what it said when the page connected (info), and these calls —
also whoami(), signOut(), forget(space), showSiriKey() and on('rooms' | 'joined' | 'signedIn' | 'signedOut' |
'account', cb).
Building the vault
The vault is a kit you build once and host yourself; Witbitz hosts nothing for it:
curl -O https://witbitz-spaces.pages.dev/sdk/3.10.0/vault-kit.mjs # Node 18+, no packages
node vault-kit.mjs ./vault --page https://app.acme.com/chat --vault https://vault.acme.com --tenant t_… --brand Acme- It is checked. The kit builds from the release it came with and checks every file against that release's
manifest.json. To check the manifest itself, pass its published hash,--manifest-sha <sha384-…>(the kit prints the one it read):
| Release | manifest.json |
|---|---|
| 3.10.0 | sha384-yZ7C+jV2zKD4bVPDJuiYJi7CSEqXjCHiuLTp+Mri6WsXl4rrhYDKLqMbBb6r7Sa5 |
| 3.9.0 | sha384-O54x1kV/gFYQMcY+R1rB4mhzFfc362yuPJl6YO6xlNPDIrWIoOoIF+xBXi0Tubzh |
| 3.8.1 | sha384-BOgk3f2wDJetxUQoNcTgb1T+nQzm0TKQ39nbgJch5GSCBUAeGjXhelrQ1tCG8UW0 |
| 3.8.0 | sha384-3UnAqfkT/reUouZuZ16dTHQs0IP1frHlxW+DfcYmScyrNhxMND7K4PHDYXG5gW0S |
| 3.7.0 | sha384-ErXcFYkF4PLKU9pl57Vj+S8GThFSSg93ibg1NaGjmMcihb1vQMpIbf0KCuMV8rdT |
| 3.6.1 | sha384-6cvolS5S4MYFbdvvz2Lh5em6wqAWoEO8rs1nnaTY5qF0IzNs+02T6rF5ZKg+9Tye |
| 3.6.0 | sha384-TInR2vsZxqAfsnx/sagRDrxbA1UVadEfVnGQ41F4kNuQOjw6oRLMhfNNtN5rvpjf |
| 3.5.0 | sha384-IaPuVH2xvt7SR3PaeYWnWvgPK/cPxCF30lqksLNnXElYtACtz4RHOTo+BCLRvjKX |
| 3.4.0 | sha384-nkO/egzP6J9E2WK35h0b+WBqB8KdprT8Kb5431tunYmnKcJK/6qk+0gySas9Y1D/ |
| 3.3.0 | sha384-nFk1GhoYEumCTysMDHAwO5m+wO0uoGykpMSAg0TegXjUFiheqh0BHw6TWTqcr/2+ |
| 3.2.0 | sha384-czJHGURTz8He5lvVQuPB4ejnl0B29zMyRCFMSZIGWPeEz+ppQMpIrGBQ/22hWdmC |
| 3.1.0 | sha384-UxVCdP2RxjJulee/A7DPESm5nLy7jKgedy2/CWTWbOT4Fo1td33THy2e0ndwlqa2 |
| 3.0.0 | sha384-uvG9uEylZQwDNynITog8HmsiI6kSEMa2zd5xx7p9qIsalUK8Y49riKLDATEagHqR |
| 2.2.4 | sha384-kX9P3Te3vMdh0UF6pvM4/0GiYcfR+ot8EBfWzLaLapPPrm1W3vnYbXrPsJqj8SnS |
| 2.2.3 | sha384-qC77F7CYYpYb5+nQkeGpruc0myKyPP9GYLe+IyMrjDu2qQQsnch9ef9a+ALTdHxX |
| 2.2.2 | sha384-tNPte4rW41HFwG9r/UoP1YKz1HKNrNLE2apSdKtbs8M90q+ZJkiHoT7FVgnnJRe0 |
| 2.2.1 | sha384-jnKIjgueEQsAfvA8xsEqaXhE2HJnR/lOrrjOUmQmJr4eJQM3gHT+qzX0TfrcpVy4 |
| 2.2.0 | sha384-DZL9h9OKKg/UKsmpajI7XPuUwF2FOqPq1Ir6othFmMKDLt5qlt++zdebDV+lp0yV |
| 2.1.2 | sha384-yJcmJ7MH5JBasfLeu0QXTUjkO7WvBOAfpblhXnWyhPeMQXgnf33VOHtFrdXeeiQO |
| 2.1.1 | sha384-J++TLx7Yhl2bxeLklJGsrHBTB74kk3/k3mUfUQYVs1fESrdGDal0wx5Gcry5irld |
| 2.1.0 | sha384-rYHqCNRtVUN5lh2DXbvPbuwYYsuUMKyRlpOYm1fuiPhkgOKJ9W69evU9Kl2tD++p |
- What is yours, and what is not. The folder holds
vault.js(your settings) andscreens.js(sign-in, share and "didn't load" — restyle them or write your own; each is a plain function that resolves when the person is done), pluscore/, everything that touches a key. Do not editcore/: a new run of the kit replaces only that folder, so your screens survive an upgrade. - Serving it.
HEADERS.mdlists the headers to serve it with (nginx, Caddy, Apache) — among themCross-Origin-Opener-Policy: same-origin, so a page that opens your vault in a window gets no handle on it — and your app page must allow the vault to frame it (frame-ancestors https://vault.acme.com). Declare both origins on your tenant (admin.origins): the vault makes the requests, so the server sees the vault's origin, and a room it creates is your tenant's. - Invite links point at the vault (
https://vault.acme.com/space?room=…#mk=…). The vault keeps the link, takes the key out of the address bar, and tells your page (wb.vault.on('joined', ({ room }) => …)).
What it protects, and what it doesn't
- Your page cannot read a key or keep one for later. The keys are in a worker on the vault's origin, and the browser keeps one origin out of another's memory and storage. A bug in your page, a compromised package or an injected script gets no key, no invite link and no sign-in token. Once a room is removed from the vault, the page cannot reach it. Signing out ends the page's access to gated rooms; an open room stays usable on that device until it is removed.
- What only the person can do, in the vault's own screens: remove a room (
wb.vault.forget), sign out, join a room from an invite link, change a room's members or rotate its key, remove a room from the account, take or release a username, remove a connection, let a room's assistant use one, and make a new Siri key or turn the Siri inbox off. Your page can ask; the vault asks the person. The page learns the signed-in email (whoami, thesignedInevent), and nothing else about the sign-in. - Your page sees the conversation it shows. Vault mode keeps keys out, not content.
- You host the vault too, so it protects keys from your page's code, not from you.
- The settings the page reads carry no connection key or funding grant, and the page's settings writes cannot grant either: those go through the vault's own calls, after the person says yes.
- The vault answers only what an app needs: the room operations of a room it holds (read, send, rows, settings,
approvals, reactions); creating a room (only the fields
createSpacesends, at most 20 in 10 minutes); the room list; sharing; sign-in. A raw key operation, a destructive one, a key or identity passed in, or a room it does not hold is refused (vault_refused,not_in_vault,vault_unsupported,rate_limited). It answers only the page it framed, on its own origin, and the page cannot navigate the vault's window away (the frame is sandboxed without top navigation).
The SDK inside a vault
Everything the SDK offers works in a vault, except cloud computers. What differs:
| In a vault | |
|---|---|
| Links | openSpace refuses (vault_opens_links) — the vault opens links. wb.vault.openRoom(room) takes a room id from wb.vault.rooms(); a room the vault does not hold is not_in_vault. openLane takes the lane's room id, not a link; wb.vault.rooms() marks a lane with lane: <its room>. |
| Sign-in | wb.signIn refuses (vault_signs_in); use wb.vault.signIn(). |
| Voice | wb.dictation(), wb.speak() and wb.speech get their short-lived speech key from the vault (its sign-in, or your wb.useUserToken()). |
| Live updates | Replies stream as they are written, as outside a vault: the socket is the vault's, since it needs the key, and the page gets its events. |
| Lanes | space.privateLane() works as outside: the vault makes the lane, keeps its key and records it as that room's lane, and the lane reads and posts into the room through the vault. setLinks, link and sealedProof() work too. |
| Confidential rooms | createSpace({ tier: 'enclave' }) works: the vault checks the enclave against Witbitz's published build and seals the key to it — only to a build Witbitz publishes. A lane of a confidential room is confidential; wb.vault.rooms() marks one tier: 'enclave'. Serve the vault with connect-src allowing the claim's origin (the kit's headers do). |
| Split-key rooms | createSpace({ key: 'split' }) works when your vault serves your app-half function at /api/appshare on its own origin and is built with --app-share (or startVault({ appShare: true })); otherwise vault_unsupported. The vault keeps only the person's half in its link and asks the function for the other. |
| Direct chats | createSpace({ direct: email }) works, with the vault's signed-in member as owner. |
| Members | changeMembers and rotateKey go through the vault, which asks the person (members). |
| Backup and account | wb.backup, wb.account and wb.erasure are the vault's: it records every room it holds, keeps the account's list in step across devices, and files the erase list. The recovery code is shown and typed only in the vault's screens: wb.backup.enable() resolves { enabled: true } with no code, and restore() opens the vault's restore screen and resolves { rooms }. The getters are the vault's last view, never a link or a key: state is null, keyFor() refuses, add() does nothing. One backup per account — another person signing in on the same vault sees none of it. On wb.account, archive, rename, forget and signOut return promises, and forget and signOut ask the person first; wb.backup.remove(room) returns a promise too (the vault asks the person; a no rejects with cancelled). |
| Connections | Connections, @usernames, the Siri inbox and outside agents work through the vault's sign-in, a lane's too. wb.connections.attach() returns { id, label, host, account }, plus mcp, webhook and job when the connection has them — the callKey stays in the vault — and space.connections.grant(conn) grants by id. MCP servers (startMcp, attachMcp, finishMcp, mcpTools) run through the vault the same way; startMcp's sign-in comes back to your page's /mcp-callback unless you pass redirectUri. |
| Siri | wb.siri.state is null and restore() refuses: the vault keeps the inbox's private keys. wb.siri.shortcut.key is null, because that key posts words into the person's rooms; the vault shows it in its own screen, wb.vault.showSiriKey(), and rotateKey() resolves null. space.siri.collect({ deliver }) hands each drop to your deliver. |
| Outside agents | wb.a2a.state is null (the vault keeps the lanes' inbox keys); lane.a2a.cardUrl, .mcpUrl and .peers are what the vault last said; addPeer returns { id, token, cardUrl, mcpUrl } as outside a vault. The vault asks the person before an outside agent may reach a lane, and before its answers may go without them. |
| Moves | An older client's move is followed by the vault, and the page gets 'moved' with { room, link: null }; a confidential room's move is refused (vault_unsupported), and one whose new room cannot be read yet gives 'moveRefused' with code: 'unreadable' and nothing is followed. |
| Cloud computers | Not in a vault yet: every wb.computers call rejects with vault_mode, because the computer's keys would have to sit in the page. |
Machine-readable source: sdk-keys.md · Generated 2026-10-27T07:05:42Z · build f301388c · every doc in one fetch: llms-full.txt (HTML)