import { describe, test, expect } from 'bun:test'; import { SubtleCryptoProvider } from '@shade/crypto-web'; import { createSenderKey, buildDistribution, installDistribution, senderKeyEncrypt, senderKeyDecrypt, } from '../src/sender-keys.js'; import type { GroupSession, SenderKeyState } from '../src/sender-keys.js'; const crypto = new SubtleCryptoProvider(); /** * Helpers: set up a group of N members who all know each other's sender keys. */ async function setupGroup(groupId: string, memberAddresses: string[]): Promise<{ sessions: Map; }> { // Each member creates their own sender key state const senderStates = new Map(); for (const addr of memberAddresses) { senderStates.set(addr, await createSenderKey(crypto)); } // Each member's session contains everyone's sender key const sessions = new Map(); for (const member of memberAddresses) { const session: GroupSession = { groupId, senderKeys: new Map() }; // Add own sender key (with private signing key) session.senderKeys.set(member, senderStates.get(member)!); // Add public copies of everyone else's sender keys (without private key) for (const other of memberAddresses) { if (other === member) continue; const dist = buildDistribution(groupId, other, senderStates.get(other)!); installDistribution(session, dist); } sessions.set(member, session); } return { sessions }; } describe('Sender Keys (group messaging)', () => { test('Alice sends to Bob + Charlie + Dave', async () => { const { sessions } = await setupGroup('group1', ['alice', 'bob', 'charlie', 'dave']); const msg = await senderKeyEncrypt( crypto, sessions.get('alice')!, 'alice', new TextEncoder().encode('hello everyone'), ); // All three recipients decrypt independently for (const recipient of ['bob', 'charlie', 'dave']) { const plain = await senderKeyDecrypt(crypto, sessions.get(recipient)!, 'group1', msg); expect(new TextDecoder().decode(plain)).toBe('hello everyone'); } }); test('multiple messages from same sender advance the chain', async () => { const { sessions } = await setupGroup('g', ['alice', 'bob']); for (let i = 0; i < 5; i++) { const msg = await senderKeyEncrypt( crypto, sessions.get('alice')!, 'alice', new TextEncoder().encode(`msg ${i}`), ); expect(msg.iteration).toBe(i); const plain = await senderKeyDecrypt(crypto, sessions.get('bob')!, 'g', msg); expect(new TextDecoder().decode(plain)).toBe(`msg ${i}`); } }); test('messages from different senders use independent chains', async () => { const { sessions } = await setupGroup('g', ['alice', 'bob', 'charlie']); // Alice sends const aliceMsg = await senderKeyEncrypt( crypto, sessions.get('alice')!, 'alice', new TextEncoder().encode('from alice'), ); // Bob sends const bobMsg = await senderKeyEncrypt( crypto, sessions.get('bob')!, 'bob', new TextEncoder().encode('from bob'), ); // Charlie decrypts both expect(new TextDecoder().decode( await senderKeyDecrypt(crypto, sessions.get('charlie')!, 'g', aliceMsg) )).toBe('from alice'); expect(new TextDecoder().decode( await senderKeyDecrypt(crypto, sessions.get('charlie')!, 'g', bobMsg) )).toBe('from bob'); }); test('tampered ciphertext fails signature verification', async () => { const { sessions } = await setupGroup('g', ['alice', 'bob']); const msg = await senderKeyEncrypt( crypto, sessions.get('alice')!, 'alice', new TextEncoder().encode('original'), ); msg.ciphertext[0] ^= 0xff; expect( senderKeyDecrypt(crypto, sessions.get('bob')!, 'g', msg), ).rejects.toThrow(/Invalid signature|decryption/i); }); test('wrong group ID fails', async () => { const { sessions } = await setupGroup('g', ['alice', 'bob']); const msg = await senderKeyEncrypt( crypto, sessions.get('alice')!, 'alice', new TextEncoder().encode('hi'), ); expect( senderKeyDecrypt(crypto, sessions.get('bob')!, 'wrong-group', msg), ).rejects.toThrow(/Group ID mismatch/); }); test('out-of-order with small skip works', async () => { const { sessions } = await setupGroup('g', ['alice', 'bob']); // Alice sends 3 messages const messages = []; for (let i = 0; i < 3; i++) { messages.push(await senderKeyEncrypt( crypto, sessions.get('alice')!, 'alice', new TextEncoder().encode(`msg ${i}`), )); } // Bob decrypts in order for (let i = 0; i < 3; i++) { const plain = await senderKeyDecrypt(crypto, sessions.get('bob')!, 'g', messages[i]!); expect(new TextDecoder().decode(plain)).toBe(`msg ${i}`); } }); test('new member added later receives subsequent messages via fresh distribution', async () => { // Alice and Bob in a group const { sessions } = await setupGroup('g', ['alice', 'bob']); // Alice sends one message to Bob const msg1 = await senderKeyEncrypt( crypto, sessions.get('alice')!, 'alice', new TextEncoder().encode('private msg'), ); await senderKeyDecrypt(crypto, sessions.get('bob')!, 'g', msg1); // Charlie joins — Alice sends him her CURRENT sender key state const aliceStateForCharlie = sessions.get('alice')!.senderKeys.get('alice')!; const charlieSession: GroupSession = { groupId: 'g', senderKeys: new Map() }; installDistribution(charlieSession, buildDistribution('g', 'alice', aliceStateForCharlie)); // Alice sends another message const msg2 = await senderKeyEncrypt( crypto, sessions.get('alice')!, 'alice', new TextEncoder().encode('welcome charlie'), ); // Charlie can decrypt (because he got the current chain state) const plain = await senderKeyDecrypt(crypto, charlieSession, 'g', msg2); expect(new TextDecoder().decode(plain)).toBe('welcome charlie'); }); });