Kibitz

← The Kibitz Engine · deep dive

Room Verification — the Link Is the Verifier

How Kibitz decides who may enter a room, with no server and no stored state. This document declares the central principle — the room link carries everything needed to verify who gets in, and nothing it shouldn't — and details every verification method built on it.

Companion: agent-protocol.md. An agent is admitted exactly like a human (it presents a credential to the same gate).


A Kibitz room has no backend and no permanent owner. The "authority" is just whichever participant's browser is currently coordinating the room (the first one in; the role migrates if they leave). So there is nowhere central to keep an access-control list — it has to live with the authority, which is an untrusted-by-default browser that can change at any moment.

The resolution is to make the link self-contained:

The room link carries a verifier — public material that lets any authority check a joiner — and never a secret. Each allowed guest separately holds a credential they were given out-of-band. The authority checks credential against verifier and admits or refuses. The authority holds no secret of its own.

Three consequences fall out:

This is a capability-URL model: holding the link grants the ability to verify, not the ability to enter. Entering still requires a credential.

The hard constraint

The strict form Kibitz targets is: nothing is persisted and nothing is kept in memory — after the room is created (and any per-guest credentials / email codes are handed out), the link is the entire authority. This rules some things in and some out (§7), and it is what makes the model serverless and migration-proof at once.


2. The gate seam

Verification is enforced by the room authority over the presence connection, before a joiner is added to the roster — so a refused peer never appears on screen and no one's data/media mesh ever dials it. It is method-agnostic: room.ts knows nothing about OIDC, invites, or codes. It exposes one injected seam:

interface IdentityGate {
  require: boolean                       // is the gate on? (live; host can toggle)
  verify(credential: string | undefined,
         remoteFp: string | null): Promise<{ ok: boolean; reason?: string }>
  bindsFingerprint?: boolean             // does verify() use the live cert fingerprint?
}

On a joiner's announce (which carries their credential), the authority:

  1. reads remoteFp — the DTLS fingerprint of the cert the joiner actually handshook on the presence connection (read from the live RTCPeerConnection, not the spoofable SDP);
  2. calls verify(credential, remoteFp);
  3. admits (rosters them) or refuses (status:'unverified', never rostered).

bindsFingerprint distinguishes credentials that are cryptographically bound to this connection (OIDC tokens — a not-yet-readable fingerprint holds the joiner) from those that aren't (invites/names — a null fingerprint must not block them).

verify is injected by the embedder (the Widget builds it from the link descriptor), so the core comms code stays free of any verification policy.


Only non-secret verifier material is encoded into the URL, namespaced g* so it never collides with the room hash:

param carries for
g the mode (invite / names / google / code / email) all
gk the invite signing public key (JWK, base64url-json) signed invites
gn the pickable name list (csv) name list
gc the OAuth client id verified identity
gt (per-guest link only) that guest's signed invite token signed invites

The shared room link carries the verifier (g,gk/gn/gc). Each guest's personal link is the room link plus their own gt credential. A creating tab's private material (an invite signing key, short codes) is never encoded — it is used at creation and dropped.


4. The methods

Each method is just a different verify() strategy on the §2 seam. They share one mental model: the link holds a verifier; the guest holds a credential.

4.0 Open (baseline)

No gate. Anyone with the link is in. The account-free default. The link is unguessable by default (a crypto-random room id), so "knowing the link" is itself the (weak) access control.

4.1 Verified identity — OIDC / Google

The strongest, and the only one where the credential proves a real-world identity.

4.2 Signed invites

The canonical fit for "the link is everything." Per-person, unforgeable, no server, no stored secret.

4.3 Name list

Presence/organization, not security.

4.4 Join code

A shared secret per person (or one shared code). Intuitive, but in tension with the link rule.

4.5 Email + mailed code

Proves control of an email address without a third-party identity provider — the one method that needs a backend. Built.


5. Comparison

