# 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](./the-double-blind.md) (why the platform can't read the room),
> [the owner rule](./the-owner-rule.md) (who decides admission, and at what tier), and
> [Verify it yourself](./verify.md) (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`:

- 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 `commitOut` so 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)** — `mk` rides 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=…`); `mk` exists only when
  both combine. The creator seals `shareA` under an out-of-band **code** and registers it as the room's `invite`
  (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 `room` and `exp`, 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 by `kid` from the provider's published **JWKS**; an unknown `kid` fails closed.
- **`oidcBinding`** — the serverless heart. At sign-in the user sets the token's **`nonce`** to 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 `/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` | `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](./the-owner-rule.md).

## 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 `revoked` list (sealed in the config); the turn
  op rejects their next turn `403` even with a valid credential.
- **Reads — server cut shipped for email-gated Spaces; the crypto cut is designed.** Every read op checks the
  allow-list + `revoked` before the server decrypts, so a removed member's next read is `403` — 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 `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](./the-companion.md) 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` + `exp` in 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](./verify.md).** 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.
