@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.
Files changed (130) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/e2ee-worker.browser.es.js +1342 -0
  3. package/dist/e2ee-worker.browser.es.js.map +1 -0
  4. package/dist/index.browser.es.js +1141 -117
  5. package/dist/index.browser.es.js.map +1 -1
  6. package/dist/index.cjs.js +1196 -115
  7. package/dist/index.cjs.js.map +1 -1
  8. package/dist/index.d.ts +5 -0
  9. package/dist/index.es.js +1174 -117
  10. package/dist/index.es.js.map +1 -1
  11. package/dist/src/Call.d.ts +66 -9
  12. package/dist/src/StreamVideoClient.d.ts +9 -1
  13. package/dist/src/coordinator/connection/connection.d.ts +1 -1
  14. package/dist/src/devices/CameraManager.d.ts +8 -1
  15. package/dist/src/devices/DeviceManager.d.ts +2 -1
  16. package/dist/src/devices/MicrophoneManager.d.ts +8 -1
  17. package/dist/src/devices/SpeakerManager.d.ts +2 -1
  18. package/dist/src/events/participant.d.ts +10 -3
  19. package/dist/src/gen/coordinator/index.d.ts +1386 -240
  20. package/dist/src/gen/video/sfu/models/models.d.ts +8 -0
  21. package/dist/src/helpers/MediaStreamRecorder.d.ts +26 -0
  22. package/dist/src/helpers/TypedEventEmitter.d.ts +23 -0
  23. package/dist/src/helpers/concurrency.d.ts +8 -0
  24. package/dist/src/helpers/ensureExhausted.d.ts +1 -1
  25. package/dist/src/helpers/loopback.d.ts +55 -0
  26. package/dist/src/permissions/PermissionsContext.d.ts +2 -2
  27. package/dist/src/reporting/ClientEventReporter.d.ts +0 -2
  28. package/dist/src/rtc/BasePeerConnection.d.ts +3 -1
  29. package/dist/src/rtc/Subscriber.d.ts +5 -0
  30. package/dist/src/rtc/e2ee/E2EEManager.d.ts +12 -0
  31. package/dist/src/rtc/e2ee/EncryptionManager.d.ts +148 -0
  32. package/dist/src/rtc/e2ee/e2ee-worker.d.ts +25 -0
  33. package/dist/src/rtc/e2ee/events.d.ts +163 -0
  34. package/dist/src/rtc/e2ee/transformSupport.d.ts +21 -0
  35. package/dist/src/rtc/helpers/rtcConfiguration.d.ts +2 -2
  36. package/dist/src/rtc/index.d.ts +1 -0
  37. package/dist/src/rtc/mediaEngine.d.ts +23 -0
  38. package/dist/src/rtc/types.d.ts +2 -0
  39. package/dist/src/store/CallState.d.ts +21 -0
  40. package/dist/src/types.d.ts +15 -6
  41. package/index.ts +5 -0
  42. package/package.json +1 -1
  43. package/src/Call.ts +311 -155
  44. package/src/StreamVideoClient.ts +35 -8
  45. package/src/__tests__/Call.lifecycle.test.ts +33 -2
  46. package/src/__tests__/Call.publishing.test.ts +42 -1
  47. package/src/__tests__/Call.test.ts +15 -0
  48. package/src/__tests__/StreamVideoClient.api.test.ts +33 -0
  49. package/src/__tests__/StreamVideoClient.rewatch.test.ts +213 -0
  50. package/src/__tests__/StreamVideoClient.ringing.test.ts +11 -4
  51. package/src/__tests__/StreamVideoClient.test.ts +14 -12
  52. package/src/coordinator/connection/connection.ts +6 -3
  53. package/src/devices/AudioDeviceManager.ts +6 -0
  54. package/src/devices/CameraManager.ts +67 -8
  55. package/src/devices/DeviceManager.ts +24 -4
  56. package/src/devices/MicrophoneManager.ts +66 -8
  57. package/src/devices/ScreenShareManager.ts +3 -0
  58. package/src/devices/SpeakerManager.ts +42 -12
  59. package/src/devices/__tests__/CameraManager.test.ts +27 -1
  60. package/src/devices/__tests__/DeviceManager.test.ts +49 -1
  61. package/src/devices/__tests__/MicrophoneManager.test.ts +23 -1
  62. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +1 -20
  63. package/src/devices/__tests__/SpeakerManager.test.ts +138 -42
  64. package/src/devices/__tests__/mocks.ts +36 -3
  65. package/src/events/__tests__/participant.test.ts +134 -1
  66. package/src/events/call.ts +6 -3
  67. package/src/events/callEventHandlers.ts +9 -3
  68. package/src/events/participant.ts +42 -7
  69. package/src/gen/coordinator/index.ts +1402 -236
  70. package/src/gen/video/sfu/models/models.ts +15 -0
  71. package/src/helpers/MediaStreamRecorder.ts +104 -0
  72. package/src/helpers/TypedEventEmitter.ts +71 -0
  73. package/src/helpers/__tests__/MediaStreamRecorder.test.ts +324 -0
  74. package/src/helpers/__tests__/TypedEventEmitter.test.ts +133 -0
  75. package/src/helpers/__tests__/concurrency.test.ts +74 -0
  76. package/src/helpers/__tests__/loopback.test.ts +367 -0
  77. package/src/helpers/concurrency.ts +24 -0
  78. package/src/helpers/ensureExhausted.ts +4 -2
  79. package/src/helpers/loopback.ts +200 -0
  80. package/src/permissions/PermissionsContext.ts +10 -5
  81. package/src/reporting/ClientEventReporter.ts +15 -28
  82. package/src/reporting/__tests__/ClientEventReporter.test.ts +3 -7
  83. package/src/rtc/BasePeerConnection.ts +12 -1
  84. package/src/rtc/Publisher.ts +8 -0
  85. package/src/rtc/Subscriber.ts +13 -0
  86. package/src/rtc/__tests__/Publisher.test.ts +101 -0
  87. package/src/rtc/__tests__/Subscriber.test.ts +190 -0
  88. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +31 -0
  89. package/src/rtc/e2ee/E2EEManager.ts +12 -0
  90. package/src/rtc/e2ee/EncryptionManager.ts +336 -0
  91. package/src/rtc/e2ee/SPEC.md +586 -0
  92. package/src/rtc/e2ee/__tests__/E2EEManager.contract.test.ts +39 -0
  93. package/src/rtc/e2ee/__tests__/EncryptionManager.test.ts +490 -0
  94. package/src/rtc/e2ee/__tests__/codec.test.ts +247 -0
  95. package/src/rtc/e2ee/__tests__/conformance.test.ts +184 -0
  96. package/src/rtc/e2ee/__tests__/failureTracker.test.ts +45 -0
  97. package/src/rtc/e2ee/__tests__/frameCounter.test.ts +90 -0
  98. package/src/rtc/e2ee/__tests__/keyStore.test.ts +221 -0
  99. package/src/rtc/e2ee/__tests__/notifications.test.ts +166 -0
  100. package/src/rtc/e2ee/__tests__/perf.test.ts +139 -0
  101. package/src/rtc/e2ee/__tests__/queue.test.ts +61 -0
  102. package/src/rtc/e2ee/__tests__/replayWindow.test.ts +133 -0
  103. package/src/rtc/e2ee/__tests__/trailer.test.ts +143 -0
  104. package/src/rtc/e2ee/__tests__/transform-pipeline.test.ts +839 -0
  105. package/src/rtc/e2ee/__tests__/transformSupport.test.ts +65 -0
  106. package/src/rtc/e2ee/e2ee-worker/codec.ts +220 -0
  107. package/src/rtc/e2ee/e2ee-worker/constants.ts +47 -0
  108. package/src/rtc/e2ee/e2ee-worker/decode.ts +159 -0
  109. package/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts +137 -0
  110. package/src/rtc/e2ee/e2ee-worker/encode.ts +147 -0
  111. package/src/rtc/e2ee/e2ee-worker/failureTracker.ts +26 -0
  112. package/src/rtc/e2ee/e2ee-worker/frameCounter.ts +37 -0
  113. package/src/rtc/e2ee/e2ee-worker/keyStore.ts +211 -0
  114. package/src/rtc/e2ee/e2ee-worker/notifications.ts +190 -0
  115. package/src/rtc/e2ee/e2ee-worker/perf.ts +129 -0
  116. package/src/rtc/e2ee/e2ee-worker/queue.ts +12 -0
  117. package/src/rtc/e2ee/e2ee-worker/replayWindow.ts +131 -0
  118. package/src/rtc/e2ee/e2ee-worker/trailer.ts +155 -0
  119. package/src/rtc/e2ee/e2ee-worker/tsconfig.json +8 -0
  120. package/src/rtc/e2ee/e2ee-worker/types.ts +29 -0
  121. package/src/rtc/e2ee/e2ee-worker.ts +27 -0
  122. package/src/rtc/e2ee/events.ts +179 -0
  123. package/src/rtc/e2ee/transformSupport.ts +35 -0
  124. package/src/rtc/helpers/rtcConfiguration.ts +4 -2
  125. package/src/rtc/index.ts +1 -0
  126. package/src/rtc/mediaEngine.ts +44 -0
  127. package/src/rtc/types.ts +2 -0
  128. package/src/store/CallState.ts +33 -1
  129. package/src/store/__tests__/CallState.test.ts +82 -1
  130. package/src/types.ts +23 -5
