322 lines
13 KiB
TypeScript
322 lines
13 KiB
TypeScript
|
|
import { describe, test, expect, beforeEach } from 'bun:test';
|
||
|
|
import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web';
|
||
|
|
import {
|
||
|
|
generateIdentityKeyPair,
|
||
|
|
generateSignedPreKey,
|
||
|
|
generateOneTimePreKeys,
|
||
|
|
createPreKeyBundle,
|
||
|
|
processPreKeyBundle,
|
||
|
|
processPreKeyMessage,
|
||
|
|
InvalidSignatureError,
|
||
|
|
PreKeyNotFoundError,
|
||
|
|
} from '../src/index.js';
|
||
|
|
import type { RatchetMessage } from '../src/index.js';
|
||
|
|
|
||
|
|
const crypto = new SubtleCryptoProvider();
|
||
|
|
|
||
|
|
/** Create a dummy RatchetMessage for testing (X3DH doesn't care about the content) */
|
||
|
|
function dummyRatchetMessage(): RatchetMessage {
|
||
|
|
return {
|
||
|
|
dhPublicKey: crypto.randomBytes(32),
|
||
|
|
previousCounter: 0,
|
||
|
|
counter: 0,
|
||
|
|
ciphertext: crypto.randomBytes(48),
|
||
|
|
nonce: crypto.randomBytes(12),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('X3DH', () => {
|
||
|
|
let aliceStorage: MemoryStorage;
|
||
|
|
let bobStorage: MemoryStorage;
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
aliceStorage = new MemoryStorage();
|
||
|
|
bobStorage = new MemoryStorage();
|
||
|
|
});
|
||
|
|
|
||
|
|
// ─── Key Generation ────────────────────────────────────────
|
||
|
|
|
||
|
|
describe('key generation', () => {
|
||
|
|
test('generates identity keypair with correct lengths', async () => {
|
||
|
|
const id = await generateIdentityKeyPair(crypto);
|
||
|
|
expect(id.signingPublicKey.length).toBe(32);
|
||
|
|
expect(id.signingPrivateKey.length).toBe(32);
|
||
|
|
expect(id.dhPublicKey.length).toBe(32);
|
||
|
|
expect(id.dhPrivateKey.length).toBe(32);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('signing and DH keys are different', async () => {
|
||
|
|
const id = await generateIdentityKeyPair(crypto);
|
||
|
|
expect(id.signingPublicKey).not.toEqual(id.dhPublicKey);
|
||
|
|
expect(id.signingPrivateKey).not.toEqual(id.dhPrivateKey);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('generates signed prekey with valid signature', async () => {
|
||
|
|
const id = await generateIdentityKeyPair(crypto);
|
||
|
|
const spk = await generateSignedPreKey(crypto, id, 1);
|
||
|
|
|
||
|
|
expect(spk.keyId).toBe(1);
|
||
|
|
expect(spk.keyPair.publicKey.length).toBe(32);
|
||
|
|
expect(spk.signature.length).toBe(64);
|
||
|
|
|
||
|
|
// Verify the signature
|
||
|
|
const valid = await crypto.verify(id.signingPublicKey, spk.keyPair.publicKey, spk.signature);
|
||
|
|
expect(valid).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('generates batch of one-time prekeys', async () => {
|
||
|
|
const otpks = await generateOneTimePreKeys(crypto, 100, 5);
|
||
|
|
expect(otpks.length).toBe(5);
|
||
|
|
|
||
|
|
for (let i = 0; i < 5; i++) {
|
||
|
|
expect(otpks[i].keyId).toBe(100 + i);
|
||
|
|
expect(otpks[i].keyPair.publicKey.length).toBe(32);
|
||
|
|
}
|
||
|
|
|
||
|
|
// All keys are unique
|
||
|
|
const pubKeys = otpks.map((k) => Array.from(k.keyPair.publicKey).join(','));
|
||
|
|
expect(new Set(pubKeys).size).toBe(5);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ─── Full Handshake ────────────────────────────────────────
|
||
|
|
|
||
|
|
describe('full handshake', () => {
|
||
|
|
test('Alice and Bob derive the same root key (with one-time prekey)', async () => {
|
||
|
|
// Bob generates keys
|
||
|
|
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||
|
|
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
|
||
|
|
const bobOneTimePreKeys = await generateOneTimePreKeys(crypto, 100, 3);
|
||
|
|
|
||
|
|
// Bob stores his keys
|
||
|
|
await bobStorage.saveIdentityKeyPair(bobIdentity);
|
||
|
|
await bobStorage.saveSignedPreKey(bobSignedPreKey);
|
||
|
|
for (const otpk of bobOneTimePreKeys) {
|
||
|
|
await bobStorage.saveOneTimePreKey(otpk);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Bob publishes a bundle (server would store this)
|
||
|
|
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey, bobOneTimePreKeys[0]);
|
||
|
|
|
||
|
|
// Alice generates her identity
|
||
|
|
const aliceIdentity = await generateIdentityKeyPair(crypto);
|
||
|
|
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
|
||
|
|
|
||
|
|
// Alice processes the bundle
|
||
|
|
const aliceResult = await processPreKeyBundle(crypto, aliceStorage, bundle);
|
||
|
|
|
||
|
|
expect(aliceResult.rootKey.length).toBe(32);
|
||
|
|
expect(aliceResult.signedPreKeyId).toBe(1);
|
||
|
|
expect(aliceResult.preKeyId).toBe(100);
|
||
|
|
expect(aliceResult.ephemeralPublicKey.length).toBe(32);
|
||
|
|
|
||
|
|
// Alice creates a PreKeyMessage
|
||
|
|
const preKeyMessage = {
|
||
|
|
registrationId: 1,
|
||
|
|
preKeyId: aliceResult.preKeyId,
|
||
|
|
signedPreKeyId: aliceResult.signedPreKeyId,
|
||
|
|
ephemeralKey: aliceResult.ephemeralPublicKey,
|
||
|
|
identityDHKey: aliceIdentity.dhPublicKey,
|
||
|
|
message: dummyRatchetMessage(),
|
||
|
|
};
|
||
|
|
|
||
|
|
// Bob processes the PreKeyMessage
|
||
|
|
const bobResult = await processPreKeyMessage(crypto, bobStorage, preKeyMessage);
|
||
|
|
|
||
|
|
// Both derive the same root key
|
||
|
|
expect(bobResult.rootKey).toEqual(aliceResult.rootKey);
|
||
|
|
expect(bobResult.remoteIdentityKey).toEqual(aliceIdentity.dhPublicKey);
|
||
|
|
expect(bobResult.remoteEphemeralKey).toEqual(aliceResult.ephemeralPublicKey);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('Alice and Bob derive the same root key (without one-time prekey)', async () => {
|
||
|
|
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||
|
|
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
|
||
|
|
await bobStorage.saveIdentityKeyPair(bobIdentity);
|
||
|
|
await bobStorage.saveSignedPreKey(bobSignedPreKey);
|
||
|
|
|
||
|
|
// Bundle without one-time prekey
|
||
|
|
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey);
|
||
|
|
|
||
|
|
const aliceIdentity = await generateIdentityKeyPair(crypto);
|
||
|
|
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
|
||
|
|
|
||
|
|
const aliceResult = await processPreKeyBundle(crypto, aliceStorage, bundle);
|
||
|
|
expect(aliceResult.preKeyId).toBeUndefined();
|
||
|
|
|
||
|
|
const preKeyMessage = {
|
||
|
|
registrationId: 1,
|
||
|
|
signedPreKeyId: aliceResult.signedPreKeyId,
|
||
|
|
ephemeralKey: aliceResult.ephemeralPublicKey,
|
||
|
|
identityDHKey: aliceIdentity.dhPublicKey,
|
||
|
|
message: dummyRatchetMessage(),
|
||
|
|
};
|
||
|
|
|
||
|
|
const bobResult = await processPreKeyMessage(crypto, bobStorage, preKeyMessage);
|
||
|
|
expect(bobResult.rootKey).toEqual(aliceResult.rootKey);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('different handshakes produce different root keys', async () => {
|
||
|
|
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||
|
|
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
|
||
|
|
await bobStorage.saveIdentityKeyPair(bobIdentity);
|
||
|
|
await bobStorage.saveSignedPreKey(bobSignedPreKey);
|
||
|
|
|
||
|
|
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey);
|
||
|
|
|
||
|
|
// Alice 1
|
||
|
|
const alice1Id = await generateIdentityKeyPair(crypto);
|
||
|
|
const alice1Storage = new MemoryStorage();
|
||
|
|
await alice1Storage.saveIdentityKeyPair(alice1Id);
|
||
|
|
const result1 = await processPreKeyBundle(crypto, alice1Storage, bundle);
|
||
|
|
|
||
|
|
// Alice 2 (different identity)
|
||
|
|
const alice2Id = await generateIdentityKeyPair(crypto);
|
||
|
|
const alice2Storage = new MemoryStorage();
|
||
|
|
await alice2Storage.saveIdentityKeyPair(alice2Id);
|
||
|
|
const result2 = await processPreKeyBundle(crypto, alice2Storage, bundle);
|
||
|
|
|
||
|
|
expect(result1.rootKey).not.toEqual(result2.rootKey);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ─── Signature Verification ────────────────────────────────
|
||
|
|
|
||
|
|
describe('signature verification', () => {
|
||
|
|
test('rejects bundle with invalid signed prekey signature', async () => {
|
||
|
|
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||
|
|
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
|
||
|
|
|
||
|
|
// Tamper with the signature
|
||
|
|
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey);
|
||
|
|
bundle.signedPreKey.signature[0] ^= 0xff;
|
||
|
|
|
||
|
|
const aliceIdentity = await generateIdentityKeyPair(crypto);
|
||
|
|
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
|
||
|
|
|
||
|
|
expect(processPreKeyBundle(crypto, aliceStorage, bundle)).rejects.toThrow(InvalidSignatureError);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('rejects bundle with wrong identity key signing', async () => {
|
||
|
|
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||
|
|
const eveIdentity = await generateIdentityKeyPair(crypto);
|
||
|
|
|
||
|
|
// Eve signs the prekey, but claims to be Bob
|
||
|
|
const eveSignedPreKey = await generateSignedPreKey(crypto, eveIdentity, 1);
|
||
|
|
const bundle = createPreKeyBundle(42, bobIdentity, eveSignedPreKey);
|
||
|
|
|
||
|
|
const aliceIdentity = await generateIdentityKeyPair(crypto);
|
||
|
|
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
|
||
|
|
|
||
|
|
expect(processPreKeyBundle(crypto, aliceStorage, bundle)).rejects.toThrow(InvalidSignatureError);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ─── One-Time Prekey Consumption ───────────────────────────
|
||
|
|
|
||
|
|
describe('one-time prekey consumption', () => {
|
||
|
|
test('one-time prekey is deleted after use', async () => {
|
||
|
|
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||
|
|
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
|
||
|
|
const bobOTPKs = await generateOneTimePreKeys(crypto, 100, 3);
|
||
|
|
await bobStorage.saveIdentityKeyPair(bobIdentity);
|
||
|
|
await bobStorage.saveSignedPreKey(bobSignedPreKey);
|
||
|
|
for (const otpk of bobOTPKs) await bobStorage.saveOneTimePreKey(otpk);
|
||
|
|
|
||
|
|
expect(await bobStorage.getOneTimePreKeyCount()).toBe(3);
|
||
|
|
|
||
|
|
const bundle = createPreKeyBundle(42, bobIdentity, bobSignedPreKey, bobOTPKs[0]);
|
||
|
|
|
||
|
|
const aliceIdentity = await generateIdentityKeyPair(crypto);
|
||
|
|
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
|
||
|
|
|
||
|
|
const aliceResult = await processPreKeyBundle(crypto, aliceStorage, bundle);
|
||
|
|
const preKeyMessage = {
|
||
|
|
registrationId: 1,
|
||
|
|
preKeyId: aliceResult.preKeyId,
|
||
|
|
signedPreKeyId: aliceResult.signedPreKeyId,
|
||
|
|
ephemeralKey: aliceResult.ephemeralPublicKey,
|
||
|
|
identityDHKey: aliceIdentity.dhPublicKey,
|
||
|
|
message: dummyRatchetMessage(),
|
||
|
|
};
|
||
|
|
|
||
|
|
await processPreKeyMessage(crypto, bobStorage, preKeyMessage);
|
||
|
|
|
||
|
|
// One-time prekey 100 should be consumed
|
||
|
|
expect(await bobStorage.getOneTimePreKeyCount()).toBe(2);
|
||
|
|
expect(await bobStorage.getOneTimePreKey(100)).toBeNull();
|
||
|
|
// Others remain
|
||
|
|
expect(await bobStorage.getOneTimePreKey(101)).not.toBeNull();
|
||
|
|
expect(await bobStorage.getOneTimePreKey(102)).not.toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('fails when referenced one-time prekey does not exist', async () => {
|
||
|
|
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||
|
|
const bobSignedPreKey = await generateSignedPreKey(crypto, bobIdentity, 1);
|
||
|
|
await bobStorage.saveIdentityKeyPair(bobIdentity);
|
||
|
|
await bobStorage.saveSignedPreKey(bobSignedPreKey);
|
||
|
|
// No one-time prekeys stored
|
||
|
|
|
||
|
|
const aliceIdentity = await generateIdentityKeyPair(crypto);
|
||
|
|
await aliceStorage.saveIdentityKeyPair(aliceIdentity);
|
||
|
|
|
||
|
|
const preKeyMessage = {
|
||
|
|
registrationId: 1,
|
||
|
|
preKeyId: 999, // doesn't exist
|
||
|
|
signedPreKeyId: 1,
|
||
|
|
ephemeralKey: crypto.randomBytes(32),
|
||
|
|
identityDHKey: aliceIdentity.dhPublicKey,
|
||
|
|
message: dummyRatchetMessage(),
|
||
|
|
};
|
||
|
|
|
||
|
|
expect(processPreKeyMessage(crypto, bobStorage, preKeyMessage)).rejects.toThrow(PreKeyNotFoundError);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('fails when referenced signed prekey does not exist', async () => {
|
||
|
|
const bobIdentity = await generateIdentityKeyPair(crypto);
|
||
|
|
await bobStorage.saveIdentityKeyPair(bobIdentity);
|
||
|
|
// No signed prekey stored
|
||
|
|
|
||
|
|
const preKeyMessage = {
|
||
|
|
registrationId: 1,
|
||
|
|
signedPreKeyId: 999,
|
||
|
|
ephemeralKey: crypto.randomBytes(32),
|
||
|
|
identityDHKey: crypto.randomBytes(32),
|
||
|
|
message: dummyRatchetMessage(),
|
||
|
|
};
|
||
|
|
|
||
|
|
expect(processPreKeyMessage(crypto, bobStorage, preKeyMessage)).rejects.toThrow(PreKeyNotFoundError);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ─── PreKey Bundle Assembly ────────────────────────────────
|
||
|
|
|
||
|
|
describe('createPreKeyBundle', () => {
|
||
|
|
test('assembles bundle with one-time prekey', async () => {
|
||
|
|
const id = await generateIdentityKeyPair(crypto);
|
||
|
|
const spk = await generateSignedPreKey(crypto, id, 5);
|
||
|
|
const otpk = (await generateOneTimePreKeys(crypto, 200, 1))[0];
|
||
|
|
|
||
|
|
const bundle = createPreKeyBundle(42, id, spk, otpk);
|
||
|
|
|
||
|
|
expect(bundle.registrationId).toBe(42);
|
||
|
|
expect(bundle.identitySigningKey).toEqual(id.signingPublicKey);
|
||
|
|
expect(bundle.identityDHKey).toEqual(id.dhPublicKey);
|
||
|
|
expect(bundle.signedPreKey.keyId).toBe(5);
|
||
|
|
expect(bundle.signedPreKey.publicKey).toEqual(spk.keyPair.publicKey);
|
||
|
|
expect(bundle.signedPreKey.signature).toEqual(spk.signature);
|
||
|
|
expect(bundle.oneTimePreKey?.keyId).toBe(200);
|
||
|
|
expect(bundle.oneTimePreKey?.publicKey).toEqual(otpk.keyPair.publicKey);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('assembles bundle without one-time prekey', async () => {
|
||
|
|
const id = await generateIdentityKeyPair(crypto);
|
||
|
|
const spk = await generateSignedPreKey(crypto, id, 1);
|
||
|
|
|
||
|
|
const bundle = createPreKeyBundle(42, id, spk);
|
||
|
|
|
||
|
|
expect(bundle.oneTimePreKey).toBeUndefined();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|