import { describe, test, expect } from 'bun:test'; import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web'; import { ShadeSessionManager, ShadeEventEmitter, shortHash, } from '../src/index.js'; import type { ShadeEvent } from '../src/index.js'; const crypto = new SubtleCryptoProvider(); describe('ShadeEventEmitter', () => { test('subscribes and emits events', () => { const emitter = new ShadeEventEmitter(); const received: ShadeEvent[] = []; emitter.on((e) => received.push(e)); emitter.emit('identity.initialized', { fingerprint: 'abc', registrationId: 1 }); expect(received.length).toBe(1); expect(received[0]!.name).toBe('identity.initialized'); expect(received[0]!.seq).toBe(1); expect(received[0]!.timestamp).toBeGreaterThan(0); expect((received[0]!.data as any).fingerprint).toBe('abc'); }); test('seq is monotonically increasing', () => { const emitter = new ShadeEventEmitter(); const seqs: number[] = []; emitter.on((e) => seqs.push(e.seq)); emitter.emit('prekey.generated', { count: 5, totalAfter: 5 }); emitter.emit('prekey.consumed', { keyId: 1 }); emitter.emit('prekey.consumed', { keyId: 2 }); expect(seqs).toEqual([1, 2, 3]); }); test('unsubscribe stops receiving events', () => { const emitter = new ShadeEventEmitter(); let count = 0; const unsub = emitter.on(() => count++); emitter.emit('prekey.generated', { count: 1, totalAfter: 1 }); unsub(); emitter.emit('prekey.generated', { count: 1, totalAfter: 2 }); expect(count).toBe(1); }); test('listener throw does not break other listeners', () => { const emitter = new ShadeEventEmitter(); let goodCount = 0; emitter.on(() => { throw new Error('boom'); }); emitter.on(() => goodCount++); emitter.emit('prekey.generated', { count: 1, totalAfter: 1 }); expect(goodCount).toBe(1); }); test('getBufferedSince returns events after seq', () => { const emitter = new ShadeEventEmitter(); emitter.emit('prekey.generated', { count: 1, totalAfter: 1 }); emitter.emit('prekey.generated', { count: 1, totalAfter: 2 }); emitter.emit('prekey.generated', { count: 1, totalAfter: 3 }); const events = emitter.getBufferedSince(1); expect(events.length).toBe(2); expect(events[0]!.seq).toBe(2); expect(events[1]!.seq).toBe(3); }); test('ring buffer evicts oldest', () => { const emitter = new ShadeEventEmitter({ bufferSize: 3 }); for (let i = 0; i < 5; i++) { emitter.emit('prekey.generated', { count: 1, totalAfter: i }); } const recent = emitter.getRecent(10); expect(recent.length).toBe(3); expect(recent[0]!.seq).toBe(3); expect(recent[2]!.seq).toBe(5); }); }); describe('shortHash helper', () => { test('produces 16-hex-char string', async () => { const hash = await shortHash(crypto, crypto.randomBytes(32)); expect(hash).toMatch(/^[0-9a-f]{16}$/); }); test('deterministic for same input', async () => { const key = new Uint8Array(32).fill(0xab); const a = await shortHash(crypto, key); const b = await shortHash(crypto, key); expect(a).toBe(b); }); test('different inputs produce different hashes', async () => { const a = await shortHash(crypto, crypto.randomBytes(32)); const b = await shortHash(crypto, crypto.randomBytes(32)); expect(a).not.toBe(b); }); }); describe('ShadeSessionManager event integration', () => { test('initialize emits identity.initialized', async () => { const events = new ShadeEventEmitter(); const received: ShadeEvent[] = []; events.on((e) => received.push(e)); const mgr = new ShadeSessionManager(crypto, new MemoryStorage(), { events }); await mgr.initialize(); const init = received.find((e) => e.name === 'identity.initialized'); expect(init).toBeDefined(); const data = init!.data as any; expect(data.fingerprint).toMatch(/^\d{5}( \d{5}){11}$/); expect(data.registrationId).toBeGreaterThan(0); }); test('full conversation emits expected event sequence', async () => { const events = new ShadeEventEmitter(); const received: ShadeEvent[] = []; events.on((e) => received.push(e)); const alice = new ShadeSessionManager(crypto, new MemoryStorage(), { events }); const bob = new ShadeSessionManager(crypto, new MemoryStorage(), { events }); await alice.initialize(); await bob.initialize(); const otpks = await bob.generateOneTimePreKeys(5); const bundle = await bob.createPreKeyBundle(); bundle.oneTimePreKey = { keyId: otpks[0].keyId, publicKey: otpks[0].keyPair.publicKey }; await alice.initSessionFromBundle('bob', bundle); const env1 = await alice.encrypt('bob', 'hello'); await bob.decrypt('alice', env1); const env2 = await bob.encrypt('alice', 'hi'); await alice.decrypt('bob', env2); const names = received.map((e) => e.name); expect(names).toContain('identity.initialized'); expect(names).toContain('prekey.generated'); expect(names).toContain('session.created'); expect(names).toContain('trust.pinned'); expect(names).toContain('message.encrypted'); expect(names).toContain('message.decrypted'); expect(names).toContain('ratchet.dh_step'); // Bob's reply triggers a DH step }); test('no events emitted when emitter not provided', async () => { const mgr = new ShadeSessionManager(crypto, new MemoryStorage()); await mgr.initialize(); // No assertion needed — should not throw or error }); test('SECURITY: no key material in event payloads', async () => { const events = new ShadeEventEmitter(); const received: ShadeEvent[] = []; events.on((e) => received.push(e)); const alice = new ShadeSessionManager(crypto, new MemoryStorage(), { events }); const bob = new ShadeSessionManager(crypto, new MemoryStorage(), { events }); await alice.initialize(); await bob.initialize(); const otpks = await bob.generateOneTimePreKeys(5); const bundle = await bob.createPreKeyBundle(); bundle.oneTimePreKey = { keyId: otpks[0].keyId, publicKey: otpks[0].keyPair.publicKey }; await alice.initSessionFromBundle('bob', bundle); const env = await alice.encrypt('bob', 'secret message'); await bob.decrypt('alice', env); await alice.rotateSignedPreKey(); // Serialize all events and check for any 32-byte base64 patterns // (which would indicate raw key material) const json = JSON.stringify(received); // 32-byte base64 = 44 chars (with padding) or 43 (without) // We allow short 16-hex-char hashes, but no 44-char base64 or 64-char hex const longBase64 = /[A-Za-z0-9+/]{43,}={0,2}/g; const longHex = /[0-9a-f]{32,}/gi; const base64Matches = json.match(longBase64) ?? []; const hexMatches = json.match(longHex) ?? []; // Filter out any matches that are inside hash fields (which are 16 hex chars, // so the regex above wouldn't match anyway, but be explicit) expect(base64Matches.length).toBe(0); expect(hexMatches.length).toBe(0); // Also no plaintext leakage expect(json).not.toContain('secret message'); }); });