@@ -0,0 +1,839 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ COUNTER_HARD_LIMIT,
4
+ E2EE_VERSION,
5
+ FAILURE_TOLERANCE,
6
+ MAGIC,
7
+ MAX_CLEAR_BYTES,
8
+ RBSP_FLAG,
9
+ TRAILER_LEN,
10
+ } from '../e2ee-worker/constants';
11
+
12
+ type Posted = {
13
+ type?: string;
14
+ userId?: string;
15
+ keyIndex?: number;
16
+ trackType?: string;
17
+ };
18
+ const posted: Posted[] = [];
19
+
20
+ // The worker registers its 'message' / 'rtctransform' listeners at import time
21
+ // and uses self.postMessage. Capture the listeners and the posted messages so
22
+ // the tests can drive the worker through its real message interface.
23
+ const handlers: Record<string, (e: { data: unknown }) => void> = {};
24
+ vi.stubGlobal(
25
+ 'addEventListener',
26
+ (type: string, h: (e: { data: unknown }) => void) => {
27
+ handlers[type] = h;
28
+ },
29
+ );
30
+ vi.stubGlobal('self', { postMessage: (m: Posted) => void posted.push(m) });
31
+
32
+ // Import AFTER stubbing so the top-level addEventListener calls are captured.
33
+ await import('../e2ee-worker/e2ee-worker-impl');
34
+ // `enqueue` is the worker's own serial message queue; awaiting a no-op task
35
+ // flushes everything queued before it (e.g. an async setKey).
36
+ const { enqueue } = await import('../e2ee-worker/queue');
37
+ // Test seams: position the per-user frame counter so we can hit the low
38
+ // values whose big-endian encoding forms Annex-B start codes, and reset the
39
+ // worker's module-level key state between tests (production teardown is
40
+ // Worker.terminate(), which tests cannot use).
41
+ const { __resetFrameCounterForTest, __setFrameCounterForTest } =
42
+ await import('../e2ee-worker/frameCounter');
43
+ const { keyStore } = await import('../e2ee-worker/keyStore');
44
+
45
+ type Frame = {
46
+ data: ArrayBuffer;
47
+ type?: 'key' | 'delta' | 'empty';
48
+ timestamp: number;
49
+ };
50
+
51
+ const KEY = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
52
+
53
+ const message = (data: unknown) => handlers.message({ data });
54
+ const flush = () => enqueue(async () => undefined);
55
+
56
+ const setKey = async (userId: string, keyIndex = 0) => {
57
+ message({
58
+ type: 'cmd.set_key',
59
+ userId,
60
+ keyIndex,
61
+ rawKey: new Uint8Array(KEY).buffer,
62
+ });
63
+ await flush();
64
+ };
65
+ const setSharedKey = async (keyIndex: number, rawKey = KEY) => {
66
+ message({
67
+ type: 'cmd.set_shared_key',
68
+ keyIndex,
69
+ rawKey: new Uint8Array(rawKey).buffer,
70
+ });
71
+ await flush();
72
+ };
73
+ const removeSharedKey = async (keyIndex: number) => {
74
+ message({ type: 'cmd.remove_shared_key', keyIndex });
75
+ await flush();
76
+ };
77
+ const removeAllKeys = async (userId: string) => {
78
+ message({ type: 'cmd.remove_all_keys', userId });
79
+ await flush();
80
+ };
81
+
82
+ // `type` is required, and `undefined` is meaningful: the absence of a key/delta
83
+ // type is exactly how the worker recognizes an audio frame, so a default here
84
+ // would silently turn audio cases into delta video ones.
85
+ const frame = (bytes: number[], type: Frame['type']): Frame => ({
86
+ data: new Uint8Array(bytes).buffer,
87
+ type,
88
+ timestamp: 1,
89
+ });
90
+
91
+ const cloneFrame = (source: Frame): Frame => ({
92
+ ...source,
93
+ data: source.data.slice(0),
94
+ });
95
+
96
+ // Attach a transform via the real worker message path (Insertable Streams
97
+ // setup branch) and run frames through it, returning what it emits.
98
+ const drive = async (
99
+ operation: 'encode' | 'decode',
100
+ userId: string,
101
+ codec: string | undefined,
102
+ frames: Frame[],
103
+ trackType?: string,
104
+ ): Promise<Frame[]> => {
105
+ const out: Frame[] = [];
106
+ const readable = new ReadableStream<Frame>({
107
+ start(c) {
108
+ for (const f of frames) c.enqueue(f);
109
+ c.close();
110
+ },
111
+ });
112
+ let resolveDone!: () => void;
113
+ const done = new Promise<void>((r) => (resolveDone = r));
114
+ const writable = new WritableStream<Frame>({
115
+ write(f) {
116
+ out.push(f);
117
+ },
118
+ close: () => resolveDone(),
119
+ abort: () => resolveDone(),
120
+ });
121
+ message({
122
+ type: 'cmd.setup_transform',
123
+ readable,
124
+ writable,
125
+ operation,
126
+ userId,
127
+ codec,
128
+ trackType,
129
+ });
130
+ await done;
131
+ return out;
132
+ };
133
+
134
+ let nextUser = 0;
135
+ const freshUser = () => `user-${nextUser++}`;
136
+
137
+ const roundTrip = async (
138
+ codec: string,
139
+ plaintext: number[],
140
+ type: Frame['type'],
141
+ ): Promise<number[]> => {
142
+ const user = freshUser();
143
+ await setKey(user);
144
+ const [encrypted] = await drive('encode', user, codec, [
145
+ frame(plaintext, type),
146
+ ]);
147
+ expect(encrypted).toBeDefined();
148
+ expect(Array.from(new Uint8Array(encrypted.data))).not.toEqual(plaintext);
149
+ // The decode side is codec-blind: it detects the format from the bytes.
150
+ const [decrypted] = await drive('decode', user, undefined, [encrypted]);
151
+ expect(decrypted).toBeDefined();
152
+ return Array.from(new Uint8Array(decrypted.data));
153
+ };
154
+
155
+ beforeEach(() => {
156
+ posted.length = 0;
157
+ });
158
+ afterEach(async () => {
159
+ await flush();
160
+ keyStore.clear();
161
+ __resetFrameCounterForTest();
162
+ });
163
+
164
+ // The worker's message interface is its whole public surface, so the commands
165
+ // that carry no frames still need a wire test. What each command *does* is
166
+ // covered by the module that owns it (perf.test.ts, crypto.test.ts); these
167
+ // cover the dispatch reaching it.
168
+ describe('worker command interface', () => {
169
+ it('answers cmd.request_key_state with fingerprints, never key material', async () => {
170
+ const user = freshUser();
171
+ await setKey(user, 4);
172
+ posted.length = 0;
173
+ message({ type: 'cmd.request_key_state' });
174
+ await flush();
175
+ const dump = posted.find((m) => m.type === 'e2ee.key_state') as
176
+ { perUserKeys: Array<{ userId: string; keyIndex: number }> } | undefined;
177
+ expect(dump).toBeDefined();
178
+ expect(dump!.perUserKeys).toContainEqual(
179
+ expect.objectContaining({ userId: user, keyIndex: 4 }),
180
+ );
181
+ // The raw bytes must never leave the worker.
182
+ expect(JSON.stringify(dump)).not.toContain(KEY.join(','));
183
+ });
184
+
185
+ it('starts and stops performance reporting', async () => {
186
+ const user = freshUser();
187
+ await setKey(user);
188
+ // Streams settle on microtasks, so faking timers here only controls the
189
+ // reporter's own interval.
190
+ vi.useFakeTimers();
191
+ try {
192
+ message({ type: 'cmd.enable_performance_reporting', enabled: true });
193
+ await flush();
194
+ await drive('encode', user, 'vp8', [frame([1, 2, 3, 4, 5, 6], 'delta')]);
195
+
196
+ posted.length = 0;
197
+ vi.advanceTimersByTime(1000);
198
+ expect(posted.some((m) => m.type === 'e2ee.perf_report')).toBe(true);
199
+
200
+ message({ type: 'cmd.enable_performance_reporting', enabled: false });
201
+ await flush();
202
+ posted.length = 0;
203
+ vi.advanceTimersByTime(3000);
204
+ expect(posted.some((m) => m.type === 'e2ee.perf_report')).toBe(false);
205
+ } finally {
206
+ vi.useRealTimers();
207
+ }
208
+ });
209
+
210
+ it('reports an unknown command instead of failing silently', async () => {
211
+ message({ type: 'cmd.not_a_real_command' });
212
+ await flush();
213
+ expect(posted).toEqual([
214
+ {
215
+ type: 'e2ee.error',
216
+ message: expect.stringContaining('cmd.not_a_real_command'),
217
+ },
218
+ ]);
219
+ });
220
+ });
221
+
222
+ describe('encode -> decode pipeline round-trips', () => {
223
+ it.each([
224
+ [
225
+ 'vp8 (clear-prefix + trailer path)',
226
+ 'vp8',
227
+ 'delta' as const,
228
+ [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
229
+ ],
230
+ [
231
+ 'h264 with a slice NALU (RBSP-escape path)',
232
+ 'h264',
233
+ 'key' as const,
234
+ [
235
+ ...[0, 0, 0, 1, 0x67, 0x42, 0x00, 0x0a], // SPS
236
+ ...[0, 0, 0, 1, 0x65, 0xb8, 0x40], // slice start code + type 5 + 2 bytes
237
+ ...[0xaa, 0xbb, 0xcc, 0xdd, 0xee], // body (encrypted)
238
+ ],
239
+ ],
240
+ [
241
+ 'h264 with no slice NALU (clearBytes 0 path)',
242
+ 'h264',
243
+ 'key' as const,
244
+ [0, 0, 0, 1, 0x67, 0x42, 0x00, 0x0a],
245
+ ],
246
+ ])('%s', async (_label, codec, type, plaintext) => {
247
+ expect(await roundTrip(codec, plaintext, type)).toEqual(plaintext);
248
+ });
249
+
250
+ it('opus (audio, 1 clear byte)', async () => {
251
+ const pt = [0x78, 0xaa, 0xbb, 0xcc, 0xdd];
252
+ // An audio frame is recognized by the ABSENCE of a key/delta type, and that
253
+ // is what selects the 1-byte Opus TOC clear header.
254
+ const user = freshUser();
255
+ await setKey(user);
256
+ const [encrypted] = await drive('encode', user, 'opus', [
257
+ frame(pt, undefined),
258
+ ]);
259
+ const bytes = Array.from(new Uint8Array(encrypted.data));
260
+ // TOC byte in the clear (the SFU reads it), everything after it encrypted.
261
+ expect(bytes[0]).toBe(0x78);
262
+ expect(bytes.slice(1, pt.length)).not.toEqual(pt.slice(1));
263
+ const [decrypted] = await drive('decode', user, undefined, [encrypted]);
264
+ expect(Array.from(new Uint8Array(decrypted.data))).toEqual(pt);
265
+ });
266
+
267
+ it('keeps old shared epochs decryptable until explicitly removed', async () => {
268
+ const user = freshUser();
269
+ const oldPlaintext = [1, 2, 3, 4, 5, 6, 7, 8];
270
+ const newPlaintext = [9, 10, 11, 12, 13, 14, 15, 16];
271
+ const nextKey = KEY.map((byte) => byte + 16);
272
+
273
+ await setSharedKey(1);
274
+ const [oldEncrypted] = await drive('encode', user, 'vp8', [
275
+ frame(oldPlaintext, 'delta'),
276
+ ]);
277
+
278
+ await setSharedKey(2, nextKey);
279
+ const [newEncrypted] = await drive('encode', user, 'vp8', [
280
+ frame(newPlaintext, 'delta'),
281
+ ]);
282
+
283
+ const duringRotation = await drive('decode', user, undefined, [
284
+ cloneFrame(oldEncrypted),
285
+ cloneFrame(newEncrypted),
286
+ ]);
287
+ expect(
288
+ duringRotation.map((decoded) => Array.from(new Uint8Array(decoded.data))),
289
+ ).toEqual([oldPlaintext, newPlaintext]);
290
+
291
+ await removeSharedKey(1);
292
+ posted.length = 0;
293
+ expect(
294
+ await drive('decode', user, undefined, [cloneFrame(oldEncrypted)]),
295
+ ).toHaveLength(0);
296
+ expect(posted).toContainEqual(
297
+ expect.objectContaining({
298
+ type: 'e2ee.missing_key',
299
+ userId: user,
300
+ keyIndex: 1,
301
+ }),
302
+ );
303
+
304
+ const [stillDecryptable] = await drive('decode', user, undefined, [
305
+ cloneFrame(newEncrypted),
306
+ ]);
307
+ expect(Array.from(new Uint8Array(stillDecryptable.data))).toEqual(
308
+ newPlaintext,
309
+ );
310
+ });
311
+ });
312
+
313
+ describe('decode pipeline edge behaviors', () => {
314
+ it('passes an unencrypted frame through, but signals it', async () => {
315
+ const user = freshUser();
316
+ const bytes = [9, 9, 9, 9, 9];
317
+ const [out] = await drive(
318
+ 'decode',
319
+ user,
320
+ undefined,
321
+ [frame(bytes, 'delta')],
322
+ 'SCREEN_SHARE',
323
+ );
324
+ expect(Array.from(new Uint8Array(out.data))).toEqual(bytes);
325
+ // Forwarded as-is (a peer may publish plain), but never silently: the host
326
+ // needs a signal to notice a downgrade on a call where everyone should encrypt.
327
+ expect(posted).toEqual([
328
+ {
329
+ type: 'e2ee.unencrypted_frame',
330
+ userId: user,
331
+ trackType: 'SCREEN_SHARE',
332
+ },
333
+ ]);
334
+ });
335
+
336
+ it('drops a frame from a newer framing version instead of forwarding it', async () => {
337
+ const user = freshUser();
338
+ await setKey(user);
339
+ const [encrypted] = await drive('encode', user, 'vp8', [
340
+ frame([1, 2, 3, 4, 5, 6, 7, 8], 'delta'),
341
+ ]);
342
+ const future = new Uint8Array(encrypted.data.slice(0));
343
+ future[future.length - 5] = E2EE_VERSION + 1;
344
+ posted.length = 0;
345
+
346
+ const out = await drive('decode', user, undefined, [
347
+ { ...encrypted, data: future.buffer },
348
+ ]);
349
+
350
+ // Forwarding would hand ciphertext to the decoder: corrupt media, reported
351
+ // as a downgrade. The peer is simply newer, so drop and say which version.
352
+ expect(out).toEqual([]);
353
+ expect(posted).toEqual([
354
+ {
355
+ type: 'e2ee.unsupported_version',
356
+ userId: user,
357
+ version: E2EE_VERSION + 1,
358
+ trackType: undefined,
359
+ },
360
+ ]);
361
+ });
362
+
363
+ it('drops and signals missing_key when the key is gone', async () => {
364
+ const user = freshUser();
365
+ await setKey(user);
366
+ const [encrypted] = await drive('encode', user, 'vp8', [
367
+ frame([1, 2, 3, 4, 5, 6, 7, 8], 'delta'),
368
+ ]);
369
+ await removeAllKeys(user);
370
+ posted.length = 0;
371
+ const out = await drive('decode', user, undefined, [encrypted], 'AUDIO');
372
+ expect(out).toHaveLength(0);
373
+ // Not holding the key is reported apart from a failed decrypt, so a host can
374
+ // tell key distribution lag from a mismatched or tampered frame.
375
+ expect(posted).toEqual([
376
+ {
377
+ type: 'e2ee.missing_key',
378
+ userId: user,
379
+ keyIndex: 0,
380
+ trackType: 'AUDIO',
381
+ },
382
+ ]);
383
+ expect(posted.some((m) => m.type === 'e2ee.decryption_failed')).toBe(false);
384
+ });
385
+
386
+ it('drops a replayed frame silently (no failure event)', async () => {
387
+ const user = freshUser();
388
+ await setKey(user);
389
+ const [encrypted] = await drive('encode', user, 'vp8', [
390
+ frame([1, 2, 3, 4, 5, 6, 7, 8], 'delta'),
391
+ ]);
392
+ const clone: Frame = { ...encrypted, data: encrypted.data.slice(0) };
393
+ posted.length = 0;
394
+ // Both frames go through ONE decode transform (shared replay window).
395
+ const out = await drive('decode', user, undefined, [encrypted, clone]);
396
+ expect(out).toHaveLength(1); // first decrypts, second is a replay
397
+ expect(posted.some((m) => m.type === 'e2ee.decryption_failed')).toBe(false);
398
+ });
399
+
400
+ it('signals decryption_failed on a tampered frame', async () => {
401
+ const user = freshUser();
402
+ await setKey(user);
403
+ const [encrypted] = await drive('encode', user, 'vp8', [
404
+ frame([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 'delta'),
405
+ ]);
406
+ const tampered = new Uint8Array(encrypted.data);
407
+ tampered[5] ^= 0xff; // flip a ciphertext byte
408
+ posted.length = 0;
409
+ const out = await drive('decode', user, undefined, [
410
+ { ...encrypted, data: tampered.buffer },
411
+ ]);
412
+ expect(out).toHaveLength(0);
413
+ expect(posted.some((m) => m.type === 'e2ee.decryption_failed')).toBe(true);
414
+ });
415
+
416
+ it('pairs every delivered decryption_failed with a decryption_resumed', async () => {
417
+ const user = freshUser();
418
+ await setKey(user);
419
+ const pt = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
420
+ const [encrypted] = await drive('encode', user, 'vp8', [
421
+ frame(pt, 'delta'),
422
+ ]);
423
+ const corrupt = (): Frame => {
424
+ const b = new Uint8Array(encrypted.data.slice(0));
425
+ b[5] ^= 0xff;
426
+ return { ...encrypted, data: b.buffer };
427
+ };
428
+ const valid = (): Frame => ({
429
+ ...encrypted,
430
+ data: encrypted.data.slice(0),
431
+ });
432
+ posted.length = 0;
433
+ // Two full fail -> recover cycles inside one throttle window. `resumed` is
434
+ // an edge, so throttling it would drop a transition permanently and leave
435
+ // the host latched on `failed` for a track that is fine.
436
+ await drive(
437
+ 'decode',
438
+ user,
439
+ undefined,
440
+ [corrupt(), valid(), corrupt(), valid()],
441
+ 'VIDEO',
442
+ );
443
+ const signals = posted
444
+ .map((m) => m.type)
445
+ .filter(
446
+ (t) =>
447
+ t === 'e2ee.decryption_failed' || t === 'e2ee.decryption_resumed',
448
+ );
449
+ // Never more recoveries than failures, never fewer, and the run ends on the
450
+ // recovery - so the host's last signal matches the track's real state.
451
+ const failed = signals.filter((t) => t === 'e2ee.decryption_failed').length;
452
+ const resumed = signals.filter(
453
+ (t) => t === 'e2ee.decryption_resumed',
454
+ ).length;
455
+ expect(resumed).toBe(failed);
456
+ expect(signals.at(-1)).toBe('e2ee.decryption_resumed');
457
+ });
458
+
459
+ it('clears a reported failure when the track recovers on a NEW keyIndex', async () => {
460
+ const user = freshUser();
461
+ await setKey(user, 0);
462
+ const pt = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
463
+ const [atKey0] = await drive('encode', user, 'vp8', [frame(pt, 'delta')]);
464
+ await setKey(user, 1);
465
+ const [atKey1] = await drive('encode', user, 'vp8', [frame(pt, 'delta')]);
466
+ const tampered = new Uint8Array(atKey0.data.slice(0));
467
+ tampered[5] ^= 0xff;
468
+
469
+ posted.length = 0;
470
+ // The failure count is per keyIndex, but the host was told "this TRACK is
471
+ // failing" with no index, so a rotation that fixes the track has to clear
472
+ // it. Gating recovery on the failing key's own count would latch forever.
473
+ await drive(
474
+ 'decode',
475
+ user,
476
+ undefined,
477
+ [{ ...atKey0, data: tampered.buffer }, atKey1],
478
+ 'VIDEO',
479
+ );
480
+ expect(posted.filter((m) => m.type === 'e2ee.decryption_failed')).toEqual([
481
+ { type: 'e2ee.decryption_failed', userId: user, trackType: 'VIDEO' },
482
+ ]);
483
+ expect(posted.filter((m) => m.type === 'e2ee.decryption_resumed')).toEqual([
484
+ { type: 'e2ee.decryption_resumed', userId: user, trackType: 'VIDEO' },
485
+ ]);
486
+ });
487
+
488
+ it('survives an RBSP frame that unescapes shorter than the trailer', async () => {
489
+ const user = freshUser();
490
+ await setKey(user);
491
+ const [genuine] = await drive('encode', user, 'h264', [
492
+ frame([0, 0, 0, 1, 0x65, 0x88, 0x11, 0x22, 0x33, 0x44], 'key'),
493
+ ]);
494
+ // Forge a frame that readTrailer accepts as RBSP - valid magic, version and
495
+ // clearBytes in the start-code-safe tail - whose escaped region unescapes to
496
+ // fewer than TRAILER_LEN bytes. Reading the trailer at a negative offset
497
+ // throws, and an unguarded throw out of transform() errors the stream and
498
+ // kills this track's pipeline for good.
499
+ const forged = new Uint8Array(40);
500
+ const view = new DataView(forged.buffer);
501
+ const clearBytes = 20;
502
+ for (let i = 0; i < 3; i++) {
503
+ forged.set([0x00, 0x00, 0x03, 0x00], clearBytes + i * 4);
504
+ }
505
+ view.setUint16(forged.length - 7, RBSP_FLAG | clearBytes);
506
+ forged[forged.length - 5] = E2EE_VERSION;
507
+ view.setUint32(forged.length - 4, MAGIC);
508
+ posted.length = 0;
509
+ // The forged frame arrives first; the genuine frame must still decrypt.
510
+ const out = await drive('decode', user, undefined, [
511
+ { ...genuine, data: forged.buffer },
512
+ genuine,
513
+ ]);
514
+ expect(out).toHaveLength(1);
515
+ expect(posted.some((m) => m.type === 'e2ee.error')).toBe(false);
516
+ });
517
+
518
+ // --- authenticate-before-mutate ------------------------------------------
519
+
520
+ it('a forged max-counter frame does not freeze the track', async () => {
521
+ const user = freshUser();
522
+ await setKey(user);
523
+ // Two genuine frames (counters 1 and 2 for this user).
524
+ const [g1, g2] = await drive('encode', user, 'vp8', [
525
+ frame([1, 2, 3, 4, 5, 6, 7, 8], 'delta'),
526
+ frame([9, 10, 11, 12, 13, 14, 15, 16], 'delta'),
527
+ ]);
528
+ // Forge a frame: copy g1 (real ivPrefix + keyIndex), rewrite the trailer
529
+ // counter to the 32-bit max, and corrupt the body so GCM rejects it.
530
+ const forged = new Uint8Array(g1.data.slice(0));
531
+ new DataView(forged.buffer).setUint32(
532
+ forged.length - TRAILER_LEN,
533
+ COUNTER_HARD_LIMIT,
534
+ );
535
+ forged[5] ^= 0xff;
536
+ posted.length = 0;
537
+ // The forged frame arrives first, then the genuine frames. With the old
538
+ // mutate-before-auth window the forged max counter advanced `highest` to
539
+ // 2^32-1, dropping every later genuine frame as "older than the window".
540
+ const out = await drive('decode', user, undefined, [
541
+ { ...g1, data: forged.buffer },
542
+ g1,
543
+ g2,
544
+ ]);
545
+ expect(out).toHaveLength(2);
546
+ expect(Array.from(new Uint8Array(out[0].data))).toEqual([
547
+ 1, 2, 3, 4, 5, 6, 7, 8,
548
+ ]);
549
+ expect(Array.from(new Uint8Array(out[1].data))).toEqual([
550
+ 9, 10, 11, 12, 13, 14, 15, 16,
551
+ ]);
552
+ });
553
+
554
+ it('keeps attempting decryption after the failure tolerance is exceeded', async () => {
555
+ const user = freshUser();
556
+ await setKey(user);
557
+ // Encode FAILURE_TOLERANCE + 2 genuine frames with distinct rising
558
+ // counters, then tamper all but the last.
559
+ const n = FAILURE_TOLERANCE + 2;
560
+ const plaintexts = Array.from({ length: n }, (_, i) => [
561
+ 1,
562
+ 2,
563
+ 3,
564
+ 4,
565
+ 5,
566
+ 6,
567
+ 7,
568
+ 8,
569
+ i & 0xff,
570
+ ]);
571
+ const encrypted = await drive(
572
+ 'encode',
573
+ user,
574
+ 'vp8',
575
+ plaintexts.map((p) => frame(p, 'delta')),
576
+ );
577
+ expect(encrypted).toHaveLength(n);
578
+ const garbage = encrypted.slice(0, n - 1).map((f) => {
579
+ const bytes = new Uint8Array(f.data.slice(0));
580
+ bytes[5] ^= 0xff; // flip a ciphertext byte; trailer/counter stay intact
581
+ return { ...f, data: bytes.buffer };
582
+ });
583
+ const genuine = encrypted[n - 1];
584
+ posted.length = 0;
585
+ const out = await drive(
586
+ 'decode',
587
+ user,
588
+ undefined,
589
+ [...garbage, genuine],
590
+ 'VIDEO',
591
+ );
592
+ // The genuine final frame still decrypts — the key was NOT latched invalid
593
+ // by the preceding failure burst.
594
+ expect(out).toHaveLength(1);
595
+ expect(Array.from(new Uint8Array(out[0].data))).toEqual(plaintexts[n - 1]);
596
+ // The break is surfaced once (on the tolerance crossing) and recovery once.
597
+ // Both name the track: a peer's audio and video are separate transforms
598
+ // reported under one userId, so a host cannot pair them up without this.
599
+ expect(posted.filter((m) => m.type === 'e2ee.decryption_stalled')).toEqual([
600
+ {
601
+ type: 'e2ee.decryption_stalled',
602
+ userId: user,
603
+ keyIndex: 0,
604
+ trackType: 'VIDEO',
605
+ },
606
+ ]);
607
+ expect(posted.filter((m) => m.type === 'e2ee.decryption_resumed')).toEqual([
608
+ { type: 'e2ee.decryption_resumed', userId: user, trackType: 'VIDEO' },
609
+ ]);
610
+ expect(posted.filter((m) => m.type === 'e2ee.decryption_failed')).toEqual([
611
+ { type: 'e2ee.decryption_failed', userId: user, trackType: 'VIDEO' },
612
+ ]);
613
+ });
614
+
615
+ it('scopes failure accounting per track so a healthy track cannot mask a broken one', async () => {
616
+ const user = freshUser();
617
+ await setKey(user);
618
+ // A video track and an audio track for the SAME user + keyIndex. Encode
619
+ // FAILURE_TOLERANCE + 1 video frames (then tamper them so each fails GCM)
620
+ // and one genuine audio frame on the same shared key.
621
+ const n = FAILURE_TOLERANCE + 1;
622
+ const videoPts = Array.from({ length: n }, (_, i) => [
623
+ 1,
624
+ 2,
625
+ 3,
626
+ 4,
627
+ 5,
628
+ 6,
629
+ 7,
630
+ 8,
631
+ i & 0xff,
632
+ ]);
633
+ const videoEnc = await drive(
634
+ 'encode',
635
+ user,
636
+ 'vp8',
637
+ videoPts.map((p) => frame(p, 'delta')),
638
+ );
639
+ const [audioEnc] = await drive('encode', user, 'opus', [
640
+ frame([0x01, 0x02, 0x03, 0x04], undefined),
641
+ ]);
642
+ const tamperedVideo = videoEnc.map((f) => {
643
+ const bytes = new Uint8Array(f.data.slice(0));
644
+ bytes[5] ^= 0xff;
645
+ return { ...f, data: bytes.buffer };
646
+ });
647
+
648
+ // Video decode transform: every frame fails, so the break surfaces once.
649
+ posted.length = 0;
650
+ const vOut = await drive('decode', user, undefined, tamperedVideo);
651
+ expect(vOut).toHaveLength(0);
652
+ expect(
653
+ posted.filter((m) => m.type === 'e2ee.decryption_stalled'),
654
+ ).toHaveLength(1);
655
+
656
+ // Audio decode transform (a SEPARATE track): the genuine frame decrypts and
657
+ // must NOT emit decryption_resumed - this track never failed. With the old
658
+ // per-(user, keyIndex) counter shared across tracks, the audio success reset
659
+ // the video failures and spuriously "resumed" (and kept e2ee.decryption_stalled from
660
+ // ever firing).
661
+ posted.length = 0;
662
+ const aOut = await drive('decode', user, undefined, [audioEnc]);
663
+ expect(aOut).toHaveLength(1);
664
+ expect(posted.some((m) => m.type === 'e2ee.decryption_resumed')).toBe(
665
+ false,
666
+ );
667
+ });
668
+ });
669
+
670
+ describe('encode pipeline edge behaviors', () => {
671
+ it('drops and signals missing_key when no key is set', async () => {
672
+ const user = freshUser();
673
+ const out = await drive('encode', user, 'vp8', [
674
+ frame([1, 2, 3, 4, 5], 'delta'),
675
+ ]);
676
+ expect(out).toHaveLength(0);
677
+ expect(posted.some((m) => m.type === 'e2ee.missing_key')).toBe(true);
678
+ });
679
+
680
+ it('fails closed and signals encryption_failed on an unsupported codec', async () => {
681
+ const user = freshUser();
682
+ await setKey(user);
683
+ // A codec the worker can't split must not be published in the clear and
684
+ // must not stall the encoder (the old behavior left frames buffering with
685
+ // no signal). The pipeline drains - nothing is emitted - and the failure is
686
+ // observable via e2ee.encryption_failed.
687
+ const out = await drive('encode', user, 'theora', [
688
+ frame([1, 2, 3, 4, 5], 'delta'),
689
+ ]);
690
+ expect(out).toHaveLength(0);
691
+ expect(posted.some((m) => m.type === 'e2ee.encryption_failed')).toBe(true);
692
+ }, 3000);
693
+
694
+ it('fails closed on a video frame whose codec was not supplied', async () => {
695
+ const user = freshUser();
696
+ await setKey(user);
697
+ // Without a codec there is no clear-byte rule and no escaping decision for
698
+ // video. Encrypting it whole would blind the SFU to the frame headers, and
699
+ // an unescaped H264 payload would be split by the packetizer on a random
700
+ // start code in the ciphertext - silently, since nothing would signal it.
701
+ const out = await drive('encode', user, undefined, [
702
+ frame([1, 2, 3, 4, 5, 6, 7, 8], 'key'),
703
+ ]);
704
+ expect(out).toHaveLength(0);
705
+ expect(posted.some((m) => m.type === 'e2ee.encryption_failed')).toBe(true);
706
+ }, 3000);
707
+
708
+ it('still encrypts an audio frame whose codec was not supplied', async () => {
709
+ const user = freshUser();
710
+ await setKey(user);
711
+ // The counterpart to the case above: an audio frame carries no key/delta
712
+ // type, and the 1-byte TOC rule holds for any audio codec, so an unlabeled
713
+ // audio track must keep working.
714
+ const pt = [0x78, 0xaa, 0xbb, 0xcc, 0xdd];
715
+ const [encrypted] = await drive('encode', user, undefined, [
716
+ frame(pt, undefined),
717
+ ]);
718
+ expect(encrypted).toBeDefined();
719
+ expect(new Uint8Array(encrypted.data)[0]).toBe(0x78);
720
+ expect(posted.some((m) => m.type === 'e2ee.encryption_failed')).toBe(false);
721
+ const [decrypted] = await drive('decode', user, undefined, [encrypted]);
722
+ expect(Array.from(new Uint8Array(decrypted.data))).toEqual(pt);
723
+ });
724
+
725
+ it('re-signals encryption_failed after recovery instead of latching for the worker lifetime', async () => {
726
+ const user = freshUser();
727
+ await setKey(user);
728
+ // One h264 encode transform: a frame whose clear header exceeds the
729
+ // trailer's 15-bit clearBytes field fails to encrypt, a normal keyframe then
730
+ // encrypts (recovering the track), and a second bad frame must signal AGAIN.
731
+ // The old worker-lifetime latch emitted only the first signal and then went
732
+ // silent forever - so a later permanent fail-closed dropped every frame with
733
+ // no event.
734
+ const bad = () => {
735
+ // Zero padding, then an IDR slice start code far enough in that
736
+ // h264ClearBytes reports a clear header past MAX_CLEAR_BYTES.
737
+ const bytes = new Array(MAX_CLEAR_BYTES + 8).fill(0);
738
+ bytes.splice(MAX_CLEAR_BYTES, 6, 0, 0, 1, 0x65, 0xaa, 0xbb);
739
+ return frame(bytes, 'key');
740
+ };
741
+ const good = () =>
742
+ frame([0, 0, 0, 1, 0x65, 0x88, 0x11, 0x22, 0x33, 0x44], 'key');
743
+ posted.length = 0;
744
+ const out = await drive('encode', user, 'h264', [bad(), good(), bad()]);
745
+ // Only the valid frame is emitted; both bad frames are dropped (fail closed).
746
+ expect(out).toHaveLength(1);
747
+ // Signaled on the first bad frame, re-armed by the good frame, signaled
748
+ // again on the second bad frame.
749
+ expect(
750
+ posted.filter((m) => m.type === 'e2ee.encryption_failed'),
751
+ ).toHaveLength(2);
752
+ });
753
+ });
754
+
755
+ describe('h264 trailer start-code safety', () => {
756
+ // SPS NALU, then an IDR slice NALU. h264ClearBytes leaves the start code +
757
+ // NALU header + 2 slice-header bytes in the clear (14 bytes here) and
758
+ // encrypts the rest.
759
+ const H264_KEYFRAME = [
760
+ 0,
761
+ 0,
762
+ 0,
763
+ 1,
764
+ 0x67,
765
+ 0x42,
766
+ 0x00,
767
+ 0x0a, // SPS
768
+ 0,
769
+ 0,
770
+ 0,
771
+ 1,
772
+ 0x65,
773
+ 0xb8,
774
+ 0x40, // IDR slice: start code + NALU header + 1 byte
775
+ 0xaa,
776
+ 0xbb,
777
+ 0xcc,
778
+ 0xdd,
779
+ 0xee, // encrypted body
780
+ ];
781
+ const H264_CLEAR_BYTES = 14;
782
+
783
+ const hasAnnexBStartCode = (b: Uint8Array): boolean => {
784
+ for (let i = 0; i + 2 < b.length; i++) {
785
+ if (b[i] === 0 && b[i + 1] === 0 && b[i + 2] === 1) return true;
786
+ }
787
+ return false;
788
+ };
789
+
790
+ it.each([
791
+ ['counter 1 -> 00 00 00 01', 0],
792
+ ['counter 256 -> 00 00 01 00', 255],
793
+ ])(
794
+ 'leaves no fake Annex-B start code in the encrypted region (%s)',
795
+ async (_label, seed) => {
796
+ const user = freshUser();
797
+ await setKey(user);
798
+ __setFrameCounterForTest(seed);
799
+ const [encrypted] = await drive('encode', user, 'h264', [
800
+ frame(H264_KEYFRAME, 'key'),
801
+ ]);
802
+ expect(encrypted).toBeDefined();
803
+ const bytes = new Uint8Array(encrypted.data);
804
+ // The clear NALU header legitimately carries start codes; only the
805
+ // encrypted region after it must be start-code free, or libwebrtc's H264
806
+ // packetizer would split a spurious NALU and corrupt the frame.
807
+ expect(hasAnnexBStartCode(bytes.subarray(H264_CLEAR_BYTES))).toBe(false);
808
+ },
809
+ );
810
+
811
+ it('leaves no start code across the clear/encrypted boundary when the clear header ends in 0x00', async () => {
812
+ // The last clear byte is the first slice-header byte, which multi-slice
813
+ // encoders can emit as 0x00 (large first_mb_in_slice). If the ciphertext
814
+ // then starts 00 01, an unseeded escaper would ship a fake start code
815
+ // spanning the boundary. Ciphertext is not controllable here, so assert
816
+ // the invariant over the boundary region on many frames instead.
817
+ const h264ZeroTail = [
818
+ ...[0, 0, 0, 1, 0x65, 0x00], // slice: start code + NALU header + 0x00
819
+ ...[0xaa, 0xbb, 0xcc, 0xdd, 0xee], // encrypted body
820
+ ];
821
+ const clearBytes = 6;
822
+ const user = freshUser();
823
+ await setKey(user);
824
+ const frames = Array.from({ length: 32 }, () =>
825
+ frame(h264ZeroTail, 'key' as const),
826
+ );
827
+ const encrypted = await drive('encode', user, 'h264', frames);
828
+ expect(encrypted.length).toBe(frames.length);
829
+ for (const f of encrypted) {
830
+ const bytes = new Uint8Array(f.data);
831
+ // Scan from 2 bytes before the boundary so a code spanning it is seen.
832
+ expect(hasAnnexBStartCode(bytes.subarray(clearBytes - 2))).toBe(false);
833
+ }
834
+ const decrypted = await drive('decode', user, undefined, encrypted);
835
+ for (const f of decrypted) {
836
+ expect(Array.from(new Uint8Array(f.data))).toEqual(h264ZeroTail);
837
+ }
838
+ });
839
+ });