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.
Link previews¶
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+jsonoriginal_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:
- Draw the preview only if
urlis 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. - Open
urlonly on an explicit tap. - 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.gobyte for byte, or the server's fast PIN check rejects every correct PIN. An earlier version of this section specifiedthreads = 4for the master key, a XOR-ed salt for the verifier, and an AES-GCM-wrapped verifier. None of those match the implementation, andthreads = 4is not even reachable from a libsodium client —crypto_pwhashfixes 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=falsefor conversations that are already established. Passconsume=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:
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:
- Generate Ed25519 identity keypair
- Derive X25519 identity key from Ed25519 (for DH operations)
- Generate X25519 signed prekey; sign with Ed25519 identity key
- Generate 100 X25519 one-time prekeys
POST /api/keys/bundle— upload identity key + signed prekey + signaturePOST /api/keys/prekeys— upload OTP batch
Post-Login Key Checks¶
- Parse
didclaim from JWT — this is the device ID - If device has no local key material → PIN restore flow or fresh keygen
GET /api/keys/prekeys/count— if count < 20, generate + upload 100 more- Rotate signed prekey if > 7 days old (increment
signed_prekey_id)