233 lines
8.5 KiB
TypeScript
233 lines
8.5 KiB
TypeScript
|
|
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');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|