import { describe, test, expect, beforeEach } from 'bun:test'; import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web'; import { ShadeSessionManager, generateOneTimePreKeys, createPreKeyBundle, } from '../src/index.js'; const crypto = new SubtleCryptoProvider(); describe('ShadeSessionManager', () => { let alice: ShadeSessionManager; let bob: ShadeSessionManager; let aliceStorage: MemoryStorage; let bobStorage: MemoryStorage; beforeEach(async () => { aliceStorage = new MemoryStorage(); bobStorage = new MemoryStorage(); alice = new ShadeSessionManager(crypto, aliceStorage); bob = new ShadeSessionManager(crypto, bobStorage); await alice.initialize(); await bob.initialize(); }); /** Helper: establish a session from Alice to Bob */ async function establishSession() { // Bob generates one-time prekeys const otpks = await bob.generateOneTimePreKeys(10); // Bob creates a bundle (with one-time prekey) const bobBundle = await bob.createPreKeyBundle(); // Add a one-time prekey to the bundle (in real life the prekey server does this) const otpk = otpks[0]; bobBundle.oneTimePreKey = { keyId: otpk.keyId, publicKey: otpk.keyPair.publicKey }; // Alice initiates session await alice.initSessionFromBundle('bob', bobBundle); } // ─── Initialization ──────────────────────────────────────── describe('initialization', () => { test('generates identity keys on first init', async () => { const pub = alice.getPublicIdentity(); expect(pub.signingKey.length).toBe(32); expect(pub.dhKey.length).toBe(32); }); test('reuses identity keys on second init', async () => { const pub1 = alice.getPublicIdentity(); const alice2 = new ShadeSessionManager(crypto, aliceStorage); await alice2.initialize(); const pub2 = alice2.getPublicIdentity(); expect(pub1.signingKey).toEqual(pub2.signingKey); expect(pub1.dhKey).toEqual(pub2.dhKey); }); test('creates a prekey bundle', async () => { const bundle = await alice.createPreKeyBundle(); expect(bundle.identitySigningKey.length).toBe(32); expect(bundle.identityDHKey.length).toBe(32); expect(bundle.signedPreKey.keyId).toBe(1); expect(bundle.signedPreKey.publicKey.length).toBe(32); expect(bundle.signedPreKey.signature.length).toBe(64); }); test('generates one-time prekeys', async () => { const keys = await alice.generateOneTimePreKeys(5); expect(keys.length).toBe(5); expect(await aliceStorage.getOneTimePreKeyCount()).toBe(5); }); }); // ─── Full Conversation ───────────────────────────────────── describe('full conversation via managers', () => { test('Alice sends to Bob, Bob replies', async () => { await establishSession(); // Alice → Bob (first message = PreKeyMessage) const env1 = await alice.encrypt('bob', 'Hello Bob!'); expect(env1.type).toBe('prekey'); const plain1 = await bob.decrypt('alice', env1); expect(plain1).toBe('Hello Bob!'); // Alice → Bob (second message = RatchetMessage) const env2 = await alice.encrypt('bob', 'Still me'); expect(env2.type).toBe('ratchet'); const plain2 = await bob.decrypt('alice', env2); expect(plain2).toBe('Still me'); // Bob → Alice (reply) const env3 = await bob.encrypt('alice', 'Hi Alice!'); expect(env3.type).toBe('ratchet'); const plain3 = await alice.decrypt('bob', env3); expect(plain3).toBe('Hi Alice!'); }); test('extended conversation with many turns', async () => { await establishSession(); // Alice sends first message to establish Bob's session const first = await alice.encrypt('bob', 'init'); await bob.decrypt('alice', first); for (let i = 0; i < 20; i++) { const senderMgr = i % 2 === 0 ? alice : bob; const receiverMgr = i % 2 === 0 ? bob : alice; const senderAddr = i % 2 === 0 ? 'bob' : 'alice'; const receiverAddr = i % 2 === 0 ? 'alice' : 'bob'; const text = `Turn ${i}`; const env = await senderMgr.encrypt(senderAddr, text); const plain = await receiverMgr.decrypt(receiverAddr, env); expect(plain).toBe(text); } }); test('burst messages then reply', async () => { await establishSession(); // Alice sends 5 messages const envelopes = []; for (let i = 0; i < 5; i++) { envelopes.push(await alice.encrypt('bob', `msg-${i}`)); } // Bob decrypts all 5 for (let i = 0; i < 5; i++) { expect(await bob.decrypt('alice', envelopes[i])).toBe(`msg-${i}`); } // Bob replies const reply = await bob.encrypt('alice', 'Got all 5!'); expect(await alice.decrypt('bob', reply)).toBe('Got all 5!'); }); }); // ─── Prekey Rotation ────────────────────────────────────── describe('prekey rotation', () => { test('rotated signed prekey works for new sessions', async () => { // Bob rotates his signed prekey await bob.rotateSignedPreKey(); // New bundle uses the new signed prekey const bundle = await bob.createPreKeyBundle(); expect(bundle.signedPreKey.keyId).toBe(2); // Alice can still establish a session with the new bundle await alice.initSessionFromBundle('bob', bundle); const env = await alice.encrypt('bob', 'After rotation'); const plain = await bob.decrypt('alice', env); expect(plain).toBe('After rotation'); }); test('old sessions continue working after rotation', async () => { await establishSession(); // Establish session and exchange messages const env1 = await alice.encrypt('bob', 'Before rotation'); await bob.decrypt('alice', env1); // Bob rotates await bob.rotateSignedPreKey(); // Existing session still works (uses ratchet keys, not prekeys) const env2 = await bob.encrypt('alice', 'After rotation, same session'); expect(await alice.decrypt('bob', env2)).toBe('After rotation, same session'); }); }); // ─── Multi-Peer ─────────────────────────────────────────── describe('multi-peer sessions', () => { test('Alice talks to Bob and Charlie simultaneously', async () => { const charlieStorage = new MemoryStorage(); const charlie = new ShadeSessionManager(crypto, charlieStorage); await charlie.initialize(); await charlie.generateOneTimePreKeys(5); // Establish Alice → Bob await establishSession(); const envB = await alice.encrypt('bob', 'Hi Bob'); await bob.decrypt('alice', envB); // Establish Alice → Charlie const charlieBundle = await charlie.createPreKeyBundle(); const otpks = await charlie.generateOneTimePreKeys(1); charlieBundle.oneTimePreKey = { keyId: otpks[0].keyId, publicKey: otpks[0].keyPair.publicKey }; await alice.initSessionFromBundle('charlie', charlieBundle); const envC = await alice.encrypt('charlie', 'Hi Charlie'); expect(await charlie.decrypt('alice', envC)).toBe('Hi Charlie'); // Both sessions work independently const envB2 = await alice.encrypt('bob', 'Still talking to you Bob'); expect(await bob.decrypt('alice', envB2)).toBe('Still talking to you Bob'); const envC2 = await alice.encrypt('charlie', 'And you Charlie'); expect(await charlie.decrypt('alice', envC2)).toBe('And you Charlie'); }); }); // ─── Error Cases ────────────────────────────────────────── describe('error cases', () => { test('encrypt to unknown peer throws NoSessionError', async () => { expect(alice.encrypt('nobody', 'test')).rejects.toThrow('No session'); }); test('decrypt from unknown peer throws NoSessionError', async () => { const fakeEnvelope = { type: 'ratchet' as const, content: { dhPublicKey: crypto.randomBytes(32), previousCounter: 0, counter: 0, ciphertext: crypto.randomBytes(48), nonce: crypto.randomBytes(12), }, timestamp: Date.now(), senderAddress: 'nobody', }; expect(alice.decrypt('nobody', fakeEnvelope)).rejects.toThrow('No session'); }); }); });