UNPKG

livekit-client

Version:

JavaScript/TypeScript client SDK for LiveKit

213 lines (188 loc) 7.81 kB
import { EventEmitter } from 'events'; import type TypedEventEmitter from 'typed-emitter'; import { workerLogger } from '../../logger'; import { KeyHandlerEvent, type ParticipantKeyHandlerCallbacks } from '../events'; import type { KeyProviderOptions, KeySet, RatchetResult } from '../types'; import { deriveKeys, importKey, ratchet } from '../utils'; // TODO ParticipantKeyHandlers currently don't get destroyed on participant disconnect // we could do this by having a separate worker message on participant disconnected. /** * ParticipantKeyHandler is responsible for providing a cryptor instance with the * en-/decryption key of a participant. It assumes that all tracks of a specific participant * are encrypted with the same key. * Additionally it exposes a method to ratchet a key which can be used by the cryptor either automatically * if decryption fails or can be triggered manually on both sender and receiver side. * */ export class ParticipantKeyHandler extends (EventEmitter as new () => TypedEventEmitter<ParticipantKeyHandlerCallbacks>) { private currentKeyIndex: number; private cryptoKeyRing: Array<KeySet | undefined>; private decryptionFailureCounts: Array<number>; private ratchetPromiseMap: Map<number, Promise<RatchetResult>>; readonly participantIdentity: string; /** @internal */ readonly keyProviderOptions: KeyProviderOptions; /** * true if the current key has not been marked as invalid */ get hasValidKey(): boolean { return !this.hasInvalidKeyAtIndex(this.currentKeyIndex); } constructor(participantIdentity: string, keyProviderOptions: KeyProviderOptions) { super(); this.currentKeyIndex = 0; if (keyProviderOptions.keyringSize < 1 || keyProviderOptions.keyringSize > 256) { throw new TypeError('Keyring size needs to be between 1 and 256'); } this.cryptoKeyRing = new Array(keyProviderOptions.keyringSize).fill(undefined); this.decryptionFailureCounts = new Array(keyProviderOptions.keyringSize).fill(0); this.keyProviderOptions = keyProviderOptions; this.ratchetPromiseMap = new Map(); this.participantIdentity = participantIdentity; } /** * Returns true if the key at the given index is marked as invalid. * * @param keyIndex the index of the key */ hasInvalidKeyAtIndex(keyIndex: number): boolean { return ( this.keyProviderOptions.failureTolerance >= 0 && this.decryptionFailureCounts[keyIndex] > this.keyProviderOptions.failureTolerance ); } /** * Informs the key handler that a decryption failure occurred for an encryption key. * @internal * @param keyIndex the key index for which the failure occurred. Defaults to the current key index. */ decryptionFailure(keyIndex: number = this.currentKeyIndex): void { if (this.keyProviderOptions.failureTolerance < 0) { return; } this.decryptionFailureCounts[keyIndex] += 1; if (this.decryptionFailureCounts[keyIndex] > this.keyProviderOptions.failureTolerance) { workerLogger.warn( `key for ${this.participantIdentity} at index ${keyIndex} is being marked as invalid`, ); } } /** * Informs the key handler that a frame was successfully decrypted using an encryption key. * @internal * @param keyIndex the key index for which the success occurred. Defaults to the current key index. */ decryptionSuccess(keyIndex: number = this.currentKeyIndex): void { this.resetKeyStatus(keyIndex); } /** * Call this after user initiated ratchet or a new key has been set in order to make sure to mark potentially * invalid keys as valid again * * @param keyIndex the index of the key. Defaults to the current key index. */ resetKeyStatus(keyIndex?: number): void { if (keyIndex === undefined) { this.decryptionFailureCounts.fill(0); } else { this.decryptionFailureCounts[keyIndex] = 0; } } /** * Ratchets the current key (or the one at keyIndex if provided) and * returns the ratcheted material * if `setKey` is true (default), it will also set the ratcheted key directly on the crypto key ring * @param keyIndex * @param setKey */ ratchetKey(keyIndex?: number, setKey = true): Promise<RatchetResult> { const currentKeyIndex = keyIndex ?? this.getCurrentKeyIndex(); const existingPromise = this.ratchetPromiseMap.get(currentKeyIndex); if (typeof existingPromise !== 'undefined') { return existingPromise; } const ratchetPromise = new Promise<RatchetResult>(async (resolve, reject) => { try { const keySet = this.getKeySet(currentKeyIndex); if (!keySet) { throw new TypeError( `Cannot ratchet key without a valid keyset of participant ${this.participantIdentity}`, ); } const currentMaterial = keySet.material; const chainKey = await ratchet(currentMaterial, this.keyProviderOptions.ratchetSalt); const newMaterial = await importKey(chainKey, currentMaterial.algorithm.name, 'derive'); const ratchetResult: RatchetResult = { chainKey, cryptoKey: newMaterial, }; if (setKey) { // Set the new key and emit a ratchet event with the ratcheted chain key await this.setKeyFromMaterial(newMaterial, currentKeyIndex, ratchetResult); } resolve(ratchetResult); } catch (e) { reject(e); } finally { this.ratchetPromiseMap.delete(currentKeyIndex); } }); this.ratchetPromiseMap.set(currentKeyIndex, ratchetPromise); return ratchetPromise; } /** * takes in a key material with `deriveBits` and `deriveKey` set as key usages * and derives encryption keys from the material and sets it on the key ring buffer * together with the material * also resets the valid key property and updates the currentKeyIndex */ async setKey(material: CryptoKey, keyIndex = 0, updateCurrentKeyIndex = true) { await this.setKeyFromMaterial(material, keyIndex, null, updateCurrentKeyIndex); if (updateCurrentKeyIndex) { this.resetKeyStatus(keyIndex); } } /** * takes in a key material with `deriveBits` and `deriveKey` set as key usages * and derives encryption keys from the material and sets it on the key ring buffers * together with the material * also updates the currentKeyIndex */ async setKeyFromMaterial( material: CryptoKey, keyIndex: number, ratchetedResult: RatchetResult | null = null, updateCurrentKeyIndex = true, ) { const keySet = await deriveKeys(material, this.keyProviderOptions); const newIndex = keyIndex >= 0 ? keyIndex % this.cryptoKeyRing.length : this.currentKeyIndex; workerLogger.debug(`setting new key with index ${keyIndex}`, { usage: material.usages, algorithm: material.algorithm, ratchetSalt: this.keyProviderOptions.ratchetSalt, }); this.setKeySet(keySet, newIndex, ratchetedResult); if (newIndex >= 0 && updateCurrentKeyIndex) this.currentKeyIndex = newIndex; } setKeySet(keySet: KeySet, keyIndex: number, ratchetedResult: RatchetResult | null = null) { this.cryptoKeyRing[keyIndex % this.cryptoKeyRing.length] = keySet; if (ratchetedResult) { this.emit(KeyHandlerEvent.KeyRatcheted, ratchetedResult, this.participantIdentity, keyIndex); } } async setCurrentKeyIndex(index: number) { this.currentKeyIndex = index % this.cryptoKeyRing.length; this.resetKeyStatus(index); } getCurrentKeyIndex() { return this.currentKeyIndex; } /** * returns currently used KeySet or the one at `keyIndex` if provided * @param keyIndex * @returns */ getKeySet(keyIndex?: number) { return this.cryptoKeyRing[keyIndex ?? this.currentKeyIndex]; } }