The Verified Room — identity, admission and lifecycle
One definition of who is in a room and who said what — and the reason it is interesting: the same definition works with no server at all (true peer-to-peer) and with a server that verifies but cannot read. It is inherited from the open Kibitz engine, where it secures live peer-to-peer calls, and Witbitz has carried it — mechanism for mechanism — to asynchronous, end-to-end-encrypted Spaces, where a certified, content-blind function does the verifying when no peer is present.
This page is the whole subject: the definition and its primitive, the credential families, how membership is proved, and — the part that is usually skipped — removal, rotation and recovery, marked precisely shipped vs designed, because for a privacy product overclaiming the lifecycle is worse than admitting a gap.
A Witbitz Space is a self-authenticated entity: its identity, membership and keys live in the link, not in a central account server. The live call is Kibitz (a pinned dependency, rebranded), so that path gets Kibitz's full gate by running the engine. The async Space carried that gate over mechanism for mechanism — the credential formats are Kibitz's; only the verifier changes.
See also: the double blind (why the platform can't read the room), the owner rule (who decides admission, and at what tier), and Verify it yourself (reproduce the gate in your terminal).
1. The definition
A verified room has no account server and no stored access-control list. Instead:
The link carries a verifier, never a secret. A member holds a credential. Anyone can check credential-against-verifier using only the link's public material — so trust is rooted in the link, not in any server.
Three consequences fall out, and together they are the whole model:
- The link is a capability. Public verifier material — a room-invite public key, a creator public key, a name list, an OAuth client id — rides the link openly. Secrets — the room key, a member's private signing key — ride the URL fragment, which a browser never transmits. Witbitz adds one thing to Kibitz's link: the fragment also carries the decryption key for the room's content, so a Witbitz link literally holds everything.
- Verification is cryptographic, not a lookup. "Is this a member?" is answered by verifying a signature against a public key from the link — never by asking a database who someone is.
- The verifier can be anyone. Because the check needs only public material, the party that runs it is a free choice — a peer, a reader, or a certified function — without changing the credential. This is the hinge on which the rest of the page turns.
The hard constraint. Nothing sensitive is persisted server-side. After a Space is created and its member links handed out, the link is the entire authority: it reconstructs the key, proves membership, and (optionally) signs entries. The server keeps only a commitment to the key and the sealed ledger — never the key, never plaintext.
2. Membership = key possession (mkCommit)
The base Space auth is possession of the room key mk. mk is minted client-side and lives in the link
fragment; the client reconstructs it locally and sends it with each turn/poll — only its commitment is ever
stored (commit(mk) = base64url(sha256(mk)), agent/envelope.mjs).
On every mutating op the render checks commit(presented mk) === the Space's stored mkCommit:
- a right key → you hold the link → you are a member → the turn runs;
- a wrong key →
401 unauthorized, and the sealed ledger is untouched (a wrong key also decrypts nothing); - first use (no commitment yet) → the server returns
commitOutso the deployment persists it, pinning the key model on first turn.
So the check is cryptographic, not an identity lookup. Key possession proves a member is speaking; which member is established by the per-member signature (§5).
The key model (k-of-n). mk can be split so presence is required to reconstruct it:
- 1-of-1 (solo) —
mkrides one link fragment (#mk=…), or a random pad shape so a stored record can't tell solo from couple. - 2-of-2 (couple) — a Shamir split: each partner's link carries one share (
#s=…);mkexists only when both combine. The creator sealsshareAunder an out-of-band code and registers it as the room'sinvite(the server stores only that ciphertext); the partner unseals it with the code and combines with their own share.
k-of-n is about presence — who must be there to open the Space — not per-member identity.
3. The primitive
Every credential in the model is the same ECDSA P-256 token, and it is tiny
(kibitz/src/core/inviteToken.ts, reimplemented byte-for-byte in agent/spaceEntrySig.mjs):
token = base64url(JSON.stringify(payload)) + "." + base64url( ECDSA-P256-SHA256 sig over the first part )
- Bearer + unforgeable. Minting needs the private key; verifying needs only the public key. Forging one is forging ECDSA, not guessing a short code.
- Room-bound + expiring. Every payload carries
roomandexp, so a token cannot be replayed into another room or used forever. - The link holds only the public half, so any authority — even one that took over after the creator left — can verify without ever holding a private key.
signPayload / verifyPayload are the shared floor; invite grants, agent assertions and host commands are all
this token with a different payload and a wire tag, so they can never be confused for one another.
4. The credential families
The link can carry any of these verifiers; a room composes them (e.g. a human gate and an agent allow-list).
4.1 Signed invites (inviteToken.ts). The creator holds an invite keypair; each guest gets a grant token
{name, room, exp}; the link carries the invite public key (gk). A signed invite link, cryptographically — no
server, no account.
4.2 The signed manifest (roomManifest.ts). The room's committed roster {room, mode, exp, members?, domains?,
agentKeys?}, signed by the creator key and verified against the creator public key (gm) in the link — so every
peer checks a joiner against the same committed roster, and the creator's signing key is discarded after minting. A
manifest can be passphrase-encrypted, so a mere link-holder can't even read who is allowed.
4.3 Join-gate modes (joinGate.ts). open · names · code (mailed secret, constant-time compared) ·
email/google (OIDC — §4.4) · invite. All but email are serverless.
4.4 OIDC + cert-binding (oidcVerify.ts · oidcBinding.ts · identityCert.ts). "Sign in with Google," without
a backend and without trusting the transport:
oidcVerify— hand-rolled RS256-only ID-token verification over WebCrypto.alg:"none", HS* (the classic algorithm-confusion attack) and ES* are rejected outright — the algorithm is never taken from the token to pick the verifier. The key is chosen bykidfrom the provider's published JWKS; an unknownkidfails closed.oidcBinding— the serverless heart. At sign-in the user sets the token'snonceto a hash of their WebRTC DTLS certificate fingerprint. Every peer recomputes that hash from the cert it actually handshook with and requires a match, so a token is valid only for the peer holding that cert's private key.identityCert— one pinned cert, reused for every connection, private key never leaving the browser → the binding is non-transferable.
The payoff: cert-binding composes with the emoji safety code (both read the same cert), so "this is really Emma" and "there is no man-in-the-middle" collapse into one guarantee.
4.5 Agent keys (agentKey.ts). An agent's identity is an ECDSA keypair. To enter it signs a cert-bound
assertion {k:"kbz-agent-key.v1", room, fp, iat}; the authority admits it iff the key is on the room's committed
agentKeys and the assertion is bound to this connection and fresh. The room stores only the public key.
4.6 Capabilities (capabilities.ts). A per-participant Grant = what it may perceive (read-chat,
read-roster, read-media, read-files, see-screen, hear-audio, receive-directed) and act (send-chat,
speak, act), plus agent disclosures (backend, egress). defaultGrant('agent') is least-privilege — an
agent perceives the conversation but acts on nothing until explicitly granted.
4.7 Verified host (hostKey.ts). Admin is bound to a password, not to who holds the room id. The link
commits the host public key (gh) and the private key sealed under a host password (ghk). It survives a
coordinator migration — every peer already holds the committed key from its own link.
5. Per-member identity in an async Space
Status — per-participant verified identity is LIVE (deployed + enforcement-proven in prod). Two admission paths sign every turn and the deployed
/spacerender verifies them, attributing each entry to a verified author (forged / unsigned / non-allowed →400 bad_attribution/403 not_authorized). Reads are gated too: every content op (poll,title,state,pending,decide,import) requires an allow-listed sign-in, so the link alone reveals nothing. Remaining: the couple (2-of-2) path is still unsigned, and distinct per-member invite links aren't built.
Key possession (§2) proves you're a member; it does not say which. A live Kibitz call attributes each message via the DTLS-bound connection; an async Space has no live connection to bind to, so each ledger entry carries its own signature.
The grant (gt). A member holds an ECDSA signing keypair. Their grant is a Kibitz invite token —
byte-identical to inviteToken.signPayload — with the payload superset { name, room, exp, spk }, where spk is
the member's signing public key, signed by the room invite key and verified against the public gk in the
link. Kibitz's own verifier reads name/room/exp and ignores spk. Strict superset — no new scheme.
The entry signature. Each entry is signed over a canonical core { room, author, ts, kind, id, text } — author
is inside the signed core, so a rename is unforgeable. The reader or the render then checks, purely over the link's
public material: verifyGrant(gt, gk, {room, now}) yields (name, spk); then the entry's claimed name must
match and its signature verify against spk. → a verified author, never rooted in the platform.
In the link. The fragment carries gk (room-invite pubkey), gt (the grant) and sk (the member's signing
private key), using Kibitz g* param names — all fragment-only, never sent.
What the pure invite-grant path cannot prove, and what closes it: a signing key is a bearer credential, so
possession is enough — it can't show the holder is present now. The email/Google path binds a fresh,
externally-issued OIDC identity to the member's signing key (nonce = spkNonce(spk)), so an entry proves not
merely "signed by the keyholder" but "signed by the keyholder who authenticated as this Google account, now".
A leaked link is then no longer enough to post.
6. One definition, three verifiers
§1.3 said the verifier can be anyone. That is what lets the same credential model span three runtimes. The only things that change are who runs the check and what fresh value the credential is bound to so it can't be replayed.
| Live P2P call (Kibitz) | Async P2P read (Witbitz) | Async server verify (Witbitz) | |
|---|---|---|---|
| Who verifies | a peer — no server | any reader, at read time — no server | the certified-ephemeral function — the only party present |
| Membership | admitted by the join gate over presence | hold the key (commit(mk) == mkCommit) |
same |
| Freshness / anti-replay | the live DTLS cert fingerprint | the per-entry signature over a canonical core | a server-issued challenge, folded into the signed core |
| Presence | co-present over the connection | none — verify what's written | none — a fresh challenge stands in |
| Trust root | the link's public key | the link's public key | the link's public key + a certified deployment |
| Content secrecy | peer-to-peer E2EE | sealed ledger, content-blind platform + owner | same — the double blind |
The unifying trick is a nonce/challenge binding: bind the credential to a fresh value only the right holder could have used. Live, that value is the DTLS fingerprint. Async, there is no connection, so it becomes the member's signing key (identity) plus a server-issued challenge (presence).
Async did not need a weaker model. It needed a different anchor for the same model.
7. Mechanism for mechanism
| Kibitz (live P2P) | Witbitz (async) | Same / changed |
|---|---|---|
inviteToken signed invite |
spaceEntrySig grant {name, room, exp, spk, caps?} |
Byte-identical wire format (no import). Adds spk + optional caps. |
| DTLS-bound attribution | per-entry signature over {room, author, ts, kind, id, text, chal?} |
Attribution moves from the connection to a signature verified at read time. |
oidcVerify + oidcBinding |
spaceOidc — RS256-only vs Google JWKS, bound to the signing key |
Same RS256/JWKS/email_verified discipline; nonce binds to the signing key, not a DTLS cert. Verified inside the render and discarded. |
| cert-binding = presence proof | spaceChallenge — server-issued HMAC challenge, folded into the signed entry core |
The member's existing signature also covers the challenge. Single-use via ledger de-dup. |
agentKey assertion + agentKeys |
spaceAgentKeys — the same AgentEntry |
One authored AgentEntry admits an agent live and async. |
capabilities.Grant |
spaceCaps — identical vocabulary |
A Grant crosses live ↔ async unchanged. |
| mesh perceive-gating | spaceScopedView — seal the entitled slice to the agent's box key, never mk |
An external agent reads only what its caps allow. |
signed roomManifest roster |
the mk-sealed config (admission, allow-list, agentKeys, revoked) |
The policy is ciphertext at rest — the platform can't read who is allowed. |
Where the server sits, precisely. In async there is no peer to run the gate, so the turn function does — but
it is a certified, ephemeral, content-blind verifier, not a trusted ACL store: it checks signatures against the
link's public keys and a public JWKS, rooting no trust of its own; the allow-list it consults is sealed
under mk, decrypted for the length of one turn; an OIDC email it verifies is transient, never persisted; and
reads it serves an agent are sealed to that agent's key. The server verifies without being trusted with
content.
8. Admission — where it comes from
Getting into a Space differs by path:
- Live call — this is the Kibitz engine. Admission uses Kibitz's join gate unchanged: open, signed invites, name list, cert-bound OIDC/Google, email + mailed code. Witbitz has these because the live call runs Kibitz, not because code was copied.
- Async Space — the whole gate, carried over. Async has a counterpart for every Kibitz method (§7). Admission is key possession (§2) plus, per the Space's policy, one of those credentials.
Who sets that policy — the owner, and at which tier — is the owner rule.
9. Lifecycle — removal, rotation, recovery
The hard questions are never the initial key exchange. A bearer link is easy to copy, screenshot, sync, log, lose or leak, so these are the ones that matter.
9.1 What happens after a member is removed?
- Writes — cut now (shipped). A revoked member is on the Space's
revokedlist (sealed in the config); the turn op rejects their next turn403even with a valid credential. - Reads — server cut shipped for email-gated Spaces; the crypto cut is designed. Every read op checks the
allow-list +
revokedbefore the server decrypts, so a removed member's next read is403— immediately, no re-key. Open Spaces have no read gate — the link reads. The crypto-strong version, so the guarantee holds even if the server were bypassed, is an epoch re-key sealing new content to a fresh key for the remaining set — live in the Bridge, not yet ported to the single-Space ledger.
"But the link is the auth — so how can you cut anyone?" A link is a bearer credential: the removed member already extracted
mkthe first time they opened it, and no edit to the link reaches into their browser to delete it. You cannot retract a held key — only abandon it. Revocation still works because of a subtlety in the thin-client model: the client never decrypts — the server does. So the server is a decryption oracle, and cutting someone means denying the oracle, not un-giving the key. (There is also no "their slice" to strip — everyone holds the samemk; you remove them from the policy, not the key.)
New secret? Cuts them because… Trust assumption Keep mk, drop from the allow-list — shippedNo the server refuses to decrypt-and-return ( 403)you trust the platform — which already holds plaintext in-use Epoch re-key — Bridge; single-Space designed Yes the new ciphertext isn't sealed to a key they hold none — holds even if the server is bypassed So dropping the member from the allow-list is a complete, shipped revocation for an ordinary private Space. A new secret buys exactly one thing: the cut holding without trusting the platform.
9.2 Can a removed member decrypt history? Can they receive future entries?
History they could already read, they retain — a re-key protects the future, not the past. There is no
cryptographic un-see; where it matters, compartmentalize by epoch from the start. Future entries: server-enforced,
no (their poll is 403); crypto-strong, also no (sealed to an epoch key they were never given).
9.3 How are links and keys rotated?
Rotation = open a new epoch, re-seal forward, hand the remaining members the new key. The Bridge does exactly this on every membership change. On the pure-link path today, rotation means issuing new member links; per-member grants are already independent and expiring, so one member rotates without disturbing the others. The friction a link adds — rotating means re-sharing a URL — is what the Companion removes: the new epoch key is delivered to a device, not pasted into a new link.
9.4 Recovery after losing every device
Today: they don't — by design. The room key lives in the link/device and there is deliberately no operator recovery key (an operator key would be a backdoor — the whole point of the double blind). "Lose every copy, lose the data" is the honest current tradeoff. The roadmap answer is user-held recovery, never operator-held: a recovery code the user stores, and/or k-of-n social recovery (the same Shamir split the couple key already uses), with the Companion safeguarding and syncing those shares. Recovery moves to the user, not to us.
9.5 Replacing a compromised identity
Yes, without rebuilding the Space — this is what per-member identity buys. Each member is a signing key bound
by a grant, not the room itself: revoke their grant (shipped), admit a new key (a fresh grant / AgentEntry),
and re-key the epoch (designed). The Space, its history and every other member are untouched. Same for an agent —
its identity is its key, so a rotated key is a one-line allow-list change, not a migration.
9.6 What metadata remains visible
Honestly: timing, sizes and coarse structure — never content or membership. The platform stores the
mk-commitment, the sealed config and the sealed ledger, and observes when turns and polls happen and how
big the ciphertext is. It does not see the conversation, the members' names (sealed in the config), or — in
invite mode — any real-world identity. In Google-sign-in mode it sees the presenter's email transiently, at verify
time, and discards it. The irreducible leak is timing correlation, which no content encryption removes.
10. Short links stay blind (/shorten)
A room whose link holds the key must never have that key stored by the shortener, or the operator would hold it.
So the shortener is blind-safe: POST /shorten refuses any target carrying key material
(k/key/mk/sk/secret/seed in query or fragment); GET /r/{code} re-attaches the short link's own
#fragment client-side. The shortener may store the room's base, never the key.
11. Security properties
- Unforgeable — every credential is an ECDSA P-256 signature; minting needs a private key.
- No cross-room, no replay —
room+expin every payload, plus a per-connection fingerprint (live) or a single-use, TTL-bounded server challenge (async). - No algorithm confusion — OIDC verification is RS256-only;
alg:"none"/HS*/ES* rejected; the verifier algorithm is never taken from the token. - Fail-closed everywhere — a malformed grant, unknown
kid, missing signature or empty allow-list → no access. - No server trust root for the credential — a compromised or curious platform cannot mint membership, only (in the async-write locus) decline to run.
- Blind at rest — the policy is sealed under
mk; verified identities are transient.
12. Status
| Piece | Live P2P (Kibitz) | Async (Witbitz) |
|---|---|---|
Key-possession membership (mkCommit) |
n/a — presence gate | shipped |
| k-of-n key model (solo / 2-of-2 Shamir) | n/a | shipped |
| Signed-invite grants + per-entry signatures | shipped | deployed + live-verified (forged → 400) |
| Capabilities in the grant (perceive/act) | shipped | deployed |
| OIDC / Google admission, allow-listed | shipped (cert-bound) | LIVE — enforcement-proven (unsigned / non-allowed write → 403) |
| Presence proof / anti-replay | shipped (cert-binding) | deployed (server-issued challenge) |
Agent admission by allow-list (AgentEntry) |
shipped | deployed (same shape, live + async) |
| Scoped read (least-privilege perceive) | shipped (mesh) | deployed (view + member-absent fetchview) |
| Identity-gated reads (the link alone reveals nothing) | admission gates presence | LIVE — enforcement-proven (token-less poll → 403) |
| Verified host / moderation | shipped | delegated authority (propose → approve → execute) |
Blind-safe /shorten |
n/a | deployed |
| Remove a member — cut writes | shipped | shipped (gated Spaces) |
| Remove a member — cut reads (server read-gate) | n/a | shipped for email-gated Spaces |
| Remove a member — cut reads (epoch re-key, forward secrecy) | n/a | shipped in the Bridge; designed for the single-Space ledger |
| Rotate a compromised member/agent key | shipped | shipped (identity + revoke) + designed (re-key for full read-cut) |
| Device-loss recovery (user-held code / k-of-n social) | n/a | designed — roadmap, never operator-held |
| Per-member identity — couple (2-of-2); distinct invite links | n/a | not yet (solo signed path live) |
13. Code map
| Concern | Kibitz (live P2P) | Witbitz (async) |
|---|---|---|
Signing primitive (payloadB64.sigB64) |
src/core/inviteToken.ts |
agent/spaceEntrySig.mjs (byte-compatible) |
| Signed invites / grants | src/core/inviteToken.ts |
agent/spaceEntrySig.mjs + spaces/public/entrySig.js |
| Signed roster / policy | src/core/roomManifest.ts |
the mk-sealed config (agent/spaceHandler.mjs, spaceService.mjs) |
| Join-gate modes | src/core/joinGate.ts |
admission open/invite/email in agent/spaceService.mjs |
| OIDC verify + binding | src/core/oidcVerify.ts · oidcBinding.ts · identityCert.ts |
agent/spaceOidc.mjs (RS256/JWKS + spkNonce) |
| Presence / anti-replay | cert fingerprint | agent/spaceChallenge.mjs |
| Agent admission | src/core/agentKey.ts |
agent/spaceAgentKeys.mjs |
| Capabilities | src/core/capabilities.ts |
agent/spaceCaps.mjs |
| Scoped read | mesh perceive-gating (mesh.ts) |
agent/spaceScopedView.mjs (view/fetchview) |
| Verified host | src/core/hostKey.ts |
agent/delegatedAuthority.mjs |
| Room key mint / commit / at-rest seal | — | agent/envelope.mjs (newRoomKey, commit, seal/open) |
| Recipient sealing (ECDH box) | — | agent/envelope.mjs (sealTo/openBox) |
| Link codec + k-of-n | — | spaces/public/spaceConfig.js, shamir.js, invite.js |
| Epoch re-key + forward secrecy | — | agent/bridgeMembership.mjs |
| Blind-safe short links | — | control-plane/src/shortlink.mjs |
The through-line: a room's authority lives in its link, and the check that enforces it can run wherever the room happens to be — on a peer, in a reader, or inside a function that verifies but cannot read.
Don't take our word for it → Verify it yourself. A token-less read/write to an email-gated Space returns
403;curl -Ithe app to read its browser-enforced egress allowlist;POST {"op":"sealed"}to read the exact ciphertext the server stores —recipients:["room"]proves it's sealed to your key, not the operator's.
Machine-readable source: room-link-auth.md · every doc in one fetch: llms-full.txt · ← the-attested-client · the-owner-rule →