Skip to content

End-to-End Encryption

Canonical specification

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

⚠️ Where this document and Encryption Modes disagree, that document wins. It reconciles the mode names, key distribution and media model across the specs and the running code, which have diverged. The epoch-key and client-side file designs here are specified but not implemented.

Three content types use E2EE in Zipp Chat: DM messages (Signal Protocol), rolling_key channel messages (epoch keys), and client-side file encryption (per-file DEK). Server channel messages in server_encrypted mode use server-side envelope encryption (Vault KEK) — the server can read them, which is disclosed to users on channel creation.

Content Who holds key Server can read
DM messages Clients (X3DH + Double Ratchet) No
rolling_key channels Clients (epoch keys, sealed per-device) No
peer_provided channels Clients (P2P delivery) No
server_encrypted channels Server (Vault KEK) Yes — disclosed
public attachments Client (plaintext DEK in DB) No — DEK public, protects storage layer
DM attachments Clients (sealed DEK per device) No

Key Types

Key Algorithm Size Purpose
Identity key (signing) Ed25519 32 bytes Long-term signing identity
Identity key (DH) X25519 32 bytes Derived from Ed25519 for key exchange
Signed prekey X25519 32 bytes Medium-term DH key (rotate weekly)
One-time prekey (OTP) X25519 32 bytes Single-use forward secrecy
Ephemeral key X25519 32 bytes Per-session DH (generated by sender)
Message key (DM) XChaCha20-Poly1305 32 bytes Per-message AEAD (Double Ratchet)
Epoch key (channel) XChaCha20-Poly1305 32 bytes Per-epoch channel key (rolling_key)
File DEK XChaCha20-Poly1305 32 bytes Per-file encryption key

X3DH Session Initiation

Used when sending the first DM to a recipient on a new device.

Sender Flow

1. GET /api/keys/bundle/{recipientID}
   → IK_B (identity), SPK_B (signed prekey), OPK_B (one-time, may be null)
   → spk_id, opk_id

2. Verify: Ed25519.Verify(IK_B, SPK_B, sig_B)
   Abort if invalid — never proceed with unverified bundle

3. Generate ephemeral keypair: EK = X25519.GenerateKeyPair()

4. DH operations:
   DH1 = X25519(IK_A_private, SPK_B)
   DH2 = X25519(EK_private,   IK_B)
   DH3 = X25519(EK_private,   SPK_B)
   DH4 = X25519(EK_private,   OPK_B)   ← only if OPK available
   KM  = DH1 || DH2 || DH3 [|| DH4]

5. shared_secret = HKDF-SHA256(
     ikm  = KM,
     salt = 0x00...00 (32 zero bytes),
     info = "zipp-chat-x3dh-v1",
     len  = 32
   )

6. Initialise Double Ratchet with shared_secret

7. Encrypt first message. Include X3DHHeader:
   {
     ephemeral_key:      EK_public (base64),
     signed_prekey_id:   spk_id,
     one_time_prekey_id: opk_id (0 if none),
     sender_device_id:   this device's ID
   }

Recipient Flow (first message)

1. Read X3DHHeader from incoming DMMessage
2. Look up own keys:
   SPK_B = signed prekey matching signed_prekey_id
   OPK_B = one-time prekey matching one_time_prekey_id (if present)
   IK_B  = own identity key (private half)
   IK_A  = sender's identity key (fetch from keyserver if not cached)

3. DH1 = X25519(SPK_B_private, IK_A)
   DH2 = X25519(IK_B_private,  EK_A)
   DH3 = X25519(SPK_B_private, EK_A)
   DH4 = X25519(OPK_B_private, EK_A)   ← if OPK was used
   KM  = DH1 || DH2 || DH3 [|| DH4]

4. shared_secret = HKDF-SHA256(KM, zeroes, "zipp-chat-x3dh-v1", 32)
5. Initialise Double Ratchet → decrypt message

Double Ratchet

After X3DH, all subsequent messages use the Double Ratchet Algorithm.

Key properties: - Forward secrecy — past messages stay safe if current keys are compromised - Break-in recovery — future messages become secure after several exchanges - Each message carries ratchet_header (current ratchet public key + chain/message indices)

KDF Functions

