184 lines
7.6 KiB
TypeScript
184 lines
7.6 KiB
TypeScript
|
|
import { describe, test, expect } from 'bun:test';
|
||
|
|
import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web';
|
||
|
|
import {
|
||
|
|
generateIdentityKeyPair,
|
||
|
|
generateSignedPreKey,
|
||
|
|
generateOneTimePreKeys,
|
||
|
|
createPreKeyBundle,
|
||
|
|
processPreKeyBundle,
|
||
|
|
processPreKeyMessage,
|
||
|
|
initSenderSession,
|
||
|
|
initReceiverSession,
|
||
|
|
ratchetEncrypt,
|
||
|
|
ratchetDecrypt,
|
||
|
|
} from '../src/index.js';
|
||
|
|
|
||
|
|
const crypto = new SubtleCryptoProvider();
|
||
|
|
const enc = new TextEncoder();
|
||
|
|
const dec = new TextDecoder();
|
||
|
|
|
||
|
|
describe('Full E2EE Integration: X3DH → Double Ratchet', () => {
|
||
|
|
test('complete conversation between Alice and Bob', async () => {
|
||
|
|
// ─── Setup Bob (publishes prekey bundle) ─────────────────
|
||
|
|
const bobStorage = new MemoryStorage();
|
||
|
|
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||
|
|
await bobStorage.saveIdentityKeyPair(bobIdentity);
|
||
|
|
await bobStorage.saveLocalRegistrationId(42);
|
||
|
|
|
||
|
|
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
|
||
|
|
await bobStorage.saveSignedPreKey(bobSignedPreKey);
|
||
|
|
|
||
|
|
const bobOTPKs = await generateOneTimePreKeys(crypto, 100, 10);
|
||
|
|
for (const otpk of bobOTPKs) await bobStorage.saveOneTimePreKey(otpk);
|
||
|
|
|
||
|
|
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey, bobOTPKs[0]);
|
||
|
|
|
||
|
|
// ─── Alice initiates (processes bundle, creates session) ──
|
||
|
|
const aliceStorage = new MemoryStorage();
|
||
|
|
const aliceIdentity = await generateIdentityKeyPair(crypto);
|
||
|
|
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
|
||
|
|
|
||
|
|
const x3dhResult = await processPreKeyBundle(crypto, aliceStorage, bundle);
|
||
|
|
|
||
|
|
// Alice initializes her ratchet session
|
||
|
|
const aliceSession = await initSenderSession(
|
||
|
|
crypto,
|
||
|
|
x3dhResult.rootKey,
|
||
|
|
x3dhResult.remoteIdentityKey,
|
||
|
|
x3dhResult.remoteSignedPreKey, // Bob's signed prekey = initial DH ratchet key
|
||
|
|
);
|
||
|
|
|
||
|
|
// Alice encrypts her first message
|
||
|
|
const firstMsg = await ratchetEncrypt(crypto, aliceSession, enc.encode('Hello Bob! This is E2EE.'));
|
||
|
|
|
||
|
|
// Alice sends a PreKeyMessage to Bob
|
||
|
|
const preKeyMessage = {
|
||
|
|
registrationId: 1,
|
||
|
|
preKeyId: x3dhResult.preKeyId,
|
||
|
|
signedPreKeyId: x3dhResult.signedPreKeyId,
|
||
|
|
ephemeralKey: x3dhResult.ephemeralPublicKey,
|
||
|
|
identityDHKey: aliceIdentity.dhPublicKey,
|
||
|
|
message: firstMsg,
|
||
|
|
};
|
||
|
|
|
||
|
|
// ─── Bob receives and processes ──────────────────────────
|
||
|
|
const bobX3dh = await processPreKeyMessage(crypto, bobStorage, preKeyMessage);
|
||
|
|
expect(bobX3dh.rootKey).toEqual(x3dhResult.rootKey);
|
||
|
|
|
||
|
|
// Bob initializes his ratchet session
|
||
|
|
const bobSession = initReceiverSession(
|
||
|
|
bobX3dh.rootKey,
|
||
|
|
bobX3dh.remoteIdentityKey,
|
||
|
|
bobSignedPreKey.keyPair, // Bob's signed prekey as his initial DH keypair
|
||
|
|
);
|
||
|
|
|
||
|
|
// Bob decrypts Alice's first message
|
||
|
|
const plaintext1 = await ratchetDecrypt(crypto, bobSession, bobX3dh.initialMessage);
|
||
|
|
expect(dec.decode(plaintext1)).toBe('Hello Bob! This is E2EE.');
|
||
|
|
|
||
|
|
// ─── Full conversation ───────────────────────────────────
|
||
|
|
|
||
|
|
// Alice sends more
|
||
|
|
const m2 = await ratchetEncrypt(crypto, aliceSession, enc.encode('Are you there?'));
|
||
|
|
expect(dec.decode(await ratchetDecrypt(crypto, bobSession, m2))).toBe('Are you there?');
|
||
|
|
|
||
|
|
// Bob replies (DH ratchet triggers)
|
||
|
|
const m3 = await ratchetEncrypt(crypto, bobSession, enc.encode('Yes! Forward secrecy is active.'));
|
||
|
|
expect(dec.decode(await ratchetDecrypt(crypto, aliceSession, m3))).toBe('Yes! Forward secrecy is active.');
|
||
|
|
|
||
|
|
// Alice replies
|
||
|
|
const m4 = await ratchetEncrypt(crypto, aliceSession, enc.encode('Every message has a unique key.'));
|
||
|
|
expect(dec.decode(await ratchetDecrypt(crypto, bobSession, m4))).toBe('Every message has a unique key.');
|
||
|
|
|
||
|
|
// Multiple back-and-forth
|
||
|
|
for (let i = 0; i < 10; i++) {
|
||
|
|
const sender = i % 2 === 0 ? aliceSession : bobSession;
|
||
|
|
const receiver = i % 2 === 0 ? bobSession : aliceSession;
|
||
|
|
const text = `Turn ${i}: ${i % 2 === 0 ? 'Alice' : 'Bob'} speaking`;
|
||
|
|
|
||
|
|
const msg = await ratchetEncrypt(crypto, sender, enc.encode(text));
|
||
|
|
expect(dec.decode(await ratchetDecrypt(crypto, receiver, msg))).toBe(text);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('works without one-time prekey', async () => {
|
||
|
|
const bobStorage = new MemoryStorage();
|
||
|
|
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||
|
|
await bobStorage.saveIdentityKeyPair(bobIdentity);
|
||
|
|
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
|
||
|
|
await bobStorage.saveSignedPreKey(bobSignedPreKey);
|
||
|
|
|
||
|
|
// No one-time prekeys
|
||
|
|
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey);
|
||
|
|
|
||
|
|
const aliceStorage = new MemoryStorage();
|
||
|
|
const aliceIdentity = await generateIdentityKeyPair(crypto);
|
||
|
|
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
|
||
|
|
|
||
|
|
const x3dhResult = await processPreKeyBundle(crypto, aliceStorage, bundle);
|
||
|
|
const aliceSession = await initSenderSession(
|
||
|
|
crypto, x3dhResult.rootKey, x3dhResult.remoteIdentityKey, x3dhResult.remoteSignedPreKey,
|
||
|
|
);
|
||
|
|
|
||
|
|
const firstMsg = await ratchetEncrypt(crypto, aliceSession, enc.encode('No OTPK needed'));
|
||
|
|
|
||
|
|
const preKeyMessage = {
|
||
|
|
registrationId: 1,
|
||
|
|
signedPreKeyId: x3dhResult.signedPreKeyId,
|
||
|
|
ephemeralKey: x3dhResult.ephemeralPublicKey,
|
||
|
|
identityDHKey: aliceIdentity.dhPublicKey,
|
||
|
|
message: firstMsg,
|
||
|
|
};
|
||
|
|
|
||
|
|
const bobX3dh = await processPreKeyMessage(crypto, bobStorage, preKeyMessage);
|
||
|
|
const bobSession = initReceiverSession(
|
||
|
|
bobX3dh.rootKey, bobX3dh.remoteIdentityKey, bobSignedPreKey.keyPair,
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(dec.decode(await ratchetDecrypt(crypto, bobSession, bobX3dh.initialMessage)))
|
||
|
|
.toBe('No OTPK needed');
|
||
|
|
|
||
|
|
// Continue conversation
|
||
|
|
const reply = await ratchetEncrypt(crypto, bobSession, enc.encode('Got it!'));
|
||
|
|
expect(dec.decode(await ratchetDecrypt(crypto, aliceSession, reply))).toBe('Got it!');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('one-time prekey consumed after use', async () => {
|
||
|
|
const bobStorage = new MemoryStorage();
|
||
|
|
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||
|
|
await bobStorage.saveIdentityKeyPair(bobIdentity);
|
||
|
|
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
|
||
|
|
await bobStorage.saveSignedPreKey(bobSignedPreKey);
|
||
|
|
const bobOTPKs = await generateOneTimePreKeys(crypto, 100, 3);
|
||
|
|
for (const otpk of bobOTPKs) await bobStorage.saveOneTimePreKey(otpk);
|
||
|
|
|
||
|
|
expect(await bobStorage.getOneTimePreKeyCount()).toBe(3);
|
||
|
|
|
||
|
|
// Alice uses OTPK 100
|
||
|
|
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey, bobOTPKs[0]);
|
||
|
|
const aliceStorage = new MemoryStorage();
|
||
|
|
const aliceIdentity = await generateIdentityKeyPair(crypto);
|
||
|
|
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
|
||
|
|
|
||
|
|
const x3dhResult = await processPreKeyBundle(crypto, aliceStorage, bundle);
|
||
|
|
const aliceSession = await initSenderSession(
|
||
|
|
crypto, x3dhResult.rootKey, x3dhResult.remoteIdentityKey, x3dhResult.remoteSignedPreKey,
|
||
|
|
);
|
||
|
|
const firstMsg = await ratchetEncrypt(crypto, aliceSession, enc.encode('test'));
|
||
|
|
|
||
|
|
await processPreKeyMessage(crypto, bobStorage, {
|
||
|
|
registrationId: 1,
|
||
|
|
preKeyId: 100,
|
||
|
|
signedPreKeyId: 1,
|
||
|
|
ephemeralKey: x3dhResult.ephemeralPublicKey,
|
||
|
|
identityDHKey: aliceIdentity.dhPublicKey,
|
||
|
|
message: firstMsg,
|
||
|
|
});
|
||
|
|
|
||
|
|
// OTPK 100 consumed, 101 and 102 remain
|
||
|
|
expect(await bobStorage.getOneTimePreKeyCount()).toBe(2);
|
||
|
|
expect(await bobStorage.getOneTimePreKey(100)).toBeNull();
|
||
|
|
expect(await bobStorage.getOneTimePreKey(101)).not.toBeNull();
|
||
|
|
});
|
||
|
|
});
|