Method "ID" is Proof of the ID Link carries Secret lives Server Survives migration Brute-force resistant Non-transferable
Open none no
Verified identity email Google signature + cert-binding client id (+ allow-list) no¹ yes yes yes
Signed invites a label creator's ECDSA signature public key (discarded) no yes yes no (bearer)
Name list name none the names no yes n/a no
Join code (short) name knows the code — (browser-held) creator browser no no only via rate-limit no
Join code (link) name knows a high-entropy code code hashes (discarded) no yes yes (if high-entropy) no
Email + code email code mailed to it code hashes (discarded) yes yes yes no

¹ No Kibitz server, but it fetches the provider's public keys (online-only).


6. Security analysis

Replay (other connection). Defeated only by cert-binding (§4.1): the credential's nonce is bound to the DTLS cert the joiner actually handshook, read from the live connection, so a captured token can't be reused over a different connection. Other methods are bearer (a captured invite/code works for whoever holds it) — by design.

Cross-room replay. Every credential is room-bound (the nonce salt / the invite payload's room), so a credential for room A is refused in room B.

The offline-oracle trilemma. Putting a verifier in the link lets anyone attack a secret offline, with no rate limit. So without a server you can have only two of: short say-aloud code, survives host hand-off, resists offline cracking. Signed invites pick the last two (the "secret" is a full signature — not guessable); browser-held short codes pick the first two (rate-limited, but creator-local); a server (§4.5) is the only way to get all three.

Algorithm/temporal. OIDC verification pins RS256 (the token header never selects the algorithm), checks exp with bounded leeway, and can bound token age.

Fail-closed. If the authority can't reach the provider's keys (an offline LAN call), it denies rather than admits. require rooms are therefore online-only by design.


7. Verified roster — no privileged host (optional)

By default the authority (the first peer in) is trusted to run the gate. It can't read content or forge an identity — both are peer-to-peer (§4.1, threat-model) — but it decides admission. The verified-roster mode removes that last trust: it publishes a committed roster and makes every participant, the host included, prove a listed identity to everyone, before entering.

The manifest. The creator publishes a signed manifest in the link: { members, policy, room, exp } signed by the creator's key, with the creator's public key in the link (the private key is then discarded). The manifest is the published, committed roster + policy — anyone can read who's expected and confirm the manifest is authentic.

Mutual, pre-share verification. Verification is a precondition to entry, both directions:

  1. authority → joiner — verified before rostering (already the gate; denied at the door);
  2. joiner → every existing member, incl. the host — verified before the joiner shares anything; if the host (or anyone) can't prove a listed identity, the joiner refuses to enter;
  3. host → itself — the honest-host bootstrap: a host's own client won't host a room it can't verify into.

Invariant: no peer is "in" until it has verified, and been verified by, every other peer (the host included) against the manifest — before any content flows. A malicious host can neither admit an off-manifest peer (everyone else rejects it) nor be the host without proving a listed identity (the first arrival checks it).

The bootstrap (why "alone host" is fine). A host that is alone has no relying party, so it can only self-attest — harmless, because no one is there to be exposed. The first arrival verifies the host before that arrival enters. So from every actual participant's view, no one ever entered a room containing an unverified peer. (You can't verify yourself to no one; verification is always relative to the party relying on it.)

Design choices.

What enforces it (code). The decision is a pure function, src/core/rosterGate.ts: given the committed roster, my own proven identity, and what every present peer has proven so far, it yields selfVerified, a per-peer pending | verified | rejected, and a canShare gate (true only when I am a listed member and every present peer is a verified member). useCall drives it — it polls each peer's cert-bound identity (getIdentity), checks it against the roster, and then

The same committed roster also drives admission: when a link carries a manifest, the authority's allow-list is the manifest (not the host's editable guest list) and require is forced on — so the door and the peer-to-peer pre-share share one roster. Mint a room with buildVerifiedGoogleRoom(base, room, emails, clientId, exp) (one shareable link; members prove by Google sign-in) or, for the invite/door path, buildVerifiedRoom(...).

