Skip to content

E2EE Implementation Guide

Partly out of date

Rendered from ProjectInfo/client/e2ee-implementation.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.

The algorithms here are what the Flutter client implements. The key-storage sections describe the retired Tauri client.

Complete cryptographic implementation details. Use libsodium (libsodium-wrappers-sumo in JS/TS, libsodium in C, go.jonnrb.io/sodium or similar in Go). All primitives referenced below are standard libsodium function names.


Crypto Library

Platform Library Notes
Web / Tauri libsodium-wrappers-sumo Init with await sodium.ready before any call
iOS swift-sodium
Android lazysodium-android
Go golang.org/x/crypto + stdlib For X3DH server-side only

Key Types and Storage

Key Algorithm Size Storage
Identity key (IK) Ed25519 64-byte secret / 32-byte public OS keychain
Identity key (X25519) X25519 32 bytes Derived at use time
Signed prekey (SPK) X25519 32 bytes each OS keychain
One-time prekeys (OTPs) X25519 32 bytes each OS keychain
Ratchet sessions XChaCha20-Poly1305 state Variable OS keychain, keyed by channel ID

Never store private keys in localStorage or unencrypted files. Use the OS credential store (Tauri credentials.rs, iOS Keychain, Android Keystore).

localStorage keys (current reference implementation)

The existing Tauri client uses localStorage as a temporary measure:

e2ee_ik_pub        Ed25519 IK public key (base64)
e2ee_ik_sec        Ed25519 IK secret key (base64)
e2ee_spk_pub       X25519 SPK public (base64)
e2ee_spk_sec       X25519 SPK secret (base64)
e2ee_spk_id        SPK ID (integer string)
e2ee_spk_sig       Ed25519 sig over SPK public (base64)
e2ee_otp_keys      JSON array of { id, pub, sec } objects (base64)
e2ee_device_id     device ID from JWT did claim
e2ee_session_<cid> JSON RatchetSession per DM channel

Key Generation

Identity Key

const kp = sodium.crypto_sign_keypair();  // Ed25519
// kp.publicKey = 32-byte public key
// kp.privateKey = 64-byte secret key (seed || public)

Signed Prekey

const spkKP = sodium.crypto_box_keypair();  // X25519
const sig = sodium.crypto_sign_detached(spkKP.publicKey, identityPrivKey);
const spkId = previousSpkId + 1;  // increment on each rotation

Upload: POST /api/keys/bundle { identity_key, signed_prekey, signed_prekey_id, signed_prekey_signature } (all base64).

One-Time Prekeys

function generateOTPs(count = 100, startId: number): OTP[] {
  return Array.from({ length: count }, (_, i) => {
    const kp = sodium.crypto_box_keypair();
    return { id: startId + i, pub: kp.publicKey, sec: kp.privateKey };
  });
}

Upload: POST /api/keys/prekeys { prekeys: [{ key_id, public_key (base64) }] }.

Ed25519 → X25519 Conversion

Required for DH operations (X3DH, epoch key sealing) since the IK is an Ed25519 key:

const ikX25519Pub  = sodium.crypto_sign_ed25519_pk_to_curve25519(ikEd25519Pub);
const ikX25519Priv = sodium.crypto_sign_ed25519_sk_to_curve25519(ikEd25519Priv);

X3DH Session Initiation (DMs)

Used for the first message to a recipient or to a new device.

Sender

async function x3dhSend(recipientBundle: PreKeyBundle, myIKPriv: Uint8Array): X3DHResult {
  // 1. Verify SPK signature — ABORT if invalid
  const valid = sodium.crypto_sign_verify_detached(
    fromBase64(recipientBundle.signed_prekey_signature),
    fromBase64(recipientBundle.signed_prekey),
    fromBase64(recipientBundle.identity_key)
  );
  if (!valid) throw new Error("SPK signature invalid — aborting X3DH");

  // 2. Convert recipient IK Ed25519 → X25519
  const ikBPubX = sodium.crypto_sign_ed25519_pk_to_curve25519(fromBase64(recipientBundle.identity_key));
  const spkBPub = fromBase64(recipientBundle.signed_prekey);
  const otpBPub = recipientBundle.one_time_prekey ? fromBase64(recipientBundle.one_time_prekey.key) : null;

  // 3. My IK Ed25519 → X25519
  const ikAPrivX = sodium.crypto_sign_ed25519_sk_to_curve25519(myIKPriv);

  // 4. Generate ephemeral keypair
  const ek = sodium.crypto_box_keypair();

  // 5. DH operations
  const DH1 = sodium.crypto_scalarmult(ikAPrivX, spkBPub);   // IK_A × SPK_B
  const DH2 = sodium.crypto_scalarmult(ek.privateKey, ikBPubX); // EK × IK_B
  const DH3 = sodium.crypto_scalarmult(ek.privateKey, spkBPub); // EK × SPK_B
  const KM = otpBPub
    ? concat(DH1, DH2, DH3, sodium.crypto_scalarmult(ek.privateKey, otpBPub)) // + EK × OPK
    : concat(DH1, DH2, DH3);

  // 6. HKDF
  const sharedSecret = hkdf(KM, new Uint8Array(32), "zipp-chat-x3dh-v1", 32);

  return {
    sharedSecret,
    ephPublicKey: ek.publicKey,
    spkId: recipientBundle.signed_prekey_id,
    otpId: recipientBundle.one_time_prekey?.id ?? 0,
  };
}

