Skip to main content

End-to-End Encryption

Full source: development/E2EE.md.

Threat model

Protected against: a reader of the database, a backup, Redis, or the Nginx logs — none of them holds a key that opens a message. Call media never touches the server at all (see Peer-to-Peer Media), so there's no server copy to read in the first place.

Not protected against: a compromised client (the keys live there), and metadata — the server still knows who wrote to which channel and when, and how big each message was.

One key per machine

A channel's symmetric AES-256-GCM key is wrapped once per device, not once per account (ChannelKey, one row per (channel, epoch, recipient user, recipient device)). That's what lets a single device be revoked without rotating an identity every other machine is also using — and what lets "who can open this channel" be answered by the wrap table directly.

Repairing a second device — without over-sharing

GET /api/v1/e2ee/keys/:channelId answers two questions: who's missing the current epoch (keeps the next message readable), and who's missing any epoch their owner already holds on another machine (lets a phone signed in today read history from before it existed). The second is deliberately bounded — "their owner already holds it" — so a new device only ever recovers access the same person already has elsewhere, never a year of history handed to someone who joined yesterday.

Revocation

Deletes the wraps addressed to that device and refuses to seal for it again; the row itself stays, because "when a machine stopped being trusted" is the only thing anyone can audit afterwards. Every channel that device could read is marked stale and re-keys. Registering a revoked device id again is refused rather than silently un-revoking — the machine asking is running the same code that would let it un-revoke itself.

What revocation does not do: reach what the device already decrypted, or stop a still-logged-in session from continuing (that's what ending the session is for — two different actions, both needed).

Pieces

PieceWhere it livesWho can read it
Device identity key (ECDH P-256)Private half sealed in the OS keychain, per machineThat machine
Device public keysdevice_keys tableEveryone in the server
Sealed identity backupidentity_backups tableWhoever knows the account password or a recovery passphrase
Channel key (AES-256-GCM)In memory on member devicesChannel members
Wrapped channel keychannel_keys tableOnly the device it was sealed for
Message bodymessages.contentChannel members
Attachment bytesObject storageChannel members (session to fetch, channel key to read)
Voice/video mediaDTLS-SRTP, direct between peersThe two people on that connection
Avatars / server iconsObject storageAnyone with the URL — deliberately public

Deliberate leaks

  • Reactions are plaintext (MessageReaction.emoji) — the server has to count them for recipients who don't currently hold the channel key, and encrypting per-recipient would need a key exchange per thumbs-up.
  • Attachment size and count are known to the server via the Attachment row, even though the file's name, type and contents stay sealed inside the message envelope.
  • Avatars and server icons are unencrypted by necessity — an <img> tag can't carry an authorization header, and a member list renders them for people who hold no channel key at all.

Identity backup: the one boundary that moved

The backup is sealed with a key derived from the account password, and the password is something a live server sees at sign-in. So a stolen database opens nothing (passwords are bcrypt-hashed, the backup is ciphertext), but a compromised running server could capture a password in use and open that user's backup afterward. Anyone whose threat model includes the running deployment should set a recovery passphrase instead — it is never sent anywhere in any form.