# The Space Link Is the Auth

How Witbitz decides **who is in a room and who said what**, with a content-blind platform and no stored
access-control list. The central principle — *the room link carries everything needed, and the platform
sees only ciphertext* — is inherited from Kibitz ([the link is the verifier](https://kibitz.chat/docs/verification))
and extended here for **async, end-to-end-encrypted Spaces**.

> A Witbitz **Space** (room) is a self-authenticated entity: its identity, membership, and keys live **in the
> link**, not in a central account server. Witbitz is built on the Kibitz engine: the **live call *is* Kibitz**
> (a pinned dependency, rebranded), so that path gets Kibitz's full peer-to-peer gate by *running the engine*. The
> **async Space** path has carried that gate over **mechanism for mechanism** — signed invites, OIDC, a
> presence/anti-replay binding, agent-key admission, capabilities, and scoped reads all have async counterparts now
> (the credential *formats* are Kibitz's; only the *verifier* changes). The full, unified treatment — **how one
> definition secures peer-to-peer live calls, peer-to-peer async reads, AND server-verified async writes** — is
> **[The Verified Room](https://docs.witbitz.chat/docs/verified-rooms.md)**. This page is the membership,
> key-model, and double-blind foundation those credentials sit on.

---

## 1. The principle: the link is a capability, the platform is blind

A Space has **no owner account** and the platform is **content-blind**. Two things follow, and together they
are the whole model:

- **The link is a capability.** Holding the right slice of the link is what makes you a member — there is no
  server-side ACL to consult. Public verifier material (a room-invite public key, a name list) can ride the
  link openly; secrets (the room key, a member's signing key) ride the **URL fragment**, which browsers never
  send to any server.
- **The platform is content-blind — the double blind.** A Space's ledger is sealed as **ciphertext at rest**
  under a room key the platform never holds. As long as the compute runs the **declared program**, the
  platform operator is blind to the data and activity — **and so is the app's own developer/owner**. Neither
  can read the users' content.

This is a **capability-URL** model, like Kibitz's, with one addition: in Kibitz the link grants the ability to
*verify* a joiner; in Witbitz the link *also* carries the **decryption key** for the Space's content. So a
Witbitz link literally **holds everything** — admission verifier, the room key, and (for a signed Space) the
member's own identity — and the server stores only commitments and ciphertext.

### 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`
(`agent/spaceService.mjs`, `agent/spaceTransport.mjs`):

- 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**: the platform verifies you hold the key and that
your turn seals to the ledger's key — it never learns who you are or what you wrote. Key possession proves *a*
member is speaking; **which** member is established by the per-member signature (§4), now live for solo Spaces (a
Space without a signed identity falls back to a self-declared `from`).

### The key model (`k-of-n`)

`mk` can be split so presence is required to reconstruct it (`spaces/public/spaceConfig.js`,
`spaces/public/shamir.js`):

- **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 shares 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 (`spaces/public/invite.js` `sealUnderCode`/`openUnderCode`, `spaceClient.joinWithCode`).

k-of-n is about *presence* (who must be there to open the Space), not per-member identity — that is §4.

---

## 3. The ledger is E2EE — the double blind

The Space's conversation is a **sealed ledger**: a snapshot encrypted under `mk` and stored as ciphertext
(`sessionStore.putLedger`/`getLedger` → `agent/envelope.mjs` `seal`/`open`), sealed to the **member key only** —
there is **no operator recovery key**, so nothing readable-by-the-operator is ever written. The client is **thin**
— it sends `mk` with a turn and receives plaintext back; it never handles ciphertext (`docs/async-spaces-m2.md`).

Plaintext exists in exactly one place: the **decrypt-once render** — a transient Lambda turn that opens the
ledger with the present member's `mk`, runs the model, re-seals, and **drops `mk`**. The AI is the one door
where plaintext opens; between turns the Space is cold ciphertext.

This is what makes privacy **verifiable, not promised**: because a Witbitz app is a backendless static page
that talks only to the audited Witbitz API, its entire data footprint is declared, and the render only ever
runs the **declared** program. The operator can't read at rest, and the app owner can't read its users. (See
[the double blind](./the-double-blind.md) and the privacy-certification north star.)

---

## 4. Per-member identity — Kibitz-link compatible

> **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 (a forged / unsigned / non-allowed turn is rejected `400 bad_attribution` / `403 not_authorized`):
> - **Invite-grant (solo):** a member identity minted on create and carried in the link (detailed below).
> - **Email / Google (allow-listed):** each write carries a **fresh Google ID token bound to the member's signing
>   key** (the token's `nonce = spkNonce(spk)`), verified RS256-vs-JWKS with the email checked against the Space's
>   **sealed allow-list** — the async analog of Kibitz's live cert-binding, now shipped. Only allow-listed accounts
>   can post; the "Sign in with Google" UI is live. *Proven in prod:* an unsigned write returns `403`, a valid one
>   runs the brain.
>
> Also **live:** gating **reads** by identity. The server holds the plaintext in-use, so it's the chokepoint —
> **every** content op (`poll`, `title`, `state`, `pending`, `decide`, `import`), not just the write, now requires an
> allow-listed Google sign-in. So the link *alone* reveals nothing: a fully **private Space**. *Proven in prod:* the
> same token-less `poll` that returned the whole history before the read-gate shipped now returns `403`.
>
> 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* member. The live Kibitz call attributes
each message via the DTLS-bound connection ([cert-binding](https://kibitz.chat/docs/cert-binding)); an async
Space has **no live connection to bind to**, so each **ledger entry carries its own signature**, and identity
rides the link exactly the way Kibitz's does.

**The grant (`gt`).** A member holds an ECDSA signing keypair. Their **grant** is a Kibitz **invite token** —
`base64url(JSON) . base64url(sig)`, byte-identical to `inviteToken.signPayload` — with the payload superset
`{ name, room, exp, spk }`, where `spk` is the member's signing **public** key. It is 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`; the Witbitz render reads `spk`. **Strict superset — no new scheme.**

**The entry signature.** Each entry is signed by the member's signing key over a canonical core
`{ room, author, ts, kind, id, text }` (`author` is inside the signed core → an unforgeable rename). The
reader / the render then checks, purely over the link's **public** material:

1. `verifyGrant(gt, gk, { room, now })` → the grant is validly signed by the room key, right room, unexpired →
   yields `(name, spk)`;
2. the entry claims that `name` and its signature verifies against `spk`.

→ a **verified author**, established by the reader/render, **never rooted in the platform**
(`agent/spaceEntrySig.mjs` server + `spaces/public/entrySig.js` browser; wired in `agent/spaceService.mjs`,
which sets the verified name and tags the entry `verified`, rejects a bad attribution `400 bad_attribution`,
and falls back to the self-declared name for open/legacy Spaces).

**In the link.** The link 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
(`entrySig.js` `appendIdentity`/`readIdentity`). This is "the link holds everything" for a signed Space.

**Who checks.** With no live peers co-present, verification moves to **read time**: the reader's client and the
render verify; the platform only orders signed, identity-bound entries and roots no trust. What Kibitz's live
cert-binding also proves — "the credential belongs to whoever is *present on this connection, right now*" — the
pure invite-grant path can't (a signing key is a bearer credential; possession is enough). The **email/Google path
closes that gap and is live:** it binds a **fresh, externally-issued OIDC identity to the member's signing key**
(the token's `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**" — the async counterpart of binding OIDC to a DTLS cert.
Possession of a leaked link is then no longer enough to post: you also need a live, allow-listed Google session.

---

## 5. Admission — where it comes from (and what is *not* copied)

*Getting into* a Space differs by path, and the distinction matters:

- **Live call — this *is* the Kibitz engine.** Admission uses Kibitz's join gate unchanged — the `g*` link
  grammar and every method ([verification](https://kibitz.chat/docs/verification)): open, signed invites, name
  list, **cert-bound OIDC/Google**, email + mailed code. Witbitz has these because the live call **runs Kibitz**,
  not because the code was copied into Witbitz's repo.
- **Async Space — the whole gate, carried over, verified by the reader or a certified function.** Async now has
  a counterpart for **every** Kibitz method: signed-invite **grants** (§4), **OIDC/Google** (verified RS256-vs-JWKS
  and bound to the member's *signing key* instead of a DTLS cert), a **presence / anti-replay** binding (a
  server-issued challenge folded into the signed entry), **agent-key admission** (the same `AgentEntry`), and
  **scoped reads** (content sealed to a party's key, never `mk`). The credential *formats* are Kibitz's; what
  changes is the **verifier** — a reader at read time, or, when no one is present, the **certified, content-blind
  turn function**. Admission is **key possession** (§2) plus, per the Space's policy, one of those credentials. The
  full mechanism-by-mechanism mapping is **[The Verified Room](https://docs.witbitz.chat/docs/verified-rooms.md)**.

---

## 6. 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 Witbitz shortener is **blind-safe** (`control-plane/src/shortlink.mjs`, deployed):

- `POST /shorten` **refuses** any target carrying key material (`k`/`key`/`mk`/`sk`/`secret`/`seed` in the
  query or fragment) — a decryption key can never land in the store. Gate/admission params (`g`/`gk`/`gm`) are
  not keys and remain storable.
- `GET /r/{code}` re-attaches the **short link's own `#fragment`** client-side, so a Space is shortened as its
  **key-free base** and shared as `witbitz.chat/r/<code>#…`; the secret rides the fragment (never sent in a
  request) and is merged onto the base in the browser.

Rule: **the shortener may store the room's base, never the key.** The trade-off is that the key is the bulky
part, so a "short" link still carries the fragment — you shorten the base, not the secret.

---

## 7. Comparison — live vs async

| | Live call (Kibitz engine) | Async Space (Witbitz) |
|---|---|---|
| Membership | admitted by the join gate over presence | **hold the key** (`commit(mk) == mkCommit`) |
| Identity anchor | OIDC token **cert-bound** to the live DTLS connection | member **signing key** bound by a link-carried **invite token** |
| Attribution | the DTLS-bound connection | **per-entry signature**, verified at read time |
| Who verifies | every peer, peer-to-peer, no server | the reader / the decrypt-once render — never the platform |
| Anti-replay / presence | cert-binding to the DTLS fingerprint | **server-issued challenge** folded into the signed entry (+ OIDC bound to the signing key) |
| Content secrecy | peer-to-peer E2EE (no server sees media) | sealed ledger, **content-blind** platform + owner (double blind) |

---

## 8. Code map

| Concern | Where |
|---|---|
| Room key mint / commit / at-rest seal (**symmetric, `mk` only**) | `agent/envelope.mjs` (`newRoomKey`, `commit`, `seal`/`open`) |
| Recipient sealing (**ECDH `genRecipientKey`/`sealTo`/`openBox`**) — to a member/agent key, *not* the ledger, *never* an operator | `agent/envelope.mjs` |
| Link codec + key model (client) | `spaces/public/spaceConfig.js` (`createConfiguredSpace`, `parseConfiguredLink`, `assembleConfiguredKey`), `spaces/public/shamir.js` |
| Code-sealed 2-of-2 handshake | `spaces/public/invite.js` (`newCode`, `sealUnderCode`, `openUnderCode`) |
| Key-possession access control | `agent/spaceService.mjs`, `agent/spaceTransport.mjs` (`commit(mk) === mkCommit`) |
| The sealed ledger + decrypt-once render | `agent/asyncTurn.mjs`, `agent/sessionStore.mjs` (`putLedger`/`getLedger`) |
| Per-member identity (server) | `agent/spaceEntrySig.mjs` (`genKeypair`/`mintGrant`/`verifyGrant`/`signEntry`/`verifyEntry`/`attribute`/`attributeTurn`) |
| Per-member identity (browser) | `spaces/public/entrySig.js` (+ `appendIdentity`/`readIdentity` link carry) |
| Turn signing (client) | `spaces/public/spaceClient.js` (`turn(room, mk, message, identity)`) |
| Attribution wiring | `agent/spaceService.mjs` (verify → verified author / `400 bad_attribution` / legacy fallback), `agent/spaceHandler.mjs` (persist `gk`, parse `grant`/`sig`/`ts`/`id`) |
| Blind-safe short links | `control-plane/src/shortlink.mjs` (`hasKeyMaterial` guard + fragment-carry) |
| Kibitz engine — the LIVE call (consumed, not copied) | `kibitz/src/core/joinGateLink.ts`, `inviteToken.ts`, `identity.ts`/`oidcBinding.ts` (cert-bound OIDC) — run by the live call; **the async path imports none of these** |
| Invite-token format, reimplemented for async (byte-compatible, no kibitz import) | `agent/spaceEntrySig.mjs` + `spaces/public/entrySig.js` mirror `inviteToken.signPayload`'s wire format |

---

## 9. Status

| Piece | Status |
|---|---|
| Key-possession membership (`mkCommit`) | shipped (async Spaces live in prod) |
| k-of-n key model (solo / 2-of-2 Shamir) | shipped |
| Sealed ledger + decrypt-once render (double blind) | shipped |
| Per-member entry signatures (solo) — server + client | **deployed + live-verified** (signed turn → verified author; forged → 400) |
| Capabilities in the grant (perceive/act, least-privilege) | **deployed** — see [The Verified Room](https://docs.witbitz.chat/docs/verified-rooms.md) |
| OIDC / Google admission (allow-listed), bound to the signing key | **LIVE — deployed + enforcement-proven** (Sign-in-with-Google UI shipped; unsigned / non-allowed write → `403`) |
| Presence / anti-replay (server-issued challenge) | **deployed** |
| Agent admission by allow-list (`AgentEntry`, live + async) | **deployed** |
| Scoped read (least-privilege perceive, sealed per-agent) | **deployed** (`view` + member-absent `fetchview`) |
| Blind-safe `/shorten` | **deployed** (key-bearing link → refused; gate params still stored) |
| Identity-gated **reads** (private Space — the link alone reveals nothing) | **LIVE — deployed + enforcement-proven** (every content op requires an allow-listed sign-in; token-less `poll` → `403`) |
| Per-member identity — couple (2-of-2) path; distinct invite links | not yet (solo signed path live) |
