Skip to content

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.md says salt XOR 0xFF; pin.go uses salt || "verifier". Both produce a different salt — equally secure, just inconsistent docs.
  • JSON.stringify as 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:

  1. generateIdentityKey() — Ed25519 keypair → localStorage
  2. generateSignedPrekey() — X25519 keypair, sign with identity key → localStorage
  3. generateOTPBatch(100) — 100 X25519 OTP keypairs → localStorage
  4. POST /api/keys/bundle — upload identity key, signed prekey, SPK ID, signature
  5. POST /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:

  1. Parse did claim from JWT → set e2ee_device_id in localStorage
  2. If hasKeys() is false → run key setup above
  3. GET /api/keys/prekeys/count — if count < 20, generate + upload 100 more OTPs
  4. 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:

  1. GET /api/keys/bundle/{recipientID} — returns one PreKeyBundleResponse per device
  2. GET /api/keys/bundle/{myUserID} — returns own other devices (excluding current)
  3. For each device: check for existing ratchet session or perform X3DH
  4. Encrypt the message key (or plaintext) separately per device → device_ciphertexts[]
  5. POST /api/dm/{channelID}/messages with 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)

OpenKeyBackup(backup, oldPIN) → blob
SealKeyBackup(newPIN, blob)    → new backup

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 — encryptDM currently uses only bundles[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