import { describe, test, expect } from 'bun:test'; import { SubtleCryptoProvider } from '@shade/crypto-web'; import { initSenderSession, initReceiverSession, ratchetEncrypt, ratchetDecrypt, MaxSkipExceededError, DecryptionError, } from '../src/index.js'; import type { SessionState, RatchetMessage } from '../src/index.js'; const crypto = new SubtleCryptoProvider(); const enc = new TextEncoder(); const dec = new TextDecoder(); /** Helper: set up Alice (sender) and Bob (receiver) sessions from a shared root key */ async function setupPair(): Promise<{ alice: SessionState; bob: SessionState }> { const rootKey = crypto.randomBytes(32); const remoteIdentityKey = crypto.randomBytes(32); // Bob's initial DH keypair (would be his signed prekey in real X3DH) const bobDH = await crypto.generateX25519KeyPair(); const alice = await initSenderSession(crypto, rootKey, remoteIdentityKey, bobDH.publicKey); const bob = initReceiverSession(rootKey, remoteIdentityKey, bobDH); return { alice, bob }; } describe('Double Ratchet', () => { // ─── Basic Send/Receive ────────────────────────────────── describe('initReceiverSession isolation', () => { /** * Regression — the V3.10 multi-sender recovery flow surfaced a * bug where `initReceiverSession` shared a reference to the * receiver's signed-prekey keypair with the new session. The * first DH ratchet step zeroed the session's stale send-key * private bytes — which were the SAME backing buffer as the * persisted signed prekey. A subsequent X3DH from a different * sender then derived a divergent root key and decryption * failed. * * Fix: `initReceiverSession` copies the keypair into the * session. Verify here. */ test('does not mutate the caller-provided keypair after a DH ratchet step', async () => { const rootKey = crypto.randomBytes(32); const remoteIdentityKey = crypto.randomBytes(32); const bobDH = await crypto.generateX25519KeyPair(); const originalPrivate = new Uint8Array(bobDH.privateKey); const originalPublic = new Uint8Array(bobDH.publicKey); const bob = initReceiverSession(rootKey, remoteIdentityKey, bobDH); // Drive a full receive that triggers `performDHRatchetStep` // via a message with a fresh dhPublicKey. const aliceFirst = await initSenderSession(crypto, rootKey, remoteIdentityKey, bobDH.publicKey); const msg = await ratchetEncrypt(crypto, aliceFirst, enc.encode('hi')); await ratchetDecrypt(crypto, bob, msg); // The ORIGINAL keypair must not have been touched, so a // second X3DH-style establishment using the same prekey // material still succeeds. expect(Array.from(bobDH.privateKey)).toEqual(Array.from(originalPrivate)); expect(Array.from(bobDH.publicKey)).toEqual(Array.from(originalPublic)); // Sanity-check: a second receiver session built from the // same keypair should still decrypt fresh sender traffic. const bob2 = initReceiverSession(rootKey, remoteIdentityKey, bobDH); const aliceSecond = await initSenderSession(crypto, rootKey, remoteIdentityKey, bobDH.publicKey); const msg2 = await ratchetEncrypt(crypto, aliceSecond, enc.encode('hi again')); const plain2 = await ratchetDecrypt(crypto, bob2, msg2); expect(dec.decode(plain2)).toBe('hi again'); }); }); describe('basic send/receive', () => { test('Alice encrypts, Bob decrypts', async () => { const { alice, bob } = await setupPair(); const msg = await ratchetEncrypt(crypto, alice, enc.encode('hello bob')); const plaintext = await ratchetDecrypt(crypto, bob, msg); expect(dec.decode(plaintext)).toBe('hello bob'); }); test('multiple messages in same direction', async () => { const { alice, bob } = await setupPair(); const messages = ['first', 'second', 'third']; const encrypted: RatchetMessage[] = []; for (const text of messages) { encrypted.push(await ratchetEncrypt(crypto, alice, enc.encode(text))); } for (let i = 0; i < messages.length; i++) { const plaintext = await ratchetDecrypt(crypto, bob, encrypted[i]); expect(dec.decode(plaintext)).toBe(messages[i]); } }); test('counter increments with each message', async () => { const { alice } = await setupPair(); const m0 = await ratchetEncrypt(crypto, alice, enc.encode('a')); const m1 = await ratchetEncrypt(crypto, alice, enc.encode('b')); const m2 = await ratchetEncrypt(crypto, alice, enc.encode('c')); expect(m0.counter).toBe(0); expect(m1.counter).toBe(1); expect(m2.counter).toBe(2); // All use the same DH key (no ratchet step yet) expect(m0.dhPublicKey).toEqual(m1.dhPublicKey); expect(m1.dhPublicKey).toEqual(m2.dhPublicKey); }); test('each message has a unique nonce', async () => { const { alice } = await setupPair(); const m0 = await ratchetEncrypt(crypto, alice, enc.encode('a')); const m1 = await ratchetEncrypt(crypto, alice, enc.encode('a')); expect(m0.nonce).not.toEqual(m1.nonce); expect(m0.ciphertext).not.toEqual(m1.ciphertext); }); }); // ─── Ping-Pong (DH Ratchet) ────────────────────────────── describe('ping-pong conversation', () => { test('alternating messages trigger DH ratchets', async () => { const { alice, bob } = await setupPair(); // Alice → Bob const m1 = await ratchetEncrypt(crypto, alice, enc.encode('hi bob')); expect(dec.decode(await ratchetDecrypt(crypto, bob, m1))).toBe('hi bob'); // Bob → Alice (new DH key) const m2 = await ratchetEncrypt(crypto, bob, enc.encode('hi alice')); expect(m2.dhPublicKey).not.toEqual(m1.dhPublicKey); // DH ratchet happened expect(dec.decode(await ratchetDecrypt(crypto, alice, m2))).toBe('hi alice'); // Alice → Bob (another new DH key) const m3 = await ratchetEncrypt(crypto, alice, enc.encode('how are you')); expect(m3.dhPublicKey).not.toEqual(m1.dhPublicKey); expect(m3.dhPublicKey).not.toEqual(m2.dhPublicKey); expect(dec.decode(await ratchetDecrypt(crypto, bob, m3))).toBe('how are you'); // Bob → Alice const m4 = await ratchetEncrypt(crypto, bob, enc.encode('great!')); expect(dec.decode(await ratchetDecrypt(crypto, alice, m4))).toBe('great!'); }); test('extended conversation with many turns', async () => { const { alice, bob } = await setupPair(); for (let i = 0; i < 20; i++) { const sender = i % 2 === 0 ? alice : bob; const receiver = i % 2 === 0 ? bob : alice; const text = `message ${i}`; const msg = await ratchetEncrypt(crypto, sender, enc.encode(text)); const plain = await ratchetDecrypt(crypto, receiver, msg); expect(dec.decode(plain)).toBe(text); } }); test('burst messages then reply', async () => { const { alice, bob } = await setupPair(); // Alice sends 5 messages const burst: RatchetMessage[] = []; for (let i = 0; i < 5; i++) { burst.push(await ratchetEncrypt(crypto, alice, enc.encode(`alice-${i}`))); } // Bob receives all 5 for (let i = 0; i < 5; i++) { expect(dec.decode(await ratchetDecrypt(crypto, bob, burst[i]))).toBe(`alice-${i}`); } // Bob replies (triggers DH ratchet) const reply = await ratchetEncrypt(crypto, bob, enc.encode('got them all')); expect(dec.decode(await ratchetDecrypt(crypto, alice, reply))).toBe('got them all'); // Alice sends more const m = await ratchetEncrypt(crypto, alice, enc.encode('great!')); expect(dec.decode(await ratchetDecrypt(crypto, bob, m))).toBe('great!'); }); }); // ─── Out-of-Order Messages ──────────────────────────────── describe('out-of-order delivery', () => { test('messages received in reverse order', async () => { const { alice, bob } = await setupPair(); const m0 = await ratchetEncrypt(crypto, alice, enc.encode('first')); const m1 = await ratchetEncrypt(crypto, alice, enc.encode('second')); const m2 = await ratchetEncrypt(crypto, alice, enc.encode('third')); // Deliver in reverse expect(dec.decode(await ratchetDecrypt(crypto, bob, m2))).toBe('third'); expect(dec.decode(await ratchetDecrypt(crypto, bob, m0))).toBe('first'); expect(dec.decode(await ratchetDecrypt(crypto, bob, m1))).toBe('second'); }); test('skip some messages, then receive them later', async () => { const { alice, bob } = await setupPair(); const messages: RatchetMessage[] = []; for (let i = 0; i < 10; i++) { messages.push(await ratchetEncrypt(crypto, alice, enc.encode(`msg-${i}`))); } // Receive only even-numbered messages first for (let i = 0; i < 10; i += 2) { expect(dec.decode(await ratchetDecrypt(crypto, bob, messages[i]))).toBe(`msg-${i}`); } // Then receive odd-numbered (skipped) messages for (let i = 1; i < 10; i += 2) { expect(dec.decode(await ratchetDecrypt(crypto, bob, messages[i]))).toBe(`msg-${i}`); } }); test('out-of-order across DH ratchet boundaries', async () => { const { alice, bob } = await setupPair(); // Alice sends 3 messages const a0 = await ratchetEncrypt(crypto, alice, enc.encode('a0')); const a1 = await ratchetEncrypt(crypto, alice, enc.encode('a1')); const a2 = await ratchetEncrypt(crypto, alice, enc.encode('a2')); // Bob receives only a2 (skips a0, a1) expect(dec.decode(await ratchetDecrypt(crypto, bob, a2))).toBe('a2'); // Bob replies (DH ratchet) const b0 = await ratchetEncrypt(crypto, bob, enc.encode('b0')); expect(dec.decode(await ratchetDecrypt(crypto, alice, b0))).toBe('b0'); // Now Bob receives the skipped a0 and a1 (from the old chain) expect(dec.decode(await ratchetDecrypt(crypto, bob, a0))).toBe('a0'); expect(dec.decode(await ratchetDecrypt(crypto, bob, a1))).toBe('a1'); }); }); // ─── Error Cases ────────────────────────────────────────── describe('error cases', () => { test('max skip exceeded throws', async () => { const { alice, bob } = await setupPair(); // Encrypt 1002 messages but only try to decrypt the last one let lastMsg: RatchetMessage | undefined; for (let i = 0; i < 1002; i++) { lastMsg = await ratchetEncrypt(crypto, alice, enc.encode(`msg-${i}`)); } expect(ratchetDecrypt(crypto, bob, lastMsg!)).rejects.toThrow(MaxSkipExceededError); }); test('tampered ciphertext fails', async () => { const { alice, bob } = await setupPair(); const msg = await ratchetEncrypt(crypto, alice, enc.encode('secret')); msg.ciphertext[0] ^= 0xff; expect(ratchetDecrypt(crypto, bob, msg)).rejects.toThrow(DecryptionError); }); test('tampered header (counter) fails due to AAD', async () => { const { alice, bob } = await setupPair(); const msg = await ratchetEncrypt(crypto, alice, enc.encode('secret')); msg.counter = 999; // tamper with counter expect(ratchetDecrypt(crypto, bob, msg)).rejects.toThrow(); }); /** * Regression — the v4.2.0 OutboundQueue waiter-since bug delivered * the same envelope twice to `manager.decrypt`. The first decrypt * succeeded via a cached skipped key; the second one fell into the * `message.counter < chain.counter` path with no skipped key * available, advanced the chainKey ONCE and rewound `chain.counter` * to `message.counter + 1`, leaving the ratchet permanently * desynced. ratchetDecrypt now rejects without mutating state when * a same-DH message is behind the chain and not in skippedKeys, so * a downstream replay (transport bug, retry, etc.) cannot poison * the session for everyone else. */ test('same-DH stale message after consumed skipped key fails without corrupting state', async () => { const { alice, bob } = await setupPair(); // Alice sends 3 messages on the same DH chain. const m0 = await ratchetEncrypt(crypto, alice, enc.encode('m0')); const m1 = await ratchetEncrypt(crypto, alice, enc.encode('m1')); const m2 = await ratchetEncrypt(crypto, alice, enc.encode('m2')); // Bob receives m1 first, caching m0's key. Then m0 (delivered // via the cache). After this, m0's skipped key is consumed. expect(dec.decode(await ratchetDecrypt(crypto, bob, m1))).toBe('m1'); expect(dec.decode(await ratchetDecrypt(crypto, bob, m0))).toBe('m0'); // Replay of m0: skippedKey is gone, chain.counter is past m0. // Pre-fix: this would corrupt Bob's chain state; post-fix it // throws cleanly. await expect(ratchetDecrypt(crypto, bob, m0)).rejects.toThrow(DecryptionError); // Bob can still decrypt the remaining valid message — chain // state was NOT mutated by the rejected replay. expect(dec.decode(await ratchetDecrypt(crypto, bob, m2))).toBe('m2'); }); }); // ─── Long Conversation ──────────────────────────────────── describe('stress test', () => { test('100+ message conversation with alternating turns', async () => { const { alice, bob } = await setupPair(); for (let i = 0; i < 50; i++) { // Alice sends 2 messages for (let j = 0; j < 2; j++) { const text = `alice-${i}-${j}`; const msg = await ratchetEncrypt(crypto, alice, enc.encode(text)); expect(dec.decode(await ratchetDecrypt(crypto, bob, msg))).toBe(text); } // Bob sends 1 message const text = `bob-${i}`; const msg = await ratchetEncrypt(crypto, bob, enc.encode(text)); expect(dec.decode(await ratchetDecrypt(crypto, alice, msg))).toBe(text); } }); }); });