# Identity and admission Identity in Witbitz is not "there is an account in our database." A Space is **self-authenticated**: its link carries the public material needed to verify who belongs, and members hold the corresponding private credentials. 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 an open, proven real-time engine — where it secures live P2P calls — and carried, mechanism for mechanism, to asynchronous, end-to-end-encrypted Spaces. This page is the whole subject: the quick reader model, then the definition and its primitive, the credential families, how membership is proved, **the owner rule** (who decides admission and at what tier), and — the part usually skipped — **removal, rotation and recovery**, marked precisely *shipped vs designed*. For a privacy product, overclaiming the lifecycle is worse than admitting a gap. > See also: [the double blind](./trust-model.md#the-double-blind) (why the platform can't read the room) and > [Verify it yourself](./verify.md) (reproduce the gate in your terminal). --- ## The quick model **Base membership: hold the room key.** Every Space has a room key `mk`. The client mints it and stores only a commitment on the server — `commit(mk) = base64url(sha256(mk))`. When a member acts, the render checks the presented key against the stored commitment: a right key proves the caller holds the link; a wrong key opens nothing. This proves *a* member is present — not *which* member. **Per-member identity: signed entries.** To attribute entries to a specific person, each member holds a signing key. The room's public invite key verifies the member grant; the member's public signing key verifies the entry. Because the author is inside the signed core, a rename or forged attribution fails verification. **Admission families.** A room composes any of these verifiers (e.g. a human gate *and* an agent allow-list): | Family | What it proves | |---|---| | **Key possession** | The caller holds the Space key. | | **Signed invite** | A creator or room authority granted this member access. | | **OIDC / Google** | The member authenticated as an allow-listed identity. | | **Agent key** | This agent key is allowed by the room. | | **Capability grant** | This participant may perceive or act in specific ways. | | **Owner policy** | The app owner mandated admission tiers for the room ([the owner rule](#the-owner-rule)). | **Gated Spaces.** On an email-gated Space the link alone is not enough: reads *and* writes both require an allow-listed sign-in — which is why the [verification page](./verify.md) starts with token-less `poll`/`turn` requests that return `403`. The allow-list lives in the sealed config, so the platform never reads the membership list. **Live vs designed, at a glance:** | Piece | Status | |---|---| | Key-possession membership | Shipped | | Solo signed identity | Live and verified | | Email-gated reads and writes | Live and checkable | | Agent-key admission · scoped agent reads | Deployed | | 2-of-2 per-member signed path · distinct invite links | Not yet | | Crypto-strong single-Space read revocation by epoch re-key | Implemented (fork-based re-key; built, wired, tested) | The rest of this page is the mechanism behind that summary. --- ## 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 the live-call 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 the rest turns on. **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. ## Membership = key possession (`mkCommit`) The base Space auth is **possession of the room key `mk`**, minted client-side and living in the link fragment — 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`, sealed ledger 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. **The key model (`k-of-n`)** governs *presence*, not per-member identity: `mk` can be split so presence is required to reconstruct it. **1-of-1 (solo)** — `mk` rides one link fragment (`#mk=…`). **2-of-2 (couple)** — a Shamir split where each partner's link carries one share (`#s=…`) and `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 partner unseals with the code and combines. ## The primitive Every credential in the model is the same ECDSA P-256 token, and it is tiny (the live-call engine's invite token, 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. ## The credential families A room can carry any of these verifiers; it composes them. - **Signed invites** (`inviteToken.ts`). The creator holds an invite keypair; each guest gets a grant `{name, room, exp}`; the link carries the invite **public** key (`gk`). A signed invite link, cryptographically — no server, no account. - **The signed manifest** (`roomManifest.ts`). The 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. Can be passphrase-encrypted so a mere link-holder can't read *who* is allowed. - **Join-gate modes** (`joinGate.ts`). `open` · `names` · `code` (mailed secret, constant-time compared) · `email`/`google` (OIDC) · `invite`. All but `email` are serverless. - **OIDC + cert-binding** (`oidcVerify.ts` · `oidcBinding.ts` · `identityCert.ts`). "Sign in with Google," without a backend and without trusting the transport. `oidcVerify` is hand-rolled **RS256-only** ID-token verification — `alg:"none"`, HS\* (algorithm-confusion) and ES\* are rejected outright, the algorithm never taken from the token; the key is chosen by `kid` from the provider's JWKS, unknown `kid` fails closed. `oidcBinding` is the serverless heart: at sign-in the user sets the token's `nonce` to a hash of *their* WebRTC DTLS cert fingerprint, and every peer recomputes it from the cert it actually handshook with — so a token is valid only for the peer holding that cert's private key. `identityCert` pins one cert, private key never leaving the browser → non-transferable. 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. - **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}`, admitted 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. - **Capabilities** (`capabilities.ts`). A per-participant **`Grant`** = what it may **perceive** (`read-chat`, `read-roster`, `read-media`, `see-screen`, `hear-audio`, `receive-directed`) and **act** (`send-chat`, `speak`, `act`). `defaultGrant('agent')` is least-privilege — an agent perceives the conversation but acts on nothing until granted. - **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. ## 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 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. A live call attributes each message via the DTLS-bound connection; an async Space has no live connection, so each **ledger entry carries its own signature**. A member holds an ECDSA signing keypair; their grant is the live-call invite token — byte-identical to `inviteToken.signPayload` — with the superset payload `{ 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`. 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 email/Google path additionally binds a fresh, externally-issued OIDC identity to the signing key (`nonce = spkNonce(spk)`), so an entry proves "signed by the keyholder who authenticated as **this** account, now" — a leaked link is then no longer enough to post. ## One definition, three verifiers The verifier can be anyone — which is what lets the *same* credential model span three runtimes. Only **who runs the check** and **what fresh value the credential is bound to** change: | | **Live P2P call** | **Async P2P read** | **Async server verify** | |---|---|---|---| | Who verifies | a **peer** — no server | **any reader**, at read time | the **certified-ephemeral function** | | Membership | the join gate over presence | **hold the key** (`commit(mk) == mkCommit`) | same | | Freshness / anti-replay | the live **DTLS cert fingerprint** | the per-entry **signature** | a **server-issued challenge**, folded into the signed core | | 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 | **Async did not need a weaker model. It needed a different anchor for the same model** — the member's signing key (identity) plus a server-issued challenge (presence) in place of the DTLS fingerprint. **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 one turn; an OIDC email it verifies is transient, never persisted; reads it serves an agent are sealed to that agent's key. The server *verifies* without being *trusted with content*. --- ## The owner rule {#the-owner-rule} > **Status: Implemented — built and tested, behavior-neutral, not yet enabled in production.** The full path is implemented and tested > end-to-end for both trust-root modes: **on-prem** (the owner pins one `OWNER_POLICY_KEY`) and **hosted multi-tenant** > (a tenant registry, `OWNER_REGISTRY=1`, resolves the trusted key *by the room's app*). Also built: the create-time > mandate, the render's integrity gate, tier-driven capabilities, client peer verification, tenant self-registration > (`op:'register-owner'`), and key rotation/revoke (`op:'rotate-owner'`). It stays inert until an owner opts in — keys > unset, rooms behave exactly as before. A Witbitz room is a door whose key is the link — great between equals, a problem the moment there's an **owner**. If a company runs an app on Witbitz, it has no way to say *"every room in my app is only for my people, and gold customers get more than free ones."* The owner rule turns **"the link is the authority"** into **"the owner is the authority,"** opt-in, without giving up content-blindness. **The rule: a signed App Policy.** The owner writes one small document and signs it with a key only they hold: ``` AppPolicy { app: "acme-portal" // which app this governs ownerKey: // the root of authority tiers: [ { name:"open", admit: anonymous, caps:{ read }, limits: low } { name:"member", admit: { issuer:"acme.okta.com", any:true }, caps:{ read, chat }, limits: mid } { name:"gold", admit: { issuer:"acme.okta.com", claim:{group:"gold"} },caps:{ read, chat, act, premium }, limits: high } ] roomTiers: { default:"member", admits:["open","member","gold"] } } → signed by ownerKey ``` The signature is the whole trick: nobody without `ownerKey` can produce a valid policy, so nobody can forge a *weaker* one. Admission stops being binary — it's a **ladder** from anon-public to premium-verified, and the flat "must be my user" rule is just its one-tier case. **Bound to a room at birth.** When a room is created through the owner's credential (hosted, the owner's tenant key; on-prem, its own create endpoint), `create` stamps the signed policy **sealed** into the room config (the render reads and enforces it) and a **cleartext marker** `{ app, ownerKeyId, policyHash }` on the public record (so the content-blind platform and keyless read paths know "this room is owner-gated" without reading anything). The owner's create path refuses to mint a room that doesn't carry the policy — the room is *born* gated. **Only the app can mint its rooms (room-genesis).** The signed policy is a reusable public artifact — anyone could copy it and stamp it on a room *they* create, standing up a look-alike to phish the owner's users. So stamping a policy at creation also requires a **room-auth**: a signature by the owner key *over this specific room* (the room id is in the signed core). Only the app owner holds the key, so only the app can mint "a genuine room of X's." **Tiers can't self-promote.** A member's tier comes only from a **signed** source: IdP claims (the owner's directory is the source of truth for who is gold), a signed capability grant (a room admin promoting a member), and the room's own tier (the ceiling). A tampered client can't forge the claim or the grant, and the **render** — not the client — resolves and enforces the tier. **Enforced in three independent places, no single point to bypass:** | When | Check | Where it runs | |---|---|---| | **Birth** | create refuses any owner-room without the signed policy + marker | the owner's create path (tenant-keyed / on-prem) | | **Writes** (every turn) | the render opens the sealed policy, verifies its signature against the pinned `ownerKey`, resolves the tier, gates each action; policy absent/invalid → refuse | the certified render | | **Blind reads** | the cleartext marker makes keyless reads refuse; keyed reads pass the same identity gate | the content-blind platform | | **Join** | the member's app verifies the room policy against its own pinned `ownerKey` and won't join an unrecognized room | every member's app (peer check) | Per-tier **limits** (rate / token / tool budget) reuse the platform's existing metering — so the ladder doubles as the owner's **pricing surface**: admission, capability, and monetization collapse into one signed table. **Registration — Witbitz federates; it doesn't run the directory.** A "registered user" is a record in the *owner's* identity provider, not a Witbitz account. Owner registration happens once (the trust root: `ownerKey`, the IdP issuer + JWKS as a [`SPACE_OIDC_ISSUERS`](./deployment-options.md) entry, and the claim → tier map); user registration happens in the owner's own directory (enterprise SSO / SCIM / invite — the tier is a group the owner assigns, so a user can't self-register as gold); device enrollment mints a signing key per device bound at sign-in. So **joining a room is authentication, not registration**, and revocation is symmetric — remove the user from the `gold` group and their next sign-in drops a tier, enforced on the very next turn. **Orthogonal to attestation.** This is the identity + capability axis; whether the client is *trustworthy code* is a separate axis ([the verified client](./client-architecture.md)). They compose — a tier's `admit` can additionally require an attested client — but identity-tiers work on the plain web with no attestation in sight. **The honest trade.** Requiring registration deliberately reintroduces a **central authority** — the owner's directory — into an otherwise account-free, link-based model. That's not a regression; it's exactly the control an owner asks for, and it's opt-in per app/room. The platform stays content-blind throughout — the policy is enforced in the certified render and by peers, never by a platform that reads the room. --- ## Lifecycle — removal, rotation, recovery {#member-lifecycle} 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. ### 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 (holding 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 — 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 reaches into their browser to delete it. You cannot *retract* a held key — only *abandon* it. Revocation works because of the thin-client model: **the client never decrypts — the server does.** The server is a decryption oracle, and cutting someone means **denying the oracle**, not un-giving the key. (There's no "their slice" to strip — everyone holds the same `mk`; you remove them from the **policy**, not the key.) Dropping the member from the allow-list is a *complete, shipped* revocation for an ordinary private Space; an epoch re-key buys exactly one more thing — the cut holding **without trusting the platform**. ### History vs future Revocation cannot make someone un-see content they already read — true for every end-to-end system. A re-key protects the **future**, not the past. History a member already saw remains known to them; new content can be blocked by read gates now and by epoch re-key in the stronger model. For rooms with hard compartment boundaries, design epochs up front. ### Membership change is a fork Adding or removing a member is **one epoch operation**, not an edit to a member list. The room forks: a fresh room key and roster, the history carried across, and a sealed pointer left behind so present members follow automatically. The removed member keeps whatever they already had (that cannot be undone) but holds no key to anything after the fork, and no deny-notice has to reach every peer for the guarantee to hold. That delivery problem is what sank the earlier in-place "config update" and lease designs. Both the Bridge and single Spaces use epoch re-key on every membership change — single Spaces via `spaces/public/spaceFork.js` (built, wired to the Members UI, and tested in `agent/spaceFork.test.mjs`). Re-key is triggered by a membership change; absent one, a Space keeps a long-lived key (no scheduled rotation). ### Rotation and replacing a compromised identity Rotation = open a new epoch, re-seal forward, hand the remaining members the new key. Per-member signing keys rotate independently: revoke the old grant, admit the new key. Replacing a compromised identity needs no rebuild — a member is a signing key bound by a grant, not the room itself: **revoke** the old grant (shipped), **admit** a fresh key, and **re-key** the epoch where a crypto-strong future read-cut is required (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. ### Metadata that remains 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. --- ## Recovery {#recovery} Witbitz deliberately has **no operator recovery key.** If the platform could recover a Space, the platform could read a Space. Recovery must belong to the user or organization, not to Witbitz as operator. **The current rule.** If every copy of the room key is lost, the Space is lost. That is not a product flourish; it is the cost of the privacy claim — the production envelope is sealed to the room key, not to an operator, support, escrow, or beta recipient. Verify it with the sealed-store check: `POST {"op":"sealed","room":""}` → the recipients list should name the **room**, not the operator ([Verify it yourself](./verify.md)). **Recovery must be user-held.** Passkey (**shipped**) · backup code (**shipped**) · k-of-n social recovery (the same Shamir split the couple key uses — *not built*) · organization key policy (the customer controls it — *on-prem*). None require Witbitz to hold a readable recovery recipient. **The shipped vault.** One vault per account, and **either** enrolled method opens it: a random 32-byte **master secret** identifies and encrypts the vault; each method stores one **wrapper** (the master secret sealed to that method's own secret); restoring with any enrolled method opens its wrapper, recovers the same master secret, and opens the one backup. So a passkey and a backup code are not two backups to keep in sync — they are two doors into the same room. The server stays content-blind throughout: it stores opaque blobs at **derived** ids and never receives the code, the passkey secret, the vault key, or any plaintext. Wrappers are written *before* the data they unlock, so a half-finished enrolment can never leave a vault nobody can open. **The offline export.** Recovery that depends on the platform being reachable is not self-sovereign, so a Space can also be exported to a file you keep, alongside a standalone decryptor page that opens it with no server at all. The export is **opaque by construction**: an earlier version wrote each Space's `room`, `title` and message `count` in the clear next to its sealed ledger — unreadable ciphertext that nonetheless announced every conversation's name and size to anyone who opened it in a text editor. That metadata is now sealed under the same room key as the ledger it describes; the decryptor shows "Conversation N" until you load your keys file. Two things remain visible: **how many** conversations the bundle holds and **when** it was exported (hiding the count would mean padding with decoys — more cost than it buys for a personal backup). **Enterprise / on-prem recovery.** In an on-prem deployment the organization controls the runtime boundary — its own backup policy, IdP, secret storage, and recovery procedure. The privacy story changes from "trust Witbitz not to read" to "verify the image you run and control the boundary yourself" ([Deployment options](./deployment-options.md)). **What not to promise.** Witbitz *cannot* recover a lost Space on hosted production — by design. And don't hide the user burden: user-held recovery is safer than operator-held, but it still requires a recovery artifact, another device, a passkey sync provider, a social-recovery set, or an organizational process. --- ## 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. ## 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. - **Sealed policy** — the policy is sealed under `mk`; verified identities are transient. ## Status | Piece | Live P2P | Async | |---|---|---| | 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`) | | Owner rule (signed App Policy, tiers, room-genesis) | n/a | **Implemented — not yet enabled in prod** | | 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 | **passkey + code shipped**; social recovery **designed** — never operator-held | | Per-member identity — couple (2-of-2); distinct invite links | n/a | **not yet** (solo signed path live) | ## Code map | Concern | Live P2P | 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 | `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`) | | Owner policy (sign / verify / register / rotate) | — | `agent/spaceHandler.mjs`, `tools/owner-policy.mjs` | | 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.