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¶
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.