UNPKG

livekit-client

Version:

JavaScript/TypeScript client SDK for LiveKit

595 lines (549 loc) 18.8 kB
import { Encryption_Type, TrackInfo } from '@livekit/protocol'; import { EventEmitter } from 'events'; import type TypedEventEmitter from 'typed-emitter'; import log, { LogLevel, workerLogger } from '../logger'; import type RTCEngine from '../room/RTCEngine'; import type Room from '../room/Room'; import { ConnectionState } from '../room/Room'; import { DeviceUnsupportedError } from '../room/errors'; import { EngineEvent, ParticipantEvent, RoomEvent } from '../room/events'; import type RemoteTrack from '../room/track/RemoteTrack'; import type { Track } from '../room/track/Track'; import type { VideoCodec } from '../room/track/options'; import { mimeTypeToVideoCodecString } from '../room/track/utils'; import { Future, isChromiumBased, isLocalTrack, isSafariBased, isVideoTrack } from '../room/utils'; import type { BaseKeyProvider } from './KeyProvider'; import { E2EE_FLAG } from './constants'; import { type E2EEManagerCallbacks, EncryptionEvent, KeyProviderEvent } from './events'; import type { DecryptDataRequestMessage, DecryptDataResponseMessage, E2EEManagerOptions, E2EEWorkerMessage, EnableMessage, EncodeMessage, EncryptDataRequestMessage, EncryptDataResponseMessage, InitMessage, KeyInfo, RTPVideoMapMessage, RatchetRequestMessage, RemoveTransformMessage, ScriptTransformOptions, SetKeyMessage, SifTrailerMessage, UpdateCodecMessage, } from './types'; import { isE2EESupported, isScriptTransformSupported } from './utils'; export interface BaseE2EEManager { setup(room: Room): void; setupEngine(engine: RTCEngine): void; isEnabled: boolean; isDataChannelEncryptionEnabled: boolean; setParticipantCryptorEnabled(enabled: boolean, participantIdentity: string): void; setSifTrailer(trailer: Uint8Array): void; encryptData(data: Uint8Array): Promise<EncryptDataResponseMessage['data']>; handleEncryptedData( payload: Uint8Array, iv: Uint8Array, participantIdentity: string, keyIndex: number, ): Promise<DecryptDataResponseMessage['data']>; on<E extends keyof E2EEManagerCallbacks>(event: E, listener: E2EEManagerCallbacks[E]): this; } /** * @experimental */ export class E2EEManager extends (EventEmitter as new () => TypedEventEmitter<E2EEManagerCallbacks>) implements BaseE2EEManager { protected worker: Worker; protected room?: Room; private encryptionEnabled: boolean; private keyProvider: BaseKeyProvider; private decryptDataRequests: Map<string, Future<DecryptDataResponseMessage['data'], Error>> = new Map(); private encryptDataRequests: Map<string, Future<EncryptDataResponseMessage['data'], Error>> = new Map(); private dataChannelEncryptionEnabled: boolean; constructor(options: E2EEManagerOptions, dcEncryptionEnabled: boolean) { super(); this.keyProvider = options.keyProvider; this.worker = options.worker; this.encryptionEnabled = false; this.dataChannelEncryptionEnabled = dcEncryptionEnabled; } get isEnabled(): boolean { return this.encryptionEnabled; } get isDataChannelEncryptionEnabled(): boolean { return this.isEnabled && this.dataChannelEncryptionEnabled; } /** * @internal */ setup(room: Room) { if (!isE2EESupported()) { throw new DeviceUnsupportedError( 'tried to setup end-to-end encryption on an unsupported browser', ); } log.info('setting up e2ee'); if (room !== this.room) { this.room = room; this.setupEventListeners(room, this.keyProvider); // this.worker = new Worker(''); const msg: InitMessage = { kind: 'init', data: { keyProviderOptions: this.keyProvider.getOptions(), loglevel: workerLogger.getLevel() as LogLevel, }, }; if (this.worker) { log.info(`initializing worker`, { worker: this.worker }); this.worker.onmessage = this.onWorkerMessage; this.worker.onerror = this.onWorkerError; this.worker.postMessage(msg); } } } /** * @internal */ setParticipantCryptorEnabled(enabled: boolean, participantIdentity: string) { log.debug(`set e2ee to ${enabled} for participant ${participantIdentity}`); this.postEnable(enabled, participantIdentity); } /** * @internal */ setSifTrailer(trailer: Uint8Array) { if (!trailer || trailer.length === 0) { log.warn("ignoring server sent trailer as it's empty"); } else { this.postSifTrailer(trailer); } } private onWorkerMessage = (ev: MessageEvent<E2EEWorkerMessage>) => { const { kind, data } = ev.data; switch (kind) { case 'error': log.error(data.error.message); // If error has uuid, it's from an async operation (encrypt/decrypt) // Reject the corresponding future if (data.uuid) { const decryptFuture = this.decryptDataRequests.get(data.uuid); if (decryptFuture?.reject) { decryptFuture.reject(data.error); break; // Don't emit general error if it's handled by future } const encryptFuture = this.encryptDataRequests.get(data.uuid); if (encryptFuture?.reject) { encryptFuture.reject(data.error); break; // Don't emit general error if it's handled by future } } // Emit general error event for unhandled errors this.emit(EncryptionEvent.EncryptionError, data.error, data.participantIdentity); break; case 'initAck': if (data.enabled) { this.keyProvider.getKeys().forEach((keyInfo) => { this.postKey(keyInfo, false); }); } break; case 'enable': if (data.enabled) { this.keyProvider.getKeys().forEach((keyInfo) => { this.postKey(keyInfo, false); }); } if ( this.encryptionEnabled !== data.enabled && data.participantIdentity === this.room?.localParticipant.identity ) { this.emit( EncryptionEvent.ParticipantEncryptionStatusChanged, data.enabled, this.room!.localParticipant, ); this.encryptionEnabled = data.enabled; } else if (data.participantIdentity) { const participant = this.room?.getParticipantByIdentity(data.participantIdentity); if (!participant) { throw TypeError( `couldn't set encryption status, participant not found${data.participantIdentity}`, ); } this.emit(EncryptionEvent.ParticipantEncryptionStatusChanged, data.enabled, participant); } break; case 'ratchetKey': this.keyProvider.emit( KeyProviderEvent.KeyRatcheted, data.ratchetResult, data.participantIdentity, data.keyIndex, ); break; case 'decryptDataResponse': const decryptFuture = this.decryptDataRequests.get(data.uuid); if (decryptFuture?.resolve) { decryptFuture.resolve(data); } break; case 'encryptDataResponse': const encryptFuture = this.encryptDataRequests.get(data.uuid); if (encryptFuture?.resolve) { encryptFuture.resolve(data as EncryptDataResponseMessage['data']); } break; default: break; } }; private onWorkerError = (ev: ErrorEvent) => { log.error('e2ee worker encountered an error:', { error: ev.error }); this.emit(EncryptionEvent.EncryptionError, ev.error, undefined); }; public setupEngine(engine: RTCEngine) { engine.on(EngineEvent.RTPVideoMapUpdate, (rtpMap) => { this.postRTPMap(rtpMap); }); } private setupEventListeners(room: Room, keyProvider: BaseKeyProvider) { room.on(RoomEvent.TrackPublished, (pub, participant) => this.setParticipantCryptorEnabled( pub.trackInfo!.encryption !== Encryption_Type.NONE, participant.identity, ), ); room .on(RoomEvent.ConnectionStateChanged, (state) => { if (state === ConnectionState.Connected) { room.remoteParticipants.forEach((participant) => { participant.trackPublications.forEach((pub) => { this.setParticipantCryptorEnabled( pub.trackInfo!.encryption !== Encryption_Type.NONE, participant.identity, ); }); }); } }) .on(RoomEvent.TrackUnsubscribed, (track, _, participant) => { const msg: RemoveTransformMessage = { kind: 'removeTransform', data: { participantIdentity: participant.identity, trackId: track.mediaStreamID, }, }; this.worker?.postMessage(msg); }) .on(RoomEvent.TrackSubscribed, (track, pub, participant) => { this.setupE2EEReceiver(track, participant.identity, pub.trackInfo); }) .on(RoomEvent.SignalConnected, () => { if (!this.room) { throw new TypeError(`expected room to be present on signal connect`); } const latestKeyIndex = keyProvider.getLatestManuallySetKeyIndex(); keyProvider.getKeys().forEach((keyInfo) => { this.postKey(keyInfo, latestKeyIndex === (keyInfo.keyIndex ?? 0)); }); this.setParticipantCryptorEnabled( this.room.localParticipant.isE2EEEnabled, this.room.localParticipant.identity, ); }); room.localParticipant.on(ParticipantEvent.LocalSenderCreated, async (sender, track) => { this.setupE2EESender(track, sender); }); room.localParticipant.on(ParticipantEvent.LocalTrackPublished, (publication) => { // Safari doesn't support retrieving payload information on RTCEncodedVideoFrame, so we need to update the codec manually once we have the trackInfo from the server if (!isVideoTrack(publication.track) || !isSafariBased()) { return; } const msg: UpdateCodecMessage = { kind: 'updateCodec', data: { trackId: publication.track!.mediaStreamID, codec: mimeTypeToVideoCodecString(publication.trackInfo!.codecs[0].mimeType), participantIdentity: this.room!.localParticipant.identity, }, }; this.worker.postMessage(msg); }); keyProvider .on(KeyProviderEvent.SetKey, (keyInfo, updateCurrentKeyIndex) => this.postKey(keyInfo, updateCurrentKeyIndex ?? true), ) .on(KeyProviderEvent.RatchetRequest, (participantId, keyIndex) => this.postRatchetRequest(participantId, keyIndex), ); } async encryptData(data: Uint8Array): Promise<EncryptDataResponseMessage['data']> { if (!this.worker) { throw Error('could not encrypt data, worker is missing'); } const uuid = crypto.randomUUID(); const msg: EncryptDataRequestMessage = { kind: 'encryptDataRequest', data: { uuid, payload: data, participantIdentity: this.room!.localParticipant.identity, }, }; const future = new Future<EncryptDataResponseMessage['data'], Error>(); future.onFinally = () => { this.encryptDataRequests.delete(uuid); }; this.encryptDataRequests.set(uuid, future); this.worker.postMessage(msg); return future!.promise!; } handleEncryptedData( payload: Uint8Array, iv: Uint8Array, participantIdentity: string, keyIndex: number, ) { if (!this.worker) { throw Error('could not handle encrypted data, worker is missing'); } const uuid = crypto.randomUUID(); const msg: DecryptDataRequestMessage = { kind: 'decryptDataRequest', data: { uuid, payload, iv, participantIdentity, keyIndex, }, }; const future = new Future<DecryptDataResponseMessage['data'], Error>(); future.onFinally = () => { this.decryptDataRequests.delete(uuid); }; this.decryptDataRequests.set(uuid, future); this.worker.postMessage(msg); return future.promise; } private postRatchetRequest(participantIdentity?: string, keyIndex?: number) { if (!this.worker) { throw Error('could not ratchet key, worker is missing'); } const msg: RatchetRequestMessage = { kind: 'ratchetRequest', data: { participantIdentity: participantIdentity, keyIndex, }, }; this.worker.postMessage(msg); } private postKey({ key, participantIdentity, keyIndex }: KeyInfo, updateCurrentKeyIndex: boolean) { if (!this.worker) { throw Error('could not set key, worker is missing'); } const msg: SetKeyMessage = { kind: 'setKey', data: { participantIdentity: participantIdentity, isPublisher: participantIdentity === this.room?.localParticipant.identity, key, keyIndex, updateCurrentKeyIndex, }, }; this.worker.postMessage(msg); } private postEnable(enabled: boolean, participantIdentity: string) { if (this.worker) { const enableMsg: EnableMessage = { kind: 'enable', data: { enabled, participantIdentity, }, }; this.worker.postMessage(enableMsg); } else { throw new ReferenceError('failed to enable e2ee, worker is not ready'); } } private postRTPMap(map: Map<number, VideoCodec>) { if (!this.worker) { throw TypeError('could not post rtp map, worker is missing'); } if (!this.room?.localParticipant.identity) { throw TypeError('could not post rtp map, local participant identity is missing'); } const msg: RTPVideoMapMessage = { kind: 'setRTPMap', data: { map, participantIdentity: this.room.localParticipant.identity, }, }; this.worker.postMessage(msg); } private postSifTrailer(trailer: Uint8Array) { if (!this.worker) { throw Error('could not post SIF trailer, worker is missing'); } const msg: SifTrailerMessage = { kind: 'setSifTrailer', data: { trailer, }, }; this.worker.postMessage(msg); } private setupE2EEReceiver(track: RemoteTrack, remoteId: string, trackInfo?: TrackInfo) { if (!track.receiver) { return; } if (!trackInfo?.mimeType || trackInfo.mimeType === '') { throw new TypeError('MimeType missing from trackInfo, cannot set up E2EE cryptor'); } this.handleReceiver( track.receiver, track.mediaStreamID, remoteId, track.kind === 'video' ? mimeTypeToVideoCodecString(trackInfo.mimeType) : undefined, ); } private setupE2EESender(track: Track, sender: RTCRtpSender) { if (!isLocalTrack(track) || !sender) { if (!sender) log.warn('early return because sender is not ready'); return; } this.handleSender(sender, track.mediaStreamID, undefined); } /** * Handles the given {@code RTCRtpReceiver} by creating a {@code TransformStream} which will inject * a frame decoder. * */ private async handleReceiver( receiver: RTCRtpReceiver, trackId: string, participantIdentity: string, codec?: VideoCodec, ) { if (!this.worker) { return; } if ( isScriptTransformSupported() && // Chrome occasionally throws an `InvalidState` error when using script transforms directly after introducing this API in 141. // Disabling it for Chrome based browsers until the API has stabilized !isChromiumBased() ) { const options: ScriptTransformOptions = { kind: 'decode', participantIdentity, trackId, codec, }; // @ts-ignore receiver.transform = new RTCRtpScriptTransform(this.worker, options); } else { if (E2EE_FLAG in receiver && codec) { // only update codec const msg: UpdateCodecMessage = { kind: 'updateCodec', data: { trackId, codec, participantIdentity: participantIdentity, }, }; this.worker.postMessage(msg); return; } // @ts-ignore let writable: WritableStream = receiver.writableStream; // @ts-ignore let readable: ReadableStream = receiver.readableStream; if (!writable || !readable) { // @ts-ignore const receiverStreams = receiver.createEncodedStreams(); // @ts-ignore receiver.writableStream = receiverStreams.writable; writable = receiverStreams.writable; // @ts-ignore receiver.readableStream = receiverStreams.readable; readable = receiverStreams.readable; } const msg: EncodeMessage = { kind: 'decode', data: { readableStream: readable, writableStream: writable, trackId: trackId, codec, participantIdentity: participantIdentity, isReuse: E2EE_FLAG in receiver, }, }; this.worker.postMessage(msg, [readable, writable]); } // @ts-ignore receiver[E2EE_FLAG] = true; } /** * Handles the given {@code RTCRtpSender} by creating a {@code TransformStream} which will inject * a frame encoder. * */ private handleSender(sender: RTCRtpSender, trackId: string, codec?: VideoCodec) { if (E2EE_FLAG in sender || !this.worker) { return; } if (!this.room?.localParticipant.identity || this.room.localParticipant.identity === '') { throw TypeError('local identity needs to be known in order to set up encrypted sender'); } if ( isScriptTransformSupported() && // Chrome occasionally throws an `InvalidState` error when using script transforms directly after introducing this API in 141. // Disabling it for Chrome based browsers until the API has stabilized !isChromiumBased() ) { log.info('initialize script transform'); const options = { kind: 'encode', participantIdentity: this.room.localParticipant.identity, trackId, codec, }; // @ts-ignore sender.transform = new RTCRtpScriptTransform(this.worker, options); } else { log.info('initialize encoded streams'); // @ts-ignore const senderStreams = sender.createEncodedStreams(); const msg: EncodeMessage = { kind: 'encode', data: { readableStream: senderStreams.readable, writableStream: senderStreams.writable, codec, trackId, participantIdentity: this.room.localParticipant.identity, isReuse: false, }, }; this.worker.postMessage(msg, [senderStreams.readable, senderStreams.writable]); } // @ts-ignore sender[E2EE_FLAG] = true; } }