ZippChat E2EE — How It Works¶
Partly out of date
Rendered from ProjectInfo/e2eefunc.md on docs/encryption-modes-spec. Its protocol content is still useful, but parts of it describe an older client or an earlier API. Where it disagrees with the code, the code wins — see the spec errata.
Security Assessment¶
TL;DR — Protocol is correct. Not production-ready yet. Fine for beta/dev.
What is solid¶
| Check | Result |
|---|---|
| X3DH DH operations | Correct order, matches Signal spec |
| SPK signature verified before X3DH proceeds | Yes — aborts on failure |
| HKDF domain separation | "zipp-chat-x3dh-v1" / "zipp-chat-dr-ratchet-v1" — clean |
| Double Ratchet KDF chain | Correct KDF_RK (HKDF-64) and KDF_CK (HMAC-0x01/0x02) |
| Message encryption | XChaCha20-Poly1305 with 192-bit nonce — no nonce collision risk |
| Ratchet header as AEAD additional data | Authenticated — header tampering detected |
| Server never holds plaintext | DM table has no plaintext column; service layer passes bytes through |
| OTP atomic consumption | Keyserver ConsumeOTP removes OTP from DB at bundle fetch time |
| AES-256-GCM usage | Random 12-byte nonce every call — correct |
| PIN backup Argon2id params | 64MB/3iter/p4 — appropriate hardness |
| PIN fast verifier | UX hint only — real auth is AES-GCM tag |
| Server lockout | 10 attempts → 24h Redis lock |
| Weak PIN rejection | 18 common patterns blocked client-side |
Blockers before production¶
| Issue | Risk | Fix |
|---|---|---|
Private keys in localStorage |
XSS on any page → full key exfiltration | Migrate to OS keychain via Tauri credentials.rs (file exists, not wired to crypto yet) |
Multi-device: only bundles[0] used |
Other recipient devices never receive the message | Implement full per-device encrypt loop in e2ee.ts:encryptDM |
| SPK rotation not automated | SPK stays forever if client doesn't rotate; spec says 7 days | Wire rotation check on login / periodic Tauri timer |
| Secure Channel E2EE not implemented | Server channels sent plaintext to server | Migration 000012 + channel key distribution (designed, not built) |
Minor notes (non-blocking)¶
- Spec vs. implementation divergence on PIN verifier salt:
e2ee.mdsayssalt XOR 0xFF;pin.gousessalt || "verifier". Both produce a different salt — equally secure, just inconsistent docs. JSON.stringifyas AEAD additional data: V8/SpiderMonkey produce stable key order for same-shape objects, so this is deterministic in practice. Switching to a canonical binary encoding would be more rigorous.- No key transparency / TOFU: First-seen identity key is trusted and now pinned — a later change blocks sending until the user reviews the safety number (
e2ee.md§ Safety Numbers and Identity Change, shipped). That closes the silent-swap hole for users who compare out-of-band; a server that swaps a key for someone who never checks is still undetected, which needs key transparency rather than fingerprints. - Session state in
localStorage: Skipped message keys are accessible if there's XSS. Moves to keychain storage when key migration above is done.
Scope¶
| Content | Encryption model | Server can read? |
|---|---|---|
| DM messages | X3DH + Double Ratchet (true E2EE) | No |
| Secure Channel messages | Rotating Channel Key, client-held | No |
| Persistent Channel messages | Envelope encryption (DEK + Vault KEK) | Yes — disclosed |
| Media files | Envelope encryption (DEK + Vault KEK) | Yes |
| Key backups | Argon2id-derived key, PIN-only | No |
| Voice/video | LiveKit E2EE | No |
Private keys never leave the client. The server stores ciphertext blobs only.
Crypto Libraries¶
| Side | Library | Primitives used |
|---|---|---|
| Client (TypeScript) | libsodium-wrappers-sumo |
Ed25519, X25519 (crypto_scalarmult), XChaCha20-Poly1305 (crypto_aead_xchacha20poly1305_ietf_*), HMAC-SHA256 |
| Server (Go) | golang.org/x/crypto |
curve25519.X25519, ed25519.Verify, hkdf, argon2.IDKey |
| Server (Go) | stdlib crypto/aes, crypto/cipher |
AES-256-GCM |
Key Types¶
| Key | Algo | Size | Lifetime | Purpose |
|---|---|---|---|---|
| Identity key (Ed25519) | Ed25519 | 64-byte secret / 32-byte public | Long-term | Signing; uploaded to keyserver |
| Identity key (X25519) | X25519 | 32 bytes | Derived | DH — converted from Ed25519 at use time |
| Signed prekey (SPK) | X25519 | 32 bytes | ~7 days | Medium-term DH; signed with identity key |
| One-time prekey (OTP) | X25519 | 32 bytes | Single-use | Forward secrecy; consumed on first message |
| Ephemeral key (EK) | X25519 | 32 bytes | Per-session | Generated by sender for X3DH only |
| Ratchet keypair | X25519 | 32 bytes | Per-ratchet-step | Double Ratchet DH output |
| Message key (MK) | XChaCha20-Poly1305 | 32 bytes | Per-message | Derived from chain key; discarded after use |
Key Generation and Local Storage¶
All private keys are stored in localStorage by the Tauri client under these keys:
e2ee_ik_pub Ed25519 identity public key (base64)
e2ee_ik_sec Ed25519 identity secret key (base64)
e2ee_spk_pub X25519 signed prekey public (base64)
e2ee_spk_sec X25519 signed prekey secret (base64)
e2ee_spk_id Signed prekey ID (integer string)
e2ee_spk_sig Ed25519 signature over SPK public (base64)
e2ee_otp_keys JSON array of { id, pub, sec } (base64 values)
e2ee_device_id Device ID (from JWT `did` claim)
e2ee_session_<cid> JSON-serialized RatchetSession per DM channel
e2ee_msg_<msgId> Cached plaintext per decrypted message
Identity key generation (keystore.ts:generateIdentityKey)¶
const kp = sodium.crypto_sign_keypair(); // Ed25519
localStorage.set(ikPub, kp.publicKey);
localStorage.set(ikSec, kp.privateKey);
Signed prekey generation (keystore.ts:generateSignedPrekey)¶
const kp = sodium.crypto_box_keypair(); // X25519
const sig = sodium.crypto_sign_detached(kp.publicKey, ik.privateKey); // Ed25519 sign
// id = previous_id + 1, persisted in localStorage
OTP batch generation (keystore.ts:generateOTPBatch)¶
Generates n (default 100) X25519 keypairs. IDs increment from the current max stored OTP ID. Stored as JSON array in e2ee_otp_keys.
Post-Registration Key Setup (e2ee.ts:initializeKeys)¶
On first login when hasKeys() is false:
generateIdentityKey()— Ed25519 keypair → localStoragegenerateSignedPrekey()— X25519 keypair, sign with identity key → localStoragegenerateOTPBatch(100)— 100 X25519 OTP keypairs → localStoragePOST /api/keys/bundle— upload identity key, signed prekey, SPK ID, signaturePOST /api/keys/prekeys— upload 100 OTP public keys
Server validates the SPK signature via ed25519.Verify(identityKey, signedPrekey, sig) before storing (bundle/service.go:UploadBundle).
Post-Login Key Checks¶
On each login:
- Parse
didclaim from JWT → sete2ee_device_idin localStorage - If
hasKeys()is false → run key setup above GET /api/keys/prekeys/count— if count < 20, generate + upload 100 more OTPs- Signed prekey rotation if > 7 days old is defined in spec but not yet automated client-side
X3DH Session Initiation¶
Used when sending the first DM to a recipient with no existing session.
Sender (x3dh.ts:x3dhSend + e2ee.ts:encryptDM)¶
1. GET /api/keys/bundle/{recipientID}
→ bundle: { identity_key, signed_prekey, signed_prekey_id,
signed_prekey_signature, one_time_prekey? }
2. Verify: sodium.crypto_sign_verify_detached(sig, spkBPub, ikBPubEd)
→ abort if invalid
3. Convert Ed25519 keys to X25519 for DH:
ikBPubX = crypto_sign_ed25519_pk_to_curve25519(ikBPubEd)
ikAPrivX = crypto_sign_ed25519_sk_to_curve25519(myIdentityPriv)
4. Generate ephemeral X25519 keypair: ekKP = crypto_box_keypair()
5. DH operations:
DH1 = scalarmult(ikAPrivX, spkBPub) // IK_A × SPK_B
DH2 = scalarmult(ekKP.priv, ikBPubX) // EK_A × IK_B
DH3 = scalarmult(ekKP.priv, spkBPub) // EK_A × SPK_B
DH4 = scalarmult(ekKP.priv, otpBPub) // EK_A × OPK_B (if OTP available)
6. KM = DH1 || DH2 || DH3 [|| DH4] (96 or 128 bytes)
7. sharedSecret = HKDF-SHA256(ikm=KM, salt=0x00×32, info="zipp-chat-x3dh-v1", len=32)
8. session = initSenderRatchet(sharedSecret, spkBPub)
9. Encrypt first message with ratchetEncrypt(session, plaintext)
10. Encode X3DHHeader as JSON → base64:
{ eph: ekKP.pub, spk_id, otp_id (0=none), did: myDeviceId, ik: myIdentityPub }
The X3DHHeader is sent alongside the ciphertext so the recipient can reconstruct the same shared secret.
Recipient (x3dh.ts:x3dhReceive + e2ee.ts:decryptDM)¶
1. Decode X3DHHeader from incoming message
2. Look up local keys:
spk = getSignedPrekeyById(header.spk_id)
otpPriv = consumeOTPKey(header.otp_id) // removes OTP from localStorage
senderIkPubEd = from_base64(header.ik)
ephPub = from_base64(header.eph)
3. Convert:
ikBPrivX = crypto_sign_ed25519_sk_to_curve25519(myIdentityPriv)
ikAPubX = crypto_sign_ed25519_pk_to_curve25519(senderIkPubEd)
4. DH operations (mirror of sender, roles swapped):
DH1 = scalarmult(spkPriv, ikAPubX) // SPK_B × IK_A
DH2 = scalarmult(ikBPrivX, ephPub) // IK_B × EK_A
DH3 = scalarmult(spkPriv, ephPub) // SPK_B × EK_A
DH4 = scalarmult(otpPriv, ephPub) // OPK_B × EK_A (if used)
5. KM = DH1 || DH2 || DH3 [|| DH4]
6. sharedSecret = HKDF-SHA256(KM, 0x00×32, "zipp-chat-x3dh-v1", 32)
→ identical to sender's sharedSecret
7. session = initReceiverRatchet(sharedSecret, spk.pub, spk.sec)
→ decrypt with ratchetDecrypt(session, ciphertext, ratchetHeader)
Server-side X3DH (pkg/crypto/v1/x3dh.go)¶
The server implements X3DHSender and X3DHRecipient in Go using curve25519.X25519 and hkdf.New(sha256.New, ...). These are available for any future server-side use (e.g., bridge bots) but not used for user DMs — the client holds all private keys.
HKDF Implementation¶
Client uses a manual HMAC-SHA256 expand (libsodium has no native HKDF):
// x3dh.ts:hkdf
prk = HMAC-SHA256(ikm, salt || zero32)
for each block:
T(i) = HMAC-SHA256(T(i-1) || info || counter, prk)
output = T(1) || T(2) || ... truncated to len
Server uses golang.org/x/crypto/hkdf with sha256.New — RFC 5869 compliant.
Both use info = "zipp-chat-x3dh-v1" and a 32-byte zero salt for X3DH.
Double Ratchet Algorithm¶
After X3DH, all subsequent messages use Double Ratchet. Implemented in ratchet.ts.
Session State (RatchetSession)¶
{
rk: string; // root key (32 bytes, base64)
cks: string | null; // sending chain key
ckr: string | null; // receiving chain key
dhsPub: string; // our current ratchet public key
dhsPriv: string; // our current ratchet private key
dhrPub: string | null; // their latest ratchet public key
Ns: number; // messages sent on current sending chain
Nr: number; // messages received on current receiving chain
PN: number; // messages sent on previous sending chain
skipped: Record<string, string>; // "dhrPub:n" → base64 message key
}
KDF Functions¶
KDF_RK(rk, dhOut):
out = HKDF(ikm=dhOut, salt=rk, info="zipp-chat-dr-ratchet-v1", len=64)
newRk = out[0:32]
newCk = out[32:64]
KDF_CK(ck):
mk = HMAC-SHA256(data=0x01, key=ck) // message key
nextCk = HMAC-SHA256(data=0x02, key=ck) // next chain key
Sending (ratchet.ts:ratchetEncrypt)¶
1. [nextCk, mk] = KDF_CK(session.cks)
2. header = { dh: dhsPub, pn: PN, n: Ns }
3. ciphertext = XChaCha20-Poly1305(
key=mk,
plaintext=plaintext,
ad=JSON.stringify(header),
nonce=random(24 bytes)
)
output = nonce || ciphertext
4. session.cks = nextCk; session.Ns++
Receiving (ratchet.ts:ratchetDecrypt)¶
1. Check skipped[header.dh + ":" + header.n] — if found, use stored key, skip ratchet
2. If header.dh ≠ session.dhrPub (new ratchet key from sender):
a. Store skipped keys for remaining messages in current receiving chain
(up to header.pn, capped at MAX_SKIP=1000)
b. DH ratchet step 1:
dhOut1 = scalarmult(dhsPriv, newDhrPub)
[rk2, newCkr] = KDF_RK(rk, dhOut1)
c. Generate new DH sending keypair
d. DH ratchet step 2:
dhOut2 = scalarmult(newDhsPriv, newDhrPub)
[rk3, newCks] = KDF_RK(rk2, dhOut2)
e. Update session: rk=rk3, ckr=newCkr, cks=newCks, new dhs keypair
3. Skip messages in receiving chain up to header.n, storing keys in skipped[]
4. [nextCkr, mk] = KDF_CK(ckr)
5. plaintext = XChaCha20-Poly1305-Decrypt(mk, ciphertext, JSON.stringify(header))
6. session.Nr++
The RatchetHeader ({ dh, pn, n }) is sent with every message as JSON → base64 and stored in the ratchet_header column.
Session Initialization¶
Sender (initSenderRatchet):
dhsKP = new random X25519 keypair
dhOut = scalarmult(dhsKP.priv, recipientSpkPub)
[newRk, cks] = KDF_RK(sharedSecret, dhOut)
session = { rk: newRk, cks, ckr: null, dhs: dhsKP, dhrPub: recipientSpkPub, Ns=Nr=PN=0 }
Receiver (initReceiverRatchet):
session = { rk: sharedSecret, cks: null, ckr: null, dhs: mySpkKeypair, dhrPub: null, Ns=Nr=PN=0 }
// cks and ckr are populated on first incoming ratchet step
Sessions are persisted to localStorage as JSON after every encrypt/decrypt call.
Message Payload Structure¶
What POST /api/dm/{channelID}/messages receives and stores:
{
"ciphertext": "<base64>", // XChaCha20-Poly1305 ciphertext for recipient
"ratchet_header": "<base64>", // JSON-encoded RatchetHeader → base64
"x3dh_header": "<base64 | null>", // JSON-encoded X3DHHeader → base64; first msg only
"device_ciphertexts": [
{ "device_id": "<myDeviceId>", "ciphertext": "<base64>" }
// one entry per device (sender's own devices + recipient's other devices)
],
"reply_to_id": "<msgId | null>"
}
What clients receive (MessageResponse):
{
"id", "channel_id", "sender_id", "sender_device",
"ciphertext": "<base64>",
"ratchet_header": "<base64>",
"x3dh_header": "<base64 | null>",
"device_ciphertexts": [{ "device_id", "ciphertext" }],
"reply_to_id", "edited", "edited_at", "created_at"
}
The server never decrypts DM content. dm/service.go:SendMessage validates membership and passes the opaque bytes straight to the DB.
Self-Encryption (Sender's Sent Message Copy)¶
The sender cannot decrypt the main ciphertext (it was encrypted to the recipient's ratchet key). To read their own sent messages, the client creates a self-encrypted copy:
// e2ee.ts:deriveSelfKey
ikX = crypto_sign_ed25519_sk_to_curve25519(identityPriv)
selfKey = HKDF(ikm=ikX, salt=channelId_utf8, info="zipp-chat-self-v1", len=32)
// e2ee.ts:selfEncrypt
nonce = random(24 bytes)
ct = XChaCha20-Poly1305(key=selfKey, plaintext, ad=empty, nonce)
output = nonce || ct
This ciphertext goes into device_ciphertexts[{ device_id: myDeviceId, ciphertext }]. On decrypt, if msg.sender_id === myUserId, the client finds its own entry and calls selfDecrypt.
Multi-Device Delivery¶
When sending a DM to a user with multiple devices, the client must:
GET /api/keys/bundle/{recipientID}— returns onePreKeyBundleResponseper deviceGET /api/keys/bundle/{myUserID}— returns own other devices (excluding current)- For each device: check for existing ratchet session or perform X3DH
- Encrypt the message key (or plaintext) separately per device →
device_ciphertexts[] POST /api/dm/{channelID}/messageswith all ciphertexts in one request
The keyserver GetBundles (bundle/service.go) atomically consumes one OTP per device during the bundle fetch — OTPs cannot be reused.
PIN Backup¶
Allows key material to be restored on a new device using an 8-digit PIN.
Weak PIN Rejection (client-side, pkg/crypto/v1/pin.go:ValidatePIN)¶
Rejects if not exactly 8 decimal digits. Hardcoded denylist:
00000000 11111111 22222222 33333333 44444444
55555555 66666666 77777777 88888888 99999999
12345678 87654321 01234567 11223344 12121212
00112233 10101010 13131313
Creating a Backup (pin.go:SealKeyBackup)¶
1. salt = random(16 bytes)
2. masterKey = Argon2id(
password = PIN (UTF-8),
salt = salt,
memory = 65536 KB (64 MB),
iterations = 3,
parallelism = 4,
keyLen = 32
) // ~1s on mid-range device
3. verifierKey = Argon2id(
password = PIN,
salt = salt || "verifier",
memory = 16384 KB,
iterations = 1,
parallelism = 2,
keyLen = 16
) // fast for server-side pre-check
4. plaintext = marshalBlob({
identity_priv, signed_prekey_priv,
otp_keys: [{ id, priv }, ...]
})
// length-prefixed binary encoding
5. encrypted_blob = AES-256-GCM(key=masterKey, plaintext, ad=nil)
6. PUT /api/keys/backup with { salt, encrypted_blob, pin_verifier=verifierKey, argon2_params }
Server stores the encrypted blob. It never sees the PIN or plaintext keys.
Restoring from Backup (bundle/service.go:RestoreBackup)¶
1. POST /api/keys/backup/restore with { pin }
2. Server: ValidatePIN(pin) — reject obviously invalid format
3. Server: CheckLocked(userID) — Redis counter, 24h lockout after 10 failures
4. Server: VerifyPINFast(pin, salt, storedVerifier)
→ fast Argon2id check against verifier — rejects obvious typos before full compute
→ wrong: RecordFailedAttempt → ErrWrongPINWithHint{Remaining}
→ locked: ErrLockedOut{Until}
5. Server: return { salt, encrypted_blob, argon2_params }
6. Client: re-derive masterKey = Argon2id(pin, salt, full params)
7. Client: plaintext = AES-256-GCM-Decrypt(masterKey, encrypted_blob)
8. Client: import keys → localStorage, rebuild sessions
Lockout: 10 failed attempts → 24-hour Redis-backed server lockout. Verifier fast-check is a UX hint; the AES-GCM auth tag is the cryptographic proof of correct PIN.
Changing PIN (pin.go:ChangePIN)¶
AES-256-GCM Primitives (pkg/crypto/v1/aes.go)¶
Used for PIN backup blobs and envelope encryption.
Encrypt(key [32], plaintext, additionalData):
nonce = random(12 bytes)
ciphertext = AES-GCM-Seal(key, nonce, plaintext, additionalData)
output = nonce || ciphertext || tag // tag appended by GCM
Decrypt(key [32], ciphertext, additionalData):
nonce = ciphertext[0:12]
ct = ciphertext[12:]
return AES-GCM-Open(key, nonce, ct, additionalData)
Key: 32 bytes. Nonce: 12 bytes random. Tag: 16 bytes (standard GCM).
Server Channel Envelope Encryption (pkg/crypto/v1/envelope.go)¶
For Persistent Channels (server can read; disclosed to users):
Seal(plaintext, channelKEK, kekVersion):
dek = GenerateKey() // random 32-byte AES key
ciphertext = AES-GCM(dek, plaintext)
encryptedDEK = AES-GCM(channelKEK, dek, ad=kekVersion)
return { ciphertext, encryptedDEK, kekVersion }
Open(envelope, channelKEK):
dek = AES-GCM-Decrypt(channelKEK, encryptedDEK, ad=kekVersion)
plaintext = AES-GCM-Decrypt(dek, ciphertext)
channelKEK is fetched from HashiCorp Vault at path secret/channels/<id>/kek/<version> at decrypt time — never cached long-term. DB stores { ciphertext, encrypted_dek, kek_version } only.
Keyserver API Surface¶
| Method | Endpoint | Purpose |
|---|---|---|
| POST | /api/keys/bundle |
Upload identity key + SPK + signature |
| GET | /api/keys/bundle/{userID} |
Fetch all devices' bundles (consumes one OTP per device) |
| POST | /api/keys/prekeys |
Upload OTP batch (max 100) |
| GET | /api/keys/prekeys/count |
Get remaining OTP count for own device |
| PUT | /api/keys/backup |
Create or replace PIN backup |
| POST | /api/keys/backup/restore |
Restore — server runs fast verifier, returns blob |
| DELETE | /api/keys/backup |
Delete backup |
Security Properties¶
| Property | Mechanism |
|---|---|
| Forward secrecy | OTPs (per-session) + Double Ratchet (per-message chain step) |
| Break-in recovery | Double Ratchet DH step re-derives root/chain keys every exchange |
| Server can't read DMs | Private keys local-only; server stores ciphertexts only |
| SPK authenticity | Ed25519 signature over SPK verified by sender before X3DH |
| PIN backup brute-force resistance | Argon2id 64MB/3iter; 10-attempt lockout; 100M combinations |
| OTP exhaustion fallback | X3DH proceeds without OTP (DH4 dropped); reduced forward secrecy |
| Skipped message handling | skipped map in ratchet session, capped at 1000 keys |
| Self-message readback | Separate self-encrypted copy in device_ciphertexts; key derived per-channel |
Known Gaps / Not Yet Implemented¶
- Secure Channel E2EE — designed (see
docs/architecture.md), migration + implementation pending - Signed prekey auto-rotation — spec says rotate if > 7 days old; client-side scheduler not wired
- Multi-device per-recipient encryption —
encryptDMcurrently uses onlybundles[0]; full multi-device loop pending - Tauri keychain storage — keys stored in
localStorage; production target is OS keychain via Tauri secure storage commands (src-tauri/src/commands/credentials.rs) - OTP replenishment trigger — post-login check exists; no real-time trigger when OTPs run low during active messaging