@stream-io/video-client 1.57.0 → 1.59.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 +28 -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 +1141 -117
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +1196 -115
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.es.js +1174 -117
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +66 -9
- package/dist/src/StreamVideoClient.d.ts +9 -1
- package/dist/src/coordinator/connection/connection.d.ts +1 -1
- package/dist/src/devices/CameraManager.d.ts +8 -1
- package/dist/src/devices/DeviceManager.d.ts +2 -1
- package/dist/src/devices/MicrophoneManager.d.ts +8 -1
- 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 +1386 -240
- package/dist/src/gen/video/sfu/models/models.d.ts +8 -0
- package/dist/src/helpers/MediaStreamRecorder.d.ts +26 -0
- package/dist/src/helpers/TypedEventEmitter.d.ts +23 -0
- package/dist/src/helpers/concurrency.d.ts +8 -0
- package/dist/src/helpers/ensureExhausted.d.ts +1 -1
- package/dist/src/helpers/loopback.d.ts +55 -0
- package/dist/src/permissions/PermissionsContext.d.ts +2 -2
- package/dist/src/reporting/ClientEventReporter.d.ts +0 -2
- package/dist/src/rtc/BasePeerConnection.d.ts +3 -1
- package/dist/src/rtc/Subscriber.d.ts +5 -0
- 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 +5 -0
- package/package.json +1 -1
- package/src/Call.ts +311 -155
- package/src/StreamVideoClient.ts +35 -8
- package/src/__tests__/Call.lifecycle.test.ts +33 -2
- package/src/__tests__/Call.publishing.test.ts +42 -1
- package/src/__tests__/Call.test.ts +15 -0
- package/src/__tests__/StreamVideoClient.api.test.ts +33 -0
- package/src/__tests__/StreamVideoClient.rewatch.test.ts +213 -0
- package/src/__tests__/StreamVideoClient.ringing.test.ts +11 -4
- package/src/__tests__/StreamVideoClient.test.ts +14 -12
- package/src/coordinator/connection/connection.ts +6 -3
- package/src/devices/AudioDeviceManager.ts +6 -0
- package/src/devices/CameraManager.ts +67 -8
- package/src/devices/DeviceManager.ts +24 -4
- package/src/devices/MicrophoneManager.ts +66 -8
- package/src/devices/ScreenShareManager.ts +3 -0
- package/src/devices/SpeakerManager.ts +42 -12
- package/src/devices/__tests__/CameraManager.test.ts +27 -1
- package/src/devices/__tests__/DeviceManager.test.ts +49 -1
- package/src/devices/__tests__/MicrophoneManager.test.ts +23 -1
- package/src/devices/__tests__/MicrophoneManagerRN.test.ts +1 -20
- package/src/devices/__tests__/SpeakerManager.test.ts +138 -42
- package/src/devices/__tests__/mocks.ts +36 -3
- package/src/events/__tests__/participant.test.ts +134 -1
- package/src/events/call.ts +6 -3
- package/src/events/callEventHandlers.ts +9 -3
- package/src/events/participant.ts +42 -7
- package/src/gen/coordinator/index.ts +1402 -236
- package/src/gen/video/sfu/models/models.ts +15 -0
- package/src/helpers/MediaStreamRecorder.ts +104 -0
- package/src/helpers/TypedEventEmitter.ts +71 -0
- package/src/helpers/__tests__/MediaStreamRecorder.test.ts +324 -0
- package/src/helpers/__tests__/TypedEventEmitter.test.ts +133 -0
- package/src/helpers/__tests__/concurrency.test.ts +74 -0
- package/src/helpers/__tests__/loopback.test.ts +367 -0
- package/src/helpers/concurrency.ts +24 -0
- package/src/helpers/ensureExhausted.ts +4 -2
- package/src/helpers/loopback.ts +200 -0
- package/src/permissions/PermissionsContext.ts +10 -5
- package/src/reporting/ClientEventReporter.ts +15 -28
- package/src/reporting/__tests__/ClientEventReporter.test.ts +3 -7
- package/src/rtc/BasePeerConnection.ts +12 -1
- package/src/rtc/Publisher.ts +8 -0
- package/src/rtc/Subscriber.ts +13 -0
- package/src/rtc/__tests__/Publisher.test.ts +101 -0
- package/src/rtc/__tests__/Subscriber.test.ts +190 -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 +2 -0
- package/src/store/CallState.ts +33 -1
- package/src/store/__tests__/CallState.test.ts +82 -1
- package/src/types.ts +23 -5
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import '../../__tests__/mocks/webrtc.mocks';
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { EncryptionManager } from '../EncryptionManager';
|
|
5
|
+
import type { E2EEEventMap } from '../events';
|
|
6
|
+
import { isChrome } from '../../../helpers/browsers';
|
|
7
|
+
|
|
8
|
+
// Mock the worker module so create() doesn't need the real bundled function
|
|
9
|
+
vi.mock('../e2ee-worker', () => ({
|
|
10
|
+
e2eeWorker: function () {
|
|
11
|
+
self.onmessage = () => {};
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
// Mock browser detection so we can drive the Chrome vs non-Chrome transform
|
|
16
|
+
// selection deterministically. Defaults to non-Chrome (reset in beforeEach).
|
|
17
|
+
vi.mock('../../../helpers/browsers', async (importOriginal) => {
|
|
18
|
+
const actual =
|
|
19
|
+
await importOriginal<typeof import('../../../helpers/browsers')>();
|
|
20
|
+
return { ...actual, isChrome: vi.fn().mockReturnValue(false) };
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('EncryptionManager', () => {
|
|
24
|
+
let manager: EncryptionManager;
|
|
25
|
+
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
vi.mocked(isChrome).mockReturnValue(false);
|
|
28
|
+
manager = await EncryptionManager.create('local-user');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
manager.dispose();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('isSupported', () => {
|
|
36
|
+
it('returns true when RTCRtpScriptTransform is available', () => {
|
|
37
|
+
expect(EncryptionManager.isSupported()).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns false when neither API is available', () => {
|
|
41
|
+
const original = globalThis.RTCRtpScriptTransform;
|
|
42
|
+
// @ts-expect-error test case
|
|
43
|
+
delete globalThis.RTCRtpScriptTransform;
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
expect(EncryptionManager.isSupported()).toBe(false);
|
|
47
|
+
} finally {
|
|
48
|
+
globalThis.RTCRtpScriptTransform = original;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('create', () => {
|
|
54
|
+
it('creates a manager with a worker', async () => {
|
|
55
|
+
const mgr = await EncryptionManager.create('user-123');
|
|
56
|
+
expect(mgr).toBeInstanceOf(EncryptionManager);
|
|
57
|
+
mgr.dispose();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('revokes the blob URL if the Worker constructor throws', async () => {
|
|
61
|
+
// e.g. a CSP `worker-src` that disallows `blob:`. The object URL created
|
|
62
|
+
// just before must not leak.
|
|
63
|
+
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL');
|
|
64
|
+
const OriginalWorker = globalThis.Worker;
|
|
65
|
+
// @ts-expect-error replace the global Worker for this test
|
|
66
|
+
globalThis.Worker = class {
|
|
67
|
+
constructor() {
|
|
68
|
+
throw new Error('worker-src blocked by CSP');
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
try {
|
|
72
|
+
await expect(EncryptionManager.create('u')).rejects.toThrow(/CSP/);
|
|
73
|
+
expect(revokeSpy).toHaveBeenCalled();
|
|
74
|
+
} finally {
|
|
75
|
+
globalThis.Worker = OriginalWorker;
|
|
76
|
+
revokeSpy.mockRestore();
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('worker commands', () => {
|
|
82
|
+
const rawKey = new ArrayBuffer(16);
|
|
83
|
+
|
|
84
|
+
it.each([
|
|
85
|
+
[
|
|
86
|
+
'setKey',
|
|
87
|
+
() => manager.setKey('remote-user', 0, rawKey),
|
|
88
|
+
{ type: 'cmd.set_key', userId: 'remote-user', keyIndex: 0, rawKey },
|
|
89
|
+
],
|
|
90
|
+
[
|
|
91
|
+
'setSharedKey',
|
|
92
|
+
() => manager.setSharedKey(0, rawKey),
|
|
93
|
+
{ type: 'cmd.set_shared_key', keyIndex: 0, rawKey },
|
|
94
|
+
],
|
|
95
|
+
[
|
|
96
|
+
'removeSharedKey',
|
|
97
|
+
() => manager.removeSharedKey(0),
|
|
98
|
+
{ type: 'cmd.remove_shared_key', keyIndex: 0 },
|
|
99
|
+
],
|
|
100
|
+
[
|
|
101
|
+
'removeKey',
|
|
102
|
+
() => manager.removeKey('remote-user', 3),
|
|
103
|
+
{ type: 'cmd.remove_key', userId: 'remote-user', keyIndex: 3 },
|
|
104
|
+
],
|
|
105
|
+
[
|
|
106
|
+
'removeAllKeys',
|
|
107
|
+
() => manager.removeAllKeys('remote-user'),
|
|
108
|
+
{ type: 'cmd.remove_all_keys', userId: 'remote-user' },
|
|
109
|
+
],
|
|
110
|
+
[
|
|
111
|
+
'requestKeyState',
|
|
112
|
+
() => manager.requestKeyState(),
|
|
113
|
+
{ type: 'cmd.request_key_state' },
|
|
114
|
+
],
|
|
115
|
+
[
|
|
116
|
+
'enablePerformanceReporting',
|
|
117
|
+
() => manager.enablePerformanceReporting(true),
|
|
118
|
+
{ type: 'cmd.enable_performance_reporting', enabled: true },
|
|
119
|
+
],
|
|
120
|
+
])('%s posts its command', (_name, call, expected) => {
|
|
121
|
+
call();
|
|
122
|
+
expect(getWorker(manager).postMessage).toHaveBeenCalledWith(expected);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('clones key material rather than transferring it', () => {
|
|
126
|
+
// A transfer list would detach the caller's ArrayBuffer and break the
|
|
127
|
+
// documented contract that the same bytes can be imported again.
|
|
128
|
+
manager.setSharedKey(0, rawKey);
|
|
129
|
+
const transferList = vi.mocked(getWorker(manager).postMessage).mock
|
|
130
|
+
.calls[0][1];
|
|
131
|
+
expect(transferList).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// One validation path behind two methods, so the table covers both rather
|
|
136
|
+
// than repeating each rule per method.
|
|
137
|
+
describe('key validation', () => {
|
|
138
|
+
const methods = ['setKey', 'setSharedKey'] as const;
|
|
139
|
+
const call = (
|
|
140
|
+
method: (typeof methods)[number],
|
|
141
|
+
keyIndex: number,
|
|
142
|
+
bytes: number,
|
|
143
|
+
) =>
|
|
144
|
+
method === 'setKey'
|
|
145
|
+
? () => manager.setKey('user', keyIndex, new ArrayBuffer(bytes))
|
|
146
|
+
: () => manager.setSharedKey(keyIndex, new ArrayBuffer(bytes));
|
|
147
|
+
|
|
148
|
+
it.each(methods)('%s accepts a keyIndex across the whole byte', (m) => {
|
|
149
|
+
expect(call(m, 0, 16)).not.toThrow();
|
|
150
|
+
expect(call(m, 255, 16)).not.toThrow();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it.each(methods)('%s rejects a keyIndex off the wire format', (m) => {
|
|
154
|
+
// The trailer carries keyIndex in one byte: 256 would wrap to 0 and the
|
|
155
|
+
// receiver would silently look up the wrong key.
|
|
156
|
+
for (const bad of [256, -1, 1.5, NaN]) {
|
|
157
|
+
expect(call(m, bad, 16)).toThrow(/keyIndex/);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// The per-epoch removals carry no key material, so they sit outside the
|
|
162
|
+
// table above while sharing its index rule.
|
|
163
|
+
it.each([
|
|
164
|
+
['removeKey', (i: number) => () => manager.removeKey('user', i)],
|
|
165
|
+
['removeSharedKey', (i: number) => () => manager.removeSharedKey(i)],
|
|
166
|
+
])('%s applies the same keyIndex rule', (_name, at) => {
|
|
167
|
+
expect(at(0)).not.toThrow();
|
|
168
|
+
expect(at(255)).not.toThrow();
|
|
169
|
+
for (const bad of [256, -1, 1.5, NaN]) {
|
|
170
|
+
expect(at(bad)).toThrow(/keyIndex/);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it.each(methods)('%s rejects a key that is not 16 bytes', (m) => {
|
|
175
|
+
for (const bad of [0, 8, 15, 17, 32]) {
|
|
176
|
+
expect(call(m, 0, bad)).toThrow(/16 bytes/);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('removeSharedKey validates the keyIndex byte', () => {
|
|
181
|
+
expect(() => manager.removeSharedKey(0)).not.toThrow();
|
|
182
|
+
expect(() => manager.removeSharedKey(255)).not.toThrow();
|
|
183
|
+
for (const bad of [256, -1, 1.5, NaN]) {
|
|
184
|
+
expect(() => manager.removeSharedKey(bad)).toThrow(/keyIndex/);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('attaching transforms', () => {
|
|
190
|
+
it.each([
|
|
191
|
+
[
|
|
192
|
+
'encrypt',
|
|
193
|
+
(t: unknown) => manager.encrypt(t as RTCRtpSender, 'vp8', 'VIDEO'),
|
|
194
|
+
{
|
|
195
|
+
operation: 'encode',
|
|
196
|
+
userId: 'local-user',
|
|
197
|
+
codec: 'vp8',
|
|
198
|
+
trackType: 'VIDEO',
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
[
|
|
202
|
+
'decrypt',
|
|
203
|
+
(t: unknown) =>
|
|
204
|
+
manager.decrypt(t as RTCRtpReceiver, 'remote-user', 'AUDIO'),
|
|
205
|
+
{ operation: 'decode', userId: 'remote-user', trackType: 'AUDIO' },
|
|
206
|
+
],
|
|
207
|
+
])(
|
|
208
|
+
'%s attaches a transform carrying its options',
|
|
209
|
+
(_name, attach, options) => {
|
|
210
|
+
const target: Record<string, unknown> = { transform: null };
|
|
211
|
+
attach(target);
|
|
212
|
+
expect(target.transform).toBeDefined();
|
|
213
|
+
expect((target.transform as Record<string, unknown>).options).toEqual(
|
|
214
|
+
options,
|
|
215
|
+
);
|
|
216
|
+
},
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
it('forwards an unsupported codec verbatim instead of gating on it', () => {
|
|
220
|
+
// The worker owns the decision and fails closed for a codec it cannot
|
|
221
|
+
// frame (av1 today); duplicating the check here would let the two drift.
|
|
222
|
+
const sender: Record<string, unknown> = { transform: null };
|
|
223
|
+
manager.encrypt(sender as unknown as RTCRtpSender, 'av1');
|
|
224
|
+
expect(
|
|
225
|
+
(sender.transform as Record<string, unknown>).options,
|
|
226
|
+
).toMatchObject({ codec: 'av1' });
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Which API is chosen for a given browser is preferredTransform's job and is
|
|
231
|
+
// covered directly in transformSupport.test.ts. These tests only cover the
|
|
232
|
+
// half the manager owns: that it wires up whichever path it was handed.
|
|
233
|
+
describe('transform wiring', () => {
|
|
234
|
+
/** Stub the non-standard createEncodedStreams on the sender/receiver prototypes. */
|
|
235
|
+
const withInsertableStreams = async (fn: () => void | Promise<void>) => {
|
|
236
|
+
Object.assign(RTCRtpSender.prototype, { createEncodedStreams: vi.fn() });
|
|
237
|
+
Object.assign(RTCRtpReceiver.prototype, {
|
|
238
|
+
createEncodedStreams: vi.fn(),
|
|
239
|
+
});
|
|
240
|
+
try {
|
|
241
|
+
await fn();
|
|
242
|
+
} finally {
|
|
243
|
+
// @ts-expect-error - cleaning up non-standard property from mock prototype
|
|
244
|
+
delete RTCRtpSender.prototype.createEncodedStreams;
|
|
245
|
+
// @ts-expect-error - cleaning up non-standard property from mock prototype
|
|
246
|
+
delete RTCRtpReceiver.prototype.createEncodedStreams;
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
it('wires the script path by assigning target.transform', async () => {
|
|
251
|
+
vi.mocked(isChrome).mockReturnValue(false);
|
|
252
|
+
// createEncodedStreams exists here too, so this also pins that the script
|
|
253
|
+
// path never touches it.
|
|
254
|
+
await withInsertableStreams(() => {
|
|
255
|
+
const receiver: Record<string, unknown> = {
|
|
256
|
+
transform: null,
|
|
257
|
+
createEncodedStreams: vi.fn(),
|
|
258
|
+
};
|
|
259
|
+
manager.decrypt(receiver as unknown as RTCRtpReceiver, 'remote-user');
|
|
260
|
+
|
|
261
|
+
expect(receiver.transform).toBeDefined();
|
|
262
|
+
expect(receiver.createEncodedStreams).not.toHaveBeenCalled();
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('wires the insertable path by transferring the streams to the worker', async () => {
|
|
267
|
+
vi.mocked(isChrome).mockReturnValue(true);
|
|
268
|
+
const readable = {};
|
|
269
|
+
const writable = {};
|
|
270
|
+
const receiver = {
|
|
271
|
+
createEncodedStreams: vi.fn(() => ({ readable, writable })),
|
|
272
|
+
} as unknown as RTCRtpReceiver;
|
|
273
|
+
|
|
274
|
+
await withInsertableStreams(async () => {
|
|
275
|
+
const mgr = await EncryptionManager.create('local-user');
|
|
276
|
+
try {
|
|
277
|
+
mgr.decrypt(receiver, 'remote-user');
|
|
278
|
+
|
|
279
|
+
// @ts-expect-error not present in the standard lib
|
|
280
|
+
expect(receiver.createEncodedStreams).toHaveBeenCalled();
|
|
281
|
+
const worker = getWorker(mgr);
|
|
282
|
+
expect(worker.postMessage).toHaveBeenCalledWith(
|
|
283
|
+
{
|
|
284
|
+
type: 'cmd.setup_transform',
|
|
285
|
+
operation: 'decode',
|
|
286
|
+
userId: 'remote-user',
|
|
287
|
+
readable,
|
|
288
|
+
writable,
|
|
289
|
+
},
|
|
290
|
+
[readable, writable],
|
|
291
|
+
);
|
|
292
|
+
} finally {
|
|
293
|
+
mgr.dispose();
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('prevents double-piping the same receiver on the Insertable Streams path', async () => {
|
|
299
|
+
vi.mocked(isChrome).mockReturnValue(true);
|
|
300
|
+
const readable = {};
|
|
301
|
+
const writable = {};
|
|
302
|
+
const receiver = {
|
|
303
|
+
createEncodedStreams: vi.fn(() => ({ readable, writable })),
|
|
304
|
+
} as unknown as RTCRtpReceiver;
|
|
305
|
+
|
|
306
|
+
await withInsertableStreams(async () => {
|
|
307
|
+
const mgr = await EncryptionManager.create('local-user');
|
|
308
|
+
try {
|
|
309
|
+
mgr.decrypt(receiver, 'user-a');
|
|
310
|
+
mgr.decrypt(receiver, 'user-b');
|
|
311
|
+
|
|
312
|
+
// @ts-expect-error not present in the standard lib
|
|
313
|
+
expect(receiver.createEncodedStreams).toHaveBeenCalledTimes(1);
|
|
314
|
+
} finally {
|
|
315
|
+
mgr.dispose();
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
describe('AES-256-GCM opt-in', () => {
|
|
322
|
+
it('swaps the required key length to 32 bytes', async () => {
|
|
323
|
+
// The default manager's 16-byte rule is covered in `key validation`;
|
|
324
|
+
// this is only about the algorithm option moving the goalposts.
|
|
325
|
+
const mgr = await EncryptionManager.create('user', {
|
|
326
|
+
algorithm: 'AES-256-GCM',
|
|
327
|
+
});
|
|
328
|
+
try {
|
|
329
|
+
expect(() => mgr.setKey('remote', 0, new ArrayBuffer(16))).toThrow(
|
|
330
|
+
/32 bytes \(AES-256\)/,
|
|
331
|
+
);
|
|
332
|
+
expect(() =>
|
|
333
|
+
mgr.setKey('remote', 0, new ArrayBuffer(32)),
|
|
334
|
+
).not.toThrow();
|
|
335
|
+
expect(() => mgr.setSharedKey(0, new ArrayBuffer(32))).not.toThrow();
|
|
336
|
+
} finally {
|
|
337
|
+
mgr.dispose();
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
describe('worker message handling', () => {
|
|
343
|
+
// The manager forwards worker messages generically: strip `type`, emit the
|
|
344
|
+
// rest as the payload. One table covers every event rather than repeating
|
|
345
|
+
// the same assertion per name - a new event needs a row, not a test.
|
|
346
|
+
const EVENTS: Array<[keyof E2EEEventMap, Record<string, unknown>]> = [
|
|
347
|
+
['e2ee.decryption_failed', { userId: 'bob', trackType: 'VIDEO' }],
|
|
348
|
+
['e2ee.decryption_resumed', { userId: 'bob', trackType: 'VIDEO' }],
|
|
349
|
+
['e2ee.encryption_failed', { userId: 'bob', reason: 'clear-bytes' }],
|
|
350
|
+
['e2ee.missing_key', { userId: 'local-user', keyIndex: 2 }],
|
|
351
|
+
[
|
|
352
|
+
'e2ee.decryption_stalled',
|
|
353
|
+
{ userId: 'bob', keyIndex: 3, trackType: 'AUDIO' },
|
|
354
|
+
],
|
|
355
|
+
['e2ee.unencrypted_frame', { userId: 'bob', trackType: 'VIDEO' }],
|
|
356
|
+
[
|
|
357
|
+
'e2ee.unsupported_version',
|
|
358
|
+
{ userId: 'bob', version: 2, trackType: 'VIDEO' },
|
|
359
|
+
],
|
|
360
|
+
[
|
|
361
|
+
'e2ee.perf_report',
|
|
362
|
+
{
|
|
363
|
+
encode: [
|
|
364
|
+
{
|
|
365
|
+
userId: 'alice',
|
|
366
|
+
trackType: 'VIDEO',
|
|
367
|
+
codec: 'vp8',
|
|
368
|
+
fps: 30,
|
|
369
|
+
maxCryptoMs: 2,
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
decode: [
|
|
373
|
+
{ userId: 'bob', trackType: 'VIDEO', fps: 29, maxCryptoMs: 3 },
|
|
374
|
+
],
|
|
375
|
+
},
|
|
376
|
+
],
|
|
377
|
+
[
|
|
378
|
+
'e2ee.key_state',
|
|
379
|
+
{
|
|
380
|
+
perUserKeys: [{ userId: 'bob', keyIndex: 0, fingerprint: 'abc123' }],
|
|
381
|
+
sharedKeys: [{ keyIndex: 1, fingerprint: 'def456', isActive: true }],
|
|
382
|
+
},
|
|
383
|
+
],
|
|
384
|
+
];
|
|
385
|
+
|
|
386
|
+
it.each(EVENTS)('emits %s with the payload verbatim', (type, payload) => {
|
|
387
|
+
const callback = vi.fn();
|
|
388
|
+
manager.on(type, callback);
|
|
389
|
+
|
|
390
|
+
const messageHandler = getEventHandler(getWorker(manager), 'message');
|
|
391
|
+
messageHandler({ data: { type, ...payload } });
|
|
392
|
+
|
|
393
|
+
expect(callback).toHaveBeenCalledWith(payload);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('does not throw when nobody is subscribed', () => {
|
|
397
|
+
// The message pump must survive an unsubscribed event: a throw here would
|
|
398
|
+
// take down every later message, not just this one.
|
|
399
|
+
const messageHandler = getEventHandler(getWorker(manager), 'message');
|
|
400
|
+
for (const [type, payload] of EVENTS) {
|
|
401
|
+
expect(() =>
|
|
402
|
+
messageHandler({ data: { type, ...payload } }),
|
|
403
|
+
).not.toThrow();
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('stops delivering after the returned unsubscribe is called', () => {
|
|
408
|
+
const callback = vi.fn();
|
|
409
|
+
const unsubscribe = manager.on('e2ee.decryption_failed', callback);
|
|
410
|
+
unsubscribe();
|
|
411
|
+
|
|
412
|
+
const messageHandler = getEventHandler(getWorker(manager), 'message');
|
|
413
|
+
messageHandler({
|
|
414
|
+
data: { type: 'e2ee.decryption_failed', userId: 'bob' },
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
expect(callback).not.toHaveBeenCalled();
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
describe('dispose', () => {
|
|
422
|
+
it('terminates the worker and revokes the blob URL', () => {
|
|
423
|
+
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL');
|
|
424
|
+
const worker = getWorker(manager);
|
|
425
|
+
manager.dispose();
|
|
426
|
+
|
|
427
|
+
expect(worker.terminate).toHaveBeenCalled();
|
|
428
|
+
expect(worker.removeEventListener).toHaveBeenCalledWith(
|
|
429
|
+
'message',
|
|
430
|
+
expect.any(Function),
|
|
431
|
+
);
|
|
432
|
+
expect(worker.removeEventListener).toHaveBeenCalledWith(
|
|
433
|
+
'error',
|
|
434
|
+
expect.any(Function),
|
|
435
|
+
);
|
|
436
|
+
expect(revokeObjectURL).toHaveBeenCalled();
|
|
437
|
+
revokeObjectURL.mockRestore();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('is safe to call multiple times', () => {
|
|
441
|
+
const worker = getWorker(manager);
|
|
442
|
+
manager.dispose();
|
|
443
|
+
manager.dispose();
|
|
444
|
+
|
|
445
|
+
expect(worker.terminate).toHaveBeenCalledTimes(1);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('rejects every other operation once disposed', () => {
|
|
449
|
+
const sender = { transform: null } as unknown as RTCRtpSender;
|
|
450
|
+
const receiver = { transform: null } as unknown as RTCRtpReceiver;
|
|
451
|
+
manager.dispose();
|
|
452
|
+
|
|
453
|
+
// The worker is gone, so postMessage is a silent no-op and an attached
|
|
454
|
+
// transform would stall forever. Fail loudly instead: a reused manager
|
|
455
|
+
// (e.g. rejoining a Call that kept it) must not look like it is working.
|
|
456
|
+
expect(() => manager.encrypt(sender, 'vp8')).toThrow(/is disposed/);
|
|
457
|
+
expect(() => manager.decrypt(receiver, 'remote-user')).toThrow(
|
|
458
|
+
/is disposed/,
|
|
459
|
+
);
|
|
460
|
+
expect(() =>
|
|
461
|
+
manager.setKey('user', 0, new Uint8Array(16).buffer),
|
|
462
|
+
).toThrow(/is disposed/);
|
|
463
|
+
expect(() => manager.setSharedKey(0, new Uint8Array(16).buffer)).toThrow(
|
|
464
|
+
/is disposed/,
|
|
465
|
+
);
|
|
466
|
+
expect(() => manager.removeSharedKey(0)).toThrow(/is disposed/);
|
|
467
|
+
expect(() => manager.removeKey('user', 0)).toThrow(/is disposed/);
|
|
468
|
+
expect(() => manager.removeAllKeys('user')).toThrow(/is disposed/);
|
|
469
|
+
expect(() => manager.requestKeyState()).toThrow(/is disposed/);
|
|
470
|
+
expect(() => manager.enablePerformanceReporting(true)).toThrow(
|
|
471
|
+
/is disposed/,
|
|
472
|
+
);
|
|
473
|
+
expect(sender.transform).toBeNull();
|
|
474
|
+
expect(receiver.transform).toBeNull();
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
/** Extract the private worker instance from the manager. */
|
|
480
|
+
function getWorker(mgr: EncryptionManager): Worker {
|
|
481
|
+
return mgr['worker' as keyof EncryptionManager] as unknown as Worker;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Extract a registered event handler from a mock worker. */
|
|
485
|
+
function getEventHandler(worker: Worker, event: string): (e: unknown) => void {
|
|
486
|
+
const calls = vi.mocked(worker.addEventListener).mock.calls;
|
|
487
|
+
const match = calls.find(([name]) => name === event);
|
|
488
|
+
if (!match) throw new Error(`No handler registered for '${event}'`);
|
|
489
|
+
return match[1] as (e: unknown) => void;
|
|
490
|
+
}
|