Witbitz docs HomeTrustAll docs

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 (why the platform can't read the room) and Verify it yourself (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).

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 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:

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):

code
token = base64url(JSON.stringify(payload)) + "." + base64url( ECDSA-P256-SHA256 sig over the first part )

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.

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

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:

code
AppPolicy {
  app:      "acme-portal"                       // which app this governs
  ownerKey: <owner's public key>                // 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 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). 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

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

"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

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":"<ROOM>"} → the recipients list should name the room, not the operator (Verify it yourself).

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).

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.


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

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 poll403)
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. 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.

Machine-readable source: identity-and-admission.md · Generated 2026-08-29T18:46:30Z · build f3ff88cc · every doc in one fetch: llms-full.txt (HTML) · ← spaces-app-defaults · client-architecture