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 a new key Production (in place: the same room, a new key for the members who stay)

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.

Usernames, discovery, and who is in the room

Status — live in the Spaces app since 2026-09-25. Usernames, invite-by-username, @username on an allow-list, the server-attested @name on verified entries, the email-discovery switch, and the verified room's Members sheet are all in production. Not yet: the on-prem file/Postgres stores answer 501 user_unsupported for usernames.

A username is an address, not an identity. An account has three fields: the email it signs in with, a display name it types, and — optionally — a @username, a globally unique public address it chooses. The username is a label over the account's stable handle (the same OPRF-derived handle the directory already keys on), never a key: nothing in a room binds to the string, so renaming never costs access — every room that admitted @alon keeps admitting the same account under its new name. The server keeps no username → email map; a lookup of @alon yields the handle, the account's invite slot, and the display name only if the owner chose to publish it.

Rules. 3–30 characters, a-z 0-9 _, no leading underscore, no dots — so @x.y is always a domain and @x is always a person, with no guessing. A short reserved list is refused. Claim, rename and release happen in Settings → Choose a username, with the device's sign-in token; a claim is one atomic write, and a released or renamed-away name is held for 30 days before anyone else can take it.

Where a username can be used.

Being found by email is a switch, and it is on by default. The directory is a membership oracle: "is this email on Spaces?" That question is what makes a sealed in-app invite possible, and it is also information. Each account can close its email door in the same Your username sheet (People can find me by my email address). Closed reads exactly like unknown — the same 404 as an address that was never enrolled — for everyone except the owner proving their own email. An emailed invite to a closed address still arrives, as an emailed link rather than an in-app drop; an invite by @username still drops in-app, because the username door carries the slot.

A verified room lists its members, not its visitors. In an email-gated room the sheet under More is titled Members and shows every admitted person with their standing: signed in (the gate verified them here — from the server's sealed roster of sightings, or an entry it badged), not yet opened (on the list, never seen), or allowed (a whole domain). The label is the identity the server saw — the @username, else the email — never a typed name; typed, unvouched rows are not shown at all in a gated room. In an open room the same sheet is Who's been here and means exactly that: people who announced themselves. A lurker holding the link is invisible by construction.

Advertised or hidden. When a room is created, each invited person is either advertised — the room can read the address, and the Members sheet names them before they ever open the room — or hidden, in which case only a salted hash of the address is stored and the room cannot read it. Advertised is the default; the eye/lock on each chip flips it. Domains have no toggle. A long-press on a member row offers Save as contact.

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. So a removal does both: the server's gate refuses the removed member at once, and the room moves to a new key that only the members who stay receive. The gate stops reads through the platform; the new key makes the cut hold 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 the new key for what is written after the change. For rooms with hard compartment boundaries, design epochs up front.

Membership changes in place, with a new key

Adding or removing a member gives the room a new key without moving it: the same room id, link, history, store and lanes. The new key is sealed to each remaining member's invite key — one grant each — and the room's sealed settings keep the older keys, so the history stays readable to those who stay. A member's device that still holds the old key is told it is stale, fetches its grant, checks the new key against the room's commitment, and carries on; a removed member has no grant, and their old key opens nothing written after. Standard, confidential and hosted rooms work this way — in the Spaces app (Members) and the SDK (space.changeMembers()). A change of members or an explicit rotation triggers it; absent one, a room keeps its key (no scheduled rotation). The Bridge changes epoch on every membership change in the same spirit. Moving a room to a new one was the earlier model; a move an older client made is still followed.

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, admit a fresh key, and rotate the room's key (space.rotateKey()) where the future must be cut without trusting the platform. 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: a random 32-byte master secret identifies and encrypts it, and each recovery method stores one wrapper — the master secret sealed to that method's own secret — so a passkey and a recovery code are two doors into the same backup, not two backups to keep in sync. The doors are behind a gate that asks for two things: the recovery code together with an email check on the account's address (a one-time code, or Google), or the backup passkey. The code alone does not open an account created signed in, and the email alone never does. The server stays content-blind throughout: it stores opaque blobs at derived ids, keeps the email only as an HMAC under a key derived from the code, and never receives the code, the passkey secret, the vault key, or any plaintext. The full mechanism: accounts and recovery.

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 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 (a new key, forward secrecy) n/a shipped — in place for single Spaces, by epoch in the Bridge
Rotate a compromised member/agent key shipped shipped (identity + revoke, and rotateKey() for the 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 (the Bridge); agent/roomRekey.mjs, spaces/public/rekeyMembers.js, sdkRekey.js (single Spaces, in place)
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-10-27T07:05:42Z · build f301388c · every doc in one fetch: llms-full.txt (HTML)