@stream-io/video-client 1.56.0 → 1.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/dist/e2ee-worker.browser.es.js +1342 -0
- package/dist/e2ee-worker.browser.es.js.map +1 -0
- package/dist/index.browser.es.js +867 -71
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +911 -70
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.es.js +900 -71
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +52 -6
- package/dist/src/coordinator/connection/connection.d.ts +1 -1
- package/dist/src/devices/CameraManager.d.ts +6 -0
- package/dist/src/devices/DeviceManager.d.ts +1 -0
- package/dist/src/devices/MicrophoneManager.d.ts +6 -0
- package/dist/src/devices/SpeakerManager.d.ts +2 -1
- package/dist/src/events/participant.d.ts +10 -3
- package/dist/src/gen/coordinator/index.d.ts +1363 -243
- package/dist/src/gen/video/sfu/models/models.d.ts +8 -0
- package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +4 -0
- package/dist/src/helpers/TypedEventEmitter.d.ts +23 -0
- package/dist/src/helpers/ensureExhausted.d.ts +1 -1
- package/dist/src/permissions/PermissionsContext.d.ts +2 -2
- package/dist/src/rtc/BasePeerConnection.d.ts +3 -1
- package/dist/src/rtc/e2ee/E2EEManager.d.ts +12 -0
- package/dist/src/rtc/e2ee/EncryptionManager.d.ts +148 -0
- package/dist/src/rtc/e2ee/e2ee-worker.d.ts +25 -0
- package/dist/src/rtc/e2ee/events.d.ts +163 -0
- package/dist/src/rtc/e2ee/transformSupport.d.ts +21 -0
- package/dist/src/rtc/helpers/rtcConfiguration.d.ts +2 -2
- package/dist/src/rtc/index.d.ts +1 -0
- package/dist/src/rtc/mediaEngine.d.ts +23 -0
- package/dist/src/rtc/types.d.ts +2 -0
- package/dist/src/store/CallState.d.ts +21 -0
- package/dist/src/types.d.ts +15 -6
- package/index.ts +3 -0
- package/package.json +11 -11
- package/src/Call.ts +160 -26
- package/src/__tests__/Call.test.ts +15 -0
- package/src/coordinator/connection/connection.ts +6 -3
- package/src/devices/AudioDeviceManager.ts +6 -0
- package/src/devices/CameraManager.ts +51 -4
- package/src/devices/DeviceManager.ts +17 -0
- package/src/devices/MicrophoneManager.ts +55 -5
- package/src/devices/ScreenShareManager.ts +3 -0
- package/src/devices/SpeakerManager.ts +42 -12
- package/src/devices/__tests__/MicrophoneManagerRN.test.ts +2 -22
- package/src/devices/__tests__/SpeakerManager.test.ts +138 -42
- package/src/devices/__tests__/mocks.ts +36 -3
- package/src/devices/devicePersistence.ts +1 -2
- package/src/events/__tests__/participant.test.ts +134 -1
- package/src/events/callEventHandlers.ts +9 -3
- package/src/events/participant.ts +42 -7
- package/src/gen/coordinator/index.ts +1384 -244
- package/src/gen/video/sfu/models/models.ts +15 -0
- package/src/helpers/MediaPlaybackWatchdog.ts +35 -16
- package/src/helpers/TypedEventEmitter.ts +71 -0
- package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +137 -8
- package/src/helpers/__tests__/TypedEventEmitter.test.ts +133 -0
- package/src/helpers/__tests__/ViewportTracker.test.ts +1 -2
- package/src/helpers/ensureExhausted.ts +4 -2
- package/src/permissions/PermissionsContext.ts +10 -5
- package/src/reporting/ClientEventReporter.ts +2 -8
- package/src/rtc/BasePeerConnection.ts +12 -1
- package/src/rtc/Publisher.ts +8 -0
- package/src/rtc/Subscriber.ts +5 -0
- package/src/rtc/__tests__/Publisher.test.ts +101 -0
- package/src/rtc/__tests__/Subscriber.test.ts +89 -0
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +31 -0
- package/src/rtc/e2ee/E2EEManager.ts +12 -0
- package/src/rtc/e2ee/EncryptionManager.ts +336 -0
- package/src/rtc/e2ee/SPEC.md +586 -0
- package/src/rtc/e2ee/__tests__/E2EEManager.contract.test.ts +39 -0
- package/src/rtc/e2ee/__tests__/EncryptionManager.test.ts +490 -0
- package/src/rtc/e2ee/__tests__/codec.test.ts +247 -0
- package/src/rtc/e2ee/__tests__/conformance.test.ts +184 -0
- package/src/rtc/e2ee/__tests__/failureTracker.test.ts +45 -0
- package/src/rtc/e2ee/__tests__/frameCounter.test.ts +90 -0
- package/src/rtc/e2ee/__tests__/keyStore.test.ts +221 -0
- package/src/rtc/e2ee/__tests__/notifications.test.ts +166 -0
- package/src/rtc/e2ee/__tests__/perf.test.ts +139 -0
- package/src/rtc/e2ee/__tests__/queue.test.ts +61 -0
- package/src/rtc/e2ee/__tests__/replayWindow.test.ts +133 -0
- package/src/rtc/e2ee/__tests__/trailer.test.ts +143 -0
- package/src/rtc/e2ee/__tests__/transform-pipeline.test.ts +839 -0
- package/src/rtc/e2ee/__tests__/transformSupport.test.ts +65 -0
- package/src/rtc/e2ee/e2ee-worker/codec.ts +220 -0
- package/src/rtc/e2ee/e2ee-worker/constants.ts +47 -0
- package/src/rtc/e2ee/e2ee-worker/decode.ts +159 -0
- package/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts +137 -0
- package/src/rtc/e2ee/e2ee-worker/encode.ts +147 -0
- package/src/rtc/e2ee/e2ee-worker/failureTracker.ts +26 -0
- package/src/rtc/e2ee/e2ee-worker/frameCounter.ts +37 -0
- package/src/rtc/e2ee/e2ee-worker/keyStore.ts +211 -0
- package/src/rtc/e2ee/e2ee-worker/notifications.ts +190 -0
- package/src/rtc/e2ee/e2ee-worker/perf.ts +129 -0
- package/src/rtc/e2ee/e2ee-worker/queue.ts +12 -0
- package/src/rtc/e2ee/e2ee-worker/replayWindow.ts +131 -0
- package/src/rtc/e2ee/e2ee-worker/trailer.ts +155 -0
- package/src/rtc/e2ee/e2ee-worker/tsconfig.json +8 -0
- package/src/rtc/e2ee/e2ee-worker/types.ts +29 -0
- package/src/rtc/e2ee/e2ee-worker.ts +27 -0
- package/src/rtc/e2ee/events.ts +179 -0
- package/src/rtc/e2ee/transformSupport.ts +35 -0
- package/src/rtc/helpers/rtcConfiguration.ts +4 -2
- package/src/rtc/index.ts +1 -0
- package/src/rtc/mediaEngine.ts +44 -0
- package/src/rtc/types.ts +3 -2
- package/src/stats/rtc/types.ts +8 -2
- package/src/store/CallState.ts +34 -3
- package/src/store/__tests__/CallState.test.ts +82 -1
- package/src/types.ts +24 -9
|
@@ -146,6 +146,50 @@ describe('Publisher', () => {
|
|
|
146
146
|
expect(negotiateSpy).toHaveBeenCalled();
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
+
it('should attach an encryptor when E2EE manager is provided', async () => {
|
|
150
|
+
const e2eeMock = {
|
|
151
|
+
encrypt: vi.fn(),
|
|
152
|
+
decrypt: vi.fn(),
|
|
153
|
+
};
|
|
154
|
+
publisher.dispose();
|
|
155
|
+
publisher = new Publisher(
|
|
156
|
+
{
|
|
157
|
+
sfuClient,
|
|
158
|
+
dispatcher,
|
|
159
|
+
state,
|
|
160
|
+
tag: 'test',
|
|
161
|
+
enableTracing: false,
|
|
162
|
+
e2ee: e2eeMock,
|
|
163
|
+
},
|
|
164
|
+
[
|
|
165
|
+
{
|
|
166
|
+
id: 1,
|
|
167
|
+
trackType: TrackType.VIDEO,
|
|
168
|
+
bitrate: 1000,
|
|
169
|
+
// @ts-expect-error - incomplete data
|
|
170
|
+
codec: { name: 'vp9' },
|
|
171
|
+
fps: 30,
|
|
172
|
+
maxTemporalLayers: 3,
|
|
173
|
+
maxSpatialLayers: 3,
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const track = new MediaStreamTrack();
|
|
179
|
+
const clone = new MediaStreamTrack();
|
|
180
|
+
vi.spyOn(track, 'clone').mockReturnValue(clone);
|
|
181
|
+
// @ts-expect-error - private method
|
|
182
|
+
vi.spyOn(publisher, 'negotiate').mockResolvedValue();
|
|
183
|
+
|
|
184
|
+
await publisher.publish(track, TrackType.VIDEO);
|
|
185
|
+
|
|
186
|
+
expect(e2eeMock.encrypt).toHaveBeenCalledWith(
|
|
187
|
+
expect.anything(), // sender
|
|
188
|
+
'vp9',
|
|
189
|
+
'VIDEO',
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
149
193
|
it('should update an existing transceiver for a new track', async () => {
|
|
150
194
|
const track = new MediaStreamTrack();
|
|
151
195
|
const clone = new MediaStreamTrack();
|
|
@@ -239,6 +283,63 @@ describe('Publisher', () => {
|
|
|
239
283
|
});
|
|
240
284
|
});
|
|
241
285
|
|
|
286
|
+
describe('E2EE peer connection config', () => {
|
|
287
|
+
const e2ee = { encrypt: vi.fn(), decrypt: vi.fn() };
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Build a publisher and return the RTCConfiguration it constructed its peer
|
|
291
|
+
* connection with. The publisher from the outer `beforeEach` already
|
|
292
|
+
* consumed a call, hence the last one rather than the first.
|
|
293
|
+
*/
|
|
294
|
+
const configOf = async (opts: { e2ee?: typeof e2ee }) => {
|
|
295
|
+
await publisher.dispose();
|
|
296
|
+
publisher = new Publisher(
|
|
297
|
+
{
|
|
298
|
+
sfuClient,
|
|
299
|
+
dispatcher,
|
|
300
|
+
state,
|
|
301
|
+
tag: 'test',
|
|
302
|
+
enableTracing: false,
|
|
303
|
+
...opts,
|
|
304
|
+
},
|
|
305
|
+
[],
|
|
306
|
+
);
|
|
307
|
+
const calls = vi.mocked(globalThis.RTCPeerConnection).mock.calls;
|
|
308
|
+
return calls[calls.length - 1][0] as Record<string, unknown> | undefined;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const withInsertableStreams = (available: boolean) => {
|
|
312
|
+
if (available) {
|
|
313
|
+
Object.assign(RTCRtpSender.prototype, {
|
|
314
|
+
createEncodedStreams: vi.fn(),
|
|
315
|
+
});
|
|
316
|
+
} else {
|
|
317
|
+
// @ts-expect-error - non-standard property from the mock prototype
|
|
318
|
+
delete RTCRtpSender.prototype.createEncodedStreams;
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
afterEach(() => withInsertableStreams(false));
|
|
323
|
+
|
|
324
|
+
it('enables encodedInsertableStreams when a manager is attached', async () => {
|
|
325
|
+
withInsertableStreams(true);
|
|
326
|
+
const config = await configOf({ e2ee });
|
|
327
|
+
expect(config?.encodedInsertableStreams).toBe(true);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('leaves the flag off when no manager is attached', async () => {
|
|
331
|
+
withInsertableStreams(true);
|
|
332
|
+
const config = await configOf({});
|
|
333
|
+
expect(config?.encodedInsertableStreams).toBeUndefined();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('leaves the flag off on browsers without Insertable Streams', async () => {
|
|
337
|
+
withInsertableStreams(false);
|
|
338
|
+
const config = await configOf({ e2ee });
|
|
339
|
+
expect(config?.encodedInsertableStreams).toBeUndefined();
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
242
343
|
describe('Event Handling', () => {
|
|
243
344
|
it('handles changePublishQuality events', () => {
|
|
244
345
|
publisher['changePublishQuality'] = vi.fn();
|
|
@@ -17,6 +17,7 @@ import { NegotiationError } from '../NegotiationError';
|
|
|
17
17
|
import { ReconnectReason } from '../types';
|
|
18
18
|
import { IceTrickleBuffer } from '../IceTrickleBuffer';
|
|
19
19
|
import { StreamClient } from '../../coordinator/connection/client';
|
|
20
|
+
import { fromPartial } from '@total-typescript/shoehorn';
|
|
20
21
|
|
|
21
22
|
vi.mock('../../StreamSfuClient', () => {
|
|
22
23
|
console.log('MOCKING StreamSfuClient');
|
|
@@ -522,6 +523,94 @@ describe('Subscriber', () => {
|
|
|
522
523
|
expect(baseTrack.stop).toHaveBeenCalled();
|
|
523
524
|
expect(baseStream.removeTrack).toHaveBeenCalledWith(baseTrack);
|
|
524
525
|
});
|
|
526
|
+
|
|
527
|
+
it('should store receiver in orphaned track when E2EE is enabled', () => {
|
|
528
|
+
const e2eeMock = {
|
|
529
|
+
encrypt: vi.fn(),
|
|
530
|
+
decrypt: vi.fn(),
|
|
531
|
+
};
|
|
532
|
+
subscriber.dispose();
|
|
533
|
+
const e2eeState = new CallState();
|
|
534
|
+
subscriber = new Subscriber({
|
|
535
|
+
sfuClient,
|
|
536
|
+
dispatcher,
|
|
537
|
+
state: e2eeState,
|
|
538
|
+
connectionConfig: { iceServers: [] },
|
|
539
|
+
tag: 'test',
|
|
540
|
+
enableTracing: true,
|
|
541
|
+
e2ee: e2eeMock,
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
const mediaStream = new MediaStream();
|
|
545
|
+
const mediaStreamTrack = new MediaStreamTrack();
|
|
546
|
+
const receiver = {};
|
|
547
|
+
// @ts-expect-error - mock
|
|
548
|
+
mediaStream.id = 'orphan-prefix:TRACK_TYPE_VIDEO';
|
|
549
|
+
|
|
550
|
+
const registerOrphanedTrackSpy = vi.spyOn(
|
|
551
|
+
e2eeState,
|
|
552
|
+
'registerOrphanedTrack',
|
|
553
|
+
);
|
|
554
|
+
const onTrack = subscriber['handleOnTrack'];
|
|
555
|
+
// @ts-expect-error - incomplete mock
|
|
556
|
+
onTrack({ streams: [mediaStream], track: mediaStreamTrack, receiver });
|
|
557
|
+
|
|
558
|
+
// Decrypt is NOT called immediately for orphaned tracks
|
|
559
|
+
expect(e2eeMock.decrypt).not.toHaveBeenCalled();
|
|
560
|
+
// Receiver is stored with the orphaned track for later reconciliation
|
|
561
|
+
expect(registerOrphanedTrackSpy).toHaveBeenCalledWith({
|
|
562
|
+
id: mediaStream.id,
|
|
563
|
+
trackLookupPrefix: 'orphan-prefix',
|
|
564
|
+
track: mediaStream,
|
|
565
|
+
trackType: TrackType.VIDEO,
|
|
566
|
+
receiver,
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('should decrypt with userId when participant is found', () => {
|
|
571
|
+
const e2eeMock = {
|
|
572
|
+
encrypt: vi.fn(),
|
|
573
|
+
decrypt: vi.fn(),
|
|
574
|
+
};
|
|
575
|
+
subscriber.dispose();
|
|
576
|
+
const e2eeState = new CallState();
|
|
577
|
+
subscriber = new Subscriber({
|
|
578
|
+
sfuClient,
|
|
579
|
+
dispatcher,
|
|
580
|
+
state: e2eeState,
|
|
581
|
+
connectionConfig: { iceServers: [] },
|
|
582
|
+
tag: 'test',
|
|
583
|
+
enableTracing: true,
|
|
584
|
+
e2ee: e2eeMock,
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// Register a participant whose trackLookupPrefix matches the stream
|
|
588
|
+
e2eeState.updateOrAddParticipant(
|
|
589
|
+
'session-id',
|
|
590
|
+
fromPartial({
|
|
591
|
+
sessionId: 'session-id',
|
|
592
|
+
userId: 'real-user-id',
|
|
593
|
+
trackLookupPrefix: '123',
|
|
594
|
+
}),
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
const mediaStream = new MediaStream();
|
|
598
|
+
const mediaStreamTrack = new MediaStreamTrack();
|
|
599
|
+
const receiver = {};
|
|
600
|
+
// @ts-expect-error - mock
|
|
601
|
+
mediaStream.id = '123:TRACK_TYPE_VIDEO';
|
|
602
|
+
|
|
603
|
+
const onTrack = subscriber['handleOnTrack'];
|
|
604
|
+
// @ts-expect-error - incomplete mock
|
|
605
|
+
onTrack({ streams: [mediaStream], track: mediaStreamTrack, receiver });
|
|
606
|
+
|
|
607
|
+
// Uses the participant's userId (not trackLookupPrefix) for key lookup
|
|
608
|
+
expect(e2eeMock.decrypt).toHaveBeenCalledWith(
|
|
609
|
+
receiver,
|
|
610
|
+
'real-user-id',
|
|
611
|
+
'VIDEO',
|
|
612
|
+
);
|
|
613
|
+
});
|
|
525
614
|
});
|
|
526
615
|
|
|
527
616
|
describe('interruptedTracks', () => {
|
|
@@ -57,6 +57,7 @@ const RTCRtpTransceiverMock = vi.fn(function (): Partial<RTCRtpTransceiver> {
|
|
|
57
57
|
replaceTrack: vi.fn(),
|
|
58
58
|
getParameters: vi.fn().mockReturnValue({}),
|
|
59
59
|
setParameters: vi.fn(),
|
|
60
|
+
transform: null,
|
|
60
61
|
},
|
|
61
62
|
setCodecPreferences: vi.fn(),
|
|
62
63
|
mid: '',
|
|
@@ -80,6 +81,7 @@ const RTCRtpReceiverMock = vi.fn(function (): Partial<typeof RTCRtpReceiver> {
|
|
|
80
81
|
getCapabilities: vi.fn(),
|
|
81
82
|
};
|
|
82
83
|
});
|
|
84
|
+
RTCRtpReceiverMock.prototype.transform = null;
|
|
83
85
|
vi.stubGlobal('RTCRtpReceiver', RTCRtpReceiverMock);
|
|
84
86
|
|
|
85
87
|
const RTCRtpSenderMock = vi.fn(function (): Partial<typeof RTCRtpSender> {
|
|
@@ -89,8 +91,37 @@ const RTCRtpSenderMock = vi.fn(function (): Partial<typeof RTCRtpSender> {
|
|
|
89
91
|
track: vi.fn(),
|
|
90
92
|
};
|
|
91
93
|
});
|
|
94
|
+
RTCRtpSenderMock.prototype.transform = null;
|
|
92
95
|
vi.stubGlobal('RTCRtpSender', RTCRtpSenderMock);
|
|
93
96
|
|
|
97
|
+
const WorkerMock = vi.fn(function (): Partial<Worker> {
|
|
98
|
+
return {
|
|
99
|
+
postMessage: vi.fn(),
|
|
100
|
+
terminate: vi.fn(),
|
|
101
|
+
addEventListener: vi.fn(),
|
|
102
|
+
removeEventListener: vi.fn(),
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
vi.stubGlobal('Worker', WorkerMock);
|
|
106
|
+
|
|
107
|
+
const RTCRtpScriptTransformMock = vi.fn(function (
|
|
108
|
+
worker: Worker,
|
|
109
|
+
options?: unknown,
|
|
110
|
+
): Partial<RTCRtpScriptTransform> {
|
|
111
|
+
return {
|
|
112
|
+
worker,
|
|
113
|
+
options,
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
vi.stubGlobal('RTCRtpScriptTransform', RTCRtpScriptTransformMock);
|
|
117
|
+
|
|
118
|
+
if (typeof URL !== 'undefined' && typeof URL.createObjectURL !== 'function') {
|
|
119
|
+
Object.defineProperty(URL, 'createObjectURL', {
|
|
120
|
+
configurable: true,
|
|
121
|
+
value: vi.fn(() => 'blob:stream-video-e2ee'),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
94
125
|
const AudioContextMock = vi.fn(function (): Partial<AudioContext> {
|
|
95
126
|
return {
|
|
96
127
|
state: 'suspended',
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the RTC layer needs to attach E2EE to a track. {@link EncryptionManager}
|
|
3
|
+
* is the built-in AES-GCM implementation, but `Call.setE2EEManager` takes any:
|
|
4
|
+
* an integrator can plug in another scheme, RFC 9605 SFrame for example, by
|
|
5
|
+
* attaching their own encoded transform in these two methods.
|
|
6
|
+
*/
|
|
7
|
+
export interface E2EEManager {
|
|
8
|
+
/** `trackType` only groups perf stats, keeping a camera and screen share apart. */
|
|
9
|
+
encrypt(sender: RTCRtpSender, codec?: string, trackType?: string): void;
|
|
10
|
+
/** `trackType` only groups perf stats, keeping a peer's audio and video apart. */
|
|
11
|
+
decrypt(receiver: RTCRtpReceiver, userId: string, trackType?: string): void;
|
|
12
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { preferredTransform } from './transformSupport';
|
|
2
|
+
import { TypedEventEmitter } from '../../helpers/TypedEventEmitter';
|
|
3
|
+
import type { E2EEEventMap } from './events';
|
|
4
|
+
import type { E2EEManager } from './E2EEManager';
|
|
5
|
+
|
|
6
|
+
export type {
|
|
7
|
+
E2EEEventMap,
|
|
8
|
+
DecryptionFailedEvent,
|
|
9
|
+
DecryptionResumedEvent,
|
|
10
|
+
DecryptionStalledEvent,
|
|
11
|
+
EncryptionFailedEvent,
|
|
12
|
+
KeyStateReport,
|
|
13
|
+
MissingKeyEvent,
|
|
14
|
+
PerfReport,
|
|
15
|
+
TrackPerf,
|
|
16
|
+
UnencryptedFrameEvent,
|
|
17
|
+
UnsupportedVersionEvent,
|
|
18
|
+
} from './events';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* - `'AES-128-GCM'` (default): 16-byte keys, per BSI TR-02102-1 §3.2.
|
|
22
|
+
* - `'AES-256-GCM'`: 32-byte keys. For a compliance reviewer that requires
|
|
23
|
+
* 256-bit strength, such as a KBV Anlage 31b certifier.
|
|
24
|
+
*/
|
|
25
|
+
export type E2EEAlgorithm = 'AES-128-GCM' | 'AES-256-GCM';
|
|
26
|
+
|
|
27
|
+
/** Options for {@link EncryptionManager.create}. */
|
|
28
|
+
export type EncryptionManagerOptions = {
|
|
29
|
+
algorithm?: E2EEAlgorithm;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Distributes keys to the E2EE Web Worker and attaches encrypt/decrypt
|
|
34
|
+
* transforms to RTCRtpSenders and RTCRtpReceivers.
|
|
35
|
+
*/
|
|
36
|
+
export class EncryptionManager
|
|
37
|
+
extends TypedEventEmitter<E2EEEventMap>
|
|
38
|
+
implements E2EEManager
|
|
39
|
+
{
|
|
40
|
+
private readonly algorithm: E2EEAlgorithm;
|
|
41
|
+
private readonly transform: 'script' | 'insertable';
|
|
42
|
+
private disposed = false;
|
|
43
|
+
private piped?: WeakSet<RTCRtpSender | RTCRtpReceiver>;
|
|
44
|
+
|
|
45
|
+
private readonly userId: string;
|
|
46
|
+
private readonly worker: Worker;
|
|
47
|
+
private readonly workerUrl: string;
|
|
48
|
+
|
|
49
|
+
private constructor(
|
|
50
|
+
userId: string,
|
|
51
|
+
worker: Worker,
|
|
52
|
+
workerUrl: string,
|
|
53
|
+
algorithm: E2EEAlgorithm,
|
|
54
|
+
transform: 'script' | 'insertable',
|
|
55
|
+
) {
|
|
56
|
+
super('EncryptionManager');
|
|
57
|
+
this.userId = userId;
|
|
58
|
+
this.worker = worker;
|
|
59
|
+
this.workerUrl = workerUrl;
|
|
60
|
+
this.algorithm = algorithm;
|
|
61
|
+
this.transform = transform;
|
|
62
|
+
this.worker.addEventListener('message', this.handleWorkerMessage);
|
|
63
|
+
this.worker.addEventListener('error', this.handleWorkerError);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether this browser has WebRTC Encoded Transforms, which E2EE requires.
|
|
68
|
+
* Use it to guard UI, or to avoid calling {@link create} where it would throw.
|
|
69
|
+
*/
|
|
70
|
+
static isSupported = (): boolean => {
|
|
71
|
+
return preferredTransform() !== undefined;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Create an EncryptionManager instance and initialize the E2EE Web Worker.
|
|
76
|
+
*
|
|
77
|
+
* @param userId - The local user's ID, normally `call.currentUserId`.
|
|
78
|
+
* @param options - the create options.
|
|
79
|
+
* @throws {Error} If the browser lacks Encoded Transforms.
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* ```ts
|
|
83
|
+
* if (EncryptionManager.isSupported()) {
|
|
84
|
+
* const e2ee = await EncryptionManager.create(call.currentUserId);
|
|
85
|
+
* call.setE2EEManager(e2ee);
|
|
86
|
+
* e2ee.setSharedKey(0, keyBytes);
|
|
87
|
+
* }
|
|
88
|
+
*/
|
|
89
|
+
static create = async (
|
|
90
|
+
userId: string,
|
|
91
|
+
options?: EncryptionManagerOptions,
|
|
92
|
+
): Promise<EncryptionManager> => {
|
|
93
|
+
const transform = preferredTransform();
|
|
94
|
+
if (!transform) {
|
|
95
|
+
throw new Error(`E2EE is not supported in this browser`);
|
|
96
|
+
}
|
|
97
|
+
const { e2eeWorker } = await import('./e2ee-worker');
|
|
98
|
+
const blob = new Blob([`(${e2eeWorker.toString()})()`], {
|
|
99
|
+
type: 'application/javascript',
|
|
100
|
+
});
|
|
101
|
+
const url = URL.createObjectURL(blob);
|
|
102
|
+
let worker: Worker;
|
|
103
|
+
try {
|
|
104
|
+
worker = new Worker(url, { name: 'stream-video-e2ee' });
|
|
105
|
+
} catch (err) {
|
|
106
|
+
// e.g. a CSP `worker-src` without `blob:`. Don't leak the object URL.
|
|
107
|
+
URL.revokeObjectURL(url);
|
|
108
|
+
throw err;
|
|
109
|
+
}
|
|
110
|
+
const algorithm = options?.algorithm ?? 'AES-128-GCM';
|
|
111
|
+
return new EncryptionManager(userId, worker, url, algorithm, transform);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* {@link dispose} terminates the worker, so `postMessage` becomes a silent
|
|
116
|
+
* no-op and an attached transform points at a dead worker: frames would stall
|
|
117
|
+
* forever with no error and no event. Throwing is also fail-closed, since a
|
|
118
|
+
* caller that swallows it still publishes nothing rather than cleartext.
|
|
119
|
+
*/
|
|
120
|
+
private assertUsable = () => {
|
|
121
|
+
if (this.disposed) throw new Error(`EncryptionManager is disposed`);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Terminate the worker and release all resources.
|
|
126
|
+
*
|
|
127
|
+
* The manager is unusable afterwards and every other method throws. Call
|
|
128
|
+
* {@link create} for a new one. Safe to call more than once.
|
|
129
|
+
*/
|
|
130
|
+
dispose = (): void => {
|
|
131
|
+
if (this.disposed) return;
|
|
132
|
+
this.disposed = true;
|
|
133
|
+
this.piped = undefined;
|
|
134
|
+
this.worker.removeEventListener('message', this.handleWorkerMessage);
|
|
135
|
+
this.worker.removeEventListener('error', this.handleWorkerError);
|
|
136
|
+
this.worker.terminate();
|
|
137
|
+
URL.revokeObjectURL(this.workerUrl);
|
|
138
|
+
this.removeAllListeners();
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Set a per-user AES-GCM encryption key in the worker's key store.
|
|
143
|
+
*
|
|
144
|
+
* Use it when each participant has their own key from a central authority.
|
|
145
|
+
* The receiver picks the right one by the `keyIndex` in the frame trailer.
|
|
146
|
+
*
|
|
147
|
+
* @param userId - The key owner.
|
|
148
|
+
* @param keyIndex - Increases with each rotation.
|
|
149
|
+
* @param rawKey - 16 bytes for AES-128-GCM, 32 for AES-256-GCM.
|
|
150
|
+
*/
|
|
151
|
+
setKey = (userId: string, keyIndex: number, rawKey: ArrayBuffer): void => {
|
|
152
|
+
this.assertUsable();
|
|
153
|
+
this.validateKeyIndex(keyIndex);
|
|
154
|
+
this.validateKeyLength(rawKey);
|
|
155
|
+
this.worker.postMessage({ type: 'cmd.set_key', userId, keyIndex, rawKey });
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Fallback key for any user without a per-user key. The simplest E2EE mode:
|
|
160
|
+
* one key for everyone, usually passphrase-derived, no distribution needed.
|
|
161
|
+
* Setting an epoch makes it active for encryption while older epochs remain
|
|
162
|
+
* available to decrypt in-flight frames until {@link removeSharedKey}.
|
|
163
|
+
*
|
|
164
|
+
* @param keyIndex - An integer 0-255, since one trailer byte carries it.
|
|
165
|
+
* @param rawKey - 16 bytes for AES-128-GCM, 32 for AES-256-GCM.
|
|
166
|
+
*/
|
|
167
|
+
setSharedKey = (keyIndex: number, rawKey: ArrayBuffer): void => {
|
|
168
|
+
this.assertUsable();
|
|
169
|
+
this.validateKeyIndex(keyIndex);
|
|
170
|
+
this.validateKeyLength(rawKey);
|
|
171
|
+
this.worker.postMessage({ type: 'cmd.set_shared_key', keyIndex, rawKey });
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Remove one shared-key epoch from the worker's receive key ring.
|
|
176
|
+
*
|
|
177
|
+
* If this is the active epoch, shared-key encryption stops until
|
|
178
|
+
* {@link setSharedKey} succeeds again. An older epoch is not reactivated.
|
|
179
|
+
*
|
|
180
|
+
* @param keyIndex - The exact shared-key epoch to remove.
|
|
181
|
+
*/
|
|
182
|
+
removeSharedKey = (keyIndex: number): void => {
|
|
183
|
+
this.assertUsable();
|
|
184
|
+
this.validateKeyIndex(keyIndex);
|
|
185
|
+
this.worker.postMessage({ type: 'cmd.remove_shared_key', keyIndex });
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Retire one of a user's key epochs, leaving their other epochs usable.
|
|
190
|
+
*
|
|
191
|
+
* @param userId - The key owner.
|
|
192
|
+
* @param keyIndex - The exact epoch to remove. Absent epochs are a no-op.
|
|
193
|
+
*/
|
|
194
|
+
removeKey = (userId: string, keyIndex: number): void => {
|
|
195
|
+
this.assertUsable();
|
|
196
|
+
this.validateKeyIndex(keyIndex);
|
|
197
|
+
this.worker.postMessage({ type: 'cmd.remove_key', userId, keyIndex });
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Drop every key a user holds, revoking their ability to decrypt later
|
|
202
|
+
* frames. Call it when a participant leaves.
|
|
203
|
+
*
|
|
204
|
+
* To retire one rotated epoch instead, use {@link removeKey}.
|
|
205
|
+
*/
|
|
206
|
+
removeAllKeys = (userId: string): void => {
|
|
207
|
+
this.assertUsable();
|
|
208
|
+
this.worker.postMessage({ type: 'cmd.remove_all_keys', userId });
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Called by the Publisher when it adds a transceiver.
|
|
213
|
+
*
|
|
214
|
+
* @param sender - The sender to encrypt.
|
|
215
|
+
* @param codec - Codec name, e.g. 'vp8', selecting the clear-byte rules.
|
|
216
|
+
* @param trackType - Optional label; only groups perf stats.
|
|
217
|
+
* @internal
|
|
218
|
+
*/
|
|
219
|
+
encrypt = (
|
|
220
|
+
sender: RTCRtpSender,
|
|
221
|
+
codec?: string,
|
|
222
|
+
trackType?: string,
|
|
223
|
+
): void => {
|
|
224
|
+
this.assertUsable();
|
|
225
|
+
this.pipe(sender, {
|
|
226
|
+
operation: 'encode',
|
|
227
|
+
userId: this.userId,
|
|
228
|
+
codec,
|
|
229
|
+
trackType,
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Called by the Subscriber when a remote track arrives.
|
|
235
|
+
*
|
|
236
|
+
* @param receiver - The receiver to decrypt.
|
|
237
|
+
* @param userId - The remote user, for key lookup in the worker.
|
|
238
|
+
* @param trackType - Optional label; only groups perf stats.
|
|
239
|
+
* @internal
|
|
240
|
+
*/
|
|
241
|
+
decrypt = (
|
|
242
|
+
receiver: RTCRtpReceiver,
|
|
243
|
+
userId: string,
|
|
244
|
+
trackType?: string,
|
|
245
|
+
): void => {
|
|
246
|
+
this.assertUsable();
|
|
247
|
+
this.pipe(receiver, { operation: 'decode', userId, trackType });
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/** Pipe through the worker's transform, tracking targets to avoid double-piping. */
|
|
251
|
+
private pipe = (
|
|
252
|
+
target: RTCRtpSender | RTCRtpReceiver,
|
|
253
|
+
options: {
|
|
254
|
+
operation: string;
|
|
255
|
+
userId: string;
|
|
256
|
+
codec?: string;
|
|
257
|
+
trackType?: string;
|
|
258
|
+
},
|
|
259
|
+
): void => {
|
|
260
|
+
if (this.transform === 'script') {
|
|
261
|
+
target.transform = new RTCRtpScriptTransform(this.worker, options);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if ((this.piped ??= new WeakSet()).has(target)) return;
|
|
266
|
+
this.piped.add(target);
|
|
267
|
+
// @ts-expect-error createEncodedStreams is not in the standard typedefs
|
|
268
|
+
const { readable, writable } = target.createEncodedStreams();
|
|
269
|
+
this.worker.postMessage(
|
|
270
|
+
{ type: 'cmd.setup_transform', ...options, readable, writable },
|
|
271
|
+
[readable, writable],
|
|
272
|
+
);
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Toggle periodic performance reporting from the E2EE worker.
|
|
277
|
+
*
|
|
278
|
+
* While on, the worker emits `e2ee.perf_report` once per second with per-track
|
|
279
|
+
* FPS and crypto timings. Useful for debugging throughput.
|
|
280
|
+
*/
|
|
281
|
+
enablePerformanceReporting = (enabled: boolean): void => {
|
|
282
|
+
this.assertUsable();
|
|
283
|
+
this.worker.postMessage({
|
|
284
|
+
type: 'cmd.enable_performance_reporting',
|
|
285
|
+
enabled,
|
|
286
|
+
});
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Request a snapshot of the worker's keys. It arrives later as the
|
|
291
|
+
* `e2ee.key_state` event, listing fingerprints only, never key material.
|
|
292
|
+
*/
|
|
293
|
+
requestKeyState = (): void => {
|
|
294
|
+
this.assertUsable();
|
|
295
|
+
this.worker.postMessage({ type: 'cmd.request_key_state' });
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
private handleWorkerMessage = (e: MessageEvent) => {
|
|
299
|
+
const { type, ...payload } = e.data ?? {};
|
|
300
|
+
if (type === 'e2ee.error') {
|
|
301
|
+
this.logger.error(e.data.message);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const event = type as keyof E2EEEventMap;
|
|
305
|
+
this.logger.debug('Dispatching', event, payload);
|
|
306
|
+
|
|
307
|
+
this.emit(event, payload as never);
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
private handleWorkerError = (e: ErrorEvent) => {
|
|
311
|
+
this.logger.error('Unhandled worker error:', e.message);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
private validateKeyLength = (rawKey: ArrayBuffer) => {
|
|
315
|
+
const is256 = this.algorithm === 'AES-256-GCM';
|
|
316
|
+
const expected = is256 ? 32 : 16;
|
|
317
|
+
if (rawKey.byteLength !== expected) {
|
|
318
|
+
throw new Error(
|
|
319
|
+
`Key must be exactly ${expected} bytes (${is256 ? 'AES-256' : 'AES-128'})`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* One trailer byte carries the keyIndex. A larger value would truncate to
|
|
326
|
+
* `keyIndex & 0xFF`, so the receiver would look up the wrong key and fail
|
|
327
|
+
* every decrypt. Reject it rather than ship a silently broken key epoch.
|
|
328
|
+
*/
|
|
329
|
+
private validateKeyIndex = (keyIndex: number) => {
|
|
330
|
+
if (!Number.isInteger(keyIndex) || keyIndex < 0 || keyIndex > 255) {
|
|
331
|
+
throw new Error(
|
|
332
|
+
`keyIndex must be an integer between 0 and 255, got ${keyIndex}`,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
}
|