Receiver

async function x3dhReceive(header: X3DHHeader, myKeys: LocalKeys): Uint8Array {
  const spk = getSignedPrekeyById(header.spk_id);
  const otpPriv = header.otp_id !== 0 ? consumeOTPKey(header.otp_id) : null;

  const ikBPrivX = sodium.crypto_sign_ed25519_sk_to_curve25519(myKeys.ikPriv);
  const ikAPubX  = sodium.crypto_sign_ed25519_pk_to_curve25519(fromBase64(header.ik));
  const ephPub   = fromBase64(header.eph);

  const DH1 = sodium.crypto_scalarmult(spk.priv, ikAPubX);   // SPK_B × IK_A
  const DH2 = sodium.crypto_scalarmult(ikBPrivX, ephPub);    // IK_B  × EK
  const DH3 = sodium.crypto_scalarmult(spk.priv, ephPub);    // SPK_B × EK
  const KM = otpPriv
    ? concat(DH1, DH2, DH3, sodium.crypto_scalarmult(otpPriv, ephPub))
    : concat(DH1, DH2, DH3);

  return hkdf(KM, new Uint8Array(32), "zipp-chat-x3dh-v1", 32);
}

X3DHHeader (wire format)

{
  "eph": "base64-ephemeral-X25519-pub",
  "spk_id": 1,
  "otp_id": 42,       // 0 if no OTP used
  "did": "device-id",
  "ik": "base64-sender-Ed25519-IK-pub"
}

Encode as JSON → base64 and place in x3dh_header field of the DM message. Send only on the first message in a session; omit on all subsequent messages.


HKDF

libsodium has no native HKDF. Implement RFC 5869 manually using HMAC-SHA256:

function hkdf(ikm: Uint8Array, salt: Uint8Array, info: string, len: number): Uint8Array {
  // Extract
  const prk = sodium.crypto_auth_hmacsha256(ikm, salt);
  // Expand
  const infoBytes = new TextEncoder().encode(info);
  const output = new Uint8Array(len);
  let prev = new Uint8Array(0);
  let offset = 0;
  let counter = 1;
  while (offset < len) {
    const data = concat(prev, infoBytes, new Uint8Array([counter++]));
    prev = sodium.crypto_auth_hmacsha256(data, prk);
    output.set(prev.slice(0, Math.min(prev.length, len - offset)), offset);
    offset += prev.length;
  }
  return output.slice(0, len);
}

Double Ratchet

Session State

interface RatchetSession {
  rk: string;             // root key (base64)
  cks: string | null;     // sending chain key (base64)
  ckr: string | null;     // receiving chain key (base64)
  dhsPub: string;         // own current ratchet public key (base64)
  dhsPriv: string;        // own current ratchet private key (base64)
  dhrPub: string | null;  // peer's latest ratchet public key (base64)
  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

function KDF_RK(rk: Uint8Array, dhOut: Uint8Array): [Uint8Array, Uint8Array] {
  const out = hkdf(dhOut, rk, "zipp-chat-dr-ratchet-v1", 64);
  return [out.slice(0, 32), out.slice(32, 64)]; // [new_rk, new_ck]
}

function KDF_CK(ck: Uint8Array): [Uint8Array, Uint8Array] {
  const mk = sodium.crypto_auth_hmacsha256(new Uint8Array([0x01]), ck);
  const nc = sodium.crypto_auth_hmacsha256(new Uint8Array([0x02]), ck);
  return [mk, nc]; // [message_key, next_chain_key]
}

Sending

function ratchetEncrypt(session: RatchetSession, plaintext: string): EncryptedDM {
  const [mk, nextCk] = KDF_CK(fromBase64(session.cks!));
  const header = { dh: session.dhsPub, pn: session.PN, n: session.Ns };
  const nonce = sodium.randombytes_buf(24);
  const ad = new TextEncoder().encode(JSON.stringify(header));
  const ct = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
    new TextEncoder().encode(plaintext), ad, null, nonce, mk
  );
  session.cks = toBase64(nextCk);
  session.Ns++;
  return {
    ciphertext: toBase64(concat(nonce, ct)),
    ratchet_header: toBase64(new TextEncoder().encode(JSON.stringify(header))),
  };
}

