191 lines
7.0 KiB
TypeScript
191 lines
7.0 KiB
TypeScript
|
|
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');
|
||
|
|
});
|
||
|
|
});
|