Published per-invitee roster + pre-entry preview. buildVerifiedRoster(base, room, invitees, clientId, exp) takes a per-invitee method and signs an invitees list into the manifest alongside the gate's match lists. Each invitee picks one of:

The room creator gets their own line (they verify too — honest host). A per-invitee show flag (default off) controls whether an email/domain is revealed in the preview. The signed invitees roster powers a pre-entry preview: opening a verified-roster link shows who's invited and how each verifies (tamper-proof — checked against the link's pubkey), lets the joiner pick which one they are, and approves before the room mounts. The OAuth client id rides the link (gc), so the whole gate runs from the link alone (the id is public; tokens are still audience-checked).

Fail-closed. A forever-pending peer (no valid proof) keeps canShare false — content is held, never leaked to an unproven peer. That is intentional (a security room favours silence over exposure); it also means one stuck/malicious peer can stall sharing, which the user resolves by leaving or (host) removing them. It also needs the provider's JWKS to be reachable, so the strong path is online-only (offline/LAN can't verify → fail-closed → hold).

Status: built for the cert-bound google mode — manifest signing/verification (roomManifest.ts), the pure gate (rosterGate.ts, unit-tested), the useCall content gate + self-gate, and the widget alarm/hold/self-gate banners. Needs a 2-device live test (an off-roster window must be refused content by every other window, not merely admitted-then- swept). Invite-mode mutual pre-share (cert-bound per-guest keys) remains a follow-up.

8. Enforcement details


9. Code map

Concern Where
The gate seam + enforcement src/core/room.ts (IdentityGate, gateIdentity, migration)
The handshook remote fingerprint src/core/transport.ts (connRemoteFingerprint), src/core/safetyCode.ts
Link codec (verifier ↔ URL) src/core/joinGateLink.ts (GateDescriptor, encode/decode)
Runtime: verifier-from-link (names / invites) src/core/joinGateRuntime.ts (gateVerifierFor, buildInviteBundle)
OIDC / email gate verify (google + email modes) src/widget/Widget.tsx (makeGateVerify) → src/core/identityMulti.ts (verifyPeerMulti, route by issuer) — not joinGateRuntime
Email-OTP backend (our own OIDC issuer) functions/api/email/{start,verify,jwks}.ts, src/core/{emailOtp,emailToken,mailers,emailProvider}.ts
Signed invites (ECDSA) src/core/inviteToken.ts
Name / code helpers src/core/joinGate.ts, src/core/gateRateLimit.ts
OIDC verify + cert-binding src/core/oidcVerify.ts, src/core/oidcBinding.ts, src/core/identity.ts
Verified roster (§7) — signed manifest + membership src/core/roomManifest.ts (signManifest/verifyManifest/memberAllowed), src/core/inviteToken.ts (signPayload/verifyPayload)
Wiring + the joiner's door src/widget/Widget.tsx (makeGateVerify, link-gate effect, paste-invite/pick-name)

10. Status

Method Status
Open shipped
Verified identity (OIDC/Google) built; bindsFingerprint:true; needs a 2-device live test before deploy
Signed invites built; live admit/deny wants a 2-device test (the authority must hold the gate)
Name list built
Join code core helpers built; browser-held variant superseded by invites under the strict link rule
Email + code built — Worker (RS256 mint + JWKS), cert-bound token, mailer seam, client provider; verified peer-to-peer via verifyPeerMulti. Live mail-provider choice still being firmed up; wants a 2-device test
Verified roster / no privileged host (§7) built — manifest crypto (roomManifest.ts) + mutual pre-share wiring (rosterGate.tsuseCall content gate) + host self-gate + widget alarms; needs a 2-device test
Authority-level door (deny unverified before rostering) built — the authority verifies a cert-bound token over presence before admitting (room.ts gate, unverified lobby status); needs a 2-device test