Receiving

function ratchetDecrypt(session: RatchetSession, ciphertextB64: string, headerB64: string): string {
  const header: { dh: string; pn: number; n: number } =
    JSON.parse(new TextDecoder().decode(fromBase64(headerB64)));

  // Check skipped keys
  const skipKey = `${header.dh}:${header.n}`;
  if (session.skipped[skipKey]) {
    const mk = fromBase64(session.skipped[skipKey]);
    delete session.skipped[skipKey];
    return decrypt(ciphertextB64, mk, headerB64);
  }

  if (header.dh !== session.dhrPub) {
    // DH ratchet step
    skipMessages(session, header.pn);
    dhRatchet(session, header.dh);
  }
  skipMessages(session, header.n);

  const [mk, nextCk] = KDF_CK(fromBase64(session.ckr!));
  session.ckr = toBase64(nextCk);
  session.Nr++;
  return decrypt(ciphertextB64, mk, headerB64);
}

function dhRatchet(session: RatchetSession, newDhrPub: string) {
  session.PN = session.Ns;
  session.Ns = 0;
  session.Nr = 0;
  session.dhrPub = newDhrPub;

  const dhOut1 = sodium.crypto_scalarmult(fromBase64(session.dhsPriv), fromBase64(newDhrPub));
  const [rk2, newCkr] = KDF_RK(fromBase64(session.rk), dhOut1);

  const newDhs = sodium.crypto_box_keypair();
  const dhOut2 = sodium.crypto_scalarmult(newDhs.privateKey, fromBase64(newDhrPub));
  const [rk3, newCks] = KDF_RK(rk2, dhOut2);

  session.rk   = toBase64(rk3);
  session.ckr  = toBase64(newCkr);
  session.cks  = toBase64(newCks);
  session.dhsPub  = toBase64(newDhs.publicKey);
  session.dhsPriv = toBase64(newDhs.privateKey);
}

MAX_SKIP = 1000 (cap on stored skipped keys per chain).

Session Initialization

Sender (after X3DH):

function initSenderRatchet(sharedSecret: Uint8Array, recipientSpkPub: Uint8Array): RatchetSession {
  const dhs = sodium.crypto_box_keypair();
  const dhOut = sodium.crypto_scalarmult(dhs.privateKey, recipientSpkPub);
  const [newRk, cks] = KDF_RK(sharedSecret, dhOut);
  return {
    rk: toBase64(newRk), cks: toBase64(cks), ckr: null,
    dhsPub: toBase64(dhs.publicKey), dhsPriv: toBase64(dhs.privateKey),
    dhrPub: toBase64(recipientSpkPub),
    Ns: 0, Nr: 0, PN: 0, skipped: {}
  };
}

Receiver (after X3DH):

function initReceiverRatchet(sharedSecret: Uint8Array, mySpk: { pub: Uint8Array; priv: Uint8Array }): RatchetSession {
  return {
    rk: toBase64(sharedSecret), cks: null, ckr: null,
    dhsPub: toBase64(mySpk.pub), dhsPriv: toBase64(mySpk.priv),
    dhrPub: null,
    Ns: 0, Nr: 0, PN: 0, skipped: {}
  };
}


