# 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](./sdk.md) · every call: [the reference](./sdk-reference.md#the-surface). --- ## The confidential tier {#confidential} 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: ```js 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_accepted` rather 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_unavailable` means it could not be read, and a `viewer` of your own must serve it (or set `enclaveClaim`). - The tier is chosen when a room is made; an existing room cannot be moved into it. How the tier works: [the confidential tier](./the-attested-tier.md). ### What a room can prove {#certificates} - **`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. `failed` lists `{ id, reason }`; `unchecked` counts 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). `null` before the first message. The [Confidential Room example](https://witbitz-spaces.pages.dev/confidential-example.html) shows it live. ### Checking an enclave with a standard token (EAT) {#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. ```js 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_PROFILE` is the profile the token must name. - **Where tokens appear.** Every A2A lane's Agent Card names this endpoint (`capabilities.extensions`, uri `https://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/.results.json`. - **Without the SDK:** `node tools/eat-verify.mjs https://agents.witbitz.chat`. - **The profile:** an ES256 JWT; `measurements: [[18, ]]` (COSE_Sign1); the document's `nonce` is `eat_nonce`, and its `public_key` signed the token; `user_data` carries the door's TLS key pin. ## Split-key rooms {#split-key} ```js const wb = new Witbitz({ appShare: '/api/appshare' }) // your function, on your origin const room = await wb.createSpace({ key: 'split', admission: { emails: [...] } }) room.shareLink // …#us=&spc=: the link alone does not open the room ``` A 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](./sdk-accounts.md#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_mismatch` otherwise). It is chosen per room; rooms without `key: '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`, and `split_mismatch` when the halves do not match the commitment. On the page, `openSpace` throws `origin_not_declared`, `split_unopenable` or `split_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](./sdk-builder.md#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 {#vault-mode} 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. ```js // 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 token ``` `wb.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 {#vault-kit} The vault is a kit you build once and host yourself; Witbitz hosts nothing for it: ```bash 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 ` (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) and `screens.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), plus `core/`, everything that touches a key. Do not edit `core/`: a new run of the kit replaces only that folder, so your screens survive an upgrade. - **Serving it.** `HEADERS.md` lists the headers to serve it with (nginx, Caddy, Apache) — among them `Cross-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`](./sdk-builder.md#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 {#vault-protects} - **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`, the `signedIn` event), 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 `createSpace` sends, 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 {#vault-differences} 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: `. | | **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](#split-key) 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](./sdk-people.md#members)). | | **Backup and account** | [`wb.backup`](./sdk-accounts.md#backup), [`wb.account`](./sdk-accounts.md#account-index) and [`wb.erasure`](./sdk-builder.md#user-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](./sdk-accounts.md#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](./sdk-people.md#fork) 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`](./sdk-computers.md) call rejects with `vault_mode`, because the computer's keys would have to sit in the page. |