import { describe, test, expect, beforeEach } from 'bun:test'; import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web'; import { generateIdentityKeyPair, generateSignedPreKey, generateOneTimePreKeys, createPreKeyBundle, processPreKeyBundle, processPreKeyMessage, InvalidSignatureError, PreKeyNotFoundError, } from '../src/index.js'; import type { RatchetMessage } from '../src/index.js'; const crypto = new SubtleCryptoProvider(); /** Create a dummy RatchetMessage for testing (X3DH doesn't care about the content) */ function dummyRatchetMessage(): RatchetMessage { return { dhPublicKey: crypto.randomBytes(32), previousCounter: 0, counter: 0, ciphertext: crypto.randomBytes(48), nonce: crypto.randomBytes(12), }; } describe('X3DH', () => { let aliceStorage: MemoryStorage; let bobStorage: MemoryStorage; beforeEach(() => { aliceStorage = new MemoryStorage(); bobStorage = new MemoryStorage(); }); // ─── Key Generation ──────────────────────────────────────── describe('key generation', () => { test('generates identity keypair with correct lengths', async () => { const id = await generateIdentityKeyPair(crypto); expect(id.signingPublicKey.length).toBe(32); expect(id.signingPrivateKey.length).toBe(32); expect(id.dhPublicKey.length).toBe(32); expect(id.dhPrivateKey.length).toBe(32); }); test('signing and DH keys are different', async () => { const id = await generateIdentityKeyPair(crypto); expect(id.signingPublicKey).not.toEqual(id.dhPublicKey); expect(id.signingPrivateKey).not.toEqual(id.dhPrivateKey); }); test('generates signed prekey with valid signature', async () => { const id = await generateIdentityKeyPair(crypto); const spk = await generateSignedPreKey(crypto, id, 1); expect(spk.keyId).toBe(1); expect(spk.keyPair.publicKey.length).toBe(32); expect(spk.signature.length).toBe(64); // Verify the signature const valid = await crypto.verify(id.signingPublicKey, spk.keyPair.publicKey, spk.signature); expect(valid).toBe(true); }); test('generates batch of one-time prekeys', async () => { const otpks = await generateOneTimePreKeys(crypto, 100, 5); expect(otpks.length).toBe(5); for (let i = 0; i < 5; i++) { expect(otpks[i].keyId).toBe(100 + i); expect(otpks[i].keyPair.publicKey.length).toBe(32); } // All keys are unique const pubKeys = otpks.map((k) => Array.from(k.keyPair.publicKey).join(',')); expect(new Set(pubKeys).size).toBe(5); }); }); // ─── Full Handshake ──────────────────────────────────────── describe('full handshake', () => { test('Alice and Bob derive the same root key (with one-time prekey)', async () => { // Bob generates keys const bobIdentity = await generateIdentityKeyPair(crypto); const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1); const bobOneTimePreKeys = await generateOneTimePreKeys(crypto, 100, 3); // Bob stores his keys await bobStorage.saveIdentityKeyPair(bobIdentity); await bobStorage.saveSignedPreKey(bobSignedPreKey); for (const otpk of bobOneTimePreKeys) { await bobStorage.saveOneTimePreKey(otpk); } // Bob publishes a bundle (server would store this) const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey, bobOneTimePreKeys[0]); // Alice generates her identity const aliceIdentity = await generateIdentityKeyPair(crypto); await aliceStorage.saveIdentityKeyPair(aliceIdentity); // Alice processes the bundle const aliceResult = await processPreKeyBundle(crypto, aliceStorage, bundle); expect(aliceResult.rootKey.length).toBe(32); expect(aliceResult.signedPreKeyId).toBe(1); expect(aliceResult.preKeyId).toBe(100); expect(aliceResult.ephemeralPublicKey.length).toBe(32); // Alice creates a PreKeyMessage const preKeyMessage = { registrationId: 1, preKeyId: aliceResult.preKeyId, signedPreKeyId: aliceResult.signedPreKeyId, ephemeralKey: aliceResult.ephemeralPublicKey, identityDHKey: aliceIdentity.dhPublicKey, message: dummyRatchetMessage(), }; // Bob processes the PreKeyMessage const bobResult = await processPreKeyMessage(crypto, bobStorage, preKeyMessage); // Both derive the same root key expect(bobResult.rootKey).toEqual(aliceResult.rootKey); expect(bobResult.remoteIdentityKey).toEqual(aliceIdentity.dhPublicKey); expect(bobResult.remoteEphemeralKey).toEqual(aliceResult.ephemeralPublicKey); }); test('Alice and Bob derive the same root key (without one-time prekey)', async () => { const bobIdentity = await generateIdentityKeyPair(crypto); const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1); await bobStorage.saveIdentityKeyPair(bobIdentity); await bobStorage.saveSignedPreKey(bobSignedPreKey); // Bundle without one-time prekey const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey); const aliceIdentity = await generateIdentityKeyPair(crypto); await aliceStorage.saveIdentityKeyPair(aliceIdentity); const aliceResult = await processPreKeyBundle(crypto, aliceStorage, bundle); expect(aliceResult.preKeyId).toBeUndefined(); const preKeyMessage = { registrationId: 1, signedPreKeyId: aliceResult.signedPreKeyId, ephemeralKey: aliceResult.ephemeralPublicKey, identityDHKey: aliceIdentity.dhPublicKey, message: dummyRatchetMessage(), }; const bobResult = await processPreKeyMessage(crypto, bobStorage, preKeyMessage); expect(bobResult.rootKey).toEqual(aliceResult.rootKey); }); test('different handshakes produce different root keys', async () => { const bobIdentity = await generateIdentityKeyPair(crypto); const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1); await bobStorage.saveIdentityKeyPair(bobIdentity); await bobStorage.saveSignedPreKey(bobSignedPreKey); const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey); // Alice 1 const alice1Id = await generateIdentityKeyPair(crypto); const alice1Storage = new MemoryStorage(); await alice1Storage.saveIdentityKeyPair(alice1Id); const result1 = await processPreKeyBundle(crypto, alice1Storage, bundle); // Alice 2 (different identity) const alice2Id = await generateIdentityKeyPair(crypto); const alice2Storage = new MemoryStorage(); await alice2Storage.saveIdentityKeyPair(alice2Id); const result2 = await processPreKeyBundle(crypto, alice2Storage, bundle); expect(result1.rootKey).not.toEqual(result2.rootKey); }); }); // ─── Signature Verification ──────────────────────────────── describe('signature verification', () => { test('rejects bundle with invalid signed prekey signature', async () => { const bobIdentity = await generateIdentityKeyPair(crypto); const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1); // Tamper with the signature const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey); bundle.signedPreKey.signature[0] ^= 0xff; const aliceIdentity = await generateIdentityKeyPair(crypto); await aliceStorage.saveIdentityKeyPair(aliceIdentity); expect(processPreKeyBundle(crypto, aliceStorage, bundle)).rejects.toThrow(InvalidSignatureError); }); test('rejects bundle with wrong identity key signing', async () => { const bobIdentity = await generateIdentityKeyPair(crypto); const eveIdentity = await generateIdentityKeyPair(crypto); // Eve signs the prekey, but claims to be Bob const eveSignedPreKey = await generateSignedPreKey(crypto, eveIdentity, 1); const bundle = createPreKeyBundle(42, bobIdentity, eveSignedPreKey); const aliceIdentity = await generateIdentityKeyPair(crypto); await aliceStorage.saveIdentityKeyPair(aliceIdentity); expect(processPreKeyBundle(crypto, aliceStorage, bundle)).rejects.toThrow(InvalidSignatureError); }); }); // ─── One-Time Prekey Consumption ─────────────────────────── describe('one-time prekey consumption', () => { test('one-time prekey is deleted after use', async () => { const bobIdentity = await generateIdentityKeyPair(crypto); const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1); const bobOTPKs = await generateOneTimePreKeys(crypto, 100, 3); await bobStorage.saveIdentityKeyPair(bobIdentity); await bobStorage.saveSignedPreKey(bobSignedPreKey); for (const otpk of bobOTPKs) await bobStorage.saveOneTimePreKey(otpk); expect(await bobStorage.getOneTimePreKeyCount()).toBe(3); const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey, bobOTPKs[0]); const aliceIdentity = await generateIdentityKeyPair(crypto); await aliceStorage.saveIdentityKeyPair(aliceIdentity); const aliceResult = await processPreKeyBundle(crypto, aliceStorage, bundle); const preKeyMessage = { registrationId: 1, preKeyId: aliceResult.preKeyId, signedPreKeyId: aliceResult.signedPreKeyId, ephemeralKey: aliceResult.ephemeralPublicKey, identityDHKey: aliceIdentity.dhPublicKey, message: dummyRatchetMessage(), }; await processPreKeyMessage(crypto, bobStorage, preKeyMessage); // One-time prekey 100 should be consumed expect(await bobStorage.getOneTimePreKeyCount()).toBe(2); expect(await bobStorage.getOneTimePreKey(100)).toBeNull(); // Others remain expect(await bobStorage.getOneTimePreKey(101)).not.toBeNull(); expect(await bobStorage.getOneTimePreKey(102)).not.toBeNull(); }); test('fails when referenced one-time prekey does not exist', async () => { const bobIdentity = await generateIdentityKeyPair(crypto); const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1); await bobStorage.saveIdentityKeyPair(bobIdentity); await bobStorage.saveSignedPreKey(bobSignedPreKey); // No one-time prekeys stored const aliceIdentity = await generateIdentityKeyPair(crypto); await aliceStorage.saveIdentityKeyPair(aliceIdentity); const preKeyMessage = { registrationId: 1, preKeyId: 999, // doesn't exist signedPreKeyId: 1, ephemeralKey: crypto.randomBytes(32), identityDHKey: aliceIdentity.dhPublicKey, message: dummyRatchetMessage(), }; expect(processPreKeyMessage(crypto, bobStorage, preKeyMessage)).rejects.toThrow(PreKeyNotFoundError); }); test('fails when referenced signed prekey does not exist', async () => { const bobIdentity = await generateIdentityKeyPair(crypto); await bobStorage.saveIdentityKeyPair(bobIdentity); // No signed prekey stored const preKeyMessage = { registrationId: 1, signedPreKeyId: 999, ephemeralKey: crypto.randomBytes(32), identityDHKey: crypto.randomBytes(32), message: dummyRatchetMessage(), }; expect(processPreKeyMessage(crypto, bobStorage, preKeyMessage)).rejects.toThrow(PreKeyNotFoundError); }); }); // ─── PreKey Bundle Assembly ──────────────────────────────── describe('createPreKeyBundle', () => { test('assembles bundle with one-time prekey', async () => { const id = await generateIdentityKeyPair(crypto); const spk = await generateSignedPreKey(crypto, id, 5); const otpk = (await generateOneTimePreKeys(crypto, 200, 1))[0]; const bundle = createPreKeyBundle(42, id, spk, otpk); expect(bundle.registrationId).toBe(42); expect(bundle.identitySigningKey).toEqual(id.signingPublicKey); expect(bundle.identityDHKey).toEqual(id.dhPublicKey); expect(bundle.signedPreKey.keyId).toBe(5); expect(bundle.signedPreKey.publicKey).toEqual(spk.keyPair.publicKey); expect(bundle.signedPreKey.signature).toEqual(spk.signature); expect(bundle.oneTimePreKey?.keyId).toBe(200); expect(bundle.oneTimePreKey?.publicKey).toEqual(otpk.keyPair.publicKey); }); test('assembles bundle without one-time prekey', async () => { const id = await generateIdentityKeyPair(crypto); const spk = await generateSignedPreKey(crypto, id, 1); const bundle = createPreKeyBundle(42, id, spk); expect(bundle.oneTimePreKey).toBeUndefined(); }); }); });