Witbitz docs PlatformTrustAll docs

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:

  1. 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.
  2. 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.
  3. 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:

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:

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 )

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:

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 /space render 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 spaceCapsidentical 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:

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?

"But the link is the auth — so how can you cut anyone?" A link is a bearer credential: the removed member already extracted mk the 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 same mk; you remove them from the policy, not the key.)

New secret? Cuts them because… Trust assumption
Keep mk, drop from the allow-list — shipped No 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

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 poll403)
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 -I the 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