← The Kibitz Engine · deep dive
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 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.
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:
remoteFp — the DTLS fingerprint of the cert the joiner actually handshook on
the presence connection (read from the live RTCPeerConnection, not the spoofable
SDP);verify(credential, remoteFp);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.
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.
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.
The strongest, and the only one where the credential proves a real-world identity.
hd domain).gc) — public. The allow-list
(allowedDomains / allowedEmails) is policy: in the link (visible — reveals who's
invited) or held by the authority.nonce = base64url(sha256(canonicalFingerprint(theirCert) + "|" + roomId)).iss /
aud / exp, email_verified, the cert-binding (nonce == sha256(remoteFp + "|" + roomId)), and allowedDomains/allowedEmails.bindsFingerprint: true.The canonical fit for "the link is everything." Per-person, unforgeable, no server, no stored secret.
gk) — safe to publish, reveals
nothing about who's invited.base64url(payload) . base64url(signature), where
payload = { name, room, exp }, signed with the creator's ECDSA P-256 private key. The
token is usually delivered as the guest's personal link (…>=<token>).room binding, and exp. Stateless — any authority verifies with the link
alone.bindsFingerprint: false.Presence/organization, not security.
gn); the guest holds
nothing — they pick a name from the list.bindsFingerprint: false.A shared secret per person (or one shared code). Intuitive, but in tension with the link rule.
{name → code} map in the creator's browser and rate-limit guesses (online-only
guessing the authority can throttle). This is safe for short codes but is creator-local
(lost on the creator's reload; doesn't migrate) — it deliberately steps outside the strict
link rule.codeMatch constant-time, formatCode,
createRateLimiter); the browser-held variant is superseded by §4.2 under the strict link
rule; a server-backed variant is §4.5.Proves control of an email address without a third-party identity provider — the one method that needs a backend. Built.
functions/api/email/*) emails a per-recipient random code, and on
correct entry mints an RS256 ID token — exactly the OIDC shape the existing verifier
already checks. The token is cert-bound (its nonce hashes the joiner's DTLS fingerprint,
server-held during the exchange) and is then verified peer-to-peer against the Worker's
published JWKS (/api/email/jwks), routed by issuer through the same verifyPeerMulti
path as Google. So after the code exchange the verification is peer-to-peer — the server
bows out, exactly like the OIDC path. The link carries only the gate mode (email) and
the backend base (apiBase), never the code.core/mailers.ts) — HTTP-only
providers (a Cloudflare Worker can't open SMTP). Premium/over-quota sends can be sponsored
by the room opener via the same opener-pays grant as TURN.| Method | "ID" is | Proof of the ID | Link carries | Secret lives | Server | Survives migration | Brute-force resistant | Non-transferable |
|---|---|---|---|---|---|---|---|---|
| Open | — | none | — | — | no | — | — | — |
| Verified identity | 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 | code mailed to it | code hashes | (discarded) | yes | yes | yes | no |
¹ No Kibitz server, but it fetches the provider's public keys (online-only).
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.
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:
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.
nonce == hash(remoteFp)),
so a relaying authority — or any peer that received someone's token — cannot replay it to
impersonate that member to a third peer. Signed invites prove "the creator authorised a
member named X" but are bearer tokens (not cert-bound): they confirm admission against the
committed roster but are replayable peer-to-peer, so the invite path gates the door (the
authority) and not the mutual, peer-to-peer pre-share. Cert-binding invites (a per-guest
keypair committed in the manifest) is a future step. Name-list / code confirm "on the list"
but are not strong proof.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
canShare, and sends a directed
message only to an individually-cleared peer;rosterGate so the widget shows "verifying the room…" while pending, a
compromised alarm (+ Leave) the moment a present peer proves an off-roster identity, and
a host self-gate banner if my own sign-in isn't on the roster.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:
signin — OIDC sign-in pinned to one email → that exact address joins members;oidc — OIDC sign-in for any verified account at a domain → the domain joins domains
(the gate admits any verified address there, matched in both the authority gate via
identityAllowed and the peer-to-peer rosterGate via memberOf(members, id, domains));mail — a mailed code to an email → method-aware in the data + UI but inert (no
backend), so it shows in the preview yet contributes nothing to members/domains.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.
verify from the link and enforces identically;
members it inherited mid-call are grandfathered (already-verified), while new joiners are
re-verified.require is flippable by the host at runtime; re-enabling drops the
"already verified" memory so everyone re-proves.| 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) |
| 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.ts → useCall 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 |