diff --git a/.github/workflows/deploy-react-sample-apps.yml b/.github/workflows/deploy-react-sample-apps.yml index a31240cd79..11de4c0df1 100644 --- a/.github/workflows/deploy-react-sample-apps.yml +++ b/.github/workflows/deploy-react-sample-apps.yml @@ -58,6 +58,8 @@ jobs: project-id: prj_uNJTw7DefSAntAoWCXwJaHc1khoA - name: audio-rooms project-id: prj_0WnHcvVkXpM4PRc2ymVmrAHFILoT + - name: e2ee-demo - https://stream-e2ee-demo.vercel.app + project-id: prj_fdQ1cP6fTIyXkjmoKfLhNnzANF4M - name: react-dogfood - https://pronto.getstream.io project-id: prj_4TTdjeVHEDhWWiFRfjIr1QFb5ell - name: react-dogfood-staging - https://pronto-staging.getstream.io diff --git a/packages/client/index.ts b/packages/client/index.ts index 55f9b6d1d1..1a98e3f21a 100644 --- a/packages/client/index.ts +++ b/packages/client/index.ts @@ -23,6 +23,8 @@ export * from './src/helpers/DynascaleManager'; export * from './src/helpers/ViewportTracker'; export * from './src/helpers/sound-detector'; export * from './src/helpers/participantUtils'; +export * from './src/rtc/e2ee/E2EEManager'; +export * from './src/rtc/e2ee/EncryptionManager'; export * as Browsers from './src/helpers/browsers'; export * from './src/logger'; diff --git a/packages/client/plugins/rollup-plugin-inline-worker.mts b/packages/client/plugins/rollup-plugin-inline-worker.mts new file mode 100644 index 0000000000..e84ffd4cb0 --- /dev/null +++ b/packages/client/plugins/rollup-plugin-inline-worker.mts @@ -0,0 +1,68 @@ +import { rollup, type Plugin } from 'rollup'; +import typescript from '@rollup/plugin-typescript'; +import { format, resolveConfig } from 'prettier'; +import { dirname, resolve } from 'path'; + +interface InlineWorkerOptions { + /** File names (not paths) that trigger the plugin, e.g. `['worker.ts']`. */ + include: string[]; +} + +/** + * Rollup plugin that bundles worker TypeScript files into inline functions. + * + * Only files whose path ends with one of the provided `include` patterns + * are processed — all other modules are skipped with zero overhead. + * + * For each matched file, the plugin: + * 1. Finds the corresponding `-impl.ts` entry (e.g. `worker.ts` → `worker/worker-impl.ts`) + * 2. Bundles it with a nested Rollup + TypeScript build + * 3. Wraps the result in an exported function and formats with prettier + * + * The consumer creates a Worker from the function via: + * `new Worker(\`data:text/javascript,(\${fn.toString()})()\`)` + * or via a Blob URL. + */ +export default function inlineWorker({ include }: InlineWorkerOptions): Plugin { + const fileNames = new Set(include); + + return { + name: 'inline-worker', + + async load(id: string) { + const fileName = id.split('/').pop(); + if (!fileNames.has(fileName!)) return null; + + // e2ee-worker.ts → e2ee-worker/e2ee-worker-impl.ts + const dir = dirname(id); + const name = fileName!.replace(/\.ts$/, ''); + const implPath = resolve(dir, name, `${name}-impl.ts`); + + const bundle = await rollup({ + input: implPath, + plugins: [ + typescript({ + tsconfig: resolve(dir, name, 'tsconfig.json'), + exclude: ['**/node_modules/**', '**/__tests__/**'], + }), + ], + }); + + const { output } = await bundle.generate({ + format: 'es', + indent: false, + sourcemap: false, + }); + await bundle.close(); + + // Wrap bundled code in an exported function, then format with prettier. + return await format( + `export function e2eeWorker() { ${output[0].code} }`, + { + parser: 'babel', + ...(await resolveConfig(implPath)), + }, + ); + }, + }; +} diff --git a/packages/client/rollup.config.mjs b/packages/client/rollup.config.mjs index 4c270910e5..96678db012 100644 --- a/packages/client/rollup.config.mjs +++ b/packages/client/rollup.config.mjs @@ -1,5 +1,6 @@ import typescript from '@rollup/plugin-typescript'; import replace from '@rollup/plugin-replace'; +import inlineWorker from './plugins/rollup-plugin-inline-worker.mts'; import pkg from './package.json' with { type: 'json' }; @@ -30,12 +31,15 @@ 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: [ + inlineWorker({ include: ['e2ee-worker.ts'] }), replace({ preventAssignment: true, 'process.env.PKG_VERSION': JSON.stringify(pkg.version), @@ -55,6 +59,7 @@ const createNodeConfig = (outputFile, format) => ({ file: outputFile, format: format, sourcemap: true, + inlineDynamicImports: true, }, external, plugins: [ diff --git a/packages/client/src/Call.ts b/packages/client/src/Call.ts index 90a90bbed8..788a98187c 100644 --- a/packages/client/src/Call.ts +++ b/packages/client/src/Call.ts @@ -15,6 +15,7 @@ import { TrackPublishOptions, trackTypeToParticipantStreamKey, } from './rtc'; +import type { E2EEManager } from './rtc/e2ee/E2EEManager'; import { registerEventHandlers, registerRingingCallEventHandlers, @@ -30,7 +31,7 @@ import { getCurrentValue, } from './store/rxUtils'; import { ScopedLogger, videoLoggerSystem } from './logger'; -import type { +import { AcceptCallResponse, BlockUserRequest, BlockUserResponse, @@ -76,8 +77,8 @@ import type { RingCallResponse, SendCallEventRequest, SendCallEventResponse, - SendReactionRequest, - SendReactionResponse, + SendVideoReactionRequest, + SendVideoReactionResponse, StartClosedCaptionsRequest, StartClosedCaptionsResponse, StartFrameRecordingRequest, @@ -109,6 +110,8 @@ import type { UpdateCallRequest, UpdateCallResponse, UpdateUserPermissionsRequest, + UpdateUserPermissionsRequestGrantPermissionsEnum, + UpdateUserPermissionsRequestRevokePermissionsEnum, UpdateUserPermissionsResponse, } from './gen/coordinator'; import { OwnCapability } from './gen/coordinator'; @@ -266,6 +269,7 @@ export class Call { subscriber?: Subscriber; publisher?: Publisher; + e2eeManager?: E2EEManager; /** * Flag telling whether this call is a "ringing" call. @@ -1542,6 +1546,7 @@ export class Call { this.sfuStatsReporter = undefined; if (closePreviousInstances && this.subscriber) { await this.subscriber.dispose(); + this.state.removeAllOrphanedTracks(); } const basePeerConnectionOptions: BasePeerConnectionOpts = { sfuClient, @@ -1552,6 +1557,7 @@ export class Call { enableTracing, statsTimestampDriftThresholdMs: reportingIntervalMs / 2, clientPublishOptions: this.clientPublishOptions, + e2ee: this.e2eeManager, onReconnectionNeeded: (kind, reason, peerType) => { this.reconnect(kind, reason).catch((err) => { const message = `[Reconnect] Error reconnecting, after a ${PeerType[peerType]} error: ${reason}`; @@ -1628,12 +1634,12 @@ export class Call { * Retrieves credentials for joining the call. * * @internal - * * @param data the join call data. */ doJoinRequest = async (data?: JoinCallData): Promise => { const location = await this.streamClient.getLocationHint(); - const request: JoinCallRequest = { ...data, location }; + const e2ee = !!this.e2eeManager; + const request: JoinCallRequest = { ...data, location, e2ee }; const joinResponse = await this.streamClient.post< JoinCallResponse, JoinCallRequest @@ -2355,6 +2361,31 @@ export class Call { this.clientPublishOptions = { ...this.clientPublishOptions, ...options }; }; + /** + * Set the E2EE (end-to-end encryption) manager for this call. + * + * Must be called before {@link join} so the RTCPeerConnection can be + * configured for E2EE. + * + * The manager is kept across {@link leave} so a rejoin of this same instance + * stays encrypted: do not dispose it while this call may be joined again. + * A disposed manager throws from `encrypt`/`decrypt` rather than silently + * publishing nothing, so re-attach a fresh one instead of reusing it. + * + * @param e2ee - Any `E2EEManager`. Use `EncryptionManager.create()` for the + * built-in AES-GCM scheme, or pass your own implementation. + * @throws if called after the peer connections have been built (i.e. after + * `join`): those PCs were already configured without an encryptor, so + * adopting a manager now would silently publish/receive cleartext for + * the live session. + */ + setE2EEManager = (e2ee: E2EEManager) => { + if (this.publisher || this.subscriber) { + throw new Error('setE2EEManager must be called before join()'); + } + this.e2eeManager = e2ee; + }; + /** * Notifies the SFU that a noise cancellation process has started. * @@ -2453,9 +2484,9 @@ export class Call { * @param reaction the reaction to send. */ sendReaction = async ( - reaction: SendReactionRequest, - ): Promise => { - return this.streamClient.post( + reaction: SendVideoReactionRequest, + ): Promise => { + return this.streamClient.post( `${this.streamClientBasePath}/reaction`, reaction, ); @@ -2673,7 +2704,7 @@ export class Call { ): Promise => { const { permissions } = data; const canRequestPermissions = permissions.every((permission) => - this.permissionsContext.canRequest(permission as OwnCapability), + this.permissionsContext.canRequest(permission), ); if (!canRequestPermissions) { throw new Error( @@ -2698,10 +2729,14 @@ export class Call { * @param userId the id of the user to grant permissions to. * @param permissions the permissions to grant. */ - grantPermissions = async (userId: string, permissions: string[]) => { + grantPermissions = async ( + userId: string, + permissions: string[] | UpdateUserPermissionsRequestGrantPermissionsEnum[], + ) => { return this.updateUserPermissions({ user_id: userId, - grant_permissions: permissions, + grant_permissions: + permissions as UpdateUserPermissionsRequestGrantPermissionsEnum[], }); }; @@ -2717,10 +2752,14 @@ export class Call { * @param userId the id of the user to revoke permissions from. * @param permissions the permissions to revoke. */ - revokePermissions = async (userId: string, permissions: string[]) => { + revokePermissions = async ( + userId: string, + permissions: string[] | UpdateUserPermissionsRequestRevokePermissionsEnum[], + ) => { return this.updateUserPermissions({ user_id: userId, - revoke_permissions: permissions, + revoke_permissions: + permissions as UpdateUserPermissionsRequestRevokePermissionsEnum[], }); }; diff --git a/packages/client/src/__tests__/Call.test.ts b/packages/client/src/__tests__/Call.test.ts index 4590bd7d4f..4cc50f50a2 100644 --- a/packages/client/src/__tests__/Call.test.ts +++ b/packages/client/src/__tests__/Call.test.ts @@ -17,6 +17,7 @@ import { Dispatcher } from '../rtc'; import { Call } from '../Call'; import { StreamVideoParticipant } from '../types'; import { TrackType } from '../gen/video/sfu/models/models'; +import type { E2EEManager } from '../rtc/e2ee/E2EEManager'; const apiKey = process.env.STREAM_API_KEY!; const secret = process.env.STREAM_SECRET!; @@ -300,6 +301,20 @@ describe('muting logic', () => { }); }); +describe('setE2EEManager', () => { + it('throws when called after the peer connections exist', () => { + const call = client.call('default', generateUUIDv4()); + const e2ee = fromPartial({}); + // Before join there are no peer connections, so it is accepted. + expect(() => call.setE2EEManager(e2ee)).not.toThrow(); + // Once a subscriber/publisher exists (i.e. after join) the PCs were already + // built without the manager; setting it now would leave the live session + // half-encrypted, so it must be rejected (finding 12). + call['subscriber'] = fromPartial({}); + expect(() => call.setE2EEManager(e2ee)).toThrow(/before join/); + }); +}); + describe('client event reporting', () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/packages/client/src/coordinator/connection/connection.ts b/packages/client/src/coordinator/connection/connection.ts index e2a963af4c..42191f3c37 100644 --- a/packages/client/src/coordinator/connection/connection.ts +++ b/packages/client/src/coordinator/connection/connection.ts @@ -9,10 +9,11 @@ import { } from './utils'; import type { StreamVideoEvent, UR, WSConnectionError } from './types'; import type { LogLevel } from '@stream-io/logger'; -import type { +import { ConnectedEvent, ConnectionErrorEvent, WSAuthMessage, + WSAuthMessageProductsEnum, } from '../../gen/coordinator'; import { makeSafePromise, type SafePromise } from '../../helpers/promise'; import { getTimers } from '../../timers'; @@ -529,15 +530,17 @@ export class StableWSConnection { return; } - const authMessage = JSON.stringify({ + const wsAuthMessage: WSAuthMessage = { token, + products: [WSAuthMessageProductsEnum.VIDEO], user_details: { id: user.id, name: user.name, image: user.image, custom: user.custom, }, - } as WSAuthMessage); + }; + const authMessage = JSON.stringify(wsAuthMessage); this._log(`onopen() - Sending auth message ${authMessage}`, {}, 'trace'); diff --git a/packages/client/src/events/__tests__/participant.test.ts b/packages/client/src/events/__tests__/participant.test.ts index 7b4d5a8c70..b23c43f795 100644 --- a/packages/client/src/events/__tests__/participant.test.ts +++ b/packages/client/src/events/__tests__/participant.test.ts @@ -1,5 +1,5 @@ import '../../rtc/__tests__/mocks/webrtc.mocks'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { CallState } from '../../store'; import { VisibilityState } from '../../types'; import { TrackType } from '../../gen/video/sfu/models/models'; @@ -192,6 +192,139 @@ describe('Participant events', () => { expect(p?.screenShareStream).toBe(mediaStream); expect(state.takeOrphanedTracks('track-lookup-prefix')).toHaveLength(0); }); + + it('participantJoined should attach E2EE decryptor to orphaned track receiver', () => { + const state = new CallState(); + const mediaStream = new MediaStream(); + const receiver = {} as RTCRtpReceiver; + const e2ee = { decrypt: vi.fn() }; + state.registerOrphanedTrack({ + trackLookupPrefix: 'track-lookup-prefix', + trackType: TrackType.VIDEO, + track: mediaStream, + id: mediaStream.id, + receiver, + }); + // @ts-expect-error partial mock + const onParticipantJoined = watchParticipantJoined(state, () => e2ee); + onParticipantJoined({ + // @ts-expect-error incomplete data + participant: { + userId: 'user-id', + sessionId: 'session-id', + trackLookupPrefix: 'track-lookup-prefix', + }, + }); + + expect(e2ee.decrypt).toHaveBeenCalledWith(receiver, 'user-id', 'VIDEO'); + }); + + it('trackPublished should attach E2EE decryptor to orphaned track receiver', () => { + const state = new CallState(); + const mediaStream = new MediaStream(); + const receiver = {} as RTCRtpReceiver; + const e2ee = { decrypt: vi.fn() }; + state.registerOrphanedTrack({ + trackLookupPrefix: 'track-lookup-prefix', + trackType: TrackType.AUDIO, + track: mediaStream, + id: mediaStream.id, + receiver, + }); + // @ts-expect-error partial mock + const onTrackPublished = watchTrackPublished(state, () => e2ee); + onTrackPublished({ + // @ts-expect-error incomplete data + participant: { + userId: 'user-id', + sessionId: 'session-id', + trackLookupPrefix: 'track-lookup-prefix', + }, + }); + + expect(e2ee.decrypt).toHaveBeenCalledWith(receiver, 'user-id', 'AUDIO'); + }); + + it('trackUnpublished should attach E2EE decryptor to orphaned track receiver', () => { + const state = new CallState(); + const mediaStream = new MediaStream(); + const receiver = {} as RTCRtpReceiver; + const e2ee = { decrypt: vi.fn() }; + state.registerOrphanedTrack({ + trackLookupPrefix: 'track-lookup-prefix', + trackType: TrackType.SCREEN_SHARE, + track: mediaStream, + id: mediaStream.id, + receiver, + }); + // @ts-expect-error partial mock + const onTrackUnPublished = watchTrackUnpublished(state, () => e2ee); + onTrackUnPublished({ + // @ts-expect-error incomplete data + participant: { + userId: 'user-id', + sessionId: 'session-id', + trackLookupPrefix: 'track-lookup-prefix', + }, + }); + + expect(e2ee.decrypt).toHaveBeenCalledWith( + receiver, + 'user-id', + 'SCREEN_SHARE', + ); + }); + + it('should not call decrypt when no E2EE manager is provided', () => { + const state = new CallState(); + const mediaStream = new MediaStream(); + const receiver = {} as RTCRtpReceiver; + state.registerOrphanedTrack({ + trackLookupPrefix: 'track-lookup-prefix', + trackType: TrackType.VIDEO, + track: mediaStream, + id: mediaStream.id, + receiver, + }); + const onParticipantJoined = watchParticipantJoined(state); + onParticipantJoined({ + // @ts-expect-error incomplete data + participant: { + userId: 'user-id', + sessionId: 'session-id', + trackLookupPrefix: 'track-lookup-prefix', + }, + }); + + const p = state.findParticipantBySessionId('session-id'); + expect(p?.videoStream).toBe(mediaStream); + }); + + it('should not call decrypt when orphaned track has no receiver', () => { + const state = new CallState(); + const mediaStream = new MediaStream(); + const e2ee = { decrypt: vi.fn() }; + state.registerOrphanedTrack({ + trackLookupPrefix: 'track-lookup-prefix', + trackType: TrackType.VIDEO, + track: mediaStream, + id: mediaStream.id, + }); + // @ts-expect-error partial mock + const onParticipantJoined = watchParticipantJoined(state, () => e2ee); + onParticipantJoined({ + // @ts-expect-error incomplete data + participant: { + userId: 'user-id', + sessionId: 'session-id', + trackLookupPrefix: 'track-lookup-prefix', + }, + }); + + expect(e2ee.decrypt).not.toHaveBeenCalled(); + const p = state.findParticipantBySessionId('session-id'); + expect(p?.videoStream).toBe(mediaStream); + }); }); describe('trackPublished', () => { diff --git a/packages/client/src/events/callEventHandlers.ts b/packages/client/src/events/callEventHandlers.ts index 5433af7aef..e4a008e359 100644 --- a/packages/client/src/events/callEventHandlers.ts +++ b/packages/client/src/events/callEventHandlers.ts @@ -40,6 +40,12 @@ type RingCallEvents = Extract< */ export const registerEventHandlers = (call: Call, dispatcher: Dispatcher) => { const state = call.state; + // Read lazily on each event: setE2EEManager can run after setup() (an app that + // inspects call settings via get()/getOrCreate() before deciding to encrypt has + // already triggered it), so a value captured here would be a stale undefined + // for the whole call and orphaned tracks would never get a decryptor. + const e2ee = () => call.e2eeManager; + const eventHandlers = [ call.on('call.ended', watchCallEnded(call)), watchSfuCallEnded(call), @@ -49,12 +55,12 @@ export const registerEventHandlers = (call: Call, dispatcher: Dispatcher) => { watchConnectionQualityChanged(dispatcher, state), watchParticipantCountChanged(dispatcher, state), - call.on('participantJoined', watchParticipantJoined(state)), + call.on('participantJoined', watchParticipantJoined(state, e2ee)), call.on('participantLeft', watchParticipantLeft(state)), call.on('participantUpdated', watchParticipantUpdated(state)), - call.on('trackPublished', watchTrackPublished(state)), - call.on('trackUnpublished', watchTrackUnpublished(state)), + call.on('trackPublished', watchTrackPublished(state, e2ee)), + call.on('trackUnpublished', watchTrackUnpublished(state, e2ee)), watchAudioLevelChanged(dispatcher, state), watchDominantSpeakerChanged(dispatcher, state), diff --git a/packages/client/src/events/participant.ts b/packages/client/src/events/participant.ts index b927446fec..fb794f643d 100644 --- a/packages/client/src/events/participant.ts +++ b/packages/client/src/events/participant.ts @@ -5,7 +5,7 @@ import type { TrackPublished, TrackUnpublished, } from '../gen/video/sfu/event/events'; -import type { Participant } from '../gen/video/sfu/models/models'; +import { type Participant, TrackType } from '../gen/video/sfu/models/models'; import { StreamVideoParticipant, StreamVideoParticipantPatch, @@ -14,11 +14,21 @@ import { import { CallState } from '../store'; import { trackTypeToParticipantStreamKey } from '../rtc'; import { pushToIfMissing } from '../helpers/array'; +import type { E2EEManager } from '../rtc/e2ee/E2EEManager'; + +/** + * Reads the call's current encryption manager. Deliberately not the manager + * itself: `Call.setE2EEManager` may be called after these handlers are wired up. + */ +type GetE2EEManager = () => E2EEManager | undefined; /** * An event responder which handles the `participantJoined` event. */ -export const watchParticipantJoined = (state: CallState) => { +export const watchParticipantJoined = ( + state: CallState, + e2ee: GetE2EEManager | undefined = undefined, +) => { return function onParticipantJoined(e: ParticipantJoined) { const { participant } = e; if (!participant) return; @@ -30,7 +40,7 @@ export const watchParticipantJoined = (state: CallState) => { // The SFU would send participant info as part of the `join` // response and then follow up with a `participantJoined` event for // already announced participants. - const orphanedTracks = reconcileOrphanedTracks(state, participant); + const orphanedTracks = reconcileOrphanedTracks(state, participant, e2ee); state.updateOrAddParticipant( participant.sessionId, Object.assign< @@ -77,7 +87,10 @@ export const watchParticipantUpdated = (state: CallState) => { * An event responder which handles the `trackPublished` event. * The SFU will send this event when a participant publishes a track. */ -export const watchTrackPublished = (state: CallState) => { +export const watchTrackPublished = ( + state: CallState, + e2ee: GetE2EEManager | undefined = undefined, +) => { return function onTrackPublished(e: TrackPublished) { const { type, sessionId } = e; // An optimization for large calls. @@ -85,7 +98,11 @@ export const watchTrackPublished = (state: CallState) => { // events, and instead, it would only provide the participant's information // once they start publishing a track. if (e.participant) { - const orphanedTracks = reconcileOrphanedTracks(state, e.participant); + const orphanedTracks = reconcileOrphanedTracks( + state, + e.participant, + e2ee, + ); const participant = Object.assign(e.participant, orphanedTracks); state.updateOrAddParticipant(sessionId, participant); } else { @@ -100,12 +117,19 @@ export const watchTrackPublished = (state: CallState) => { * An event responder which handles the `trackUnpublished` event. * The SFU will send this event when a participant unpublishes a track. */ -export const watchTrackUnpublished = (state: CallState) => { +export const watchTrackUnpublished = ( + state: CallState, + e2ee: GetE2EEManager | undefined = undefined, +) => { return function onTrackUnpublished(e: TrackUnpublished) { const { type, sessionId } = e; // An optimization for large calls. See `watchTrackPublished`. if (e.participant) { - const orphanedTracks = reconcileOrphanedTracks(state, e.participant); + const orphanedTracks = reconcileOrphanedTracks( + state, + e.participant, + e2ee, + ); const participant = Object.assign(e.participant, orphanedTracks); state.updateOrAddParticipant(sessionId, participant, (p) => ({ pausedTracks: p.pausedTracks?.filter((t) => t !== type), @@ -124,18 +148,29 @@ export const watchTrackUnpublished = (state: CallState) => { * * @param state the call state. * @param participant the participant. + * @param e2ee accessor for the call's encryption manager, if any. */ const reconcileOrphanedTracks = ( state: CallState, participant: Participant, + e2ee: GetE2EEManager | undefined, ): StreamVideoParticipantPatch | undefined => { const orphanTracks = state.takeOrphanedTracks(participant.trackLookupPrefix); if (!orphanTracks.length) return; + const manager = e2ee?.(); const reconciledTracks: StreamVideoParticipantPatch = {}; for (const orphan of orphanTracks) { const key = trackTypeToParticipantStreamKey(orphan.trackType); if (!key) continue; reconciledTracks[key] = orphan.track; + + if (manager && orphan.receiver) { + manager.decrypt( + orphan.receiver, + participant.userId, + TrackType[orphan.trackType], + ); + } } return reconciledTracks; }; diff --git a/packages/client/src/gen/coordinator/index.ts b/packages/client/src/gen/coordinator/index.ts index 467dbd8b70..87f1a99f27 100644 --- a/packages/client/src/gen/coordinator/index.ts +++ b/packages/client/src/gen/coordinator/index.ts @@ -663,6 +663,12 @@ export interface CallEndedEvent { * @memberof CallEndedEvent */ created_at: string; + /** + * The list of members in the call + * @type {Array} + * @memberof CallEndedEvent + */ + members?: Array; /** * The reason why the call ended, if available * @type {string} @@ -966,6 +972,37 @@ export interface CallIngressResponse { */ whip: WHIPIngress; } +/** + * + * @export + * @interface CallLevelEventPayload + */ +export interface CallLevelEventPayload { + /** + * + * @type {string} + * @memberof CallLevelEventPayload + */ + event_type: string; + /** + * + * @type {{ [key: string]: any; }} + * @memberof CallLevelEventPayload + */ + payload?: { [key: string]: any }; + /** + * + * @type {number} + * @memberof CallLevelEventPayload + */ + timestamp: number; + /** + * + * @type {string} + * @memberof CallLevelEventPayload + */ + user_id: string; +} /** * This event is sent when a call is started. Clients receiving this event should start the call. * @export @@ -1443,10 +1480,10 @@ export interface CallReactionEvent { created_at: string; /** * - * @type {ReactionResponse} + * @type {VideoReactionResponse} * @memberof CallReactionEvent */ - reaction: ReactionResponse; + reaction: VideoReactionResponse; /** * The type of event: "call.reaction_new" in this case * @type {string} @@ -1912,6 +1949,12 @@ export interface CallResponse { * @memberof CallResponse */ recording: boolean; + /** + * 10-digit routing number for SIP routing + * @type {string} + * @memberof CallResponse + */ + routing_number?: string; /** * * @type {CallSessionResponse} @@ -2299,12 +2342,6 @@ export interface CallSessionResponse { * @memberof CallSessionResponse */ anonymous_participant_count: number; - /** - * - * @type {string} - * @memberof CallSessionResponse - */ - created_at: string; /** * * @type {string} @@ -2427,6 +2464,12 @@ export interface CallSettingsRequest { * @memberof CallSettingsRequest */ broadcasting?: BroadcastSettingsRequest; + /** + * + * @type {EncryptionSettingsRequest} + * @memberof CallSettingsRequest + */ + encryption?: EncryptionSettingsRequest; /** * * @type {FrameRecordingSettingsRequest} @@ -2530,6 +2573,12 @@ export interface CallSettingsResponse { * @memberof CallSettingsResponse */ broadcasting: BroadcastSettingsResponse; + /** + * + * @type {EncryptionSettingsResponse} + * @memberof CallSettingsResponse + */ + encryption: EncryptionSettingsResponse; /** * * @type {FrameRecordingSettingsResponse} @@ -2894,12 +2943,54 @@ export interface CallStatsParticipant { * @interface CallStatsParticipantCounts */ export interface CallStatsParticipantCounts { + /** + * + * @type {number} + * @memberof CallStatsParticipantCounts + */ + average_jitter_ms?: number; + /** + * + * @type {number} + * @memberof CallStatsParticipantCounts + */ + average_latency_ms?: number; + /** + * + * @type {number} + * @memberof CallStatsParticipantCounts + */ + avg_user_rating?: number; + /** + * + * @type {number} + * @memberof CallStatsParticipantCounts + */ + call_event_count?: number; + /** + * + * @type {number} + * @memberof CallStatsParticipantCounts + */ + cq_score?: number; /** * * @type {number} * @memberof CallStatsParticipantCounts */ live_sessions: number; + /** + * + * @type {number} + * @memberof CallStatsParticipantCounts + */ + max_freezes_duration_ms?: number; + /** + * + * @type {number} + * @memberof CallStatsParticipantCounts + */ + min_user_rating?: number; /** * * @type {number} @@ -2930,6 +3021,18 @@ export interface CallStatsParticipantCounts { * @memberof CallStatsParticipantCounts */ sessions: number; + /** + * + * @type {number} + * @memberof CallStatsParticipantCounts + */ + sfus_used: number; + /** + * + * @type {number} + * @memberof CallStatsParticipantCounts + */ + total_participant_duration?: number; } /** * @@ -2979,12 +3082,36 @@ export interface CallStatsParticipantSession { * @memberof CallStatsParticipantSession */ ended_at?: string; + /** + * + * @type {number} + * @memberof CallStatsParticipantSession + */ + freezes_duration_ms?: number; + /** + * + * @type {string} + * @memberof CallStatsParticipantSession + */ + ingress?: string; /** * * @type {boolean} * @memberof CallStatsParticipantSession */ is_live: boolean; + /** + * + * @type {number} + * @memberof CallStatsParticipantSession + */ + jitter_ms?: number; + /** + * + * @type {number} + * @memberof CallStatsParticipantSession + */ + latency_ms?: number; /** * * @type {CallStatsLocation} @@ -3058,12 +3185,30 @@ export interface CallStatsReportReadyEvent { * @memberof CallStatsReportReadyEvent */ call_cid: string; + /** + * + * @type {CallStatsParticipantCounts} + * @memberof CallStatsReportReadyEvent + */ + counts: CallStatsParticipantCounts; /** * * @type {string} * @memberof CallStatsReportReadyEvent */ created_at: string; + /** + * Whether participants_overview is truncated by the server-side limit + * @type {boolean} + * @memberof CallStatsReportReadyEvent + */ + is_trimmed?: boolean; + /** + * Top participant sessions overview + * @type {Array} + * @memberof CallStatsReportReadyEvent + */ + participants_overview?: Array; /** * Call session ID * @type {string} @@ -3132,6 +3277,55 @@ export interface CallStatsReportSummaryResponse { */ quality_score?: number; } +/** + * + * @export + * @interface CallStatsSessionResponse + */ +export interface CallStatsSessionResponse { + /** + * + * @type {string} + * @memberof CallStatsSessionResponse + */ + call_ended_at?: string; + /** + * + * @type {string} + * @memberof CallStatsSessionResponse + */ + call_id: string; + /** + * + * @type {string} + * @memberof CallStatsSessionResponse + */ + call_session_id: string; + /** + * + * @type {string} + * @memberof CallStatsSessionResponse + */ + call_started_at?: string; + /** + * + * @type {string} + * @memberof CallStatsSessionResponse + */ + call_type: string; + /** + * + * @type {CallStatsParticipantCounts} + * @memberof CallStatsSessionResponse + */ + counts: CallStatsParticipantCounts; + /** + * + * @type {string} + * @memberof CallStatsSessionResponse + */ + generated_at: string; +} /** * CallTranscription represents a transcription of a call. * @export @@ -3493,147 +3687,365 @@ export interface ChatActivityStatsResponse { Messages?: MessageStatsResponse; } /** - * This event is sent when closed captions are being sent in a call, clients should use this to show the closed captions in the call screen + * * @export - * @interface ClosedCaptionEvent + * @interface ChatPreferencesResponse */ -export interface ClosedCaptionEvent { +export interface ChatPreferencesResponse { /** * * @type {string} - * @memberof ClosedCaptionEvent - */ - call_cid: string; - /** - * - * @type {CallClosedCaption} - * @memberof ClosedCaptionEvent + * @memberof ChatPreferencesResponse */ - closed_caption: CallClosedCaption; + channel_mentions?: string; /** * * @type {string} - * @memberof ClosedCaptionEvent - */ - created_at: string; - /** - * The type of event: "call.closed_caption" in this case - * @type {string} - * @memberof ClosedCaptionEvent - */ - type: string; -} -/** - * - * @export - * @interface CollectUserFeedbackRequest - */ -export interface CollectUserFeedbackRequest { - /** - * - * @type {{ [key: string]: any; }} - * @memberof CollectUserFeedbackRequest + * @memberof ChatPreferencesResponse */ - custom?: { [key: string]: any }; + default_preference?: string; /** * - * @type {number} - * @memberof CollectUserFeedbackRequest + * @type {string} + * @memberof ChatPreferencesResponse */ - rating: number; + direct_mentions?: string; /** * * @type {string} - * @memberof CollectUserFeedbackRequest + * @memberof ChatPreferencesResponse */ - reason?: string; + group_mentions?: string; /** * * @type {string} - * @memberof CollectUserFeedbackRequest + * @memberof ChatPreferencesResponse */ - sdk: string; + here_mentions?: string; /** * * @type {string} - * @memberof CollectUserFeedbackRequest + * @memberof ChatPreferencesResponse */ - sdk_version: string; + role_mentions?: string; /** * * @type {string} - * @memberof CollectUserFeedbackRequest + * @memberof ChatPreferencesResponse */ - user_session_id?: string; + thread_replies?: string; } /** - * Basic response information + * A single client-side telemetry event. JoinInitiated is the top-level marker emitted when a user begins a join attempt and carries only join_attempt_id (no stage_id or coordinator_connect_id). When stage is CoordinatorJoin, CoordinatorWS, WSJoin, or PeerConnectionConnect the event reports a join-lifecycle attempt; initiation and completion of a stage attempt share the same stage_id. FirstAudioFrame and FirstVideoFrame report media readiness and only ever carry an initiated event. MediaDevicePermission reports the result of requesting screen-share, microphone, and camera permissions. Other stage values denote generic client events. * @export - * @interface CollectUserFeedbackResponse + * @interface ClientEvent */ -export interface CollectUserFeedbackResponse { +export interface ClientEvent { /** - * Duration of the request in milliseconds + * Call session ID associated with the attempt. Required on every event except CoordinatorJoin initiation and CoordinatorJoin failure (where the call session is not yet established); optional on MediaDevicePermission. * @type {string} - * @memberof CollectUserFeedbackResponse + * @memberof ClientEvent */ - duration: string; -} -/** - * - * @export - * @interface CompositeRecordingResponse - */ -export interface CompositeRecordingResponse { + call_session_id?: string; /** - * + * Camera permission status: INITIATED, FAILED, GRANTED, or NOT_INITIATED. Required on every MediaDevicePermission event. * @type {string} - * @memberof CompositeRecordingResponse + * @memberof ClientEvent */ - status: string; -} -/** - * - * @export - * @interface ConnectUserDetailsRequest - */ -export interface ConnectUserDetailsRequest { + camera_permission_status?: string; /** - * - * @type {{ [key: string]: any; }} - * @memberof ConnectUserDetailsRequest + * UUID generated by the client and shared across every event of the same coordinator connection. Required on every event except JoinInitiated, which is reported before a coordinator connection exists. + * @type {string} + * @memberof ClientEvent */ - custom?: { [key: string]: any }; + coordinator_connect_id?: string; /** - * - * @type {string} - * @memberof ConnectUserDetailsRequest + * Milliseconds elapsed between the stage attempt's initiation and this event. + * @type {number} + * @memberof ClientEvent */ - id: string; + elapsed_time?: number; /** - * + * Whether the event marks the start (initiated) or resolution (completed) of a stage attempt, or another event-specific value * @type {string} - * @memberof ConnectUserDetailsRequest + * @memberof ClientEvent */ - image?: string; + event_type?: string; /** - * - * @type {boolean} - * @memberof ConnectUserDetailsRequest + * Terminal state of the peer connection. Required on PeerConnectionConnect failure. + * @type {string} + * @memberof ClientEvent */ - invisible?: boolean; + ice_state?: string; /** - * + * Call ID associated with the event. Required on every stage except CoordinatorWS, where it is optional. * @type {string} - * @memberof ConnectUserDetailsRequest + * @memberof ClientEvent */ - language?: string; + id?: string; /** - * + * UUID generated by the client and shared across JoinInitiated and the join-lifecycle events (CoordinatorJoin, WSJoin, PeerConnectionConnect) of the same overall join attempt. Required on every join event except CoordinatorWS, which is reported before a join attempt is established. * @type {string} - * @memberof ConnectUserDetailsRequest + * @memberof ClientEvent */ - name?: string; + join_attempt_id?: string; + /** + * Reason the client initiated the join. Optional on CoordinatorJoin events; empty when not provided. + * @type {string} + * @memberof ClientEvent + */ + join_reason?: string; + /** + * Microphone permission status: INITIATED, FAILED, GRANTED, or NOT_INITIATED. Required on every MediaDevicePermission event. + * @type {string} + * @memberof ClientEvent + */ + microphone_permission_status?: string; + /** + * Resolution of a completed event: success or failure. Required on completed join events; forbidden on initiated join events. + * @type {string} + * @memberof ClientEvent + */ + outcome?: string; + /** + * Which peer connection a PeerConnectionConnect event reports on: publish or subscribe. Required on every PeerConnectionConnect event. + * @type {string} + * @memberof ClientEvent + */ + peer_connection?: string; + /** + * UTC timestamp at which the ICE connection was established earlier in the session, when applicable + * @type {string} + * @memberof ClientEvent + */ + previously_connected_timestamp?: string; + /** + * Total in-stage retries the client made before resolving (0–1000). Required on completed join events. + * @type {number} + * @memberof ClientEvent + */ + retry_count_attempt?: number; + /** + * Failure code string. Required on CoordinatorJoin, CoordinatorWS, WSJoin, and PeerConnectionConnect failure. + * @type {string} + * @memberof ClientEvent + */ + retry_failure_code?: string; + /** + * Failure reason string. Required on CoordinatorJoin, CoordinatorWS, WSJoin, and PeerConnectionConnect failure. + * @type {string} + * @memberof ClientEvent + */ + retry_failure_reason?: string; + /** + * Screen-share permission status: INITIATED, FAILED, GRANTED, or NOT_INITIATED. Optional on MediaDevicePermission events. + * @type {string} + * @memberof ClientEvent + */ + screen_share_status?: string; + /** + * Version of the client SDK + * @type {string} + * @memberof ClientEvent + */ + sdk_version?: string; + /** + * Identifier of the SFU the client was attempting to connect to. Required on WSJoin and PeerConnectionConnect failure, and on FirstAudioFrame and FirstVideoFrame. + * @type {string} + * @memberof ClientEvent + */ + sfu_id?: string; + /** + * Discriminator identifying the event kind. JoinInitiated marks the start of a join attempt; join-lifecycle events use CoordinatorJoin, CoordinatorWS, WSJoin, or PeerConnectionConnect; media-readiness events use FirstAudioFrame or FirstVideoFrame; MediaDevicePermission reports device permission results; other values denote generic client events. + * @type {string} + * @memberof ClientEvent + */ + stage?: string; + /** + * UUID generated by the client at initiation. Identical on the matching completion event. Absent on JoinInitiated. + * @type {string} + * @memberof ClientEvent + */ + stage_id?: string; + /** + * UTC timestamp at which the event was recorded + * @type {string} + * @memberof ClientEvent + */ + timestamp?: string; + /** + * Identifier of the media track the frame belongs to. Required on FirstVideoFrame; optional on FirstAudioFrame. + * @type {string} + * @memberof ClientEvent + */ + track_id?: string; + /** + * Call type associated with the event. Required on every stage except CoordinatorWS, where it is optional. + * @type {string} + * @memberof ClientEvent + */ + type?: string; + /** + * User agent string of the client SDK + * @type {string} + * @memberof ClientEvent + */ + user_agent?: string; + /** + * ID of the user the event was recorded for + * @type {string} + * @memberof ClientEvent + */ + user_id?: string; + /** + * Whether the ICE connection had been established earlier in the same session. Required on every PeerConnectionConnect event so reconnects can be distinguished from fresh connects. + * @type {boolean} + * @memberof ClientEvent + */ + was_previously_connected?: boolean; +} +/** + * This event is sent when closed captions are being sent in a call, clients should use this to show the closed captions in the call screen + * @export + * @interface ClosedCaptionEvent + */ +export interface ClosedCaptionEvent { + /** + * + * @type {string} + * @memberof ClosedCaptionEvent + */ + call_cid: string; + /** + * + * @type {CallClosedCaption} + * @memberof ClosedCaptionEvent + */ + closed_caption: CallClosedCaption; + /** + * + * @type {string} + * @memberof ClosedCaptionEvent + */ + created_at: string; + /** + * The type of event: "call.closed_caption" in this case + * @type {string} + * @memberof ClosedCaptionEvent + */ + type: string; +} +/** + * + * @export + * @interface CollectUserFeedbackRequest + */ +export interface CollectUserFeedbackRequest { + /** + * + * @type {{ [key: string]: any; }} + * @memberof CollectUserFeedbackRequest + */ + custom?: { [key: string]: any }; + /** + * + * @type {number} + * @memberof CollectUserFeedbackRequest + */ + rating: number; + /** + * + * @type {string} + * @memberof CollectUserFeedbackRequest + */ + reason?: string; + /** + * + * @type {string} + * @memberof CollectUserFeedbackRequest + */ + sdk: string; + /** + * + * @type {string} + * @memberof CollectUserFeedbackRequest + */ + sdk_version: string; + /** + * + * @type {string} + * @memberof CollectUserFeedbackRequest + */ + user_session_id?: string; +} +/** + * Basic response information + * @export + * @interface CollectUserFeedbackResponse + */ +export interface CollectUserFeedbackResponse { + /** + * Duration of the request in milliseconds + * @type {string} + * @memberof CollectUserFeedbackResponse + */ + duration: string; +} +/** + * + * @export + * @interface CompositeRecordingResponse + */ +export interface CompositeRecordingResponse { + /** + * + * @type {string} + * @memberof CompositeRecordingResponse + */ + status: string; +} +/** + * + * @export + * @interface ConnectUserDetailsRequest + */ +export interface ConnectUserDetailsRequest { + /** + * + * @type {{ [key: string]: any; }} + * @memberof ConnectUserDetailsRequest + */ + custom?: { [key: string]: any }; + /** + * + * @type {string} + * @memberof ConnectUserDetailsRequest + */ + id: string; + /** + * + * @type {string} + * @memberof ConnectUserDetailsRequest + */ + image?: string; + /** + * + * @type {boolean} + * @memberof ConnectUserDetailsRequest + */ + invisible?: boolean; + /** + * + * @type {string} + * @memberof ConnectUserDetailsRequest + */ + language?: string; + /** + * + * @type {string} + * @memberof ConnectUserDetailsRequest + */ + name?: string; /** * * @type {object} @@ -3704,21 +4116,21 @@ export interface ConnectionErrorEvent { type: string; } /** - * + * Geographic coordinates * @export - * @interface Coordinates + * @interface CoordinatesResponse */ -export interface Coordinates { +export interface CoordinatesResponse { /** - * + * Latitude coordinate * @type {number} - * @memberof Coordinates + * @memberof CoordinatesResponse */ latitude: number; /** - * + * Longitude coordinate * @type {number} - * @memberof Coordinates + * @memberof CoordinatesResponse */ longitude: number; } @@ -3747,6 +4159,12 @@ export interface CountByMinuteResponse { * @interface CreateDeviceRequest */ export interface CreateDeviceRequest { + /** + * Stable physical device identifier used to deduplicate pushes across push providers (e.g. APNs VoIP and Firebase on the same iOS device). Distinct from 'id', which is the push token. + * @type {string} + * @memberof CreateDeviceRequest + */ + hardware_id?: string; /** * Device ID * @type {string} @@ -3831,10 +4249,10 @@ export interface CreateGuestResponse { export interface Credentials { /** * - * @type {Array} + * @type {Array} * @memberof Credentials */ - ice_servers: Array; + ice_servers: Array; /** * * @type {SFUResponse} @@ -4087,6 +4505,12 @@ export interface DeviceResponse { * @memberof DeviceResponse */ disabled_reason?: string; + /** + * Stable physical device identifier used to deduplicate pushes across push providers + * @type {string} + * @memberof DeviceResponse + */ + hardware_id?: string; /** * Device ID * @type {string} @@ -4284,6 +4708,56 @@ export interface EgressResponse { */ rtmps: Array; } +/** + * + * @export + * @interface EncryptionSettingsRequest + */ +export interface EncryptionSettingsRequest { + /** + * Encryption mode. One of: available, disabled, auto-on + * @type {string} + * @memberof EncryptionSettingsRequest + */ + mode?: EncryptionSettingsRequestModeEnum; +} + +/** + * @export + */ +export const EncryptionSettingsRequestModeEnum = { + AVAILABLE: 'available', + DISABLED: 'disabled', + AUTO_ON: 'auto-on', +} as const; +export type EncryptionSettingsRequestModeEnum = + (typeof EncryptionSettingsRequestModeEnum)[keyof typeof EncryptionSettingsRequestModeEnum]; + +/** + * EncryptionSettings is the payload for end-to-end encryption settings + * @export + * @interface EncryptionSettingsResponse + */ +export interface EncryptionSettingsResponse { + /** + * the resolved encryption mode for the call + * @type {string} + * @memberof EncryptionSettingsResponse + */ + mode: EncryptionSettingsResponseModeEnum; +} + +/** + * @export + */ +export const EncryptionSettingsResponseModeEnum = { + AVAILABLE: 'available', + DISABLED: 'disabled', + AUTO_ON: 'auto-on', +} as const; +export type EncryptionSettingsResponseModeEnum = + (typeof EncryptionSettingsResponseModeEnum)[keyof typeof EncryptionSettingsResponseModeEnum]; + /** * Response for ending a call * @export @@ -4309,12 +4783,24 @@ export interface FeedsPreferencesResponse { * @memberof FeedsPreferencesResponse */ comment?: string; + /** + * + * @type {string} + * @memberof FeedsPreferencesResponse + */ + comment_mention?: string; /** * * @type {string} * @memberof FeedsPreferencesResponse */ comment_reaction?: string; + /** + * + * @type {string} + * @memberof FeedsPreferencesResponse + */ + comment_reply?: string; /** * * @type {{ [key: string]: string; }} @@ -4503,6 +4989,67 @@ export interface GeofenceSettingsResponse { */ names: Array; } +/** + * Basic response information + * @export + * @interface GetCallParticipantSessionMetricsResponse + */ +export interface GetCallParticipantSessionMetricsResponse { + /** + * + * @type {SessionClient} + * @memberof GetCallParticipantSessionMetricsResponse + */ + client?: SessionClient; + /** + * Duration of the request in milliseconds + * @type {string} + * @memberof GetCallParticipantSessionMetricsResponse + */ + duration: string; + /** + * + * @type {boolean} + * @memberof GetCallParticipantSessionMetricsResponse + */ + is_publisher?: boolean; + /** + * + * @type {boolean} + * @memberof GetCallParticipantSessionMetricsResponse + */ + is_subscriber?: boolean; + /** + * + * @type {string} + * @memberof GetCallParticipantSessionMetricsResponse + */ + joined_at?: string; + /** + * + * @type {Array} + * @memberof GetCallParticipantSessionMetricsResponse + */ + published_tracks?: Array; + /** + * + * @type {string} + * @memberof GetCallParticipantSessionMetricsResponse + */ + publisher_type?: string; + /** + * + * @type {string} + * @memberof GetCallParticipantSessionMetricsResponse + */ + user_id?: string; + /** + * + * @type {string} + * @memberof GetCallParticipantSessionMetricsResponse + */ + user_session_id?: string; +} /** * Basic response information * @export @@ -4855,24 +5402,43 @@ export interface GroupedStatsResponse { */ export interface HLSSettingsRequest { /** - * + * Whether HLS broadcasting should start automatically * @type {boolean} * @memberof HLSSettingsRequest */ auto_on?: boolean; /** - * + * Whether HLS broadcasting is enabled * @type {boolean} * @memberof HLSSettingsRequest */ enabled?: boolean; /** - * + * Quality tracks for HLS. One of: 360p, 480p, 720p, 1080p, 1440p, portrait-360x640, portrait-480x854, portrait-720x1280, portrait-1080x1920, portrait-1440x2560 * @type {Array} * @memberof HLSSettingsRequest */ - quality_tracks: Array; + quality_tracks: Array; } + +/** + * @export + */ +export const HLSSettingsRequestQualityTracksEnum = { + _360P: '360p', + _480P: '480p', + _720P: '720p', + _1080P: '1080p', + _1440P: '1440p', + PORTRAIT_360X640: 'portrait-360x640', + PORTRAIT_480X854: 'portrait-480x854', + PORTRAIT_720X1280: 'portrait-720x1280', + PORTRAIT_1080X1920: 'portrait-1080x1920', + PORTRAIT_1440X2560: 'portrait-1440x2560', +} as const; +export type HLSSettingsRequestQualityTracksEnum = + (typeof HLSSettingsRequestQualityTracksEnum)[keyof typeof HLSSettingsRequestQualityTracksEnum]; + /** * HLSSettings is the payload for HLS settings * @export @@ -4942,27 +5508,27 @@ export interface HealthCheckEvent { type: string; } /** - * + * ICE server configuration for WebRTC connections * @export - * @interface ICEServer + * @interface ICEServerResponse */ -export interface ICEServer { +export interface ICEServerResponse { /** - * + * ICE server password * @type {string} - * @memberof ICEServer + * @memberof ICEServerResponse */ password: string; /** - * + * ICE server URLs * @type {Array} - * @memberof ICEServer + * @memberof ICEServerResponse */ urls: Array; /** - * + * ICE server username * @type {string} - * @memberof ICEServer + * @memberof ICEServerResponse */ username: string; } @@ -4986,11 +5552,17 @@ export interface IndividualRecordingResponse { */ export interface IndividualRecordingSettingsRequest { /** - * + * Recording mode. One of: available, disabled, auto-on * @type {string} * @memberof IndividualRecordingSettingsRequest */ mode: IndividualRecordingSettingsRequestModeEnum; + /** + * Output types to include: audio_only, video_only, audio_video, screenshare_audio_only, screenshare_video_only, screenshare_audio_video + * @type {Array} + * @memberof IndividualRecordingSettingsRequest + */ + output_types?: Array; } /** @@ -5004,6 +5576,20 @@ export const IndividualRecordingSettingsRequestModeEnum = { export type IndividualRecordingSettingsRequestModeEnum = (typeof IndividualRecordingSettingsRequestModeEnum)[keyof typeof IndividualRecordingSettingsRequestModeEnum]; +/** + * @export + */ +export const IndividualRecordingSettingsRequestOutputTypesEnum = { + AUDIO_ONLY: 'audio_only', + VIDEO_ONLY: 'video_only', + AUDIO_VIDEO: 'audio_video', + SCREENSHARE_AUDIO_ONLY: 'screenshare_audio_only', + SCREENSHARE_VIDEO_ONLY: 'screenshare_video_only', + SCREENSHARE_AUDIO_VIDEO: 'screenshare_audio_video', +} as const; +export type IndividualRecordingSettingsRequestOutputTypesEnum = + (typeof IndividualRecordingSettingsRequestOutputTypesEnum)[keyof typeof IndividualRecordingSettingsRequestOutputTypesEnum]; + /** * * @export @@ -5016,6 +5602,12 @@ export interface IndividualRecordingSettingsResponse { * @memberof IndividualRecordingSettingsResponse */ mode: IndividualRecordingSettingsResponseModeEnum; + /** + * + * @type {Array} + * @memberof IndividualRecordingSettingsResponse + */ + output_types?: Array; } /** @@ -5344,6 +5936,12 @@ export interface JoinCallRequest { * @memberof JoinCallRequest */ data?: CallRequest; + /** + * the encryption mode the client intends to use for this join; the join is rejected if it does not match the call's encryption configuration + * @type {boolean} + * @memberof JoinCallRequest + */ + e2ee?: boolean; /** * if true, the participant will be marked as publsihing to large audience * @type {boolean} @@ -5688,27 +6286,27 @@ export interface ListTranscriptionsResponse { transcriptions: Array; } /** - * + * Geographic location metadata * @export - * @interface Location + * @interface LocationResponse */ -export interface Location { +export interface LocationResponse { /** - * + * Continent code * @type {string} - * @memberof Location + * @memberof LocationResponse */ continent_code: string; /** - * + * Country ISO code * @type {string} - * @memberof Location + * @memberof LocationResponse */ country_iso_code: string; /** - * + * Subdivision ISO code * @type {string} - * @memberof Location + * @memberof LocationResponse */ subdivision_iso_code: string; } @@ -5861,6 +6459,19 @@ export interface MetricThreshold { */ window_seconds?: number; } +/** + * + * @export + * @interface MetricTimeSeries + */ +export interface MetricTimeSeries { + /** + * + * @type {Array>} + * @memberof MetricTimeSeries + */ + data_points?: Array>; +} /** * * @export @@ -6483,6 +7094,55 @@ export interface ParticipantSeriesUserStats { */ thresholds?: { [key: string]: Array }; } +/** + * + * @export + * @interface ParticipantSessionDetails + */ +export interface ParticipantSessionDetails { + /** + * + * @type {number} + * @memberof ParticipantSessionDetails + */ + duration_in_seconds?: number; + /** + * + * @type {string} + * @memberof ParticipantSessionDetails + */ + joined_at?: string; + /** + * + * @type {string} + * @memberof ParticipantSessionDetails + */ + left_at?: string; + /** + * + * @type {string} + * @memberof ParticipantSessionDetails + */ + publisher_type: string; + /** + * + * @type {Array} + * @memberof ParticipantSessionDetails + */ + roles: Array; + /** + * + * @type {string} + * @memberof ParticipantSessionDetails + */ + user_id: string; + /** + * + * @type {string} + * @memberof ParticipantSessionDetails + */ + user_session_id: string; +} /** * * @export @@ -6603,6 +7263,55 @@ export interface PublishedTrackFlags { */ video: boolean; } +/** + * + * @export + * @interface PublishedTrackMetrics + */ +export interface PublishedTrackMetrics { + /** + * + * @type {MetricTimeSeries} + * @memberof PublishedTrackMetrics + */ + bitrate?: MetricTimeSeries; + /** + * + * @type {string} + * @memberof PublishedTrackMetrics + */ + codec?: string; + /** + * + * @type {MetricTimeSeries} + * @memberof PublishedTrackMetrics + */ + framerate?: MetricTimeSeries; + /** + * + * @type {ResolutionMetricsTimeSeries} + * @memberof PublishedTrackMetrics + */ + resolution?: ResolutionMetricsTimeSeries; + /** + * + * @type {string} + * @memberof PublishedTrackMetrics + */ + track_id?: string; + /** + * + * @type {string} + * @memberof PublishedTrackMetrics + */ + track_type?: string; + /** + * + * @type {Array} + * @memberof PublishedTrackMetrics + */ + warnings?: Array; +} /** * * @export @@ -6646,6 +7355,12 @@ export interface PushPreferencesResponse { * @memberof PushPreferencesResponse */ chat_level?: string; + /** + * + * @type {ChatPreferencesResponse} + * @memberof PushPreferencesResponse + */ + chat_preferences?: ChatPreferencesResponse; /** * * @type {string} @@ -6778,7 +7493,7 @@ export interface QueryAggregateCallStatsResponse { */ export interface QueryCallMembersRequest { /** - * + * Filter conditions to apply to the query * @type {{ [key: string]: any; }} * @memberof QueryCallMembersRequest */ @@ -6808,7 +7523,7 @@ export interface QueryCallMembersRequest { */ prev?: string; /** - * + * Array of sort parameters * @type {Array} * @memberof QueryCallMembersRequest */ @@ -6851,6 +7566,73 @@ export interface QueryCallMembersResponse { */ prev?: string; } +/** + * Basic response information + * @export + * @interface QueryCallParticipantSessionsResponse + */ +export interface QueryCallParticipantSessionsResponse { + /** + * + * @type {string} + * @memberof QueryCallParticipantSessionsResponse + */ + call_id: string; + /** + * + * @type {string} + * @memberof QueryCallParticipantSessionsResponse + */ + call_session_id: string; + /** + * + * @type {string} + * @memberof QueryCallParticipantSessionsResponse + */ + call_type: string; + /** + * Duration of the request in milliseconds + * @type {number} + * @memberof QueryCallParticipantSessionsResponse + */ + duration: number; + /** + * + * @type {string} + * @memberof QueryCallParticipantSessionsResponse + */ + next?: string; + /** + * + * @type {Array} + * @memberof QueryCallParticipantSessionsResponse + */ + participants_sessions: Array; + /** + * + * @type {string} + * @memberof QueryCallParticipantSessionsResponse + */ + prev?: string; + /** + * + * @type {CallSessionResponse} + * @memberof QueryCallParticipantSessionsResponse + */ + session?: CallSessionResponse; + /** + * + * @type {number} + * @memberof QueryCallParticipantSessionsResponse + */ + total_participant_duration: number; + /** + * + * @type {number} + * @memberof QueryCallParticipantSessionsResponse + */ + total_participant_sessions: number; +} /** * * @export @@ -6858,7 +7640,7 @@ export interface QueryCallMembersResponse { */ export interface QueryCallParticipantsRequest { /** - * + * Filter conditions to apply to the query * @type {{ [key: string]: any; }} * @memberof QueryCallParticipantsRequest */ @@ -6925,6 +7707,12 @@ export interface QueryCallSessionParticipantStatsResponse { * @memberof QueryCallSessionParticipantStatsResponse */ call_ended_at?: string; + /** + * + * @type {Array} + * @memberof QueryCallSessionParticipantStatsResponse + */ + call_events?: Array; /** * * @type {string} @@ -7035,6 +7823,74 @@ export interface QueryCallSessionParticipantStatsTimelineResponse { */ user_session_id: string; } +/** + * + * @export + * @interface QueryCallSessionStatsRequest + */ +export interface QueryCallSessionStatsRequest { + /** + * Filter conditions to apply to the query + * @type {{ [key: string]: any; }} + * @memberof QueryCallSessionStatsRequest + */ + filter_conditions?: { [key: string]: any }; + /** + * + * @type {number} + * @memberof QueryCallSessionStatsRequest + */ + limit?: number; + /** + * + * @type {string} + * @memberof QueryCallSessionStatsRequest + */ + next?: string; + /** + * + * @type {string} + * @memberof QueryCallSessionStatsRequest + */ + prev?: string; + /** + * Array of sort parameters + * @type {Array} + * @memberof QueryCallSessionStatsRequest + */ + sort?: Array; +} +/** + * Basic response information + * @export + * @interface QueryCallSessionStatsResponse + */ +export interface QueryCallSessionStatsResponse { + /** + * + * @type {Array} + * @memberof QueryCallSessionStatsResponse + */ + call_stats: Array; + /** + * Duration of the request in milliseconds + * @type {string} + * @memberof QueryCallSessionStatsResponse + */ + duration: string; + /** + * + * @type {string} + * @memberof QueryCallSessionStatsResponse + */ + next?: string; + /** + * + * @type {string} + * @memberof QueryCallSessionStatsResponse + */ + prev?: string; +} /** * Basic response information * @export @@ -7133,7 +7989,7 @@ export interface QueryCallStatsMapResponse { */ export interface QueryCallStatsRequest { /** - * + * Filter conditions to apply to the query * @type {{ [key: string]: any; }} * @memberof QueryCallStatsRequest */ @@ -7157,7 +8013,7 @@ export interface QueryCallStatsRequest { */ prev?: string; /** - * + * Array of sort parameters * @type {Array} * @memberof QueryCallStatsRequest */ @@ -7201,7 +8057,7 @@ export interface QueryCallStatsResponse { */ export interface QueryCallsRequest { /** - * + * Filter conditions to apply to the query * @type {{ [key: string]: any; }} * @memberof QueryCallsRequest */ @@ -7287,7 +8143,7 @@ export interface RTMPBroadcastRequest { */ name: string; /** - * If provided, will override the call's RTMP settings quality + * If provided, will override the call's RTMP settings quality. One of: 360p, 480p, 720p, 1080p, 1440p, portrait-360x640, portrait-480x854, portrait-720x1280, portrait-1080x1920, portrait-1440x2560 * @type {string} * @memberof RTMPBroadcastRequest */ @@ -7344,13 +8200,13 @@ export interface RTMPIngress { */ export interface RTMPSettingsRequest { /** - * + * Whether RTMP broadcasting is enabled * @type {boolean} * @memberof RTMPSettingsRequest */ enabled?: boolean; /** - * Resolution to set for the RTMP stream + * Resolution to set for the RTMP stream. One of: 360p, 480p, 720p, 1080p, 1440p, portrait-360x640, portrait-480x854, portrait-720x1280, portrait-1080x1920, portrait-1440x2560 * @type {string} * @memberof RTMPSettingsRequest */ @@ -7414,7 +8270,13 @@ export interface RawRecordingResponse { */ export interface RawRecordingSettingsRequest { /** - * + * If true, only audio tracks will be recorded + * @type {boolean} + * @memberof RawRecordingSettingsRequest + */ + audio_only?: boolean; + /** + * Recording mode. One of: available, disabled, auto-on * @type {string} * @memberof RawRecordingSettingsRequest */ @@ -7440,54 +8302,29 @@ export type RawRecordingSettingsRequestModeEnum = export interface RawRecordingSettingsResponse { /** * - * @type {string} + * @type {boolean} * @memberof RawRecordingSettingsResponse */ - mode: RawRecordingSettingsResponseModeEnum; -} - -/** - * @export - */ -export const RawRecordingSettingsResponseModeEnum = { - AVAILABLE: 'available', - DISABLED: 'disabled', - AUTO_ON: 'auto-on', -} as const; -export type RawRecordingSettingsResponseModeEnum = - (typeof RawRecordingSettingsResponseModeEnum)[keyof typeof RawRecordingSettingsResponseModeEnum]; - -/** - * - * @export - * @interface ReactionResponse - */ -export interface ReactionResponse { - /** - * - * @type {{ [key: string]: any; }} - * @memberof ReactionResponse - */ - custom?: { [key: string]: any }; - /** - * - * @type {string} - * @memberof ReactionResponse - */ - emoji_code?: string; - /** - * - * @type {string} - * @memberof ReactionResponse - */ - type: string; + audio_only?: boolean; /** * - * @type {UserResponse} - * @memberof ReactionResponse + * @type {string} + * @memberof RawRecordingSettingsResponse */ - user: UserResponse; + mode: RawRecordingSettingsResponseModeEnum; } + +/** + * @export + */ +export const RawRecordingSettingsResponseModeEnum = { + AVAILABLE: 'available', + DISABLED: 'disabled', + AUTO_ON: 'auto-on', +} as const; +export type RawRecordingSettingsResponseModeEnum = + (typeof RawRecordingSettingsResponseModeEnum)[keyof typeof RawRecordingSettingsResponseModeEnum]; + /** * * @export @@ -7495,19 +8332,19 @@ export interface ReactionResponse { */ export interface RecordSettingsRequest { /** - * + * Whether to record audio only * @type {boolean} * @memberof RecordSettingsRequest */ audio_only?: boolean; /** - * + * Recording mode. One of: available, disabled, auto-on * @type {string} * @memberof RecordSettingsRequest */ mode: RecordSettingsRequestModeEnum; /** - * + * Recording quality. One of: 360p, 480p, 720p, 1080p, 1440p, portrait-360x640, portrait-480x854, portrait-720x1280, portrait-1080x1920, portrait-1440x2560 * @type {string} * @memberof RecordSettingsRequest */ @@ -7667,8 +8504,20 @@ export interface RequestPermissionRequest { * @type {Array} * @memberof RequestPermissionRequest */ - permissions: Array; + permissions: Array; } + +/** + * @export + */ +export const RequestPermissionRequestPermissionsEnum = { + SCREENSHARE: 'screenshare', + SEND_AUDIO: 'send-audio', + SEND_VIDEO: 'send-video', +} as const; +export type RequestPermissionRequestPermissionsEnum = + (typeof RequestPermissionRequestPermissionsEnum)[keyof typeof RequestPermissionRequestPermissionsEnum]; + /** * * @export @@ -7682,6 +8531,93 @@ export interface RequestPermissionResponse { */ duration: string; } +/** + * + * @export + * @interface ResolutionMetricsTimeSeries + */ +export interface ResolutionMetricsTimeSeries { + /** + * + * @type {MetricTimeSeries} + * @memberof ResolutionMetricsTimeSeries + */ + height?: MetricTimeSeries; + /** + * + * @type {MetricTimeSeries} + * @memberof ResolutionMetricsTimeSeries + */ + width?: MetricTimeSeries; +} +/** + * Request to determine SIP trunk authentication requirements + * @export + * @interface ResolveSipAuthRequest + */ +export interface ResolveSipAuthRequest { + /** + * Host from the SIP From header + * @type {string} + * @memberof ResolveSipAuthRequest + */ + from_host?: string; + /** + * SIP caller number + * @type {string} + * @memberof ResolveSipAuthRequest + */ + sip_caller_number: string; + /** + * SIP trunk number to look up + * @type {string} + * @memberof ResolveSipAuthRequest + */ + sip_trunk_number: string; + /** + * Transport-layer source IP address of the SIP request + * @type {string} + * @memberof ResolveSipAuthRequest + */ + source_ip?: string; +} +/** + * Response containing the pre-authentication decision for a SIP trunk + * @export + * @interface ResolveSipAuthResponse + */ +export interface ResolveSipAuthResponse { + /** + * Authentication result: password, accept, or no_trunk_found + * @type {string} + * @memberof ResolveSipAuthResponse + */ + auth_result: string; + /** + * + * @type {string} + * @memberof ResolveSipAuthResponse + */ + duration: string; + /** + * Password for digest authentication (when auth_result is password) + * @type {string} + * @memberof ResolveSipAuthResponse + */ + password?: string; + /** + * ID of the matched SIP trunk + * @type {string} + * @memberof ResolveSipAuthResponse + */ + trunk_id?: string; + /** + * Username for digest authentication (when auth_result is password) + * @type {string} + * @memberof ResolveSipAuthResponse + */ + username?: string; +} /** * Request to resolve SIP inbound routing using challenge authentication * @export @@ -7690,10 +8626,16 @@ export interface RequestPermissionResponse { export interface ResolveSipInboundRequest { /** * - * @type {SIPChallenge} + * @type {SIPChallengeRequest} + * @memberof ResolveSipInboundRequest + */ + challenge?: SIPChallengeRequest; + /** + * Optional routing number for routing number-based call routing (10 digits) + * @type {string} * @memberof ResolveSipInboundRequest */ - challenge: SIPChallenge; + routing_number?: string; /** * SIP caller number * @type {string} @@ -7712,6 +8654,12 @@ export interface ResolveSipInboundRequest { * @memberof ResolveSipInboundRequest */ sip_trunk_number: string; + /** + * Optional pre-authenticated trunk ID (from PreAuth no-auth flow) + * @type {string} + * @memberof ResolveSipInboundRequest + */ + trunk_id?: string; } /** * Response containing resolved SIP inbound routing information @@ -7879,10 +8827,16 @@ export interface SDKUsageReportResponse { export interface SFULocationResponse { /** * - * @type {Coordinates} + * @type {CoordinatesResponse} + * @memberof SFULocationResponse + */ + coordinates: CoordinatesResponse; + /** + * + * @type {number} * @memberof SFULocationResponse */ - coordinates: Coordinates; + count?: number; /** * * @type {string} @@ -7897,10 +8851,10 @@ export interface SFULocationResponse { id: string; /** * - * @type {Location} + * @type {LocationResponse} * @memberof SFULocationResponse */ - location: Location; + location: LocationResponse; } /** * @@ -7960,105 +8914,105 @@ export interface SIPCallerConfigsResponse { id: string; } /** - * + * SIP digest challenge authentication data * @export - * @interface SIPChallenge + * @interface SIPChallengeRequest */ -export interface SIPChallenge { +export interface SIPChallengeRequest { /** - * + * Deprecated: A1 hash for backward compatibility * @type {string} - * @memberof SIPChallenge + * @memberof SIPChallengeRequest */ a1?: string; /** - * + * Hash algorithm (e.g., MD5, SHA-256) * @type {string} - * @memberof SIPChallenge + * @memberof SIPChallengeRequest */ algorithm?: string; /** - * + * Character set * @type {string} - * @memberof SIPChallenge + * @memberof SIPChallengeRequest */ charset?: string; /** - * + * Client nonce for qop=auth * @type {string} - * @memberof SIPChallenge + * @memberof SIPChallengeRequest */ cnonce?: string; /** - * + * Domain list * @type {Array} - * @memberof SIPChallenge + * @memberof SIPChallengeRequest */ domain?: Array; /** - * + * SIP method (e.g., INVITE) * @type {string} - * @memberof SIPChallenge + * @memberof SIPChallengeRequest */ method?: string; /** - * + * Nonce count for qop=auth * @type {string} - * @memberof SIPChallenge + * @memberof SIPChallengeRequest */ nc?: string; /** - * + * Server nonce * @type {string} - * @memberof SIPChallenge + * @memberof SIPChallengeRequest */ nonce?: string; /** - * + * Opaque value * @type {string} - * @memberof SIPChallenge + * @memberof SIPChallengeRequest */ opaque?: string; /** - * + * Quality of protection options * @type {Array} - * @memberof SIPChallenge + * @memberof SIPChallengeRequest */ qop?: Array; /** - * + * Authentication realm * @type {string} - * @memberof SIPChallenge + * @memberof SIPChallengeRequest */ realm?: string; /** - * + * Digest response hash from client * @type {string} - * @memberof SIPChallenge + * @memberof SIPChallengeRequest */ response?: string; /** - * + * Whether the nonce is stale * @type {boolean} - * @memberof SIPChallenge + * @memberof SIPChallengeRequest */ stale?: boolean; /** - * + * Request URI * @type {string} - * @memberof SIPChallenge + * @memberof SIPChallengeRequest */ uri?: string; /** - * + * Whether to hash the username * @type {boolean} - * @memberof SIPChallenge + * @memberof SIPChallengeRequest */ userhash?: boolean; /** - * + * Username for authentication * @type {string} - * @memberof SIPChallenge + * @memberof SIPChallengeRequest */ username?: string; } @@ -8240,6 +9194,12 @@ export interface SIPPinProtectionConfigsResponse { * @interface SIPTrunkResponse */ export interface SIPTrunkResponse { + /** + * Allowed IPv4/IPv6 addresses or CIDR blocks + * @type {Array} + * @memberof SIPTrunkResponse + */ + allowed_ips: Array; /** * Creation timestamp * @type {string} @@ -8381,46 +9341,83 @@ export interface SendCallEventResponse { /** * * @export - * @interface SendReactionRequest + * @interface SendVideoReactionRequest */ -export interface SendReactionRequest { +export interface SendVideoReactionRequest { /** * * @type {{ [key: string]: any; }} - * @memberof SendReactionRequest + * @memberof SendVideoReactionRequest */ custom?: { [key: string]: any }; /** * * @type {string} - * @memberof SendReactionRequest + * @memberof SendVideoReactionRequest */ emoji_code?: string; /** * * @type {string} - * @memberof SendReactionRequest + * @memberof SendVideoReactionRequest */ type: string; } /** * Basic response information * @export - * @interface SendReactionResponse + * @interface SendVideoReactionResponse */ -export interface SendReactionResponse { +export interface SendVideoReactionResponse { /** * Duration of the request in milliseconds * @type {string} - * @memberof SendReactionResponse + * @memberof SendVideoReactionResponse */ duration: string; /** * - * @type {ReactionResponse} - * @memberof SendReactionResponse + * @type {VideoReactionResponse} + * @memberof SendVideoReactionResponse + */ + reaction: VideoReactionResponse; +} +/** + * + * @export + * @interface SessionClient + */ +export interface SessionClient { + /** + * + * @type {string} + * @memberof SessionClient + */ + ip?: string; + /** + * + * @type {CallStatsLocation} + * @memberof SessionClient + */ + location?: CallStatsLocation; + /** + * + * @type {string} + * @memberof SessionClient + */ + name?: string; + /** + * + * @type {string} + * @memberof SessionClient + */ + network_type?: string; + /** + * + * @type {string} + * @memberof SessionClient */ - reaction: ReactionResponse; + version?: string; } /** * @@ -8448,12 +9445,43 @@ export interface SessionSettingsResponse { */ inactivity_timeout_seconds: number; } +/** + * + * @export + * @interface SessionWarningResponse + */ +export interface SessionWarningResponse { + /** + * + * @type {string} + * @memberof SessionWarningResponse + */ + code: string; + /** + * + * @type {string} + * @memberof SessionWarningResponse + */ + time?: string; + /** + * + * @type {string} + * @memberof SessionWarningResponse + */ + warning: string; +} /** * Credentials for SIP inbound call authentication * @export * @interface SipInboundCredentials */ export interface SipInboundCredentials { + /** + * API key for the application + * @type {string} + * @memberof SipInboundCredentials + */ + api_key: string; /** * Custom data associated with the call * @type {{ [key: string]: any; }} @@ -8498,7 +9526,7 @@ export interface SipInboundCredentials { */ export interface SortParamRequest { /** - * Direction of sorting, 1 for Ascending, -1 for Descending, default is 1 + * Direction of sorting, 1 for Ascending, -1 for Descending, default is 1. One of: -1, 1 * @type {number} * @memberof SortParamRequest */ @@ -8509,6 +9537,12 @@ export interface SortParamRequest { * @memberof SortParamRequest */ field?: string; + /** + * Type of field to sort by. Empty string or omitted means string type (default). One of: number, boolean + * @type {string} + * @memberof SortParamRequest + */ + type?: string; } /** * @@ -8548,7 +9582,7 @@ export interface StartClosedCaptionsRequest { */ external_storage?: string; /** - * The spoken language in the call, if not provided the language defined in the transcription settings will be used + * The spoken language in the call, if not provided the language defined in the transcription settings will be used. One of: auto, ar, bg, ca, cs, da, de, el, en, es, et, fi, fr, he, hi, hr, hu, id, it, ja, ko, ms, nl, no, pl, pt, ro, ru, sk, sl, sv, ta, th, tl, tr, uk, zh * @type {string} * @memberof StartClosedCaptionsRequest */ @@ -8729,7 +9763,7 @@ export interface StartTranscriptionRequest { */ enable_closed_captions?: boolean; /** - * The spoken language in the call, if not provided the language defined in the transcription settings will be used + * The spoken language in the call, if not provided the language defined in the transcription settings will be used. One of: auto, ar, bg, ca, cs, da, de, el, en, es, et, fi, fr, he, hi, hr, hu, id, it, ja, ko, ms, nl, no, pl, pt, ro, ru, sk, sl, sv, ta, th, tl, tr, uk, zh * @type {string} * @memberof StartTranscriptionRequest */ @@ -9345,8 +10379,49 @@ export interface TranslationSettings { * @type {Array} * @memberof TranslationSettings */ - languages?: Array; + languages?: Array; } + +/** + * @export + */ +export const TranslationSettingsLanguagesEnum = { + EN: 'en', + FR: 'fr', + ES: 'es', + DE: 'de', + IT: 'it', + NL: 'nl', + PT: 'pt', + PL: 'pl', + CA: 'ca', + CS: 'cs', + DA: 'da', + EL: 'el', + FI: 'fi', + ID: 'id', + JA: 'ja', + RU: 'ru', + SV: 'sv', + TA: 'ta', + TH: 'th', + TR: 'tr', + HU: 'hu', + RO: 'ro', + ZH: 'zh', + AR: 'ar', + TL: 'tl', + HE: 'he', + HI: 'hi', + HR: 'hr', + KO: 'ko', + MS: 'ms', + NO: 'no', + UK: 'uk', +} as const; +export type TranslationSettingsLanguagesEnum = + (typeof TranslationSettingsLanguagesEnum)[keyof typeof TranslationSettingsLanguagesEnum]; + /** * UnblockUserRequest is the payload for unblocking a user. * @export @@ -9548,13 +10623,13 @@ export interface UpdateUserPermissionsRequest { * @type {Array} * @memberof UpdateUserPermissionsRequest */ - grant_permissions?: Array; + grant_permissions?: Array; /** * * @type {Array} * @memberof UpdateUserPermissionsRequest */ - revoke_permissions?: Array; + revoke_permissions?: Array; /** * * @type {string} @@ -9562,6 +10637,29 @@ export interface UpdateUserPermissionsRequest { */ user_id: string; } + +/** + * @export + */ +export const UpdateUserPermissionsRequestGrantPermissionsEnum = { + SCREENSHARE: 'screenshare', + SEND_AUDIO: 'send-audio', + SEND_VIDEO: 'send-video', +} as const; +export type UpdateUserPermissionsRequestGrantPermissionsEnum = + (typeof UpdateUserPermissionsRequestGrantPermissionsEnum)[keyof typeof UpdateUserPermissionsRequestGrantPermissionsEnum]; + +/** + * @export + */ +export const UpdateUserPermissionsRequestRevokePermissionsEnum = { + SCREENSHARE: 'screenshare', + SEND_AUDIO: 'send-audio', + SEND_VIDEO: 'send-video', +} as const; +export type UpdateUserPermissionsRequestRevokePermissionsEnum = + (typeof UpdateUserPermissionsRequestRevokePermissionsEnum)[keyof typeof UpdateUserPermissionsRequestRevokePermissionsEnum]; + /** * Basic response information * @export @@ -10055,6 +11153,37 @@ export interface VideoReactionOverTimeResponse { */ by_minute?: Array; } +/** + * + * @export + * @interface VideoReactionResponse + */ +export interface VideoReactionResponse { + /** + * + * @type {{ [key: string]: any; }} + * @memberof VideoReactionResponse + */ + custom?: { [key: string]: any }; + /** + * + * @type {string} + * @memberof VideoReactionResponse + */ + emoji_code?: string; + /** + * + * @type {string} + * @memberof VideoReactionResponse + */ + type: string; + /** + * + * @type {UserResponse} + * @memberof VideoReactionResponse + */ + user: UserResponse; +} /** * * @export @@ -10192,13 +11321,13 @@ export interface WHIPIngress { */ export interface WSAuthMessage { /** - * + * List of products to subscribe to. One of: chat, video, feeds * @type {Array} * @memberof WSAuthMessage */ - products?: Array; + products?: Array; /** - * + * JWT token for authentication * @type {string} * @memberof WSAuthMessage */ @@ -10210,3 +11339,14 @@ export interface WSAuthMessage { */ user_details: ConnectUserDetailsRequest; } + +/** + * @export + */ +export const WSAuthMessageProductsEnum = { + CHAT: 'chat', + VIDEO: 'video', + FEEDS: 'feeds', +} as const; +export type WSAuthMessageProductsEnum = + (typeof WSAuthMessageProductsEnum)[keyof typeof WSAuthMessageProductsEnum]; diff --git a/packages/client/src/gen/video/sfu/models/models.ts b/packages/client/src/gen/video/sfu/models/models.ts index 635f66f1a3..9fa719f6f5 100644 --- a/packages/client/src/gen/video/sfu/models/models.ts +++ b/packages/client/src/gen/video/sfu/models/models.ts @@ -40,6 +40,14 @@ export interface CallState { * @generated from protobuf field: repeated stream.video.sfu.models.Pin pins = 4; */ pins: Pin[]; + /** + * e2ee_enabled is true when the call uses end-to-end encryption. Clients + * must enable their frame encryptor; the SFU forwards encrypted frames + * opaquely and server-side recording/transcription/broadcasting are disabled. + * + * @generated from protobuf field: bool e2ee_enabled = 5 [json_name = "e2eeEnabled"]; + */ + e2EeEnabled: boolean; } /** * @generated from protobuf message stream.video.sfu.models.ParticipantCount @@ -1401,6 +1409,13 @@ class CallState$Type extends MessageType { repeat: 2 /*RepeatType.UNPACKED*/, T: () => Pin, }, + { + no: 5, + name: 'e2ee_enabled', + kind: 'scalar', + jsonName: 'e2eeEnabled', + T: 8 /*ScalarType.BOOL*/, + }, ]); } } diff --git a/packages/client/src/helpers/TypedEventEmitter.ts b/packages/client/src/helpers/TypedEventEmitter.ts new file mode 100644 index 0000000000..969411f49a --- /dev/null +++ b/packages/client/src/helpers/TypedEventEmitter.ts @@ -0,0 +1,71 @@ +import { ScopedLogger, videoLoggerSystem } from '../logger'; + +export type EventMap = Record; + +export type Listener

= (payload: P) => void | Promise; + +/** + * Tiny, type-safe event emitter. + * + * Usage styles: + * - compose: `private readonly events = new TypedEventEmitter()` + * - extend: `class Foo extends TypedEventEmitter {}` + * + * Listener exceptions (sync throw or rejected promise) are caught and logged, + * so one bad listener cannot break dispatch for the rest. + */ +export class TypedEventEmitter { + protected readonly logger: ScopedLogger; + private readonly byEvent = new Map>>(); + + constructor(logger: ScopedLogger | string = 'TypedEventEmitter') { + this.logger = + typeof logger === 'string' ? videoLoggerSystem.getLogger(logger) : logger; + } + + on = (event: E, fn: Listener): (() => void) => { + let listeners = this.byEvent.get(event); + if (!listeners) { + listeners = new Set(); + this.byEvent.set(event, listeners); + } + listeners.add(fn as Listener); + return () => this.off(event, fn); + }; + + off = (event: E, fn: Listener): void => { + const listeners = this.byEvent.get(event); + if (!listeners) return; + listeners.delete(fn as Listener); + if (listeners.size === 0) this.byEvent.delete(event); + }; + + emit = (event: E, payload: M[E]): void => { + const listeners = this.byEvent.get(event); + if (!listeners || listeners.size === 0) return; + for (const listener of [...listeners]) { + this.invoke(() => listener(payload), event); + } + }; + + removeAllListeners = (event?: keyof M): void => { + if (event === undefined) { + this.byEvent.clear(); + } else { + this.byEvent.delete(event); + } + }; + + private invoke = (run: () => void | Promise, event: keyof M): void => { + try { + const result = run(); + if (result && typeof (result as Promise).then === 'function') { + (result as Promise).catch((err) => { + this.logger.warn(`Listener for '${String(event)}' rejected`, err); + }); + } + } catch (err) { + this.logger.warn(`Listener for '${String(event)}' threw`, err); + } + }; +} diff --git a/packages/client/src/helpers/__tests__/TypedEventEmitter.test.ts b/packages/client/src/helpers/__tests__/TypedEventEmitter.test.ts new file mode 100644 index 0000000000..5a653974e1 --- /dev/null +++ b/packages/client/src/helpers/__tests__/TypedEventEmitter.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { TypedEventEmitter } from '../TypedEventEmitter'; + +type TestEvents = { + hello: string; + ping: { n: number }; + empty: undefined; +}; + +describe('TypedEventEmitter', () => { + let emitter: TypedEventEmitter; + + beforeEach(() => { + emitter = new TypedEventEmitter(); + }); + + it('invokes listeners registered with on()', () => { + const listener = vi.fn(); + emitter.on('hello', listener); + emitter.emit('hello', 'world'); + expect(listener).toHaveBeenCalledWith('world'); + }); + + it('returns an unsubscribe function from on()', () => { + const listener = vi.fn(); + const unsubscribe = emitter.on('hello', listener); + unsubscribe(); + emitter.emit('hello', 'world'); + expect(listener).not.toHaveBeenCalled(); + }); + + it('removes listeners via off()', () => { + const listener = vi.fn(); + emitter.on('hello', listener); + emitter.off('hello', listener); + emitter.emit('hello', 'world'); + expect(listener).not.toHaveBeenCalled(); + }); + + it('supports multiple listeners for the same event', () => { + const a = vi.fn(); + const b = vi.fn(); + emitter.on('ping', a); + emitter.on('ping', b); + emitter.emit('ping', { n: 1 }); + expect(a).toHaveBeenCalledWith({ n: 1 }); + expect(b).toHaveBeenCalledWith({ n: 1 }); + }); + + it('deduplicates identical listener references', () => { + const listener = vi.fn(); + emitter.on('hello', listener); + emitter.on('hello', listener); + emitter.emit('hello', 'once'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('isolates a throwing listener from subsequent listeners', () => { + const bad = vi.fn(() => { + throw new Error('boom'); + }); + const good = vi.fn(); + emitter.on('hello', bad); + emitter.on('hello', good); + expect(() => emitter.emit('hello', 'world')).not.toThrow(); + expect(bad).toHaveBeenCalled(); + expect(good).toHaveBeenCalledWith('world'); + }); + + it('isolates a rejecting async listener', async () => { + const rejected = vi.fn(async () => { + throw new Error('async boom'); + }); + const good = vi.fn(); + emitter.on('hello', rejected); + emitter.on('hello', good); + expect(() => emitter.emit('hello', 'world')).not.toThrow(); + expect(good).toHaveBeenCalled(); + // allow microtask to settle; rejection should have been swallowed by the emitter + await Promise.resolve(); + }); + + it('is safe to call off() from within a listener (current emit still fires siblings)', () => { + const sibling = vi.fn(); + const self = vi.fn(() => { + emitter.off('hello', self); + }); + emitter.on('hello', self); + emitter.on('hello', sibling); + emitter.emit('hello', 'world'); + expect(self).toHaveBeenCalledTimes(1); + expect(sibling).toHaveBeenCalledTimes(1); + // second emit should not invoke self again + emitter.emit('hello', 'again'); + expect(self).toHaveBeenCalledTimes(1); + expect(sibling).toHaveBeenCalledTimes(2); + }); + + it('is safe to add a listener from within a listener (added listener does not fire in current emit)', () => { + const later = vi.fn(); + emitter.on('hello', () => { + emitter.on('hello', later); + }); + emitter.emit('hello', 'first'); + expect(later).not.toHaveBeenCalled(); + emitter.emit('hello', 'second'); + expect(later).toHaveBeenCalledWith('second'); + }); + + it('removeAllListeners() clears every subscription when called without args', () => { + const a = vi.fn(); + const b = vi.fn(); + emitter.on('hello', a); + emitter.on('ping', b); + emitter.removeAllListeners(); + emitter.emit('hello', 'world'); + emitter.emit('ping', { n: 1 }); + expect(a).not.toHaveBeenCalled(); + expect(b).not.toHaveBeenCalled(); + }); + + it('removeAllListeners(event) clears only that event', () => { + const a = vi.fn(); + const b = vi.fn(); + emitter.on('hello', a); + emitter.on('ping', b); + emitter.removeAllListeners('hello'); + emitter.emit('hello', 'world'); + emitter.emit('ping', { n: 1 }); + expect(a).not.toHaveBeenCalled(); + expect(b).toHaveBeenCalledWith({ n: 1 }); + }); +}); diff --git a/packages/client/src/helpers/ensureExhausted.ts b/packages/client/src/helpers/ensureExhausted.ts index 94dceb2c78..f8494bec48 100644 --- a/packages/client/src/helpers/ensureExhausted.ts +++ b/packages/client/src/helpers/ensureExhausted.ts @@ -1,5 +1,7 @@ import { videoLoggerSystem } from '../logger'; -export const ensureExhausted = (x: never, message: string) => { - videoLoggerSystem.getLogger('helpers').warn(message, x); +export const ensureExhausted = (x: never, message?: string) => { + if (message) { + videoLoggerSystem.getLogger('helpers').warn(message, x); + } }; diff --git a/packages/client/src/permissions/PermissionsContext.ts b/packages/client/src/permissions/PermissionsContext.ts index 2a0506a4bb..0072be9bc3 100644 --- a/packages/client/src/permissions/PermissionsContext.ts +++ b/packages/client/src/permissions/PermissionsContext.ts @@ -1,4 +1,8 @@ -import { CallSettingsResponse, OwnCapability } from '../gen/coordinator'; +import { + CallSettingsResponse, + OwnCapability, + RequestPermissionRequestPermissionsEnum, +} from '../gen/coordinator'; import { TrackType } from '../gen/video/sfu/models/models'; import { ensureExhausted } from '../helpers/ensureExhausted'; @@ -70,20 +74,21 @@ export class PermissionsContext { * @param settings the call settings to check against (optional). */ canRequest = ( - permission: OwnCapability, + permission: RequestPermissionRequestPermissionsEnum, settings: CallSettingsResponse | undefined = this.settings, ) => { if (!settings) return false; const { audio, video, screensharing } = settings; switch (permission) { - case OwnCapability.SEND_AUDIO: + case RequestPermissionRequestPermissionsEnum.SEND_AUDIO: return audio.access_request_enabled; - case OwnCapability.SEND_VIDEO: + case RequestPermissionRequestPermissionsEnum.SEND_VIDEO: return video.access_request_enabled; - case OwnCapability.SCREENSHARE: + case RequestPermissionRequestPermissionsEnum.SCREENSHARE: return screensharing.access_request_enabled; default: + ensureExhausted(permission); return false; } }; diff --git a/packages/client/src/rtc/BasePeerConnection.ts b/packages/client/src/rtc/BasePeerConnection.ts index 1b7ec172a4..e6c5c92765 100644 --- a/packages/client/src/rtc/BasePeerConnection.ts +++ b/packages/client/src/rtc/BasePeerConnection.ts @@ -8,6 +8,8 @@ import { TrackType, WebsocketReconnectStrategy, } from '../gen/video/sfu/models/models'; +import type { E2EEManager } from './e2ee/E2EEManager'; +import { hasInsertableStreams } from './e2ee/transformSupport'; import { NegotiationError } from './NegotiationError'; import { StreamSfuClient } from '../StreamSfuClient'; import { AllSfuEvents, Dispatcher } from './Dispatcher'; @@ -35,6 +37,7 @@ export abstract class BasePeerConnection { protected readonly state: CallState; protected readonly dispatcher: Dispatcher; protected readonly clientPublishOptions?: ClientPublishOptions; + protected readonly e2ee?: E2EEManager; protected tag: string; protected sfuClient: StreamSfuClient; @@ -75,6 +78,7 @@ export abstract class BasePeerConnection { tag, enableTracing, clientPublishOptions, + e2ee, iceRestartDelay = 2500, statsTimestampDriftThresholdMs = 0, }: BasePeerConnectionOpts, @@ -85,6 +89,7 @@ export abstract class BasePeerConnection { this.dispatcher = dispatcher; this.iceRestartDelay = iceRestartDelay; this.clientPublishOptions = clientPublishOptions; + this.e2ee = e2ee; this.tag = tag; this.onReconnectionNeeded = onReconnectionNeeded; this.onIceConnected = onIceConnected; @@ -114,7 +119,13 @@ export abstract class BasePeerConnection { } private createPeerConnection = (connectionConfig?: RTCConfiguration) => { - const pc = new RTCPeerConnection(connectionConfig); + const config: RTCConfiguration = { ...connectionConfig }; + // The legacy Insertable Streams path requires this non-standard flag. + if (this.e2ee && hasInsertableStreams()) { + // @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 90337cf1e1..8422c6e0c0 100644 --- a/packages/client/src/rtc/Publisher.ts +++ b/packages/client/src/rtc/Publisher.ts @@ -166,6 +166,14 @@ export class Publisher extends BasePeerConnection { toRTCDegradationPreference(publishOption.degradationPreference) ?? 'maintain-framerate'; await transceiver.sender.setParameters(params); + if (this.e2ee) { + this.e2ee.encrypt( + transceiver.sender, + publishOption.codec?.name.toLowerCase(), + TrackType[publishOption.trackType], + ); + this.logger.debug('E2EE encryptor attached to sender'); + } await this.negotiate(); }; diff --git a/packages/client/src/rtc/Subscriber.ts b/packages/client/src/rtc/Subscriber.ts index a5f93c4063..0aae9872c4 100644 --- a/packages/client/src/rtc/Subscriber.ts +++ b/packages/client/src/rtc/Subscriber.ts @@ -151,8 +151,13 @@ export class Subscriber extends BasePeerConnection { trackLookupPrefix: trackId, track: primaryStream, trackType, + receiver: this.e2ee ? e.receiver : undefined, }); return; + } else if (this.e2ee) { + const { userId } = participantToUpdate; + this.e2ee.decrypt(e.receiver, userId, TrackType[trackType]); + this.logger.debug('E2EE decryptor attached to receiver', userId); } const streamKindProp = trackTypeToParticipantStreamKey(trackType); diff --git a/packages/client/src/rtc/__tests__/Publisher.test.ts b/packages/client/src/rtc/__tests__/Publisher.test.ts index 3807640ce8..3813e59059 100644 --- a/packages/client/src/rtc/__tests__/Publisher.test.ts +++ b/packages/client/src/rtc/__tests__/Publisher.test.ts @@ -146,6 +146,50 @@ describe('Publisher', () => { expect(negotiateSpy).toHaveBeenCalled(); }); + it('should attach an encryptor when E2EE manager is provided', async () => { + const e2eeMock = { + encrypt: vi.fn(), + decrypt: vi.fn(), + }; + publisher.dispose(); + publisher = new Publisher( + { + sfuClient, + dispatcher, + state, + tag: 'test', + enableTracing: false, + e2ee: e2eeMock, + }, + [ + { + 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); + + expect(e2eeMock.encrypt).toHaveBeenCalledWith( + expect.anything(), // sender + 'vp9', + 'VIDEO', + ); + }); + it('should update an existing transceiver for a new track', async () => { const track = new MediaStreamTrack(); const clone = new MediaStreamTrack(); @@ -239,6 +283,63 @@ describe('Publisher', () => { }); }); + describe('E2EE peer connection config', () => { + const e2ee = { encrypt: vi.fn(), decrypt: vi.fn() }; + + /** + * Build a publisher and return the RTCConfiguration it constructed its peer + * connection with. The publisher from the outer `beforeEach` already + * consumed a call, hence the last one rather than the first. + */ + const configOf = async (opts: { e2ee?: typeof e2ee }) => { + await publisher.dispose(); + publisher = new Publisher( + { + sfuClient, + dispatcher, + state, + tag: 'test', + enableTracing: false, + ...opts, + }, + [], + ); + const calls = vi.mocked(globalThis.RTCPeerConnection).mock.calls; + return calls[calls.length - 1][0] as Record | undefined; + }; + + const withInsertableStreams = (available: boolean) => { + if (available) { + Object.assign(RTCRtpSender.prototype, { + createEncodedStreams: vi.fn(), + }); + } else { + // @ts-expect-error - non-standard property from the mock prototype + delete RTCRtpSender.prototype.createEncodedStreams; + } + }; + + afterEach(() => withInsertableStreams(false)); + + it('enables encodedInsertableStreams when a manager is attached', async () => { + withInsertableStreams(true); + const config = await configOf({ e2ee }); + expect(config?.encodedInsertableStreams).toBe(true); + }); + + it('leaves the flag off when no manager is attached', async () => { + withInsertableStreams(true); + const config = await configOf({}); + expect(config?.encodedInsertableStreams).toBeUndefined(); + }); + + it('leaves the flag off on browsers without Insertable Streams', async () => { + withInsertableStreams(false); + const config = await configOf({ e2ee }); + expect(config?.encodedInsertableStreams).toBeUndefined(); + }); + }); + describe('Event Handling', () => { it('handles changePublishQuality events', () => { publisher['changePublishQuality'] = vi.fn(); diff --git a/packages/client/src/rtc/__tests__/Subscriber.test.ts b/packages/client/src/rtc/__tests__/Subscriber.test.ts index 5d6fa0cfc1..54b1d68501 100644 --- a/packages/client/src/rtc/__tests__/Subscriber.test.ts +++ b/packages/client/src/rtc/__tests__/Subscriber.test.ts @@ -17,6 +17,7 @@ import { NegotiationError } from '../NegotiationError'; import { ReconnectReason } from '../types'; import { IceTrickleBuffer } from '../IceTrickleBuffer'; import { StreamClient } from '../../coordinator/connection/client'; +import { fromPartial } from '@total-typescript/shoehorn'; vi.mock('../../StreamSfuClient', () => { console.log('MOCKING StreamSfuClient'); @@ -522,6 +523,94 @@ describe('Subscriber', () => { expect(baseTrack.stop).toHaveBeenCalled(); expect(baseStream.removeTrack).toHaveBeenCalledWith(baseTrack); }); + + it('should store receiver in orphaned track when E2EE is enabled', () => { + const e2eeMock = { + encrypt: vi.fn(), + decrypt: vi.fn(), + }; + subscriber.dispose(); + const e2eeState = new CallState(); + subscriber = new Subscriber({ + sfuClient, + dispatcher, + state: e2eeState, + connectionConfig: { iceServers: [] }, + tag: 'test', + enableTracing: true, + e2ee: e2eeMock, + }); + + const mediaStream = new MediaStream(); + const mediaStreamTrack = new MediaStreamTrack(); + const receiver = {}; + // @ts-expect-error - mock + mediaStream.id = 'orphan-prefix:TRACK_TYPE_VIDEO'; + + const registerOrphanedTrackSpy = vi.spyOn( + e2eeState, + 'registerOrphanedTrack', + ); + const onTrack = subscriber['handleOnTrack']; + // @ts-expect-error - incomplete mock + onTrack({ streams: [mediaStream], track: mediaStreamTrack, receiver }); + + // Decrypt is NOT called immediately for orphaned tracks + expect(e2eeMock.decrypt).not.toHaveBeenCalled(); + // Receiver is stored with the orphaned track for later reconciliation + expect(registerOrphanedTrackSpy).toHaveBeenCalledWith({ + id: mediaStream.id, + trackLookupPrefix: 'orphan-prefix', + track: mediaStream, + trackType: TrackType.VIDEO, + receiver, + }); + }); + + it('should decrypt with userId when participant is found', () => { + const e2eeMock = { + encrypt: vi.fn(), + decrypt: vi.fn(), + }; + subscriber.dispose(); + const e2eeState = new CallState(); + subscriber = new Subscriber({ + sfuClient, + dispatcher, + state: e2eeState, + connectionConfig: { iceServers: [] }, + tag: 'test', + enableTracing: true, + e2ee: e2eeMock, + }); + + // Register a participant whose trackLookupPrefix matches the stream + e2eeState.updateOrAddParticipant( + 'session-id', + fromPartial({ + sessionId: 'session-id', + userId: 'real-user-id', + trackLookupPrefix: '123', + }), + ); + + const mediaStream = new MediaStream(); + const mediaStreamTrack = new MediaStreamTrack(); + const receiver = {}; + // @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 }); + + // Uses the participant's userId (not trackLookupPrefix) for key lookup + expect(e2eeMock.decrypt).toHaveBeenCalledWith( + receiver, + 'real-user-id', + 'VIDEO', + ); + }); }); describe('interruptedTracks', () => { diff --git a/packages/client/src/rtc/__tests__/mocks/webrtc.mocks.ts b/packages/client/src/rtc/__tests__/mocks/webrtc.mocks.ts index dbfa06faf0..c51307c849 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(function (): Partial { replaceTrack: vi.fn(), getParameters: vi.fn().mockReturnValue({}), setParameters: vi.fn(), + transform: null, }, setCodecPreferences: vi.fn(), mid: '', @@ -80,6 +81,7 @@ const RTCRtpReceiverMock = vi.fn(function (): Partial { getCapabilities: vi.fn(), }; }); +RTCRtpReceiverMock.prototype.transform = null; vi.stubGlobal('RTCRtpReceiver', RTCRtpReceiverMock); const RTCRtpSenderMock = vi.fn(function (): Partial { @@ -89,8 +91,37 @@ const RTCRtpSenderMock = vi.fn(function (): Partial { track: vi.fn(), }; }); +RTCRtpSenderMock.prototype.transform = null; vi.stubGlobal('RTCRtpSender', RTCRtpSenderMock); +const WorkerMock = vi.fn(function (): Partial { + return { + postMessage: vi.fn(), + terminate: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; +}); +vi.stubGlobal('Worker', WorkerMock); + +const RTCRtpScriptTransformMock = vi.fn(function ( + 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(function (): Partial { return { state: 'suspended', diff --git a/packages/client/src/rtc/e2ee/E2EEManager.ts b/packages/client/src/rtc/e2ee/E2EEManager.ts new file mode 100644 index 0000000000..82b6583262 --- /dev/null +++ b/packages/client/src/rtc/e2ee/E2EEManager.ts @@ -0,0 +1,12 @@ +/** + * What the RTC layer needs to attach E2EE to a track. {@link EncryptionManager} + * is the built-in AES-GCM implementation, but `Call.setE2EEManager` takes any: + * an integrator can plug in another scheme, RFC 9605 SFrame for example, by + * attaching their own encoded transform in these two methods. + */ +export interface E2EEManager { + /** `trackType` only groups perf stats, keeping a camera and screen share apart. */ + encrypt(sender: RTCRtpSender, codec?: string, trackType?: string): void; + /** `trackType` only groups perf stats, keeping a peer's audio and video apart. */ + decrypt(receiver: RTCRtpReceiver, userId: string, trackType?: string): void; +} diff --git a/packages/client/src/rtc/e2ee/EncryptionManager.ts b/packages/client/src/rtc/e2ee/EncryptionManager.ts new file mode 100644 index 0000000000..1fa1ca3655 --- /dev/null +++ b/packages/client/src/rtc/e2ee/EncryptionManager.ts @@ -0,0 +1,321 @@ +import { preferredTransform } from './transformSupport'; +import { TypedEventEmitter } from '../../helpers/TypedEventEmitter'; +import type { E2EEEventMap } from './events'; +import type { E2EEManager } from './E2EEManager'; + +export type { + E2EEEventMap, + E2EEBrokenEvent, + DecryptionFailedEvent, + DecryptionResumedEvent, + EncryptionFailedEvent, + KeyStateReport, + MissingKeyEvent, + PerfReport, + TrackPerf, + UnencryptedFrameEvent, +} from './events'; + +/** + * - `'AES-128-GCM'` (default): 16-byte keys, per BSI TR-02102-1 §3.2. + * - `'AES-256-GCM'`: 32-byte keys. For a compliance reviewer that requires + * 256-bit strength, such as a KBV Anlage 31b certifier. + */ +export type E2EEAlgorithm = 'AES-128-GCM' | 'AES-256-GCM'; + +/** Options for {@link EncryptionManager.create}. */ +export type EncryptionManagerOptions = { + algorithm?: E2EEAlgorithm; +}; + +/** + * Distributes keys to the E2EE Web Worker and attaches encrypt/decrypt + * transforms to RTCRtpSenders and RTCRtpReceivers. + */ +export class EncryptionManager + extends TypedEventEmitter + implements E2EEManager +{ + private readonly algorithm: E2EEAlgorithm; + private readonly transform: 'script' | 'insertable'; + private disposed = false; + private piped?: WeakSet; + + private readonly userId: string; + private readonly worker: Worker; + private readonly workerUrl: string; + + private constructor( + userId: string, + worker: Worker, + workerUrl: string, + algorithm: E2EEAlgorithm, + transform: 'script' | 'insertable', + ) { + super('EncryptionManager'); + this.userId = userId; + this.worker = worker; + this.workerUrl = workerUrl; + this.algorithm = algorithm; + this.transform = transform; + this.worker.addEventListener('message', this.handleWorkerMessage); + this.worker.addEventListener('error', this.handleWorkerError); + } + + /** + * Whether this browser has WebRTC Encoded Transforms, which E2EE requires. + * Use it to guard UI, or to avoid calling {@link create} where it would throw. + */ + static isSupported = (): boolean => { + return preferredTransform() !== undefined; + }; + + /** + * Create an EncryptionManager instance and initialize the E2EE Web Worker. + * + * @param userId - The local user's ID, normally `call.currentUserId`. + * @param options - the create options. + * @throws {Error} If the browser lacks Encoded Transforms. + * + * @example + * ```ts + * if (EncryptionManager.isSupported()) { + * const e2ee = await EncryptionManager.create(call.currentUserId); + * call.setE2EEManager(e2ee); + * e2ee.setSharedKey(0, keyBytes); + * } + */ + static create = async ( + userId: string, + options?: EncryptionManagerOptions, + ): Promise => { + const transform = preferredTransform(); + if (!transform) { + throw new Error(`E2EE is not supported in this browser`); + } + const { e2eeWorker } = await import('./e2ee-worker'); + const blob = new Blob([`(${e2eeWorker.toString()})()`], { + type: 'application/javascript', + }); + const url = URL.createObjectURL(blob); + let worker: Worker; + try { + worker = new Worker(url, { name: 'stream-video-e2ee' }); + } catch (err) { + // e.g. a CSP `worker-src` without `blob:`. Don't leak the object URL. + URL.revokeObjectURL(url); + throw err; + } + const algorithm = options?.algorithm ?? 'AES-128-GCM'; + return new EncryptionManager(userId, worker, url, algorithm, transform); + }; + + /** + * {@link dispose} terminates the worker, so `postMessage` becomes a silent + * no-op and an attached transform points at a dead worker: frames would stall + * forever with no error and no event. Throwing is also fail-closed, since a + * caller that swallows it still publishes nothing rather than cleartext. + */ + private assertUsable = () => { + if (this.disposed) throw new Error(`EncryptionManager is disposed`); + }; + + /** + * Terminate the worker and release all resources. + * + * The manager is unusable afterwards and every other method throws. Call + * {@link create} for a new one. Safe to call more than once. + */ + dispose = (): void => { + if (this.disposed) return; + this.disposed = true; + this.piped = undefined; + this.worker.removeEventListener('message', this.handleWorkerMessage); + this.worker.removeEventListener('error', this.handleWorkerError); + this.worker.terminate(); + URL.revokeObjectURL(this.workerUrl); + this.removeAllListeners(); + }; + + /** + * Set a per-user AES-GCM encryption key in the worker's key store. + * + * Use it when each participant has their own key from a central authority. + * The receiver picks the right one by the `keyIndex` in the frame trailer. + * + * @param userId - The key owner. + * @param keyIndex - Increases with each rotation. + * @param rawKey - 16 bytes for AES-128-GCM, 32 for AES-256-GCM. + */ + setKey = (userId: string, keyIndex: number, rawKey: ArrayBuffer): void => { + this.assertUsable(); + this.validateKeyIndex(keyIndex); + this.validateKeyLength(rawKey); + this.worker.postMessage({ type: 'cmd.set_key', userId, keyIndex, rawKey }); + }; + + /** + * Fallback key for any user without a per-user key. The simplest E2EE mode: + * one key for everyone, usually passphrase-derived, no distribution needed. + * Setting an epoch makes it active for encryption while older epochs remain + * available to decrypt in-flight frames until {@link removeSharedKey}. + * + * @param keyIndex - An integer 0-255, since one trailer byte carries it. + * @param rawKey - 16 bytes for AES-128-GCM, 32 for AES-256-GCM. + */ + setSharedKey = (keyIndex: number, rawKey: ArrayBuffer): void => { + this.assertUsable(); + this.validateKeyIndex(keyIndex); + this.validateKeyLength(rawKey); + this.worker.postMessage({ type: 'cmd.set_shared_key', keyIndex, rawKey }); + }; + + /** + * Remove one shared-key epoch from the worker's receive key ring. + * + * If this is the active epoch, shared-key encryption stops until + * {@link setSharedKey} succeeds again. An older epoch is not reactivated. + * + * @param keyIndex - The exact shared-key epoch to remove. + */ + removeSharedKey = (keyIndex: number): void => { + this.assertUsable(); + this.validateKeyIndex(keyIndex); + this.worker.postMessage({ type: 'cmd.remove_shared_key', keyIndex }); + }; + + /** + * Drop a user's keys, revoking their ability to decrypt later frames. + * Call it when a participant leaves. + */ + removeKeys = (userId: string): void => { + this.assertUsable(); + this.worker.postMessage({ type: 'cmd.remove_keys', userId }); + }; + + /** + * Called by the Publisher when it adds a transceiver. + * + * @param sender - The sender to encrypt. + * @param codec - Codec name, e.g. 'vp8', selecting the clear-byte rules. + * @param trackType - Optional label; only groups perf stats. + * @internal + */ + encrypt = ( + sender: RTCRtpSender, + codec?: string, + trackType?: string, + ): void => { + this.assertUsable(); + this.pipe(sender, { + operation: 'encode', + userId: this.userId, + codec, + trackType, + }); + }; + + /** + * Called by the Subscriber when a remote track arrives. + * + * @param receiver - The receiver to decrypt. + * @param userId - The remote user, for key lookup in the worker. + * @param trackType - Optional label; only groups perf stats. + * @internal + */ + decrypt = ( + receiver: RTCRtpReceiver, + userId: string, + trackType?: string, + ): void => { + this.assertUsable(); + this.pipe(receiver, { operation: 'decode', userId, trackType }); + }; + + /** Pipe through the worker's transform, tracking targets to avoid double-piping. */ + private pipe = ( + target: RTCRtpSender | RTCRtpReceiver, + options: { + operation: string; + userId: string; + codec?: string; + trackType?: string; + }, + ): void => { + if (this.transform === 'script') { + target.transform = new RTCRtpScriptTransform(this.worker, options); + return; + } + + if ((this.piped ??= new WeakSet()).has(target)) return; + this.piped.add(target); + // @ts-expect-error createEncodedStreams is not in the standard typedefs + const { readable, writable } = target.createEncodedStreams(); + this.worker.postMessage( + { type: 'cmd.setup_transform', ...options, readable, writable }, + [readable, writable], + ); + }; + + /** + * Toggle periodic performance reporting from the E2EE worker. + * + * While on, the worker emits `e2ee.perf_report` once per second with per-track + * FPS and crypto timings. Useful for debugging throughput. + */ + enablePerformanceReporting = (enabled: boolean): void => { + this.assertUsable(); + this.worker.postMessage({ + type: 'cmd.enable_performance_reporting', + enabled, + }); + }; + + /** + * Request a snapshot of the worker's keys. It arrives later as the + * `e2ee.key_state` event, listing fingerprints only, never key material. + */ + requestKeyDump = (): void => { + this.assertUsable(); + this.worker.postMessage({ type: 'cmd.dump_key_state' }); + }; + + private handleWorkerMessage = (e: MessageEvent) => { + const { type, ...payload } = e.data ?? {}; + if (type === 'e2ee.error') { + this.logger.error(e.data.message); + return; + } + const event = type as keyof E2EEEventMap; + this.logger.debug('Dispatching', event, payload); + + this.emit(event, payload as never); + }; + + private handleWorkerError = (e: ErrorEvent) => { + this.logger.error('Unhandled worker error:', e.message); + }; + + private validateKeyLength = (rawKey: ArrayBuffer) => { + const is256 = this.algorithm === 'AES-256-GCM'; + const expected = is256 ? 32 : 16; + if (rawKey.byteLength !== expected) { + throw new Error( + `Key must be exactly ${expected} bytes (${is256 ? 'AES-256' : 'AES-128'})`, + ); + } + }; + + /** + * One trailer byte carries the keyIndex. A larger value would truncate to + * `keyIndex & 0xFF`, so the receiver would look up the wrong key and fail + * every decrypt. Reject it rather than ship a silently broken key epoch. + */ + private validateKeyIndex = (keyIndex: number) => { + if (!Number.isInteger(keyIndex) || keyIndex < 0 || keyIndex > 255) { + throw new Error( + `keyIndex must be an integer between 0 and 255, got ${keyIndex}`, + ); + } + }; +} diff --git a/packages/client/src/rtc/e2ee/SPEC.md b/packages/client/src/rtc/e2ee/SPEC.md new file mode 100644 index 0000000000..ae714d35ff --- /dev/null +++ b/packages/client/src/rtc/e2ee/SPEC.md @@ -0,0 +1,507 @@ +# Stream Video E2EE: cross-SDK implementation spec + +**Status:** draft, derived from the JS reference implementation in `packages/client/src/rtc/e2ee`. +**Audience:** SDK teams implementing E2EE for iOS, Android, Flutter, Unity. +**Goal:** every SDK exposes the same public API and produces byte-identical frames, so any two platforms interoperate in the same call. + +--- + +## 1. Overview + +Media frames are encrypted with **AES-GCM** inside a WebRTC _encoded transform_ (frame-level hook between encoder and packetizer, and between depacketizer and decoder). The SFU forwards ciphertext and never holds a key. + +There is **one on-wire scheme**, `version = 1`, used by every supported codec (Opus, VP8, VP9, H.264): + +``` +[ clear header ][ AES-GCM ciphertext + tag ][ 20-byte trailer ] +``` + +**AV1 is not supported.** + +**Endianness:** all multi-byte integers are **big-endian**. + +--- + +## 2. Public API contract + +Every SDK should expose the same shape. Names may be idiomatic per platform (`isSupported` -> `isSupported()`, `is_supported`, etc.), but semantics must match. + +```ts +// capability check, before showing any E2EE UI +EncryptionManager.isSupported(): boolean + +// construction; binds the manager to the local user +EncryptionManager.create(userId, { + algorithm?: 'AES-128-GCM' | 'AES-256-GCM', // default AES-128-GCM +}): Promise + +// key distribution (host-owned: the SDK never derives or exchanges keys) +setKey(userId: string, keyIndex: number, rawKey: bytes): void +setSharedKey(keyIndex: number, rawKey: bytes): void +removeKeys(userId: string): void +removeSharedKey(keyIndex: number): void + +// diagnostics +enablePerformanceReporting(enabled: boolean): void +requestKeyDump(): void + +// lifecycle +dispose(): void + +// events (see §10) +on(event, handler) / off(event, handler) +``` + +Wiring into a call: + +```ts +import { EncryptionManager } from '@stream-io/video-react-sdk'; + +const call = client.call(type, id); +if (EncryptionManager.isSupported()) { + const e2ee = await EncryptionManager.create(call.currentUserId); + e2ee.setSharedKey(0, keyBytes); + call.setE2EEManager(e2ee); // MUST be called before join() +} + +await call.join(); +``` + +### Rules + +- **`create` throws when E2EE cannot run**, rather than degrading: no Encoded Transform API in this browser, or the worker could not be constructed (on web, a CSP `worker-src` that omits `blob:` is the common cause). Guard with `isSupported()` and handle the rejection. There is deliberately no silent-fallback mode, because falling back means publishing cleartext on a call the user was told is encrypted. +- **`setE2EEManager` before `join`.** The peer connections must be created with the transform hook in place. Calling it after join throws. +- **`keyIndex` is 0-255** (one trailer byte). Reject anything else at the API boundary. The index identifies a key slot, not a monotonic sequence, so a long-running rotation may wrap 255 → 0 and re-use low indices. Reusing an occupied index replaces that slot immediately, so the host must not reuse it until frames from the old epoch can no longer be in flight. +- **`rawKey` is exactly 16 bytes** (AES-128) or **32 bytes** (AES-256). Reject other lengths. +- **The key buffer is copied, not consumed.** Callers may re-import the same bytes. +- A successful **`setSharedKey(keyIndex, rawKey)`** stores or replaces that shared receive epoch and makes it the active shared epoch for encryption. A failed import changes neither the key ring nor the active epoch. +- **`removeSharedKey(keyIndex)`** removes exactly that shared receive epoch. Removing the active epoch disables shared-key encryption until another `setSharedKey` succeeds; an older retained epoch is never reactivated implicitly. +- **Join request carries `e2ee: true`** so the backend knows the call is encrypted. +- The internal attach points (`encrypt(sender, codec, trackType)` / `decrypt(receiver, userId, trackType)`) are called by the RTC layer, not by apps. Keeping them behind a small interface lets an integrator plug in a different scheme (e.g. SFrame). + +--- + +## 3. Key management and identity + +A per-user key is identified by **`(userId, keyIndex)`**. A shared key is identified by +**`keyIndex`**. + +- **Per-user keys** (`setKey`): the encoder looks up the local user's latest key; each decoder looks up the remote sender's key by the `keyIndex` carried in the frame. +- **Shared keys** (`setSharedKey`): an indexed fallback receive-key ring used for any user without a per-user key at the requested index. The most recently imported shared epoch is explicitly active for encryption; older epochs remain receive-only so delayed frames survive a rotation. +- **Resolution on decode:** per-user entry at that `keyIndex` first; else the shared-key entry at that index; else no key (frame dropped, `missing_key` fired). +- **Resolution on encode:** the most recently imported per-user key for the local user; else the active shared key. Retained inactive shared epochs must never be selected for encryption. +- **`removeKeys(userId)`** drops that user's key material but **must not reset the frame counter** (see §9). +- **`removeSharedKey(keyIndex)`** drops only that shared epoch. If it was active, shared-key encode fallback becomes unavailable; retained older epochs remain usable for decryption but inactive for encryption. + +### Shared-key rotation + +The host owns the grace period and retires old epochs explicitly: + +```ts +manager.setSharedKey(7, nextKey); // epoch 7 becomes active; older epochs remain +// distribute epoch 7 and allow delayed frames from the prior epoch to drain +manager.removeSharedKey(6); // epoch 6 can no longer decrypt +``` + +All participants must use the same `keyIndex` for the same shared key. Do not reuse an +index while frames encrypted with its previous material may still arrive: the new import +replaces that slot, so those delayed frames would resolve the replacement key and fail +authentication. + +### Per-import state + +Every key import generates: + +- a fresh **random 8-byte `ivPrefix`** (sender-side only), and +- an 8-byte **fingerprint** = first 8 bytes of `SHA-256(rawKey)`, for diagnostics only. + +The fresh prefix per import is what makes re-importing the same raw key safe: it cannot reproduce an `(ivPrefix, counter)` pair from an earlier import. **Receivers never consult a local prefix** - they read it from the frame. + +The prefix must come from a **cryptographic RNG**, never from a user id, session id, timestamp or counter. Under a shared key it is the only thing separating one participant's IVs from another's - see the warning in §9. + +--- + +## 4. Crypto primitives + +- **Cipher:** AES-GCM, 128-bit tag (the default; the tag is appended to the ciphertext). +- **Key sizes:** 128-bit or 256-bit, selected at manager construction. +- **IV:** 12 bytes, `ivPrefix (8) || frameCounter (4, big-endian)`. +- **Frame counter:** a single monotonic counter per manager, **shared across all of the local user's tracks and codecs**. Starts at 0, first frame uses 1. +- **AAD:** the frame's clear header. Authenticated, not encrypted - see §5.3. + +> **IV-uniqueness contract.** For a given key, never encrypt two different frames under the same **IV**, where `IV = ivPrefix ∥ counter`. Reusing one is catastrophic under AES-GCM. +> +> Note the invariant is on the IV, not on the counter. **`(key, counter)` repeats constantly and that is expected**: under a shared key every participant starts at counter 1, as does the same user on a second device, and so does any sender that reconnects with a fresh manager. Two mechanisms cover two different scopes, and both are load-bearing: +> +> - **Within one sender**, the monotonic counter separates frames. It must be **one counter for the whole manager, not one per track** - a per-track counter would let one user's audio and video frames land on the same counter under the same key. +> - **Between senders**, the counter separates nothing, because they all start near 0. Only the random per-import `ivPrefix` keeps them apart, which is why §9 requires it to come from a cryptographic RNG. + +--- + +## 5. Trailer format (`version = 1`) + +Applies to Opus, VP8, VP9, H.264, and audio frames whose codec was not supplied. Video with no codec fails closed (§5.1). + +``` ++------------------+----------------------------+-------------------+ +| clear header | AES-GCM ciphertext + tag | trailer (20 B) | +| (clearBytes) | | | ++------------------+----------------------------+-------------------+ + | + +-- plaintext, and passed as AAD (§5.3) +``` + +### 5.1 Clear bytes per codec + +The leading `clearBytes` bytes stay in plaintext so the SFU can still read frame headers (keyframe detection, layer selection). They are passed as AAD, so the SFU can read them but cannot alter them undetected (§5.3). + +| Codec (`codec` string) | Clear bytes | +| ---------------------------- | ------------------------------------------------------------------------------------------------ | +| `opus` / any audio frame | **1** (the Opus TOC byte) | +| `vp8` | **10** on keyframes, **3** on delta frames | +| `vp9` | **10** on keyframes, **3** on delta frames | +| `h264` | offset of the first slice NALU's **header byte + 2** (see §5.4); **0** if no slice NALU is found | +| codec not supplied/supported | **fail closed**: drop the frame, emit `encryption_failed` | + +**Clamp `clearBytes` to the frame length, in every rule.** A frame shorter than its nominal clear header is rare but not impossible, and the clamp is part of the wire format rather than a defensive nicety: an SDK that omits it computes a larger `clearBytes` than one that applies it, so the two sides build AADs of different **lengths**, and a length mismatch fails the tag every time (§5.3, consequence 2). The encoder would also read past the end of the frame to assemble the header. + +### 5.2 Trailer layout (20 bytes, appended at the end of the frame) + +| Offset | Size | Field | Notes | +| ------ | ---- | --------------------- | -------------------------------------------------------- | +| 0 | 4 | `frameCounter` | big-endian, low 32 bits of the IV | +| 4 | 8 | `ivPrefix` | sender's random prefix for this key import | +| 12 | 1 | `keyIndex` | 0-255 | +| 13 | 2 | `clearBytes \| flags` | bit 15 = `RBSP_FLAG`, bits 0-14 = clearBytes (max 32767) | +| 15 | 1 | `version` | `0x01` | +| 16 | 4 | `magic` | `0xE2EEFEED` | + +**Overhead:** 16 (GCM tag) + 20 (trailer) = **36 bytes per frame**, plus RBSP escape bytes for H.264. + +### 5.3 AAD (Additional Authenticated Data) + +AES-GCM is an AEAD cipher: it takes a third input alongside the key and the plaintext, called the _additional authenticated data_. The AAD is **covered by the authentication tag but not encrypted**. It is also **not part of the output** - GCM does not transmit it. Both sides must supply the identical bytes independently, or the tag check fails. + +``` +AAD = frame[0 .. clearBytes) # the clear header; empty when clearBytes == 0 +plaintext = frame[clearBytes .. end] +``` + +The receiver does not need the AAD delivered separately: it is the literal first `clearBytes` bytes of the frame it just received, and `clearBytes` is in the trailer. + +**Why the clear header is AAD and not just plaintext.** The SFU must read codec headers to detect keyframes and select layers, so those bytes cannot be encrypted. But leaving them merely unencrypted would let any relay rewrite them undetected - flip a frame's keyframe bit and the receiver's decoder desynchronises. Making them AAD gives the SFU **read access without write access**: it can parse the header, and any modification to it breaks the tag on the very next decrypt. + +**Three consequences implementers must respect:** + +1. **Byte-exact agreement.** One differing byte in the AAD makes decryption fail. There is no partial match. +2. **Length is part of the agreement.** An AAD of a different _length_ fails just as hard as different content. +3. **Empty AAD is normal**, not an error case. It happens on H.264 with no slice NALU, and on video with a codec the encoder does not recognize. Pass a zero-length buffer; in GCM that is equivalent to supplying no AAD at all, so either form interoperates. + +**What is not in the AAD.** The 20-byte trailer is excluded. Every field except `version` and `magic` still fails closed: + +| Tampered field | Result | +| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `frameCounter`, `ivPrefix` | different IV, tag fails, frame dropped | +| `keyIndex` | wrong key or no key, tag fails or `missing_key`, frame dropped | +| `clearBytes` / RBSP flag | different AAD and a different ciphertext split, tag fails, frame dropped | +| `version`, `magic` | the frame is no longer recognized as encrypted. It is **forwarded to the decoder as ciphertext** and reported as `unencrypted_frame` (§7) | + +The last row is the one worth knowing: it is not a decryption failure, it is a downgrade. Media renders as garbage rather than being dropped. + +### 5.4 H.264 quirk: RBSP escaping + +**Problem.** Ciphertext is uniformly random and will contain `00 00 01` / `00 00 00 01` byte runs. `libwebrtc` H.264 packetizer scans the frame for Annex-B start codes and would split it at those false boundaries, destroying the frame. + +**Rule.** For H.264 with `clearBytes > 0`, apply RBSP emulation-prevention over **ciphertext and trailer as one contiguous stream** before appending: + +- Insert `0x03` after any `00 00` run when the next byte is `0x00`-`0x03`. +- **Seed the escaper with the clear header's trailing `0x00` count, capped at 2.** The packetizer scans the whole frame, and the header's tail and the escaped stream are contiguous on the wire: a header ending in `0x00` followed by ciphertext starting `00 01` would otherwise form a start code across the boundary that the escaper never saw. Browser encoders do not produce a header ending in `0x00` (the last clear byte is the first slice-header byte, and `first_mb_in_slice = 0` forces its top bit on), but multi-slice hardware encoders can, so the seed is part of the wire format. Both sides derive it from the same clear bytes; nothing extra travels in the frame. +- Set `RBSP_FLAG` (bit 15 of the `clearBytes` field). +- Frame becomes `[clear header][ escaped(ciphertext || trailer) ]`. + +**Why the trailer is escaped with the ciphertext, yet still readable.** The last **7** trailer bytes (`clearBytes|flag`, `version`, `magic`) are start-code-safe by construction: the RBSP flag forces the `clearBytes` high byte to `>= 0x80`, which breaks any zero run reaching them. They therefore pass through escaping unchanged, and the decoder can read them straight off the raw frame tail to discover `clearBytes` and the RBSP flag. The first 13 bytes (`frameCounter`, `ivPrefix`, `keyIndex`) sit **inside** the escaped region and are only valid after un-escaping. + +**Decode order for an RBSP frame:** + +1. Read the raw tail to get `clearBytes`, `isRbsp`, `version`, `magic`. +2. Un-escape `frame[clearBytes .. end]`, seeded with the clear header's trailing `0x00` count (capped at 2) exactly like the encoder, so an escape byte sitting right at the boundary is recognized. +3. If the un-escaped unit is shorter than 20 bytes, **drop the frame** (both `clearBytes` and the flag are plaintext, so a relay can forge exactly this shape). +4. Re-read `frameCounter` / `ivPrefix` / `keyIndex` from the un-escaped unit's last 20 bytes. +5. Ciphertext is the un-escaped unit minus its last 20 bytes. + +**Finding the slice NALU.** Walk Annex-B start codes; the first NALU whose `nal_unit_type` (`byte & 0x1F`) is **1** (non-IDR slice) or **5** (IDR slice) ends the clear header at `startCodePos + startCodeLen + 2`, clamped to the frame length. That keeps the start code, the NALU header byte, and one byte of slice header in the clear. + +> **Known limitation.** If no slice NALU is found, `clearBytes` is 0 and escaping is skipped, so the ciphertext may contain false start codes. In practice every encoder-emitted frame contains a slice NALU. + +--- + +## 6. Encode algorithm + +``` +on frame: + if frame.payload is empty: forward unchanged + key, keyIndex, ivPrefix = latestKeyFor(localUserId) + if no key: drop frame, emit missing_key (throttled); return + + // A key/delta type marks video. Checked before the counter, so a dropped + // frame costs no IV. + if profileFor(codec).audioOnly and frame.type is set: + drop, emit encryption_failed; return + + clearBytes = clearBytesFor(codec, frame.type, frame.data) + if clearBytes > 0x7FFF: drop, emit encryption_failed; return + isRbsp = isH264 && clearBytes > 0 + counter = nextFrameCounter() // may throw at the ceiling + iv = ivPrefix || counter + aad = frame[0 .. clearBytes) + ct = AES-GCM-encrypt(key, iv, aad, frame[clearBytes ..]) + trailer = writeTrailer(counter, ivPrefix, keyIndex, clearBytes, isRbsp) + out = isRbsp ? aad || rbspEscape(ct || trailer, seed = trailingZeros(aad, max 2)) + : aad || ct || trailer + forward out +``` + +**Fail closed, always.** Any error on the encode path drops the frame. A frame is never forwarded in the clear because encryption failed. + +**Unsupported codecs.** A named codec with no clear-byte rule (AV1 today, H.265, anything unrecognized) installs a transform that drops every frame and emits `encryption_failed` once. This is the safety net, not the plan: the SFU must not negotiate AV1 on an encrypted call, because the net costs the track entirely. + +## 7. Decode algorithm + +The decoder is **codec-agnostic**: the format is self-describing. Do not pass a codec hint to the decode transform. + +``` +on frame: + if frame.payload is empty: forward unchanged + + trailer = readTrailer(frame) // magic == 0xE2EEFEED && version == 1 + if trailer == null: + emit unencrypted_frame (throttled); forward unchanged; return + -> recover ciphertext + IV fields (RBSP path per §5.4) + -> decode with (keyIndex, ivPrefix, frameCounter) + +decode with (keyIndex, ivPrefix, counter): + key = resolveKey(senderUserId, keyIndex) + if no key: drop, emit missing_key(keyIndex) (throttled); return + if not replayWindow.peek(counter, ivPrefix): drop silently; return + try: + plaintext = decrypt(...) + replayWindow.commit(counter, ivPrefix) // only after authentication + failures.clearFailures(keyIndex) // gates `broken`, nothing else + emit decryption_resumed // unthrottled and paired; §10 + forward clearHeader || plaintext + catch: + if failures.recordFailure(keyIndex) crosses tolerance: emit broken + emit decryption_failed (throttled) + drop +``` + +**`readTrailer` validation order:** length >= 20, then `magic == 0xE2EEFEED`, then `version == 1`, then `clearBytes <= frameLength - 20`. Any mismatch means "not our trailer" - forward the frame as cleartext rather than attempting a decrypt. + +An unknown version must be treated as **not ours**, not as an error. That is what keeps unrelated frames that happen to end in `0xE2EEFEED` from producing spurious failures, and what lets a future version coexist. + +The magic is a heuristic, not a guarantee: any 4-byte value collides with random data at 2^-32. `0xE2EEFEED` is chosen to be an unlikely accident rather than a common one - a widespread debug fill such as `0xDEADBEEF` shows up in real buffers far more often than a value nothing else uses. No byte of it is `0x00` or `<= 0x03` either, which is what keeps the start-code-safe trailer tail safe (§5.4). + +> **Consequence worth knowing.** `unencrypted_frame` therefore also fires for a frame that _is_ encrypted but by a peer on a different `version`. The frame is then handed to the decoder as ciphertext, so the symptom is corrupt media plus an event that reads as a downgrade. Version skew across SDKs must be avoided rather than detected. + +--- + +## 8. Receiver hardening + +### Trust ordering (the SFrame / SRTP rule) + +Everything read before the decrypt call (`frameCounter`, `ivPrefix`, `keyIndex`, `clearBytes`, the RBSP flag) is **plaintext and forgeable by a relay**. Nothing may mutate trust state until GCM authenticates the frame. + +- The replay window is **peeked** before decrypt and **committed** only after success. +- The failure counter is diagnostic only. It gates the `broken` signal; it never gates a decrypt attempt. A burst of forged frames must not be able to latch a genuine key invalid. + +### Replay window + +Scoped **per remote track**, not per user. Remote tracks travel on independent SSRCs with independent jitter buffers; a shared window would let one track's delivery skew reject the other's frames. + +- **Window:** 1024 frames, RFC 6479-style sliding bitmap over the counter. +- **Accept** if the counter is above the high-water mark, or within the window and not already seen. **Reject** if `counter <= highest - 1024` or already seen. +- Rejections are **silent drops**, not decryption failures. +- Within a track, the window is partitioned by **sender `ivPrefix` ("epochs")**. A sender restart or key re-import brings a fresh prefix and a counter that restarts low; a fresh epoch gives it a clean window instead of rejecting it against a stale high-water mark. +- Keep at most **3 epochs**, most-recent first, evicting the oldest. Epochs are created and evicted **only by `commit`**, i.e. only by authenticated frames, so a relay cannot fabricate novel-prefix frames to evict a genuine epoch. + +### Failure tolerance + +Consecutive decryption failures are counted **per track, per `keyIndex`**. After **10** consecutive failures, the 11th fires `broken` exactly once per failure run. A successful decrypt clears the count for that `keyIndex`, and also fires `decryption_resumed` - but keep the two independent. The count gates `broken` and nothing else; the recovery is gated separately, on whether a failure was ever delivered to the host (§10). + +Per-track scoping is load-bearing: a counter shared across a user's tracks lets one track's healthy frames reset another's failures, so the threshold is never crossed and `broken` can never fire. + +### Throttling + +The _level_ notifications (`missing_key`, `decryption_failed`, `unencrypted_frame`) are throttled to **at most one per second** per key (per user, or per keyIndex where noted), so a sustained failure cannot flood the host. + +`decryption_resumed` is an _edge_ and is **never throttled** - throttling it would drop a state transition permanently. It is bounded by pairing instead: it is emitted only for a failure that actually reached the host, and those are throttled. See §10. + +--- + +## 9. Counter exhaustion + +The counter is a 32-bit IV field. **It must never wrap** - wrapping would fold into a previously used `(ivPrefix, counter)` pair, which is catastrophic under AES-GCM. Check before incrementing and fail closed: + +``` +c = counter + 1 +if c > 0xFFFFFFFF: + throw # do NOT store c - the counter stays pinned at the ceiling +counter = c +``` + +The throw propagates out of the encode path, so the frame is dropped and `encryption_failed` is emitted. **This ceiling is the only counter threshold; nothing fires below it.** + +**Hold the counter as a single value, not as a map keyed by user id.** A manager is bound to one local user at construction and only the encode path draws from the counter, so keying it buys nothing - but it costs a failure mode: a wrong or changed id hands out a _fresh_ counter starting at 1, which under the same key and `ivPrefix` is exactly the IV reuse this ceiling exists to prevent. A single value cannot do that. + +> **Why IV reuse is catastrophic, and not merely a leak.** GCM is CTR mode plus GHASH, and a repeated IV breaks both halves. +> +> The keystream is a function of `(key, IV)` alone, so two frames encrypted under the same one give `C1 ⊕ C2 = P1 ⊕ P2`: the keystream cancels and the plaintexts leak against each other. Video frames are highly correlated and partly predictable, so that XOR is close to recovering both. +> +> The authentication failure is worse. The tag is `GHASH_H(A, C) ⊕ E_K(J0)`, and `J0` derives from the IV, so on a collision the `E_K(J0)` mask cancels too: `T1 ⊕ T2` leaves a polynomial whose only unknown is the GHASH subkey `H`. Solving it recovers `H`, and an attacker holding `H` can forge a valid tag for **any** frame under that key, not only the two that collided. This is the "forbidden attack", demonstrated in practice against TLS stacks that repeated a nonce. +> +> A wrap is the guaranteed form of this: `ivPrefix` is fixed for the key's lifetime, so the IV is a pure function of the counter, and wrapping replays the entire IV sequence in order. Hence fail closed rather than wrap. + +**The counter is scoped to the manager, not to the key.** It must survive key imports and removals untouched, and reset only when the manager is torn down: + +| Action | Frame counter | `ivPrefix` | Key state | +| -------------------------------------- | ----------------------- | ------------ | -------------------------------- | +| `setKey` / `setSharedKey` | unchanged, keeps rising | fresh random | slot added or replaced | +| `removeKeys` | unchanged, keeps rising | dropped | user's slots removed | +| `removeSharedKey` on an inactive epoch | unchanged, keeps rising | dropped | shared slot removed | +| `removeSharedKey` on the active epoch | unchanged, keeps rising | dropped | slot removed; no active fallback | +| new manager instance | reset to 0 | - | empty | + +Two guards keep IVs unique within one sender: the persistent counter, and the fresh random `ivPrefix` per import. Resetting the counter on import would collapse them into one. + +> **The `ivPrefix` RNG is load-bearing on its own - do not weaken it.** The counter only separates IVs _within_ a single sender. It contributes nothing **between** senders, and under a shared key that is exactly the case that matters: every participant holds the same AES key, and every participant's counter independently starts at 0, so participant A's first frame uses `P_A || 1` and B's uses `P_B || 1`. Only `P_A != P_B` keeps them apart. With 8 random bytes the collision probability across _n_ participants is about `n² / 2^65` (~3e-16 for 100 participants), which is why 64 bits suffice - but it means the prefix must be **8 bytes from a cryptographic RNG, generated fresh on every import**. Deriving it from a user id, session id, timestamp, or counter, reusing one across imports, or shortening it, breaks AES-GCM outright for the entire call. This is the single easiest thing to get wrong when porting. + +**Consequence: a rekey cannot recover an exhausted sender.** Rotation gives a disjoint IV space but not a fresh budget, and the failing call does not advance the counter, so every later frame fails identically and the track publishes nothing for the rest of the manager's life. The only recovery is a new manager. Say so in the error message: one that points at rekeying sends integrators down a path that cannot work. + +Because `encryption_failed` is latched per track, the host sees one event per track and then silence, not a per-frame flood. + +### How long is the budget? + +The counter is shared across **all** of a sender's tracks, so the aggregate frame rate is what matters: + +``` +months ≈ 2^32 / (aggregate frames per second) / 2.6e6 +``` + +Worked example, a typical camera call: Opus at 20 ms ptime contributes 50 fps, and a 30 fps camera track with 3 simulcast layers contributes 90 fps (each layer's frames traverse the transform separately), so ~140 fps aggregate. + +| Case | Aggregate | Hard stop at 2^32 | +| ---------------------------------------- | --------- | ----------------- | +| Camera + mic, 3 simulcast layers | ~140 fps | **~12 months** | +| Camera + mic, single stream (SVC, 1 rid) | ~80 fps | ~20 months | + +That is **continuous publishing in a single session**, and counters reset with each new manager, so no real call approaches it. This is a correctness guard, not an operational event. Do not add an early-warning signal below the ceiling: it would fire only after ~6 months, and it could name no remedy that works, since a rotation cannot restore the budget. Do not skip the ceiling check on the same reasoning: a per-track counter, or one that resets on rekey, turns "never happens" into IV reuse. + +--- + +## 10. Events + +Event names are listed below unprefixed. On the wire and in the JS API they carry an `e2ee.` prefix (`e2ee.missing_key`, `e2ee.broken`, ...); keep that convention so E2EE events stay distinguishable from SFU and coordinator events. + +| Event | Payload | Fires when | Host action | +| ------------------------------- | --------------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| `missing_key` (no `keyIndex`) | `userId` | encoder has no local key; **every outgoing track is dropped** | provide a key | +| `missing_key` (with `keyIndex`) | `userId`, `keyIndex`, `trackType` | a remote frame referenced a key this peer does not hold | usually benign: key distribution or rotation in flight | +| `decryption_failed` | `userId`, `trackType` | GCM tag failure on a remote frame | key mismatch, rotation, or tampering | +| `decryption_resumed` | `userId`, `trackType` | that track decrypts again | clear the warning raised by `decryption_failed` | +| `encryption_failed` | `userId`, `trackType`, `reason` | an outgoing frame could not be encrypted | that track is publishing nothing | +| `broken` | `userId`, `keyIndex`, `trackType` | 10+ consecutive failures for `(track, keyIndex)` | surface to the user; redistribute keys | +| `unencrypted_frame` | `userId`, `trackType` | a remote frame carried no E2EE framing and was forwarded as-is | expected when the call's mode allows plain publishers; otherwise a downgrade | +| `perf_report` | per-track encode/decode samples | once per second when perf reporting is on | diagnostics | +| `key_state` | `KeyStateReport` | in response to `requestKeyDump` | diagnostics | + +`missing_key` is deliberately distinct from `decryption_failed`: a host cannot otherwise tell "key not here yet" from "key mismatch or tampering". + +### Rules for emitting these + +**Carry `trackType` on anything reported per track.** Every event above except the encode-side `missing_key` is raised inside one track's transform, so without it a peer's audio, video and screen share produce byte-identical messages the host cannot tell apart or act on. The encode-side `missing_key` is the one genuine exception: the local user holds no key at all, which stalls every outgoing track at once, so it is reported once for the user and carries no track. + +**Levels may be throttled; edges may not.** `decryption_failed`, `missing_key` and `unencrypted_frame` are _levels_ - they describe a condition that persists, so throttling them to one per second per track is safe, because the next frame re-raises the same condition. + +`decryption_resumed` is an _edge_. Throttling it drops a state transition permanently and strands the host on `decryption_failed` for a track that has recovered. Emit it **unthrottled**, paired one-to-one with delivered failures: + +``` +on decryption failure: + if failureThrottle.tryFire(): + failureReported = true + emit decryption_failed + +on successful decrypt: + clear the per-keyIndex failure count # gates `broken` + if failureReported: # gates `resumed` + failureReported = false + emit decryption_resumed +``` + +Pairing bounds the rate for free: a recovery can only be emitted for a failure that was emitted, and those are throttled. Two details follow from it, and both are load-bearing: + +- Do **not** gate the recovery on the failing `keyIndex`'s own count. `decryption_failed` names a track, not a key epoch, so a track that recovers by rotating to a **new** `keyIndex` must still clear it - otherwise the host stays latched on failed forever. +- Do **not** emit a recovery for a failure that was throttled away. The host never heard about it, so there is nothing to clear. + +**`encryption_failed` is latched per track**, re-arming when a frame encrypts again. A permanently dead track therefore reports once, not once per frame. + +`key_state` returns per-user and shared keys with their **fingerprints only** (hex of the first 8 bytes of `SHA-256(rawKey)`). Raw key material must never leave the worker. +At most one shared entry has `isActive: true`. It is valid for every shared entry to be inactive after the active epoch is removed. + +--- + +## 11. Conformance test vectors + +All vectors use: + +- key = `000102030405060708090a0b0c0d0e0f` (AES-128-GCM) +- `keyIndex` = 0 +- `frameCounter` = 1 + +All vectors use `ivPrefix` = `1111111111111111`. + +**Opus** (audio frame, `clearBytes` = 1) + +``` +in 78aabbccdd +out 78 + d02bf795e85c0bed034f7b282ca617cf76d57eb0 <- ciphertext + tag + 00000001 1111111111111111 00 0001 01 e2eefeed +``` + +**VP8 keyframe** (`clearBytes` = 10) + +``` +in 10111213141516171819aabb +out 10111213141516171819 + d02b0484ce4aa2b21a4a83cbfe2ed6511c68 + 00000001 1111111111111111 00 000a 01 e2eefeed +``` + +**H.264 IDR** (`clearBytes` = 6, RBSP path; note `0x03` inserted before the counter, and `clearBytes` encoded as `0x8006`) + +``` +in 00000001 65 8884deadbe +out 000000016588 + fe4e96f631df11f57a43ed2003eaad0c5d6db632 <- ciphertext + tag + 0000030001 1111111111111111 00 8006 01 e2eefeed <- escaped trailer +``` + +A new implementation is conformant when it reproduces the bytes above exactly, decrypts them back to the inputs, and handles both fail-closed cases. All three vectors were regenerated from the reference implementation and verified to round-trip. + +--- + +## 12. Versioning rules + +- Bump `version` only when the trailer layout or IV derivation changes, and only in lockstep across SDKs. +- A receiver seeing an unknown version must treat the frame as **not ours** (forward as cleartext with `unencrypted_frame`), never as a decryption failure. +- Any change to the AAD composition, the clear-byte rules, or the escaping rules is a wire break and requires a version bump plus new vectors in §11. + +--- + +## 13. Platform notes and open items + +- **Transform API (web only):** Chrome ships `RTCRtpScriptTransform` but it is still unreliable for E2EE, so the JS SDK puts Chrome on the legacy Insertable Streams path (`createEncodedStreams`, which additionally requires the non-standard `encodedInsertableStreams` flag on the `RTCPeerConnection`). Firefox and Safari always use `RTCRtpScriptTransform`. The selection is not configurable. Native SDKs have their own frame-transformer hooks and can ignore this. +- **Key exchange is out of scope.** The SDK transports frames; the host owns key derivation, distribution and rotation. +- **RED (audio redundancy)** is applied by the packetizer, after the encode transform, so it does not affect this format. +- **H.264 with no slice NALU** falls back to whole-frame encryption without escaping (§5.4). Flag if any platform's encoder can actually produce this. +- **AV1 is out of scope for the initial release** (§1). The SFU must not negotiate it on an encrypted call; the client-side fail-closed is a net, not a fallback. Adding it later means a second framing scheme, a new version number, and new vectors. diff --git a/packages/client/src/rtc/e2ee/__tests__/E2EEManager.contract.test.ts b/packages/client/src/rtc/e2ee/__tests__/E2EEManager.contract.test.ts new file mode 100644 index 0000000000..368463ae90 --- /dev/null +++ b/packages/client/src/rtc/e2ee/__tests__/E2EEManager.contract.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import type { E2EEManager } from '../E2EEManager'; + +/** + * `Call.setE2EEManager` accepts any {@link E2EEManager}, so a third party can + * plug in another scheme (RFC 9605 SFrame, say) instead of the built-in one. + * + * That contract is a compile-time property, so the assertion that matters here + * is the type annotation below, not the expectations in the test body: adding a + * required member to the interface breaks the build in this file, which is the + * signal worth raising, since it is a breaking change for implementors. + */ +const attached: string[] = []; + +const customImplementation: E2EEManager = { + // Parameters are contextually typed from the annotation, so a signature the + // interface does not describe fails to compile. + encrypt: (sender, codec, trackType) => { + attached.push(`encode:${sender.track?.kind}:${codec}:${trackType}`); + }, + decrypt: (receiver, userId, trackType) => { + attached.push(`decode:${receiver.track?.kind}:${userId}:${trackType}`); + }, +}; + +describe('E2EEManager contract', () => { + it('is satisfied by an implementation providing only encrypt and decrypt', () => { + const sender = { track: { kind: 'video' } } as RTCRtpSender; + const receiver = { track: { kind: 'audio' } } as RTCRtpReceiver; + + customImplementation.encrypt(sender, 'vp8', 'VIDEO'); + customImplementation.decrypt(receiver, 'bob', 'AUDIO'); + + expect(attached).toEqual([ + 'encode:video:vp8:VIDEO', + 'decode:audio:bob:AUDIO', + ]); + }); +}); diff --git a/packages/client/src/rtc/e2ee/__tests__/EncryptionManager.test.ts b/packages/client/src/rtc/e2ee/__tests__/EncryptionManager.test.ts new file mode 100644 index 0000000000..7bd3bfab77 --- /dev/null +++ b/packages/client/src/rtc/e2ee/__tests__/EncryptionManager.test.ts @@ -0,0 +1,464 @@ +import '../../__tests__/mocks/webrtc.mocks'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { EncryptionManager } from '../EncryptionManager'; +import type { E2EEEventMap } from '../events'; +import { isChrome } from '../../../helpers/browsers'; + +// Mock the worker module so create() doesn't need the real bundled function +vi.mock('../e2ee-worker', () => ({ + e2eeWorker: function () { + self.onmessage = () => {}; + }, +})); + +// Mock browser detection so we can drive the Chrome vs non-Chrome transform +// selection deterministically. Defaults to non-Chrome (reset in beforeEach). +vi.mock('../../../helpers/browsers', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, isChrome: vi.fn().mockReturnValue(false) }; +}); + +describe('EncryptionManager', () => { + let manager: EncryptionManager; + + beforeEach(async () => { + vi.mocked(isChrome).mockReturnValue(false); + manager = await EncryptionManager.create('local-user'); + }); + + afterEach(() => { + manager.dispose(); + }); + + describe('isSupported', () => { + it('returns true when RTCRtpScriptTransform is available', () => { + expect(EncryptionManager.isSupported()).toBe(true); + }); + + it('returns false when neither API is available', () => { + const original = globalThis.RTCRtpScriptTransform; + // @ts-expect-error test case + delete globalThis.RTCRtpScriptTransform; + + try { + expect(EncryptionManager.isSupported()).toBe(false); + } finally { + globalThis.RTCRtpScriptTransform = original; + } + }); + }); + + describe('create', () => { + it('creates a manager with a worker', async () => { + const mgr = await EncryptionManager.create('user-123'); + expect(mgr).toBeInstanceOf(EncryptionManager); + mgr.dispose(); + }); + + it('revokes the blob URL if the Worker constructor throws', async () => { + // e.g. a CSP `worker-src` that disallows `blob:`. The object URL created + // just before must not leak. + const revokeSpy = vi.spyOn(URL, 'revokeObjectURL'); + const OriginalWorker = globalThis.Worker; + // @ts-expect-error replace the global Worker for this test + globalThis.Worker = class { + constructor() { + throw new Error('worker-src blocked by CSP'); + } + }; + try { + await expect(EncryptionManager.create('u')).rejects.toThrow(/CSP/); + expect(revokeSpy).toHaveBeenCalled(); + } finally { + globalThis.Worker = OriginalWorker; + revokeSpy.mockRestore(); + } + }); + }); + + describe('worker commands', () => { + const rawKey = new ArrayBuffer(16); + + it.each([ + [ + 'setKey', + () => manager.setKey('remote-user', 0, rawKey), + { type: 'cmd.set_key', userId: 'remote-user', keyIndex: 0, rawKey }, + ], + [ + 'setSharedKey', + () => manager.setSharedKey(0, rawKey), + { type: 'cmd.set_shared_key', keyIndex: 0, rawKey }, + ], + [ + 'removeSharedKey', + () => manager.removeSharedKey(0), + { type: 'cmd.remove_shared_key', keyIndex: 0 }, + ], + [ + 'removeKeys', + () => manager.removeKeys('remote-user'), + { type: 'cmd.remove_keys', userId: 'remote-user' }, + ], + [ + 'requestKeyDump', + () => manager.requestKeyDump(), + { type: 'cmd.dump_key_state' }, + ], + [ + 'enablePerformanceReporting', + () => manager.enablePerformanceReporting(true), + { type: 'cmd.enable_performance_reporting', enabled: true }, + ], + ])('%s posts its command', (_name, call, expected) => { + call(); + expect(getWorker(manager).postMessage).toHaveBeenCalledWith(expected); + }); + + it('clones key material rather than transferring it', () => { + // A transfer list would detach the caller's ArrayBuffer and break the + // documented contract that the same bytes can be imported again. + manager.setSharedKey(0, rawKey); + const transferList = vi.mocked(getWorker(manager).postMessage).mock + .calls[0][1]; + expect(transferList).toBeUndefined(); + }); + }); + + // One validation path behind two methods, so the table covers both rather + // than repeating each rule per method. + describe('key validation', () => { + const methods = ['setKey', 'setSharedKey'] as const; + const call = ( + method: (typeof methods)[number], + keyIndex: number, + bytes: number, + ) => + method === 'setKey' + ? () => manager.setKey('user', keyIndex, new ArrayBuffer(bytes)) + : () => manager.setSharedKey(keyIndex, new ArrayBuffer(bytes)); + + it.each(methods)('%s accepts a keyIndex across the whole byte', (m) => { + expect(call(m, 0, 16)).not.toThrow(); + expect(call(m, 255, 16)).not.toThrow(); + }); + + it.each(methods)('%s rejects a keyIndex off the wire format', (m) => { + // The trailer carries keyIndex in one byte: 256 would wrap to 0 and the + // receiver would silently look up the wrong key. + for (const bad of [256, -1, 1.5, NaN]) { + expect(call(m, bad, 16)).toThrow(/keyIndex/); + } + }); + + it.each(methods)('%s rejects a key that is not 16 bytes', (m) => { + for (const bad of [0, 8, 15, 17, 32]) { + expect(call(m, 0, bad)).toThrow(/16 bytes/); + } + }); + + it('removeSharedKey validates the keyIndex byte', () => { + expect(() => manager.removeSharedKey(0)).not.toThrow(); + expect(() => manager.removeSharedKey(255)).not.toThrow(); + for (const bad of [256, -1, 1.5, NaN]) { + expect(() => manager.removeSharedKey(bad)).toThrow(/keyIndex/); + } + }); + }); + + describe('attaching transforms', () => { + it.each([ + [ + 'encrypt', + (t: unknown) => manager.encrypt(t as RTCRtpSender, 'vp8', 'VIDEO'), + { + operation: 'encode', + userId: 'local-user', + codec: 'vp8', + trackType: 'VIDEO', + }, + ], + [ + 'decrypt', + (t: unknown) => + manager.decrypt(t as RTCRtpReceiver, 'remote-user', 'AUDIO'), + { operation: 'decode', userId: 'remote-user', trackType: 'AUDIO' }, + ], + ])( + '%s attaches a transform carrying its options', + (_name, attach, options) => { + const target: Record = { transform: null }; + attach(target); + expect(target.transform).toBeDefined(); + expect((target.transform as Record).options).toEqual( + options, + ); + }, + ); + + it('forwards an unsupported codec verbatim instead of gating on it', () => { + // The worker owns the decision and fails closed for a codec it cannot + // frame (av1 today); duplicating the check here would let the two drift. + const sender: Record = { transform: null }; + manager.encrypt(sender as unknown as RTCRtpSender, 'av1'); + expect( + (sender.transform as Record).options, + ).toMatchObject({ codec: 'av1' }); + }); + }); + + // Which API is chosen for a given browser is preferredTransform's job and is + // covered directly in transformSupport.test.ts. These tests only cover the + // half the manager owns: that it wires up whichever path it was handed. + describe('transform wiring', () => { + /** Stub the non-standard createEncodedStreams on the sender/receiver prototypes. */ + const withInsertableStreams = async (fn: () => void | Promise) => { + Object.assign(RTCRtpSender.prototype, { createEncodedStreams: vi.fn() }); + Object.assign(RTCRtpReceiver.prototype, { + createEncodedStreams: vi.fn(), + }); + try { + await fn(); + } finally { + // @ts-expect-error - cleaning up non-standard property from mock prototype + delete RTCRtpSender.prototype.createEncodedStreams; + // @ts-expect-error - cleaning up non-standard property from mock prototype + delete RTCRtpReceiver.prototype.createEncodedStreams; + } + }; + + it('wires the script path by assigning target.transform', async () => { + vi.mocked(isChrome).mockReturnValue(false); + // createEncodedStreams exists here too, so this also pins that the script + // path never touches it. + await withInsertableStreams(() => { + const receiver: Record = { + transform: null, + createEncodedStreams: vi.fn(), + }; + manager.decrypt(receiver as unknown as RTCRtpReceiver, 'remote-user'); + + expect(receiver.transform).toBeDefined(); + expect(receiver.createEncodedStreams).not.toHaveBeenCalled(); + }); + }); + + it('wires the insertable path by transferring the streams to the worker', async () => { + vi.mocked(isChrome).mockReturnValue(true); + const readable = {}; + const writable = {}; + const receiver = { + createEncodedStreams: vi.fn(() => ({ readable, writable })), + } as unknown as RTCRtpReceiver; + + await withInsertableStreams(async () => { + const mgr = await EncryptionManager.create('local-user'); + try { + mgr.decrypt(receiver, 'remote-user'); + + // @ts-expect-error not present in the standard lib + expect(receiver.createEncodedStreams).toHaveBeenCalled(); + const worker = getWorker(mgr); + expect(worker.postMessage).toHaveBeenCalledWith( + { + type: 'cmd.setup_transform', + operation: 'decode', + userId: 'remote-user', + readable, + writable, + }, + [readable, writable], + ); + } finally { + mgr.dispose(); + } + }); + }); + + it('prevents double-piping the same receiver on the Insertable Streams path', async () => { + vi.mocked(isChrome).mockReturnValue(true); + const readable = {}; + const writable = {}; + const receiver = { + createEncodedStreams: vi.fn(() => ({ readable, writable })), + } as unknown as RTCRtpReceiver; + + await withInsertableStreams(async () => { + const mgr = await EncryptionManager.create('local-user'); + try { + mgr.decrypt(receiver, 'user-a'); + mgr.decrypt(receiver, 'user-b'); + + // @ts-expect-error not present in the standard lib + expect(receiver.createEncodedStreams).toHaveBeenCalledTimes(1); + } finally { + mgr.dispose(); + } + }); + }); + }); + + describe('AES-256-GCM opt-in', () => { + it('swaps the required key length to 32 bytes', async () => { + // The default manager's 16-byte rule is covered in `key validation`; + // this is only about the algorithm option moving the goalposts. + const mgr = await EncryptionManager.create('user', { + algorithm: 'AES-256-GCM', + }); + try { + expect(() => mgr.setKey('remote', 0, new ArrayBuffer(16))).toThrow( + /32 bytes \(AES-256\)/, + ); + expect(() => + mgr.setKey('remote', 0, new ArrayBuffer(32)), + ).not.toThrow(); + expect(() => mgr.setSharedKey(0, new ArrayBuffer(32))).not.toThrow(); + } finally { + mgr.dispose(); + } + }); + }); + + describe('worker message handling', () => { + // The manager forwards worker messages generically: strip `type`, emit the + // rest as the payload. One table covers every event rather than repeating + // the same assertion per name - a new event needs a row, not a test. + const EVENTS: Array<[keyof E2EEEventMap, Record]> = [ + ['e2ee.decryption_failed', { userId: 'bob', trackType: 'VIDEO' }], + ['e2ee.decryption_resumed', { userId: 'bob', trackType: 'VIDEO' }], + ['e2ee.encryption_failed', { userId: 'bob', reason: 'clear-bytes' }], + ['e2ee.missing_key', { userId: 'local-user', keyIndex: 2 }], + ['e2ee.broken', { userId: 'bob', keyIndex: 3, trackType: 'AUDIO' }], + ['e2ee.unencrypted_frame', { userId: 'bob', trackType: 'VIDEO' }], + [ + 'e2ee.perf_report', + { + encode: [ + { + userId: 'alice', + trackType: 'VIDEO', + codec: 'vp8', + fps: 30, + maxCryptoMs: 2, + }, + ], + decode: [ + { userId: 'bob', trackType: 'VIDEO', fps: 29, maxCryptoMs: 3 }, + ], + }, + ], + [ + 'e2ee.key_state', + { + perUserKeys: [{ userId: 'bob', keyIndex: 0, fingerprint: 'abc123' }], + sharedKeys: [{ keyIndex: 1, fingerprint: 'def456', isActive: true }], + }, + ], + ]; + + it.each(EVENTS)('emits %s with the payload verbatim', (type, payload) => { + const callback = vi.fn(); + manager.on(type, callback); + + const messageHandler = getEventHandler(getWorker(manager), 'message'); + messageHandler({ data: { type, ...payload } }); + + expect(callback).toHaveBeenCalledWith(payload); + }); + + it('does not throw when nobody is subscribed', () => { + // The message pump must survive an unsubscribed event: a throw here would + // take down every later message, not just this one. + const messageHandler = getEventHandler(getWorker(manager), 'message'); + for (const [type, payload] of EVENTS) { + expect(() => + messageHandler({ data: { type, ...payload } }), + ).not.toThrow(); + } + }); + + it('stops delivering after the returned unsubscribe is called', () => { + const callback = vi.fn(); + const unsubscribe = manager.on('e2ee.decryption_failed', callback); + unsubscribe(); + + const messageHandler = getEventHandler(getWorker(manager), 'message'); + messageHandler({ + data: { type: 'e2ee.decryption_failed', userId: 'bob' }, + }); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe('dispose', () => { + it('terminates the worker and revokes the blob URL', () => { + const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL'); + const worker = getWorker(manager); + manager.dispose(); + + expect(worker.terminate).toHaveBeenCalled(); + expect(worker.removeEventListener).toHaveBeenCalledWith( + 'message', + expect.any(Function), + ); + expect(worker.removeEventListener).toHaveBeenCalledWith( + 'error', + expect.any(Function), + ); + expect(revokeObjectURL).toHaveBeenCalled(); + revokeObjectURL.mockRestore(); + }); + + it('is safe to call multiple times', () => { + const worker = getWorker(manager); + manager.dispose(); + manager.dispose(); + + expect(worker.terminate).toHaveBeenCalledTimes(1); + }); + + it('rejects every other operation once disposed', () => { + const sender = { transform: null } as unknown as RTCRtpSender; + const receiver = { transform: null } as unknown as RTCRtpReceiver; + manager.dispose(); + + // The worker is gone, so postMessage is a silent no-op and an attached + // transform would stall forever. Fail loudly instead: a reused manager + // (e.g. rejoining a Call that kept it) must not look like it is working. + expect(() => manager.encrypt(sender, 'vp8')).toThrow(/is disposed/); + expect(() => manager.decrypt(receiver, 'remote-user')).toThrow( + /is disposed/, + ); + expect(() => + manager.setKey('user', 0, new Uint8Array(16).buffer), + ).toThrow(/is disposed/); + expect(() => manager.setSharedKey(0, new Uint8Array(16).buffer)).toThrow( + /is disposed/, + ); + expect(() => manager.removeSharedKey(0)).toThrow(/is disposed/); + expect(() => manager.removeKeys('user')).toThrow(/is disposed/); + expect(() => manager.requestKeyDump()).toThrow(/is disposed/); + expect(() => manager.enablePerformanceReporting(true)).toThrow( + /is disposed/, + ); + expect(sender.transform).toBeNull(); + expect(receiver.transform).toBeNull(); + }); + }); +}); + +/** Extract the private worker instance from the manager. */ +function getWorker(mgr: EncryptionManager): Worker { + return mgr['worker' as keyof EncryptionManager] as unknown as Worker; +} + +/** Extract a registered event handler from a mock worker. */ +function getEventHandler(worker: Worker, event: string): (e: unknown) => void { + const calls = vi.mocked(worker.addEventListener).mock.calls; + const match = calls.find(([name]) => name === event); + if (!match) throw new Error(`No handler registered for '${event}'`); + return match[1] as (e: unknown) => void; +} diff --git a/packages/client/src/rtc/e2ee/__tests__/codec.test.ts b/packages/client/src/rtc/e2ee/__tests__/codec.test.ts new file mode 100644 index 0000000000..0ee111f0ab --- /dev/null +++ b/packages/client/src/rtc/e2ee/__tests__/codec.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from 'vitest'; +import { + boundarySeedZeros, + getCodecProfile, + isSupportedCodec, + rbspEscapeInto, + rbspEscapedLength, + rbspUnescape, +} from '../e2ee-worker/codec'; + +// Single-buffer escape helper. Production only ever escapes the +// [ciphertext, trailer] segment pair via rbspEscapedLength + rbspEscapeInto, so +// this convenience wrapper lives in the test rather than the shipped worker. +const rbspEscape = (data: Uint8Array, seedZeros = 0): Uint8Array => { + const out = new Uint8Array(rbspEscapedLength([data], seedZeros)); + rbspEscapeInto(out, 0, [data], seedZeros); + return out; +}; + +describe('rbspEscape + rbspUnescape', () => { + // deterministic "random" to exercise many byte values + const pseudoRandom = Array.from({ length: 256 }, (_, i) => (i * 31) & 0xff); + + it.each([ + ['no escapable sequence', [1, 2, 3, 4, 5]], + ['a run of zeros', [0, 0, 0, 0, 0, 0]], + ['mixed content', [0xaa, 0, 0, 1, 0xbb, 0xcc, 0, 0, 2, 0xdd]], + ['empty input', []], + ['a 256-byte buffer', pseudoRandom], + ])('round-trips %s', (_label, input) => { + const escaped = rbspEscape(new Uint8Array(input)); + expect(Array.from(rbspUnescape(escaped, 0))).toEqual(input); + }); + + it('is byte-identical when nothing needs escaping', () => { + // No zero pairs → no emulation-prevention bytes inserted. + expect(rbspEscape(new Uint8Array([1, 2, 3, 4, 5]))).toEqual( + new Uint8Array([1, 2, 3, 4, 5]), + ); + }); + + it('inserts 0x03 between 00 00 and 00-03', () => { + // [0, 0, 1] → [0, 0, 3, 1] + const out = rbspEscape(new Uint8Array([0, 0, 1])); + expect(Array.from(out)).toEqual([0, 0, 3, 1]); + }); + + it('produces a buffer free of forbidden start-code-like sequences', () => { + // After RBSP escaping, the sequences 00 00 00, 00 00 01, and 00 00 02 + // must not appear. 00 00 03 is allowed — it's the escape marker itself. + const escaped = rbspEscape(new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0])); + for (let i = 0; i < escaped.length - 2; i++) { + if (escaped[i] === 0 && escaped[i + 1] === 0) { + expect(escaped[i + 2]).toBeGreaterThanOrEqual(3); + } + } + }); +}); + +describe('rbspEscapeInto + rbspEscapedLength (multi-segment)', () => { + // The H264 encode path escapes [ciphertext, trailer] as one stream straight + // behind the clear header. These lock in that escaping the segments is + // byte-identical to escaping their concatenation, including when an escape + // sequence straddles the segment boundary. + const concat = (...segs: number[][]) => new Uint8Array(segs.flat()); + + const escapeSegments = (segs: number[][], seedZeros = 0) => { + const segments = segs.map((s) => new Uint8Array(s)); + const out = new Uint8Array(rbspEscapedLength(segments, seedZeros)); + rbspEscapeInto(out, 0, segments, seedZeros); + return out; + }; + + it('matches single-buffer escaping of the concatenation', () => { + const a = [0xaa, 0, 0, 1, 0xbb]; + const b = [0, 0, 2, 0xcc]; + expect(Array.from(escapeSegments([a, b]))).toEqual( + Array.from(rbspEscape(concat(a, b))), + ); + }); + + it('escapes a 00 00 run that straddles the segment boundary', () => { + // a ends in 00 00, b starts with 01 -> the escape byte must be inserted at + // the boundary exactly as if the bytes were one buffer. + const a = [0xaa, 0, 0]; + const b = [1, 0xbb]; + const escaped = escapeSegments([a, b]); + expect(Array.from(escaped)).toEqual([0xaa, 0, 0, 3, 1, 0xbb]); + expect(Array.from(rbspUnescape(escaped, 0))).toEqual([...a, ...b]); + }); + + it('writes at a non-zero offset, leaving earlier bytes untouched', () => { + const segments = [new Uint8Array([0, 0, 1])]; + const out = new Uint8Array(2 + rbspEscapedLength(segments, 0)); + out[0] = 0x11; + out[1] = 0x22; + rbspEscapeInto(out, 2, segments, 0); + expect(Array.from(out)).toEqual([0x11, 0x22, 0, 0, 3, 1]); + }); +}); + +describe('boundary seeding (clear header ending in zeros)', () => { + // The encoder never escapes the clear header itself, but on the wire the + // header's tail and the escaped unit are contiguous. Seeding the escaper + // with the header's trailing zeros keeps a start code from forming across + // that boundary, e.g. header ...00 + ciphertext 00 01 -> 00 00 01. + + it('boundarySeedZeros counts trailing zeros, capped at 2', () => { + expect(boundarySeedZeros(new Uint8Array([1, 2, 3]))).toBe(0); + expect(boundarySeedZeros(new Uint8Array([1, 2, 0]))).toBe(1); + expect(boundarySeedZeros(new Uint8Array([1, 0, 0]))).toBe(2); + expect(boundarySeedZeros(new Uint8Array([0, 0, 0, 0]))).toBe(2); + expect(boundarySeedZeros(new Uint8Array([0]))).toBe(1); + expect(boundarySeedZeros(new Uint8Array([]))).toBe(0); + }); + + it('escapes a start code forming across the clear/encrypted boundary', () => { + // Header ends in one 0x00 (seed 1); the unit starts 00 01. Unseeded this + // would ship ...00 | 00 01 (a 3-byte start code); seeded, an escape byte + // lands before the 01. + const escaped = rbspEscape(new Uint8Array([0, 1, 0xbb]), 1); + expect(Array.from(escaped)).toEqual([0, 3, 1, 0xbb]); + expect(Array.from(rbspUnescape(escaped, 1))).toEqual([0, 1, 0xbb]); + }); + + it('escapes the very first unit byte when the header ends in 00 00', () => { + const escaped = rbspEscape(new Uint8Array([1, 0xbb]), 2); + expect(Array.from(escaped)).toEqual([3, 1, 0xbb]); + expect(Array.from(rbspUnescape(escaped, 2))).toEqual([1, 0xbb]); + }); + + it('leaves a safe boundary alone', () => { + // The unit starts with a non-start-code byte; nothing to escape. + const escaped = rbspEscape(new Uint8Array([0xaa, 0, 1]), 2); + expect(Array.from(escaped)).toEqual([0xaa, 0, 1]); + expect(Array.from(rbspUnescape(escaped, 2))).toEqual([0xaa, 0, 1]); + }); + + it('round-trips with any seed on content needing internal escapes', () => { + const input = [0, 0, 1, 0xaa, 0, 0, 0, 2, 0xbb]; + for (const seed of [0, 1, 2]) { + const escaped = rbspEscape(new Uint8Array(input), seed); + expect(Array.from(rbspUnescape(escaped, seed))).toEqual(input); + } + }); +}); + +describe('codec clear-byte rules', () => { + // The clear-byte count per codec, via the same profile.clearBytes path the + // encoder uses (getClearByteCount delegate removed to save a hot-path frame). + const clearBytes = ( + codec: string | undefined, + frameType: string | undefined, + data: Uint8Array, + ) => getCodecProfile(codec).clearBytes(frameType, data); + + // Annex B: [00 00 00 01][SPS][00 00 00 01][slice NALU type 5][...]. The slice + // start code sits at byte 8 and is 4 bytes long, so clear = 8 + 4 + 2 = 14. + const h264Idr = new Uint8Array([ + 0x00, 0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x0a, 0x00, 0x00, 0x00, 0x01, + 0x65, 0xb8, 0x40, + ]); + // Only SPS (type 7), so no slice NALU to end the clear header on. + const h264NoSlice = new Uint8Array([0x00, 0x00, 0x00, 0x01, 0x67, 0x42]); + const big = new Uint8Array(50); + + it.each([ + // Audio is identified by the absence of a key/delta type; the Opus TOC + // byte stays clear so the SFU can read it. + ['audio, codec unlabeled', undefined, undefined, big, 1], + ['audio, opus', 'opus', undefined, big, 1], + ['vp8 keyframe', 'vp8', 'key', big, 10], + ['vp8 delta', 'vp8', 'delta', big, 3], + ['vp9 keyframe', 'vp9', 'key', big, 10], + ['vp9 delta', 'vp9', 'delta', big, 3], + // A frame shorter than the nominal count must not claim more clear bytes + // than it has, or encode zero-pads the header and decode builds an AAD of + // a different length, failing GCM for a frame that should round-trip. + ['vp8 delta shorter than 3 bytes', 'vp8', 'delta', new Uint8Array(2), 2], + ['vp9 keyframe shorter than 10 bytes', 'vp9', 'key', new Uint8Array(5), 5], + ['h264 up to the first slice NALU', 'h264', 'key', h264Idr, 14], + ['h264 with no slice NALU', 'h264', 'key', h264NoSlice, 0], + ['an unknown codec', 'unknown', 'delta', big, 0], + ])('%s -> %s clear bytes', (_label, codec, frameType, data, expected) => { + expect(clearBytes(codec, frameType, data)).toBe(expected); + }); +}); + +describe('getCodecProfile', () => { + it('marks only h264 for RBSP escaping', () => { + // The load-bearing invariant of the table: a codec is fully described in one + // place, so a half-wired codec (e.g. NALU escaping forgotten) is impossible. + expect(getCodecProfile('h264')).toMatchObject({ rbsp: true }); + for (const codec of ['opus', 'vp8', 'vp9']) { + expect(getCodecProfile(codec)).toMatchObject({ rbsp: false }); + } + }); + + it('falls back to a passthrough profile for unknown / absent codecs', () => { + for (const codec of [undefined, 'h265', 'video/vp8']) { + expect(getCodecProfile(codec)).toMatchObject({ rbsp: false }); + } + }); + + it('does not resolve Object.prototype members to a profile', () => { + // Looked up with `in` rather than Object.hasOwn, 'toString' resolves to a + // function: getCodecProfile returns it and profile.clearBytes is undefined, + // so every frame on that track throws inside the encode path. + for (const codec of ['toString', 'constructor', 'valueOf', '__proto__']) { + expect(getCodecProfile(codec)).toBe(getCodecProfile('no-such-codec')); + expect(typeof getCodecProfile(codec).clearBytes).toBe('function'); + } + }); +}); + +describe('isSupportedCodec', () => { + it('accepts the known codecs, and undefined for unlabeled audio', () => { + for (const codec of ['opus', 'vp8', 'vp9', 'h264', undefined]) { + expect(isSupportedCodec(codec)).toBe(true); + } + }); + + it('rejects unknown or mis-cased codecs', () => { + expect(isSupportedCodec('H264')).toBe(false); + expect(isSupportedCodec('video/vp8')).toBe(false); + }); + + it('rejects Object.prototype members', () => { + // `codec in CODEC_PROFILES` walks the prototype chain and would report + // these as supported, sending the track down the encode path with no + // usable profile behind it. + for (const codec of [ + 'toString', + 'constructor', + 'valueOf', + 'hasOwnProperty', + ]) + expect(isSupportedCodec(codec)).toBe(false); + }); + + it('rejects av1, which has no E2EE framing scheme yet', () => { + // The encode path turns this into a fail-closed transform: every frame is + // dropped and e2ee.encryption_failed is emitted, so an AV1 track can never + // be published in the clear on an encrypted call. + expect(isSupportedCodec('av1')).toBe(false); + }); +}); diff --git a/packages/client/src/rtc/e2ee/__tests__/conformance.test.ts b/packages/client/src/rtc/e2ee/__tests__/conformance.test.ts new file mode 100644 index 0000000000..845a838bf1 --- /dev/null +++ b/packages/client/src/rtc/e2ee/__tests__/conformance.test.ts @@ -0,0 +1,184 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +/** + * SPEC.md §11 conformance vectors, pinned byte-for-byte. + * + * These lock the wire format: a refactor of the trailer writer, IV derivation, + * clear-byte rules or RBSP escaping that changes any output byte breaks + * interop with every other SDK, and the symmetric round-trip tests would not + * notice. Any intentional change here is a wire break: bump the trailer + * `version` and regenerate the vectors in SPEC.md §11. + */ + +// The worker registers its listeners at import time and uses self.postMessage; +// capture both so the tests can drive it through its real message interface. +const handlers: Record void> = {}; +vi.stubGlobal( + 'addEventListener', + (type: string, h: (e: { data: unknown }) => void) => { + handlers[type] = h; + }, +); +vi.stubGlobal('self', { postMessage: () => undefined }); + +// The vectors fix `ivPrefix` = 11 11 11 11 11 11 11 11, but the prefix is +// drawn from the CSPRNG on key import, so pin the RNG. SubtleCrypto stays real: +// the ciphertext bytes come from actual AES-GCM. +const realCrypto = globalThis.crypto; +vi.stubGlobal('crypto', { + subtle: realCrypto.subtle, + getRandomValues: (arr: Uint8Array) => arr.fill(0x11), +}); + +await import('../e2ee-worker/e2ee-worker-impl'); +const { enqueue } = await import('../e2ee-worker/queue'); +const { keyStore } = await import('../e2ee-worker/keyStore'); +const { __resetFrameCounterForTest } = + await import('../e2ee-worker/frameCounter'); + +type Frame = { + data: ArrayBuffer; + type?: 'key' | 'delta' | 'empty'; + timestamp: number; +}; + +// key = 000102030405060708090a0b0c0d0e0f (AES-128-GCM), keyIndex = 0. +const KEY = Array.from({ length: 16 }, (_, i) => i); + +const hex = (s: string): Uint8Array => { + const clean = s.replace(/\s+/g, ''); + const out = new Uint8Array(clean.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); + } + return out; +}; +const toHex = (b: Uint8Array): string => + Array.from(b, (x) => x.toString(16).padStart(2, '0')).join(''); + +const message = (data: unknown) => handlers.message({ data }); +const flush = () => enqueue(async () => undefined); + +const setKey = async (userId: string) => { + message({ + type: 'cmd.set_key', + userId, + keyIndex: 0, + rawKey: new Uint8Array(KEY).buffer, + }); + await flush(); +}; + +const drive = async ( + operation: 'encode' | 'decode', + userId: string, + codec: string | undefined, + frames: Frame[], +): Promise => { + const out: Frame[] = []; + const readable = new ReadableStream({ + start(c) { + for (const f of frames) c.enqueue(f); + c.close(); + }, + }); + let resolveDone!: () => void; + const done = new Promise((r) => (resolveDone = r)); + const writable = new WritableStream({ + write(f) { + out.push(f); + }, + close: () => resolveDone(), + abort: () => resolveDone(), + }); + message({ + type: 'cmd.setup_transform', + readable, + writable, + operation, + userId, + codec, + }); + await done; + return out; +}; + +let nextUser = 0; +// A fresh user starts at frame counter 0, so the first frame uses counter 1, +// matching the vectors. +const freshUser = () => `vector-user-${nextUser++}`; + +afterEach(async () => { + await flush(); + keyStore.clear(); + __resetFrameCounterForTest(); +}); + +interface Vector { + codec: string; + frameType: Frame['type']; + input: string; + output: string; +} + +const VECTORS: Record = { + 'opus (audio, clearBytes 1)': { + codec: 'opus', + frameType: undefined, + input: '78aabbccdd', + output: + '78' + + 'd02bf795e85c0bed034f7b282ca617cf76d57eb0' + + '00000001 1111111111111111 00 0001 01 e2eefeed', + }, + 'vp8 keyframe (clearBytes 10)': { + codec: 'vp8', + frameType: 'key', + input: '10111213141516171819aabb', + output: + '10111213141516171819' + + 'd02b0484ce4aa2b21a4a83cbfe2ed6511c68' + + '00000001 1111111111111111 00 000a 01 e2eefeed', + }, + 'h264 IDR (clearBytes 6, RBSP escaping)': { + codec: 'h264', + frameType: 'key', + input: '00000001 65 8884deadbe', + output: + '000000016588' + + 'fe4e96f631df11f57a43ed2003eaad0c5d6db632' + + // Escaped trailer: 0x03 inserted into the counter's 00 00 00 01 run, + // and clearBytes carries the RBSP flag (0x8006). + '0000030001 1111111111111111 00 8006 01 e2eefeed', + }, +}; + +describe('SPEC §11 conformance vectors', () => { + it.each(Object.entries(VECTORS))( + 'encodes %s to the exact spec bytes', + async (_label, v) => { + const user = freshUser(); + await setKey(user); + const [encrypted] = await drive('encode', user, v.codec, [ + { data: hex(v.input).buffer, type: v.frameType, timestamp: 1 }, + ]); + expect(encrypted).toBeDefined(); + expect(toHex(new Uint8Array(encrypted.data))).toBe(toHex(hex(v.output))); + }, + ); + + it.each(Object.entries(VECTORS))( + 'decodes the %s spec bytes back to the input', + async (_label, v) => { + const user = freshUser(); + await setKey(user); + // Decode the pinned bytes, not this build's encode output, so the + // decoder is checked against the format other SDKs will send. + const [decrypted] = await drive('decode', user, undefined, [ + { data: hex(v.output).buffer, type: v.frameType, timestamp: 1 }, + ]); + expect(decrypted).toBeDefined(); + expect(toHex(new Uint8Array(decrypted.data))).toBe(toHex(hex(v.input))); + }, + ); +}); diff --git a/packages/client/src/rtc/e2ee/__tests__/failureTracker.test.ts b/packages/client/src/rtc/e2ee/__tests__/failureTracker.test.ts new file mode 100644 index 0000000000..06fd94d698 --- /dev/null +++ b/packages/client/src/rtc/e2ee/__tests__/failureTracker.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { FAILURE_TOLERANCE } from '../e2ee-worker/constants'; +import { FailureTracker } from '../e2ee-worker/failureTracker'; + +describe('FailureTracker', () => { + it('flags the break only on the failure that crosses tolerance', () => { + const tracker = new FailureTracker(); + // The first FAILURE_TOLERANCE failures stay under the bar. + for (let i = 0; i < FAILURE_TOLERANCE; i++) { + expect(tracker.recordFailure(1)).toBe(false); + } + // The next one crosses it - the break transition fires exactly once. + expect(tracker.recordFailure(1)).toBe(true); + expect(tracker.recordFailure(1)).toBe(false); // already broken, no re-fire + }); + + it('recordSuccess clears the count and reports whether there were failures', () => { + const tracker = new FailureTracker(); + expect(tracker.recordSuccess(1)).toBe(false); // nothing to resume + tracker.recordFailure(1); + expect(tracker.recordSuccess(1)).toBe(true); // had a failure -> recovered + expect(tracker.recordSuccess(1)).toBe(false); // already clear + // After a reset the tolerance bar can be crossed (and reported) again. + for (let i = 0; i < FAILURE_TOLERANCE; i++) tracker.recordFailure(1); + expect(tracker.recordFailure(1)).toBe(true); + }); + + it('counts each keyIndex independently within a track', () => { + const tracker = new FailureTracker(); + for (let i = 0; i <= FAILURE_TOLERANCE; i++) tracker.recordFailure(1); + // keyIndex 2 starts fresh: a key rotation does not inherit index 1's count. + expect(tracker.recordFailure(2)).toBe(false); + }); + + it('scopes failures per tracker so one track cannot reset another', () => { + const video = new FailureTracker(); + const audio = new FailureTracker(); + for (let i = 0; i <= FAILURE_TOLERANCE; i++) video.recordFailure(1); + // The audio track shares neither the count nor the recovery edge. + expect(audio.recordFailure(1)).toBe(false); + expect(audio.recordSuccess(1)).toBe(true); // only its own single failure + // ...and video's break state is untouched by audio's activity. + expect(video.recordSuccess(1)).toBe(true); + }); +}); diff --git a/packages/client/src/rtc/e2ee/__tests__/frameCounter.test.ts b/packages/client/src/rtc/e2ee/__tests__/frameCounter.test.ts new file mode 100644 index 0000000000..68a0c5c5d8 --- /dev/null +++ b/packages/client/src/rtc/e2ee/__tests__/frameCounter.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { COUNTER_HARD_LIMIT } from '../e2ee-worker/constants'; + +// The counter itself posts nothing, but importing a key to prove a rekey does +// not reset it goes through notifications.ts on failure. +const postMessage = vi.fn(); +vi.stubGlobal('self', { postMessage }); + +import { + __resetFrameCounterForTest, + __setFrameCounterForTest, + nextFrameCounter, +} from '../e2ee-worker/frameCounter'; +import { keyStore } from '../e2ee-worker/keyStore'; + +const rawKey = (seed = 0xab): ArrayBuffer => { + const buf = new ArrayBuffer(16); + const bytes = new Uint8Array(buf); + for (let i = 0; i < 16; i++) bytes[i] = (seed + i) & 0xff; + return buf; +}; + +beforeEach(() => { + __resetFrameCounterForTest(); + keyStore.clear(); + postMessage.mockClear(); +}); + +describe('nextFrameCounter', () => { + it('increments monotonically, shared across every track', () => { + // One counter per worker, drawn from by every encode transform. Separate + // counters per track would let two of this sender's tracks land on the + // same (ivPrefix, counter) pair under one key. + expect(nextFrameCounter()).toBe(1); + expect(nextFrameCounter()).toBe(2); + expect(nextFrameCounter()).toBe(3); + }); + + it('survives removeKeys — counter is never rolled back', async () => { + await keyStore.importKey('alice', 1, rawKey()); + expect(nextFrameCounter()).toBe(1); + expect(nextFrameCounter()).toBe(2); + + keyStore.removeKeys('alice'); + + // Re-import the same raw key. Counter must NOT restart — otherwise + // we'd reuse IVs on the new import's first frames. + await keyStore.importKey('alice', 1, rawKey()); + expect(nextFrameCounter()).toBe(3); + }); + + it('throws at the 32-bit hard limit and stays exhausted', () => { + __setFrameCounterForTest(COUNTER_HARD_LIMIT); + expect(() => nextFrameCounter()).toThrow(/counter exhausted/); + // Every later frame must fail identically rather than wrapping into a + // counter that was already used with this ivPrefix. + expect(() => nextFrameCounter()).toThrow(/counter exhausted/); + expect(() => nextFrameCounter()).toThrow(/counter exhausted/); + }); + + it('a rekey does not recover an exhausted counter', async () => { + __setFrameCounterForTest(COUNTER_HARD_LIMIT); + expect(() => nextFrameCounter()).toThrow(/new EncryptionManager/); + + // Importing fresh key material is the remedy an integrator would reach + // for first. It gives a new ivPrefix, but the counter is scoped to the + // worker rather than to the key, so encryption stays dead. + await keyStore.importKey('alice', 7, rawKey()); + expect(() => nextFrameCounter()).toThrow(/counter exhausted/); + + // Same after dropping the user's keys entirely. + keyStore.removeKeys('alice'); + await keyStore.importKey('alice', 8, rawKey()); + expect(() => nextFrameCounter()).toThrow(/counter exhausted/); + }); + + it('never posts a rotation warning ahead of the hard limit', () => { + // The 2^31 soft threshold and its `e2ee.rotation_needed` event were + // removed: rotating cannot buy back counter budget, so the signal named a + // remedy that does nothing. Only the fail-closed ceiling remains. + __setFrameCounterForTest(0x80000000 - 1); + nextFrameCounter(); + nextFrameCounter(); + expect( + postMessage.mock.calls.filter(([msg]) => + String(msg?.type).startsWith('e2ee.'), + ), + ).toHaveLength(0); + }); +}); diff --git a/packages/client/src/rtc/e2ee/__tests__/keyStore.test.ts b/packages/client/src/rtc/e2ee/__tests__/keyStore.test.ts new file mode 100644 index 0000000000..18654cf024 --- /dev/null +++ b/packages/client/src/rtc/e2ee/__tests__/keyStore.test.ts @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { IV_PREFIX_LEN } from '../e2ee-worker/constants'; + +// A failed import is reported through notifications.ts, which posts to the +// host. Stub it so tests run in the default Node environment. +const postMessage = vi.fn(); +vi.stubGlobal('self', { postMessage }); + +import { keyStore } from '../e2ee-worker/keyStore'; + +const rawKey = (seed = 0xab): ArrayBuffer => { + const buf = new ArrayBuffer(16); + const bytes = new Uint8Array(buf); + for (let i = 0; i < 16; i++) bytes[i] = (seed + i) & 0xff; + return buf; +}; + +beforeEach(() => { + keyStore.clear(); + postMessage.mockClear(); +}); + +describe('importKey', () => { + it('stores the key and generates a random 8-byte IV prefix', async () => { + await keyStore.importKey('alice', 1, rawKey()); + expect(keyStore.getKey('alice', 1)).toBeDefined(); + + const prefix = keyStore.getLatestKey('alice')?.ivPrefix; + expect(prefix).toBeDefined(); + expect(prefix!.length).toBe(IV_PREFIX_LEN); + }); + + it('generates a fresh prefix on each import (even for the same raw key)', async () => { + await keyStore.importKey('alice', 1, rawKey(0x01)); + const p1 = Array.from(keyStore.getLatestKey('alice')!.ivPrefix); + + await keyStore.importKey('alice', 1, rawKey(0x01)); + const p2 = Array.from(keyStore.getLatestKey('alice')!.ivPrefix); + + // 64 bits of randomness — practically impossible for two draws to collide. + expect(p2).not.toEqual(p1); + }); + + it('getLatestKey returns the most recently imported key', async () => { + await keyStore.importKey('alice', 1, rawKey(0x01)); + await keyStore.importKey('alice', 5, rawKey(0x02)); + const latest = keyStore.getLatestKey('alice'); + expect(latest!.keyIndex).toBe(5); + }); + + it('falls back to the shared key when no per-user key is registered', async () => { + await keyStore.importSharedKey(3, rawKey(0x55)); + const latest = keyStore.getLatestKey('bob'); + expect(latest!.keyIndex).toBe(3); + }); + + it('accepts 32-byte raw material (AES-256-GCM)', async () => { + const rawKey32 = new ArrayBuffer(32); + new Uint8Array(rawKey32).fill(0x42); + await keyStore.importKey('alice', 1, rawKey32); + expect(keyStore.getKey('alice', 1)).toBeDefined(); + }); +}); + +describe('dumpKeyState', () => { + it('returns fingerprints (not raw key material)', async () => { + await keyStore.importKey('alice', 1, rawKey(0x01)); + await keyStore.importSharedKey(0, rawKey(0x02)); + + const dump = keyStore.dump(); + expect(dump.perUserKeys).toHaveLength(1); + expect(dump.perUserKeys[0]).toMatchObject({ + userId: 'alice', + keyIndex: 1, + }); + // Fingerprint is 8 bytes = 16 hex chars. + expect(dump.perUserKeys[0].fingerprint).toMatch(/^[0-9a-f]{16}$/); + expect(dump.sharedKeys[0].fingerprint).toMatch(/^[0-9a-f]{16}$/); + expect(dump.sharedKeys).toEqual([ + { + keyIndex: 0, + fingerprint: dump.sharedKeys[0].fingerprint, + isActive: true, + }, + ]); + }); + + it('identifies key material: same key same print, different key different', async () => { + // What makes the dump useful: two peers can compare prints to confirm they + // hold the same key, under any user id or key index. + await keyStore.importKey('alice', 1, rawKey(0xaa)); + const alice = keyStore.dump().perUserKeys[0].fingerprint; + + keyStore.clear(); + await keyStore.importKey('bob', 99, rawKey(0xaa)); + await keyStore.importKey('bob', 100, rawKey(0x02)); + const [same, different] = keyStore.dump().perUserKeys; + + expect(same.fingerprint).toBe(alice); + expect(different.fingerprint).not.toBe(alice); + }); +}); + +describe('shared-key rotation', () => { + it('retains old epochs for decryption and encrypts with the newest', async () => { + await keyStore.importSharedKey(1, rawKey(0x11)); + const oldKey = keyStore.getKey('alice', 1); + + await keyStore.importSharedKey(2, rawKey(0x22)); + + expect(keyStore.getKey('alice', 1)).toBe(oldKey); + expect(keyStore.getKey('alice', 2)).toBeDefined(); + expect(keyStore.getLatestKey('alice')?.keyIndex).toBe(2); + }); + + it('keeps the active epoch when importing its replacement fails', async () => { + await keyStore.importSharedKey(1, rawKey(0x11)); + const active = keyStore.getLatestKey('alice'); + + await keyStore.importSharedKey(2, new ArrayBuffer(7)); + + const stillActive = keyStore.getLatestKey('alice'); + expect(stillActive?.keyIndex).toBe(1); + expect(stillActive?.key).toBe(active?.key); + expect(stillActive?.ivPrefix).toEqual(active?.ivPrefix); + expect(keyStore.getKey('alice', 2)).toBeUndefined(); + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'e2ee.error', + message: expect.stringContaining('Failed to import shared key'), + }), + ); + }); + + it('removes only the requested inactive epoch', async () => { + await keyStore.importSharedKey(1, rawKey(0x11)); + await keyStore.importSharedKey(2, rawKey(0x22)); + + keyStore.removeSharedKey(1); + + expect(keyStore.getKey('alice', 1)).toBeUndefined(); + expect(keyStore.getKey('alice', 2)).toBeDefined(); + expect(keyStore.getLatestKey('alice')?.keyIndex).toBe(2); + }); + + it('does not reactivate an old epoch when the active one is removed', async () => { + await keyStore.importSharedKey(1, rawKey(0x11)); + await keyStore.importSharedKey(2, rawKey(0x22)); + + keyStore.removeSharedKey(2); + + // Epoch 1 remains available to decrypt delayed frames, but silently + // resuming encryption with it would undo the caller's rotation policy. + expect(keyStore.getKey('alice', 1)).toBeDefined(); + expect(keyStore.getKey('alice', 2)).toBeUndefined(); + expect(keyStore.getLatestKey('alice')).toBeNull(); + expect(keyStore.dump()).toMatchObject({ + sharedKeys: [{ keyIndex: 1, isActive: false }], + }); + }); +}); + +describe('removeKeys', () => { + it('deletes that user key state and leaves the others', async () => { + await keyStore.importKey('alice', 1, rawKey(0x01)); + await keyStore.importKey('bob', 1, rawKey(0x02)); + + keyStore.removeKeys('alice'); + + expect(keyStore.getKey('alice', 1)).toBeUndefined(); + expect(keyStore.getLatestKey('alice')).toBeNull(); + expect(keyStore.getKey('bob', 1)).toBeDefined(); + }); +}); diff --git a/packages/client/src/rtc/e2ee/__tests__/notifications.test.ts b/packages/client/src/rtc/e2ee/__tests__/notifications.test.ts new file mode 100644 index 0000000000..79b8e3dd29 --- /dev/null +++ b/packages/client/src/rtc/e2ee/__tests__/notifications.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * The SPEC section 10 delivery rules, tested directly rather than through the + * transforms: throttling needs controllable time, and the pipeline tests cannot + * assert "and nothing more was delivered for another second" without it. + */ + +const postMessage = vi.fn(); +vi.stubGlobal('self', { postMessage }); + +const { DecodeNotifier, EncodeNotifier, notifyMissingEncodeKey } = + await import('../e2ee-worker/notifications'); + +const types = () => postMessage.mock.calls.map(([m]) => m.type); + +beforeEach(() => { + vi.useFakeTimers(); + // A non-zero epoch: the throttle compares against a 0 default for an unseen + // key, so starting at time 0 would suppress the very first notification. + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + postMessage.mockClear(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('EncodeNotifier', () => { + it('latches: one signal per failure run, and time alone does not re-arm it', () => { + // Latching is not throttling: a permanently dead track reports once, no + // matter how long it keeps failing. + const notify = new EncodeNotifier('alice', 'VIDEO'); + notify.failed('first'); + notify.failed('second'); + vi.advanceTimersByTime(60_000); + notify.failed('still dead'); + + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage).toHaveBeenCalledWith({ + type: 'e2ee.encryption_failed', + userId: 'alice', + trackType: 'VIDEO', + reason: 'first', + }); + }); + + it('re-arms on recovery so a later permanent failure is not hidden', () => { + const notify = new EncodeNotifier('alice', 'VIDEO'); + notify.failed('transient'); + notify.recovered(); + notify.failed('counter exhausted'); + expect(types()).toEqual([ + 'e2ee.encryption_failed', + 'e2ee.encryption_failed', + ]); + expect(postMessage.mock.calls[1][0].reason).toBe('counter exhausted'); + }); +}); + +describe('DecodeNotifier throttling', () => { + // Levels: they describe a condition that persists, so one per second is + // enough - the next frame re-raises the same condition. + it.each([ + ['decryption_failed', (n: DecodeNotifier) => n.failed()], + ['unencrypted_frame', (n: DecodeNotifier) => n.unencrypted()], + ])('delivers at most one %s per second', (type, raise) => { + const notify = new DecodeNotifier('bob', 'VIDEO'); + raise(notify); + raise(notify); + raise(notify); + expect(types()).toEqual([`e2ee.${type}`]); + + vi.advanceTimersByTime(1001); + raise(notify); + expect(types()).toEqual([`e2ee.${type}`, `e2ee.${type}`]); + }); + + it('throttles missing_key per keyIndex, so a rotation still reports', () => { + const notify = new DecodeNotifier('bob', 'VIDEO'); + notify.missingKey(1); + notify.missingKey(1); + // A different key epoch is a different condition and must not be + // suppressed by the first one's window. + notify.missingKey(2); + expect(postMessage.mock.calls.map(([m]) => m.keyIndex)).toEqual([1, 2]); + }); + + it('does not throttle broken: it is already once per failure run', () => { + const notify = new DecodeNotifier('bob', 'VIDEO'); + notify.broken(0); + notify.broken(1); + expect(types()).toEqual(['e2ee.broken', 'e2ee.broken']); + }); + + it('scopes throttles per notifier, so one track cannot mute another', () => { + const video = new DecodeNotifier('bob', 'VIDEO'); + const audio = new DecodeNotifier('bob', 'AUDIO'); + video.failed(); + audio.failed(); + expect(postMessage.mock.calls.map(([m]) => m.trackType)).toEqual([ + 'VIDEO', + 'AUDIO', + ]); + }); +}); + +describe('DecodeNotifier failure/recovery pairing', () => { + it('emits exactly one recovery per delivered failure, unthrottled', () => { + const notify = new DecodeNotifier('bob', 'VIDEO'); + notify.resumed(); // nothing was reported yet, so nothing to clear + expect(postMessage).not.toHaveBeenCalled(); + + notify.failed(); + notify.resumed(); // inside the failure's own throttle window, still fires: + notify.resumed(); // an edge must never be delayed or dropped... + notify.resumed(); // ...but it also does not repeat. + expect(types()).toEqual([ + 'e2ee.decryption_failed', + 'e2ee.decryption_resumed', + ]); + }); + + it('does not emit a recovery for a failure the throttle swallowed', () => { + const notify = new DecodeNotifier('bob', 'VIDEO'); + notify.failed(); // delivered + notify.resumed(); // pairs with it + postMessage.mockClear(); + notify.failed(); // suppressed: still inside the window + notify.resumed(); // the host never heard the failure, so nothing to clear + expect(postMessage).not.toHaveBeenCalled(); + }); +}); + +describe('notifyMissingEncodeKey', () => { + it('throttles per user and carries no trackType', () => { + // The local encoder holding no key stalls every outgoing track at once, so + // this is reported once for the user rather than per track. + notifyMissingEncodeKey('alice'); + notifyMissingEncodeKey('alice'); + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage).toHaveBeenCalledWith({ + type: 'e2ee.missing_key', + userId: 'alice', + }); + + notifyMissingEncodeKey('carol'); + expect(postMessage).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/client/src/rtc/e2ee/__tests__/perf.test.ts b/packages/client/src/rtc/e2ee/__tests__/perf.test.ts new file mode 100644 index 0000000000..a5f73437ea --- /dev/null +++ b/packages/client/src/rtc/e2ee/__tests__/perf.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const postMessage = vi.fn(); +vi.stubGlobal('self', { postMessage }); + +const { decodeStats, encodeStats, startPerfReport, stopPerfReport } = + await import('../e2ee-worker/perf'); + +const encodeTrack = (trackType: string, userId = 'alice') => + encodeStats.track(trackType, { userId, trackType, codec: 'vp8' }); +const decodeTrack = (userId: string, trackType = 'VIDEO') => + decodeStats.track(`${userId}/${trackType}`, { userId, trackType }); + +beforeEach(() => { + vi.useFakeTimers(); + postMessage.mockClear(); +}); + +afterEach(() => { + stopPerfReport(); + vi.useRealTimers(); +}); + +describe('while reporting is off', () => { + it('records nothing, so the transforms need no guard of their own', () => { + const track = encodeTrack('VIDEO'); + track.bump(); + track.endCrypto(track.startCrypto()); + expect(encodeStats.flush(1)).toEqual([]); + }); +}); + +describe('counting', () => { + beforeEach(() => startPerfReport()); + + it('reports each track separately, as a rate over the elapsed window', () => { + // Two tracks on one sender: a vp8 camera and a vp8 screen share must be + // reported apart rather than summed. + const camera = encodeTrack('VIDEO'); + camera.bump(); + camera.bump(); + camera.bump(); + encodeTrack('SCREEN_SHARE').bump(); + + expect(encodeStats.flush(1.5)).toEqual([ + { + userId: 'alice', + trackType: 'VIDEO', + codec: 'vp8', + fps: 2, + maxCryptoMs: 0, + }, + { + userId: 'alice', + trackType: 'SCREEN_SHARE', + codec: 'vp8', + fps: 1 / 1.5, + maxCryptoMs: 0, + }, + ]); + }); + + it('drops an idle track from the next report instead of reporting 0 fps', () => { + // The accumulator is created on demand and cleared by flush, so a track + // that stops delivering disappears rather than reporting zeroes forever. + const track = encodeTrack('VIDEO'); + track.bump(); + expect(encodeStats.flush(1)).toHaveLength(1); + expect(encodeStats.flush(1)).toHaveLength(0); + // ...and it comes back as soon as it delivers again. + track.bump(); + expect(encodeStats.flush(1)).toHaveLength(1); + }); + + it('takes the worst crypto time seen in the window', () => { + const track = encodeTrack('VIDEO'); + const slow = track.startCrypto(); + vi.advanceTimersByTime(7); + track.endCrypto(slow); + const fast = track.startCrypto(); + vi.advanceTimersByTime(2); + track.endCrypto(fast); + expect(encodeStats.flush(1)[0].maxCryptoMs).toBe(7); + }); + + it('labels decode samples without a codec', () => { + // A remote sender's codec is not reliably known locally, so decode rows + // carry only (userId, trackType). + decodeTrack('bob').bump(); + expect(decodeStats.flush(1)).toEqual([ + { userId: 'bob', trackType: 'VIDEO', fps: 1, maxCryptoMs: 0 }, + ]); + }); + + it('removeUser drops that user rows and leaves the others', () => { + decodeTrack('bob').bump(); + decodeTrack('carol').bump(); + decodeStats.removeUser('bob'); + expect(decodeStats.flush(1).map((s) => s.userId)).toEqual(['carol']); + }); +}); + +describe('the reporting interval', () => { + it('posts one report per second with both directions', () => { + startPerfReport(); + encodeTrack('VIDEO').bump(); + decodeTrack('bob').bump(); + + vi.advanceTimersByTime(1000); + + expect(postMessage).toHaveBeenCalledTimes(1); + const report = postMessage.mock.calls[0][0]; + expect(report.type).toBe('e2ee.perf_report'); + expect(report.encode).toHaveLength(1); + expect(report.decode).toHaveLength(1); + }); + + it('is idempotent: a second start does not add a second interval', () => { + startPerfReport(); + startPerfReport(); + startPerfReport(); + encodeTrack('VIDEO').bump(); + + vi.advanceTimersByTime(1000); + + // A leaked interval would post the same window several times over. + expect(postMessage).toHaveBeenCalledTimes(1); + }); + + it('stops the interval and clears the counters on stop', () => { + startPerfReport(); + encodeTrack('VIDEO').bump(); + stopPerfReport(); + + expect(encodeStats.flush(1)).toEqual([]); + vi.advanceTimersByTime(5000); + expect(postMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/client/src/rtc/e2ee/__tests__/queue.test.ts b/packages/client/src/rtc/e2ee/__tests__/queue.test.ts new file mode 100644 index 0000000000..efec3d470b --- /dev/null +++ b/packages/client/src/rtc/e2ee/__tests__/queue.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { enqueue } from '../e2ee-worker/queue'; + +describe('enqueue', () => { + it('carries the task outcome back to its own caller', async () => { + await expect(enqueue(async () => 42)).resolves.toBe(42); + await expect( + enqueue(async () => { + throw new Error('boom'); + }), + ).rejects.toThrow('boom'); + }); + + it('preserves task ordering', async () => { + const order: number[] = []; + const promises = []; + for (let i = 0; i < 5; i++) { + promises.push( + enqueue(async () => { + await Promise.resolve(); + order.push(i); + }), + ); + } + await Promise.all(promises); + expect(order).toEqual([0, 1, 2, 3, 4]); + }); + + it('runs tasks serially, never overlapping a previous task still in flight', async () => { + // Serialization, not just emission order: each task body yields several + // microtasks while "active". If two ran concurrently, active would exceed 1. + let active = 0; + let maxActive = 0; + const task = () => + enqueue(async () => { + active++; + maxActive = Math.max(maxActive, active); + await Promise.resolve(); + await Promise.resolve(); + active--; + }); + await Promise.all([task(), task(), task()]); + expect(maxActive).toBe(1); + }); + + it('continues running later tasks after one rejects', async () => { + const seen: string[] = []; + const ok1 = enqueue(async () => { + seen.push('a'); + }); + const bad = enqueue(async () => { + seen.push('b'); + throw new Error('fail'); + }); + const ok2 = enqueue(async () => { + seen.push('c'); + }); + await Promise.all([ok1, bad.catch(() => {}), ok2]); + expect(seen).toEqual(['a', 'b', 'c']); + }); +}); diff --git a/packages/client/src/rtc/e2ee/__tests__/replayWindow.test.ts b/packages/client/src/rtc/e2ee/__tests__/replayWindow.test.ts new file mode 100644 index 0000000000..da0e1c5b58 --- /dev/null +++ b/packages/client/src/rtc/e2ee/__tests__/replayWindow.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; +import { COUNTER_HARD_LIMIT, REPLAY_WINDOW } from '../e2ee-worker/constants'; +import { ReplayWindow } from '../e2ee-worker/replayWindow'; + +describe('ReplayWindow', () => { + const PREFIX_A = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + const PREFIX_B = new Uint8Array([9, 9, 9, 9, 9, 9, 9, 9]); + const PREFIX_C = new Uint8Array([3, 3, 3, 3, 3, 3, 3, 3]); + const PREFIX_D = new Uint8Array([4, 4, 4, 4, 4, 4, 4, 4]); + + // Mirrors the real decode flow: a frame is only recorded (commit) once it + // would have authenticated. Returns whether the window admitted it. + const accept = (w: ReplayWindow, counter: number, prefix: Uint8Array) => { + const ok = w.peek(counter, prefix); + if (ok) w.commit(counter, prefix); + return ok; + }; + + it('accepts monotonically increasing counters', () => { + const w = new ReplayWindow(); + expect(accept(w, 1, PREFIX_A)).toBe(true); + expect(accept(w, 2, PREFIX_A)).toBe(true); + expect(accept(w, 3, PREFIX_A)).toBe(true); + }); + + it('rejects an exact replay', () => { + const w = new ReplayWindow(); + expect(accept(w, 5, PREFIX_A)).toBe(true); + expect(accept(w, 5, PREFIX_A)).toBe(false); + }); + + it('accepts out-of-order frames within the window', () => { + const w = new ReplayWindow(); + expect(accept(w, 10, PREFIX_A)).toBe(true); + expect(accept(w, 8, PREFIX_A)).toBe(true); // late arrival + expect(accept(w, 8, PREFIX_A)).toBe(false); // replay of late arrival + }); + + it('rejects frames older than the replay window', () => { + const w = new ReplayWindow(); + const high = REPLAY_WINDOW + 50; + expect(accept(w, high, PREFIX_A)).toBe(true); + expect(accept(w, 1, PREFIX_A)).toBe(false); + expect(accept(w, high - REPLAY_WINDOW, PREFIX_A)).toBe(false); + }); + + it('isolates state per track (the M1 fix)', () => { + // Each decode transform owns its own guard, so one track racing far + // ahead in counter terms can never evict a slower track's frames — the + // failure mode of the old shared (userId, keyIndex) window. + const audio = new ReplayWindow(); + const video = new ReplayWindow(); + expect(accept(audio, REPLAY_WINDOW * 4, PREFIX_A)).toBe(true); + expect(accept(video, 5, PREFIX_A)).toBe(true); + expect(accept(video, 6, PREFIX_A)).toBe(true); + }); + + it('partitions the window by sender prefix, so a restart is not rejected', () => { + // A sender restart or key re-import brings a fresh prefix and a counter + // near 0. Those low counters must not be judged against the old prefix's + // high-water mark, while replays within each prefix are still caught. + const w = new ReplayWindow(); + expect(accept(w, 5000, PREFIX_A)).toBe(true); + expect(accept(w, 1, PREFIX_B)).toBe(true); + expect(accept(w, 2, PREFIX_B)).toBe(true); + expect(accept(w, 5000, PREFIX_A)).toBe(false); // replay within prefix A + expect(accept(w, 1, PREFIX_B)).toBe(false); // replay within prefix B + }); + + // --- authenticate-before-commit ----------------------------------------- + + it('peek is read-only — a forged high counter cannot wedge the track', () => { + const w = new ReplayWindow(); + // A genuine frame establishes the window. + expect(accept(w, 10, PREFIX_A)).toBe(true); + // Forged frames copy the prefix and claim far-future counters. They peek + // OK (nothing seen is newer), but GCM rejects them, so they are never + // committed and `highest` must not move: otherwise every later genuine + // frame lands below `highest - REPLAY_WINDOW` and is dropped forever. + expect(w.peek(COUNTER_HARD_LIMIT, PREFIX_A)).toBe(true); + expect(w.peek(900_000, PREFIX_A)).toBe(true); + expect(w.peek(900_000, PREFIX_A)).toBe(true); + expect(accept(w, 11, PREFIX_A)).toBe(true); + expect(accept(w, 12, PREFIX_A)).toBe(true); + // The uncommitted peeks also left no epoch behind, so a genuine low + // counter is still new rather than a replay. + expect(accept(w, 1, PREFIX_A)).toBe(true); + expect(accept(w, 1, PREFIX_A)).toBe(false); + }); + + it('clears the slots an advance skipped, so a reused slot is not a false replay', () => { + // Bitmap slots repeat every REPLAY_WINDOW counters, so counter 5 and + // counter 5 + REPLAY_WINDOW share one bit. When the mark advances past + // counters that never arrived, their slots must be cleared, or a later + // genuine frame landing on one is rejected as a replay of the old counter. + const w = new ReplayWindow(); + const reused = 5 + REPLAY_WINDOW; // same bitmap slot as counter 5 + expect(accept(w, 5, PREFIX_A)).toBe(true); + // Advance in steps smaller than the window, so the skipped slots are + // cleared one by one rather than by wiping the whole bitmap. + expect(accept(w, REPLAY_WINDOW - 24, PREFIX_A)).toBe(true); + expect(accept(w, REPLAY_WINDOW + 76, PREFIX_A)).toBe(true); + // `reused` is a new counter, still inside the window, and its slot was + // last set by counter 5. It must be accepted. + expect(accept(w, reused, PREFIX_A)).toBe(true); + expect(accept(w, reused, PREFIX_A)).toBe(false); // now a genuine replay + }); + + it('handles a counter jump larger than the replay window', () => { + // Exercises the window-advance path where the whole bitmap is stale and + // must be cleared at once. + const w = new ReplayWindow(); + expect(accept(w, 1, PREFIX_A)).toBe(true); + const far = 1 + REPLAY_WINDOW * 3; + expect(accept(w, far, PREFIX_A)).toBe(true); // jump well beyond the window + expect(accept(w, far, PREFIX_A)).toBe(false); // replay of the far frame + expect(accept(w, 2, PREFIX_A)).toBe(false); // now far older than the window + }); + + it('an uncommitted novel-prefix peek cannot evict a committed epoch', () => { + const w = new ReplayWindow(); + // Authentic frame on prefix A is committed. + expect(accept(w, 5, PREFIX_A)).toBe(true); + // Attacker injects frames with distinct novel prefixes (> REPLAY_EPOCHS + // worth). They fail GCM, so they are peeked but never committed — no epoch + // is created, nothing is evicted. + for (const p of [PREFIX_B, PREFIX_C, PREFIX_D]) { + expect(w.peek(1, p)).toBe(true); // a novel prefix always peeks OK + } + // Prefix A's epoch survived, so replaying the authentic frame is caught. + expect(w.peek(5, PREFIX_A)).toBe(false); + }); +}); diff --git a/packages/client/src/rtc/e2ee/__tests__/trailer.test.ts b/packages/client/src/rtc/e2ee/__tests__/trailer.test.ts new file mode 100644 index 0000000000..15ced47672 --- /dev/null +++ b/packages/client/src/rtc/e2ee/__tests__/trailer.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { + IV_PREFIX_LEN, + MAX_CLEAR_BYTES, + TRAILER_LEN, +} from '../e2ee-worker/constants'; +import { readTrailer, writeTrailer } from '../e2ee-worker/trailer'; + +const makeFrame = (bodyLen: number): Uint8Array => + new Uint8Array(bodyLen + TRAILER_LEN); + +const randomPrefix = (): Uint8Array => { + const p = new Uint8Array(IV_PREFIX_LEN); + for (let i = 0; i < p.length; i++) p[i] = (i * 17 + 3) & 0xff; + return p; +}; + +describe('writeTrailer + readTrailer', () => { + it.each([ + ['without the RBSP flag', false], + ['with the RBSP flag', true], + ])('round-trips the full payload %s', (_label, isRbsp) => { + const body = 32; + const dst = makeFrame(body); + const prefix = randomPrefix(); + writeTrailer(dst, body, 123456, prefix, 7, 10, isRbsp); + + const trailer = readTrailer(dst); + expect(trailer).not.toBeNull(); + expect(trailer!.frameCounter).toBe(123456); + expect(trailer!.keyIndex).toBe(7); + expect(trailer!.clearBytes).toBe(10); + expect(trailer!.isRbsp).toBe(isRbsp); + expect(Array.from(trailer!.ivPrefix)).toEqual(Array.from(prefix)); + }); + + it('rejects a trailer it could not encode', () => { + const dst = makeFrame(100); + expect(() => + writeTrailer(dst, 100, 1, randomPrefix(), 0, MAX_CLEAR_BYTES + 1, false), + ).toThrow(/15-bit/); + expect(() => + writeTrailer(dst, 20, 1, new Uint8Array(IV_PREFIX_LEN - 1), 0, 0, false), + ).toThrow(/ivPrefix/); + }); + + // Anything unrecognized means "not our trailer", so the frame is forwarded as + // cleartext rather than sent to a decrypt that would fail. The exact bytes of + // a valid trailer are pinned by the SPEC vectors in conformance.test.ts. + it.each([ + [ + 'the frame is shorter than a trailer', + () => new Uint8Array(TRAILER_LEN - 1), + ], + [ + 'the magic does not match', + (f: Uint8Array) => { + f[f.length - 1] ^= 0x01; + return f; + }, + ], + [ + 'the version is unknown', + (f: Uint8Array) => { + f[f.length - 5] = 99; + return f; + }, + ], + [ + 'the declared clearBytes overruns the body', + (f: Uint8Array) => { + // Still inside the 15-bit limit, so only the length check catches it. + new DataView(f.buffer).setUint16(f.length - 7, 6); + return f; + }, + ], + ])('returns null when %s', (_label, corrupt) => { + const body = 5; + const dst = makeFrame(body); + writeTrailer(dst, body, 1, randomPrefix(), 0, 0, false); + expect(readTrailer(corrupt(dst))).toBeNull(); + }); +}); diff --git a/packages/client/src/rtc/e2ee/__tests__/transform-pipeline.test.ts b/packages/client/src/rtc/e2ee/__tests__/transform-pipeline.test.ts new file mode 100644 index 0000000000..ac8035dcdf --- /dev/null +++ b/packages/client/src/rtc/e2ee/__tests__/transform-pipeline.test.ts @@ -0,0 +1,805 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + COUNTER_HARD_LIMIT, + E2EE_VERSION, + FAILURE_TOLERANCE, + MAGIC, + MAX_CLEAR_BYTES, + RBSP_FLAG, + TRAILER_LEN, +} from '../e2ee-worker/constants'; + +type Posted = { + type?: string; + userId?: string; + keyIndex?: number; + trackType?: string; +}; +const posted: Posted[] = []; + +// The worker registers its 'message' / 'rtctransform' listeners at import time +// and uses self.postMessage. Capture the listeners and the posted messages so +// the tests can drive the worker through its real message interface. +const handlers: Record void> = {}; +vi.stubGlobal( + 'addEventListener', + (type: string, h: (e: { data: unknown }) => void) => { + handlers[type] = h; + }, +); +vi.stubGlobal('self', { postMessage: (m: Posted) => void posted.push(m) }); + +// Import AFTER stubbing so the top-level addEventListener calls are captured. +await import('../e2ee-worker/e2ee-worker-impl'); +// `enqueue` is the worker's own serial message queue; awaiting a no-op task +// flushes everything queued before it (e.g. an async setKey). +const { enqueue } = await import('../e2ee-worker/queue'); +// Test seams: position the per-user frame counter so we can hit the low +// values whose big-endian encoding forms Annex-B start codes, and reset the +// worker's module-level key state between tests (production teardown is +// Worker.terminate(), which tests cannot use). +const { __resetFrameCounterForTest, __setFrameCounterForTest } = + await import('../e2ee-worker/frameCounter'); +const { keyStore } = await import('../e2ee-worker/keyStore'); + +type Frame = { + data: ArrayBuffer; + type?: 'key' | 'delta' | 'empty'; + timestamp: number; +}; + +const KEY = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]; + +const message = (data: unknown) => handlers.message({ data }); +const flush = () => enqueue(async () => undefined); + +const setKey = async (userId: string, keyIndex = 0) => { + message({ + type: 'cmd.set_key', + userId, + keyIndex, + rawKey: new Uint8Array(KEY).buffer, + }); + await flush(); +}; +const setSharedKey = async (keyIndex: number, rawKey = KEY) => { + message({ + type: 'cmd.set_shared_key', + keyIndex, + rawKey: new Uint8Array(rawKey).buffer, + }); + await flush(); +}; +const removeSharedKey = async (keyIndex: number) => { + message({ type: 'cmd.remove_shared_key', keyIndex }); + await flush(); +}; +const removeKeys = async (userId: string) => { + message({ type: 'cmd.remove_keys', userId }); + await flush(); +}; + +// `type` is required, and `undefined` is meaningful: the absence of a key/delta +// type is exactly how the worker recognizes an audio frame, so a default here +// would silently turn audio cases into delta video ones. +const frame = (bytes: number[], type: Frame['type']): Frame => ({ + data: new Uint8Array(bytes).buffer, + type, + timestamp: 1, +}); + +const cloneFrame = (source: Frame): Frame => ({ + ...source, + data: source.data.slice(0), +}); + +// Attach a transform via the real worker message path (Insertable Streams +// setup branch) and run frames through it, returning what it emits. +const drive = async ( + operation: 'encode' | 'decode', + userId: string, + codec: string | undefined, + frames: Frame[], + trackType?: string, +): Promise => { + const out: Frame[] = []; + const readable = new ReadableStream({ + start(c) { + for (const f of frames) c.enqueue(f); + c.close(); + }, + }); + let resolveDone!: () => void; + const done = new Promise((r) => (resolveDone = r)); + const writable = new WritableStream({ + write(f) { + out.push(f); + }, + close: () => resolveDone(), + abort: () => resolveDone(), + }); + message({ + type: 'cmd.setup_transform', + readable, + writable, + operation, + userId, + codec, + trackType, + }); + await done; + return out; +}; + +let nextUser = 0; +const freshUser = () => `user-${nextUser++}`; + +const roundTrip = async ( + codec: string, + plaintext: number[], + type: Frame['type'], +): Promise => { + const user = freshUser(); + await setKey(user); + const [encrypted] = await drive('encode', user, codec, [ + frame(plaintext, type), + ]); + expect(encrypted).toBeDefined(); + expect(Array.from(new Uint8Array(encrypted.data))).not.toEqual(plaintext); + // The decode side is codec-blind: it detects the format from the bytes. + const [decrypted] = await drive('decode', user, undefined, [encrypted]); + expect(decrypted).toBeDefined(); + return Array.from(new Uint8Array(decrypted.data)); +}; + +beforeEach(() => { + posted.length = 0; +}); +afterEach(async () => { + await flush(); + keyStore.clear(); + __resetFrameCounterForTest(); +}); + +// The worker's message interface is its whole public surface, so the commands +// that carry no frames still need a wire test. What each command *does* is +// covered by the module that owns it (perf.test.ts, crypto.test.ts); these +// cover the dispatch reaching it. +describe('worker command interface', () => { + it('answers cmd.dump_key_state with fingerprints, never key material', async () => { + const user = freshUser(); + await setKey(user, 4); + posted.length = 0; + message({ type: 'cmd.dump_key_state' }); + await flush(); + const dump = posted.find((m) => m.type === 'e2ee.key_state') as + { perUserKeys: Array<{ userId: string; keyIndex: number }> } | undefined; + expect(dump).toBeDefined(); + expect(dump!.perUserKeys).toContainEqual( + expect.objectContaining({ userId: user, keyIndex: 4 }), + ); + // The raw bytes must never leave the worker. + expect(JSON.stringify(dump)).not.toContain(KEY.join(',')); + }); + + it('starts and stops performance reporting', async () => { + const user = freshUser(); + await setKey(user); + // Streams settle on microtasks, so faking timers here only controls the + // reporter's own interval. + vi.useFakeTimers(); + try { + message({ type: 'cmd.enable_performance_reporting', enabled: true }); + await flush(); + await drive('encode', user, 'vp8', [frame([1, 2, 3, 4, 5, 6], 'delta')]); + + posted.length = 0; + vi.advanceTimersByTime(1000); + expect(posted.some((m) => m.type === 'e2ee.perf_report')).toBe(true); + + message({ type: 'cmd.enable_performance_reporting', enabled: false }); + await flush(); + posted.length = 0; + vi.advanceTimersByTime(3000); + expect(posted.some((m) => m.type === 'e2ee.perf_report')).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('reports an unknown command instead of failing silently', async () => { + message({ type: 'cmd.not_a_real_command' }); + await flush(); + expect(posted).toEqual([ + { + type: 'e2ee.error', + message: expect.stringContaining('cmd.not_a_real_command'), + }, + ]); + }); +}); + +describe('encode -> decode pipeline round-trips', () => { + it.each([ + [ + 'vp8 (clear-prefix + trailer path)', + 'vp8', + 'delta' as const, + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + ], + [ + 'h264 with a slice NALU (RBSP-escape path)', + 'h264', + 'key' as const, + [ + ...[0, 0, 0, 1, 0x67, 0x42, 0x00, 0x0a], // SPS + ...[0, 0, 0, 1, 0x65, 0xb8, 0x40], // slice start code + type 5 + 2 bytes + ...[0xaa, 0xbb, 0xcc, 0xdd, 0xee], // body (encrypted) + ], + ], + [ + 'h264 with no slice NALU (clearBytes 0 path)', + 'h264', + 'key' as const, + [0, 0, 0, 1, 0x67, 0x42, 0x00, 0x0a], + ], + ])('%s', async (_label, codec, type, plaintext) => { + expect(await roundTrip(codec, plaintext, type)).toEqual(plaintext); + }); + + it('opus (audio, 1 clear byte)', async () => { + const pt = [0x78, 0xaa, 0xbb, 0xcc, 0xdd]; + // An audio frame is recognized by the ABSENCE of a key/delta type, and that + // is what selects the 1-byte Opus TOC clear header. + const user = freshUser(); + await setKey(user); + const [encrypted] = await drive('encode', user, 'opus', [ + frame(pt, undefined), + ]); + const bytes = Array.from(new Uint8Array(encrypted.data)); + // TOC byte in the clear (the SFU reads it), everything after it encrypted. + expect(bytes[0]).toBe(0x78); + expect(bytes.slice(1, pt.length)).not.toEqual(pt.slice(1)); + const [decrypted] = await drive('decode', user, undefined, [encrypted]); + expect(Array.from(new Uint8Array(decrypted.data))).toEqual(pt); + }); + + it('keeps old shared epochs decryptable until explicitly removed', async () => { + const user = freshUser(); + const oldPlaintext = [1, 2, 3, 4, 5, 6, 7, 8]; + const newPlaintext = [9, 10, 11, 12, 13, 14, 15, 16]; + const nextKey = KEY.map((byte) => byte + 16); + + await setSharedKey(1); + const [oldEncrypted] = await drive('encode', user, 'vp8', [ + frame(oldPlaintext, 'delta'), + ]); + + await setSharedKey(2, nextKey); + const [newEncrypted] = await drive('encode', user, 'vp8', [ + frame(newPlaintext, 'delta'), + ]); + + const duringRotation = await drive('decode', user, undefined, [ + cloneFrame(oldEncrypted), + cloneFrame(newEncrypted), + ]); + expect( + duringRotation.map((decoded) => Array.from(new Uint8Array(decoded.data))), + ).toEqual([oldPlaintext, newPlaintext]); + + await removeSharedKey(1); + posted.length = 0; + expect( + await drive('decode', user, undefined, [cloneFrame(oldEncrypted)]), + ).toHaveLength(0); + expect(posted).toContainEqual( + expect.objectContaining({ + type: 'e2ee.missing_key', + userId: user, + keyIndex: 1, + }), + ); + + const [stillDecryptable] = await drive('decode', user, undefined, [ + cloneFrame(newEncrypted), + ]); + expect(Array.from(new Uint8Array(stillDecryptable.data))).toEqual( + newPlaintext, + ); + }); +}); + +describe('decode pipeline edge behaviors', () => { + it('passes an unencrypted frame through, but signals it', async () => { + const user = freshUser(); + const bytes = [9, 9, 9, 9, 9]; + const [out] = await drive( + 'decode', + user, + undefined, + [frame(bytes, 'delta')], + 'SCREEN_SHARE', + ); + expect(Array.from(new Uint8Array(out.data))).toEqual(bytes); + // Forwarded as-is (a peer may publish plain), but never silently: the host + // needs a signal to notice a downgrade on a call where everyone should encrypt. + expect(posted).toEqual([ + { + type: 'e2ee.unencrypted_frame', + userId: user, + trackType: 'SCREEN_SHARE', + }, + ]); + }); + + it('drops and signals missing_key when the key is gone', async () => { + const user = freshUser(); + await setKey(user); + const [encrypted] = await drive('encode', user, 'vp8', [ + frame([1, 2, 3, 4, 5, 6, 7, 8], 'delta'), + ]); + await removeKeys(user); + posted.length = 0; + const out = await drive('decode', user, undefined, [encrypted], 'AUDIO'); + expect(out).toHaveLength(0); + // Not holding the key is reported apart from a failed decrypt, so a host can + // tell key distribution lag from a mismatched or tampered frame. + expect(posted).toEqual([ + { + type: 'e2ee.missing_key', + userId: user, + keyIndex: 0, + trackType: 'AUDIO', + }, + ]); + expect(posted.some((m) => m.type === 'e2ee.decryption_failed')).toBe(false); + }); + + it('drops a replayed frame silently (no failure event)', async () => { + const user = freshUser(); + await setKey(user); + const [encrypted] = await drive('encode', user, 'vp8', [ + frame([1, 2, 3, 4, 5, 6, 7, 8], 'delta'), + ]); + const clone: Frame = { ...encrypted, data: encrypted.data.slice(0) }; + posted.length = 0; + // Both frames go through ONE decode transform (shared replay window). + const out = await drive('decode', user, undefined, [encrypted, clone]); + expect(out).toHaveLength(1); // first decrypts, second is a replay + expect(posted.some((m) => m.type === 'e2ee.decryption_failed')).toBe(false); + }); + + it('signals decryption_failed on a tampered frame', async () => { + const user = freshUser(); + await setKey(user); + const [encrypted] = await drive('encode', user, 'vp8', [ + frame([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 'delta'), + ]); + const tampered = new Uint8Array(encrypted.data); + tampered[5] ^= 0xff; // flip a ciphertext byte + posted.length = 0; + const out = await drive('decode', user, undefined, [ + { ...encrypted, data: tampered.buffer }, + ]); + expect(out).toHaveLength(0); + expect(posted.some((m) => m.type === 'e2ee.decryption_failed')).toBe(true); + }); + + it('pairs every delivered decryption_failed with a decryption_resumed', async () => { + const user = freshUser(); + await setKey(user); + const pt = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + const [encrypted] = await drive('encode', user, 'vp8', [ + frame(pt, 'delta'), + ]); + const corrupt = (): Frame => { + const b = new Uint8Array(encrypted.data.slice(0)); + b[5] ^= 0xff; + return { ...encrypted, data: b.buffer }; + }; + const valid = (): Frame => ({ + ...encrypted, + data: encrypted.data.slice(0), + }); + posted.length = 0; + // Two full fail -> recover cycles inside one throttle window. `resumed` is + // an edge, so throttling it would drop a transition permanently and leave + // the host latched on `failed` for a track that is fine. + await drive( + 'decode', + user, + undefined, + [corrupt(), valid(), corrupt(), valid()], + 'VIDEO', + ); + const signals = posted + .map((m) => m.type) + .filter( + (t) => + t === 'e2ee.decryption_failed' || t === 'e2ee.decryption_resumed', + ); + // Never more recoveries than failures, never fewer, and the run ends on the + // recovery - so the host's last signal matches the track's real state. + const failed = signals.filter((t) => t === 'e2ee.decryption_failed').length; + const resumed = signals.filter( + (t) => t === 'e2ee.decryption_resumed', + ).length; + expect(resumed).toBe(failed); + expect(signals.at(-1)).toBe('e2ee.decryption_resumed'); + }); + + it('clears a reported failure when the track recovers on a NEW keyIndex', async () => { + const user = freshUser(); + await setKey(user, 0); + const pt = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + const [atKey0] = await drive('encode', user, 'vp8', [frame(pt, 'delta')]); + await setKey(user, 1); + const [atKey1] = await drive('encode', user, 'vp8', [frame(pt, 'delta')]); + const tampered = new Uint8Array(atKey0.data.slice(0)); + tampered[5] ^= 0xff; + + posted.length = 0; + // The failure count is per keyIndex, but the host was told "this TRACK is + // failing" with no index, so a rotation that fixes the track has to clear + // it. Gating recovery on the failing key's own count would latch forever. + await drive( + 'decode', + user, + undefined, + [{ ...atKey0, data: tampered.buffer }, atKey1], + 'VIDEO', + ); + expect(posted.filter((m) => m.type === 'e2ee.decryption_failed')).toEqual([ + { type: 'e2ee.decryption_failed', userId: user, trackType: 'VIDEO' }, + ]); + expect(posted.filter((m) => m.type === 'e2ee.decryption_resumed')).toEqual([ + { type: 'e2ee.decryption_resumed', userId: user, trackType: 'VIDEO' }, + ]); + }); + + it('survives an RBSP frame that unescapes shorter than the trailer', async () => { + const user = freshUser(); + await setKey(user); + const [genuine] = await drive('encode', user, 'h264', [ + frame([0, 0, 0, 1, 0x65, 0x88, 0x11, 0x22, 0x33, 0x44], 'key'), + ]); + // Forge a frame that readTrailer accepts as RBSP - valid magic, version and + // clearBytes in the start-code-safe tail - whose escaped region unescapes to + // fewer than TRAILER_LEN bytes. Reading the trailer at a negative offset + // throws, and an unguarded throw out of transform() errors the stream and + // kills this track's pipeline for good. + const forged = new Uint8Array(40); + const view = new DataView(forged.buffer); + const clearBytes = 20; + for (let i = 0; i < 3; i++) { + forged.set([0x00, 0x00, 0x03, 0x00], clearBytes + i * 4); + } + view.setUint16(forged.length - 7, RBSP_FLAG | clearBytes); + forged[forged.length - 5] = E2EE_VERSION; + view.setUint32(forged.length - 4, MAGIC); + posted.length = 0; + // The forged frame arrives first; the genuine frame must still decrypt. + const out = await drive('decode', user, undefined, [ + { ...genuine, data: forged.buffer }, + genuine, + ]); + expect(out).toHaveLength(1); + expect(posted.some((m) => m.type === 'e2ee.error')).toBe(false); + }); + + // --- authenticate-before-mutate ------------------------------------------ + + it('a forged max-counter frame does not freeze the track', async () => { + const user = freshUser(); + await setKey(user); + // Two genuine frames (counters 1 and 2 for this user). + const [g1, g2] = await drive('encode', user, 'vp8', [ + frame([1, 2, 3, 4, 5, 6, 7, 8], 'delta'), + frame([9, 10, 11, 12, 13, 14, 15, 16], 'delta'), + ]); + // Forge a frame: copy g1 (real ivPrefix + keyIndex), rewrite the trailer + // counter to the 32-bit max, and corrupt the body so GCM rejects it. + const forged = new Uint8Array(g1.data.slice(0)); + new DataView(forged.buffer).setUint32( + forged.length - TRAILER_LEN, + COUNTER_HARD_LIMIT, + ); + forged[5] ^= 0xff; + posted.length = 0; + // The forged frame arrives first, then the genuine frames. With the old + // mutate-before-auth window the forged max counter advanced `highest` to + // 2^32-1, dropping every later genuine frame as "older than the window". + const out = await drive('decode', user, undefined, [ + { ...g1, data: forged.buffer }, + g1, + g2, + ]); + expect(out).toHaveLength(2); + expect(Array.from(new Uint8Array(out[0].data))).toEqual([ + 1, 2, 3, 4, 5, 6, 7, 8, + ]); + expect(Array.from(new Uint8Array(out[1].data))).toEqual([ + 9, 10, 11, 12, 13, 14, 15, 16, + ]); + }); + + it('keeps attempting decryption after the failure tolerance is exceeded', async () => { + const user = freshUser(); + await setKey(user); + // Encode FAILURE_TOLERANCE + 2 genuine frames with distinct rising + // counters, then tamper all but the last. + const n = FAILURE_TOLERANCE + 2; + const plaintexts = Array.from({ length: n }, (_, i) => [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + i & 0xff, + ]); + const encrypted = await drive( + 'encode', + user, + 'vp8', + plaintexts.map((p) => frame(p, 'delta')), + ); + expect(encrypted).toHaveLength(n); + const garbage = encrypted.slice(0, n - 1).map((f) => { + const bytes = new Uint8Array(f.data.slice(0)); + bytes[5] ^= 0xff; // flip a ciphertext byte; trailer/counter stay intact + return { ...f, data: bytes.buffer }; + }); + const genuine = encrypted[n - 1]; + posted.length = 0; + const out = await drive( + 'decode', + user, + undefined, + [...garbage, genuine], + 'VIDEO', + ); + // The genuine final frame still decrypts — the key was NOT latched invalid + // by the preceding failure burst. + expect(out).toHaveLength(1); + expect(Array.from(new Uint8Array(out[0].data))).toEqual(plaintexts[n - 1]); + // The break is surfaced once (on the tolerance crossing) and recovery once. + // Both name the track: a peer's audio and video are separate transforms + // reported under one userId, so a host cannot pair them up without this. + expect(posted.filter((m) => m.type === 'e2ee.broken')).toEqual([ + { type: 'e2ee.broken', userId: user, keyIndex: 0, trackType: 'VIDEO' }, + ]); + expect(posted.filter((m) => m.type === 'e2ee.decryption_resumed')).toEqual([ + { type: 'e2ee.decryption_resumed', userId: user, trackType: 'VIDEO' }, + ]); + expect(posted.filter((m) => m.type === 'e2ee.decryption_failed')).toEqual([ + { type: 'e2ee.decryption_failed', userId: user, trackType: 'VIDEO' }, + ]); + }); + + it('scopes failure accounting per track so a healthy track cannot mask a broken one', async () => { + const user = freshUser(); + await setKey(user); + // A video track and an audio track for the SAME user + keyIndex. Encode + // FAILURE_TOLERANCE + 1 video frames (then tamper them so each fails GCM) + // and one genuine audio frame on the same shared key. + const n = FAILURE_TOLERANCE + 1; + const videoPts = Array.from({ length: n }, (_, i) => [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + i & 0xff, + ]); + const videoEnc = await drive( + 'encode', + user, + 'vp8', + videoPts.map((p) => frame(p, 'delta')), + ); + const [audioEnc] = await drive('encode', user, 'opus', [ + frame([0x01, 0x02, 0x03, 0x04], undefined), + ]); + const tamperedVideo = videoEnc.map((f) => { + const bytes = new Uint8Array(f.data.slice(0)); + bytes[5] ^= 0xff; + return { ...f, data: bytes.buffer }; + }); + + // Video decode transform: every frame fails, so the break surfaces once. + posted.length = 0; + const vOut = await drive('decode', user, undefined, tamperedVideo); + expect(vOut).toHaveLength(0); + expect(posted.filter((m) => m.type === 'e2ee.broken')).toHaveLength(1); + + // Audio decode transform (a SEPARATE track): the genuine frame decrypts and + // must NOT emit decryption_resumed - this track never failed. With the old + // per-(user, keyIndex) counter shared across tracks, the audio success reset + // the video failures and spuriously "resumed" (and kept e2ee.broken from + // ever firing). + posted.length = 0; + const aOut = await drive('decode', user, undefined, [audioEnc]); + expect(aOut).toHaveLength(1); + expect(posted.some((m) => m.type === 'e2ee.decryption_resumed')).toBe( + false, + ); + }); +}); + +describe('encode pipeline edge behaviors', () => { + it('drops and signals missing_key when no key is set', async () => { + const user = freshUser(); + const out = await drive('encode', user, 'vp8', [ + frame([1, 2, 3, 4, 5], 'delta'), + ]); + expect(out).toHaveLength(0); + expect(posted.some((m) => m.type === 'e2ee.missing_key')).toBe(true); + }); + + it('fails closed and signals encryption_failed on an unsupported codec', async () => { + const user = freshUser(); + await setKey(user); + // A codec the worker can't split must not be published in the clear and + // must not stall the encoder (the old behavior left frames buffering with + // no signal). The pipeline drains - nothing is emitted - and the failure is + // observable via e2ee.encryption_failed. + const out = await drive('encode', user, 'theora', [ + frame([1, 2, 3, 4, 5], 'delta'), + ]); + expect(out).toHaveLength(0); + expect(posted.some((m) => m.type === 'e2ee.encryption_failed')).toBe(true); + }, 3000); + + it('fails closed on a video frame whose codec was not supplied', async () => { + const user = freshUser(); + await setKey(user); + // Without a codec there is no clear-byte rule and no escaping decision for + // video. Encrypting it whole would blind the SFU to the frame headers, and + // an unescaped H264 payload would be split by the packetizer on a random + // start code in the ciphertext - silently, since nothing would signal it. + const out = await drive('encode', user, undefined, [ + frame([1, 2, 3, 4, 5, 6, 7, 8], 'key'), + ]); + expect(out).toHaveLength(0); + expect(posted.some((m) => m.type === 'e2ee.encryption_failed')).toBe(true); + }, 3000); + + it('still encrypts an audio frame whose codec was not supplied', async () => { + const user = freshUser(); + await setKey(user); + // The counterpart to the case above: an audio frame carries no key/delta + // type, and the 1-byte TOC rule holds for any audio codec, so an unlabeled + // audio track must keep working. + const pt = [0x78, 0xaa, 0xbb, 0xcc, 0xdd]; + const [encrypted] = await drive('encode', user, undefined, [ + frame(pt, undefined), + ]); + expect(encrypted).toBeDefined(); + expect(new Uint8Array(encrypted.data)[0]).toBe(0x78); + expect(posted.some((m) => m.type === 'e2ee.encryption_failed')).toBe(false); + const [decrypted] = await drive('decode', user, undefined, [encrypted]); + expect(Array.from(new Uint8Array(decrypted.data))).toEqual(pt); + }); + + it('re-signals encryption_failed after recovery instead of latching for the worker lifetime', async () => { + const user = freshUser(); + await setKey(user); + // One h264 encode transform: a frame whose clear header exceeds the + // trailer's 15-bit clearBytes field fails to encrypt, a normal keyframe then + // encrypts (recovering the track), and a second bad frame must signal AGAIN. + // The old worker-lifetime latch emitted only the first signal and then went + // silent forever - so a later permanent fail-closed dropped every frame with + // no event. + const bad = () => { + // Zero padding, then an IDR slice start code far enough in that + // h264ClearBytes reports a clear header past MAX_CLEAR_BYTES. + const bytes = new Array(MAX_CLEAR_BYTES + 8).fill(0); + bytes.splice(MAX_CLEAR_BYTES, 6, 0, 0, 1, 0x65, 0xaa, 0xbb); + return frame(bytes, 'key'); + }; + const good = () => + frame([0, 0, 0, 1, 0x65, 0x88, 0x11, 0x22, 0x33, 0x44], 'key'); + posted.length = 0; + const out = await drive('encode', user, 'h264', [bad(), good(), bad()]); + // Only the valid frame is emitted; both bad frames are dropped (fail closed). + expect(out).toHaveLength(1); + // Signaled on the first bad frame, re-armed by the good frame, signaled + // again on the second bad frame. + expect( + posted.filter((m) => m.type === 'e2ee.encryption_failed'), + ).toHaveLength(2); + }); +}); + +describe('h264 trailer start-code safety', () => { + // SPS NALU, then an IDR slice NALU. h264ClearBytes leaves the start code + + // NALU header + 2 slice-header bytes in the clear (14 bytes here) and + // encrypts the rest. + const H264_KEYFRAME = [ + 0, + 0, + 0, + 1, + 0x67, + 0x42, + 0x00, + 0x0a, // SPS + 0, + 0, + 0, + 1, + 0x65, + 0xb8, + 0x40, // IDR slice: start code + NALU header + 1 byte + 0xaa, + 0xbb, + 0xcc, + 0xdd, + 0xee, // encrypted body + ]; + const H264_CLEAR_BYTES = 14; + + const hasAnnexBStartCode = (b: Uint8Array): boolean => { + for (let i = 0; i + 2 < b.length; i++) { + if (b[i] === 0 && b[i + 1] === 0 && b[i + 2] === 1) return true; + } + return false; + }; + + it.each([ + ['counter 1 -> 00 00 00 01', 0], + ['counter 256 -> 00 00 01 00', 255], + ])( + 'leaves no fake Annex-B start code in the encrypted region (%s)', + async (_label, seed) => { + const user = freshUser(); + await setKey(user); + __setFrameCounterForTest(seed); + const [encrypted] = await drive('encode', user, 'h264', [ + frame(H264_KEYFRAME, 'key'), + ]); + expect(encrypted).toBeDefined(); + const bytes = new Uint8Array(encrypted.data); + // The clear NALU header legitimately carries start codes; only the + // encrypted region after it must be start-code free, or libwebrtc's H264 + // packetizer would split a spurious NALU and corrupt the frame. + expect(hasAnnexBStartCode(bytes.subarray(H264_CLEAR_BYTES))).toBe(false); + }, + ); + + it('leaves no start code across the clear/encrypted boundary when the clear header ends in 0x00', async () => { + // The last clear byte is the first slice-header byte, which multi-slice + // encoders can emit as 0x00 (large first_mb_in_slice). If the ciphertext + // then starts 00 01, an unseeded escaper would ship a fake start code + // spanning the boundary. Ciphertext is not controllable here, so assert + // the invariant over the boundary region on many frames instead. + const h264ZeroTail = [ + ...[0, 0, 0, 1, 0x65, 0x00], // slice: start code + NALU header + 0x00 + ...[0xaa, 0xbb, 0xcc, 0xdd, 0xee], // encrypted body + ]; + const clearBytes = 6; + const user = freshUser(); + await setKey(user); + const frames = Array.from({ length: 32 }, () => + frame(h264ZeroTail, 'key' as const), + ); + const encrypted = await drive('encode', user, 'h264', frames); + expect(encrypted.length).toBe(frames.length); + for (const f of encrypted) { + const bytes = new Uint8Array(f.data); + // Scan from 2 bytes before the boundary so a code spanning it is seen. + expect(hasAnnexBStartCode(bytes.subarray(clearBytes - 2))).toBe(false); + } + const decrypted = await drive('decode', user, undefined, encrypted); + for (const f of decrypted) { + expect(Array.from(new Uint8Array(f.data))).toEqual(h264ZeroTail); + } + }); +}); diff --git a/packages/client/src/rtc/e2ee/__tests__/transformSupport.test.ts b/packages/client/src/rtc/e2ee/__tests__/transformSupport.test.ts new file mode 100644 index 0000000000..3d138da687 --- /dev/null +++ b/packages/client/src/rtc/e2ee/__tests__/transformSupport.test.ts @@ -0,0 +1,65 @@ +import '../../__tests__/mocks/webrtc.mocks'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { preferredTransform } from '../transformSupport'; +import { isChrome } from '../../../helpers/browsers'; + +// Mock browser detection so we can drive the Chrome vs non-Chrome transform +// selection deterministically. Defaults to non-Chrome (reset in beforeEach). +vi.mock('../../../helpers/browsers', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, isChrome: vi.fn().mockReturnValue(false) }; +}); + +describe('preferredTransform', () => { + const setInsertableStreams = (available: boolean) => { + if (available) { + Object.assign(RTCRtpSender.prototype, { + createEncodedStreams: vi.fn(), + }); + } else { + // @ts-expect-error - cleaning up non-standard property from mock prototype + delete RTCRtpSender.prototype.createEncodedStreams; + } + }; + + beforeEach(() => { + vi.mocked(isChrome).mockReturnValue(false); + }); + + afterEach(() => { + // @ts-expect-error - cleaning up non-standard property from mock prototype + delete RTCRtpSender.prototype.createEncodedStreams; + }); + + const setScriptTransform = (available: boolean) => { + if (available) return; + const original = globalThis.RTCRtpScriptTransform; + delete globalThis.RTCRtpScriptTransform; + return () => { + globalThis.RTCRtpScriptTransform = original; + }; + }; + + // The whole selection policy is this matrix. Chrome is pinned to Insertable + // Streams wherever it exists, because its RTCRtpScriptTransform is still + // unreliable for E2EE; everything else prefers the standard API. + it.each([ + ['chrome', true, true, true, 'insertable'], + ['chrome, no insertable streams', true, false, true, 'script'], + ['chrome, neither API', true, false, false, undefined], + ['non-chrome', false, true, true, 'script'], + ['non-chrome, no script transform', false, true, false, 'insertable'], + ['non-chrome, neither API', false, false, false, undefined], + ] as const)('%s', (_label, chrome, insertable, script, expected) => { + vi.mocked(isChrome).mockReturnValue(chrome); + setInsertableStreams(insertable); + const restore = setScriptTransform(script); + try { + expect(preferredTransform()).toBe(expected); + } finally { + restore?.(); + } + }); +}); diff --git a/packages/client/src/rtc/e2ee/e2ee-worker.ts b/packages/client/src/rtc/e2ee/e2ee-worker.ts new file mode 100644 index 0000000000..d4be1721a5 --- /dev/null +++ b/packages/client/src/rtc/e2ee/e2ee-worker.ts @@ -0,0 +1,27 @@ +/** + * E2EE worker. The build populates it. + * + * The actual implementation lives in `e2ee-worker/e2ee-worker-impl.ts` and its + * sibling modules (`constants.ts`, `codec.ts`, `crypto.ts`, `utils.ts`, + * `types.ts`), all written as real TypeScript with full type checking + * and import support. + * + * ## How it works + * + * 1. You edit files in `e2ee-worker/` as normal TypeScript + * 2. `yarn build` runs Rollup, which invokes `rollup-plugin-inline-worker` + * 3. The plugin bundles `e2ee-worker/e2ee-worker-impl.ts` (and its imports) + * into a single function via a nested Rollup + TypeScript build + * 4. The function replaces this file's export at build time + * 5. `EncryptionManager.create()` creates a Worker from `e2eeWorker.toString()` + * + * In tests, this module is mocked (see `__tests__/EncryptionManager.test.ts`), + * so the placeholder is never evaluated. + * + * @see e2ee-worker/e2ee-worker-impl.ts - the entry point + * @see ../../plugins/rollup-plugin-inline-worker.ts - the Rollup plugin + * @see EncryptionManager.ts - consumes e2eeWorker + */ +export function e2eeWorker() { + // will be populated at build time +} diff --git a/packages/client/src/rtc/e2ee/e2ee-worker/codec.ts b/packages/client/src/rtc/e2ee/e2ee-worker/codec.ts new file mode 100644 index 0000000000..8e26873117 --- /dev/null +++ b/packages/client/src/rtc/e2ee/e2ee-worker/codec.ts @@ -0,0 +1,220 @@ +/** + * Codec-specific framing: how many header bytes stay clear, and H.264 RBSP + * escaping so ciphertext cannot contain a fake Annex B start code. + */ +const findStartCode = ( + data: Uint8Array, + offset: number, +): { pos: number; len: number } | null => { + 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; +}; + +/** + * Clear bytes up to the first slice NALU (type 1 or 5) plus 2: start code, + * NALU header, one byte of slice header. Ignores frameType, to match + * {@link CodecProfile.clearBytes}. + */ +const h264ClearBytes = ( + _frameType: string | undefined, + data: Uint8Array, +): number => { + 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) { + const clear = sc.pos + sc.len + 2; + // A real slice header exceeds 2 bytes, so this clamp should never bite. + return clear > data.length ? data.length : clear; + } + sc = findStartCode(data, headerPos); + } + return 0; +}; + +/** + * Escape-state seed for the clear-header boundary: how many 0x00 bytes end the + * clear header, capped at 2 (a longer run does not change the escaper's next + * decision). + * + * The clear header itself is never escaped, but its tail and the first escaped + * bytes form one contiguous stream on the wire: a header ending in 0x00 + * followed by ciphertext starting 0x00 0x01 is a fake start code the + * packetizer would split on. Seeding the escaper as if the header's zeros were + * already scanned closes that gap. Encode and decode both derive the seed from + * the same clear bytes, so nothing extra travels in the frame. + */ +export const boundarySeedZeros = (clearHeader: Uint8Array): number => { + const n = clearHeader.length; + let zeros = 0; + while (zeros < 2 && zeros < n && clearHeader[n - 1 - zeros] === 0) zeros++; + return zeros; +}; + +/** + * Escaped length of `segments`, read as one contiguous stream so a 0x00 0x00 + * run spanning a boundary counts. Sizes the buffer for {@link rbspEscapeInto}. + * + * @param seedZeros zero-run state entering the stream; see + * {@link boundarySeedZeros}. + */ +export const rbspEscapedLength = ( + segments: Uint8Array[], + seedZeros: number, +): number => { + let total = 0; + let zeros = seedZeros; + for (const seg of segments) { + total += seg.length; + for (let i = 0; i < seg.length; ++i) { + const byte = seg[i]; + if (zeros >= 2 && byte <= 3) { + total++; + zeros = 0; + } + zeros = byte === 0 ? zeros + 1 : 0; + } + } + return total; +}; + +/** + * Escape `segments` into `dst` at `offset`, inserting 0x03 after each 0x00 0x00 + * run followed by 0x00-0x03. `dst` needs {@link rbspEscapedLength} bytes free, + * computed with the same `seedZeros`. + * + * Sizing separately lets the encoder escape straight behind the clear header, + * copying the ciphertext once instead of twice. + */ +export const rbspEscapeInto = ( + dst: Uint8Array, + offset: number, + segments: Uint8Array[], + seedZeros: number, +): void => { + let j = offset; + let zeros = seedZeros; + for (const seg of segments) { + for (let i = 0; i < seg.length; ++i) { + const byte = seg[i]; + if (zeros >= 2 && byte <= 3) { + dst[j++] = 3; + zeros = 0; + } + dst[j++] = byte; + zeros = byte === 0 ? zeros + 1 : 0; + } + } +}; + +/** + * Reverse of {@link rbspEscapeInto}: drop each 0x03 the escaper inserted, + * tracked with the same zero-run state so an escape byte right at the + * clear-header boundary (possible only with a non-zero `seedZeros`) is + * recognized too. + */ +export const rbspUnescape = ( + data: Uint8Array, + seedZeros: number, +): Uint8Array => { + const isEscapeByte = (i: number, zeros: number): boolean => + zeros >= 2 && data[i] === 3 && i + 1 < data.length && data[i + 1] <= 3; + let remove = 0; + let zeros = seedZeros; + for (let i = 0; i < data.length; ++i) { + if (isEscapeByte(i, zeros)) { + remove++; + zeros = 0; + continue; + } + zeros = data[i] === 0 ? zeros + 1 : 0; + } + if (remove === 0) return data; + const result = new Uint8Array(data.length - remove); + let j = 0; + zeros = seedZeros; + for (let i = 0; i < data.length; ++i) { + if (isEscapeByte(i, zeros)) { + zeros = 0; + continue; + } + result[j++] = data[i]; + zeros = data[i] === 0 ? zeros + 1 : 0; + } + return result; +}; + +/** + * How E2EE splits a frame, for one codec. The only place holding encode-side + * codec knowledge: one entry wires support detection, clear-byte sizing and + * RBSP escaping together, so no codec can be half-supported. An H265 entry + * that forgot NALU escaping would ship start-code-corrupting ciphertext. + */ +export interface CodecProfile { + /** Escape ciphertext and trailer against fake Annex-B start codes (H264). */ + rbsp: boolean; + /** + * The profile can frame audio only, so the encoder fails closed on a frame + * that carries a key/delta type. Without this an unlabeled video frame would + * be encrypted whole and unescaped: the SFU could not read its headers, and a + * NALU packetizer would split it on a random start code in the ciphertext. + */ + audioOnly: boolean; + /** Leading bytes left clear and passed as AAD, so the SFU can select layers. */ + clearBytes: (frameType: string | undefined, data: Uint8Array) => number; +} + +// Audio has no keyframes, so the absence of a frame type identifies it. Keep +// the Opus TOC byte clear. Only reached for audio: `audioOnly` rejects a video +// frame before this runs. +const defaultClearBytes = (frameType: string | undefined): number => + frameType === undefined ? 1 : 0; + +// Clamped to the frame length. A short frame claiming more would make encode +// zero-pad the header and decode build an AAD of a different length. +const vpClearBytes = ( + frameType: string | undefined, + data: Uint8Array, +): number => { + const clear = frameType === 'key' ? 10 : 3; + return clear > data.length ? data.length : clear; +}; + +// AV1 is absent on purpose: the AV1 RTP packetizer parses the OBU stream, so a +// frame trailer does not survive. It needs its own scheme. Without an entry it +// falls through to isSupportedCodec and fails closed. +const CODEC_PROFILES: Record = { + opus: { rbsp: false, audioOnly: true, clearBytes: defaultClearBytes }, + vp8: { rbsp: false, audioOnly: false, clearBytes: vpClearBytes }, + vp9: { rbsp: false, audioOnly: false, clearBytes: vpClearBytes }, + h264: { rbsp: true, audioOnly: false, clearBytes: h264ClearBytes }, +}; + +// Used when the caller names no codec. Audio is safe to frame unlabeled, since +// the TOC byte rule holds for any audio codec. Video is not: the right clear +// header and whether to escape both depend on the codec, so it fails closed. +const DEFAULT_PROFILE: CodecProfile = { + rbsp: false, + audioOnly: true, + clearBytes: defaultClearBytes, +}; + +// Object.hasOwn, not `in`: `in` walks the prototype chain, so a codec named +// like an Object.prototype member ('toString') would resolve to a function +// instead of a profile. +export const getCodecProfile = (codec: string | undefined): CodecProfile => + codec !== undefined && Object.hasOwn(CODEC_PROFILES, codec) + ? CODEC_PROFILES[codec] + : DEFAULT_PROFILE; + +export const isSupportedCodec = (codec: string | undefined): boolean => + codec === undefined || Object.hasOwn(CODEC_PROFILES, codec); diff --git a/packages/client/src/rtc/e2ee/e2ee-worker/constants.ts b/packages/client/src/rtc/e2ee/e2ee-worker/constants.ts new file mode 100644 index 0000000000..1614cf9b34 --- /dev/null +++ b/packages/client/src/rtc/e2ee/e2ee-worker/constants.ts @@ -0,0 +1,47 @@ +/** Marks an encrypted frame's trailer. */ +export const MAGIC = 0xe2eefeed; + +/** + * Wire format version. Bump it when the trailer layout or IV derivation change. + * + * v1: [4B frameCounter][8B ivPrefix][1B keyIndex][2B clearBytes|flags] + * [1B version][4B magic]. IV = ivPrefix ∥ frameCounter. + */ +export const E2EE_VERSION = 1; + +export const IV_PREFIX_LEN = 8; +export const FRAME_COUNTER_LEN = 4; +export const IV_LEN = IV_PREFIX_LEN + FRAME_COUNTER_LEN; + +const KEY_INDEX_LEN = 1; +const CLEAR_BYTES_LEN = 2; +const VERSION_LEN = 1; +const MAGIC_LEN = 4; + +/** 4 + 8 + 1 + 2 + 1 + 4 */ +export const TRAILER_LEN = + FRAME_COUNTER_LEN + + IV_PREFIX_LEN + + KEY_INDEX_LEN + + CLEAR_BYTES_LEN + + VERSION_LEN + + MAGIC_LEN; + +/** Bit 15 of the clearBytes field. Signals RBSP escaping. */ +export const RBSP_FLAG = 0x8000; +/** Bit 15 belongs to RBSP_FLAG, so clearBytes gets 15 bits. */ +export const MAX_CLEAR_BYTES = 0x7fff; + +export const EMPTY_AAD = new Uint8Array(0); + +/** Consecutive decrypt failures on one track before `e2ee.broken` fires. */ +export const FAILURE_TOLERANCE = 10; + +/** Replay window in frames. A counter <= highestSeen - this is rejected. */ +export const REPLAY_WINDOW = 1024; + +/** + * One more than this wraps into an (ivPrefix, counter) pair the sender already + * used, which is IV reuse under AES-GCM. Encoding throws instead. + */ +export const COUNTER_HARD_LIMIT = 0xffffffff; // 2^32 - 1 diff --git a/packages/client/src/rtc/e2ee/e2ee-worker/decode.ts b/packages/client/src/rtc/e2ee/e2ee-worker/decode.ts new file mode 100644 index 0000000000..3cee23a1f5 --- /dev/null +++ b/packages/client/src/rtc/e2ee/e2ee-worker/decode.ts @@ -0,0 +1,145 @@ +import { EMPTY_AAD, IV_LEN, TRAILER_LEN } from './constants'; +import { boundarySeedZeros, rbspUnescape } from './codec'; +import { fillIV, readTrailer, readTrailerIv } from './trailer'; +import { FailureTracker } from './failureTracker'; +import { ReplayWindow } from './replayWindow'; +import { keyStore } from './keyStore'; +import { decodeStats } from './perf'; +import { DecodeNotifier } from './notifications'; +import type { EncodedFrame, FrameController } from './types'; + +export const decodeTransform = ( + userId: string, + trackType: string | undefined, +) => { + // Counts a peer's audio and video apart. + const trackKey = trackType ?? 'unknown'; + const stats = decodeStats.track(`${userId}/${trackKey}`, { + userId, + trackType: trackKey, + }); + const notify = new DecodeNotifier(userId, trackType); + const iv = new Uint8Array(IV_LEN); + const ivView = new DataView(iv.buffer); + + // Per track, so a user's audio, video and screen share never share a window + // or a failure count. The separate count is what lets e2ee.broken fire. + const replay = new ReplayWindow(); + const failures = new FailureTracker(); + + /** + * Gates on key and replay, decrypts, emits, then records failure or recovery. + * `decrypt` throws on a GCM tag failure, dropping the frame. Separate from + * the framing parse so the trust ordering lives in one place. + * + * Trust ordering (the SFrame/SRTP rule): a relay can forge `frameCounter`, + * `ivPrefix` and `keyIndex`, which are plaintext in the trailer, so nothing + * changes trust state until GCM authenticates. Hence peek before, commit + * after. The failure counter is diagnostic only - it gates `e2ee.broken`, + * never the decrypt attempt - so forged frames cannot mark a key invalid. + */ + const finishDecode = async ( + frame: EncodedFrame, + controller: FrameController, + keyIndex: number, + ivPrefix: Uint8Array, + frameCounter: number, + decrypt: (key: CryptoKey) => Promise, + ) => { + const cryptoKey = keyStore.getKey(userId, keyIndex); + if (!cryptoKey) { + notify.missingKey(keyIndex); + return; + } + // No state change. A replay or an out-of-window frame is dropped silently; + // neither is a decryption failure. + if (!replay.peek(frameCounter, ivPrefix)) return; + try { + const t0 = stats.startCrypto(); + const data = await decrypt(cryptoKey); + stats.endCrypto(t0); + // Authenticated: only now is it safe to advance the replay window. + replay.commit(frameCounter, ivPrefix); + // Independent on purpose: the count is per keyIndex, but + // `decryption_failed` is per track, so a track recovering on a NEW + // keyIndex must still clear it. Gating on recordSuccess would latch the + // host on failed forever. + failures.recordSuccess(keyIndex); + notify.resumed(); + frame.data = data; + controller.enqueue(frame); + stats.bump(); + } catch { + // True only on the failure crossing the tolerance, so `e2ee.broken` fires + // once per run, not once per frame. + const becameInvalid = failures.recordFailure(keyIndex); + notify.failed(); + if (becameInvalid) notify.broken(keyIndex); + } + }; + + return new TransformStream({ + async transform(frame, controller) { + if (frame.data.byteLength === 0) { + controller.enqueue(frame); + stats.bump(); + return; + } + + const src = new Uint8Array(frame.data); + const trailer = readTrailer(src); + + if (!trailer) { + notify.unencrypted(); + controller.enqueue(frame); + stats.bump(); + return; + } + + const { clearBytes, isRbsp } = trailer; + + // An RBSP (H264) frame escaped the ciphertext together with the counter, + // ivPrefix and keyIndex, so un-escape to recover them; only the trailer + // tail read above stayed clear. A non-RBSP frame keeps the trailer raw. + let { frameCounter, ivPrefix, keyIndex } = trailer; + let ciphertext: Uint8Array; + if (isRbsp) { + // Same boundary seed as the encoder, derived from the same clear bytes. + const seed = boundarySeedZeros(src.subarray(0, clearBytes)); + const unit = rbspUnescape(src.subarray(clearBytes), seed); + // Un-escaping can leave less than a trailer, since readTrailer sized + // clearBytes against the raw frame. A negative offset would throw out + // of transform() and kill this track's pipeline for the session, and a + // relay can forge the shape: clearBytes and the flag are plaintext. + if (unit.length < TRAILER_LEN) return; + ({ frameCounter, ivPrefix, keyIndex } = readTrailerIv(unit)); + ciphertext = unit.subarray(0, unit.length - TRAILER_LEN); + } else { + ciphertext = src.subarray(clearBytes, src.length - TRAILER_LEN); + } + + return finishDecode( + frame, + controller, + keyIndex, + ivPrefix, + frameCounter, + async (key) => { + fillIV(iv, ivView, ivPrefix, frameCounter); + const aad = clearBytes > 0 ? src.subarray(0, clearBytes) : EMPTY_AAD; + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv, additionalData: aad as BufferSource }, + key, + ciphertext as BufferSource, + ); + if (clearBytes === 0) return decrypted; + const plaintext = new Uint8Array(decrypted); + const dst = new Uint8Array(clearBytes + plaintext.length); + dst.set(src.subarray(0, clearBytes), 0); + dst.set(plaintext, clearBytes); + return dst.buffer; + }, + ); + }, + }); +}; diff --git a/packages/client/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts b/packages/client/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts new file mode 100644 index 0000000000..ab3f9ef70b --- /dev/null +++ b/packages/client/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts @@ -0,0 +1,132 @@ +/** + * E2EE Web Worker entry point. Owns the worker's message interface and wires + * the encrypt/decrypt transforms into WebRTC Encoded Transforms. The main + * thread distributes keys by postMessage, and transforms look one up per frame + * by (userId, keyIndex). + * + * ## Wire format + * + * Frame layout is [clear header][ciphertext + GCM tag][20B trailer], so 36 + * bytes of overhead. The trailer holds: + * [4B frameCounter][8B ivPrefix][1B keyIndex][2B clearBytes|flags] + * [1B version][4B 0xE2EEFEED] + * + * The clear header keeps codec headers readable so the SFU can detect keyframes + * and select layers: 1 byte for Opus, 10/3 for VP8 and VP9 (key/delta), and for + * H264 everything up to the first slice NALU + 2, with the encrypted tail + * RBSP-escaped against fake start codes. It doubles as the AAD, so the SFU can + * read it but decrypt still detects tampering. + * + * The 12-byte IV is [ivPrefix][frameCounter]. `ivPrefix` is random per key + * import and travels in the trailer, so IVs stay unique even when the host + * imports the same raw key twice. + * + * rollup-plugin-inline-worker bundles this into the function + * `../e2ee-worker.ts` exports. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Using_Encoded_Transforms + * @see https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/encrypt#aes-gcm + */ + +import { isSupportedCodec } from './codec'; +import { enqueue } from './queue'; +import { keyStore } from './keyStore'; +import { decodeStats, startPerfReport, stopPerfReport } from './perf'; +import { EncodeNotifier, reportError } from './notifications'; +import { encodeTransform } from './encode'; +import { decodeTransform } from './decode'; +import type { EncodedFrame } from './types'; + +/** + * Decode always runs. An encode whose codec the worker cannot split fails + * closed: it still installs a transform, but one that drops every frame, since + * returning without one leaves the encoder buffering forever with no signal. + */ +const selectTransform = ( + operation: string, + userId: string, + codec: string | undefined, + trackType: string | undefined, +): TransformStream => { + if (operation !== 'encode') return decodeTransform(userId, trackType); + if (isSupportedCodec(codec)) return encodeTransform(userId, codec, trackType); + new EncodeNotifier(userId, trackType).failed( + `unsupported codec for E2EE: ${codec}`, + ); + // Enqueues nothing: every frame is dropped. + return new TransformStream({ transform() {} }); +}; + +const setupTransform = ({ + readable, + writable, + operation, + userId, + codec, + trackType, +}: { + readable: ReadableStream; + writable: WritableStream; + operation: string; + userId: string; + codec?: string; + trackType?: string; +}) => { + const transform = selectTransform(operation, userId, codec, trackType); + readable + .pipeThrough(transform) + .pipeTo(writable) + .catch((err: any) => { + reportError( + `Transform pipeline error (${operation}, ${userId}): ${ + err?.message || err + }`, + ); + }); +}; + +addEventListener('rtctransform', (event) => { + const { readable, writable, options } = event.transformer; + // Same queue as message-based setup, so an in-flight key import completes + // before the transform is wired up. + enqueue(async () => { + setupTransform({ readable, writable, ...options }); + }).catch((err: any) => { + reportError(`Transform setup failed: ${err?.message || err}`); + }); +}); + +addEventListener('message', ({ data }) => { + enqueue(async () => { + switch (data.type) { + case 'cmd.set_key': + await keyStore.importKey(data.userId, data.keyIndex, data.rawKey); + break; + case 'cmd.set_shared_key': + await keyStore.importSharedKey(data.keyIndex, data.rawKey); + break; + case 'cmd.remove_shared_key': + keyStore.removeSharedKey(data.keyIndex); + break; + case 'cmd.remove_keys': + keyStore.removeKeys(data.userId); + decodeStats.removeUser(data.userId); + break; + case 'cmd.enable_performance_reporting': + if (data.enabled) startPerfReport(); + else stopPerfReport(); + break; + case 'cmd.dump_key_state': + self.postMessage({ type: 'e2ee.key_state', ...keyStore.dump() }); + break; + case 'cmd.setup_transform': + setupTransform(data); + break; + default: + reportError(`Unknown command type: ${data.type}`); + break; + } + }).catch((err: any) => { + reportError(`Message handler error: ${err?.message || err}`); + }); +}); diff --git a/packages/client/src/rtc/e2ee/e2ee-worker/encode.ts b/packages/client/src/rtc/e2ee/e2ee-worker/encode.ts new file mode 100644 index 0000000000..0dda461461 --- /dev/null +++ b/packages/client/src/rtc/e2ee/e2ee-worker/encode.ts @@ -0,0 +1,147 @@ +import { EMPTY_AAD, IV_LEN, MAX_CLEAR_BYTES, TRAILER_LEN } from './constants'; +import { + boundarySeedZeros, + getCodecProfile, + rbspEscapeInto, + rbspEscapedLength, +} from './codec'; +import { fillIV, writeTrailer } from './trailer'; +import { nextFrameCounter } from './frameCounter'; +import { keyStore } from './keyStore'; +import { encodeStats } from './perf'; +import { EncodeNotifier, notifyMissingEncodeKey } from './notifications'; +import type { EncodedFrame, FrameController } from './types'; + +export const encodeTransform = ( + userId: string, + codec: string | undefined, + trackType: string | undefined, +) => { + const profile = getCodecProfile(codec); + const isNalu = profile.rbsp; + // trackType is unique per sender; fall back to codec when it is unlabeled. + const trackKey = trackType ?? codec ?? 'unknown'; + const codecKey = codec ?? 'unknown'; + const stats = encodeStats.track(trackKey, { + userId, + trackType: trackKey, + codec: codecKey, + }); + const notify = new EncodeNotifier(userId, trackType); + const iv = new Uint8Array(IV_LEN); + const ivView = new DataView(iv.buffer); + + /** + * Times the encryption, emits the frame, reports failures. `produce` returns + * the new bytes, or null when it already dropped the frame with its own + * reason. Any throw drops the frame; it is never emitted in the clear. + */ + const finishEncode = async ( + frame: EncodedFrame, + controller: FrameController, + produce: () => Promise | null>, + ) => { + try { + const t0 = stats.startCrypto(); + const out = await produce(); + stats.endCrypto(t0); + if (!out) return; + frame.data = out.buffer; + controller.enqueue(frame); + notify.recovered(); + stats.bump(); + } catch (err: any) { + notify.failed(err?.message || String(err)); + } + }; + + return new TransformStream({ + async transform(frame, controller) { + // No payload to encrypt. + if (frame.data.byteLength === 0) { + controller.enqueue(frame); + stats.bump(); + return; + } + + const entry = keyStore.getLatestKey(userId); + if (!entry) { + notifyMissingEncodeKey(userId); + return; + } + + const { key: cryptoKey, keyIndex, ivPrefix: prefix } = entry; + + return finishEncode(frame, controller, async () => { + // A key/delta type marks a video frame. An audio-only profile has no + // clear-byte rule for one, so drop it rather than ship a whole-frame, + // unescaped encrypt the SFU cannot read and a NALU packetizer would + // split. Checked before the counter, so a dropped frame costs no IV. + if (profile.audioOnly && frame.type !== undefined) { + notify.failed(`no clear-byte rule for video on codec ${codecKey}`); + return null; + } + const src = new Uint8Array(frame.data); + const clearBytes = profile.clearBytes(frame.type, src); + if (clearBytes > MAX_CLEAR_BYTES) { + // Writing this would overflow into the RBSP flag bit. + notify.failed('clearBytes exceeds trailer capacity'); + return null; + } + // Throws at the 32-bit ceiling; finishEncode catches it, so the track + // fails closed instead of reusing an IV. + const counter = nextFrameCounter(); + fillIV(iv, ivView, prefix, counter); + const aad = clearBytes > 0 ? src.subarray(0, clearBytes) : EMPTY_AAD; + const plaintext = clearBytes > 0 ? src.subarray(clearBytes) : src; + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv, additionalData: aad as BufferSource }, + cryptoKey, + plaintext as BufferSource, + ); + const ciphertext = new Uint8Array(encrypted); + if (isNalu && clearBytes > 0) { + // Escape ciphertext and trailer as one unit: random ciphertext or + // the counter bytes could otherwise form a fake Annex-B start code + // that libwebrtc's H264 packetizer would split on. The escaper is + // seeded with the clear header's trailing zeros so a start code + // cannot form across the clear/encrypted boundary either. + // + // The last 7 trailer bytes survive escaping untouched: the RBSP flag + // holds the clearBytes high byte at >= 0x80, which breaks any zero + // run before it can reach them. The decoder reads them off the raw + // frame tail to locate the unit. + // + // Escaping straight behind the clear header copies the ciphertext + // once, instead of staging it through an intermediate unit buffer + // and copying it again. + const trailer = new Uint8Array(TRAILER_LEN); + writeTrailer(trailer, 0, counter, prefix, keyIndex, clearBytes, true); + const body = [ciphertext, trailer]; + const seed = boundarySeedZeros(aad); + const dst = new Uint8Array( + clearBytes + rbspEscapedLength(body, seed), + ); + dst.set(aad, 0); + rbspEscapeInto(dst, clearBytes, body, seed); + return dst; + } + const dst = new Uint8Array( + clearBytes + ciphertext.length + TRAILER_LEN, + ); + if (clearBytes > 0) dst.set(aad, 0); + dst.set(ciphertext, clearBytes); + writeTrailer( + dst, + clearBytes + ciphertext.length, + counter, + prefix, + keyIndex, + clearBytes, + false, + ); + return dst; + }); + }, + }); +}; diff --git a/packages/client/src/rtc/e2ee/e2ee-worker/failureTracker.ts b/packages/client/src/rtc/e2ee/e2ee-worker/failureTracker.ts new file mode 100644 index 0000000000..1e6c29f34b --- /dev/null +++ b/packages/client/src/rtc/e2ee/e2ee-worker/failureTracker.ts @@ -0,0 +1,26 @@ +import { FAILURE_TOLERANCE } from './constants'; + +/** + * Consecutive decryption failures on one track, keyed by keyIndex so a rotation + * starts fresh. + */ +export class FailureTracker { + private counts: Map = new Map(); + + /** + * True only on the failure crossing {@link FAILURE_TOLERANCE}, so + * `e2ee.broken` fires once per run. + */ + recordFailure = (keyIndex: number): boolean => { + const next = (this.counts.get(keyIndex) ?? 0) + 1; + this.counts.set(keyIndex, next); + return next === FAILURE_TOLERANCE + 1; + }; + + /** + * True if there was a count to clear. Do NOT gate `e2ee.decryption_resumed` + * on it: that event names a track, not a key epoch, so a track recovering on + * a new keyIndex must still clear it. + */ + recordSuccess = (keyIndex: number): boolean => this.counts.delete(keyIndex); +} diff --git a/packages/client/src/rtc/e2ee/e2ee-worker/frameCounter.ts b/packages/client/src/rtc/e2ee/e2ee-worker/frameCounter.ts new file mode 100644 index 0000000000..638b8602b7 --- /dev/null +++ b/packages/client/src/rtc/e2ee/e2ee-worker/frameCounter.ts @@ -0,0 +1,37 @@ +import { COUNTER_HARD_LIMIT } from './constants'; + +/** + * Monotonic, and deliberately shared across every track and codec this sender + * publishes: the IV is `ivPrefix ∥ counter`, so two tracks drawing from + * separate counters would encrypt different frames under the same IV. + * + * Survives `removeKeys` on purpose - if the same raw key is imported again the + * counter keeps climbing, so no (ivPrefix, counter) pair repeats. Second guard + * against IV reuse, after the per-import random prefix. + */ +let counter = 0; + +export const nextFrameCounter = (): number => { + const next = counter + 1; + if (next > COUNTER_HARD_LIMIT) { + throw new Error('frame counter exhausted, create a new EncryptionManager'); + } + counter = next; + return next; +}; + +/** + * @internal Test-only. Reaches counter values that would otherwise need 2^32 + * frames. Unused in production, so the bundler drops it. + */ +export const __setFrameCounterForTest = (value: number) => { + counter = value; +}; + +/** + * @internal Test-only. Resets between test cases. Production teardown is + * `Worker.terminate()`, which reclaims the whole worker. + */ +export const __resetFrameCounterForTest = () => { + counter = 0; +}; diff --git a/packages/client/src/rtc/e2ee/e2ee-worker/keyStore.ts b/packages/client/src/rtc/e2ee/e2ee-worker/keyStore.ts new file mode 100644 index 0000000000..f9e7259671 --- /dev/null +++ b/packages/client/src/rtc/e2ee/e2ee-worker/keyStore.ts @@ -0,0 +1,193 @@ +import { IV_PREFIX_LEN } from './constants'; +import { reportError } from './notifications'; +import type { ResolvedKey } from './types'; + +/** Nested rather than a `"userId:keyIndex"` string key, which a colon in the + * userId would make ambiguous. */ +type UserKeyMap = Map>; + +const getOrCreate = (map: UserKeyMap, userId: string): Map => { + let inner = map.get(userId); + if (!inner) { + inner = new Map(); + map.set(userId, inner); + } + return inner; +}; + +/** One imported key. Always written and deleted as a unit. */ +interface KeyMaterial { + key: CryptoKey; + /** + * Sender-side, fresh per import, so two imports of the same raw key get + * different prefixes and cannot reuse an IV. Receivers read the prefix off + * the frame trailer instead. + */ + ivPrefix: Uint8Array; + /** First 8 bytes of SHA-256(rawKey). Not reversible, so safe to expose. */ + fingerprint: Uint8Array; +} + +const randomBytes = (n: number): Uint8Array => { + const bytes = new Uint8Array(n); + crypto.getRandomValues(bytes); + return bytes; +}; + +const fingerprint = async (rawKey: ArrayBuffer): Promise => { + const hash = await crypto.subtle.digest('SHA-256', rawKey); + return new Uint8Array(hash, 0, 8); +}; + +/** + * The buffer length picks the variant; EncryptionManager already validated it + * as 16 or 32 bytes. Passing `length` keeps WebCrypto unambiguous across + * browsers. + */ +const aesGcmParams = (rawKey: ArrayBuffer): AesKeyAlgorithm => ({ + name: 'AES-GCM', + length: rawKey.byteLength * 8, +}); + +/** + * Import into a non-extractable CryptoKey, with a fresh random IV prefix and + * the fingerprint. The fresh prefix is what lets the same raw key be imported + * again without IV reuse. + */ +const importKeyMaterial = async (rawKey: ArrayBuffer): Promise => { + const [key, fp] = await Promise.all([ + crypto.subtle.importKey('raw', rawKey, aesGcmParams(rawKey), false, [ + 'encrypt', + 'decrypt', + ]), + fingerprint(rawKey), + ]); + return { key, ivPrefix: randomBytes(IV_PREFIX_LEN), fingerprint: fp }; +}; + +const toHex = (bytes: Uint8Array): string => + Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + +export class KeyStore { + private perUserKeys: UserKeyMap = new Map(); + private latestKeyIndex = new Map(); + /** Shared receive keys retained by epoch so in-flight frames survive rotation. */ + private sharedKeys = new Map(); + /** The shared epoch used for encode fallback. Older retained epochs never win. */ + private activeSharedKeyIndex: number | undefined; + + /** + * Decryption key for (userId, keyIndex): per-user entry, else the shared key + * at that index. The keyIndex comes from the frame trailer. + */ + getKey = (userId: string, keyIndex: number): CryptoKey | undefined => { + const perUser = this.perUserKeys.get(userId)?.get(keyIndex); + if (perUser) return perUser.key; + return this.sharedKeys.get(keyIndex)?.key; + }; + + /** + * The encode path's only lookup: latest per-user key, else the explicitly + * active shared key. Retained shared epochs are receive-only. The `ivPrefix` + * rides along so the encoder never resolves the same material twice. + */ + getLatestKey = ( + userId: string, + ): (ResolvedKey & { ivPrefix: Uint8Array }) | null => { + const idx = this.latestKeyIndex.get(userId); + if (idx !== undefined) { + const km = this.perUserKeys.get(userId)?.get(idx); + if (km) return { key: km.key, keyIndex: idx, ivPrefix: km.ivPrefix }; + } + if (this.activeSharedKeyIndex === undefined) return null; + const shared = this.sharedKeys.get(this.activeSharedKeyIndex); + return shared + ? { + key: shared.key, + keyIndex: this.activeSharedKeyIndex, + ivPrefix: shared.ivPrefix, + } + : null; + }; + + importKey = async (userId: string, keyIndex: number, rawKey: ArrayBuffer) => { + try { + getOrCreate(this.perUserKeys, userId).set( + keyIndex, + await importKeyMaterial(rawKey), + ); + this.latestKeyIndex.set(userId, keyIndex); + } catch (e: any) { + reportError( + `Failed to import key for user ${userId}: ${e?.message || e}`, + ); + } + }; + + importSharedKey = async (keyIndex: number, rawKey: ArrayBuffer) => { + try { + const material = await importKeyMaterial(rawKey); + this.sharedKeys.set(keyIndex, material); + this.activeSharedKeyIndex = keyIndex; + } catch (e: any) { + reportError(`Failed to import shared key: ${e?.message || e}`); + } + }; + + /** + * Leaves the frame counters intact on purpose: a reset counter would reuse + * IVs if the same raw key is imported again later. They live in + * `frameCounter.ts`, so this cannot reach them even by accident. + */ + removeKeys = (userId: string) => { + this.perUserKeys.delete(userId); + this.latestKeyIndex.delete(userId); + }; + + /** + * Removes exactly one shared receive epoch. Removing the active epoch also + * disables shared-key encode fallback; an older epoch is never reactivated + * implicitly because doing so could resume sending with a retired key. + */ + removeSharedKey = (keyIndex: number) => { + this.sharedKeys.delete(keyIndex); + if (this.activeSharedKeyIndex === keyIndex) { + this.activeSharedKeyIndex = undefined; + } + }; + + /** + * Debug snapshot. Fingerprints only: enough to confirm a sender and receiver + * hold matching key material, and it exposes no key. + */ + dump = () => ({ + perUserKeys: Array.from(this.perUserKeys).flatMap(([userId, perKeyIndex]) => + Array.from(perKeyIndex, ([keyIndex, km]) => ({ + userId, + keyIndex, + fingerprint: toHex(km.fingerprint), + })), + ), + sharedKeys: Array.from(this.sharedKeys, ([keyIndex, material]) => ({ + keyIndex, + fingerprint: toHex(material.fingerprint), + isActive: keyIndex === this.activeSharedKeyIndex, + })), + }); + + /** + * @internal Test-only. Production teardown is `Worker.terminate()`, which + * reclaims the whole worker. + */ + clear = () => { + this.perUserKeys.clear(); + this.latestKeyIndex.clear(); + this.sharedKeys.clear(); + this.activeSharedKeyIndex = undefined; + }; +} + +/** The worker has exactly one. */ +export const keyStore = new KeyStore(); diff --git a/packages/client/src/rtc/e2ee/e2ee-worker/notifications.ts b/packages/client/src/rtc/e2ee/e2ee-worker/notifications.ts new file mode 100644 index 0000000000..f4e783a92e --- /dev/null +++ b/packages/client/src/rtc/e2ee/e2ee-worker/notifications.ts @@ -0,0 +1,169 @@ +/** At most one notification per second per key. */ +const THROTTLE_INTERVAL_MS = 1000; + +/** + * Rate limiter keyed by an arbitrary string, so one throttle can cover several + * independent conditions (a keyIndex, a userId) without them muting each other. + * + * Lives here because throttling is a delivery rule, not a general utility - + * every user of it is in this file. + */ +class Throttle { + private readonly intervalMs: number; + private lastFiredAt: Map = new Map(); + + constructor(intervalMs: number) { + this.intervalMs = intervalMs; + } + + /** + * True at most once per `intervalMs` for that key, so a sustained failure + * cannot flood the host with notifications. + */ + tryFire = (key: string): boolean => { + const now = Date.now(); + if (now - (this.lastFiredAt.get(key) ?? 0) > this.intervalMs) { + this.lastFiredAt.set(key, now); + return true; + } + return false; + }; +} + +/** + * Internal log-only channel: no `E2EEEventMap` entry, so the manager logs it + * rather than emitting it to the host. + */ +export const reportError = (message: string): void => { + self.postMessage({ type: 'e2ee.error', message }); +}; + +/** + * The encoder holds no key, so every outgoing frame is dropped. Without this + * the host just sees black video with nothing to act on. Throttled per user, + * and stops on its own once a key arrives. + * + * Module-scoped rather than per transform: this condition is per user by + * definition, so every track sharing one throttle is the point. + */ +const missingKeyThrottle = new Throttle(THROTTLE_INTERVAL_MS); +export const notifyMissingEncodeKey = (userId: string): void => { + if (missingKeyThrottle.tryFire(userId)) { + self.postMessage({ type: 'e2ee.missing_key', userId }); + } +}; + +/** Encode-side signals for one track. */ +export class EncodeNotifier { + private readonly userId: string; + private readonly trackType: string | undefined; + /** True once a failure was reported, until {@link recovered} re-arms it. */ + private latched = false; + + constructor(userId: string, trackType: string | undefined) { + this.userId = userId; + this.trackType = trackType; + } + + /** First failure of a run; silent until {@link recovered} re-arms it. */ + failed = (reason: string): void => { + if (this.latched) return; + this.latched = true; + self.postMessage({ + type: 'e2ee.encryption_failed', + userId: this.userId, + trackType: this.trackType, + reason, + }); + }; + + /** + * A frame encrypted again, so the next failure is worth reporting. Re-arming + * is deliberate: it stops one early transient error from hiding a later + * permanent one, such as the frame-counter hard limit. + */ + recovered = (): void => { + this.latched = false; + }; +} + +/** + * Decode-side signals for one track. + * + * One notifier is one track, so `userId` is constant: each throttle holds a + * single entry and limits that track alone. + */ +export class DecodeNotifier { + private readonly userId: string; + private readonly trackType: string | undefined; + private readonly failureThrottle = new Throttle(THROTTLE_INTERVAL_MS); + /** + * A key in flight, or a rotation whose keyIndex has not arrived, are both + * normal. Keyed by keyIndex: one signal per key epoch. + */ + private readonly missingKeyThrottle = new Throttle(THROTTLE_INTERVAL_MS); + private readonly cleartextThrottle = new Throttle(THROTTLE_INTERVAL_MS); + /** + * True once a `decryption_failed` reached the host. Pairs the two signals: + * only a delivered failure needs clearing, and clearing it re-arms this. + */ + private failureReported = false; + + constructor(userId: string, trackType: string | undefined) { + this.userId = userId; + this.trackType = trackType; + } + + /** GCM tag failure. Throttled; records that a failure reached the host. */ + failed = (): void => { + if (!this.failureThrottle.tryFire(this.userId)) return; + this.failureReported = true; + self.postMessage({ + type: 'e2ee.decryption_failed', + userId: this.userId, + trackType: this.trackType, + }); + }; + + /** Paired with {@link failed}: no-op unless a failure was delivered. */ + resumed = (): void => { + if (!this.failureReported) return; + this.failureReported = false; + self.postMessage({ + type: 'e2ee.decryption_resumed', + userId: this.userId, + trackType: this.trackType, + }); + }; + + /** A frame named a key this peer does not hold. Throttled per keyIndex. */ + missingKey = (keyIndex: number): void => { + if (!this.missingKeyThrottle.tryFire(String(keyIndex))) return; + self.postMessage({ + type: 'e2ee.missing_key', + userId: this.userId, + keyIndex, + trackType: this.trackType, + }); + }; + + /** A frame carried no E2EE framing and was forwarded as-is. Throttled. */ + unencrypted = (): void => { + if (!this.cleartextThrottle.tryFire(this.userId)) return; + self.postMessage({ + type: 'e2ee.unencrypted_frame', + userId: this.userId, + trackType: this.trackType, + }); + }; + + /** Consecutive failures crossed the tolerance. Already once-per-run. */ + broken = (keyIndex: number): void => { + self.postMessage({ + type: 'e2ee.broken', + userId: this.userId, + keyIndex, + trackType: this.trackType, + }); + }; +} diff --git a/packages/client/src/rtc/e2ee/e2ee-worker/perf.ts b/packages/client/src/rtc/e2ee/e2ee-worker/perf.ts new file mode 100644 index 0000000000..73c65559af --- /dev/null +++ b/packages/client/src/rtc/e2ee/e2ee-worker/perf.ts @@ -0,0 +1,129 @@ +/** Labels a perf sample carries. `codec` is known on the encode side only. */ +type StatLabels = { userId: string; trackType: string; codec?: string }; + +/** Accumulator for one track, reset by each {@link StatsRegistry.flush}. */ +type StatEntry = StatLabels & { count: number; maxCryptoMs: number }; + +/** One track's rates, as reported to the host. */ +type StatSample = StatLabels & { fps: number; maxCryptoMs: number }; + +/** + * Whether reporting is on. Module-scoped rather than per registry: the worker + * has exactly one of each, and a single flag keeps encode and decode from + * drifting apart. + */ +let perfEnabled = false; +let perfInterval: ReturnType | null = null; +let perfLastTick = 0; + +/** + * Counter handle for one track, held by that track's transform for its + * lifetime. Every method no-ops while reporting is off. + */ +class TrackStats { + private stats: Map; + private readonly key: string; + private readonly labels: StatLabels; + + constructor(stats: Map, key: string, labels: StatLabels) { + this.stats = stats; + this.key = key; + this.labels = labels; + } + + /** + * This track's accumulator, created on first use. Lazy on purpose: the entry + * is what puts a row in the next report, so a track that goes idle after a + * flush drops out instead of reporting 0 fps forever. + */ + private entry = (): StatEntry | undefined => { + if (!perfEnabled) return undefined; + let stat = this.stats.get(this.key); + if (!stat) { + stat = { ...this.labels, count: 0, maxCryptoMs: 0 }; + this.stats.set(this.key, stat); + } + return stat; + }; + + /** Count one frame through the transform. */ + bump = (): void => { + const stat = this.entry(); + if (stat) stat.count++; + }; + + /** Timestamp to hand back to {@link endCrypto}; 0 while disabled. */ + startCrypto = (): number => (perfEnabled ? performance.now() : 0); + + endCrypto = (startedAt: number): void => { + const stat = this.entry(); + if (!stat) return; + stat.maxCryptoMs = Math.max( + stat.maxCryptoMs, + performance.now() - startedAt, + ); + }; +} + +/** + * Per-track counters for one direction. The key is unique per track, so a vp8 + * camera and a vp8 screen share (encode), or a peer's audio and video (decode), + * are reported apart instead of summed. + */ +class StatsRegistry { + private stats: Map = new Map(); + + track = (key: string, labels: StatLabels): TrackStats => + new TrackStats(this.stats, key, labels); + + /** Drain all accumulators into per-track rates. */ + flush = (dtSec: number): StatSample[] => { + const samples = Array.from( + this.stats.values(), + ({ count, maxCryptoMs, ...labels }) => ({ + ...labels, + fps: count / dtSec, + maxCryptoMs, + }), + ); + this.stats.clear(); + return samples; + }; + + clear = (): void => this.stats.clear(); + + removeUser = (userId: string): void => { + for (const [key, stat] of this.stats) { + if (stat.userId === userId) this.stats.delete(key); + } + }; +} + +export const encodeStats = new StatsRegistry(); +export const decodeStats = new StatsRegistry(); + +export const startPerfReport = () => { + if (perfInterval) return; // a second interval would leak + perfEnabled = true; + perfLastTick = performance.now(); + perfInterval = setInterval(() => { + const now = performance.now(); + const dtSec = Math.max(0.001, (now - perfLastTick) / 1000); + perfLastTick = now; + self.postMessage({ + type: 'e2ee.perf_report', + encode: encodeStats.flush(dtSec), + decode: decodeStats.flush(dtSec), + }); + }, 1000); +}; + +export const stopPerfReport = () => { + perfEnabled = false; + if (perfInterval) { + clearInterval(perfInterval); + perfInterval = null; + } + encodeStats.clear(); + decodeStats.clear(); +}; diff --git a/packages/client/src/rtc/e2ee/e2ee-worker/queue.ts b/packages/client/src/rtc/e2ee/e2ee-worker/queue.ts new file mode 100644 index 0000000000..7d8d6f0bd2 --- /dev/null +++ b/packages/client/src/rtc/e2ee/e2ee-worker/queue.ts @@ -0,0 +1,12 @@ +let tail: Promise = Promise.resolve(); + +/** + * Run tasks FIFO, one at a time, so a `setKey` cannot race transform setup. + * `tail` swallows errors so one rejection cannot stall the queue; the returned + * promise still carries that task's own outcome. + */ +export const enqueue = (fn: () => Promise): Promise => { + const run = tail.then(fn); + tail = run.catch(() => {}); + return run; +}; diff --git a/packages/client/src/rtc/e2ee/e2ee-worker/replayWindow.ts b/packages/client/src/rtc/e2ee/e2ee-worker/replayWindow.ts new file mode 100644 index 0000000000..78813b3475 --- /dev/null +++ b/packages/client/src/rtc/e2ee/e2ee-worker/replayWindow.ts @@ -0,0 +1,131 @@ +import { REPLAY_WINDOW } from './constants'; + +/** Length-safe byte comparison, for matching a frame's prefix to an epoch. */ +const bytesEqual = (a: Uint8Array, b: Uint8Array): boolean => { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +}; + +const REPLAY_WINDOW_WORDS = REPLAY_WINDOW >>> 5; + +/** + * One sender IV-prefix epoch: a high-water mark plus a bitmap over the + * preceding {@link REPLAY_WINDOW} counters, where bit `counter % REPLAY_WINDOW` + * marks a seen counter. O(1) checks and in-order advance, where a `Set` would + * need an O(REPLAY_WINDOW) prune per frame. + */ +class ReplayEpoch { + /** Copied, since the caller's view points into a frame buffer that is reused. */ + readonly prefix: Uint8Array; + private highest: number; + private bitmap: Uint32Array = new Uint32Array(REPLAY_WINDOW_WORDS); + + constructor(prefix: Uint8Array, counter: number) { + this.prefix = prefix.slice(); + this.highest = counter; + this.mark(counter); + } + + /** Above the high-water mark, or inside the window and not yet seen. */ + accepts = (counter: number): boolean => { + if (counter > this.highest) return true; + if (counter <= this.highest - REPLAY_WINDOW) return false; + return !this.isMarked(counter); + }; + + record = (counter: number): void => { + if (counter > this.highest) { + // Slots repeat every REPLAY_WINDOW counters, so skipped ones can hold a + // stale bit and must be cleared. In-order frames skip none; a large jump + // makes the whole bitmap stale. + if (counter - this.highest >= REPLAY_WINDOW) { + this.bitmap.fill(0); + } else { + for (let c = this.highest + 1; c < counter; c++) this.clear(c); + } + this.highest = counter; + } + this.mark(counter); + }; + + private slot = (counter: number) => { + const idx = counter % REPLAY_WINDOW; + return { word: idx >>> 5, mask: 1 << (idx & 31) }; + }; + + private isMarked = (counter: number): boolean => { + const { word, mask } = this.slot(counter); + return (this.bitmap[word] & mask) !== 0; + }; + + private mark = (counter: number): void => { + const { word, mask } = this.slot(counter); + this.bitmap[word] |= mask; + }; + + private clear = (counter: number): void => { + const { word, mask } = this.slot(counter); + this.bitmap[word] &= ~mask; + }; +} + +/** + * Sender IV-prefix "epochs" one track's guard keeps. One is normal; a second or + * third appears briefly around a key re-import or sender restart, while old and + * new prefixes interleave in the jitter buffer. + * + * Eviction is safe because the sender never reuses an (ivPrefix, counter) pair. + * Only `commit` creates and evicts epochs, and only authenticated frames reach + * it, so a relay cannot forge new-prefix frames to evict a genuine epoch. + */ +const REPLAY_EPOCHS = 3; + +/** + * Replay guard for one remote track. + * + * Shared across tracks it would couple them: independent SSRCs and jitter + * buffers mean delivery skew could advance the high-water mark far enough to + * reject a lagging track's frames, dropping media and reporting false failures. + * + * Inside a track the sender's IV prefix partitions the window further, so a + * sender restart (fresh prefix, counter near 0) opens a clean window instead of + * losing its low counters to a stale mark. + * + * Only receive-side bookkeeping is per track. The sender's counter stays global + * per user (see `frameCounter.ts`), which is what keeps IVs unique across a + * user's tracks and the wire format identical for other SDKs. + */ +export class ReplayWindow { + private epochs: ReplayEpoch[] = []; + + private find = (ivPrefix: Uint8Array): ReplayEpoch | undefined => + this.epochs.find((e) => bytesEqual(e.prefix, ivPrefix)); + + /** + * True when this prefix can accept `counter`: new prefix, above the + * high-water mark, or inside the window and not yet committed. + * + * Changes no state. A relay can forge the trailer fields this reads, so only + * an authenticated frame advances the window. See {@link commit}. + */ + peek = (counter: number, ivPrefix: Uint8Array): boolean => { + // A prefix with no committed frame yet opens a clean window. + return this.find(ivPrefix)?.accepts(counter) ?? true; + }; + + /** + * Record `counter` as seen, advancing the high-water mark. Call it only after + * AES-GCM authenticates, so unauthenticated bytes cannot wedge the window or + * evict a genuine epoch. + */ + commit = (counter: number, ivPrefix: Uint8Array): void => { + const epoch = this.find(ivPrefix); + if (epoch) { + epoch.record(counter); + return; + } + this.epochs.unshift(new ReplayEpoch(ivPrefix, counter)); + if (this.epochs.length > REPLAY_EPOCHS) this.epochs.pop(); + }; +} diff --git a/packages/client/src/rtc/e2ee/e2ee-worker/trailer.ts b/packages/client/src/rtc/e2ee/e2ee-worker/trailer.ts new file mode 100644 index 0000000000..60a5b32849 --- /dev/null +++ b/packages/client/src/rtc/e2ee/e2ee-worker/trailer.ts @@ -0,0 +1,120 @@ +/** + * The on-wire framing: the 20-byte trailer appended to every encrypted frame, + * and the 12-byte IV both directions derive. + * + * Write and read live together on purpose. They are one contract - the encoder + * lays out bytes the decoder must find at the same offsets - so the layout is + * defined once, here, and a change to it is a wire break: bump + * {@link E2EE_VERSION} and regenerate the SPEC section 11 vectors. + * + * Layout, from the trailer's first byte: + * [4B frameCounter][8B ivPrefix][1B keyIndex][2B clearBytes|flags] + * [1B version][4B magic] + */ + +import { + E2EE_VERSION, + FRAME_COUNTER_LEN, + IV_PREFIX_LEN, + MAGIC, + MAX_CLEAR_BYTES, + RBSP_FLAG, + TRAILER_LEN, +} from './constants'; +import type { Trailer } from './types'; + +const OFF_IV_PREFIX = FRAME_COUNTER_LEN; // 4 +const OFF_KEY_INDEX = OFF_IV_PREFIX + IV_PREFIX_LEN; // 12 +const OFF_CLEAR_BYTES = OFF_KEY_INDEX + 1; // 13 +const OFF_VERSION = OFF_CLEAR_BYTES + 2; // 15 +const OFF_MAGIC = OFF_VERSION + 1; // 16 + +/** + * Fill a pre-allocated 12-byte IV: `[8B ivPrefix][4B frameCounter, BE]`. + * + * The encoder builds it from its own prefix and counter; the decoder rebuilds + * the identical bytes from the trailer. The buffer is reused across frames + * rather than allocated per frame - this runs once per frame on every track. + */ +export const fillIV = ( + iv: Uint8Array, + ivView: DataView, + prefix: Uint8Array, + frameCounter: number, +) => { + iv.set(prefix, 0); + ivView.setUint32(IV_PREFIX_LEN, frameCounter); +}; + +export const writeTrailer = ( + dst: Uint8Array, + offset: number, + frameCounter: number, + ivPrefix: Uint8Array, + keyIndex: number, + clearBytes: number, + isRbsp: boolean, +) => { + if (clearBytes > MAX_CLEAR_BYTES) { + throw new Error( + `clearBytes ${clearBytes} exceeds 15-bit max ${MAX_CLEAR_BYTES}`, + ); + } + if (ivPrefix.length !== IV_PREFIX_LEN) { + throw new Error( + `ivPrefix must be ${IV_PREFIX_LEN} bytes, got ${ivPrefix.length}`, + ); + } + const view = new DataView(dst.buffer, dst.byteOffset, dst.byteLength); + view.setUint32(offset, frameCounter); + dst.set(ivPrefix, offset + OFF_IV_PREFIX); + dst[offset + OFF_KEY_INDEX] = keyIndex; + view.setUint16( + offset + OFF_CLEAR_BYTES, + isRbsp ? clearBytes | RBSP_FLAG : clearBytes, + ); + dst[offset + OFF_VERSION] = E2EE_VERSION; + view.setUint32(offset + OFF_MAGIC, MAGIC); +}; + +/** + * IV fields only, from an already-recognized trailer. An H264 RBSP frame + * escapes these three with the ciphertext, so un-escape the unit before + * calling this. + */ +export const readTrailerIv = ( + buf: Uint8Array, +): Pick => { + const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + const start = buf.length - TRAILER_LEN; + return { + frameCounter: view.getUint32(start), + ivPrefix: buf.subarray(start + OFF_IV_PREFIX, start + OFF_KEY_INDEX), + keyIndex: buf[start + OFF_KEY_INDEX], + }; +}; + +export const readTrailer = (src: Uint8Array): Trailer | null => { + if (src.length < TRAILER_LEN) return null; + const view = new DataView(src.buffer, src.byteOffset, src.byteLength); + const start = src.length - TRAILER_LEN; + if (view.getUint32(start + OFF_MAGIC) !== MAGIC) return null; + const version = src[start + OFF_VERSION]; + // Unknown version means not our trailer, so an unrelated frame that happens + // to end in MAGIC does not reach a decrypt. + if (version !== E2EE_VERSION) return null; + const raw = view.getUint16(start + OFF_CLEAR_BYTES); + const clearBytes = raw & MAX_CLEAR_BYTES; + // Bail out before allocating; the decrypt would fail anyway. + if (clearBytes > src.length - TRAILER_LEN) return null; + // The last 7 bytes survive escaping untouched: the RBSP flag holds the + // clearBytes high byte >= 0x80 and breaks any zero run. The three below are + // valid only on a non-RBSP frame; see {@link readTrailerIv}. + return { + frameCounter: view.getUint32(start), + ivPrefix: src.subarray(start + OFF_IV_PREFIX, start + OFF_KEY_INDEX), + keyIndex: src[start + OFF_KEY_INDEX], + clearBytes, + isRbsp: (raw & RBSP_FLAG) !== 0, + }; +}; diff --git a/packages/client/src/rtc/e2ee/e2ee-worker/tsconfig.json b/packages/client/src/rtc/e2ee/e2ee-worker/tsconfig.json new file mode 100644 index 0000000000..e0576ff151 --- /dev/null +++ b/packages/client/src/rtc/e2ee/e2ee-worker/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@stream-io/typescript-config/library-web.json", + "compilerOptions": { + "outDir": "./dist", + "lib": ["ES2022", "WebWorker"] + }, + "include": ["./**/*.ts"] +} diff --git a/packages/client/src/rtc/e2ee/e2ee-worker/types.ts b/packages/client/src/rtc/e2ee/e2ee-worker/types.ts new file mode 100644 index 0000000000..734c1ff5ef --- /dev/null +++ b/packages/client/src/rtc/e2ee/e2ee-worker/types.ts @@ -0,0 +1,29 @@ +/** Minimal shape of an RTCEncodedVideo/AudioFrame. */ +export interface EncodedFrame { + data: ArrayBuffer; + /** Absent on audio: the lack of a key/delta type is how audio is recognized. */ + type?: 'key' | 'delta' | 'empty'; + timestamp: number; +} + +/** The subset of TransformStreamDefaultController the transforms use. */ +export type FrameController = { + enqueue(frame: EncodedFrame): void; + terminate(): void; +}; + +/** A resolved encryption key paired with its rotation index. */ +export interface ResolvedKey { + key: CryptoKey; + keyIndex: number; +} + +/** Parsed 20-byte frame trailer appended to every encrypted frame (v1). */ +export interface Trailer { + frameCounter: number; + /** View of the 8-byte IV prefix inside the source frame buffer. */ + ivPrefix: Uint8Array; + keyIndex: number; + clearBytes: number; + isRbsp: boolean; +} diff --git a/packages/client/src/rtc/e2ee/events.ts b/packages/client/src/rtc/e2ee/events.ts new file mode 100644 index 0000000000..839082558e --- /dev/null +++ b/packages/client/src/rtc/e2ee/events.ts @@ -0,0 +1,151 @@ +/** + * Throughput sample for one track. `userId` is the local sender on encode, the + * remote sender on decode. The (userId, trackType) pair keeps a peer's audio + * and video reported apart instead of summed. + */ +export type TrackPerf = { + userId: string; + trackType: string; + fps: number; + maxCryptoMs: number; +}; + +/** + * Encode entries carry the `codec` the publisher knows; decode entries cannot, + * since a remote sender's codec is not reliably known locally. + */ +export type PerfReport = { + encode: (TrackPerf & { codec: string })[]; + decode: TrackPerf[]; +}; + +/** + * Fired when a needed key is not held. The `keyIndex` tells the two cases apart: + * + * - Without it, the local encoder has no key and drops every outgoing frame. + * The host never provided a key, or a key import failed. + * - With it, a remote sender's frame named a key this peer does not hold, so + * the frame was dropped. This is normal while a key is in flight, or while a + * rotation propagates. + */ +export type MissingKeyEvent = { + /** The local user, or the remote sender. */ + userId: string; + /** Decode direction only; absent when the local encoder has no key. */ + keyIndex?: number; + /** + * Decode direction only, which reports per track: a peer's tracks can sit on + * different key epochs, so one may stall while another plays. The encode + * direction stops every outgoing track at once and is reported per user. + */ + trackType?: string; +}; + +/** + * A remote frame arrived unencrypted and went to the decoder as-is. Expected + * when the call's mode is `available` and that peer publishes plain; where every + * peer must encrypt, it means media renders without authentication. + */ +export type UnencryptedFrameEvent = { + userId: string; + trackType?: string; +}; + +/** The worker could not decrypt a remote frame. Throttled per track. */ +export type DecryptionFailedEvent = { + userId: string; + trackType?: string; +}; + +/** + * Fired when a track decrypts again after a reported failure. See + * {@link E2EEEventMap} for how it pairs with `e2ee.decryption_failed`. + */ +export type DecryptionResumedEvent = { + userId: string; + trackType?: string; +}; + +/** + * An outgoing frame could not be encrypted, so that track publishes nothing. + * Latched per track, so a permanently failing track reports once. + */ +export type EncryptionFailedEvent = { + userId: string; + /** Only this track is affected; the sender's others keep publishing. */ + trackType?: string; + /** Short, human-readable reason. */ + reason: string; +}; + +/** + * Fired when a remote track passes the internal failure tolerance: decryption + * has failed on that many consecutive frames. + */ +export type E2EEBrokenEvent = { + userId: string; + /** The keyIndex that crossed the tolerance. */ + keyIndex: number; + trackType?: string; +}; + +/** + * Answer to {@link EncryptionManager.requestKeyDump}. `fingerprint` is hex of + * the first 8 bytes of SHA-256(rawKey): not reversible, so safe to log. Key + * material is never returned. + */ +export type KeyStateReport = { + perUserKeys: Array<{ + userId: string; + keyIndex: number; + fingerprint: string; + }>; + /** Every shared receive epoch, including the one currently used to encode. */ + sharedKeys: Array<{ + keyIndex: number; + fingerprint: string; + isActive: boolean; + }>; +}; + +/** + * Events that the E2EE {@link EncryptionManager} emits. + * + * Subscribe with `manager.on(eventName, handler)`. To unsubscribe, call the + * function it returns, or `manager.off(eventName, handler)`. + * + * Every name follows the `e2ee.` convention. That makes them easy + * to grep, and keeps them distinct from SFU and coordinator events. + */ +export type E2EEEventMap = { + /** Key mismatch, rotation in progress, or a tampered frame. */ + 'e2ee.decryption_failed': DecryptionFailedEvent; + + /** + * Pairs one-to-one with `e2ee.decryption_failed` and is never throttled, so a + * host can drive its UI from the pair alone. Also fires for a track that + * recovers on a new keyIndex. + */ + 'e2ee.decryption_resumed': DecryptionResumedEvent; + + /** That track is publishing nothing. Latched, so it reports once. */ + 'e2ee.encryption_failed': EncryptionFailedEvent; + + /** + * The host must set or distribute a key. Distinct from + * `e2ee.encryption_failed`, where a key was present but the crypto threw. + * Throttled, and stops once the key arrives. + */ + 'e2ee.missing_key': MissingKeyEvent; + + 'e2ee.unencrypted_frame': UnencryptedFrameEvent; + + /** Once per second while {@link EncryptionManager.enablePerformanceReporting} is on. */ + 'e2ee.perf_report': PerfReport; + + /** Fires once per (userId, keyIndex) entering the failed state. */ + 'e2ee.broken': E2EEBrokenEvent; + + /** Answer to {@link EncryptionManager.requestKeyDump}. */ + 'e2ee.key_state': KeyStateReport; +}; diff --git a/packages/client/src/rtc/e2ee/transformSupport.ts b/packages/client/src/rtc/e2ee/transformSupport.ts new file mode 100644 index 0000000000..10280299ae --- /dev/null +++ b/packages/client/src/rtc/e2ee/transformSupport.ts @@ -0,0 +1,35 @@ +import { isChrome } from '../../helpers/browsers'; + +/** + * Detection and selection policy for the two WebRTC Encoded Transform APIs. + * Internal: which one the SDK attaches is an RTC-layer detail. Consumers want + * `EncryptionManager.isSupported` instead. + */ + +/** + * Chrome only, and the reason an RTCPeerConnection carrying E2EE needs the + * non-standard `encodedInsertableStreams` flag. + */ +export const hasInsertableStreams = (): boolean => + typeof RTCRtpSender !== 'undefined' && + 'createEncodedStreams' in RTCRtpSender.prototype; + +/** Whether the standard `RTCRtpScriptTransform` API exists. */ +export const hasScriptTransform = (): boolean => + typeof RTCRtpScriptTransform !== 'undefined'; + +/** + * Which Encoded Transform API to attach E2EE with here. + * + * - `'insertable'`: legacy Insertable Streams. Used on Chrome, where + * `RTCRtpScriptTransform` is still unreliable for E2EE. + * - `'script'`: the standard API. Used everywhere else. + * - `undefined`: neither exists, so E2EE cannot run. + */ +export const preferredTransform = (): 'script' | 'insertable' | undefined => { + const insertable = hasInsertableStreams(); + // Chrome's RTCRtpScriptTransform is still unreliable for E2EE. + if (isChrome() && insertable) return 'insertable'; + if (hasScriptTransform()) return 'script'; + return insertable ? 'insertable' : undefined; +}; diff --git a/packages/client/src/rtc/helpers/rtcConfiguration.ts b/packages/client/src/rtc/helpers/rtcConfiguration.ts index edf2713499..ef421cdf95 100644 --- a/packages/client/src/rtc/helpers/rtcConfiguration.ts +++ b/packages/client/src/rtc/helpers/rtcConfiguration.ts @@ -1,6 +1,8 @@ -import { ICEServer } from '../../gen/coordinator'; +import { ICEServerResponse } from '../../gen/coordinator'; -export const toRtcConfiguration = (config: ICEServer[]): RTCConfiguration => { +export const toRtcConfiguration = ( + config: ICEServerResponse[], +): RTCConfiguration => { return { bundlePolicy: 'max-bundle', iceServers: config.map((ice) => ({ diff --git a/packages/client/src/rtc/types.ts b/packages/client/src/rtc/types.ts index 2193bb852b..9fd217ed94 100644 --- a/packages/client/src/rtc/types.ts +++ b/packages/client/src/rtc/types.ts @@ -10,6 +10,7 @@ import { CallState } from '../store'; import { Dispatcher } from './Dispatcher'; import type { OptimalVideoLayer } from './layers'; import type { ClientPublishOptions } from '../types'; +import type { E2EEManager } from './e2ee/E2EEManager'; import type { VideoSender } from '../gen/video/sfu/event/events'; /** @@ -102,6 +103,7 @@ export type BasePeerConnectionOpts = { iceRestartDelay?: number; clientPublishOptions?: ClientPublishOptions; statsTimestampDriftThresholdMs?: number; + e2ee?: E2EEManager; }; export type TrackPublishOptions = { diff --git a/packages/client/src/store/CallState.ts b/packages/client/src/store/CallState.ts index ff5db78fb9..c4599955d2 100644 --- a/packages/client/src/store/CallState.ts +++ b/packages/client/src/store/CallState.ts @@ -74,6 +74,7 @@ type OrphanedTrack = { trackLookupPrefix: string; trackType: TrackType; track: MediaStream; + receiver?: RTCRtpReceiver; }; /** @@ -108,6 +109,7 @@ export class CallState { >(undefined); private transcribingSubject = new BehaviorSubject(false); private captioningSubject = new BehaviorSubject(false); + private e2eeEnabledSubject = new BehaviorSubject(false); private endedBySubject = new BehaviorSubject( undefined, ); @@ -311,6 +313,12 @@ export class CallState { */ captioning$: Observable; + /** + * Whether end-to-end encryption is active for this call, as reported by the + * SFU in the join response. + */ + e2eeEnabled$: Observable; + /** * Will provide the user who ended this call. */ @@ -467,6 +475,7 @@ export class CallState { this.rawRecording$ = duc(this.rawRecordingSubject); this.transcribing$ = duc(this.transcribingSubject); this.captioning$ = duc(this.captioningSubject); + this.e2eeEnabled$ = duc(this.e2eeEnabledSubject); this.eventHandlers = { // these events are not updating the call state: @@ -574,6 +583,7 @@ export class CallState { clearTimeout(taskId); this.closedCaptionsTasks.delete(ccKey); } + this.removeAllOrphanedTracks(); }; /** @@ -973,6 +983,13 @@ export class CallState { return this.getCurrentValue(this.transcribing$); } + /** + * Whether end-to-end encryption is active for this call. + */ + get e2eeEnabled() { + return this.getCurrentValue(this.e2eeEnabled$); + } + /** * Will provide the user who ended this call. */ @@ -1249,6 +1266,19 @@ export class CallState { this.orphanedTracks = this.orphanedTracks.filter((o) => o.id !== id); }; + /** + * Drops every orphaned track. Call this when the peer connections that own + * the stored receivers go away (full leave, reconnect, or migration): + * `pc.close()` does not raise the track `ended` event, so the per-track + * cleanup never fires and the receivers + their closed PCs would otherwise + * leak for the call's lifetime. + * + * @internal + */ + removeAllOrphanedTracks = () => { + this.orphanedTracks = []; + }; + /** * Takes all orphaned tracks with the given track lookup prefix. * All orphaned tracks with the given track lookup prefix are removed from the call state. @@ -1336,7 +1366,8 @@ export class CallState { currentSessionId: string, reconnectDetails?: ReconnectDetails, ) => { - const { participants, participantCount, startedAt, pins } = callState; + const { participants, participantCount, startedAt, pins, e2EeEnabled } = + callState; const localPublishedTracks = reconnectDetails?.announcedTracks.map((t) => t.trackType) ?? []; this.setParticipants(() => { @@ -1365,6 +1396,7 @@ export class CallState { this.setAnonymousParticipantCount(participantCount?.anonymous || 0); this.setStartedAt(startedAt ? Timestamp.toDate(startedAt) : new Date()); this.setServerSidePins(pins); + this.setCurrentValue(this.e2eeEnabledSubject, e2EeEnabled); }; private updateFromMemberRemoved = (event: CallMemberRemovedEvent) => { diff --git a/packages/client/src/store/__tests__/CallState.test.ts b/packages/client/src/store/__tests__/CallState.test.ts index 595cfffa2b..e9bd582bdc 100644 --- a/packages/client/src/store/__tests__/CallState.test.ts +++ b/packages/client/src/store/__tests__/CallState.test.ts @@ -5,7 +5,10 @@ import { fromPartial } from '@total-typescript/shoehorn'; import { StreamVideoParticipant, VisibilityState } from '../../types'; import { CallingState } from '../CallingState'; import { CallState } from '../CallState'; -import { TrackType } from '../../gen/video/sfu/models/models'; +import { + type CallState as SfuCallState, + TrackType, +} from '../../gen/video/sfu/models/models'; import { combineComparators, conditional, @@ -387,6 +390,48 @@ describe('CallState', () => { }); }); + describe('e2ee', () => { + const sfuCallState = (e2EeEnabled: boolean) => + fromPartial({ + participants: [], + pins: [], + e2EeEnabled, + }); + + it('is disabled until the SFU reports otherwise', () => { + const state = new CallState(); + expect(state.e2eeEnabled).toBe(false); + + state.updateFromSfuCallState(sfuCallState(false), 'session-id'); + expect(state.e2eeEnabled).toBe(false); + }); + + it('is enabled when the SFU call state says so', () => { + const state = new CallState(); + state.updateFromSfuCallState(sfuCallState(true), 'session-id'); + expect(state.e2eeEnabled).toBe(true); + }); + + it('follows the SFU across a rejoin into a plain call', () => { + const state = new CallState(); + state.updateFromSfuCallState(sfuCallState(true), 'session-id'); + state.updateFromSfuCallState(sfuCallState(false), 'session-id'); + expect(state.e2eeEnabled).toBe(false); + }); + + it(`doesn't emit when the value didn't change`, () => { + const state = new CallState(); + const listener = vi.fn(); + state.e2eeEnabled$.subscribe(listener); + expect(listener).toHaveBeenCalledTimes(1); // initial value + + state.updateFromSfuCallState(sfuCallState(true), 'session-id'); + state.updateFromSfuCallState(sfuCallState(true), 'session-id'); + expect(listener).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenLastCalledWith(true); + }); + }); + describe('events', () => { describe('call.live and backstage events', () => { it('handles call.live_started events', () => { @@ -1291,6 +1336,42 @@ describe('CallState', () => { state.removeOrphanedTrack(id); expect(state['orphanedTracks'].length).toBe(0); }); + + it('removes all orphaned tracks at once', () => { + const state = new CallState(); + state.registerOrphanedTrack({ + id: 'a', + track: new MediaStream(), + trackLookupPrefix: '1', + trackType: TrackType.VIDEO, + }); + state.registerOrphanedTrack({ + id: 'b', + track: new MediaStream(), + trackLookupPrefix: '2', + trackType: TrackType.AUDIO, + }); + expect(state['orphanedTracks'].length).toBe(2); + state.removeAllOrphanedTracks(); + expect(state['orphanedTracks'].length).toBe(0); + }); + + it('purges orphaned tracks on dispose (no receiver/PC leak)', () => { + // Orphaned tracks can hold an RTCRtpReceiver tied to a now-closed peer + // connection; pc.close() does not raise the track `ended` event, so they + // are never purged otherwise and leak for the call's lifetime (finding + // 15). + const state = new CallState(); + state.registerOrphanedTrack({ + id: '123:TRACK_TYPE_VIDEO', + track: new MediaStream(), + trackLookupPrefix: '123', + trackType: TrackType.VIDEO, + }); + expect(state['orphanedTracks'].length).toBe(1); + state.dispose(); + expect(state['orphanedTracks'].length).toBe(0); + }); }); describe('closed captions', () => { diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 74e6877925..ee4e4f8e10 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -9,7 +9,7 @@ import type { JoinCallRequest, MemberResponse, OwnCapability, - ReactionResponse, + VideoReactionResponse, StartRecordingRequest, StartRecordingResponse, } from './gen/coordinator'; @@ -27,7 +27,7 @@ import { AxiosError } from 'axios'; import type { Call } from './Call'; export type StreamReaction = Pick< - ReactionResponse, + VideoReactionResponse, 'type' | 'emoji_code' | 'custom' >; @@ -514,5 +514,5 @@ declare global { /** * The options to pass to {@link Call.join} method. */ -export type JoinCallData = Omit; +export type JoinCallData = Omit; export { AxiosError }; diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index d41fd47c79..f36d3fd98d 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -5,6 +5,6 @@ "types": ["node"], "noEmitOnError": true }, - "exclude": ["**/__tests__/**"], - "include": ["./src", "index.ts", "version.ts"] + "exclude": ["**/__tests__/**", "src/rtc/e2ee/e2ee-worker/**"], + "include": ["./src", "index.ts"] } diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts index 75a20ec00a..0ae7023765 100644 --- a/packages/client/vite.config.ts +++ b/packages/client/vite.config.ts @@ -5,7 +5,9 @@ export default defineConfig({ testTimeout: 15000, coverage: { provider: 'v8', - include: ['src/**'], + // Only TypeScript sources: the v8 remapper tries to parse everything it is + // given, so a stray .md / .json under src/ makes it throw. + include: ['src/**/*.ts'], exclude: ['**/__tests__/**', 'src/gen/**'], reportsDirectory: './coverage', reporter: ['lcov'], diff --git a/packages/react-bindings/src/hooks/callStateHooks.ts b/packages/react-bindings/src/hooks/callStateHooks.ts index 8b54abfefe..3b9d6bda21 100644 --- a/packages/react-bindings/src/hooks/callStateHooks.ts +++ b/packages/react-bindings/src/hooks/callStateHooks.ts @@ -189,6 +189,14 @@ export const useIsCallTranscribingInProgress = (): boolean => { return useObservableValue(transcribing$); }; +/** + * Returns whether end-to-end encryption is active for the current call. + */ +export const useE2eeEnabled = (): boolean => { + const { e2eeEnabled$ } = useCallState(); + return useObservableValue(e2eeEnabled$); +}; + /** * Returns information about the user who has marked this call as ended. */ diff --git a/packages/react-bindings/src/wrappers/Restricted.tsx b/packages/react-bindings/src/wrappers/Restricted.tsx index 5b60ad619a..17cf688cb4 100644 --- a/packages/react-bindings/src/wrappers/Restricted.tsx +++ b/packages/react-bindings/src/wrappers/Restricted.tsx @@ -1,4 +1,7 @@ -import { OwnCapability } from '@stream-io/video-client'; +import { + OwnCapability, + RequestPermissionRequestPermissionsEnum, +} from '@stream-io/video-client'; import { PropsWithChildren } from 'react'; import { useCall } from '../contexts'; @@ -42,7 +45,10 @@ export const Restricted = ({ if (hasPermissionsOnly) return hasPermissions ? <>{children} : null; const canRequest = requiredGrants.some((capability) => - call?.permissionsContext.canRequest(capability, settings), + call?.permissionsContext.canRequest( + capability as RequestPermissionRequestPermissionsEnum, + settings, + ), ); if (canRequestOnly) return canRequest ? <>{children} : null; diff --git a/packages/react-native-sdk/src/components/Call/CallControls/internal/ReactionsPicker.tsx b/packages/react-native-sdk/src/components/Call/CallControls/internal/ReactionsPicker.tsx index eef6806667..eecf7e282b 100644 --- a/packages/react-native-sdk/src/components/Call/CallControls/internal/ReactionsPicker.tsx +++ b/packages/react-native-sdk/src/components/Call/CallControls/internal/ReactionsPicker.tsx @@ -8,7 +8,7 @@ import { } from 'react-native'; import { useCall } from '@stream-io/video-react-bindings'; import { - type SendReactionRequest, + type SendVideoReactionRequest, videoLoggerSystem, } from '@stream-io/video-client'; import { ComponentTestIds } from '../../../../constants/TestIds'; @@ -82,7 +82,7 @@ export const ReactionsPicker = ({ left: reactionsButtonLayoutRectangle?.x, }; - const onClose = (reaction?: SendReactionRequest) => { + const onClose = (reaction?: SendVideoReactionRequest) => { if (reaction) { call?.sendReaction(reaction).catch((e) => { const logger = videoLoggerSystem.getLogger('ReactionsPicker'); diff --git a/packages/react-sdk/src/components/CallControls/ScreenShareButton.tsx b/packages/react-sdk/src/components/CallControls/ScreenShareButton.tsx index 2106bcbc2d..033bb939b7 100644 --- a/packages/react-sdk/src/components/CallControls/ScreenShareButton.tsx +++ b/packages/react-sdk/src/components/CallControls/ScreenShareButton.tsx @@ -1,4 +1,7 @@ -import { OwnCapability } from '@stream-io/video-client'; +import { + OwnCapability, + RequestPermissionRequestPermissionsEnum, +} from '@stream-io/video-client'; import { Restricted, useCallStateHooks, @@ -29,7 +32,7 @@ export const ScreenShareButton = (props: ScreenShareButtonProps) => { useCallStateHooks(); const isSomeoneScreenSharing = useHasOngoingScreenShare(); const { hasPermission, requestPermission, isAwaitingPermission } = - useRequestPermission(OwnCapability.SCREENSHARE); + useRequestPermission(RequestPermissionRequestPermissionsEnum.SCREENSHARE); const callSettings = useCallSettings(); const isScreenSharingAllowed = callSettings?.screensharing.enabled; diff --git a/packages/react-sdk/src/components/CallControls/ToggleAudioButton.tsx b/packages/react-sdk/src/components/CallControls/ToggleAudioButton.tsx index e98c5d9722..5f03c5e6de 100644 --- a/packages/react-sdk/src/components/CallControls/ToggleAudioButton.tsx +++ b/packages/react-sdk/src/components/CallControls/ToggleAudioButton.tsx @@ -1,4 +1,8 @@ -import { OwnCapability, SfuModels } from '@stream-io/video-client'; +import { + OwnCapability, + RequestPermissionRequestPermissionsEnum, + SfuModels, +} from '@stream-io/video-client'; import { Restricted, useCallStateHooks, @@ -139,7 +143,7 @@ export const ToggleAudioPublishingButton = ( } = props; const { hasPermission, requestPermission, isAwaitingPermission } = - useRequestPermission(OwnCapability.SEND_AUDIO); + useRequestPermission(RequestPermissionRequestPermissionsEnum.SEND_AUDIO); const { useMicrophoneState, useLocalParticipant } = useCallStateHooks(); const { diff --git a/packages/react-sdk/src/components/CallControls/ToggleVideoButton.tsx b/packages/react-sdk/src/components/CallControls/ToggleVideoButton.tsx index 3d3c046cc4..09c454e561 100644 --- a/packages/react-sdk/src/components/CallControls/ToggleVideoButton.tsx +++ b/packages/react-sdk/src/components/CallControls/ToggleVideoButton.tsx @@ -5,7 +5,11 @@ import { UseInputMediaDeviceOptions, } from '@stream-io/video-react-bindings'; import clsx from 'clsx'; -import { OwnCapability, SfuModels } from '@stream-io/video-client'; +import { + OwnCapability, + RequestPermissionRequestPermissionsEnum, + SfuModels, +} from '@stream-io/video-client'; import { CompositeButton, IconButtonWithMenuProps } from '../Button/'; import { DeviceSelectorVideo } from '../DeviceSettings'; import { PermissionNotification } from '../Notification'; @@ -137,7 +141,7 @@ export const ToggleVideoPublishingButton = ( } = props; const { hasPermission, requestPermission, isAwaitingPermission } = - useRequestPermission(OwnCapability.SEND_VIDEO); + useRequestPermission(RequestPermissionRequestPermissionsEnum.SEND_VIDEO); const { useCameraState, useCallSettings, useLocalParticipant } = useCallStateHooks(); diff --git a/packages/react-sdk/src/core/components/ParticipantView/ParticipantActionsContextMenu.tsx b/packages/react-sdk/src/core/components/ParticipantView/ParticipantActionsContextMenu.tsx index 09c8152b26..486aa0d77c 100644 --- a/packages/react-sdk/src/core/components/ParticipantView/ParticipantActionsContextMenu.tsx +++ b/packages/react-sdk/src/core/components/ParticipantView/ParticipantActionsContextMenu.tsx @@ -6,6 +6,8 @@ import { hasScreenShareAudio, hasVideo, OwnCapability, + UpdateUserPermissionsRequestGrantPermissionsEnum, + UpdateUserPermissionsRequestRevokePermissionsEnum, } from '@stream-io/video-client'; import { useParticipantViewContext } from './ParticipantViewContext'; import { @@ -41,19 +43,21 @@ export const ParticipantActionsContextMenu = () => { const muteScreenShareAudio = () => call?.muteUser(userId, 'screenshare_audio'); - const grantPermission = (permission: string) => () => { - call?.updateUserPermissions({ - user_id: userId, - grant_permissions: [permission], - }); - }; + const grantPermission = + (permission: UpdateUserPermissionsRequestGrantPermissionsEnum) => () => { + call?.updateUserPermissions({ + user_id: userId, + grant_permissions: [permission], + }); + }; - const revokePermission = (permission: string) => () => { - call?.updateUserPermissions({ - user_id: userId, - revoke_permissions: [permission], - }); - }; + const revokePermission = + (permission: UpdateUserPermissionsRequestRevokePermissionsEnum) => () => { + call?.updateUserPermissions({ + user_id: userId, + revoke_permissions: [permission], + }); + }; const toggleParticipantPin = () => { if (pin) { diff --git a/packages/react-sdk/src/hooks/useRequestPermission.ts b/packages/react-sdk/src/hooks/useRequestPermission.ts index ca104131c1..1fcacb990f 100644 --- a/packages/react-sdk/src/hooks/useRequestPermission.ts +++ b/packages/react-sdk/src/hooks/useRequestPermission.ts @@ -1,8 +1,10 @@ import { useCallback, useEffect, useState } from 'react'; -import { OwnCapability } from '@stream-io/video-client'; +import { RequestPermissionRequestPermissionsEnum } from '@stream-io/video-client'; import { useCall, useCallStateHooks } from '@stream-io/video-react-bindings'; -export const useRequestPermission = (permission: OwnCapability) => { +export const useRequestPermission = ( + permission: RequestPermissionRequestPermissionsEnum, +) => { const call = useCall(); const { useHasPermissions } = useCallStateHooks(); const hasPermission = useHasPermissions(permission); diff --git a/sample-apps/react-native/dogfood/src/components/CallControls/MoreActionsButton/BottomControlsDrawer.tsx b/sample-apps/react-native/dogfood/src/components/CallControls/MoreActionsButton/BottomControlsDrawer.tsx index 29c1394b42..23e80a3248 100644 --- a/sample-apps/react-native/dogfood/src/components/CallControls/MoreActionsButton/BottomControlsDrawer.tsx +++ b/sample-apps/react-native/dogfood/src/components/CallControls/MoreActionsButton/BottomControlsDrawer.tsx @@ -1,5 +1,5 @@ import { - SendReactionRequest, + SendVideoReactionRequest, useCall, useTheme, } from '@stream-io/video-react-native-sdk'; @@ -116,7 +116,7 @@ export const BottomControlsDrawer: React.FC = ({ const elasticAnimRef = useRef(new Animated.Value(0.5)); - const onCloseReaction = (reaction?: SendReactionRequest) => { + const onCloseReaction = (reaction?: SendVideoReactionRequest) => { if (reaction) { call?.sendReaction(reaction).catch((e) => { console.log('Error on onClose-sendReaction: ', e); diff --git a/sample-apps/react-native/dogfood/src/components/ParticipantActions.tsx b/sample-apps/react-native/dogfood/src/components/ParticipantActions.tsx index 0eddf932ed..63a752a6bb 100644 --- a/sample-apps/react-native/dogfood/src/components/ParticipantActions.tsx +++ b/sample-apps/react-native/dogfood/src/components/ParticipantActions.tsx @@ -4,6 +4,8 @@ import { hasVideo, OwnCapability, StreamVideoParticipant, + UpdateUserPermissionsRequestGrantPermissionsEnum, + UpdateUserPermissionsRequestRevokePermissionsEnum, useCall, useCallStateHooks, useI18n, @@ -60,14 +62,18 @@ export const ParticipantActions = (props: ParticipantActionsType) => { return null; } - const grantPermission = async (permission: string) => { + const grantPermission = async ( + permission: UpdateUserPermissionsRequestGrantPermissionsEnum, + ) => { await call?.updateUserPermissions({ user_id: participant.userId, grant_permissions: [permission], }); }; - const revokePermission = async (permission: string) => { + const revokePermission = async ( + permission: UpdateUserPermissionsRequestRevokePermissionsEnum, + ) => { await call?.updateUserPermissions({ user_id: participant.userId, revoke_permissions: [permission], diff --git a/sample-apps/react/audio-rooms/src/components/Room/SpeakingRequestsList.tsx b/sample-apps/react/audio-rooms/src/components/Room/SpeakingRequestsList.tsx index 4039a2eb77..ad7950778e 100644 --- a/sample-apps/react/audio-rooms/src/components/Room/SpeakingRequestsList.tsx +++ b/sample-apps/react/audio-rooms/src/components/Room/SpeakingRequestsList.tsx @@ -1,6 +1,7 @@ import { useCallback } from 'react'; import { PermissionRequestEvent, + UpdateUserPermissionsRequestGrantPermissionsEnum, useCall, useCallStateHooks, } from '@stream-io/video-react-sdk'; @@ -56,7 +57,9 @@ const SpeakingRequest = ({ await call?.updateUserPermissions({ user_id: speakingRequest.user.id, - grant_permissions: [...speakingRequest.permissions], + grant_permissions: [ + ...speakingRequest.permissions, + ] as UpdateUserPermissionsRequestGrantPermissionsEnum[], }); await call?.update({ diff --git a/sample-apps/react/e2ee-demo/index.html b/sample-apps/react/e2ee-demo/index.html new file mode 100644 index 0000000000..1cce7062b5 --- /dev/null +++ b/sample-apps/react/e2ee-demo/index.html @@ -0,0 +1,12 @@ + + + + + + Stream E2EE Demo + + +

+ + + diff --git a/sample-apps/react/e2ee-demo/package.json b/sample-apps/react/e2ee-demo/package.json new file mode 100644 index 0000000000..ff7ae5c614 --- /dev/null +++ b/sample-apps/react/e2ee-demo/package.json @@ -0,0 +1,24 @@ +{ + "name": "@stream-io/e2ee-demo", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@stream-io/video-react-sdk": "workspace:^", + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@stream-io/typescript-config": "workspace:^", + "@types/react": "~19.2.18", + "@types/react-dom": "~19.2.4", + "@vitejs/plugin-react": "^6.0.5", + "typescript": "^6.0.3", + "vite": "^8.2.0" + } +} diff --git a/sample-apps/react/e2ee-demo/src/App.css b/sample-apps/react/e2ee-demo/src/App.css new file mode 100644 index 0000000000..238ad1614f --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/App.css @@ -0,0 +1,45 @@ +:root { + --color-bg: #0f1218; + --color-surface: #1a1f2b; + --color-surface-elevated: #242b3a; + --color-border: #2d3548; + --color-text: #e8eaed; + --color-text-secondary: #8b95a8; + --color-text-mono: #7dd3fc; + --radius-md: 8px; + --radius-sm: 4px; + --font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', Consolas, monospace; + --shadow-card: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body, +html { + margin: 0; + padding: 0; + height: 100%; + width: 100%; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, + sans-serif; + background: var(--color-bg); + color: var(--color-text); + -webkit-font-smoothing: antialiased; +} + +#root { + height: 100%; +} + +.app { + display: flex; + flex-direction: column; + height: 100dvh; + width: 100%; + overflow: hidden; +} diff --git a/sample-apps/react/e2ee-demo/src/App.tsx b/sample-apps/react/e2ee-demo/src/App.tsx new file mode 100644 index 0000000000..c1b9128acc --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/App.tsx @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react'; +import { HarnessProvider, useCreateHarness } from './hooks/useHarness'; +import { ControlBar } from './components/ControlBar'; +import { KeyOverridePanel } from './components/KeyOverridePanel'; +import { CallGrid } from './components/CallGrid'; +import { resolveCallId, resolveCallType, writeUrl } from './harness/url'; + +import '@stream-io/video-react-sdk/dist/css/styles.css'; +import './App.css'; + +const App = () => { + const callId = resolveCallId(window.location.search); + const callType = resolveCallType(window.location.search); + const engine = useCreateHarness(callId, callType); + const [showKeys, setShowKeys] = useState(false); + + // Reflect the call id in the URL so the harness is bookmarkable and shareable. + useEffect(() => { + writeUrl(callId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + +
+ setShowKeys((v) => !v)} + /> + setShowKeys(false)} /> + +
+
+ ); +}; + +export default App; diff --git a/sample-apps/react/e2ee-demo/src/components/CallGrid.css b/sample-apps/react/e2ee-demo/src/components/CallGrid.css new file mode 100644 index 0000000000..d134d7e6a6 --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/components/CallGrid.css @@ -0,0 +1,21 @@ +.call-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); + grid-auto-rows: 1fr; + gap: 12px; + padding: 12px 16px; + flex: 1; + min-height: 0; + overflow-y: auto; +} +.call-grid__empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + padding: 60px; + text-align: center; + color: #8b949e; + gap: 4px; +} diff --git a/sample-apps/react/e2ee-demo/src/components/CallGrid.tsx b/sample-apps/react/e2ee-demo/src/components/CallGrid.tsx new file mode 100644 index 0000000000..72a18810ea --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/components/CallGrid.tsx @@ -0,0 +1,33 @@ +import { useSnapshot } from '../hooks/useHarness'; +import { ParticipantPanel } from './ParticipantPanel'; +import type { EventLogEntry } from './EventLog'; +import './CallGrid.css'; + +export const CallGrid = () => { + const { participants, log } = useSnapshot(); + const nameByUserId = Object.fromEntries( + participants.map((p) => [p.userId, p.name]), + ); + + if (participants.length === 0) { + return ( +
+

No participants yet.

+

Click "+ Participant" to add someone to the call.

+
+ ); + } + + return ( +
+ {participants.map((p) => ( + e.userId === p.userId) as EventLogEntry[]} + /> + ))} +
+ ); +}; diff --git a/sample-apps/react/e2ee-demo/src/components/ChaosControls.css b/sample-apps/react/e2ee-demo/src/components/ChaosControls.css new file mode 100644 index 0000000000..03c32ae740 --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/components/ChaosControls.css @@ -0,0 +1,35 @@ +.chaos { + font-size: 12px; + padding: 6px 10px; +} +.chaos summary { + cursor: pointer; + color: #d92d20; + font-weight: 600; +} +.chaos__row { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + margin-top: 6px; +} +.chaos__label { + color: #6e7681; +} +.chaos__muted { + color: #6e7681; + font-style: italic; +} +.chaos button { + font-size: 11px; + padding: 3px 8px; + border: 1px solid #d92d20; + background: transparent; + color: #d92d20; + border-radius: 4px; + cursor: pointer; +} +.chaos button:hover { + background: #d92d2015; +} diff --git a/sample-apps/react/e2ee-demo/src/components/ChaosControls.tsx b/sample-apps/react/e2ee-demo/src/components/ChaosControls.tsx new file mode 100644 index 0000000000..0409e8f828 --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/components/ChaosControls.tsx @@ -0,0 +1,48 @@ +import { useHarnessEngine, useSnapshot } from '../hooks/useHarness'; +import type { HarnessParticipant } from '../harness/snapshot'; +import './ChaosControls.css'; + +export const ChaosControls = ({ + participant, +}: { + participant: HarnessParticipant; +}) => { + const engine = useHarnessEngine(); + const { participants } = useSnapshot(); + // Only peers with a manager attached can hold this participant's key, so only + // they are revocable: the keyless spy and any plain joiner never received one. + const others = participants.filter( + (p) => p.userId !== participant.userId && p.enabled, + ); + + return ( +
+ Failure injection +
+ + + +
+
+ Revoke my key from: + {others.length === 0 && ( + no key-holding peers + )} + {others.map((o) => ( + + ))} +
+
+ ); +}; diff --git a/sample-apps/react/e2ee-demo/src/components/ControlBar.css b/sample-apps/react/e2ee-demo/src/components/ControlBar.css new file mode 100644 index 0000000000..617cb9f11e --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/components/ControlBar.css @@ -0,0 +1,104 @@ +.control-bar { + padding: 12px 16px; + background: #161b22; + border-bottom: 1px solid #30363d; + display: flex; + flex-direction: column; + gap: 8px; +} +.control-bar__row { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} +.control-bar__title { + font-size: 16px; + margin: 0; + color: #f0f6fc; +} +.control-bar__call-id { + color: #8b949e; + font-size: 13px; +} +.control-bar__badge { + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; +} +.control-bar__badge.ok { + background: #00b36833; + color: #3fb950; +} +.control-bar__badge.no { + background: #d92d2033; + color: #f85149; +} +.control-bar label { + font-size: 12px; + color: #8b949e; + display: flex; + gap: 4px; + align-items: center; +} +.control-bar select, +.control-bar input[type='text'] { + background: #0d1117; + color: #c9d1d9; + border: 1px solid #30363d; + border-radius: 4px; + padding: 3px 6px; +} +.control-bar button { + background: #238636; + color: #fff; + border: none; + border-radius: 5px; + padding: 5px 12px; + cursor: pointer; + font-size: 12px; +} +.control-bar button:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.control-bar__hint { + font-size: 11px; + color: #6e7681; + font-style: italic; +} +/* Plain (non-E2EE) join: neutral, so the green button stays the encrypted one. */ +.control-bar button.control-bar__plain { + background: #21262d; + border: 1px solid #30363d; +} +.control-bar button.control-bar__keys-toggle { + margin-left: auto; + background: #21262d; + border: 1px solid #30363d; +} +.control-bar button.control-bar__keys-toggle.active { + background: #1f6feb; + border-color: #1f6feb; +} +.control-bar__shared { + display: flex; + gap: 6px; + align-items: center; + margin-left: auto; +} +.control-bar__error { + color: #f85149; + background: #d92d2022; + border: 1px solid #f85149; + border-radius: 5px; + padding: 6px 10px; +} +.control-bar__error span { + flex: 1; +} +.control-bar__error button { + background: transparent; + border: 1px solid #f85149; + color: #f85149; +} diff --git a/sample-apps/react/e2ee-demo/src/components/ControlBar.tsx b/sample-apps/react/e2ee-demo/src/components/ControlBar.tsx new file mode 100644 index 0000000000..8edd623bfa --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/components/ControlBar.tsx @@ -0,0 +1,174 @@ +import { useMemo, useState } from 'react'; +import { + EncryptionManager, + EncryptionSettingsResponseModeEnum, +} from '@stream-io/video-react-sdk'; +import { MAX_PARTICIPANTS } from '../config'; +import { useHarnessEngine, useSnapshot } from '../hooks/useHarness'; +import type { PreferredCodec } from '../harness/snapshot'; +import { detectTransformSupport } from '../harness/transformSupport'; +import './ControlBar.css'; + +interface ControlBarProps { + showKeys: boolean; + onToggleKeys: () => void; +} + +export const ControlBar = ({ showKeys, onToggleKeys }: ControlBarProps) => { + const engine = useHarnessEngine(); + const { + config, + participants, + globalError, + resolvedEncryptionMode, + e2eeEnabled, + } = useSnapshot(); + const isSupported = EncryptionManager.isSupported(); + const support = useMemo(detectTransformSupport, []); + const [shared, setShared] = useState(''); + + // What this browser can do, as a plain capability list. The SDK owns the + // choice between the two APIs and does not expose it, so the harness states + // facts instead of predicting the pick. + const capabilities = [ + support.hasInsertableStreams && 'Insertable Streams', + support.hasScriptTransform && 'RTCRtpScriptTransform', + ].filter(Boolean); + + const joined = participants.length; + const normals = participants.filter((p) => p.role === 'normal').length; + const atCapacity = normals >= MAX_PARTICIPANTS; + + return ( +
+
+

Stream E2EE Harness

+ + call: {config.callType}:{config.callId} + + + {isSupported ? 'E2EE supported' : 'E2EE not supported'} + + {resolvedEncryptionMode && ( + + mode: {resolvedEncryptionMode} + + )} + {joined > 0 && ( + + SFU: E2EE {e2eeEnabled ? 'active' : 'inactive'} + + )} + +
+ +
+ + + {capabilities.length + ? `browser has: ${capabilities.join(', ')}` + : 'no encoded transform API'} + + + + + + {config.keyMode === 'shared' && ( + + setShared(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && shared.trim()) { + engine.setSharedKey(shared.trim()); + } + }} + /> + + + )} +
+ + {globalError && ( +
+ {globalError} + +
+ )} +
+ ); +}; diff --git a/sample-apps/react/e2ee-demo/src/components/EventLog.css b/sample-apps/react/e2ee-demo/src/components/EventLog.css new file mode 100644 index 0000000000..99ca555f3d --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/components/EventLog.css @@ -0,0 +1,62 @@ +/* Embedded at the bottom of each participant panel: capped height with its + own scroll so the video keeps the remaining vertical space. */ +.event-log { + background: var(--color-surface); + border-top: 1px solid var(--color-border); + display: flex; + flex-direction: column; + max-height: 150px; + flex-shrink: 0; +} + +.event-log__header { + padding: 6px 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-secondary); + border-bottom: 1px solid var(--color-border); +} + +.event-log__entries { + overflow-y: auto; + flex: 1; + padding: 4px 0; +} + +.event-log__empty { + padding: 16px; + text-align: center; + color: var(--color-text-secondary); + font-size: 13px; +} + +.event-log__entry { + display: flex; + align-items: baseline; + gap: 8px; + padding: 3px 12px; + font-size: 12px; + line-height: 1.5; +} + +.event-log__entry:hover { + background: var(--color-surface-elevated); +} + +.event-log__icon { + flex-shrink: 0; + font-size: 11px; +} + +.event-log__time { + flex-shrink: 0; + color: var(--color-text-secondary); + font-family: var(--font-mono); + font-size: 11px; +} + +.event-log__message { + color: var(--color-text); +} diff --git a/sample-apps/react/e2ee-demo/src/components/EventLog.tsx b/sample-apps/react/e2ee-demo/src/components/EventLog.tsx new file mode 100644 index 0000000000..07a8dfcd3f --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/components/EventLog.tsx @@ -0,0 +1,58 @@ +import { useEffect, useRef } from 'react'; +import './EventLog.css'; + +export interface EventLogEntry { + id: number; + timestamp: Date; + message: string; + type: + | 'key-set' + | 'key-rotate' + | 'key-distribute' + | 'join' + | 'leave' + | 'error' + | 'perf'; +} + +const TYPE_LABELS: Record = { + 'key-set': '🔑', + 'key-rotate': '🔄', + 'key-distribute': '📤', + join: '✅', + leave: '👋', + error: '❌', + perf: '📊', +}; + +export const EventLog = ({ entries }: { entries: EventLogEntry[] }) => { + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [entries.length]); + + return ( +
+
Event Log
+
+ {entries.length === 0 && ( +
No events yet
+ )} + {entries.map((entry) => ( +
+ {TYPE_LABELS[entry.type]} + + {entry.timestamp.toLocaleTimeString()} + + {entry.message} +
+ ))} +
+
+
+ ); +}; diff --git a/sample-apps/react/e2ee-demo/src/components/KeyControls.css b/sample-apps/react/e2ee-demo/src/components/KeyControls.css new file mode 100644 index 0000000000..a618f1a12b --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/components/KeyControls.css @@ -0,0 +1,127 @@ +.key-controls { + padding: 10px 12px; + border-top: 1px solid var(--color-border); + background: var(--color-surface); +} + +.key-controls__title { + font-size: 12px; + font-weight: 700; + color: var(--color-text); + margin-bottom: 8px; +} + +.key-controls__current { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.key-controls__label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-secondary); + flex-shrink: 0; +} + +.key-controls__hex { + font-family: var(--font-mono); + font-size: 11px; + color: var(--color-text-mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +.key-controls__index { + font-size: 10px; + font-weight: 700; + color: #fff; + padding: 1px 6px; + border-radius: 10px; + flex-shrink: 0; +} + +.key-controls__actions { + display: flex; + gap: 6px; +} + +.key-controls__input { + flex: 1; + min-width: 0; + padding: 6px 10px; + font-size: 12px; + font-family: var(--font-mono); + background: var(--color-bg); + color: var(--color-text); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + outline: none; +} + +.key-controls__input:focus { + border-color: var(--color-text-secondary); +} + +.key-controls__input::placeholder { + color: var(--color-text-secondary); + font-family: inherit; +} + +.key-controls__btn { + padding: 6px 12px; + font-size: 12px; + font-weight: 600; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + white-space: nowrap; + transition: opacity 0.15s; +} + +.key-controls__btn:hover { + opacity: 0.85; +} + +.key-controls__btn:disabled { + opacity: 0.4; + cursor: default; +} + +.key-controls__btn--set { + background: var(--color-surface-elevated); + color: var(--color-text); + border: 1px solid var(--color-border); +} + +.key-controls__btn--rotate { + background: #005fff; + color: #fff; +} + +.key-controls__local-only { + display: flex; + align-items: center; + gap: 6px; + margin-top: 6px; + font-size: 11px; + color: var(--color-text-secondary); + cursor: pointer; + user-select: none; +} + +.key-controls__local-only input[type='checkbox'] { + accent-color: #d92d20; + cursor: pointer; +} + +.key-controls__local-only-hint { + color: #d92d20; + font-style: italic; +} diff --git a/sample-apps/react/e2ee-demo/src/components/KeyControls.tsx b/sample-apps/react/e2ee-demo/src/components/KeyControls.tsx new file mode 100644 index 0000000000..d64b381455 --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/components/KeyControls.tsx @@ -0,0 +1,88 @@ +import { useState } from 'react'; +import { toHex } from '../harness/keys'; +import './KeyControls.css'; + +interface KeyControlsProps { + currentKey: ArrayBuffer; + keyIndex: number; + color: string; + onRotate: (localOnly: boolean) => void; + onSetKey: (input: string, localOnly: boolean) => void; +} + +export const KeyControls = ({ + currentKey, + keyIndex, + color, + onRotate, + onSetKey, +}: KeyControlsProps) => { + const [input, setInput] = useState(''); + const [localOnly, setLocalOnly] = useState(false); + const hex = toHex(currentKey); + + const handleSetKey = () => { + const trimmed = input.trim(); + if (!trimmed) return; + onSetKey(trimmed, localOnly); + setInput(''); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') handleSetKey(); + }; + + return ( +
+
Encryption key
+
+ Current Key + + {hex} + + + #{keyIndex} + +
+ +
+ setInput(e.target.value)} + onKeyDown={handleKeyDown} + /> + + +
+ + +
+ ); +}; diff --git a/sample-apps/react/e2ee-demo/src/components/KeyOverridePanel.css b/sample-apps/react/e2ee-demo/src/components/KeyOverridePanel.css new file mode 100644 index 0000000000..0024f95d95 --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/components/KeyOverridePanel.css @@ -0,0 +1,148 @@ +.key-override { + position: fixed; + top: 84px; + right: 16px; + z-index: 50; + width: min(460px, calc(100vw - 32px)); + max-height: calc(100vh - 100px); + overflow-y: auto; + padding: 14px; + background: var(--color-surface-elevated); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + box-shadow: 0 10px 34px rgba(0, 0, 0, 0.45); +} + +.key-override__head { + display: flex; + align-items: flex-start; + gap: 8px; + margin-bottom: 10px; +} + +.key-override__head-text { + flex: 1; + min-width: 0; +} + +.key-override__close { + flex-shrink: 0; + width: 24px; + height: 24px; + line-height: 1; + font-size: 18px; + color: var(--color-text-secondary); + background: none; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; +} + +.key-override__close:hover { + color: var(--color-text); + background: var(--color-surface); +} + +.key-override__empty { + font-size: 12px; + color: var(--color-text-secondary); + margin: 0; +} + +.key-override__title { + font-size: 13px; + font-weight: 700; + color: var(--color-text); + margin: 0; +} + +.key-override__hint { + font-size: 11px; + color: var(--color-text-secondary); + margin: 4px 0 0; + max-width: 640px; +} + +.key-override__rows { + display: flex; + flex-direction: column; + gap: 6px; +} + +.key-override__row { + display: flex; + align-items: center; + gap: 8px; +} + +.key-override__name { + display: flex; + align-items: center; + gap: 6px; + min-width: 120px; + font-size: 12px; + font-weight: 600; + color: var(--color-text); +} + +.key-override__badge { + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #fff; + background: #005fff; + padding: 1px 5px; + border-radius: 8px; +} + +.key-override__id { + font-family: var(--font-mono); + font-size: 11px; + color: var(--color-text-mono); + min-width: 120px; +} + +.key-override__input { + flex: 1; + min-width: 0; + padding: 6px 10px; + font-size: 12px; + font-family: var(--font-mono); + background: var(--color-bg); + color: var(--color-text); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + outline: none; +} + +.key-override__input:focus { + border-color: var(--color-text-secondary); +} + +.key-override__input::placeholder { + color: var(--color-text-secondary); + font-family: inherit; +} + +.key-override__btn { + padding: 6px 14px; + font-size: 12px; + font-weight: 600; + color: #fff; + background: #005fff; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + white-space: nowrap; + transition: opacity 0.15s; +} + +.key-override__btn:hover { + opacity: 0.85; +} + +.key-override__btn:disabled { + opacity: 0.4; + cursor: default; +} diff --git a/sample-apps/react/e2ee-demo/src/components/KeyOverridePanel.tsx b/sample-apps/react/e2ee-demo/src/components/KeyOverridePanel.tsx new file mode 100644 index 0000000000..b81d49cfb0 --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/components/KeyOverridePanel.tsx @@ -0,0 +1,95 @@ +import { useState } from 'react'; +import { useHarnessEngine, useSnapshot } from '../hooks/useHarness'; +import './KeyOverridePanel.css'; + +interface KeyOverridePanelProps { + open: boolean; + onClose: () => void; +} + +/** + * Manually set the encryption key of any participant in the call - local or + * remote (including peers joined from other tabs/browsers). Paste the same + * value in each tab to make them interoperate; keys are not auto-distributed. + * + * Rendered as a floating overlay toggled from the header so it does not take + * layout space when closed. + */ +export const KeyOverridePanel = ({ open, onClose }: KeyOverridePanelProps) => { + const engine = useHarnessEngine(); + const { roster } = useSnapshot(); + const [values, setValues] = useState>({}); + + if (!open) return null; + + const apply = (userId: string) => { + const value = (values[userId] ?? '').trim(); + if (!value) return; + engine.overrideKey(userId, value); + // Keep the value in the input so it can be copied into another tab/browser. + }; + + return ( +
+
+
+

Manual key override

+

+ Set any participant's key (32-char hex or passphrase). Paste + the same value into the other tab/browser to decrypt each other. + Applied at a fixed key index; not auto-distributed. +

+
+ +
+ {roster.length === 0 ? ( +

+ No participants in the call yet. Add one, or wait for a peer to join + from another tab. +

+ ) : ( +
+ {roster.map((r) => ( +
+ + {r.name} + {r.isLocal && ( + local + )} + + + {r.userId.slice(0, 14)} + + + setValues((s) => ({ ...s, [r.userId]: e.target.value })) + } + onKeyDown={(e) => { + if (e.key === 'Enter') apply(r.userId); + }} + /> + +
+ ))} +
+ )} +
+ ); +}; diff --git a/sample-apps/react/e2ee-demo/src/components/ParticipantPanel.css b/sample-apps/react/e2ee-demo/src/components/ParticipantPanel.css new file mode 100644 index 0000000000..37c5a1512a --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/components/ParticipantPanel.css @@ -0,0 +1,108 @@ +.participant-panel { + background: var(--color-surface); + border-radius: var(--radius-md); + border-top: 3px solid transparent; + box-shadow: var(--shadow-card); + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; +} + +.participant-panel__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--color-surface-elevated); +} + +.participant-panel__identity { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.participant-panel__dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.participant-panel__name { + font-size: 14px; + font-weight: 600; + color: var(--color-text); +} + +.participant-panel__actions { + display: flex; + align-items: center; + gap: 8px; +} + +.participant-panel__remove { + background: none; + border: none; + color: var(--color-text-secondary); + font-size: 18px; + line-height: 1; + cursor: pointer; + padding: 2px 6px; + border-radius: var(--radius-sm); + transition: + background 0.15s, + color 0.15s; +} + +.participant-panel__remove:hover { + background: rgba(217, 45, 32, 0.15); + color: #d92d20; +} + +.participant-panel__video { + flex: 1; + min-height: 0; + background: var(--color-bg); + overflow: hidden; + display: flex; + flex-direction: column; + position: relative; +} + +.participant-panel__video .str-video { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.participant-panel__video .str-video__paginated-grid-layout__wrapper { + flex: 1; + min-height: 0; + overflow: hidden; +} + +.participant-panel__loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--color-text-secondary); + font-size: 13px; +} + +.participant-panel--spy { + box-shadow: 0 0 0 2px #b42318 inset; +} +.participant-panel__spy-badge { + font-size: 10px; + font-weight: 700; + color: #fff; + background: #b42318; + padding: 2px 6px; + border-radius: 4px; + margin-left: 6px; +} diff --git a/sample-apps/react/e2ee-demo/src/components/ParticipantPanel.tsx b/sample-apps/react/e2ee-demo/src/components/ParticipantPanel.tsx new file mode 100644 index 0000000000..14cbebec4c --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/components/ParticipantPanel.tsx @@ -0,0 +1,112 @@ +import { memo } from 'react'; +import { + CallingState, + CallControls, + PaginatedGridLayout, + StreamCall, + StreamTheme, + StreamVideo, + useCallStateHooks, +} from '@stream-io/video-react-sdk'; +import type { HarnessParticipant } from '../harness/snapshot'; +import { useHarnessEngine } from '../hooks/useHarness'; +import { KeyControls } from './KeyControls'; +import { StatusReadout } from './StatusReadout'; +import { ChaosControls } from './ChaosControls'; +import { SpyOverlay } from './SpyOverlay'; +import { EventLog } from './EventLog'; +import type { EventLogEntry } from './EventLog'; +import './ParticipantPanel.css'; + +const CallUI = () => { + const { useCallCallingState } = useCallStateHooks(); + if (useCallCallingState() !== CallingState.JOINED) { + return
Connecting...
; + } + return ( + + + + + ); +}; + +interface Props { + participant: HarnessParticipant; + nameByUserId: Record; + events: EventLogEntry[]; +} + +export const ParticipantPanel = memo(function ParticipantPanel({ + participant, + nameByUserId, + events, +}: Props) { + const engine = useHarnessEngine(); + const { userId, name, color, role, client, call, currentKey, keyIndex } = + participant; + const isSpy = role === 'spy'; + + return ( +
+
+
+ + {name} + {isSpy && ( + + ADMITTED · NO KEYS + + )} +
+
+ +
+
+ +
+ + + + + + {isSpy && } +
+ + + + {!isSpy && currentKey && ( + engine.rotateKey(userId, localOnly)} + onSetKey={(input, localOnly) => + engine.setKey(userId, input, localOnly) + } + /> + )} + + {/* Nothing to inject without a key: on a plain call there is no encryption + to break, and the buttons would all be no-ops. */} + {!isSpy && currentKey && } + + +
+ ); +}); diff --git a/sample-apps/react/e2ee-demo/src/components/SpyOverlay.css b/sample-apps/react/e2ee-demo/src/components/SpyOverlay.css new file mode 100644 index 0000000000..e03b02a357 --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/components/SpyOverlay.css @@ -0,0 +1,26 @@ +.spy-overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + /* Translucent tint so the undecryptable (gibberish) video shows underneath. */ + background: rgba(40, 0, 0, 0.25); + color: #ffd7d2; + text-align: center; + pointer-events: none; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.95); +} +.spy-overlay__lock { + font-size: 28px; +} +.spy-overlay__title { + font-weight: 700; + font-size: 14px; +} +.spy-overlay__sub { + font-size: 11px; + opacity: 0.95; +} diff --git a/sample-apps/react/e2ee-demo/src/components/SpyOverlay.tsx b/sample-apps/react/e2ee-demo/src/components/SpyOverlay.tsx new file mode 100644 index 0000000000..896aca67a8 --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/components/SpyOverlay.tsx @@ -0,0 +1,11 @@ +import './SpyOverlay.css'; + +export const SpyOverlay = () => ( +
+ 🔒 + Admitted, no keys + + in the call, but peers' media is undecryptable + +
+); diff --git a/sample-apps/react/e2ee-demo/src/components/StatusReadout.css b/sample-apps/react/e2ee-demo/src/components/StatusReadout.css new file mode 100644 index 0000000000..37fbbbbd78 --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/components/StatusReadout.css @@ -0,0 +1,49 @@ +.status-readout { + font-family: var(--str-video-font-family, monospace); + font-size: 11px; + line-height: 1.6; + padding: 8px 10px; + background: #0d1117; + color: #c9d1d9; + border-radius: 6px; +} +.status-readout__row { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} +.status-readout__label { + color: #8b949e; + min-width: 44px; +} +.status-readout__muted { + color: #6e7681; +} +.status-readout__dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} +.status-readout__dot.on { + background: #00b368; +} +.status-readout__dot.off { + background: #6e7681; +} +.status-readout__fail { + color: #f85149; +} +.status-readout__warn { + color: #e3b341; + margin-top: 4px; +} +.status-readout__perf { + color: #8b949e; +} +.status-readout code { + background: #161b22; + padding: 1px 5px; + border-radius: 4px; +} diff --git a/sample-apps/react/e2ee-demo/src/components/StatusReadout.tsx b/sample-apps/react/e2ee-demo/src/components/StatusReadout.tsx new file mode 100644 index 0000000000..36ec58a32a --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/components/StatusReadout.tsx @@ -0,0 +1,115 @@ +import type { HarnessParticipant } from '../harness/snapshot'; +import './StatusReadout.css'; + +const fmtNames = (ids: string[], lookup: Record): string => + ids.length ? ids.map((id) => lookup[id] ?? id.slice(0, 8)).join(', ') : '-'; + +interface Props { + participant: HarnessParticipant; + nameByUserId: Record; +} + +export const StatusReadout = ({ participant, nameByUserId }: Props) => { + const { enabled, codec, keyStore, tracks, perf, encryptionFailure } = + participant; + const nameOf = (userId: string) => nameByUserId[userId] ?? userId.slice(0, 8); + // The worker rebuilds its stats maps every report interval, so array order is + // non-deterministic. Sort by stable keys so rows do not swap places between + // reports and are easy to track. + const encodeRows = [...perf.encode].sort( + (a, b) => + a.trackType.localeCompare(b.trackType) || a.codec.localeCompare(b.codec), + ); + const decodeRows = [...perf.decode].sort( + (a, b) => + nameOf(a.userId).localeCompare(nameOf(b.userId)) || + a.trackType.localeCompare(b.trackType), + ); + const perUserKeys = [...(keyStore?.perUserKeys ?? [])].sort( + (a, b) => + nameOf(a.userId).localeCompare(nameOf(b.userId)) || + a.keyIndex - b.keyIndex, + ); + const sharedKeys = [...(keyStore?.sharedKeys ?? [])].sort( + (a, b) => a.keyIndex - b.keyIndex, + ); + return ( +
+
+ + E2EE {enabled ? 'on' : 'off'} · {codec} +
+ +
+ keys + {sharedKeys.map((key) => ( + + shared #{key.keyIndex} {key.fingerprint} + {key.isActive && ' (active)'} + + ))} + {perUserKeys.map((k) => ( + + {nameOf(k.userId)} #{k.keyIndex} {k.fingerprint} + + ))} + {!sharedKeys.length && !perUserKeys.length && ( + none + )} +
+ +
+ tracks + enc {tracks.encrypting ? '✓' : '✗'} · dec{' '} + {fmtNames(tracks.decryptingFrom, nameByUserId)} + {tracks.failingFrom.length > 0 && ( + + · fail {fmtNames(tracks.failingFrom, nameByUserId)} + + )} + {tracks.brokenFrom.length > 0 && ( + + · broken {fmtNames(tracks.brokenFrom, nameByUserId)} + + )} +
+ + {encryptionFailure && ( +
+ ⚠ encrypt failed ({encryptionFailure}) - publishing nothing +
+ )} + + {encodeRows.length > 0 && ( +
+ encode + {encodeRows.map((e) => ( + + {e.trackType.toLowerCase()} ({e.codec}) {Math.round(e.fps)}fps + {e.maxCryptoMs > 0 && ` · ${e.maxCryptoMs.toFixed(1)}ms`} + + ))} +
+ )} + + {decodeRows.length > 0 && ( +
+ decode + {decodeRows.map((d) => ( + + {nameOf(d.userId)} {d.trackType.toLowerCase()} {Math.round(d.fps)} + fps + {d.maxCryptoMs > 0 && ` · ${d.maxCryptoMs.toFixed(1)}ms`} + + ))} +
+ )} +
+ ); +}; diff --git a/sample-apps/react/e2ee-demo/src/config.ts b/sample-apps/react/e2ee-demo/src/config.ts new file mode 100644 index 0000000000..4b2171bdcf --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/config.ts @@ -0,0 +1,12 @@ +export const TOKEN_ENDPOINT = + 'https://pronto.getstream.io/api/auth/create-token'; +export const TOKEN_ENVIRONMENT = 'pronto-staging'; +/** Default call type. Overridable per session with `?call_type=`. */ +export const CALL_TYPE = 'e2ee'; +export const MAX_PARTICIPANTS = 4; + +export const PARTICIPANT_NAMES = ['Alice', 'Bob', 'Charlie', 'Diana']; +export const PARTICIPANT_COLORS = ['#005fff', '#00b368', '#e07912', '#d92d20']; + +export const SPY_NAME = 'Trudy'; +export const SPY_COLOR = '#b42318'; diff --git a/sample-apps/react/e2ee-demo/src/harness/E2EEHarness.ts b/sample-apps/react/e2ee-demo/src/harness/E2EEHarness.ts new file mode 100644 index 0000000000..4ff273afd0 --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/harness/E2EEHarness.ts @@ -0,0 +1,805 @@ +import { + CallingState, + EncryptionManager, + StreamVideoClient, + type Call, + type EncryptionSettingsResponseModeEnum, + type KeyStateReport, + type PerfReport, +} from '@stream-io/video-react-sdk'; +import { + TOKEN_ENDPOINT, + CALL_TYPE, + PARTICIPANT_NAMES, + PARTICIPANT_COLORS, + MAX_PARTICIPANTS, + SPY_NAME, + SPY_COLOR, +} from '../config'; +import { resolveEnvironment } from './url'; +import { generateKey, toHex, parseKeyInput } from './keys'; +import type { SendKeyFn } from './keyTransport'; +import type { + HarnessConfig, + HarnessParticipant, + LogEntry, + PreferredCodec, + RosterEntry, + Snapshot, +} from './snapshot'; + +const MAX_LOG = 200; + +// Manual overrides and shared keys use one fixed key index so peers on other +// tabs only need to copy the key value, not coordinate an index. Decryption +// resolves a key by (userId, keyIndex) from the frame trailer, so a per-tab +// counter would drift and break cross-tab decryption; a fixed index always +// lines up. setKey/setSharedKey make the given index the active one, so the +// local encoder uses exactly this key. +const FIXED_KEY_INDEX = 0; + +const defaultFetchCredentials = async (userId: string) => { + const url = new URL(TOKEN_ENDPOINT); + url.searchParams.set( + 'environment', + resolveEnvironment(window.location.search), + ); + url.searchParams.set('user_id', userId); + const { apiKey, token } = await fetch(url).then((r) => r.json()); + return { apiKey: apiKey as string, token: token as string }; +}; + +export interface HarnessDeps { + fetchCredentials: ( + userId: string, + ) => Promise<{ apiKey: string; token: string }>; + createClient: (args: { + apiKey: string; + token: string; + userId: string; + name: string; + fetchCredentials: ( + userId: string, + ) => Promise<{ apiKey: string; token: string }>; + }) => StreamVideoClient; + createManager: (userId: string) => Promise; +} + +export const defaultDeps = (): HarnessDeps => ({ + fetchCredentials: defaultFetchCredentials, + createClient: ({ apiKey, token, userId, name, fetchCredentials }) => + new StreamVideoClient({ + apiKey, + user: { id: userId, name }, + token, + // Debug level surfaces the SDK's "E2EE encryptor attached to sender" line + // and any worker errors, which is most of the point of this harness. + options: { logLevel: 'debug' }, + tokenProvider: () => fetchCredentials(userId).then((c) => c.token), + }), + createManager: (userId) => EncryptionManager.create(userId), +}); + +/** Internal per-participant state held by the engine (not the snapshot shape). */ +interface EngineParticipant { + userId: string; + name: string; + color: string; + role: 'normal' | 'spy'; + codec: PreferredCodec; + client: StreamVideoClient; + call: Call; + /** Absent for a participant joined as plain: no E2EE, no encoded transforms. */ + manager?: EncryptionManager; + currentKey?: ArrayBuffer; + keyIndex: number; + enabled: boolean; + keyStore: KeyStateReport | null; + perf: PerfReport | null; + failingFrom: Set; + brokenFrom: Set; + encryptionFailure: string | null; + unsubscribes: Array<() => void>; +} + +export class E2EEHarness { + private deps: HarnessDeps; + private participants: EngineParticipant[] = []; + private config: HarnessConfig; + private log: LogEntry[] = []; + private globalError: string | null = null; + private listeners = new Set<() => void>(); + private snapshot: Snapshot; + private logId = 0; + private activeSharedKeyIndex = -1; + private sharedKeyBytes: ArrayBuffer | null = null; + private resolvedEncryptionMode: + | EncryptionSettingsResponseModeEnum + | undefined; + private e2eeEnabled = false; + + constructor( + init: { callId: string; callType?: string; codec?: PreferredCodec }, + deps: HarnessDeps = defaultDeps(), + ) { + this.deps = deps; + this.config = { + callId: init.callId, + callType: init.callType ?? CALL_TYPE, + codec: init.codec ?? 'vp8', + keyMode: 'per-user', + }; + this.snapshot = this.build(); + } + + // --- external store --- + + subscribe = (cb: () => void): (() => void) => { + this.listeners.add(cb); + return () => this.listeners.delete(cb); + }; + + getSnapshot = (): Snapshot => this.snapshot; + + private emit = (): void => { + this.snapshot = this.build(); + this.listeners.forEach((cb) => cb()); + }; + + private build = (): Snapshot => ({ + config: { ...this.config }, + participants: this.participants.map(this.toSnapshotParticipant), + roster: this.buildRoster(), + log: this.log, + globalError: this.globalError, + resolvedEncryptionMode: this.resolvedEncryptionMode, + e2eeEnabled: this.e2eeEnabled, + }); + + /** + * The union of every local participant's SFU roster, deduped by userId. All + * local participants share the same call, so this surfaces everyone in the + * call - including remote peers joined from other tabs or browsers. + */ + private buildRoster = (): RosterEntry[] => { + const localIds = new Set(this.participants.map((p) => p.userId)); + const seen = new Map(); + for (const p of this.participants) { + for (const sfu of p.call.state.participants) { + if (seen.has(sfu.userId)) continue; + seen.set(sfu.userId, { + userId: sfu.userId, + name: sfu.name || sfu.userId.slice(0, 8), + isLocal: localIds.has(sfu.userId), + }); + } + } + // Stable order so the key-override rows do not jump around as the SFU + // roster re-sorts (dominant speaker, etc.). + return [...seen.values()].sort( + (a, b) => + a.name.localeCompare(b.name) || a.userId.localeCompare(b.userId), + ); + }; + + private toSnapshotParticipant = ( + p: EngineParticipant, + ): HarnessParticipant => { + const decryptingFrom = p.enabled + ? this.participants + .filter( + (o) => + o.userId !== p.userId && + !!o.currentKey && + !p.failingFrom.has(o.userId), + ) + .map((o) => o.userId) + : []; + const failingFrom = [...p.failingFrom]; + return { + userId: p.userId, + name: p.name, + color: p.color, + role: p.role, + enabled: p.enabled, + codec: p.codec, + currentKey: p.currentKey, + keyIndex: p.keyIndex, + keyStore: p.keyStore, + encryptionFailure: p.encryptionFailure, + tracks: { + encrypting: + p.enabled && (!!p.currentKey || this.activeSharedKeyIndex >= 0), + decryptingFrom, + failingFrom, + brokenFrom: [...p.brokenFrom], + }, + perf: { + encode: p.perf?.encode ?? [], + decode: p.perf?.decode ?? [], + }, + client: p.client, + call: p.call, + }; + }; + + // --- config --- + + setConfig = ( + patch: Partial>, + ): void => { + Object.assign(this.config, patch); + this.emit(); + }; + + // --- participants --- + + /** + * Join a new participant. `e2ee` decides whether it gets an + * {@link EncryptionManager}: without one the SDK declares `e2ee: false` and + * publishes in the clear, which is how the non-E2EE path gets exercised. + * + * Whether the call *permits* either is the backend's call - the harness sends + * no encryption settings, so the call type's configuration decides, and a + * mismatch surfaces as a rejected join. + */ + addParticipant = async (e2ee: boolean): Promise => { + const normals = this.participants.filter((p) => p.role === 'normal'); + const index = normals.length; + if (index >= MAX_PARTICIPANTS) return; + await this.spawn({ + name: PARTICIPANT_NAMES[index], + color: PARTICIPANT_COLORS[index], + role: 'normal', + withKey: e2ee && this.config.keyMode === 'per-user', + e2ee, + }); + }; + + addSpy = async (): Promise => { + if (this.participants.some((p) => p.role === 'spy')) return; // one spy is enough + await this.spawn({ + name: SPY_NAME, + color: SPY_COLOR, + role: 'spy', + withKey: false, + e2ee: true, + }); + }; + + private spawn = async (opts: { + name: string; + color: string; + role: 'normal' | 'spy'; + withKey: boolean; + e2ee: boolean; + }): Promise => { + const userId = `e2ee-${opts.name.toLowerCase()}-${crypto.randomUUID().slice(0, 8)}`; + try { + this.globalError = null; + const { apiKey, token } = await this.deps.fetchCredentials(userId); + const client = this.deps.createClient({ + apiKey, + token, + userId, + name: opts.name, + fetchCredentials: this.deps.fetchCredentials, + }); + const call = client.call(this.config.callType, this.config.callId); + const isNormal = opts.role === 'normal'; + // A plain participant gets no manager at all - not even an unattached one. + // Constructing it would spin up a worker nobody uses and would throw on a + // browser without Encoded Transforms, which is exactly where testing the + // plain path matters most. + const manager = opts.e2ee + ? await this.deps.createManager(userId) + : undefined; + + const p: EngineParticipant = { + userId, + name: opts.name, + color: opts.color, + role: opts.role, + codec: this.config.codec, + client, + call, + manager, + keyIndex: 0, + // "E2EE on" for this participant: a normal role is not enough, there has + // to be a manager attached. A plain participant never encrypts. + enabled: isNormal && !!manager, + keyStore: null, + perf: null, + failingFrom: new Set(), + brokenFrom: new Set(), + encryptionFailure: null, + unsubscribes: [], + }; + + // Every participant, including the spy, attaches an E2EEManager before + // joining. The call is created in `auto-on` mode, so E2EE is mandatory and + // the backend rejects a join whose e2ee flag (which the SDK sends whenever a + // manager is attached) does not match: the spy cannot slip in as a plain, + // manager-less client. She differs from the others only in her keys, and + // never receives any. Her decode transform therefore fails on every peer and + // renders gibberish - the proof the media is unusable without the keys - + // while her own encoder drops outgoing frames for lack of a key. + // + // With no manager (`'none'` mode) none of that applies: the SDK declares + // `e2ee: false`, attaches no transforms, and the media stays in the clear. + if (manager) { + call.setE2EEManager(manager); + this.wireEvents(p, manager); + manager.enablePerformanceReporting(true); + manager.requestKeyDump(); + } + + if (isNormal && manager) { + if (opts.withKey) { + const key = generateKey(); + manager.setKey(userId, 0, key.slice(0)); + p.currentKey = key; + this.addLog( + userId, + `Set key: ${toHex(key).slice(0, 16)}...`, + 'key-set', + ); + } else if (this.config.keyMode === 'shared' && this.sharedKeyBytes) { + manager.setSharedKey( + this.activeSharedKeyIndex, + this.sharedKeyBytes.slice(0), + ); + p.currentKey = this.sharedKeyBytes; + p.keyIndex = this.activeSharedKeyIndex; + this.addLog(userId, 'Shared key applied', 'key-distribute'); + } + } + + call.updatePublishOptions({ preferredCodec: this.config.codec }); + // No encryption settings override: whether this call is E2EE is entirely + // the backend's decision, taken from the call type's configuration. The + // resolved mode is read back below and shown in the header. + await call.join({ create: true }); + this.addLog(userId, `Joined the call`, 'join'); + + // Publish real camera + mic so there is encrypted media flowing - for + // peers to decrypt and for the spy to fail to decrypt into gibberish. + try { + await call.camera.enable(); + await call.microphone.enable(); + } catch (err) { + this.addLog( + userId, + `Could not enable camera/mic: ${String(err)}`, + 'error', + ); + } + + this.participants.push(p); + this.publishDebugHandles(); + // The call can end without going through removeParticipant - the SDK's own + // hang-up button in CallControls, or the SFU dropping us. Retire the panel + // when that happens, otherwise it lingers on the "Connecting..." fallback + // that CallUI shows for any non-JOINED state. Subscribed after the join, so + // the replayed initial value is JOINED and never triggers this. + const callingStateSub = p.call.state.callingState$.subscribe((state) => { + if (state !== CallingState.LEFT) return; + this.addLog(p.userId, 'Left the call', 'leave'); + this.removeParticipant(p.userId); + }); + p.unsubscribes.push(() => callingStateSub.unsubscribe()); + // Re-emit when the SFU roster changes so the manual key-override panel + // tracks peers joining or leaving from other tabs. + const rosterSub = p.call.state.participants$.subscribe(() => this.emit()); + p.unsubscribes.push(() => rosterSub.unsubscribe()); + // Read the encryption mode the backend resolved for this call. The harness + // never requests one, so this is whatever the call type is configured with. + const settingsSub = p.call.state.settings$.subscribe((settings) => { + const mode = settings?.encryption?.mode; + if (!mode || mode === this.resolvedEncryptionMode) return; + this.resolvedEncryptionMode = mode; + this.addLog(null, `Call encryption mode: ${mode}`, 'join'); + this.emit(); + }); + p.unsubscribes.push(() => settingsSub.unsubscribe()); + // Whether E2EE is actually active, straight from the SFU's join response. + // This is the signal to trust; the resolved mode above only says what the + // call permits. + const e2eeSub = p.call.state.e2eeEnabled$.subscribe((enabled) => { + if (enabled === this.e2eeEnabled) return; + this.e2eeEnabled = enabled; + this.addLog( + null, + `SFU reports E2EE ${enabled ? 'active' : 'inactive'}`, + enabled ? 'join' : 'error', + ); + this.emit(); + }); + p.unsubscribes.push(() => e2eeSub.unsubscribe()); + if (isNormal) this.exchangeOnJoin(p); + this.emit(); + } catch (err) { + this.globalError = `Failed to add ${opts.name}: ${String(err)}`; + this.emit(); + } + }; + + // --- console debugging --- + + /** + * Mirror the live `Call` instances onto `window` so they can be poked at from + * the browser console: `window.calls.alice`, or `window.call` for the first + * participant. Refreshed whenever the participant list changes, so the handles + * never point at a call that has already been torn down. + * + * The harness runs several calls at once, which is why the keyed map is the + * primary handle and the singular one is only a shortcut. + */ + private publishDebugHandles = (): void => { + window.calls = Object.fromEntries( + this.participants.map((p) => [p.name.toLowerCase(), p.call]), + ); + window.call = this.participants[0]?.call; + }; + + // --- key exchange --- + + /** In-tab transport: set the key directly on the recipient's manager. */ + private sendKey: SendKeyFn = ( + toUserId: string, + fromUserId: string, + keyIndex: number, + key: ArrayBuffer, + ): void => { + const recipient = this.participants.find((p) => p.userId === toUserId); + if (!recipient?.manager) return; + recipient.manager.setKey(fromUserId, keyIndex, key.slice(0)); + const sender = this.participants.find((p) => p.userId === fromUserId); + this.addLog( + toUserId, + `Received ${sender?.name ?? fromUserId}'s key`, + 'key-distribute', + ); + }; + + private exchangeOnJoin = (joiner: EngineParticipant): void => { + if (this.config.keyMode === 'shared') return; + const existing = this.participants.filter( + (p) => p.userId !== joiner.userId && p.role === 'normal', + ); + // 1. Give existing participants the joiner's key. + if (joiner.currentKey) { + for (const other of existing) { + this.sendKey( + other.userId, + joiner.userId, + joiner.keyIndex, + joiner.currentKey, + ); + } + } + // 2. Give the joiner each existing participant's key. + for (const other of existing) { + if (!other.currentKey) continue; + this.sendKey( + joiner.userId, + other.userId, + other.keyIndex, + other.currentKey, + ); + } + }; + + // --- key rotation / set --- + + /** + * Drop the last `e2ee.encryption_failed` reason when fresh key material is + * installed, so a stale banner does not outlive the encoder it described. + * The worker re-arms its own failure latch on the next frame that encrypts, + * so a still-failing encoder re-reports within a frame. + * + * Note this does NOT reset the sender's frame counter - that is scoped to the + * worker, not to a key, and a rekey never restores its budget. The one + * failure this banner reset would hide is counter exhaustion, which needs + * 2^32 frames and cannot be reached here. + */ + private clearEncoderWarnings = (p: EngineParticipant): void => { + p.encryptionFailure = null; + }; + + rotateKey = (targetUserId: string, localOnly: boolean): void => { + const target = this.participants.find((p) => p.userId === targetUserId); + if (!target?.manager) return; + const key = generateKey(); + const keyIndex = target.keyIndex + 1; + target.manager.setKey(targetUserId, keyIndex, key.slice(0)); + target.currentKey = key; + target.keyIndex = keyIndex; + this.clearEncoderWarnings(target); + if (!localOnly) this.distribute(target); + this.addLog( + targetUserId, + `Rotated key (#${keyIndex}): ${toHex(key).slice(0, 16)}...${ + localOnly ? ' [LOCAL ONLY]' : '' + }`, + 'key-rotate', + ); + target.manager.requestKeyDump(); + this.emit(); + }; + + setKey = async ( + targetUserId: string, + input: string, + localOnly: boolean, + ): Promise => { + const target = this.participants.find((p) => p.userId === targetUserId); + if (!target?.manager) return; + const key = await parseKeyInput(input); + const keyIndex = target.keyIndex + 1; + target.manager.setKey(targetUserId, keyIndex, key.slice(0)); + target.currentKey = key; + target.keyIndex = keyIndex; + this.clearEncoderWarnings(target); + if (!localOnly) this.distribute(target); + this.addLog( + targetUserId, + `Set key (#${keyIndex}): ${toHex(key).slice(0, 16)}...${ + localOnly ? ' [LOCAL ONLY]' : '' + }`, + 'key-set', + ); + target.manager.requestKeyDump(); + this.emit(); + }; + + private distribute = (from: EngineParticipant): void => { + if (!from.currentKey) return; + for (const r of this.participants) { + if (r.userId === from.userId || r.role === 'spy') continue; + this.sendKey(r.userId, from.userId, from.keyIndex, from.currentKey); + } + }; + + // --- manual key override (cross-tab) --- + + /** + * Manually set the key for any participant in the call by userId, at the + * fixed {@link FIXED_KEY_INDEX}, on every local manager. If the userId + * belongs to a local participant this becomes their encode key; otherwise it + * is the decode key their peers use. Nothing is auto-distributed - paste the + * same value into another tab or browser to interoperate. + */ + overrideKey = async (userId: string, input: string): Promise => { + const key = await parseKeyInput(input); + for (const p of this.participants) { + if (p.role === 'spy') continue; // the spy stays keyless + p.manager?.setKey(userId, FIXED_KEY_INDEX, key.slice(0)); + p.manager?.requestKeyDump(); + } + const local = this.participants.find( + (p) => p.userId === userId && p.role === 'normal', + ); + if (local) { + local.currentKey = key; + local.keyIndex = FIXED_KEY_INDEX; + this.clearEncoderWarnings(local); + } + this.addLog( + userId, + `Manual key override (#${FIXED_KEY_INDEX}): ${toHex(key).slice(0, 16)}...`, + 'key-set', + ); + this.emit(); + }; + + // --- shared key --- + + setSharedKey = async (passphrase: string): Promise => { + const key = await parseKeyInput(passphrase); + // Fixed index (not a per-tab counter) so the same passphrase decrypts + // across tabs regardless of how many times each side sets it. + const keyIndex = FIXED_KEY_INDEX; + this.activeSharedKeyIndex = keyIndex; + this.sharedKeyBytes = key; + this.config.keyMode = 'shared'; + + // The spy never receives the shared key - she stays an outsider. + const targets = this.participants.filter((p) => p.role === 'normal'); + for (const p of targets) { + p.manager?.setSharedKey(keyIndex, key.slice(0)); + } + // Revoke per-user keys so the shared key is the baseline. + for (const p of targets) { + for (const other of targets) { + if (other.userId !== p.userId) p.manager?.removeKeys(other.userId); + } + p.manager?.removeKeys(p.userId); + p.currentKey = key; + p.keyIndex = keyIndex; + this.clearEncoderWarnings(p); + } + const label = + passphrase.length > 12 ? passphrase.slice(0, 12) + '...' : passphrase; + for (const p of targets) { + this.addLog( + p.userId, + `Shared key set from "${label}", per-user keys revoked`, + 'key-set', + ); + } + this.emit(); + }; + + // --- remove --- + + removeParticipant = (targetUserId: string): void => { + const target = this.participants.find((p) => p.userId === targetUserId); + if (!target?.manager) return; + this.teardown(target); + this.participants = this.participants.filter( + (p) => p.userId !== targetUserId, + ); + this.publishDebugHandles(); + for (const other of this.participants) { + if (other.role === 'spy') continue; + other.manager?.removeKeys(targetUserId); + other.failingFrom.delete(targetUserId); + other.brokenFrom.delete(targetUserId); + this.addLog( + other.userId, + `Removed ${target.name}'s keys`, + 'key-distribute', + ); + } + this.emit(); + }; + + dismissError = (): void => { + this.globalError = null; + this.emit(); + }; + + private teardown = (p: EngineParticipant): void => { + p.unsubscribes.forEach((u) => u()); + p.call.leave().catch(() => {}); + p.manager?.dispose(); + p.client.disconnectUser().catch(() => {}); + }; + + // --- failure injection --- + + /** Remove `targetUserId`'s key from `fromUserId`'s manager (or from all). */ + revokeKey = (targetUserId: string, fromUserId?: string): void => { + const holders = ( + fromUserId + ? this.participants.filter((p) => p.userId === fromUserId) + : this.participants.filter((p) => p.userId !== targetUserId) + ).filter((p) => p.role === 'normal'); + for (const h of holders) { + // A participant joined as plain has no manager and so never held the key. + // Skip it rather than logging a revocation that did not happen. + if (!h.manager) continue; + h.manager.removeKeys(targetUserId); + this.addLog( + h.userId, + `Revoked ${this.nameFor(targetUserId)}'s key`, + 'key-distribute', + ); + } + this.emit(); + }; + + /** Set a fresh local key without distributing it: instant decrypt mismatch. */ + setWrongKey = (targetUserId: string): void => { + const target = this.participants.find((p) => p.userId === targetUserId); + if (!target?.manager) return; + const key = generateKey(); + const keyIndex = target.keyIndex + 1; + target.manager.setKey(targetUserId, keyIndex, key.slice(0)); + target.currentKey = key; + target.keyIndex = keyIndex; + this.clearEncoderWarnings(target); + this.addLog( + targetUserId, + `Set WRONG key (#${keyIndex}) [not distributed]`, + 'key-rotate', + ); + target.manager.requestKeyDump(); + this.emit(); + }; + + /** Fire two rotations back-to-back to exercise replay-window / key-index handling. */ + rotationRace = (targetUserId: string): void => { + this.addLog( + targetUserId, + 'Rotation race: firing two rotations', + 'key-rotate', + ); + this.rotateKey(targetUserId, false); + this.rotateKey(targetUserId, false); + }; + + // --- event wiring --- + + private wireEvents = (p: EngineParticipant, m: EncryptionManager): void => { + p.unsubscribes.push( + m.on('e2ee.decryption_failed', ({ userId: remoteUserId }) => { + p.failingFrom.add(remoteUserId); + const name = this.nameFor(remoteUserId); + this.addLog( + p.userId, + `Failed to decrypt from ${name}: key mismatch`, + 'error', + ); + this.emit(); + }), + m.on('e2ee.decryption_resumed', ({ userId: remoteUserId }) => { + p.failingFrom.delete(remoteUserId); + p.brokenFrom.delete(remoteUserId); + this.addLog( + p.userId, + `Decryption resumed from ${this.nameFor(remoteUserId)}`, + 'join', + ); + this.emit(); + }), + m.on('e2ee.broken', ({ userId: remoteUserId, keyIndex }) => { + p.brokenFrom.add(remoteUserId); + this.addLog( + p.userId, + `E2EE broken from ${this.nameFor(remoteUserId)} (key #${keyIndex}): failures past tolerance`, + 'error', + ); + this.emit(); + }), + // One event name, two directions. Without `keyIndex` the local encoder + // holds no key and every outgoing track is stalled; with one, a remote + // sender's frame referenced a key this peer does not have yet, which is + // routine while distribution or a rotation is in flight. + m.on('e2ee.missing_key', ({ keyIndex, trackType }) => { + this.addLog( + p.userId, + keyIndex === undefined + ? 'No encryption key set: outgoing frames dropped' + : `Awaiting key ${keyIndex} for ${trackType ?? 'a track'}: frames dropped`, + keyIndex === undefined ? 'error' : 'key-distribute', + ); + this.emit(); + }), + m.on('e2ee.encryption_failed', ({ reason, trackType }) => { + p.encryptionFailure = reason; + this.addLog( + p.userId, + `Encryption failed on ${trackType ?? 'a track'}, publishing nothing: ${reason}`, + 'error', + ); + this.emit(); + }), + m.on('e2ee.key_state', (report: KeyStateReport) => { + p.keyStore = report; + this.emit(); + }), + m.on('e2ee.perf_report', (report: PerfReport) => { + p.perf = report; + this.emit(); + }), + ); + }; + + private nameFor = (userId: string): string => + this.participants.find((p) => p.userId === userId)?.name ?? userId; + + // --- logging --- + + private addLog = ( + userId: string | null, + message: string, + type: LogEntry['type'], + ): void => { + this.log = [ + ...this.log, + { id: ++this.logId, userId, timestamp: new Date(), message, type }, + ].slice(-MAX_LOG); + }; +} diff --git a/sample-apps/react/e2ee-demo/src/harness/keyTransport.ts b/sample-apps/react/e2ee-demo/src/harness/keyTransport.ts new file mode 100644 index 0000000000..074885cff3 --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/harness/keyTransport.ts @@ -0,0 +1,19 @@ +/** + * Key transport abstraction for the E2EE harness. + * + * In this harness all participants share one browser tab, so the engine's + * transport simply calls `EncryptionManager.setKey()` on the recipient's + * manager directly (see E2EEHarness.sendKey). + * + * In a production app, participants are on different devices. Implement a + * SendKeyFn that delivers the key over your secure channel (REST, WebSocket), + * encrypted in transit (TLS minimum; per-recipient ECDH/ECIES for maximum + * security), and have the receiving side call + * `e2ee.setKey(fromUserId, keyIndex, rawKey)` on delivery. + */ +export type SendKeyFn = ( + toUserId: string, + fromUserId: string, + keyIndex: number, + key: ArrayBuffer, +) => void; diff --git a/sample-apps/react/e2ee-demo/src/harness/keys.ts b/sample-apps/react/e2ee-demo/src/harness/keys.ts new file mode 100644 index 0000000000..3ccc9265f0 --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/harness/keys.ts @@ -0,0 +1,61 @@ +/** Pure E2EE key utilities: generation, hex conversion, passphrase derivation. */ + +/** Generate a random 16-byte AES-128 key. */ +export const generateKey = (): ArrayBuffer => { + const key = new ArrayBuffer(16); + crypto.getRandomValues(new Uint8Array(key)); + return key; +}; + +/** Convert an ArrayBuffer to a hex string. */ +export const toHex = (buffer: ArrayBuffer): string => + Array.from(new Uint8Array(buffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + +/** Convert a hex string (32 chars) to a 16-byte ArrayBuffer. */ +export const fromHex = (hex: string): ArrayBuffer => { + const bytes = new Uint8Array(16); + for (let i = 0; i < 16; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes.buffer; +}; + +/** Check if a string is valid 32-character hex. */ +export const isValidHex = (value: string): boolean => + /^[0-9a-fA-F]{32}$/.test(value); + +/** Derive a 16-byte AES-128 key from an arbitrary passphrase using PBKDF2. */ +export const deriveKeyFromPassphrase = async ( + passphrase: string, +): Promise => { + const enc = new TextEncoder(); + const baseKey = await crypto.subtle.importKey( + 'raw', + enc.encode(passphrase), + 'PBKDF2', + false, + ['deriveBits'], + ); + return crypto.subtle.deriveBits( + { + name: 'PBKDF2', + salt: enc.encode('stream-e2ee'), + iterations: 100_000, + hash: 'SHA-256', + }, + baseKey, + 128, + ); +}; + +/** + * Parse user input as either a hex key or a passphrase. + * - 32-character hex string -> convert directly to 16 bytes + * - Anything else -> derive via PBKDF2 + */ +export const parseKeyInput = async (input: string): Promise => { + if (isValidHex(input)) return fromHex(input); + return deriveKeyFromPassphrase(input); +}; diff --git a/sample-apps/react/e2ee-demo/src/harness/snapshot.ts b/sample-apps/react/e2ee-demo/src/harness/snapshot.ts new file mode 100644 index 0000000000..c9e0b2e184 --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/harness/snapshot.ts @@ -0,0 +1,98 @@ +import type { + StreamVideoClient, + Call, + EncryptionSettingsResponseModeEnum, + KeyStateReport, + PerfReport, +} from '@stream-io/video-react-sdk'; + +// AV1 is deliberately excluded: it has no E2EE framing scheme, so the worker +// fails closed on it and the track would publish nothing. +export type PreferredCodec = 'vp8' | 'vp9' | 'h264'; +export type KeyMode = 'per-user' | 'shared'; +export type ParticipantRole = 'normal' | 'spy'; + +export interface LogEntry { + id: number; + userId: string | null; // null = global (e.g. spawn failure) + timestamp: Date; + message: string; + type: + | 'key-set' + | 'key-rotate' + | 'key-distribute' + | 'join' + | 'leave' + | 'error' + | 'perf'; +} + +export interface HarnessParticipant { + userId: string; + name: string; + color: string; + role: ParticipantRole; + enabled: boolean; + codec: PreferredCodec; + currentKey?: ArrayBuffer; + keyIndex: number; + keyStore: KeyStateReport | null; + /** Last `e2ee.encryption_failed` reason, if the local encoder ever threw. */ + encryptionFailure: string | null; + tracks: { + encrypting: boolean; + decryptingFrom: string[]; + /** Remotes reporting `e2ee.decryption_failed`, possibly transient. */ + failingFrom: string[]; + /** + * Remotes whose session the SDK declared broken via `e2ee.broken` - + * decryption failed past the internal tolerance, so this is terminal + * until new key material arrives. + */ + brokenFrom: string[]; + }; + perf: PerfReport; + // Live SDK handles, for rendering only. Never serialized. + client: StreamVideoClient; + call: Call; +} + +export interface HarnessConfig { + callId: string; + /** Call type every participant joins, from `?call_type=`. Fixed per session. */ + callType: string; + codec: PreferredCodec; + keyMode: KeyMode; +} + +/** + * A participant seen in the call via the SFU roster (local or remote, including + * peers from other tabs/browsers). Used by the manual key-override UI. + */ +export interface RosterEntry { + userId: string; + name: string; + isLocal: boolean; +} + +export interface Snapshot { + config: HarnessConfig; + participants: HarnessParticipant[]; + roster: RosterEntry[]; + log: LogEntry[]; + globalError: string | null; + /** + * Encryption mode the backend resolved for this call, read back from the call + * settings. `undefined` until the first participant joins. The harness never + * requests a mode - this is purely whatever the call type is configured with + * server-side. + */ + resolvedEncryptionMode: EncryptionSettingsResponseModeEnum | undefined; + /** + * Whether the SFU reports E2EE as actually active for this call, from the join + * response. Unlike {@link Snapshot.resolvedEncryptionMode} - which is only what + * the call permits - this is the authoritative signal, so a mismatch between + * the two is exactly the bug this harness exists to catch. + */ + e2eeEnabled: boolean; +} diff --git a/sample-apps/react/e2ee-demo/src/harness/transformSupport.ts b/sample-apps/react/e2ee-demo/src/harness/transformSupport.ts new file mode 100644 index 0000000000..66b1e6581f --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/harness/transformSupport.ts @@ -0,0 +1,21 @@ +export interface TransformSupport { + /** Whether the legacy Insertable Streams (`createEncodedStreams`) API exists. */ + hasInsertableStreams: boolean; + /** Whether the standard `RTCRtpScriptTransform` API exists. */ + hasScriptTransform: boolean; +} + +/** + * Feature-detect which Encoded Transform APIs the current browser exposes. + * + * Which one the SDK actually attaches is its own business (Chrome prefers the + * legacy Insertable Streams path, everything else uses `RTCRtpScriptTransform`), + * and it deliberately isn't public API - so the harness reports raw capabilities + * rather than second-guessing the selection and drifting from it. + */ +export const detectTransformSupport = (): TransformSupport => ({ + hasInsertableStreams: + typeof RTCRtpSender !== 'undefined' && + 'createEncodedStreams' in RTCRtpSender.prototype, + hasScriptTransform: typeof RTCRtpScriptTransform !== 'undefined', +}); diff --git a/sample-apps/react/e2ee-demo/src/harness/url.ts b/sample-apps/react/e2ee-demo/src/harness/url.ts new file mode 100644 index 0000000000..aff125290b --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/harness/url.ts @@ -0,0 +1,32 @@ +import { CALL_TYPE, TOKEN_ENVIRONMENT } from '../config'; + +const CALL_ID_PARAM = 'call_id'; +const CALL_TYPE_PARAM = 'call_type'; +const ENVIRONMENT_PARAM = 'environment'; + +export const resolveCallId = (search: string): string => { + const fromUrl = new URLSearchParams(search).get(CALL_ID_PARAM); + return fromUrl || `e2ee-demo-${crypto.randomUUID().slice(0, 8)}`; +}; + +/** + * The call type, overridable with `?call_type=audio_room`. Not validated here - + * the backend rejects an unknown type on call creation. + */ +export const resolveCallType = (search: string): string => { + const fromUrl = new URLSearchParams(search).get(CALL_TYPE_PARAM); + return fromUrl || CALL_TYPE; +}; + +/** The token environment, overridable with `?environment=pronto-staging`. */ +export const resolveEnvironment = (search: string): string => { + const fromUrl = new URLSearchParams(search).get(ENVIRONMENT_PARAM); + return fromUrl || TOKEN_ENVIRONMENT; +}; + +/** Reflect the call id in the URL so the harness is bookmarkable and shareable. */ +export const writeUrl = (callId: string): void => { + const url = new URL(window.location.href); + url.searchParams.set(CALL_ID_PARAM, callId); + window.history.replaceState(null, '', url); +}; diff --git a/sample-apps/react/e2ee-demo/src/hooks/useHarness.ts b/sample-apps/react/e2ee-demo/src/hooks/useHarness.ts new file mode 100644 index 0000000000..71f9010866 --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/hooks/useHarness.ts @@ -0,0 +1,30 @@ +import { createContext, useContext, useRef, useSyncExternalStore } from 'react'; +import { E2EEHarness } from '../harness/E2EEHarness'; +import type { Snapshot } from '../harness/snapshot'; + +const HarnessContext = createContext(null); + +export const HarnessProvider = HarnessContext.Provider; + +/** Create one engine instance, stable for the page lifetime. */ +export const useCreateHarness = ( + callId: string, + callType: string, +): E2EEHarness => { + const ref = useRef(null); + if (!ref.current) ref.current = new E2EEHarness({ callId, callType }); + return ref.current; +}; + +export const useHarnessEngine = (): E2EEHarness => { + const engine = useContext(HarnessContext); + if (!engine) + throw new Error('useHarnessEngine must be used within HarnessProvider'); + return engine; +}; + +/** Subscribe to the engine snapshot. */ +export const useSnapshot = (): Snapshot => { + const engine = useHarnessEngine(); + return useSyncExternalStore(engine.subscribe, engine.getSnapshot); +}; diff --git a/sample-apps/react/e2ee-demo/src/main.tsx b/sample-apps/react/e2ee-demo/src/main.tsx new file mode 100644 index 0000000000..d9736adc9c --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/main.tsx @@ -0,0 +1,9 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/sample-apps/react/e2ee-demo/src/vite-env.d.ts b/sample-apps/react/e2ee-demo/src/vite-env.d.ts new file mode 100644 index 0000000000..58f8742d9d --- /dev/null +++ b/sample-apps/react/e2ee-demo/src/vite-env.d.ts @@ -0,0 +1,15 @@ +/// + +import type { Call } from '@stream-io/video-react-sdk'; + +declare global { + interface Window { + /** + * Live `Call` instances keyed by lowercased participant name + * (`window.calls.alice`), published by the harness for console debugging. + */ + calls?: Record; + /** The first participant's call - a shortcut for {@link Window.calls}. */ + call?: Call; + } +} diff --git a/sample-apps/react/e2ee-demo/tsconfig.json b/sample-apps/react/e2ee-demo/tsconfig.json new file mode 100644 index 0000000000..7c1b5bfa77 --- /dev/null +++ b/sample-apps/react/e2ee-demo/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@stream-io/typescript-config/app-web.json", + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/sample-apps/react/e2ee-demo/tsconfig.node.json b/sample-apps/react/e2ee-demo/tsconfig.node.json new file mode 100644 index 0000000000..a535f7d4d2 --- /dev/null +++ b/sample-apps/react/e2ee-demo/tsconfig.node.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/sample-apps/react/e2ee-demo/vercel.json b/sample-apps/react/e2ee-demo/vercel.json new file mode 100644 index 0000000000..408821b11a --- /dev/null +++ b/sample-apps/react/e2ee-demo/vercel.json @@ -0,0 +1,8 @@ +{ + "rewrites": [ + { + "source": "/(.*)", + "destination": "/" + } + ] +} diff --git a/sample-apps/react/e2ee-demo/vite.config.ts b/sample-apps/react/e2ee-demo/vite.config.ts new file mode 100644 index 0000000000..0466183af6 --- /dev/null +++ b/sample-apps/react/e2ee-demo/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], +}); diff --git a/sample-apps/react/react-dogfood/components/ActiveCall.tsx b/sample-apps/react/react-dogfood/components/ActiveCall.tsx index c40c59a8ad..61f60cccc8 100644 --- a/sample-apps/react/react-dogfood/components/ActiveCall.tsx +++ b/sample-apps/react/react-dogfood/components/ActiveCall.tsx @@ -66,6 +66,7 @@ import { useRemoteFilePublisher, } from './RemoteFilePublisher'; import { ModerationNotification } from './ModerationNotification'; +import { E2EEKeyNotification } from './E2EEKeyNotification'; export type ActiveCallProps = { chatClient?: StreamChat | null; @@ -237,6 +238,7 @@ export const ActiveCall = (props: ActiveCallProps) => {
+
{ const { useCallStatsReport } = useCallStateHooks(); @@ -74,6 +79,30 @@ const RecordingIndicator = () => { ); }; +const E2EEBadge = () => { + const { t } = useI18n(); + const isPronto = useIsProntoEnvironment(); + const { useE2eeEnabled } = useCallStateHooks(); + const e2eeEnabled = useE2eeEnabled(); + // Compact, always-visible lock chip (the sibling latency/participant-count + // indicators are hidden below the `sm` breakpoint, so a labelled badge would + // overflow the header on mobile). The description lives in the tooltip. + if (!isPronto || !e2eeEnabled) return null; + return ( + +
+ + + {t('Encrypted')} + +
+
+ ); +}; + const ParticipantCountIndicator = () => { const { useParticipants, useParticipantCount } = useCallStateHooks(); const participants = useParticipants(); @@ -137,6 +166,7 @@ export const ActiveCallHeader = ({
+ {(isRecordingInProgress || isRawRecordingInProgress || isIndividualRecordingInProgress) && } diff --git a/sample-apps/react/react-dogfood/components/CallScope.tsx b/sample-apps/react/react-dogfood/components/CallScope.tsx new file mode 100644 index 0000000000..991b9457cf --- /dev/null +++ b/sample-apps/react/react-dogfood/components/CallScope.tsx @@ -0,0 +1,93 @@ +import type { INoiseCancellation } from '@stream-io/audio-filters-web'; +import { + BackgroundFiltersProvider, + Call, + NoiseCancellationProvider, + StreamCall, + useCallStateHooks, +} from '@stream-io/video-react-sdk'; +import Head from 'next/head'; +import { ComponentProps, useEffect, useRef, useState } from 'react'; + +import { MeetingUI } from './MeetingUI'; +import { RingingCallNotification } from './Ringing/RingingCallNotification'; +import { TourProvider } from '../context/TourContext'; +import { getSegmentationModelUrl } from '../hooks'; + +const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ''; + +const HeadComponent = ({ callId }: { callId: string }) => { + const { useCallCustomData } = useCallStateHooks(); + const customData = useCallCustomData(); + + return ( + + Stream Calls: {customData.name || callId} + + + ); +}; + +/** + * The call-scoped subtree: the noise-cancellation instance and every provider + * that binds to a specific call live here, so the call page can swap the active + * call (e.g. when toggling E2EE) by simply passing a new `call` prop. The swap + * only reaches these providers once {@link useLobbyCall} has awaited the new + * call's `getOrCreate`, so it is ready and there is no noise-cancellation + * capability race. + */ +export const CallScope = ({ + call, + chatClient, + useLegacyFilters, + segmentationModel, +}: { + call: Call; + chatClient: ComponentProps['chatClient']; + useLegacyFilters: boolean; + segmentationModel: Parameters[0]; +}) => { + const [noiseCancellation, setNoiseCancellation] = + useState(); + const ncLoader = useRef>(undefined); + useEffect(() => { + const load = (ncLoader.current || Promise.resolve()) + .then(() => import('@stream-io/audio-filters-web')) + .then(({ NoiseCancellation }) => { + setNoiseCancellation(new NoiseCancellation()); + }); + return () => { + ncLoader.current = load.then(() => setNoiseCancellation(undefined)); + }; + }, []); + + return ( + + + + + + {noiseCancellation && ( + + + + + )} + + + + ); +}; diff --git a/sample-apps/react/react-dogfood/components/E2EEKeyNotification.tsx b/sample-apps/react/react-dogfood/components/E2EEKeyNotification.tsx new file mode 100644 index 0000000000..7b8d1b838b --- /dev/null +++ b/sample-apps/react/react-dogfood/components/E2EEKeyNotification.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from 'react'; +import { Notification, useI18n } from '@stream-io/video-react-sdk'; +import { useE2eeKeyStatus } from '../hooks/useE2eeKeyStatus'; +import { useLobbyE2EE } from '../context/LobbyE2EEContext'; + +/** + * Surfaces a shared-key mismatch on an encrypted call. + * + * Without this a wrong meeting key looks like a broken call rather than a wrong + * key: media arrives, fails its authentication tag and is dropped, so tiles stay + * black and audio silent with nothing said about why. + * + * When the failure looks local, the banner doubles as the fix: the key can be + * re-entered here and is pushed straight to the worker, so a mistyped key does + * not cost a rejoin. Dismissable, and re-armed once decryption recovers, so a + * later mismatch is surfaced again rather than nagging about this one. + */ +export const E2EEKeyNotification = () => { + const status = useE2eeKeyStatus(); + const e2ee = useLobbyE2EE(); + const { t } = useI18n(); + const [dismissed, setDismissed] = useState(false); + const [draftKey, setDraftKey] = useState(''); + + // Re-arm once the call recovers, so a later mismatch is surfaced again. + useEffect(() => { + if (status.kind === 'ok') { + setDismissed(false); + setDraftKey(''); + } + }, [status.kind]); + + if (status.kind === 'ok') return null; + + const message = + status.kind === 'local-key-mismatch' ? ( + + {t( + "Nobody's audio or video can be decrypted. Your meeting key is most likely wrong.", + )} + {e2ee && ( +
{ + event.preventDefault(); + const key = draftKey.trim(); + if (!key) return; + e2ee.updateEncryptionKey(key); + setDraftKey(''); + }} + > + setDraftKey(event.target.value)} + /> + +
+ )} +
+ ) : ( + `${t('Cannot decrypt participants:')} ${status.names.join(', ')}` + ); + + return ( + setDismissed(true)} + /> + ); +}; diff --git a/sample-apps/react/react-dogfood/components/Lobby.tsx b/sample-apps/react/react-dogfood/components/Lobby.tsx index 577c15f521..87978eeeaa 100644 --- a/sample-apps/react/react-dogfood/components/Lobby.tsx +++ b/sample-apps/react/react-dogfood/components/Lobby.tsx @@ -27,6 +27,7 @@ import { ToggleMicButton } from './ToggleMicButton'; import { ToggleCameraButton } from './ToggleCameraButton'; import { ToggleParticipantsPreviewButton } from './ToggleParticipantsPreview'; import { ToggleHiFiButton } from './ToggleHiFiButton'; +import { LobbyEncryption } from './LobbyEncryption'; import { useEdges } from '../hooks/useEdges'; import { DefaultAppHeader } from './DefaultAppHeader'; @@ -35,6 +36,8 @@ import { useIsDemoEnvironment, useIsProntoEnvironment, } from '../context/AppEnvironmentContext'; +import { useLobbyE2EE } from '../context/LobbyE2EEContext'; +import { isCallEncrypted } from '../lib/e2ee'; import { getRandomName } from '../lib/names'; import { ToggleNoiseCancellationButton } from './ToggleNoiseCancellationButton'; @@ -67,6 +70,10 @@ export const Lobby = ({ onJoin, mode = 'regular' }: LobbyProps) => { const currentUser = useConnectedUser(); const isProntoEnvironment = useIsProntoEnvironment(); const isDemoEnvironment = useIsDemoEnvironment(); + const e2ee = useLobbyE2EE(); + // An `auto-on` call requires E2EE of every participant, so the backend rejects + // a non-e2ee join: gate the Join button until a key is provided. + const needsEncryptionKey = isCallEncrypted(settings) && !e2ee?.encryptionKey; const [displayNameOverride, setDisplayNameOverride] = useState( isDemoEnvironment ? getRandomName() : null, ); @@ -259,8 +266,13 @@ export const Lobby = ({ onJoin, mode = 'regular' }: LobbyProps) => {
+ {isProntoEnvironment && mode !== 'anon' && } + {isProntoEnvironment && (
{mode === 'regular' && ( diff --git a/sample-apps/react/react-dogfood/components/LobbyEncryption.tsx b/sample-apps/react/react-dogfood/components/LobbyEncryption.tsx new file mode 100644 index 0000000000..b2ff328bfd --- /dev/null +++ b/sample-apps/react/react-dogfood/components/LobbyEncryption.tsx @@ -0,0 +1,238 @@ +import { useCallback, useRef, useState } from 'react'; +import { + Icon, + useCallStateHooks, + useConnectedUser, + useI18n, +} from '@stream-io/video-react-sdk'; +import clsx from 'clsx'; + +import { LockIcon } from './LockIcon'; +import { isCallEncrypted } from '../lib/e2ee'; +import { getRandomWords } from '../lib/names'; +import { useLobbyE2EE } from '../context/LobbyE2EEContext'; + +/** + * Lobby control that turns on end-to-end encryption for the call and manages the + * shared room key. + * + * Encryption is fixed at call creation, so toggling delegates to the call page + * (via {@link useLobbyE2EE}), which swaps the active call for a freshly created + * one in place - no navigation, no remount. The switch state is kept locally and + * optimistic so the key appears immediately; the shared key is client-side only, + * so editing it updates the current call without creating a new one. + * + * Rendered only in the `pronto` environment (gated by the caller). + */ +export const LobbyEncryption = () => { + const { t } = useI18n(); + const e2ee = useLobbyE2EE(); + const { useCallSettings, useCallCreatedBy } = useCallStateHooks(); + const settings = useCallSettings(); + const createdBy = useCallCreatedBy(); + const connectedUser = useConnectedUser(); + + // Only the call's creator gets the interactive toggle. A 2nd+ participant (a + // joiner) can't change a call's encryption (it's fixed at creation), so a + // toggle would only be confusing: + // - joiner on an encrypted call -> show a read-only "encrypted" banner + // (and let them enter the key if the link didn't carry it); + // - joiner on a plain call -> hide the control entirely (nothing to change). + // We only know creator vs joiner (`createdBy`) and encrypted vs plain + // (`settings`) once the call response arrives, so until both resolve we render + // nothing - a joiner never briefly sees the creator's toggle. This mirrors the + // lobby's video preview, which also waits on `settings`. + const resolved = !!settings && !!createdBy; + const isEncryptedCall = isCallEncrypted(settings); + const isJoiner = + !!createdBy && !!connectedUser && createdBy.id !== connectedUser.id; + const locked = isJoiner && isEncryptedCall; + const hideForJoiner = isJoiner && !isEncryptedCall; + const creatorName = createdBy?.name || createdBy?.id; + + const [enabled, setEnabled] = useState(!!e2ee?.encryptionKey); + const [encryptionKey, setEncryptionKey] = useState(e2ee?.encryptionKey ?? ''); + const [busy, setBusy] = useState(false); + const [copied, setCopied] = useState(false); + const copyResetRef = useRef>(undefined); + + // The control shows "on" whenever the call is actually encrypted - even for a + // joiner who opened a link without the key. `enabled` is the local (creator) + // toggle intent; a locked joiner never toggles, so it stays at its mount value. + const isOn = enabled || isEncryptedCall; + // A locked joiner who did not receive the key must type it in to decrypt; one + // who already has it should not be able to edit (and break) a working key. + const needsKey = locked && !enabled; + const keyReadOnly = locked && enabled; + + const onToggle = useCallback(async () => { + if (!e2ee || busy || locked) return; + if (!enabled) { + const key = encryptionKey || getRandomWords(3); + setEncryptionKey(key); + setEnabled(true); // optimistic: reveal the key right away + try { + setBusy(true); + await e2ee.enableEncryption(key); + } catch (err) { + console.error('Failed to enable encryption', err); + setEnabled(false); + } finally { + setBusy(false); + } + } else { + setEnabled(false); + try { + setBusy(true); + await e2ee.disableEncryption(); + } catch (err) { + console.error('Failed to disable encryption', err); + setEnabled(true); + } finally { + setBusy(false); + } + } + }, [e2ee, busy, locked, enabled, encryptionKey]); + + const onKeyChange = useCallback( + (value: string) => { + setEncryptionKey(value); + if (isOn) e2ee?.updateEncryptionKey(value); + }, + [e2ee, isOn], + ); + + const onRefresh = useCallback(() => { + // The key is a client-side shared secret, so a new one updates the current + // call (and URL) without creating a new call. + const key = getRandomWords(3); + setEncryptionKey(key); + e2ee?.updateEncryptionKey(key); + }, [e2ee]); + + const onCopyLink = useCallback(() => { + if (typeof window === 'undefined') return; + navigator.clipboard + .writeText(window.location.href) + .then(() => { + setCopied(true); + clearTimeout(copyResetRef.current); + copyResetRef.current = setTimeout(() => setCopied(false), 2000); + }) + .catch((err) => console.error('Failed to copy invite link', err)); + }, []); + + if (!e2ee || !resolved || hideForJoiner) return null; + + return ( +
+ {locked ? ( + // Joiner on an encrypted call: an informational banner, not a toggle. +
+ + + + {t('End-to-end encryption')} + + + {needsKey + ? t('Enter the shared key to join') + : creatorName + ? `${t('Enabled by')} ${creatorName}` + : t('This call is encrypted')} + + +
+ ) : ( + + )} + +
+
+
+
+ {t('Shared key')} +
+
+
+ onKeyChange(e.currentTarget.value)} + /> + {!locked && ( + + )} +
+ +
+

+ {needsKey + ? t( + 'Ask the call creator for the shared key, then enter it here.', + ) + : t( + 'Anyone with this key (or the invite link that contains it) can join the call. Share it only with people you trust.', + )} +

+
+
+
+
+ ); +}; diff --git a/sample-apps/react/react-dogfood/components/LockIcon.tsx b/sample-apps/react/react-dogfood/components/LockIcon.tsx new file mode 100644 index 0000000000..6e06d0b7f3 --- /dev/null +++ b/sample-apps/react/react-dogfood/components/LockIcon.tsx @@ -0,0 +1,26 @@ +/** + * A small, self-contained padlock glyph used for the E2EE affordances (lobby + * toggle + active-call header badge). The SDK icon set has no lock, and this + * avoids basePath handling for a public SVG asset. Uses `currentColor` so it + * inherits the surrounding text color in both light and dark themes. + */ +export const LockIcon = ({ className }: { className?: string }) => ( + +); diff --git a/sample-apps/react/react-dogfood/components/MeetingUI.tsx b/sample-apps/react/react-dogfood/components/MeetingUI.tsx index 9c472a0781..0b39fb158c 100644 --- a/sample-apps/react/react-dogfood/components/MeetingUI.tsx +++ b/sample-apps/react/react-dogfood/components/MeetingUI.tsx @@ -13,7 +13,11 @@ import { useRouter } from 'next/router'; import { JSX, useCallback, useEffect, useState } from 'react'; import { StreamChat } from 'stream-chat'; -import { useIsRestrictedEnvironment } from '../context/AppEnvironmentContext'; +import { + useIsE2EEEnvironment, + useIsRestrictedEnvironment, +} from '../context/AppEnvironmentContext'; +import { useLobbyE2EE } from '../context/LobbyE2EEContext'; import { useKeyboardShortcuts, usePersistedVideoFilter, @@ -57,6 +61,8 @@ export const MeetingUI = ({ chatClient, mode }: MeetingUIProps) => { const callState = useCallCallingState(); useModeration(); const isRestricted = useIsRestrictedEnvironment(); + const allowEncryption = useIsE2EEEnvironment(); + const e2ee = useLobbyE2EE(); const [remoteFilePublisherAPI, setRemoteFilePublisherAPI] = useState(); @@ -65,10 +71,11 @@ export const MeetingUI = ({ chatClient, mode }: MeetingUIProps) => { if (!options.fastJoin) setShow('loading'); if (!call) throw new Error('No active call found'); try { - const { videoFile, videoFileLeaveCallOnEnd } = applyQueryConfigParams( - call, - router.query, - ); + const { videoFile, videoFileLeaveCallOnEnd } = + await applyQueryConfigParams(call, router.query, { + allowEncryption, + encryptionKey: e2ee?.encryptionKey, + }); if (call.state.callingState !== CallingState.JOINED) { if (typeof options.displayName === 'string') { const name = options.displayName || getRandomName(); @@ -95,7 +102,7 @@ export const MeetingUI = ({ chatClient, mode }: MeetingUIProps) => { setShow('error-join'); } }, - [call, router, chatClient, isRestricted], + [call, router, chatClient, isRestricted, allowEncryption, e2ee], ); const onLeave = useCallback( diff --git a/sample-apps/react/react-dogfood/context/AppEnvironmentContext.tsx b/sample-apps/react/react-dogfood/context/AppEnvironmentContext.tsx index b5aabc2769..32752c1896 100644 --- a/sample-apps/react/react-dogfood/context/AppEnvironmentContext.tsx +++ b/sample-apps/react/react-dogfood/context/AppEnvironmentContext.tsx @@ -37,6 +37,21 @@ export const useAppEnvironment = (): AppEnvironment => { */ export const useIsProntoEnvironment = () => useAppEnvironment() === 'pronto'; +/** + * Environments where end-to-end encryption is available. `pronto-staging` is + * included alongside `pronto` so encrypted calls (and the Slack `--e2ee` / + * `--private` links) work on staging too. + */ +export const isE2EEEnvironment = (env: AppEnvironment): boolean => + env === 'pronto' || env === 'pronto-staging'; + +/** + * Returns true when end-to-end encryption is available in the current + * environment. See {@link isE2EEEnvironment}. + */ +export const useIsE2EEEnvironment = () => + isE2EEEnvironment(useAppEnvironment()); + /** * Returns true if the current app environment is 'demo'. */ diff --git a/sample-apps/react/react-dogfood/context/LobbyE2EEContext.tsx b/sample-apps/react/react-dogfood/context/LobbyE2EEContext.tsx new file mode 100644 index 0000000000..ace2dd703d --- /dev/null +++ b/sample-apps/react/react-dogfood/context/LobbyE2EEContext.tsx @@ -0,0 +1,33 @@ +import { createContext, useContext } from 'react'; + +/** + * Lobby-facing controls for toggling end-to-end encryption. + * + * A call's encryption setting is fixed at creation, so enabling/disabling + * swaps the active call for a freshly created one (of the same type) in place - + * no navigation, no remount - and rewrites the URL via the History API so the + * invite link stays shareable. The shared key is client-side only, so editing it + * does not create a new call. Provided by the call page; consumed by the lobby + * control and the join flow. + */ +export type LobbyE2EEContextValue = { + /** The current shared key, or undefined when E2EE is off. */ + encryptionKey: string | undefined; + /** Swap in a fresh encrypted call and publish the key. */ + enableEncryption: (key: string) => Promise; + /** Swap in a fresh unencrypted call. */ + disableEncryption: () => Promise; + /** Change the shared key on the current (already encrypted) call. */ + updateEncryptionKey: (key: string) => void; +}; + +export const LobbyE2EEContext = createContext( + null, +); + +/** + * Returns the lobby E2EE controls, or `null` outside a provider (e.g. non-pronto + * environments where E2EE is not wired). + */ +export const useLobbyE2EE = (): LobbyE2EEContextValue | null => + useContext(LobbyE2EEContext); diff --git a/sample-apps/react/react-dogfood/hooks/index.ts b/sample-apps/react/react-dogfood/hooks/index.ts index 68037c797d..cc05051f81 100644 --- a/sample-apps/react/react-dogfood/hooks/index.ts +++ b/sample-apps/react/react-dogfood/hooks/index.ts @@ -5,3 +5,4 @@ export * from './usePersistedVideoFilter'; export * from './useWakeLock'; export * from './useBreakpoints'; export * from './useLayoutSwitcher'; +export * from './useLobbyCall'; diff --git a/sample-apps/react/react-dogfood/hooks/useE2eeKeyStatus.ts b/sample-apps/react/react-dogfood/hooks/useE2eeKeyStatus.ts new file mode 100644 index 0000000000..0fa5e7f954 --- /dev/null +++ b/sample-apps/react/react-dogfood/hooks/useE2eeKeyStatus.ts @@ -0,0 +1,108 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + EncryptionManager, + useCall, + useCallStateHooks, +} from '@stream-io/video-react-sdk'; + +/** + * What the local peer can conclude about key agreement from its own decryption + * failures. + * + * - `ok`: nothing is failing (or there is nothing to judge from yet). + * - `local-key-mismatch`: every publishing peer fails to decrypt. With a shared + * key that means *this* peer holds the wrong one - a peer with the right key + * would still decrypt the majority. + * - `peer-key-mismatch`: only some peers fail, so their keys differ from ours. + */ +export type E2EEKeyStatus = + | { kind: 'ok' } + | { kind: 'local-key-mismatch' } + | { kind: 'peer-key-mismatch'; names: string[] }; + +/** + * Detect a shared-key mismatch from the worker's decryption signals. + * + * There is no direct "your key is wrong" event, and there cannot be: a wrong key + * still encrypts happily, so the local encoder never complains and nothing tells + * us that others cannot decrypt *us*. The only evidence is inbound, and it is + * per remote peer - so the verdict comes from the breadth of the failures rather + * than from any single event. + * + * `e2ee.broken` is the trigger, not `e2ee.decryption_failed`: the latter fires + * once a second for any transient mismatch, including the brief window while a + * key change propagates, and would cry wolf. `broken` means the track has failed + * past the SDK's tolerance and is not recovering on its own. + * + * Blind spots worth knowing: alone in the call, or with every peer muted and + * camera-off, a wrong key is undetectable. And if two peers share the same wrong + * key they decrypt each other, so neither sees a full sweep of failures. + */ +export const useE2eeKeyStatus = (): E2EEKeyStatus => { + const call = useCall(); + const { useRemoteParticipants } = useCallStateHooks(); + const remoteParticipants = useRemoteParticipants(); + // Keyed per (userId, trackType) because the SDK counts failures per track: a + // peer publishing audio and video reports them independently, and their video + // can recover while audio is still broken. + const [brokenTracks, setBrokenTracks] = useState>( + () => new Set(), + ); + + useEffect(() => { + // Only the built-in manager emits these events; a custom E2EEManager + // implementation satisfies the RTC contract without them. + const manager = call?.e2eeManager; + if (!(manager instanceof EncryptionManager)) return; + + const trackKey = (userId: string, trackType?: string) => + `${userId}/${trackType ?? 'unknown'}`; + + const unsubscribes = [ + manager.on('e2ee.broken', ({ userId, trackType }) => { + setBrokenTracks((prev) => { + const next = new Set(prev); + next.add(trackKey(userId, trackType)); + return next; + }); + }), + manager.on('e2ee.decryption_resumed', ({ userId, trackType }) => { + setBrokenTracks((prev) => { + const key = trackKey(userId, trackType); + if (!prev.has(key)) return prev; + const next = new Set(prev); + next.delete(key); + return next; + }); + }), + ]; + + return () => unsubscribes.forEach((unsubscribe) => unsubscribe()); + }, [call]); + + return useMemo(() => { + if (brokenTracks.size === 0) return { kind: 'ok' }; + const brokenUserIds = new Set( + [...brokenTracks].map((key) => key.slice(0, key.lastIndexOf('/'))), + ); + // Judge only against peers that are actually sending something: a muted, + // camera-off peer produces no frames and so no evidence either way. Peers + // who have left keep stale entries in the set, which is harmless - they are + // simply not part of this comparison. + const publishing = remoteParticipants.filter( + (participant) => participant.publishedTracks.length > 0, + ); + const failing = publishing.filter((participant) => + brokenUserIds.has(participant.userId), + ); + if (failing.length === 0) return { kind: 'ok' }; + if (failing.length === publishing.length) + return { kind: 'local-key-mismatch' }; + return { + kind: 'peer-key-mismatch', + names: failing.map( + (participant) => participant.name || participant.userId, + ), + }; + }, [brokenTracks, remoteParticipants]); +}; diff --git a/sample-apps/react/react-dogfood/hooks/useLobbyCall.ts b/sample-apps/react/react-dogfood/hooks/useLobbyCall.ts new file mode 100644 index 0000000000..352bbce13d --- /dev/null +++ b/sample-apps/react/react-dogfood/hooks/useLobbyCall.ts @@ -0,0 +1,166 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Call, + CallingState, + CallRequest, + EncryptionManager, + StreamVideoClient, +} from '@stream-io/video-react-sdk'; + +import { + deriveKeyFromPassphrase, + ENCRYPTION_OVERRIDE, + SHARED_KEY_INDEX, +} from '../lib/e2ee'; +import { meetingId } from '../lib/idGenerators'; +import type { LobbyE2EEContextValue } from '../context/LobbyE2EEContext'; + +export type UseLobbyCallParams = { + client: StreamVideoClient | undefined; + callId: string; + callType: string; + userId: string | undefined; + /** Whether the initial call should be created end-to-end encrypted. */ + e2eeEnabled: boolean; + /** Shared key present in the URL on load (drives the initial E2EE state). */ + initialEncryptionKey: string | undefined; +}; + +export type UseLobbyCallResult = { + call: Call | undefined; + callError: string | null; + e2eeControls: LobbyE2EEContextValue; +}; + +/** + * Owns the join page's active call and the E2EE toggle controls. + * + * A call's encryption setting is fixed at creation, so toggling E2EE + * swaps the active call for a freshly created one (of the same type) *in place* + * - no navigation, no remount - and rewrites the URL via the History API so the + * invite link stays shareable. `getOrCreate` is awaited before the swap so the + * new call is fully ready (no capability race on noise cancellation). The shared + * key is client-side only, so editing it updates the current call without + * creating a new one. + */ +export const useLobbyCall = ({ + client, + callId, + callType, + userId, + e2eeEnabled, + initialEncryptionKey, +}: UseLobbyCallParams): UseLobbyCallResult => { + const [call, setCall] = useState(); + const [callError, setCallError] = useState(null); + const [encryptionKey, setEncryptionKey] = useState( + e2eeEnabled ? initialEncryptionKey : undefined, + ); + const activeCallRef = useRef(undefined); + + // Point the provider tree at `next`, leaving the previous (never-joined) call + // behind. Swapping the call object in place avoids a navigation/remount. + const swapCall = useCallback((next: Call) => { + const prev = activeCallRef.current; + if ( + prev && + prev !== next && + prev.state.callingState !== CallingState.LEFT + ) { + prev.leave().catch((e) => console.error('Failed to leave call', e)); + } + activeCallRef.current = next; + window.call = next; + setCall(next); + }, []); + + useEffect(() => { + if (!client) return; + const initial = client.call(callType, callId, { reuseInstance: true }); + swapCall(initial); + // "restricted" is a special call type that only allows the `call_member` + // role to join the call. + const data: CallRequest = + callType === 'restricted' + ? { members: [{ user_id: userId || '!anon', role: 'call_member' }] } + : {}; + if (e2eeEnabled) { + data.settings_override = { encryption: ENCRYPTION_OVERRIDE }; + } + initial.getOrCreate({ data }).catch((err) => { + console.error(`Failed to get or create call`, err); + setCallError( + err instanceof Error ? err.message : 'Could not get or create call', + ); + }); + + return () => { + const active = activeCallRef.current; + if (active && active.state.callingState !== CallingState.LEFT) { + active.leave().catch((e) => console.error('Failed to leave call', e)); + } + activeCallRef.current = undefined; + window.call = undefined; + setCall(undefined); + }; + }, [callId, callType, client, e2eeEnabled, userId, swapCall]); + + // Rewrite the URL (call id + shared key) without a Next.js navigation, so the + // invite link stays shareable and the router's leave-on-route-change handler + // does not fire. + const replaceUrl = useCallback((id: string, key: string | undefined) => { + const url = new URL(window.location.href); + url.pathname = url.pathname.replace(/[^/]+$/, id); + if (key) url.searchParams.set('encryption_key', key); + else url.searchParams.delete('encryption_key'); + window.history.replaceState(window.history.state, '', url.toString()); + }, []); + + // Encryption is fixed at creation, so toggling swaps in a freshly created call + // of the same type. getOrCreate is awaited so the call is fully ready before it + // is handed to the providers (no capability race on noise cancellation). + const switchEncryption = useCallback( + async (enabled: boolean, key?: string) => { + if (!client) return; + const next = client.call(callType, meetingId()); + await next.getOrCreate({ + data: enabled + ? { settings_override: { encryption: ENCRYPTION_OVERRIDE } } + : {}, + }); + swapCall(next); + setEncryptionKey(enabled ? key : undefined); + replaceUrl(next.id, enabled ? key : undefined); + }, + [client, callType, swapCall, replaceUrl], + ); + + const e2eeControls = useMemo( + () => ({ + encryptionKey, + enableEncryption: (key: string) => switchEncryption(true, key), + disableEncryption: () => switchEncryption(false), + updateEncryptionKey: (key: string) => { + setEncryptionKey(key); + const activeCall = activeCallRef.current; + if (activeCall) replaceUrl(activeCall.id, key); + // Before joining there is no manager yet and the key is picked up from + // state at join time. Once joined, the worker already holds the old key + // and has to be told about the new one, otherwise correcting a mistyped + // key would mean rejoining the call. Re-using SHARED_KEY_INDEX replaces + // the key in place; the worker clears its failure count for that index on + // the first frame that decrypts and reports `e2ee.decryption_resumed`. + const manager = activeCall?.e2eeManager; + if (!(manager instanceof EncryptionManager)) return; + deriveKeyFromPassphrase(key) + .then((rawKey) => manager.setSharedKey(SHARED_KEY_INDEX, rawKey)) + .catch((err) => + console.error('Failed to apply the new encryption key', err), + ); + }, + }), + [encryptionKey, switchEncryption, replaceUrl], + ); + + return { call, callError, e2eeControls }; +}; diff --git a/sample-apps/react/react-dogfood/lib/e2ee.ts b/sample-apps/react/react-dogfood/lib/e2ee.ts new file mode 100644 index 0000000000..c6746387ba --- /dev/null +++ b/sample-apps/react/react-dogfood/lib/e2ee.ts @@ -0,0 +1,73 @@ +import { + EncryptionSettingsRequestModeEnum, + EncryptionSettingsResponseModeEnum, + type CallSettingsResponse, + type EncryptionSettingsRequest, +} from '@stream-io/video-react-sdk'; + +/** + * Settings override that creates a call as end-to-end encrypted, to match the + * `e2ee: true` flag the SDK sends on join whenever a manager is attached. + * Without it the backend rejects the join. + * + * `auto-on` (E2EE required) rather than `available` (E2EE merely permitted): + * under `available` some participants could publish unencrypted, so the lock + * badge would be claiming more than the call guarantees, and gating the Join + * button on a key would be gating something the call does not actually require. + */ +export const ENCRYPTION_OVERRIDE: EncryptionSettingsRequest = { + mode: EncryptionSettingsRequestModeEnum.AUTO_ON, +}; + +/** + * The single key index this app uses for its shared key. + * + * Everyone derives the key from the same passphrase, so everyone has to agree on + * the index too: a frame carries the index it was encrypted with, and a receiver + * that looked elsewhere would fail every decrypt. Re-keying reuses this index + * rather than bumping it - a bump would only be visible to peers told about it. + */ +export const SHARED_KEY_INDEX = 0; + +/** + * Derive the 128-bit AES key every participant shares, from the passphrase in + * the invite link. The salt and iteration count are part of the wire contract + * between participants: change either and peers on the old build derive a + * different key from the same passphrase and nothing decrypts. + */ +export const deriveKeyFromPassphrase = async ( + passphrase: string, +): Promise => { + const enc = new TextEncoder(); + const baseKey = await crypto.subtle.importKey( + 'raw', + enc.encode(passphrase), + 'PBKDF2', + false, + ['deriveBits'], + ); + return crypto.subtle.deriveBits( + { + name: 'PBKDF2', + salt: enc.encode('stream-e2ee'), + iterations: 100_000, + hash: 'SHA-256', + }, + baseKey, + 128, + ); +}; + +/** + * Whether the call these settings describe is end-to-end encrypted. + * + * Prefer the `useE2eeEnabled()` hook, which reads the SFU's join response and + * so reports whether E2EE is actually in effect. This settings-based check + * exists for the lobby, which runs before the call is joined - at that point the + * SFU has said nothing and the hook is still `false`, so the requested mode from + * the coordinator is the only thing to go on. + */ +export const isCallEncrypted = ( + settings: CallSettingsResponse | undefined, +): boolean => + settings?.encryption?.mode === EncryptionSettingsResponseModeEnum.AUTO_ON; diff --git a/sample-apps/react/react-dogfood/lib/names.ts b/sample-apps/react/react-dogfood/lib/names.ts index 937699b9f2..b22f64c606 100644 --- a/sample-apps/react/react-dogfood/lib/names.ts +++ b/sample-apps/react/react-dogfood/lib/names.ts @@ -4,6 +4,21 @@ export function getRandomName() { return `${capitalize(pick(words.predicates))} ${capitalize(pick(words.objects))}`; } +/** + * Builds a memorable, easy-to-share passphrase out of `count` distinct random + * words drawn from the same word pools as {@link getRandomName}, joined by `-` + * (e.g. `brave-otter-canyon`). Used to seed the E2EE shared key in the lobby. + */ +export function getRandomWords(count = 3): string { + const pool = [...words.predicates, ...words.objects]; + const picked: string[] = []; + while (picked.length < count && picked.length < pool.length) { + const word = pool[Math.floor(Math.random() * pool.length)].toLowerCase(); + if (!picked.includes(word)) picked.push(word); + } + return picked.join('-'); +} + export function getUserIdFromEmail(email: string) { const name = email.split('@').at(0); diff --git a/sample-apps/react/react-dogfood/lib/queryConfigParams.ts b/sample-apps/react/react-dogfood/lib/queryConfigParams.ts index 8d250c172f..20c6aded37 100644 --- a/sample-apps/react/react-dogfood/lib/queryConfigParams.ts +++ b/sample-apps/react/react-dogfood/lib/queryConfigParams.ts @@ -1,5 +1,10 @@ import { NextRouter } from 'next/router'; -import { Call, PreferredCodec } from '@stream-io/video-react-sdk'; +import { + Call, + PreferredCodec, + EncryptionManager, +} from '@stream-io/video-react-sdk'; +import { deriveKeyFromPassphrase, SHARED_KEY_INDEX } from './e2ee'; export const getQueryConfigParams = (query: NextRouter['query']) => { return { @@ -15,12 +20,16 @@ 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, }; }; -export const applyQueryConfigParams = ( +export const applyQueryConfigParams = async ( call: Call, query: NextRouter['query'], + // The shared key is owned by the lobby (it can change without a URL change), + // so it is passed in explicitly rather than read from `query`. + options: { allowEncryption?: boolean; encryptionKey?: string } = {}, ) => { const config = getQueryConfigParams(query); const { @@ -34,6 +43,7 @@ export const applyQueryConfigParams = ( cameraOverride, microphoneOverride, } = config; + const { allowEncryption = false, encryptionKey } = options; if (cameraOverride != null) { if (cameraOverride === 'false') { @@ -63,6 +73,21 @@ export const applyQueryConfigParams = ( ? parseInt(bitrateOverride, 10) : undefined; + // E2EE must be fully initialized before join() so the RTCPeerConnection can + // be configured for E2EE (the legacy Insertable Streams path needs + // encodedInsertableStreams). + if ( + allowEncryption && + encryptionKey && + call.currentUserId && + EncryptionManager.isSupported() + ) { + const rawKey = await deriveKeyFromPassphrase(encryptionKey); + const e2ee = await EncryptionManager.create(call.currentUserId); + e2ee.setSharedKey(SHARED_KEY_INDEX, rawKey); + call.setE2EEManager(e2ee); + } + call.updatePublishOptions({ dangerouslyForceCodec: forceCodec, preferredCodec: videoCodecOverride, diff --git a/sample-apps/react/react-dogfood/pages/api/call/create.ts b/sample-apps/react/react-dogfood/pages/api/call/create.ts index fc33b2ad19..5fa9240413 100644 --- a/sample-apps/react/react-dogfood/pages/api/call/create.ts +++ b/sample-apps/react/react-dogfood/pages/api/call/create.ts @@ -1,6 +1,15 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import yargs from 'yargs'; import { meetingId } from '../../../lib/idGenerators'; +import { getRandomWords } from '../../../lib/names'; + +/** + * A yargs-parsed flag counts as "set" when present, unless explicitly negated + * (`--no-e2ee` -> false, or `--e2ee=false`). Bare `--e2ee` / `--private` parse + * to `true`, which URLSearchParams stringifies to `"true"`. + */ +const isFlagSet = (value: string | null): boolean => + value !== null && value !== 'false' && value !== '0'; const createCallSlackHookAPI = async ( req: NextApiRequest, @@ -36,6 +45,15 @@ const createCallSlackHookAPI = async ( queryParams.delete('staging'); } + const withE2ee = + isFlagSet(queryParams.get('e2ee')) || + isFlagSet(queryParams.get('private')); + queryParams.delete('e2ee'); + queryParams.delete('private'); + if (withE2ee) { + queryParams.set('encryption_key', getRandomWords(3)); + } + const protocol = req.headers['x-forwarded-proto'] ? 'https://' : 'http://'; const host = req.headers.host === 'stream-calls-dogfood.vercel.app' @@ -57,7 +75,9 @@ const createCallSlackHookAPI = async ( type: 'section', text: { type: 'mrkdwn', - text: `${initiator} has invited you for a new Stream Call \n ${joinUrl}`, + text: `${initiator} has invited you for a new Stream Call${ + withE2ee ? ' :lock: _(end-to-end encrypted)_' : '' + } \n ${joinUrl}`, }, accessory: { type: 'button', diff --git a/sample-apps/react/react-dogfood/pages/bare/join/[callId].tsx b/sample-apps/react/react-dogfood/pages/bare/join/[callId].tsx index 713128ba15..90eff5a130 100644 --- a/sample-apps/react/react-dogfood/pages/bare/join/[callId].tsx +++ b/sample-apps/react/react-dogfood/pages/bare/join/[callId].tsx @@ -62,7 +62,9 @@ export default function BareCallRoom(props: ServerSideCredentialsProps) { const _call = client.call(callType, callId); setCall(_call); - applyQueryConfigParams(_call, router.query); + applyQueryConfigParams(_call, router.query).catch((e) => + console.error('Failed to apply query config params', e), + ); window.call = _call; return () => { diff --git a/sample-apps/react/react-dogfood/pages/join/[callId].tsx b/sample-apps/react/react-dogfood/pages/join/[callId].tsx index 33e1bb0eea..f4f443f839 100644 --- a/sample-apps/react/react-dogfood/pages/join/[callId].tsx +++ b/sample-apps/react/react-dogfood/pages/join/[callId].tsx @@ -1,48 +1,27 @@ -import type { INoiseCancellation } from '@stream-io/audio-filters-web'; import { - BackgroundFiltersProvider, - Call, - CallingState, - CallRequest, - NoiseCancellationProvider, - StreamCall, StreamVideo, StreamVideoClient, - useCallStateHooks, User, } from '@stream-io/video-react-sdk'; -import Head from 'next/head'; import { useRouter } from 'next/router'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { TranslationLanguages } from 'stream-chat'; -import { MeetingUI } from '../../components'; -import { useAppEnvironment } from '../../context/AppEnvironmentContext'; + +import { CallScope } from '../../components/CallScope'; +import { LobbyE2EEContext } from '../../context/LobbyE2EEContext'; +import { + isE2EEEnvironment, + useAppEnvironment, +} from '../../context/AppEnvironmentContext'; import { useSettings } from '../../context/SettingsContext'; -import { getSegmentationModelUrl } from '../../hooks'; -import { TourProvider } from '../../context/TourContext'; import { getClient } from '../../helpers/client'; -import { useCreateStreamChatClient } from '../../hooks'; +import { useCreateStreamChatClient, useLobbyCall } from '../../hooks'; import { useGleap } from '../../hooks/useGleap'; import { getServerSideCredentialsPropsWithOptions, ServerSideCredentialsProps, } from '../../lib/getServerSideCredentialsProps'; import appTranslations from '../../translations'; -import { RingingCallNotification } from '../../components/Ringing/RingingCallNotification'; - -const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ''; - -const HeadComponent = ({ callId }: { callId: string }) => { - const { useCallCustomData } = useCallStateHooks(); - const customData = useCallCustomData(); - - return ( - - Stream Calls: {customData.name || callId} - - - ); -}; const CallRoom = (props: ServerSideCredentialsProps) => { const router = useRouter(); @@ -61,6 +40,15 @@ const CallRoom = (props: ServerSideCredentialsProps) => { const environment = useAppEnvironment(); + // E2EE is limited to the `pronto` / `pronto-staging` environments for now. + // When a shared key is present in the URL, the initial call must be *created* + // end-to-end encrypted - otherwise the backend rejects the (e2ee: true) join. + // See lib/queryConfigParams. + const initialEncryptionKey = router.query['encryption_key'] as + | string + | undefined; + const e2eeEnabled = isE2EEEnvironment(environment) && !!initialEncryptionKey; + const [client, setClient] = useState(); useEffect(() => { const _client = getClient( @@ -86,41 +74,14 @@ const CallRoom = (props: ServerSideCredentialsProps) => { }, }); - const [call, setCall] = useState(); - const [callError, setCallError] = useState(null); - useEffect(() => { - if (!client) return; - const _call = client.call(callType, callId, { reuseInstance: true }); - setCall(_call); - - window.call = _call; - - return () => { - if (_call.state.callingState !== CallingState.LEFT) { - _call.leave().catch((e) => console.error('Failed to leave call', e)); - setCall(undefined); - - window.call = undefined; - } - }; - }, [callId, callType, client]); - - useEffect(() => { - if (!call) return; - // "restricted" is a special call type that only allows - // `call_member` role to join the call - const data: CallRequest = - callType === 'restricted' - ? { members: [{ user_id: user.id || '!anon', role: 'call_member' }] } - : {}; - - call.getOrCreate({ data }).catch((err) => { - console.error(`Failed to get or create call`, err); - setCallError( - err instanceof Error ? err.message : 'Could not get or create call', - ); - }); - }, [call, callType, user.id]); + const { call, callError, e2eeControls } = useLobbyCall({ + client, + callId, + callType, + userId: user.id, + e2eeEnabled, + initialEncryptionKey, + }); // apple-itunes-app meta-tag is used to open the app from the browser // we need to update the app-argument to the current URL so that the app @@ -140,22 +101,6 @@ const CallRoom = (props: ServerSideCredentialsProps) => { }, []); useGleap(gleapApiKey, client, call, user); - const [noiseCancellation, setNoiseCancellation] = - useState(); - const ncLoader = useRef>(undefined); - useEffect(() => { - const load = (ncLoader.current || Promise.resolve()) - .then(() => import('@stream-io/audio-filters-web')) - .then(({ NoiseCancellation }) => { - // const modelsPath = `${basePath}/krispai/models`; - // const nc = new NoiseCancellation({ basePath: modelsPath }); - const nc = new NoiseCancellation(); - setNoiseCancellation(nc); - }); - return () => { - ncLoader.current = load.then(() => setNoiseCancellation(undefined)); - }; - }, []); if (!client || !call) return null; @@ -182,44 +127,21 @@ const CallRoom = (props: ServerSideCredentialsProps) => { } return ( - <> - - - - - - - {noiseCancellation && ( - - - - - )} - - - - - + + + + + ); }; diff --git a/sample-apps/react/react-dogfood/style/CallHeader/CallHeader-layout.scss b/sample-apps/react/react-dogfood/style/CallHeader/CallHeader-layout.scss index a8ff0213db..15f3c23163 100644 --- a/sample-apps/react/react-dogfood/style/CallHeader/CallHeader-layout.scss +++ b/sample-apps/react/react-dogfood/style/CallHeader/CallHeader-layout.scss @@ -69,6 +69,39 @@ gap: 0.5rem; } +// Prominent, always-visible E2EE indicator: a solid green pill with dark, +// high-contrast text and a soft glow. The icon is always shown; the "Encrypted" +// label is revealed above the `sm` breakpoint (kept out on small screens so the +// tight header row - where latency/participant-count are also hidden - never +// overflows). Full description lives in the tooltip. +.rd__call-header__e2ee-badge { + display: inline-flex; + justify-content: center; + align-items: center; + gap: var(--str-video__spacing-xs); + height: 28px; + min-width: 28px; + padding: 0 var(--str-video__spacing-sm); + border-radius: var(--str-video__border-radius-md); + background-color: var(--str-video__alert-success); + color: var(--str-video__base-color7); + font-size: var(--str-video__font-size-xs); + font-weight: 600; + letter-spacing: 0.02ex; + box-shadow: 0 0 14px -2px rgba(0, 226, 161, 0.7); + + &-icon { + font-size: 16px; + flex-shrink: 0; + } + + &-label { + display: none; + white-space: nowrap; + text-transform: uppercase; + } +} + .rd__header__recording-indicator { display: flex; justify-content: center; @@ -334,6 +367,10 @@ display: flex; } + .rd__call-header__e2ee-badge-label { + display: inline; + } + .rd__user-session { &__user { display: flex; diff --git a/sample-apps/react/react-dogfood/style/E2EEKeyNotification.scss b/sample-apps/react/react-dogfood/style/E2EEKeyNotification.scss new file mode 100644 index 0000000000..33299ced14 --- /dev/null +++ b/sample-apps/react/react-dogfood/style/E2EEKeyNotification.scss @@ -0,0 +1,40 @@ +// Inline key-correction form inside the E2EE mismatch notification. The +// notification is a narrow floating panel, so the form wraps under the message +// rather than sitting beside it. +.rd__e2ee-key-notification { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.rd__e2ee-key-notification__form { + display: flex; + gap: 0.5rem; + align-items: center; + + input { + flex: 1; + min-width: 0; + padding: 0.25rem 0.5rem; + border: 1px solid var(--str-video__base-color5, #4c525c); + border-radius: var(--str-video__border-radius-xs, 0.25rem); + background: var(--str-video__background-color2, #1c1e22); + color: inherit; + font: inherit; + } + + button { + padding: 0.25rem 0.75rem; + border: none; + border-radius: var(--str-video__border-radius-xs, 0.25rem); + background: var(--str-video__primary-color, #005fff); + color: #fff; + font: inherit; + cursor: pointer; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } +} diff --git a/sample-apps/react/react-dogfood/style/index.scss b/sample-apps/react/react-dogfood/style/index.scss index 5654db1d82..35eb0371dc 100644 --- a/sample-apps/react/react-dogfood/style/index.scss +++ b/sample-apps/react/react-dogfood/style/index.scss @@ -42,6 +42,7 @@ @use 'LayoutSelector'; @use 'DevMenu'; @use 'Inspector'; +@use 'E2EEKeyNotification'; @use 'barebones'; @use 'livestream'; diff --git a/sample-apps/react/react-dogfood/style/lobby.scss b/sample-apps/react/react-dogfood/style/lobby.scss index badb1731e0..609ab466a9 100644 --- a/sample-apps/react/react-dogfood/style/lobby.scss +++ b/sample-apps/react/react-dogfood/style/lobby.scss @@ -9,12 +9,20 @@ &-container { display: flex; flex-direction: column; - justify-content: center; + // Top-aligned (not centered) so expanding the E2EE panel pushes only the + // content below it down - the video preview and Join button stay put. + justify-content: flex-start; align-items: center; width: 100%; height: 100%; + box-sizing: border-box; padding: var(--str-video__spacing-lg); + // Clear the transparent app header (rd__call-header, z-index 2) that overlays + // the top of the lobby, plus a little breathing room. + padding-top: 6rem; + // Scroll instead of clipping if the content is taller than the viewport. + overflow-y: auto; position: absolute; top: 0; @@ -266,3 +274,227 @@ a.mapboxgl-ctrl-logo { display: none; } + +// End-to-end encryption lobby control (pronto only). Sits below the join +// button; a compact switch that reveals the shared-key row when enabled. +.rd__lobby-encryption { + width: 100%; + box-sizing: border-box; + margin-top: var(--str-video__spacing-md); + // Frosted-glass card, matching the "Choose display name" panel. + background-color: #1d29380f; + border: 1px solid #ffffff08; + border-radius: var(--str-video__border-radius-xl); + backdrop-filter: blur(10px); + transition: + background-color 0.2s ease, + border-color 0.2s ease; + + &__switch { + display: flex; + align-items: center; + gap: var(--str-video__spacing-md); + width: 100%; + box-sizing: border-box; + padding: var(--str-video__spacing-md); + background: transparent; + border: none; + color: inherit; + text-align: left; + cursor: pointer; + } + + &__lock { + font-size: 20px; + color: var(--str-video__text-color2); + flex-shrink: 0; + } + + &__text { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + } + + &__title { + font-weight: 600; + font-size: var(--str-video__font-size-sm); + } + + &__subtitle { + font-size: var(--str-video__font-size-xs); + color: var(--str-video__text-color2); + } + + // Toggle track + thumb. + &__track { + position: relative; + flex-shrink: 0; + width: 40px; + height: 24px; + border-radius: var(--str-video__border-radius-circle); + background-color: var(--str-video__base-color5); + transition: background-color 0.15s ease; + } + + &__thumb { + position: absolute; + top: 3px; + left: 3px; + width: 18px; + height: 18px; + border-radius: var(--str-video__border-radius-circle); + background-color: #fff; + transition: transform 0.15s ease; + } + + // Encrypted state: a soft green wash + green lock/title (no loud border glow). + &--on { + background-color: rgba(0, 226, 161, 0.08); + border-color: rgba(0, 226, 161, 0.4); + } + + &--on &__lock, + &--on &__title { + color: var(--str-video__alert-success); + } + + &--on &__track { + background-color: var(--str-video__alert-success); + } + + &--on &__thumb { + transform: translateX(16px); + } + + // Joiner banner: same green as `--on`, but a static informational header + // instead of an interactive switch (no track/thumb) - a grayed-out toggle + // reads as broken to people who can't change it. + &__banner-row { + cursor: default; + } + + // Animated reveal: the grid row grows 0fr -> 1fr (height 0 -> auto) so only + // the content below the toggle is pushed down, with a quick motion. + &__reveal { + display: grid; + grid-template-rows: 0fr; + opacity: 0; + transition: + grid-template-rows 0.22s ease, + opacity 0.22s ease; + + &--open { + grid-template-rows: 1fr; + opacity: 1; + } + } + + &__reveal-inner { + min-height: 0; + overflow: hidden; + } + + &__details { + display: flex; + flex-direction: column; + gap: var(--str-video__spacing-xs); + // Inside the card now: align with the switch row, bottom padding to match. + padding: 0 var(--str-video__spacing-md) var(--str-video__spacing-md); + } + + &__key-label { + font-size: var(--str-video__font-size-xs); + font-weight: 600; + letter-spacing: 0.25ex; + text-transform: uppercase; + color: var(--str-video__text-color2); + text-align: left; + } + + &__key-row { + display: flex; + align-items: center; + gap: var(--str-video__spacing-sm); + } + + // Holds the key input plus the inline "refresh" button. + &__key-field { + position: relative; + flex: 1; + min-width: 0; + } + + &__key-input { + width: 100%; + box-sizing: border-box; + text-align: left; + // Room for the inline refresh button. + padding-right: 2.25rem; + + // Read-only for joiners: signal it isn't editable (no refresh button either). + &:read-only { + padding-right: var(--str-video__spacing-md); + color: var(--str-video__text-color2); + cursor: default; + } + } + + &__refresh { + position: absolute; + top: 50%; + right: var(--str-video__spacing-xs); + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + background: transparent; + border-radius: var(--str-video__border-radius-md); + color: var(--str-video__text-color2); + cursor: pointer; + + &:hover { + color: var(--str-video__text-color1); + background-color: rgba(255, 255, 255, 0.06); + } + + .str-video__icon { + width: 16px; + height: 16px; + background-color: currentColor; + } + } + + &__hint { + margin: 0; + font-size: var(--str-video__font-size-xs); + color: var(--str-video__text-color2); + text-align: left; + line-height: 1.4; + } + + &__copy { + flex-shrink: 0; + // Fixed width so swapping the label ("Copy link" -> "Copied") doesn't reflow + // and resize the key input next to it. + min-width: 5.5rem; + padding: var(--str-video__spacing-xs) var(--str-video__spacing-sm); + font-size: var(--str-video__font-size-xs); + text-align: center; + border-radius: var(--str-video__border-radius-md); + border: 1px solid var(--str-video__base-color5); + background-color: var(--str-video__base-color6); + color: var(--str-video__text-color1); + white-space: nowrap; + cursor: pointer; + + &:hover { + background-color: var(--str-video__base-color5); + } + } +} diff --git a/yarn.lock b/yarn.lock index 7c888ae7af..b79c976966 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6776,6 +6776,22 @@ __metadata: languageName: unknown linkType: soft +"@stream-io/e2ee-demo@workspace:sample-apps/react/e2ee-demo": + version: 0.0.0-use.local + resolution: "@stream-io/e2ee-demo@workspace:sample-apps/react/e2ee-demo" + dependencies: + "@stream-io/typescript-config": "workspace:^" + "@stream-io/video-react-sdk": "workspace:^" + "@types/react": "npm:~19.2.18" + "@types/react-dom": "npm:~19.2.4" + "@vitejs/plugin-react": "npm:^6.0.5" + react: "npm:19.2.8" + react-dom: "npm:19.2.8" + typescript: "npm:^6.0.3" + vite: "npm:^8.2.0" + languageName: unknown + linkType: soft + "@stream-io/egress-composite@workspace:sample-apps/react/egress-composite": version: 0.0.0-use.local resolution: "@stream-io/egress-composite@workspace:sample-apps/react/egress-composite"