livekit-client
Version:
JavaScript/TypeScript client SDK for LiveKit
595 lines (549 loc) • 18.8 kB
text/typescript
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;
}
}