livekit-client
Version:
JavaScript/TypeScript client SDK for LiveKit
387 lines (294 loc) • 12.7 kB
text/typescript
import { describe, expect, it, test, vitest } from 'vitest';
import { ENCRYPTION_ALGORITHM, KEY_PROVIDER_DEFAULTS } from '../constants';
import { KeyHandlerEvent } from '../events';
import { createKeyMaterialFromString, importKey } from '../utils';
import { ParticipantKeyHandler } from './ParticipantKeyHandler';
describe('ParticipantKeyHandler', () => {
const participantIdentity = 'testParticipant';
it('keyringSize must be greater than 0', () => {
expect(() => {
new ParticipantKeyHandler(participantIdentity, { ...KEY_PROVIDER_DEFAULTS, keyringSize: 0 });
}).toThrowError(TypeError);
});
it('keyringSize must be max 256', () => {
expect(() => {
new ParticipantKeyHandler(participantIdentity, {
...KEY_PROVIDER_DEFAULTS,
keyringSize: 257,
});
}).toThrowError(TypeError);
});
it('get and sets keys at an index', async () => {
const keyHandler = new ParticipantKeyHandler(participantIdentity, {
...KEY_PROVIDER_DEFAULTS,
keyringSize: 128,
});
const materialA = await createKeyMaterialFromString('passwordA');
const materialB = await createKeyMaterialFromString('passwordB');
await keyHandler.setKey(materialA, 0);
expect(keyHandler.getKeySet(0)).toBeDefined();
expect(keyHandler.getKeySet(0)?.material).toEqual(materialA);
await keyHandler.setKey(materialB, 0);
expect(keyHandler.getKeySet(0)?.material).toEqual(materialB);
});
it('defaults to key index of 0 when setting key', async () => {
const keyHandler = new ParticipantKeyHandler(participantIdentity, {
...KEY_PROVIDER_DEFAULTS,
});
const materialA = await createKeyMaterialFromString('passwordA');
await keyHandler.setKey(materialA);
expect(keyHandler.getKeySet(0)?.material).toEqual(materialA);
});
it('defaults to current key index when getting key', async () => {
const keyHandler = new ParticipantKeyHandler(participantIdentity, {
...KEY_PROVIDER_DEFAULTS,
});
const materialA = await createKeyMaterialFromString('passwordA');
await keyHandler.setKey(materialA, 10);
expect(keyHandler.getKeySet()?.material).toEqual(materialA);
});
it('marks current key invalid if more than failureTolerance failures', async () => {
const keyHandler = new ParticipantKeyHandler(participantIdentity, {
...KEY_PROVIDER_DEFAULTS,
failureTolerance: 2,
});
keyHandler.setCurrentKeyIndex(10);
expect(keyHandler.hasValidKey).toBe(true);
// 1
keyHandler.decryptionFailure();
expect(keyHandler.hasValidKey).toBe(true);
// 2
keyHandler.decryptionFailure();
expect(keyHandler.hasValidKey).toBe(true);
// 3
keyHandler.decryptionFailure();
expect(keyHandler.hasValidKey).toBe(false);
});
it('marks current key valid on encryption success', async () => {
const keyHandler = new ParticipantKeyHandler(participantIdentity, {
...KEY_PROVIDER_DEFAULTS,
failureTolerance: 0,
});
keyHandler.setCurrentKeyIndex(10);
expect(keyHandler.hasValidKey).toBe(true);
expect(keyHandler.hasInvalidKeyAtIndex(0)).toBe(false);
keyHandler.decryptionFailure();
expect(keyHandler.hasValidKey).toBe(false);
keyHandler.decryptionSuccess();
expect(keyHandler.hasValidKey).toBe(true);
});
it('marks specific key invalid if more than failureTolerance failures', async () => {
const keyHandler = new ParticipantKeyHandler(participantIdentity, {
...KEY_PROVIDER_DEFAULTS,
failureTolerance: 2,
});
// set the current key to something different from what we are testing
keyHandler.setCurrentKeyIndex(10);
expect(keyHandler.hasInvalidKeyAtIndex(5)).toBe(false);
// 1
keyHandler.decryptionFailure(5);
expect(keyHandler.hasInvalidKeyAtIndex(5)).toBe(false);
// 2
keyHandler.decryptionFailure(5);
expect(keyHandler.hasInvalidKeyAtIndex(5)).toBe(false);
// 3
keyHandler.decryptionFailure(5);
expect(keyHandler.hasInvalidKeyAtIndex(5)).toBe(true);
expect(keyHandler.hasInvalidKeyAtIndex(10)).toBe(false);
});
it('marks specific key valid on encryption success', async () => {
const keyHandler = new ParticipantKeyHandler(participantIdentity, {
...KEY_PROVIDER_DEFAULTS,
failureTolerance: 0,
});
// set the current key to something different from what we are testing
keyHandler.setCurrentKeyIndex(10);
expect(keyHandler.hasInvalidKeyAtIndex(5)).toBe(false);
keyHandler.decryptionFailure(5);
expect(keyHandler.hasInvalidKeyAtIndex(5)).toBe(true);
keyHandler.decryptionSuccess(5);
expect(keyHandler.hasInvalidKeyAtIndex(5)).toBe(false);
});
it('marks valid on new key', async () => {
const keyHandler = new ParticipantKeyHandler(participantIdentity, {
...KEY_PROVIDER_DEFAULTS,
failureTolerance: 0,
});
keyHandler.setCurrentKeyIndex(10);
expect(keyHandler.hasValidKey).toBe(true);
expect(keyHandler.hasInvalidKeyAtIndex(0)).toBe(false);
keyHandler.decryptionFailure();
expect(keyHandler.hasValidKey).toBe(false);
await keyHandler.setKey(await createKeyMaterialFromString('passwordA'));
expect(keyHandler.hasValidKey).toBe(true);
});
it('updates currentKeyIndex on new key', async () => {
const keyHandler = new ParticipantKeyHandler(participantIdentity, KEY_PROVIDER_DEFAULTS);
const material = await createKeyMaterialFromString('password');
expect(keyHandler.getCurrentKeyIndex()).toBe(0);
// default is zero
await keyHandler.setKey(material);
expect(keyHandler.getCurrentKeyIndex()).toBe(0);
// should go to next index
await keyHandler.setKey(material, 1);
expect(keyHandler.getCurrentKeyIndex()).toBe(1);
// should be able to jump ahead
await keyHandler.setKey(material, 10);
expect(keyHandler.getCurrentKeyIndex()).toBe(10);
});
it('allows currentKeyIndex to be explicitly set', async () => {
const keyHandler = new ParticipantKeyHandler(participantIdentity, KEY_PROVIDER_DEFAULTS);
keyHandler.setCurrentKeyIndex(10);
expect(keyHandler.getCurrentKeyIndex()).toBe(10);
});
it('allows many failures if failureTolerance is less than zero', async () => {
const keyHandler = new ParticipantKeyHandler(participantIdentity, {
...KEY_PROVIDER_DEFAULTS,
failureTolerance: -1,
});
expect(keyHandler.hasValidKey).toBe(true);
for (let i = 0; i < 100; i++) {
keyHandler.decryptionFailure();
expect(keyHandler.hasValidKey).toBe(true);
}
});
describe('resetKeyStatus', () => {
it('marks all keys as valid if no index is provided', () => {
const keyHandler = new ParticipantKeyHandler(participantIdentity, {
...KEY_PROVIDER_DEFAULTS,
failureTolerance: 0,
});
for (let i = 0; i < KEY_PROVIDER_DEFAULTS.keyringSize; i++) {
keyHandler.decryptionFailure(i);
expect(keyHandler.hasInvalidKeyAtIndex(i)).toBe(true);
}
keyHandler.resetKeyStatus();
for (let i = 0; i < KEY_PROVIDER_DEFAULTS.keyringSize; i++) {
expect(keyHandler.hasInvalidKeyAtIndex(i)).toBe(false);
}
});
});
describe('ratchetKey', () => {
it('emits event', async () => {
const keyHandler = new ParticipantKeyHandler(participantIdentity, KEY_PROVIDER_DEFAULTS);
const material = await createKeyMaterialFromString('password');
const keyRatched = vitest.fn();
keyHandler.on(KeyHandlerEvent.KeyRatcheted, keyRatched);
await keyHandler.setKey(material);
const ratchetResult = await keyHandler.ratchetKey();
const newMaterial = keyHandler.getKeySet()?.material;
expect(keyRatched).toHaveBeenCalledWith(
{
chainKey: ratchetResult.chainKey,
cryptoKey: newMaterial,
},
participantIdentity,
0,
);
});
it('ratchets keys predictably', async () => {
// we can't extract the keys directly, so we instead use them to encrypt a known plaintext
const keyHandler = new ParticipantKeyHandler(participantIdentity, KEY_PROVIDER_DEFAULTS);
const originalMaterial = await createKeyMaterialFromString('password');
await keyHandler.setKey(originalMaterial);
const ciphertexts: Uint8Array[] = [];
const plaintext = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
const iv = new Uint8Array(12);
const additionalData = new Uint8Array(0);
for (let i = 0; i < 10; i++) {
const { encryptionKey } = keyHandler.getKeySet()!;
const ciphertext = await crypto.subtle.encrypt(
{
name: ENCRYPTION_ALGORITHM,
iv,
additionalData,
},
encryptionKey,
plaintext,
);
ciphertexts.push(new Uint8Array(ciphertext));
await keyHandler.ratchetKey();
}
// check that all ciphertexts are unique
expect(new Set(ciphertexts.map((x) => new TextDecoder().decode(x))).size).toEqual(
ciphertexts.length,
);
expect(ciphertexts).matchSnapshot('ciphertexts');
});
});
describe(`E2EE Ratcheting`, () => {
test('Should be possible to share ratcheted material to remote participant', async () => {
const senderKeyHandler = new ParticipantKeyHandler('test-sender', KEY_PROVIDER_DEFAULTS);
// Initial key
const initialMaterial = new Uint8Array(32);
crypto.getRandomValues(initialMaterial);
const rootMaterial = await importKey(initialMaterial, 'HKDF', 'derive');
await senderKeyHandler.setKeyFromMaterial(rootMaterial, 0);
const iv = new Uint8Array(12);
crypto.getRandomValues(iv);
const firstMessagePreRatchet = new TextEncoder().encode(
'Hello world, this is the first message',
);
const firstCipherText = await encrypt(senderKeyHandler, 0, iv, firstMessagePreRatchet);
let ratchetBufferResolve: (key: ArrayBuffer) => void;
const expectEmitted = new Promise<ArrayBuffer>(async (resolve) => {
ratchetBufferResolve = resolve;
});
senderKeyHandler.on(KeyHandlerEvent.KeyRatcheted, (material, identity, keyIndex) => {
expect(identity).toEqual('test-sender');
expect(keyIndex).toEqual(0);
ratchetBufferResolve(material.chainKey);
});
const currentKeyIndex = senderKeyHandler.getCurrentKeyIndex();
const ratchetResult = await senderKeyHandler.ratchetKey(currentKeyIndex, true);
// Notice that ratchetedKeySet is not exportable, so we cannot share it out-of-band.
// This is a limitation of webcrypto for KDFs keys, they cannot be exported.
expect(ratchetResult.cryptoKey.extractable).toBe(false);
const ratchetedMaterial = await expectEmitted;
// The ratcheted material can be sent out-of-band to new participants. And they
// should be able to generate the same keyMaterial
const generatedMaterial = await importKey(ratchetedMaterial, 'HKDF', 'derive');
const receiverKeyHandler = new ParticipantKeyHandler('test-receiver', KEY_PROVIDER_DEFAULTS);
await receiverKeyHandler.setKeyFromMaterial(generatedMaterial, 0);
// Now sender should be able to encrypt to recipient
const plainText = new TextEncoder().encode('Hello world, this is a test message');
const cipherText = await encrypt(senderKeyHandler, 0, iv, plainText);
const clearTextBuffer = await decrypt(receiverKeyHandler, 0, iv, cipherText);
const clearText = new Uint8Array(clearTextBuffer);
expect(clearText).toEqual(plainText);
// The receiver should not be able to decrypt the first message
const decryptPromise = decrypt(receiverKeyHandler, 0, iv, firstCipherText);
await expect(decryptPromise).rejects.toThrowError();
});
async function encrypt(
participantKeyHandler: ParticipantKeyHandler,
keyIndex: number,
iv: Uint8Array,
data: Uint8Array,
): Promise<ArrayBuffer> {
return crypto.subtle.encrypt(
{
name: ENCRYPTION_ALGORITHM,
iv,
},
participantKeyHandler.getKeySet(keyIndex)!.encryptionKey,
data,
);
}
async function decrypt(
participantKeyHandler: ParticipantKeyHandler,
keyIndex: number,
iv: Uint8Array,
cipherText: ArrayBuffer,
): Promise<ArrayBuffer> {
return crypto.subtle.decrypt(
{
name: ENCRYPTION_ALGORITHM,
iv,
},
participantKeyHandler.getKeySet(keyIndex)!.encryptionKey,
cipherText,
);
}
});
});