Self-Encryption (Sender's Sent-Message Copy)

The main ciphertext is encrypted to the recipient. To read your own sent messages, create a self-encrypted copy keyed per-channel:

function deriveSelfKey(channelId: string, ikPriv: Uint8Array): Uint8Array {
  const ikX = sodium.crypto_sign_ed25519_sk_to_curve25519(ikPriv);
  return hkdf(ikX, new TextEncoder().encode(channelId), "zipp-chat-self-v1", 32);
}

function selfEncrypt(plaintext: string, selfKey: Uint8Array): string {
  const nonce = sodium.randombytes_buf(24);
  const ct = sodium.crypto_secretbox_easy(new TextEncoder().encode(plaintext), nonce, selfKey);
  return toBase64(concat(nonce, ct));
}

function selfDecrypt(ciphertext: string, selfKey: Uint8Array): string {
  const wire = fromBase64(ciphertext);
  const nonce = wire.slice(0, 24);
  const ct = wire.slice(24);
  const plain = sodium.crypto_secretbox_open_easy(ct, nonce, selfKey);
  return new TextDecoder().decode(plain);
}

Add to device_ciphertexts with device_id = myDeviceId.


Rolling-Key Channel Encryption

Generating an Epoch Key

const epochKey = sodium.randombytes_buf(32);

Sealing an Epoch Key for a Device

function sealEpochKey(epochKey: Uint8Array, deviceIKX25519Pub: Uint8Array): string {
  return toBase64(sodium.crypto_box_seal(epochKey, deviceIKX25519Pub));
}

Opening a Sealed Epoch Key

function openSealedEpochKey(encryptedB64: string, ownIKX25519Pub: Uint8Array, ownIKX25519Priv: Uint8Array): Uint8Array {
  const sealed = fromBase64(encryptedB64);
  return sodium.crypto_box_seal_open(sealed, ownIKX25519Pub, ownIKX25519Priv);
}

Encrypting a Message with an Epoch Key

function encryptMessage(plaintext: string, epochKey: Uint8Array): string {
  const nonce = sodium.randombytes_buf(24);
  const ct = sodium.crypto_secretbox_easy(new TextEncoder().encode(plaintext), nonce, epochKey);
  return toBase64(concat(nonce, ct));  // base64(nonce ∥ ct)
}

Decrypting a Message

function decryptMessage(base64Wire: string, epochKey: Uint8Array): string {
  const wire = fromBase64(base64Wire);
  const nonce = wire.slice(0, 24);
  const ct = wire.slice(24);
  const plain = sodium.crypto_secretbox_open_easy(ct, nonce, epochKey);
  return new TextDecoder().decode(plain);
}

PIN Backup

Creating a Backup

async function createPINBackup(pin: string, keyBlob: Uint8Array): void {
  // 1. Validate PIN (enforce before calling)
  // 2. Generate salt
  const salt = sodium.randombytes_buf(16);

  // 3. Derive master key — Argon2id (full params)
  const masterKey = sodium.crypto_pwhash(
    32, pin, salt,
    3,           // iterations
    65536 * 1024, // 64 MB memory
    sodium.crypto_pwhash_ALG_ARGON2ID13
  );

  // 4. Derive verifier key — cheap params for server-side pre-check
  const verifySalt = concat(salt, new TextEncoder().encode("verifier"));
  const verifierKey = sodium.crypto_pwhash(
    16, pin, verifySalt,
    1, 16384 * 1024,
    sodium.crypto_pwhash_ALG_ARGON2ID13
  );

  // 5. Encrypt key blob with master key
  const nonce = sodium.randombytes_buf(12);
  // AES-256-GCM via SubtleCrypto (libsodium has no GCM)
  const encrypted = await subtleCryptoAESGCMEncrypt(masterKey, nonce, keyBlob);

  // 6. Upload
  await fetch(`${API_BASE}/api/keys/backup`, {
    method: "PUT",
    body: JSON.stringify({
      salt: toBase64(salt),
      encrypted_blob: toBase64(concat(nonce, encrypted)),
      pin_verifier: toBase64(verifierKey),
      argon2_memory: 65536,
      argon2_iterations: 3,
      argon2_parallelism: 4,
      version: 1
    })
  });
}

Restoring from Backup

async function restorePINBackup(pin: string): KeyBundle {
  const res = await fetch(`${API_BASE}/api/keys/backup/restore`, {
    method: "POST",
    body: JSON.stringify({ pin })
  });
  // 401 = wrong PIN; check "too many attempts" in body for lockout

  const { encrypted_blob, salt, argon2_memory, argon2_iterations, argon2_parallelism } = await res.json();

  const masterKey = sodium.crypto_pwhash(
    32, pin, fromBase64(salt),
    argon2_iterations, argon2_memory * 1024,
    sodium.crypto_pwhash_ALG_ARGON2ID13
  );

  const blobBytes = fromBase64(encrypted_blob);
  const nonce = blobBytes.slice(0, 12);
  const ct = blobBytes.slice(12);
  const keyBlob = await subtleCryptoAESGCMDecrypt(masterKey, nonce, ct);

  // Import keys from keyBlob → save to secure storage
}

Weak PIN Deny-List (enforce client-side)

Reject these PINs before allowing backup creation:

00000000  11111111  22222222  33333333  44444444
55555555  66666666  77777777  88888888  99999999
12345678  87654321  01234567  76543210

Also reject anything that is not exactly 8 decimal digits.

Server-side lockout: 10 failed restore attempts triggers a 24-hour lockout. Show a countdown timer during lockout.