KDF_RK(rk, dh_out):
  out = HKDF-SHA256(ikm=dh_out, salt=rk, info="ratchet-root", len=64)
  new_rk = out[0:32]
  new_ck = out[32:64]

KDF_CK(ck):
  mk = HMAC-SHA256(key=ck, data=0x01)   ← message key
  nc = HMAC-SHA256(key=ck, data=0x02)   ← next chain key

Encrypt/Decrypt: XChaCha20-Poly1305
  nonce = random(24 bytes)
  AD = ratchet_header bytes

Multi-Device Delivery

When sending a DM, encrypt separately for each recipient device AND each own device.

1. GET /api/keys/bundle/{recipientID}  → all recipient devices
2. GET /api/keys/bundle/{myUserID}     → all own devices (excluding current)
3. For each device: check for existing session or perform X3DH
4. Encrypt message key for each device → device_ciphertexts[]
5. POST /api/dm/{channelID}/messages with all device_ciphertexts

Rolling-Key Channel Encryption

Server channels created with encryption_mode: "rolling_key". The server stores only epoch ciphertexts — no key material.

Epoch Keys

Each epoch is a 256-bit random key covering all messages sent between key rotations. An epoch is identified by a UUID (epoch_id) and a monotonic counter (epoch_num). The key is distributed per-device via crypto_box_seal (anonymous ECIES using the device's IK X25519 public key).

The server validates on every rotation that the grant set covers all current server members. This prevents a malicious client from excluding a member.

Initial Epoch (on channel creation)

1. Creator: POST /api/servers/{id}/channels with encryption_mode: "rolling_key"
   Server returns Channel with ck_version: 0

2. Creator generates epochKey = random(32 bytes)

3. For each member device (including own devices):
   a. Fetch device IK public key (Ed25519) from keyserver
   b. Convert to X25519: ed25519PubToX25519(ikPub)
   c. encrypted_key_b64 = base64(crypto_box_seal(epochKey, deviceIKX25519))

4. POST /api/channels/{channelID}/epoch
   {
     epoch_num: 1,
     member_hash: sha256hex(sortedMemberIDs.join("")),
     grants: [{ user_id, device_id, encrypted_key_b64 }, ...]
   }

Key Rotation (on member join or leave)

Any online member triggers rotation. The first client to detect the event performs it:

1. Receive MEMBER_JOIN or MEMBER_LEAVE gateway event.

2. GET /api/channels/{channelID}  → current ck_version

3. Generate new epochKey = random(32 bytes)

4. Compute member_hash = sha256hex(sortedCurrentMemberIDs.join(""))
   (Exclude the leaver. Include the joiner.)

5. Seal epochKey for all member devices → grants array

6. POST /api/channels/{channelID}/epoch
   {
     epoch_num: ck_version + 1,
     member_hash,
     grants
   }

   409 Conflict → another client rotated concurrently; re-fetch and retry.

Async Grant Issuance (new member onboarding)

When a new member joins, they may not yet have a device grant if no rotation happened immediately (the epoch was already current). Any online member who holds the epoch key issues grants without user interaction:

1. Receive MEMBER_JOINED event.
2. GET new member's device IK public keys from keyserver.
3. Seal current epoch key for each new device.
4. POST /api/channels/{channelID}/epoch/{epochID}/grants
   { grants: [{ user_id, device_id, encrypted_key_b64 }] }

Sending a Message

epochKey = openSealedEpochKey(GET /api/channels/{id}/epoch)

nonce      = random(24 bytes)
ciphertext = crypto_secretbox_easy(plaintext, nonce, epochKey)
content    = base64(nonce || ciphertext)

POST /api/channels/{channelID}/messages
{
  "content": content,
  "epoch_id": epochId
}

If the server returns 409 (epoch mismatch): re-fetch the epoch key and re-encrypt with the new epoch.

Receiving a Message

1. On MESSAGE_CREATE: msg.encryption_mode === "rolling_key"
2. Look up decrypted epoch key for msg.epoch_id in local store.
3. If not cached: GET /api/channels/{channelID}/epoch → decrypt epoch key, cache by epoch_id.
4. wire = base64decode(msg.content)
   nonce = wire[0:24]
   ct    = wire[24:]
   plaintext = crypto_secretbox_open_easy(ct, nonce, epochKey)

History on Member Join

None. A new member cannot read messages encrypted under epoch keys issued before their grant.


Peer-Provided Key Channels

Server channels created with encryption_mode: "peer_provided". The server stores ciphertext only — no key material of any kind.

Key delivery is handled out-of-band: when a user joins, an online member sends them the channel key via a DM. The server cannot assist with key recovery.

Status: Server stores and serves ciphertext. Client-side P2P key delivery via DM is planned but not yet implemented.


Client-Side File Encryption

All files are encrypted before upload. Wire format: nonce (24 bytes) ∥ ciphertext (XChaCha20-Poly1305).

key_type = public (avatars, icons, public assets)

dek        = random(32 bytes)
nonce      = random(24 bytes)
ciphertext = crypto_secretbox_easy(plaintext, nonce, dek)

dek is stored in plaintext in media_files.public_dek. It is not secret — it only protects the storage layer.

key_type = dm (DM channel attachments)

dek        = random(32 bytes)
ciphertext = crypto_secretbox_easy(plaintext, nonce, dek)

# Seal DEK for each device (sender's own devices + all recipient devices):
for device in devices:
  ikX25519 = ed25519PubToX25519(device.identityKeyPub)
  grant    = crypto_box_seal(dek, ikX25519)
  store(grant, device.user_id, device.device_id)

On download: dek = crypto_box_seal_open(grant, ownIKX25519Pub, ownIKX25519Priv).


Message Plaintext

What is inside a message's ciphertext, once decrypted.

Channel messages (both modes) carry the bare UTF-8 text. Mentions are stored as <@user_id> markers inside it, so they follow a rename and the server never learns a mention happened. There is no wrapper: every client ever shipped renders the plaintext as text, so anything structured added here would show up as JSON on all of them. Extensions ride as attachments instead (below).

DM messages carry a JSON payload, {"zipp":1,"t":…}:

t Fields Meaning
msg text, optional reply (message id), optional files ([{id,name,type,key}]) a message; each file's own key travels here
edit target, text replaces the text of target
react target, emoji, on adds or removes a reaction
call key (base64, 32 bytes) a voice call starting; key is that call's media key, generated by the caller. The server never sees it — see dm-calls.md

Plaintext that does not start with {"zipp": is a plain message (DMs written before the payload existed). Unknown fields are ignored and unknown t values are dropped, so a newer client can add both without breaking an older one.

Signal's model. The sender's client fetches the page while the message is being written and attaches what it found; receivers draw what was attached and never fetch the URL — doing so would tell whoever runs the site that a ZippChat user received the link, when, and from what address.

A preview travels as an ordinary sealed attachment — same key path as any file in that channel or DM — with:

  • content_type: application/vnd.zippchat.link-preview+json
  • original_name: link-preview.json

Its plaintext:

{
  "v": 1,
  "url": "https://example.com/article",
  "title": "An article",
  "description": "What it is about",
  "site_name": "Example",
  "image": "<base64 JPEG>",
  "image_type": "image/jpeg"
}

Only v and url are required. Limits, enforced by the receiver too, since a sender is not trusted to have kept to them: title 300, description 1000, site name 100 characters; image at most 96 KiB, JPEG, at most 400 px on a side.

Receivers:

  1. Draw the preview only if url is http(s) and appears verbatim in the message text (as a link the text would linkify, not in a code span). Otherwise a sender could attach a card naming one site under text that links to another, and the card is what readers trust.
  2. Open url only on an explicit tap.
  3. Show nothing while the attachment loads or if it fails to open — the text is already there.

Clients that predate this see a file chip named link-preview.json under the text. The text itself is unaffected, which is why this is an attachment and not a change to the plaintext.

What the server learns: that a message carries a preview (an attachment row exists, with this content type), and its size. Not the URL or anything on the page.

How the sender fetches, chosen per device and on by default:

Route Where Who sees the URL
Direct (default) native builds only — a browser cannot read another site's page (CORS) the site, from the sender's address
Own proxy (HTTP or SOCKS5) native builds only — a page cannot pick a proxy the proxy; the site sees the proxy's address. SOCKS5 sends the host name unresolved, so the proxy does the DNS lookup
Instance (POST /api/link-preview) the only route a web build has; opt-in the instance, at preview time; the site sees the instance's address. The instance does not learn whether the preview was sent or to whom

A web build left on the default sends links without previews rather than falling back to the instance silently. The instance endpoint is described in Server/docs/api.md §15; it refuses non-public addresses at connect time, never logs the URL, and an operator can turn it off (LINK_PREVIEW_PROXY).

Images in a preview are scanned against the known-material hash list before sealing, like any other picture the sender attaches.

Invite embeds

An invite link in a message (<client origin>/…#/invite/<code>) is drawn as a card. These are not sealed previews: the server named in the link is looked up only when it is the reader's own instance (GET /api/invites/{code}, which the reader's instance already serves them), and Join uses POST /api/invites/{code}. A link to another instance is drawn from the link alone — its host and an Open button — because looking it up would hand every reader's address to a server none of them chose to contact.


PIN Backup

Lets users restore DM keys on a new device using an 8-digit PIN.

Creating a Backup

These parameters are normative and must match pkg/crypto/v1/pin.go byte for byte, or the server's fast PIN check rejects every correct PIN. An earlier version of this section specified threads = 4 for the master key, a XOR-ed salt for the verifier, and an AES-GCM-wrapped verifier. None of those match the implementation, and threads = 4 is not even reachable from a libsodium client — crypto_pwhash fixes the lane count at 1.

1. salt = random(16 bytes)          # exactly 16 — libsodium crypto_pwhash requires it

2. masterKey = Argon2id(
     password = PIN as UTF-8,
     salt     = salt,
     memory   = 65536 KB (64 MB),   # libsodium memlimit = 67108864 BYTES
     time     = 3,
     threads  = 1,                  # libsodium fixes this at 1
     keyLen   = 32
   )

3. pin_verifier = Argon2id(
     password = PIN + ":verifier",  # NOT the bare PIN
     salt     = salt,               # the same salt, NOT XOR-ed
     memory   = 16384 KB (16 MB),
     time     = 1,
     threads  = 1,
     keyLen   = 16                  # 16, not 32
   )
   Stored and compared as raw bytes in constant time. It is NOT encrypted.

4. encrypted_blob = AES-256-GCM(masterKey, key_bundle_bytes, ad = nil)
   Wire format: nonce(12) || ciphertext || tag(16)

5. PUT /api/keys/backup
   { salt, encrypted_blob, pin_verifier,
     argon2_memory, argon2_iterations, argon2_parallelism, version }

Backup blob encoding

encrypted_blob decrypts to this length-prefixed binary format (marshalBlob in pin.go). All integers big-endian:

u32 len ‖ identity_private_key
u32 len ‖ signed_prekey_private
u32 otp_count
  repeated otp_count times:
    u64 otp_id
    u32 len ‖ otp_private_key

Known-answer vector

Any implementation must reproduce these exactly:

pin      = "48261937"
salt     = 000102030405060708090a0b0c0d0e0f      (hex)
masterKey    = FdyVafTzC/sg0DFQ+hsDT0kUd764+DF0+63V6I5HyHg=   (base64)
pin_verifier = i/40eU1pNFsTwD+/VZ7kZg==                       (base64)

Restoring from Backup

1. GET /api/keys/backup
   → { salt, encrypted_blob, argon2_memory, argon2_iterations,
       argon2_parallelism, version }
2. Client derives masterKey with the stored params
3. Decrypt encrypted_blob → private keys
4. Import into local secure storage, rebuild Double Ratchet sessions

POST /api/keys/backup/restore also exists and takes the PIN in the request body. It is deprecated — it transmits the PIN to the server, which the GET form avoids. Do not use it in new clients.

Lockout: 10 failed attempts → 24-hour server-side lockout (Redis counter). The lockout is enforced on the endpoints that see an attempt.

Weak PIN Rejection (client-side, before sending)

Must match weakPINs in pkg/crypto/v1/pin.go — a client that accepts a PIN the server rejects produces a confusing late failure.

Reject: 00000000, 11111111, 22222222, 33333333, 44444444,
        55555555, 66666666, 77777777, 88888888, 99999999,
        12345678, 87654321, 01234567, 11223344, 12121212,
        00112233, 10101010, 13131313
Also reject: not exactly 8 decimal digits

(The server does not list 76543210, which an earlier version of this document did.)


AES-256-GCM Primitives

Used for PIN backup and server-side envelope encryption (server_encrypted channels).

Encrypt:
  nonce      = random(12 bytes)
  ciphertext = AES_GCM(key, nonce, plaintext, additional_data)
  output     = nonce || ciphertext || tag

Decrypt:
  nonce              = output[0:12]
  ciphertext_and_tag = output[12:]
  plaintext          = AES_GCM_Decrypt(key, nonce, ciphertext_and_tag, additional_data)

Key: 32 bytes. Nonce: 12 bytes random. Tag: 16 bytes (standard GCM).


Safety Numbers and Identity Change

X3DH authenticates a session against whatever identity key the keyserver hands out. Nothing in the protocol proves that key belongs to the person named beside it — the server could substitute its own and sit in the middle, and every signature would still verify. This is the TOFU gap noted in e2eefunc.md; safety numbers are how users close it out-of-band.

Trust model

Trust on first use, then pin. The first identity key a client sees for a peer is accepted silently and recorded. Every later sighting is compared against that record:

Situation Behaviour
No record Record it. No warning — there is nothing to compare against.
Matches the record Normal operation.
Differs from the record Mark the conversation unverified, warn prominently, and block sending until the user accepts or rejects.

Blocking sends is deliberate. A changed identity key is indistinguishable from a server-run MITM, and the moment of danger is the next message the user sends — a passive banner they can type straight past is not a warning, it is decoration.

An identity change is not rare in normal use: a reinstall without a PIN backup produces one. The UI must say what it means (their device or their keys changed) and what to do (confirm the safety number with them over another channel), not merely that something is wrong.

Where the peer's identity key comes from

Two sources, both authenticated:

  • The X3DH header (ik) on the first message of a session. It is not merely claimed: the receiver derives the shared secret from it, so a wrong key yields a wrong secret and decryption fails. A message that decrypts proves the header's identity key.
  • GET /api/keys/bundle/{userID}?consume=false for conversations that are already established. Pass consume=false — checking an identity must not burn a one-time prekey.

Compare on both send and receive. Receive-side is what catches a server that swapped keys to intercept; send-side is what stops the user handing plaintext to a key they have not accepted.

Computing the safety number

The construction follows Signal's, which exists to make it expensive to search offline for a different identity key whose displayed digits collide.

For one party, given their Ed25519 identity public key and their stable user id:

h = SHA-512( 0x0000 || identityKey || userId )     // version 0, 2 bytes big-endian
repeat 5199 times:  h = SHA-512( h || identityKey )
fingerprint = first 30 bytes of h

The iteration is the point: a single hash would let an attacker grind candidate keys cheaply until the 60 displayed digits matched.

Each 5-byte group of the 30-byte fingerprint becomes 5 decimal digits:

digits = (bigEndianUint40(group) mod 100000), zero-padded to 5

That is 6 groups — 30 digits — per party.

The safety number is the two fingerprints concatenated, ordered by comparing the two fingerprints as byte strings and putting the smaller first. Ordering by fingerprint rather than by user id makes both sides compute the same 60 digits without agreeing on who is "first".

Display as 12 groups of 5 digits.

Verification state

Per peer, a client stores: the pinned identity key, and whether the user has marked the safety number verified.

  • Verified survives only as long as the identity key does. Any change to the key clears it — a verification is a statement about one specific key, not about a person.
  • Accepting a changed key after a reinstall is normal and re-pins it, but does not restore verified status. The user must compare the number again to get that back.

What this does not do

Safety numbers detect substitution after the fact and only if users actually compare them. They are not key transparency: a server that swaps a key for a user who never checks is still undetected. Real protection would need a verifiable, append-only log of identity keys. That is out of scope here and should not be described as solved.


Post-Registration Key Setup

After account creation, before sending any DMs:

  1. Generate Ed25519 identity keypair
  2. Derive X25519 identity key from Ed25519 (for DH operations)
  3. Generate X25519 signed prekey; sign with Ed25519 identity key
  4. Generate 100 X25519 one-time prekeys
  5. POST /api/keys/bundle — upload identity key + signed prekey + signature
  6. POST /api/keys/prekeys — upload OTP batch

Post-Login Key Checks

  1. Parse did claim from JWT — this is the device ID
  2. If device has no local key material → PIN restore flow or fresh keygen
  3. GET /api/keys/prekeys/count — if count < 20, generate + upload 100 more
  4. Rotate signed prekey if > 7 days old (increment signed_prekey_id)