diff --git a/packages/client/rollup.config.mjs b/packages/client/rollup.config.mjs index 4c270910e5..5d62cc2f15 100644 --- a/packages/client/rollup.config.mjs +++ b/packages/client/rollup.config.mjs @@ -30,9 +30,11 @@ const external = [ const browserConfig = { input: 'index.ts', output: { - file: 'dist/index.browser.es.js', + dir: 'dist', format: 'esm', sourcemap: true, + entryFileNames: 'index.browser.es.js', + chunkFileNames: '[name].browser.es.js', }, external: external.filter((dep) => !browserIgnoredModules.includes(dep)), plugins: [ @@ -55,6 +57,7 @@ const createNodeConfig = (outputFile, format) => ({ file: outputFile, format: format, sourcemap: true, + inlineDynamicImports: true, }, external, plugins: [ diff --git a/packages/client/src/rtc/BasePeerConnection.ts b/packages/client/src/rtc/BasePeerConnection.ts index 8cff2f0388..51d2ff0603 100644 --- a/packages/client/src/rtc/BasePeerConnection.ts +++ b/packages/client/src/rtc/BasePeerConnection.ts @@ -11,6 +11,7 @@ import { import { NegotiationError } from './NegotiationError'; import { StreamSfuClient } from '../StreamSfuClient'; import { AllSfuEvents, Dispatcher } from './Dispatcher'; +import { isChrome } from '../helpers/browsers'; import { withoutConcurrency } from '../helpers/concurrency'; import { StatsTracer, Tracer, traceRTCPeerConnection } from '../stats'; import type { BasePeerConnectionOpts, OnReconnectionNeeded } from './types'; @@ -89,7 +90,13 @@ export abstract class BasePeerConnection { } private createPeerConnection = (connectionConfig?: RTCConfiguration) => { - const pc = new RTCPeerConnection(connectionConfig); + const config: RTCConfiguration = { ...connectionConfig }; + // Chrome needs encodedInsertableStreams for the Insertable Streams path. + if (this.clientPublishOptions?.encryptionKey && isChrome()) { + // @ts-expect-error not part of the standard lib yet + config.encodedInsertableStreams = true; + } + const pc = new RTCPeerConnection(config); pc.addEventListener('icecandidate', this.onIceCandidate); pc.addEventListener('icecandidateerror', this.onIceCandidateError); pc.addEventListener( diff --git a/packages/client/src/rtc/Publisher.ts b/packages/client/src/rtc/Publisher.ts index cbcc8461d7..0cfe780a0d 100644 --- a/packages/client/src/rtc/Publisher.ts +++ b/packages/client/src/rtc/Publisher.ts @@ -20,6 +20,7 @@ import { toVideoLayers, } from './layers'; import { isSvcCodec } from './codecs'; +import { supportsE2EE } from './e2ee/compatibility'; import { isAudioTrackType } from './helpers/tracks'; import { extractMid, removeCodecsExcept, setStartBitrate } from './helpers/sdp'; import { withoutConcurrency } from '../helpers/concurrency'; @@ -137,6 +138,20 @@ export class Publisher extends BasePeerConnection { const params = transceiver.sender.getParameters(); params.degradationPreference = 'maintain-framerate'; await transceiver.sender.setParameters(params); + const { encryptionKey } = this.clientPublishOptions || {}; + if (encryptionKey) { + if (supportsE2EE()) { + const { createEncryptor } = await import('./e2ee/e2ee'); + createEncryptor( + transceiver.sender, + encryptionKey, + publishOption.codec?.name.toLowerCase(), + ); + this.logger.debug('E2EE encryptor attached to sender'); + } else { + this.logger.warn(`E2EE requested but not supported`); + } + } const trackType = publishOption.trackType; this.logger.debug(`Added ${TrackType[trackType]} transceiver`); diff --git a/packages/client/src/rtc/Subscriber.ts b/packages/client/src/rtc/Subscriber.ts index ed6c01824e..2cc0630e00 100644 --- a/packages/client/src/rtc/Subscriber.ts +++ b/packages/client/src/rtc/Subscriber.ts @@ -3,6 +3,7 @@ import { BasePeerConnectionOpts } from './types'; import { NegotiationError } from './NegotiationError'; import { PeerType } from '../gen/video/sfu/models/models'; import { SubscriberOffer } from '../gen/video/sfu/event/events'; +import { supportsE2EE } from './e2ee/compatibility'; import { toTrackType, trackTypeToParticipantStreamKey } from './helpers/tracks'; import { enableStereo, removeCodecsExcept } from './helpers/sdp'; @@ -93,6 +94,18 @@ export class Subscriber extends BasePeerConnection { this.state.removeOrphanedTrack(primaryStream.id); }); + const { encryptionKey } = this.clientPublishOptions || {}; + if (encryptionKey) { + if (supportsE2EE()) { + import('./e2ee/e2ee').then(({ createDecryptor }) => { + createDecryptor(e.receiver, encryptionKey); + this.logger.debug('E2EE decryptor attached to receiver'); + }); + } else { + this.logger.warn(`E2EE requested but not supported`); + } + } + const trackType = toTrackType(rawTrackType); if (!trackType) { return this.logger.error(`Unknown track type: ${rawTrackType}`); diff --git a/packages/client/src/rtc/__tests__/Publisher.test.ts b/packages/client/src/rtc/__tests__/Publisher.test.ts index bb88e4a046..b802ac8bbf 100644 --- a/packages/client/src/rtc/__tests__/Publisher.test.ts +++ b/packages/client/src/rtc/__tests__/Publisher.test.ts @@ -133,6 +133,51 @@ describe('Publisher', () => { expect(negotiateSpy).toHaveBeenCalled(); }); + it('should attach an encryptor when an encryption key is provided', async () => { + publisher.dispose(); + publisher = new Publisher( + { + sfuClient, + dispatcher, + state, + tag: 'test', + enableTracing: false, + clientPublishOptions: { + encryptionKey: 'shared-secret', + }, + }, + [ + { + id: 1, + trackType: TrackType.VIDEO, + bitrate: 1000, + // @ts-expect-error - incomplete data + codec: { name: 'vp9' }, + fps: 30, + maxTemporalLayers: 3, + maxSpatialLayers: 3, + }, + ], + ); + + const track = new MediaStreamTrack(); + const clone = new MediaStreamTrack(); + vi.spyOn(track, 'clone').mockReturnValue(clone); + // @ts-expect-error - private method + vi.spyOn(publisher, 'negotiate').mockResolvedValue(); + + await publisher.publish(track, TrackType.VIDEO); + + const transceiver = vi.mocked(publisher['pc'].addTransceiver).mock + .results[0]?.value; + expect(transceiver.sender.transform).toMatchObject({ + options: expect.objectContaining({ + operation: 'encode', + key: 'shared-secret', + }), + }); + }); + it('should update an existing transceiver for a new track', async () => { const track = new MediaStreamTrack(); const clone = new MediaStreamTrack(); diff --git a/packages/client/src/rtc/__tests__/Subscriber.test.ts b/packages/client/src/rtc/__tests__/Subscriber.test.ts index 1a4f9e3781..dfe825f5d6 100644 --- a/packages/client/src/rtc/__tests__/Subscriber.test.ts +++ b/packages/client/src/rtc/__tests__/Subscriber.test.ts @@ -214,6 +214,41 @@ describe('Subscriber', () => { expect(baseTrack.stop).toHaveBeenCalled(); expect(baseStream.removeTrack).toHaveBeenCalledWith(baseTrack); }); + + it('should attach a decryptor when an encryption key is provided', async () => { + subscriber.dispose(); + subscriber = new Subscriber({ + sfuClient, + dispatcher, + state, + connectionConfig: { iceServers: [] }, + tag: 'test', + enableTracing: true, + clientPublishOptions: { + encryptionKey: 'shared-secret', + }, + }); + + const mediaStream = new MediaStream(); + const mediaStreamTrack = new MediaStreamTrack(); + const receiver = { transform: null }; + // @ts-expect-error - mock + mediaStream.id = '123:TRACK_TYPE_VIDEO'; + + const onTrack = subscriber['handleOnTrack']; + // @ts-expect-error - incomplete mock + onTrack({ streams: [mediaStream], track: mediaStreamTrack, receiver }); + + // decryptor is attached via dynamic import, flush the microtask queue + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(receiver.transform).toMatchObject({ + options: expect.objectContaining({ + operation: 'decode', + key: 'shared-secret', + }), + }); + }); }); describe('Negotiation', () => { diff --git a/packages/client/src/rtc/__tests__/mocks/webrtc.mocks.ts b/packages/client/src/rtc/__tests__/mocks/webrtc.mocks.ts index 1ccb98f412..4227ecd4b7 100644 --- a/packages/client/src/rtc/__tests__/mocks/webrtc.mocks.ts +++ b/packages/client/src/rtc/__tests__/mocks/webrtc.mocks.ts @@ -57,6 +57,7 @@ const RTCRtpTransceiverMock = vi.fn((): Partial => { replaceTrack: vi.fn(), getParameters: vi.fn().mockReturnValue({}), setParameters: vi.fn(), + transform: null, }, setCodecPreferences: vi.fn(), mid: '', @@ -79,6 +80,7 @@ const RTCRtpReceiverMock = vi.fn((): Partial => { getCapabilities: vi.fn(), }; }); +RTCRtpReceiverMock.prototype.transform = null; vi.stubGlobal('RTCRtpReceiver', RTCRtpReceiverMock); const RTCRtpSenderMock = vi.fn((): Partial => { @@ -88,8 +90,36 @@ const RTCRtpSenderMock = vi.fn((): Partial => { track: vi.fn(), }; }); +RTCRtpSenderMock.prototype.transform = null; vi.stubGlobal('RTCRtpSender', RTCRtpSenderMock); +const WorkerMock = vi.fn((): Partial => { + return { + postMessage: vi.fn(), + terminate: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; +}); +vi.stubGlobal('Worker', WorkerMock); + +const RTCRtpScriptTransformMock = vi.fn( + (worker: Worker, options?: unknown): Partial => { + return { + worker, + options, + }; + }, +); +vi.stubGlobal('RTCRtpScriptTransform', RTCRtpScriptTransformMock); + +if (typeof URL !== 'undefined' && typeof URL.createObjectURL !== 'function') { + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn(() => 'blob:stream-video-e2ee'), + }); +} + const AudioContextMock = vi.fn((): Partial => { return { state: 'suspended', diff --git a/packages/client/src/rtc/e2ee/compatibility.ts b/packages/client/src/rtc/e2ee/compatibility.ts new file mode 100644 index 0000000000..872258c28b --- /dev/null +++ b/packages/client/src/rtc/e2ee/compatibility.ts @@ -0,0 +1,7 @@ +/** + * Checks whether the browser supports Encoded Transforms for E2EE. + */ +export const supportsE2EE = (): boolean => + typeof RTCRtpScriptTransform !== 'undefined' || + (typeof RTCRtpSender !== 'undefined' && + 'createEncodedStreams' in RTCRtpSender.prototype); diff --git a/packages/client/src/rtc/e2ee/e2ee.ts b/packages/client/src/rtc/e2ee/e2ee.ts new file mode 100644 index 0000000000..52d2ed75df --- /dev/null +++ b/packages/client/src/rtc/e2ee/e2ee.ts @@ -0,0 +1,49 @@ +import { getWorker } from './worker'; + +/** + * Chrome exposes RTCRtpScriptTransform, but it doesn't seem to work reliably. + * Use Insertable Streams (createEncodedStreams) there instead. + */ +const shouldUseInsertableStreams = (): boolean => + typeof navigator !== 'undefined' && + navigator.userAgent?.includes('Chrome') && + typeof RTCRtpSender !== 'undefined' && + 'createEncodedStreams' in RTCRtpSender.prototype; + +/** Tracks senders/receivers that already have encoded streams piped. */ +let piped: WeakSet | undefined; + +const attachTransform = ( + target: RTCRtpSender | RTCRtpReceiver, + key: string, + operation: 'encode' | 'decode', + codec?: string, +) => { + const w = getWorker(); + if (!shouldUseInsertableStreams()) { + target.transform = new RTCRtpScriptTransform(w, { + operation, + key, + codec, + }); + return; + } + + if ((piped ??= new WeakSet()).has(target)) return; + piped.add(target); + // @ts-expect-error createEncodedStreams is not in the standard typedefs + const { readable, writable } = target.createEncodedStreams(); + w.postMessage({ operation, readable, writable, key, codec }, [ + readable, + writable, + ]); +}; + +export const createEncryptor = ( + sender: RTCRtpSender, + key: string, + codec?: string, +) => attachTransform(sender, key, 'encode', codec); + +export const createDecryptor = (receiver: RTCRtpReceiver, key: string) => + attachTransform(receiver, key, 'decode'); diff --git a/packages/client/src/rtc/e2ee/worker.ts b/packages/client/src/rtc/e2ee/worker.ts new file mode 100644 index 0000000000..c05bff639a --- /dev/null +++ b/packages/client/src/rtc/e2ee/worker.ts @@ -0,0 +1,237 @@ +/** + * E2EE worker source and lifecycle management. + * + * The worker handles frame encryption/decryption using WebRTC Encoded Transforms. + * + * Codec-specific clear-byte rules preserve frame headers so the SFU + * can still detect keyframes and select layers: + * - Audio (Opus): 1 byte clear + * - VP8: 10 bytes (keyframe) / 3 bytes (delta) + * - VP9: 0 bytes (descriptor is in RTP header) + * - H264: NALU-aware — clear up to first slice NALU start + 2, then + * RBSP-escape the encrypted tail to prevent fake start codes + * - AV1: not supported (frames pass through unencrypted) + * + * Encrypted frames carry a 5-byte trailer: [1 byte offset][4 bytes 0xDEADBEEF]. + * The decoder reads the trailer to know how many clear bytes were used, + * making decryption codec-agnostic. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Using_Encoded_Transforms + * @see https://github.com/webrtc/samples/blob/gh-pages/src/content/insertable-streams/endtoend-encryption/js/worker.js + */ + +const WORKER_SOURCE = ` +'use strict'; + +const CHECKSUM = 0xDEADBEEF; +const TRAILER_LEN = 5; // 1 byte offset + 4 bytes checksum +const RBSP_FLAG = 0x80; // bit 7 of the offset byte signals RBSP escaping + +function findStartCode(data, offset) { + for (let i = offset; i < data.length - 2; ++i) { + if (data[i] === 0 && data[i + 1] === 0) { + if (data[i + 2] === 1) return { pos: i, len: 3 }; + if (data[i + 2] === 0 && i + 3 < data.length && data[i + 3] === 1) { + return { pos: i, len: 4 }; + } + } + } + return null; +} + +// Returns clear-byte count for H264: everything up to the first slice +// NALU's start index + 2 (start code + NALU header + 1 byte of slice header). +// Slice NALUs: type 1 (non-IDR) and type 5 (IDR). +function h264ClearBytes(data) { + let sc = findStartCode(data, 0); + while (sc) { + const headerPos = sc.pos + sc.len; + if (headerPos >= data.length) break; + const naluType = data[headerPos] & 0x1F; + if (naluType === 1 || naluType === 5) { + return sc.pos + sc.len + 2; + } + sc = findStartCode(data, headerPos); + } + return 0; +} + +// Insert emulation-prevention bytes (0x03) after 0x00 0x00 when followed +// by 0x00–0x03, preventing fake Annex B start codes in encrypted data. +function rbspEscape(data) { + let extra = 0; + for (let i = 0; i < data.length - 2; ++i) { + if (data[i] === 0 && data[i + 1] === 0 && data[i + 2] <= 3) { + extra++; + i++; + } + } + if (extra === 0) return data; + const result = new Uint8Array(data.length + extra); + let j = 0; + for (let i = 0; i < data.length; ++i) { + result[j++] = data[i]; + if (i < data.length - 2 && data[i] === 0 && data[i + 1] === 0 && data[i + 2] <= 3) { + result[j++] = data[++i]; // copy second 0x00 + result[j++] = 3; // insert emulation-prevention byte + } + } + return result.subarray(0, j); +} + +// Reverse of rbspEscape: strip 0x03 after 0x00 0x00 before 0x00–0x03. +function rbspUnescape(data) { + let remove = 0; + for (let i = 0; i < data.length - 2; ++i) { + if (data[i] === 0 && data[i + 1] === 0 && data[i + 2] === 3 + && i + 3 < data.length && data[i + 3] <= 3) { + remove++; + i += 2; + } + } + if (remove === 0) return data; + const result = new Uint8Array(data.length - remove); + let j = 0; + for (let i = 0; i < data.length; ++i) { + if (i < data.length - 2 && data[i] === 0 && data[i + 1] === 0 && data[i + 2] === 3 + && i + 3 < data.length && data[i + 3] <= 3) { + result[j++] = 0; + result[j++] = 0; + i += 2; // skip 00 00 03 + continue; + } + result[j++] = data[i]; + } + return result.subarray(0, j); +} + +function getClearByteCount(codec, frameType, data) { + if (frameType === undefined) return 1; // audio + if (codec === 'vp8') return frameType === 'key' ? 10 : 3; + if (codec === 'h264') return h264ClearBytes(data); + return 0; // VP9 / others +} + +function xorPayload(src, dst, offset, key, keyLen, len) { + for (let i = 0; i < len; ++i) { + dst[i] = src[i] ^ key.charCodeAt((offset + i) % keyLen); + } +} + +function encodeTransform(key, codec) { + const keyLen = key.length; + const isNalu = codec === 'h264'; + + return new TransformStream({ + transform(frame, controller) { + // https://groups.google.com/g/discuss-webrtc/c/5CMOZ4JtERo + // https://issues.chromium.org/issues/40287616 + if (codec === 'av1') { + controller.enqueue(frame); + return; + } + + const src = new Uint8Array(frame.data); + const clearBytes = getClearByteCount(codec, frame.type, src); + + if (isNalu && clearBytes > 0) { + // Encrypt the payload portion + const encrypted = new Uint8Array(src.length - clearBytes); + xorPayload(src.subarray(clearBytes), encrypted, clearBytes, key, keyLen, encrypted.length); + + // RBSP-escape to prevent fake start codes + const escaped = rbspEscape(encrypted); + + // Assemble: [clear header][escaped payload][trailer] + const dst = new Uint8Array(clearBytes + escaped.length + TRAILER_LEN); + dst.set(src.subarray(0, clearBytes), 0); + dst.set(escaped, clearBytes); + dst[dst.length - TRAILER_LEN] = clearBytes | RBSP_FLAG; + new DataView(dst.buffer).setUint32(dst.length - 4, CHECKSUM); + frame.data = dst.buffer; + } else { + // Standard path: single-pass XOR, no RBSP escaping + const dst = new Uint8Array(src.length + TRAILER_LEN); + for (let i = 0; i < src.length; ++i) { + dst[i] = i < clearBytes ? src[i] : src[i] ^ key.charCodeAt(i % keyLen); + } + dst[src.length] = clearBytes; + new DataView(dst.buffer).setUint32(src.length + 1, CHECKSUM); + frame.data = dst.buffer; + } + + controller.enqueue(frame); + }, + }); +} + +function decodeTransform(key) { + const keyLen = key.length; + return new TransformStream({ + transform(frame, controller) { + const src = new Uint8Array(frame.data); + + if (src.length > TRAILER_LEN) { + const view = new DataView(src.buffer, src.byteOffset, src.byteLength); + if (view.getUint32(src.length - 4) === CHECKSUM) { + const raw = src[src.length - TRAILER_LEN]; + const isRbsp = (raw & RBSP_FLAG) !== 0; + const clearBytes = raw & 0x7F; + const bodyEnd = src.length - TRAILER_LEN; + + if (isRbsp) { + // NALU path: unescape, then decrypt + const unescaped = rbspUnescape(src.subarray(clearBytes, bodyEnd)); + const dst = new Uint8Array(clearBytes + unescaped.length); + dst.set(src.subarray(0, clearBytes), 0); + xorPayload(unescaped, dst.subarray(clearBytes), clearBytes, key, keyLen, unescaped.length); + frame.data = dst.buffer; + } else { + // Standard path + const dst = new Uint8Array(bodyEnd); + for (let i = 0; i < bodyEnd; ++i) { + dst[i] = i < clearBytes ? src[i] : src[i] ^ key.charCodeAt(i % keyLen); + } + frame.data = dst.buffer; + } + + controller.enqueue(frame); + return; + } + } + + // No checksum — unencrypted frame, pass through + controller.enqueue(frame); + }, + }); +} + +function handleTransform({ readable, writable, key, operation, codec }) { + const transform = operation === 'encode' + ? encodeTransform(key, codec) + : decodeTransform(key); + readable.pipeThrough(transform).pipeTo(writable); +} + +addEventListener('rtctransform', ({ transformer: { readable, writable, options } }) => { + handleTransform({ readable, writable, ...options }); +}); + +addEventListener('message', ({ data }) => handleTransform(data)); +`; + +let worker: Worker | undefined; +let workerUrl: string | undefined; + +export const getWorker = () => { + if (!worker) { + if (!workerUrl) { + const blob = new Blob([WORKER_SOURCE], { + type: 'application/javascript', + }); + workerUrl = URL.createObjectURL(blob); + } + worker = new Worker(workerUrl, { name: 'stream-video-e2ee' }); + } + return worker; +}; diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 4c544ac1bb..a9bb6625d8 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -236,6 +236,12 @@ export type ClientPublishOptions = { * For example, if the max bitrate is 1500kbps and the start bitrate factor is 0.5, the start bitrate will be 750kbps. */ dangerouslySetStartBitrateFactor?: number; + + /** + * Optional E2EE key to use for published media tracks in browsers that + * support WebRTC encoded transforms. + */ + encryptionKey?: string; }; export type ScreenShareSettings = { diff --git a/sample-apps/react/react-dogfood/lib/queryConfigParams.ts b/sample-apps/react/react-dogfood/lib/queryConfigParams.ts index 8cbbfba403..d830d7ed25 100644 --- a/sample-apps/react/react-dogfood/lib/queryConfigParams.ts +++ b/sample-apps/react/react-dogfood/lib/queryConfigParams.ts @@ -16,6 +16,7 @@ export const getQueryConfigParams = (query: NextRouter['query']) => { forceCodec: query['force_codec'] as PreferredCodec | undefined, cameraOverride: query['camera'] as string | undefined, microphoneOverride: query['mic'] as string | undefined, + encryptionKey: query['encryption_key'] as string | undefined, }; }; @@ -34,6 +35,7 @@ export const applyQueryConfigParams = ( maxSimulcastLayers, cameraOverride, microphoneOverride, + encryptionKey, } = config; if (cameraOverride != null) { @@ -74,6 +76,7 @@ export const applyQueryConfigParams = ( maxSimulcastLayers: maxSimulcastLayers ? parseInt(maxSimulcastLayers, 10) : undefined, + encryptionKey, }); return config;