From 4d6ba0efb936cc3e53a507c4d9c41f020eb98347 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Fri, 10 Apr 2026 10:49:47 +0200 Subject: [PATCH 1/3] feat(client): add E2EE support via WebRTC Encoded Transforms Add end-to-end encryption for media tracks using a symmetric XOR transform applied to every encoded frame in a dedicated Web Worker. Uses RTCRtpScriptTransform (W3C standard) on browsers that support it (Safari, Firefox) and falls back to Insertable Streams (createEncodedStreams) on Chrome where RTCRtpScriptTransform is unreliable. --- packages/client/src/rtc/BasePeerConnection.ts | 9 +- packages/client/src/rtc/Publisher.ts | 10 ++ packages/client/src/rtc/Subscriber.ts | 11 ++ .../src/rtc/__tests__/Publisher.test.ts | 45 ++++++++ .../src/rtc/__tests__/Subscriber.test.ts | 32 ++++++ .../src/rtc/__tests__/mocks/webrtc.mocks.ts | 30 +++++ packages/client/src/rtc/e2ee/index.ts | 105 ++++++++++++++++++ packages/client/src/types.ts | 6 + .../react-dogfood/lib/queryConfigParams.ts | 3 + 9 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 packages/client/src/rtc/e2ee/index.ts 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..7af1c15891 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 { createEncryptor, supportsE2EE } from './e2ee'; import { isAudioTrackType } from './helpers/tracks'; import { extractMid, removeCodecsExcept, setStartBitrate } from './helpers/sdp'; import { withoutConcurrency } from '../helpers/concurrency'; @@ -137,6 +138,15 @@ 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()) { + createEncryptor(transceiver.sender, encryptionKey); + 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..0b3fed4717 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 { createDecryptor, supportsE2EE } from './e2ee'; import { toTrackType, trackTypeToParticipantStreamKey } from './helpers/tracks'; import { enableStereo, removeCodecsExcept } from './helpers/sdp'; @@ -93,6 +94,16 @@ export class Subscriber extends BasePeerConnection { this.state.removeOrphanedTrack(primaryStream.id); }); + const { encryptionKey } = this.clientPublishOptions || {}; + if (encryptionKey) { + if (supportsE2EE()) { + 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..e09fdcbe30 100644 --- a/packages/client/src/rtc/__tests__/Subscriber.test.ts +++ b/packages/client/src/rtc/__tests__/Subscriber.test.ts @@ -214,6 +214,38 @@ describe('Subscriber', () => { expect(baseTrack.stop).toHaveBeenCalled(); expect(baseStream.removeTrack).toHaveBeenCalledWith(baseTrack); }); + + it('should attach a decryptor when an encryption key is provided', () => { + 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 }); + + 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/index.ts b/packages/client/src/rtc/e2ee/index.ts new file mode 100644 index 0000000000..81d17b3d78 --- /dev/null +++ b/packages/client/src/rtc/e2ee/index.ts @@ -0,0 +1,105 @@ +/** + * E2EE via WebRTC Encoded Transforms. + * + * Uses RTCRtpScriptTransform (W3C standard) when available, + * falls back to Insertable Streams (createEncodedStreams) on Chrome + * where RTCRtpScriptTransform support is incomplete. + * + * @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 + */ + +import { isChrome } from '../../helpers/browsers'; + +/** + * Checks whether the browser supports Encoded Transforms for E2EE. + */ +export const supportsE2EE = (): boolean => + typeof RTCRtpScriptTransform !== 'undefined' || + (typeof RTCRtpSender !== 'undefined' && + 'createEncodedStreams' in RTCRtpSender.prototype); + +/** + * Chrome exposes RTCRtpScriptTransform, but it doesn't seem to work reliably. + * Use Insertable Streams (createEncodedStreams) there instead. + */ +const shouldUseInsertableStreams = (): boolean => + isChrome() && + typeof RTCRtpSender !== 'undefined' && + 'createEncodedStreams' in RTCRtpSender.prototype; + +const WORKER_SOURCE = ` +'use strict'; + +function xorTransform(key) { + const keyLen = key.length; + return new TransformStream({ + transform(encodedFrame, controller) { + const view = new DataView(encodedFrame.data); + const newData = new ArrayBuffer(encodedFrame.data.byteLength); + const newView = new DataView(newData); + for (let i = 0; i < encodedFrame.data.byteLength; ++i) { + newView.setInt8(i, view.getInt8(i) ^ key.charCodeAt(i % keyLen)); + } + encodedFrame.data = newData; + controller.enqueue(encodedFrame); + }, + }); +} + +function handleTransform({ readable, writable, key }) { + readable.pipeThrough(xorTransform(key)).pipeTo(writable); +} + +// Standard path: RTCRtpScriptTransform dispatches this event. +if (self.RTCTransformEvent) { + self.onrtctransform = ({ transformer: { readable, writable, options } }) => { + handleTransform({ readable, writable, key: options.key }); + }; +} + +// Insertable Streams path: main thread posts readable/writable via message. +self.onmessage = ({ data }) => handleTransform(data); +`; + +/** Tracks senders/receivers that already have encoded streams piped. */ +let piped: Set | undefined; +let worker: Worker | undefined; +let workerUrl: string | undefined; + +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; +}; + +const attachTransform = ( + target: RTCRtpSender | RTCRtpReceiver, + key: string, + operation: 'encode' | 'decode', +) => { + const w = getWorker(); + if (!shouldUseInsertableStreams()) { + target.transform = new RTCRtpScriptTransform(w, { operation, key }); + return; + } + + if ((piped ??= new Set()).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 }, [readable, writable]); +}; + +export const createEncryptor = (sender: RTCRtpSender, key: string) => + attachTransform(sender, key, 'encode'); + +export const createDecryptor = (receiver: RTCRtpReceiver, key: string) => + attachTransform(receiver, key, 'decode'); 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; From 138501a16125ab477d96897331374efc94bcd23d Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Fri, 10 Apr 2026 15:04:28 +0200 Subject: [PATCH 2/3] feat: support more codecs --- packages/client/src/rtc/Publisher.ts | 6 +- packages/client/src/rtc/e2ee/index.ts | 235 +++++++++++++++++++++++--- 2 files changed, 218 insertions(+), 23 deletions(-) diff --git a/packages/client/src/rtc/Publisher.ts b/packages/client/src/rtc/Publisher.ts index 7af1c15891..23e6197571 100644 --- a/packages/client/src/rtc/Publisher.ts +++ b/packages/client/src/rtc/Publisher.ts @@ -141,7 +141,11 @@ export class Publisher extends BasePeerConnection { const { encryptionKey } = this.clientPublishOptions || {}; if (encryptionKey) { if (supportsE2EE()) { - createEncryptor(transceiver.sender, encryptionKey); + 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`); diff --git a/packages/client/src/rtc/e2ee/index.ts b/packages/client/src/rtc/e2ee/index.ts index 81d17b3d78..b52d3ce38f 100644 --- a/packages/client/src/rtc/e2ee/index.ts +++ b/packages/client/src/rtc/e2ee/index.ts @@ -5,6 +5,19 @@ * falls back to Insertable Streams (createEncodedStreams) on Chrome * where RTCRtpScriptTransform support is incomplete. * + * 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 */ @@ -31,35 +44,202 @@ const shouldUseInsertableStreams = (): boolean => const WORKER_SOURCE = ` 'use strict'; -function xorTransform(key) { +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(encodedFrame, controller) { - const view = new DataView(encodedFrame.data); - const newData = new ArrayBuffer(encodedFrame.data.byteLength); - const newView = new DataView(newData); - for (let i = 0; i < encodedFrame.data.byteLength; ++i) { - newView.setInt8(i, view.getInt8(i) ^ key.charCodeAt(i % keyLen)); + 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; } - encodedFrame.data = newData; - controller.enqueue(encodedFrame); + + controller.enqueue(frame); }, }); } -function handleTransform({ readable, writable, key }) { - readable.pipeThrough(xorTransform(key)).pipeTo(writable); +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); + }, + }); } -// Standard path: RTCRtpScriptTransform dispatches this event. -if (self.RTCTransformEvent) { - self.onrtctransform = ({ transformer: { readable, writable, options } }) => { - handleTransform({ readable, writable, key: options.key }); - }; +function handleTransform({ readable, writable, key, operation, codec }) { + const transform = operation === 'encode' + ? encodeTransform(key, codec) + : decodeTransform(key); + readable.pipeThrough(transform).pipeTo(writable); } -// Insertable Streams path: main thread posts readable/writable via message. -self.onmessage = ({ data }) => handleTransform(data); +addEventListener('rtctransform', ({ transformer: { readable, writable, options } }) => { + handleTransform({ readable, writable, ...options }); +}); + +addEventListener('message', ({ data }) => handleTransform(data)); `; /** Tracks senders/receivers that already have encoded streams piped. */ @@ -84,10 +264,15 @@ const attachTransform = ( target: RTCRtpSender | RTCRtpReceiver, key: string, operation: 'encode' | 'decode', + codec?: string, ) => { const w = getWorker(); if (!shouldUseInsertableStreams()) { - target.transform = new RTCRtpScriptTransform(w, { operation, key }); + target.transform = new RTCRtpScriptTransform(w, { + operation, + key, + codec, + }); return; } @@ -95,11 +280,17 @@ const attachTransform = ( piped.add(target); // @ts-expect-error createEncodedStreams is not in the standard typedefs const { readable, writable } = target.createEncodedStreams(); - w.postMessage({ operation, readable, writable, key }, [readable, writable]); + w.postMessage({ operation, readable, writable, key, codec }, [ + readable, + writable, + ]); }; -export const createEncryptor = (sender: RTCRtpSender, key: string) => - attachTransform(sender, key, 'encode'); +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'); From 86148a33f161764f905d454a5aa1eba282a29281 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Fri, 10 Apr 2026 16:46:45 +0200 Subject: [PATCH 3/3] refactor(client): code-split E2EE into a separate browser chunk Split the monolithic e2ee/index.ts into three files: - compatibility.ts: supportsE2EE() stays in the main bundle - worker.ts: worker source string + lifecycle management - e2ee.ts: createEncryptor/createDecryptor API Publisher and Subscriber now dynamically import the heavy E2EE code, so consumers who don't use E2EE pay zero bundle cost. The browser build emits a separate e2ee chunk; node builds inline it. --- packages/client/rollup.config.mjs | 5 +- packages/client/src/rtc/Publisher.ts | 3 +- packages/client/src/rtc/Subscriber.ts | 8 ++- .../src/rtc/__tests__/Subscriber.test.ts | 5 +- packages/client/src/rtc/e2ee/compatibility.ts | 7 ++ packages/client/src/rtc/e2ee/e2ee.ts | 49 ++++++++++++++ .../src/rtc/e2ee/{index.ts => worker.ts} | 65 +------------------ 7 files changed, 74 insertions(+), 68 deletions(-) create mode 100644 packages/client/src/rtc/e2ee/compatibility.ts create mode 100644 packages/client/src/rtc/e2ee/e2ee.ts rename packages/client/src/rtc/e2ee/{index.ts => worker.ts} (80%) 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/Publisher.ts b/packages/client/src/rtc/Publisher.ts index 23e6197571..0cfe780a0d 100644 --- a/packages/client/src/rtc/Publisher.ts +++ b/packages/client/src/rtc/Publisher.ts @@ -20,7 +20,7 @@ import { toVideoLayers, } from './layers'; import { isSvcCodec } from './codecs'; -import { createEncryptor, supportsE2EE } from './e2ee'; +import { supportsE2EE } from './e2ee/compatibility'; import { isAudioTrackType } from './helpers/tracks'; import { extractMid, removeCodecsExcept, setStartBitrate } from './helpers/sdp'; import { withoutConcurrency } from '../helpers/concurrency'; @@ -141,6 +141,7 @@ export class Publisher extends BasePeerConnection { const { encryptionKey } = this.clientPublishOptions || {}; if (encryptionKey) { if (supportsE2EE()) { + const { createEncryptor } = await import('./e2ee/e2ee'); createEncryptor( transceiver.sender, encryptionKey, diff --git a/packages/client/src/rtc/Subscriber.ts b/packages/client/src/rtc/Subscriber.ts index 0b3fed4717..2cc0630e00 100644 --- a/packages/client/src/rtc/Subscriber.ts +++ b/packages/client/src/rtc/Subscriber.ts @@ -3,7 +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 { createDecryptor, supportsE2EE } from './e2ee'; +import { supportsE2EE } from './e2ee/compatibility'; import { toTrackType, trackTypeToParticipantStreamKey } from './helpers/tracks'; import { enableStereo, removeCodecsExcept } from './helpers/sdp'; @@ -97,8 +97,10 @@ export class Subscriber extends BasePeerConnection { const { encryptionKey } = this.clientPublishOptions || {}; if (encryptionKey) { if (supportsE2EE()) { - createDecryptor(e.receiver, encryptionKey); - this.logger.debug('E2EE decryptor attached to receiver'); + 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`); } diff --git a/packages/client/src/rtc/__tests__/Subscriber.test.ts b/packages/client/src/rtc/__tests__/Subscriber.test.ts index e09fdcbe30..dfe825f5d6 100644 --- a/packages/client/src/rtc/__tests__/Subscriber.test.ts +++ b/packages/client/src/rtc/__tests__/Subscriber.test.ts @@ -215,7 +215,7 @@ describe('Subscriber', () => { expect(baseStream.removeTrack).toHaveBeenCalledWith(baseTrack); }); - it('should attach a decryptor when an encryption key is provided', () => { + it('should attach a decryptor when an encryption key is provided', async () => { subscriber.dispose(); subscriber = new Subscriber({ sfuClient, @@ -239,6 +239,9 @@ describe('Subscriber', () => { // @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', 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/index.ts b/packages/client/src/rtc/e2ee/worker.ts similarity index 80% rename from packages/client/src/rtc/e2ee/index.ts rename to packages/client/src/rtc/e2ee/worker.ts index b52d3ce38f..c05bff639a 100644 --- a/packages/client/src/rtc/e2ee/index.ts +++ b/packages/client/src/rtc/e2ee/worker.ts @@ -1,9 +1,7 @@ /** - * E2EE via WebRTC Encoded Transforms. + * E2EE worker source and lifecycle management. * - * Uses RTCRtpScriptTransform (W3C standard) when available, - * falls back to Insertable Streams (createEncodedStreams) on Chrome - * where RTCRtpScriptTransform support is incomplete. + * 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: @@ -22,25 +20,6 @@ * @see https://github.com/webrtc/samples/blob/gh-pages/src/content/insertable-streams/endtoend-encryption/js/worker.js */ -import { isChrome } from '../../helpers/browsers'; - -/** - * Checks whether the browser supports Encoded Transforms for E2EE. - */ -export const supportsE2EE = (): boolean => - typeof RTCRtpScriptTransform !== 'undefined' || - (typeof RTCRtpSender !== 'undefined' && - 'createEncodedStreams' in RTCRtpSender.prototype); - -/** - * Chrome exposes RTCRtpScriptTransform, but it doesn't seem to work reliably. - * Use Insertable Streams (createEncodedStreams) there instead. - */ -const shouldUseInsertableStreams = (): boolean => - isChrome() && - typeof RTCRtpSender !== 'undefined' && - 'createEncodedStreams' in RTCRtpSender.prototype; - const WORKER_SOURCE = ` 'use strict'; @@ -126,7 +105,6 @@ function rbspUnescape(data) { return result.subarray(0, j); } - function getClearByteCount(codec, frameType, data) { if (frameType === undefined) return 1; // audio if (codec === 'vp8') return frameType === 'key' ? 10 : 3; @@ -242,12 +220,10 @@ addEventListener('rtctransform', ({ transformer: { readable, writable, options } addEventListener('message', ({ data }) => handleTransform(data)); `; -/** Tracks senders/receivers that already have encoded streams piped. */ -let piped: Set | undefined; let worker: Worker | undefined; let workerUrl: string | undefined; -const getWorker = () => { +export const getWorker = () => { if (!worker) { if (!workerUrl) { const blob = new Blob([WORKER_SOURCE], { @@ -259,38 +235,3 @@ const getWorker = () => { } return worker; }; - -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 Set()).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');