UNPKG

livekit-client

Version:

JavaScript/TypeScript client SDK for LiveKit

728 lines (646 loc) 25.4 kB
/* eslint-disable @typescript-eslint/no-unused-vars */ // TODO code inspired by https://github.com/webrtc/samples/blob/gh-pages/src/content/insertable-streams/endtoend-encryption/js/worker.js import { EventEmitter } from 'events'; import type TypedEventEmitter from 'typed-emitter'; import { workerLogger } from '../../logger'; import type { VideoCodec } from '../../room/track/options'; import { ENCRYPTION_ALGORITHM, IV_LENGTH, UNENCRYPTED_BYTES } from '../constants'; import { CryptorError, CryptorErrorReason } from '../errors'; import { CryptorCallbacks, CryptorEvent } from '../events'; import type { DecodeRatchetOptions, KeyProviderOptions, KeySet } from '../types'; import { deriveKeys, isVideoFrame, needsRbspUnescaping, parseRbsp, writeRbsp } from '../utils'; import type { ParticipantKeyHandler } from './ParticipantKeyHandler'; import { SifGuard } from './SifGuard'; export const encryptionEnabledMap: Map<string, boolean> = new Map(); export interface FrameCryptorConstructor { new (opts?: unknown): BaseFrameCryptor; } export interface TransformerInfo { readable: ReadableStream; writable: WritableStream; transformer: TransformStream; abortController: AbortController; } export class BaseFrameCryptor extends (EventEmitter as new () => TypedEventEmitter<CryptorCallbacks>) { protected encodeFunction( encodedFrame: RTCEncodedVideoFrame | RTCEncodedAudioFrame, controller: TransformStreamDefaultController, ): Promise<any> { throw Error('not implemented for subclass'); } protected decodeFunction( encodedFrame: RTCEncodedVideoFrame | RTCEncodedAudioFrame, controller: TransformStreamDefaultController, ): Promise<any> { throw Error('not implemented for subclass'); } } /** * Cryptor is responsible for en-/decrypting media frames. * Each Cryptor instance is responsible for en-/decrypting a single mediaStreamTrack. */ export class FrameCryptor extends BaseFrameCryptor { private sendCounts: Map<number, number>; private participantIdentity: string | undefined; private trackId: string | undefined; private keys: ParticipantKeyHandler; private videoCodec?: VideoCodec; private rtpMap: Map<number, VideoCodec>; private keyProviderOptions: KeyProviderOptions; /** * used for detecting server injected unencrypted frames */ private sifTrailer: Uint8Array; private sifGuard: SifGuard; private detectedCodec?: VideoCodec; constructor(opts: { keys: ParticipantKeyHandler; participantIdentity: string; keyProviderOptions: KeyProviderOptions; sifTrailer?: Uint8Array; }) { super(); this.sendCounts = new Map(); this.keys = opts.keys; this.participantIdentity = opts.participantIdentity; this.rtpMap = new Map(); this.keyProviderOptions = opts.keyProviderOptions; this.sifTrailer = opts.sifTrailer ?? Uint8Array.from([]); this.sifGuard = new SifGuard(); } private get logContext() { return { participant: this.participantIdentity, mediaTrackId: this.trackId, fallbackCodec: this.videoCodec, }; } /** * Assign a different participant to the cryptor. * useful for transceiver re-use * @param id * @param keys */ setParticipant(id: string, keys: ParticipantKeyHandler) { workerLogger.debug('setting new participant on cryptor', { ...this.logContext, participant: id, }); if (this.participantIdentity) { workerLogger.error( 'cryptor has already a participant set, participant should have been unset before', { ...this.logContext, }, ); } this.participantIdentity = id; this.keys = keys; this.sifGuard.reset(); } unsetParticipant() { workerLogger.debug('unsetting participant', this.logContext); this.participantIdentity = undefined; } isEnabled() { if (this.participantIdentity) { return encryptionEnabledMap.get(this.participantIdentity); } else { return undefined; } } getParticipantIdentity() { return this.participantIdentity; } getTrackId() { return this.trackId; } /** * Update the video codec used by the mediaStreamTrack * @param codec */ setVideoCodec(codec: VideoCodec) { this.videoCodec = codec; } /** * rtp payload type map used for figuring out codec of payload type when encoding * @param map */ setRtpMap(map: Map<number, VideoCodec>) { this.rtpMap = map; } setupTransform( operation: 'encode' | 'decode', readable: ReadableStream, writable: WritableStream, trackId: string, codec?: VideoCodec, ) { if (codec) { workerLogger.info('setting codec on cryptor to', { codec }); this.videoCodec = codec; } workerLogger.debug('Setting up frame cryptor transform', { operation, passedTrackId: trackId, codec, ...this.logContext, }); const transformFn = operation === 'encode' ? this.encodeFunction : this.decodeFunction; const transformStream = new TransformStream({ transform: transformFn.bind(this), }); readable .pipeThrough(transformStream) .pipeTo(writable) .catch((e) => { workerLogger.warn(e); this.emit( CryptorEvent.Error, e instanceof CryptorError ? e : new CryptorError(e.message, undefined, this.participantIdentity), ); }); this.trackId = trackId; } setSifTrailer(trailer: Uint8Array) { workerLogger.debug('setting SIF trailer', { ...this.logContext, trailer }); this.sifTrailer = trailer; } /** * Function that will be injected in a stream and will encrypt the given encoded frames. * * @param {RTCEncodedVideoFrame|RTCEncodedAudioFrame} encodedFrame - Encoded video frame. * @param {TransformStreamDefaultController} controller - TransportStreamController. * * The VP8 payload descriptor described in * https://tools.ietf.org/html/rfc7741#section-4.2 * is part of the RTP packet and not part of the frame and is not controllable by us. * This is fine as the SFU keeps having access to it for routing. * * The encrypted frame is formed as follows: * 1) Find unencrypted byte length, depending on the codec, frame type and kind. * 2) Form the GCM IV for the frame as described above. * 3) Encrypt the rest of the frame using AES-GCM. * 4) Allocate space for the encrypted frame. * 5) Copy the unencrypted bytes to the start of the encrypted frame. * 6) Append the ciphertext to the encrypted frame. * 7) Append the IV. * 8) Append a single byte for the key identifier. * 9) Enqueue the encrypted frame for sending. */ protected async encodeFunction( encodedFrame: RTCEncodedVideoFrame | RTCEncodedAudioFrame, controller: TransformStreamDefaultController, ) { if ( !this.isEnabled() || // skip for encryption for empty dtx frames encodedFrame.data.byteLength === 0 ) { return controller.enqueue(encodedFrame); } const keySet = this.keys.getKeySet(); if (!keySet) { throw new TypeError( `key set not found for ${ this.participantIdentity } at index ${this.keys.getCurrentKeyIndex()}`, ); } const { encryptionKey } = keySet; const keyIndex = this.keys.getCurrentKeyIndex(); if (encryptionKey) { const iv = this.makeIV( encodedFrame.getMetadata().synchronizationSource ?? -1, encodedFrame.timestamp, ); let frameInfo = this.getUnencryptedBytes(encodedFrame); // Thіs is not encrypted and contains the VP8 payload descriptor or the Opus TOC byte. const frameHeader = new Uint8Array(encodedFrame.data, 0, frameInfo.unencryptedBytes); // Frame trailer contains the R|IV_LENGTH and key index const frameTrailer = new Uint8Array(2); frameTrailer[0] = IV_LENGTH; frameTrailer[1] = keyIndex; // Construct frame trailer. Similar to the frame header described in // https://tools.ietf.org/html/draft-omara-sframe-00#section-4.2 // but we put it at the end. // // ---------+-------------------------+-+---------+---- // payload |IV...(length = IV_LENGTH)|R|IV_LENGTH|KID | // ---------+-------------------------+-+---------+---- try { const cipherText = await crypto.subtle.encrypt( { name: ENCRYPTION_ALGORITHM, iv, additionalData: new Uint8Array(encodedFrame.data, 0, frameHeader.byteLength), }, encryptionKey, new Uint8Array(encodedFrame.data, frameInfo.unencryptedBytes), ); let newDataWithoutHeader = new Uint8Array( cipherText.byteLength + iv.byteLength + frameTrailer.byteLength, ); newDataWithoutHeader.set(new Uint8Array(cipherText)); // add ciphertext. newDataWithoutHeader.set(new Uint8Array(iv), cipherText.byteLength); // append IV. newDataWithoutHeader.set(frameTrailer, cipherText.byteLength + iv.byteLength); // append frame trailer. if (frameInfo.isH264) { newDataWithoutHeader = writeRbsp(newDataWithoutHeader); } var newData = new Uint8Array(frameHeader.byteLength + newDataWithoutHeader.byteLength); newData.set(frameHeader); newData.set(newDataWithoutHeader, frameHeader.byteLength); encodedFrame.data = newData.buffer; return controller.enqueue(encodedFrame); } catch (e: any) { // TODO: surface this to the app. workerLogger.error(e); } } else { workerLogger.debug('failed to encrypt, emitting error', this.logContext); this.emit( CryptorEvent.Error, new CryptorError( `encryption key missing for encoding`, CryptorErrorReason.MissingKey, this.participantIdentity, ), ); } } /** * Function that will be injected in a stream and will decrypt the given encoded frames. * * @param {RTCEncodedVideoFrame|RTCEncodedAudioFrame} encodedFrame - Encoded video frame. * @param {TransformStreamDefaultController} controller - TransportStreamController. */ protected async decodeFunction( encodedFrame: RTCEncodedVideoFrame | RTCEncodedAudioFrame, controller: TransformStreamDefaultController, ) { if ( !this.isEnabled() || // skip for decryption for empty dtx frames encodedFrame.data.byteLength === 0 ) { workerLogger.debug('skipping empty frame', this.logContext); this.sifGuard.recordUserFrame(); return controller.enqueue(encodedFrame); } if (isFrameServerInjected(encodedFrame.data, this.sifTrailer)) { workerLogger.debug('enqueue SIF', this.logContext); this.sifGuard.recordSif(); if (this.sifGuard.isSifAllowed()) { encodedFrame.data = encodedFrame.data.slice( 0, encodedFrame.data.byteLength - this.sifTrailer.byteLength, ); return controller.enqueue(encodedFrame); } else { workerLogger.warn('SIF limit reached, dropping frame'); return; } } else { this.sifGuard.recordUserFrame(); } const data = new Uint8Array(encodedFrame.data); const keyIndex = data[encodedFrame.data.byteLength - 1]; if (this.keys.getKeySet(keyIndex) && this.keys.hasValidKey) { try { const decodedFrame = await this.decryptFrame(encodedFrame, keyIndex); this.keys.decryptionSuccess(); if (decodedFrame) { return controller.enqueue(decodedFrame); } } catch (error) { if (error instanceof CryptorError && error.reason === CryptorErrorReason.InvalidKey) { if (this.keys.hasValidKey) { this.emit(CryptorEvent.Error, error); this.keys.decryptionFailure(); } } else { workerLogger.warn('decoding frame failed', { error }); } } } else if (!this.keys.getKeySet(keyIndex) && this.keys.hasValidKey) { // emit an error in case the key index is out of bounds but the key handler thinks we still have a valid key workerLogger.warn(`skipping decryption due to missing key at index ${keyIndex}`); this.emit( CryptorEvent.Error, new CryptorError( `missing key at index ${keyIndex} for participant ${this.participantIdentity}`, CryptorErrorReason.MissingKey, this.participantIdentity, ), ); } } /** * Function that will decrypt the given encoded frame. If the decryption fails, it will * ratchet the key for up to RATCHET_WINDOW_SIZE times. */ private async decryptFrame( encodedFrame: RTCEncodedVideoFrame | RTCEncodedAudioFrame, keyIndex: number, initialMaterial: KeySet | undefined = undefined, ratchetOpts: DecodeRatchetOptions = { ratchetCount: 0 }, ): Promise<RTCEncodedVideoFrame | RTCEncodedAudioFrame | undefined> { const keySet = this.keys.getKeySet(keyIndex); if (!ratchetOpts.encryptionKey && !keySet) { throw new TypeError(`no encryption key found for decryption of ${this.participantIdentity}`); } let frameInfo = this.getUnencryptedBytes(encodedFrame); // Construct frame trailer. Similar to the frame header described in // https://tools.ietf.org/html/draft-omara-sframe-00#section-4.2 // but we put it at the end. // // ---------+-------------------------+-+---------+---- // payload |IV...(length = IV_LENGTH)|R|IV_LENGTH|KID | // ---------+-------------------------+-+---------+---- try { const frameHeader = new Uint8Array(encodedFrame.data, 0, frameInfo.unencryptedBytes); var encryptedData = new Uint8Array( encodedFrame.data, frameHeader.length, encodedFrame.data.byteLength - frameHeader.length, ); if (frameInfo.isH264 && needsRbspUnescaping(encryptedData)) { encryptedData = parseRbsp(encryptedData); const newUint8 = new Uint8Array(frameHeader.byteLength + encryptedData.byteLength); newUint8.set(frameHeader); newUint8.set(encryptedData, frameHeader.byteLength); encodedFrame.data = newUint8.buffer; } const frameTrailer = new Uint8Array(encodedFrame.data, encodedFrame.data.byteLength - 2, 2); const ivLength = frameTrailer[0]; const iv = new Uint8Array( encodedFrame.data, encodedFrame.data.byteLength - ivLength - frameTrailer.byteLength, ivLength, ); const cipherTextStart = frameHeader.byteLength; const cipherTextLength = encodedFrame.data.byteLength - (frameHeader.byteLength + ivLength + frameTrailer.byteLength); const plainText = await crypto.subtle.decrypt( { name: ENCRYPTION_ALGORITHM, iv, additionalData: new Uint8Array(encodedFrame.data, 0, frameHeader.byteLength), }, ratchetOpts.encryptionKey ?? keySet!.encryptionKey, new Uint8Array(encodedFrame.data, cipherTextStart, cipherTextLength), ); const newData = new ArrayBuffer(frameHeader.byteLength + plainText.byteLength); const newUint8 = new Uint8Array(newData); newUint8.set(new Uint8Array(encodedFrame.data, 0, frameHeader.byteLength)); newUint8.set(new Uint8Array(plainText), frameHeader.byteLength); encodedFrame.data = newData; return encodedFrame; } catch (error: any) { if (this.keyProviderOptions.ratchetWindowSize > 0) { if (ratchetOpts.ratchetCount < this.keyProviderOptions.ratchetWindowSize) { workerLogger.debug( `ratcheting key attempt ${ratchetOpts.ratchetCount} of ${ this.keyProviderOptions.ratchetWindowSize }, for kind ${encodedFrame instanceof RTCEncodedAudioFrame ? 'audio' : 'video'}`, ); let ratchetedKeySet: KeySet | undefined; if ((initialMaterial ?? keySet) === this.keys.getKeySet(keyIndex)) { // only ratchet if the currently set key is still the same as the one used to decrypt this frame // if not, it might be that a different frame has already ratcheted and we try with that one first const newMaterial = await this.keys.ratchetKey(keyIndex, false); ratchetedKeySet = await deriveKeys(newMaterial, this.keyProviderOptions.ratchetSalt); } const frame = await this.decryptFrame(encodedFrame, keyIndex, initialMaterial || keySet, { ratchetCount: ratchetOpts.ratchetCount + 1, encryptionKey: ratchetedKeySet?.encryptionKey, }); if (frame && ratchetedKeySet) { // before updating the keys, make sure that the keySet used for this frame is still the same as the currently set key // if it's not, a new key might have been set already, which we don't want to override if ((initialMaterial ?? keySet) === this.keys.getKeySet(keyIndex)) { this.keys.setKeySet(ratchetedKeySet, keyIndex, true); // decryption was successful, set the new key index to reflect the ratcheted key set this.keys.setCurrentKeyIndex(keyIndex); } } return frame; } else { /** * Because we only set a new key once decryption has been successful, * we can be sure that we don't need to reset the key to the initial material at this point * as the key has not been updated on the keyHandler instance */ workerLogger.warn('maximum ratchet attempts exceeded'); throw new CryptorError( `valid key missing for participant ${this.participantIdentity}`, CryptorErrorReason.InvalidKey, this.participantIdentity, ); } } else { throw new CryptorError( `Decryption failed: ${error.message}`, CryptorErrorReason.InvalidKey, this.participantIdentity, ); } } } /** * Construct the IV used for AES-GCM and sent (in plain) with the packet similar to * https://tools.ietf.org/html/rfc7714#section-8.1 * It concatenates * - the 32 bit synchronization source (SSRC) given on the encoded frame, * - the 32 bit rtp timestamp given on the encoded frame, * - a send counter that is specific to the SSRC. Starts at a random number. * The send counter is essentially the pictureId but we currently have to implement this ourselves. * There is no XOR with a salt. Note that this IV leaks the SSRC to the receiver but since this is * randomly generated and SFUs may not rewrite this is considered acceptable. * The SSRC is used to allow demultiplexing multiple streams with the same key, as described in * https://tools.ietf.org/html/rfc3711#section-4.1.1 * The RTP timestamp is 32 bits and advances by the codec clock rate (90khz for video, 48khz for * opus audio) every second. For video it rolls over roughly every 13 hours. * The send counter will advance at the frame rate (30fps for video, 50fps for 20ms opus audio) * every second. It will take a long time to roll over. * * See also https://developer.mozilla.org/en-US/docs/Web/API/AesGcmParams */ private makeIV(synchronizationSource: number, timestamp: number) { const iv = new ArrayBuffer(IV_LENGTH); const ivView = new DataView(iv); // having to keep our own send count (similar to a picture id) is not ideal. if (!this.sendCounts.has(synchronizationSource)) { // Initialize with a random offset, similar to the RTP sequence number. this.sendCounts.set(synchronizationSource, Math.floor(Math.random() * 0xffff)); } const sendCount = this.sendCounts.get(synchronizationSource) ?? 0; ivView.setUint32(0, synchronizationSource); ivView.setUint32(4, timestamp); ivView.setUint32(8, timestamp - (sendCount % 0xffff)); this.sendCounts.set(synchronizationSource, sendCount + 1); return iv; } private getUnencryptedBytes(frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame): { unencryptedBytes: number; isH264: boolean; } { var frameInfo = { unencryptedBytes: 0, isH264: false }; if (isVideoFrame(frame)) { let detectedCodec = this.getVideoCodec(frame) ?? this.videoCodec; if (detectedCodec !== this.detectedCodec) { workerLogger.debug('detected different codec', { detectedCodec, oldCodec: this.detectedCodec, ...this.logContext, }); this.detectedCodec = detectedCodec; } if (detectedCodec === 'av1') { throw new Error(`${detectedCodec} is not yet supported for end to end encryption`); } if (detectedCodec === 'vp8') { frameInfo.unencryptedBytes = UNENCRYPTED_BYTES[frame.type]; } else if (detectedCodec === 'vp9') { frameInfo.unencryptedBytes = 0; return frameInfo; } const data = new Uint8Array(frame.data); try { const naluIndices = findNALUIndices(data); // if the detected codec is undefined we test whether it _looks_ like a h264 frame as a best guess frameInfo.isH264 = detectedCodec === 'h264' || naluIndices.some((naluIndex) => [NALUType.SLICE_IDR, NALUType.SLICE_NON_IDR].includes(parseNALUType(data[naluIndex])), ); if (frameInfo.isH264) { for (const index of naluIndices) { let type = parseNALUType(data[index]); switch (type) { case NALUType.SLICE_IDR: case NALUType.SLICE_NON_IDR: frameInfo.unencryptedBytes = index + 2; return frameInfo; default: break; } } throw new TypeError('Could not find NALU'); } } catch (e) { // no op, we just continue and fallback to vp8 } frameInfo.unencryptedBytes = UNENCRYPTED_BYTES[frame.type]; return frameInfo; } else { frameInfo.unencryptedBytes = UNENCRYPTED_BYTES.audio; return frameInfo; } } /** * inspects frame payloadtype if available and maps it to the codec specified in rtpMap */ private getVideoCodec(frame: RTCEncodedVideoFrame): VideoCodec | undefined { if (this.rtpMap.size === 0) { return undefined; } const payloadType = frame.getMetadata().payloadType; const codec = payloadType ? this.rtpMap.get(payloadType) : undefined; return codec; } } /** * Slice the NALUs present in the supplied buffer, assuming it is already byte-aligned * code adapted from https://github.com/medooze/h264-frame-parser/blob/main/lib/NalUnits.ts to return indices only */ export function findNALUIndices(stream: Uint8Array): number[] { const result: number[] = []; let start = 0, pos = 0, searchLength = stream.length - 2; while (pos < searchLength) { // skip until end of current NALU while ( pos < searchLength && !(stream[pos] === 0 && stream[pos + 1] === 0 && stream[pos + 2] === 1) ) pos++; if (pos >= searchLength) pos = stream.length; // remove trailing zeros from current NALU let end = pos; while (end > start && stream[end - 1] === 0) end--; // save current NALU if (start === 0) { if (end !== start) throw TypeError('byte stream contains leading data'); } else { result.push(start); } // begin new NALU start = pos = pos + 3; } return result; } export function parseNALUType(startByte: number): NALUType { return startByte & kNaluTypeMask; } const kNaluTypeMask = 0x1f; export enum NALUType { /** Coded slice of a non-IDR picture */ SLICE_NON_IDR = 1, /** Coded slice data partition A */ SLICE_PARTITION_A = 2, /** Coded slice data partition B */ SLICE_PARTITION_B = 3, /** Coded slice data partition C */ SLICE_PARTITION_C = 4, /** Coded slice of an IDR picture */ SLICE_IDR = 5, /** Supplemental enhancement information */ SEI = 6, /** Sequence parameter set */ SPS = 7, /** Picture parameter set */ PPS = 8, /** Access unit delimiter */ AUD = 9, /** End of sequence */ END_SEQ = 10, /** End of stream */ END_STREAM = 11, /** Filler data */ FILLER_DATA = 12, /** Sequence parameter set extension */ SPS_EXT = 13, /** Prefix NAL unit */ PREFIX_NALU = 14, /** Subset sequence parameter set */ SUBSET_SPS = 15, /** Depth parameter set */ DPS = 16, // 17, 18 reserved /** Coded slice of an auxiliary coded picture without partitioning */ SLICE_AUX = 19, /** Coded slice extension */ SLICE_EXT = 20, /** Coded slice extension for a depth view component or a 3D-AVC texture view component */ SLICE_LAYER_EXT = 21, // 22, 23 reserved } /** * we use a magic frame trailer to detect whether a frame is injected * by the livekit server and thus to be treated as unencrypted * @internal */ export function isFrameServerInjected(frameData: ArrayBuffer, trailerBytes: Uint8Array): boolean { if (trailerBytes.byteLength === 0) { return false; } const frameTrailer = new Uint8Array( frameData.slice(frameData.byteLength - trailerBytes.byteLength), ); return trailerBytes.every((value, index) => value === frameTrailer[index]); }