Skip to content

Hosting model and cross-instance identity

Canonical specification

This page is ProjectInfo/hosting-model.md on docs/encryption-modes-spec, rendered at build time. That file is the source of truth; the site holds no copy of it.

Status: the hosting model below is current. Cross-instance identity is a design, not an implementation — nothing in the codebase supports it yet.

Two ways to run ZippChat

ZippChat is a hosted service. There is a main production server, run by the project, and it is what this codebase deploys. Most users will never run anything.

An instance is also self-hostable, for someone who wants their own server for themselves and their friends. A self-hosted instance is the same software, not a cut-down build.

The two are not alternatives to choose between at the product level; both exist permanently and one of them has to be the default a user meets.

What that means for the threat model

This is the part that matters, and it is easy to get wrong by reasoning about "the server" as if it were one thing.

On a self-hosted instance, the operator is someone the users know — usually the person who invited them. "The server can read this" means your friend can read this. Bad, bounded, and legible.

On the main server, the operator is ZippChat. "The server can read this" means the company can read this, and can be compelled to hand it over. That is a different promise entirely, made to people who did not choose their operator and cannot inspect it.

The product claims Signal security. Signal cannot read message content. So:

On the main server, anything the operator can read is a defect unless the user chose it explicitly and was told plainly.

Consequences already in the code:

  • New channels default to secure (end-to-end encrypted; see encryption-modes.md). persistent — where the server holds the channel KEK — is an explicit opt-out, and the create-channel handler coerces any value that is not exactly "persistent" to "secure" so an old client or a typo fails to the safe side.
  • Moderation cannot read secure channels. POST /api/messages/{id}/decrypt returns ErrNotDecryptable for them, by design. Abuse handling on secure channels is report-driven — a user submits the content — which is the same position Signal is in and the same one it defends.
  • Voice is end-to-end encrypted and the SFU is excluded, which is also why participant display names are deliberately kept out of the LiveKit token: a plaintext roster of who is on a call gives back in metadata what the encryption removed.

Self-hosted operators may reasonably want the opposite trade — full history for new members, server-side moderation — and persistent remains available for exactly that. The difference is which one you get without asking.

Cross-instance identity — the design

Not built. No code, no endpoints, no schema. This section exists so that whoever builds it starts from the right constraint rather than the easy one.

The goal: a self-hosted instance may have its own local accounts, may accept main-ZippChat accounts, or both. A user with a ZippChat account should be able to join a friend's instance without making a second identity.

The one question that decides everything

Who holds the identity keys?

The tempting answer is that main ZippChat holds them and vouches for a user to whichever instance asks. It is far simpler: an instance calls an endpoint, gets "yes, this is user X and here is their key", and trusts it.

That answer is wrong, and it is wrong in the specific way that quietly destroys the product's central claim. If main ZippChat supplies the key a self-hosted instance uses to encrypt to a user, then main ZippChat can supply a different key — one it holds the private half of — and read every conversation on that instance. Nobody on the instance could tell. The operator would not know. The users would not know. The instance would be end-to-end encrypted to an endpoint of ZippChat's choosing.

Signal's answer to federation was to refuse it, largely for reasons in this neighbourhood. We are not refusing it, so we have to do the harder thing.

Device-rooted identity

The identity key belongs to the user's device. Main ZippChat is an authentication and discovery provider, never a source of key material.

  • A user's long-term identity keypair is generated on their device and never leaves it, exactly as it works today (see e2ee.md).
  • Main ZippChat may attest "this account authenticated" — an OIDC-style assertion binding an account identifier to a session. It says nothing about keys.
  • The instance learns the user's public identity key from the user's own device, on first contact, and pins it — the same trust-on-first-use the DM flow already uses.
  • Safety numbers remain the check, and they are computed from device identity keys, not from anything an instance or the identity provider supplied. Two users comparing a safety number out of band detect a substitution regardless of which instance either of them is on, and regardless of what main ZippChat asserted.
  • An identity change surfaces to the user exactly as it does today, with the same warning.

The property this buys: a hostile or compromised main ZippChat can deny you service, and can learn who talks to whom, but it cannot read your messages on any instance. That is the same boundary the product already claims within a single instance, extended across them without widening it.

What must not be built

  • No endpoint that hands one instance another user's private key material, in any form, for any reason.
  • No "trusted instance" flag that suppresses safety-number warnings. An instance the user has not verified is not more trustworthy for being federated.
  • No silent key rotation on the identity provider's say-so. A new device is a visible event.

Open questions

  • Discovery. How does an instance find a main-ZippChat user's key bundle without main ZippChat becoming a mandatory intermediary that can lie? Probably: it may relay, but the pinned key and the safety number are what the client trusts.
  • Revocation. If a main-ZippChat account is deleted or suspended, what should a self-hosted instance do? Almost certainly nothing automatic to message history — suspension is an authentication decision, not a licence to alter what people said.
  • Naming collisions. Two users called alice on two instances. Discord solves display collisions with per-server nicknames, which already exist here; the identifier under them still has to be globally unambiguous.
  • Which instance holds a shared server's data. A server lives on exactly one instance. Cross-instance membership is the feature; cross-instance servers are not, and should not be confused with it.