livekit-client 2.22.1 → 2.22.3

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 (88) hide show
  1. package/dist/livekit-client.e2ee.worker.js +1 -1
  2. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  3. package/dist/livekit-client.e2ee.worker.mjs +663 -476
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +480 -157
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.fm.worker.js +1 -1
  8. package/dist/livekit-client.fm.worker.js.map +1 -1
  9. package/dist/livekit-client.fm.worker.mjs +8 -1
  10. package/dist/livekit-client.fm.worker.mjs.map +1 -1
  11. package/dist/livekit-client.umd.js +1 -1
  12. package/dist/livekit-client.umd.js.map +1 -1
  13. package/dist/src/api/SignalClient.d.ts.map +1 -1
  14. package/dist/src/api/WebSocketStream.d.ts.map +1 -1
  15. package/dist/src/api/utils.d.ts +1 -0
  16. package/dist/src/api/utils.d.ts.map +1 -1
  17. package/dist/src/e2ee/E2eeManager.d.ts +33 -0
  18. package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
  19. package/dist/src/e2ee/constants.d.ts +5 -0
  20. package/dist/src/e2ee/constants.d.ts.map +1 -1
  21. package/dist/src/e2ee/types.d.ts +24 -3
  22. package/dist/src/e2ee/types.d.ts.map +1 -1
  23. package/dist/src/e2ee/worker/DataCryptor.d.ts.map +1 -1
  24. package/dist/src/e2ee/worker/ErrorRateLimiter.d.ts +21 -0
  25. package/dist/src/e2ee/worker/ErrorRateLimiter.d.ts.map +1 -0
  26. package/dist/src/e2ee/worker/FrameCryptor.d.ts +43 -19
  27. package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
  28. package/dist/src/logger.d.ts +4 -0
  29. package/dist/src/logger.d.ts.map +1 -1
  30. package/dist/src/room/PCTransportManager.d.ts +12 -0
  31. package/dist/src/room/PCTransportManager.d.ts.map +1 -1
  32. package/dist/src/room/RTCEngine.d.ts +1 -0
  33. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  34. package/dist/src/room/data-stream/incoming/StreamReader.d.ts +17 -17
  35. package/dist/src/room/data-stream/incoming/StreamReader.d.ts.map +1 -1
  36. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  37. package/dist/src/room/participant/publishUtils.d.ts +16 -0
  38. package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
  39. package/dist/src/room/track/LocalVideoTrack.d.ts +7 -0
  40. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  41. package/dist/src/room/track/options.d.ts +1 -1
  42. package/dist/src/room/utils.d.ts +34 -0
  43. package/dist/src/room/utils.d.ts.map +1 -1
  44. package/dist/ts4.2/api/utils.d.ts +1 -0
  45. package/dist/ts4.2/e2ee/E2eeManager.d.ts +33 -0
  46. package/dist/ts4.2/e2ee/constants.d.ts +5 -0
  47. package/dist/ts4.2/e2ee/types.d.ts +24 -3
  48. package/dist/ts4.2/e2ee/worker/ErrorRateLimiter.d.ts +21 -0
  49. package/dist/ts4.2/e2ee/worker/FrameCryptor.d.ts +43 -19
  50. package/dist/ts4.2/logger.d.ts +4 -0
  51. package/dist/ts4.2/room/PCTransportManager.d.ts +12 -0
  52. package/dist/ts4.2/room/RTCEngine.d.ts +1 -0
  53. package/dist/ts4.2/room/data-stream/incoming/StreamReader.d.ts +17 -17
  54. package/dist/ts4.2/room/participant/publishUtils.d.ts +16 -0
  55. package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +7 -0
  56. package/dist/ts4.2/room/track/options.d.ts +1 -1
  57. package/dist/ts4.2/room/utils.d.ts +34 -0
  58. package/package.json +1 -1
  59. package/src/api/SignalClient.ts +2 -1
  60. package/src/api/WebSocketStream.ts +3 -8
  61. package/src/api/utils.ts +10 -0
  62. package/src/e2ee/E2eeManager.test.ts +196 -0
  63. package/src/e2ee/E2eeManager.ts +150 -31
  64. package/src/e2ee/constants.ts +6 -0
  65. package/src/e2ee/subscriberBlackScreen.test.ts +544 -0
  66. package/src/e2ee/types.ts +28 -3
  67. package/src/e2ee/worker/DataCryptor.ts +2 -1
  68. package/src/e2ee/worker/ErrorRateLimiter.test.ts +53 -0
  69. package/src/e2ee/worker/ErrorRateLimiter.ts +52 -0
  70. package/src/e2ee/worker/FrameCryptor.race.test.ts +9 -26
  71. package/src/e2ee/worker/FrameCryptor.test.ts +0 -1
  72. package/src/e2ee/worker/FrameCryptor.ts +202 -119
  73. package/src/e2ee/worker/e2ee.worker.ts +72 -17
  74. package/src/logger.ts +22 -0
  75. package/src/room/PCTransportManager.test.ts +35 -0
  76. package/src/room/PCTransportManager.ts +12 -4
  77. package/src/room/RTCEngine.ts +28 -7
  78. package/src/room/Room.ts +1 -1
  79. package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +171 -0
  80. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +17 -18
  81. package/src/room/data-stream/incoming/StreamReader.ts +20 -50
  82. package/src/room/participant/LocalParticipant.ts +30 -14
  83. package/src/room/participant/publishUtils.test.ts +133 -0
  84. package/src/room/participant/publishUtils.ts +54 -19
  85. package/src/room/track/LocalVideoTrack.ts +15 -5
  86. package/src/room/track/options.ts +1 -1
  87. package/src/room/utils.test.ts +87 -0
  88. package/src/room/utils.ts +59 -0
@@ -0,0 +1,544 @@
1
+ import { Encryption_Type, ParticipantInfo, TrackInfo, TrackType } from '@livekit/protocol';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import Room, { ConnectionState } from '../room/Room';
4
+ import { EngineEvent, RoomEvent } from '../room/events';
5
+ import type RemoteParticipant from '../room/participant/RemoteParticipant';
6
+ import RemoteTrackPublication from '../room/track/RemoteTrackPublication';
7
+ import RemoteVideoTrack from '../room/track/RemoteVideoTrack';
8
+ import { Track } from '../room/track/Track';
9
+ import MockMediaStreamTrack from '../test/MockMediaStreamTrack';
10
+ import { E2EEManager } from './E2eeManager';
11
+ import { BaseKeyProvider } from './KeyProvider';
12
+ import { E2EE_FLAG, KEY_PROVIDER_DEFAULTS } from './constants';
13
+ import { CryptorEvent } from './events';
14
+ import { createKeyMaterialFromString } from './utils';
15
+ import { FrameCryptor, encryptionEnabledMap } from './worker/FrameCryptor';
16
+ import { ParticipantKeyHandler } from './worker/ParticipantKeyHandler';
17
+
18
+ /**
19
+ * Repros for the "remote participant is a black screen for exactly one
20
+ * subscriber, audio fine, no decryption errors anywhere" bug.
21
+ *
22
+ * All three end in the same state: a track the SDK considers fully subscribed
23
+ * has NO live decrypt transform, so AES-GCM ciphertext (or nothing at all)
24
+ * reaches the video decoder. The decoder then PLIs forever while the SFU
25
+ * reports it forwarded keyframes -- the frame's first 10 bytes are left
26
+ * unencrypted, so keyframe detection on the server still succeeds.
27
+ *
28
+ * Each test asserts the CORRECT behaviour, so they fail on the current code
29
+ * and pass once the fix lands. The comments record what happens today.
30
+ */
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // shims: take the Chromium code path under happy-dom
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /**
37
+ * isE2EESupported() requires insertable streams. isScriptTransformSupportedForWorker()
38
+ * must stay false so we exercise the createEncodedStreams path Chromium uses
39
+ * (script transforms are explicitly disabled for Chromium in room/utils.ts).
40
+ */
41
+ function installChromiumLikeE2EEShims() {
42
+ const w = window as unknown as Record<string, any>;
43
+ if (typeof w.RTCRtpSender === 'undefined') {
44
+ w.RTCRtpSender = class {};
45
+ }
46
+ w.RTCRtpSender.prototype.createEncodedStreams = () => {};
47
+ expect(w.RTCRtpScriptTransform).toBeUndefined();
48
+ }
49
+
50
+ class FakeWorker {
51
+ messages: Array<{ kind: string; data: any }> = [];
52
+
53
+ onmessage: unknown = null;
54
+
55
+ onerror: unknown = null;
56
+
57
+ postMessage(msg: any) {
58
+ this.messages.push(msg);
59
+ }
60
+
61
+ kinds() {
62
+ return this.messages.map((m) => m.kind);
63
+ }
64
+
65
+ messagesOfKind(kind: string) {
66
+ return this.messages.filter((m) => m.kind === kind);
67
+ }
68
+ }
69
+
70
+ /** Behaves like Chrome's receiver: encoded streams can only be created once. */
71
+ function createReceiverStub() {
72
+ let created = false;
73
+ return {
74
+ createEncodedStreams: () => {
75
+ if (created) {
76
+ throw new Error('createEncodedStreams can only be called once');
77
+ }
78
+ created = true;
79
+ return { readable: new ReadableStream(), writable: new WritableStream() };
80
+ },
81
+ } as unknown as RTCRtpReceiver;
82
+ }
83
+
84
+ function setupRoomWithE2EE() {
85
+ installChromiumLikeE2EEShims();
86
+
87
+ const room = new Room();
88
+ const worker = new FakeWorker();
89
+ const manager = new E2EEManager(
90
+ { keyProvider: new BaseKeyProvider({ sharedKey: true }), worker: worker as unknown as Worker },
91
+ false,
92
+ );
93
+ manager.setup(room);
94
+
95
+ // keep the resume path from reaching the (unconnected) signal client
96
+ vi.spyOn(room.engine, 'sendSyncState').mockImplementation(() => {});
97
+
98
+ room.state = ConnectionState.Connected;
99
+
100
+ const participant: RemoteParticipant = (
101
+ room as unknown as {
102
+ getOrCreateParticipant(identity: string, info: ParticipantInfo): RemoteParticipant;
103
+ }
104
+ ).getOrCreateParticipant('jake', new ParticipantInfo({ sid: 'PA_jake', identity: 'jake' }));
105
+
106
+ const trackInfo = new TrackInfo({
107
+ sid: 'TR_video',
108
+ type: TrackType.VIDEO,
109
+ name: 'camera',
110
+ mimeType: 'video/h264',
111
+ encryption: Encryption_Type.GCM,
112
+ });
113
+ const publication = new RemoteTrackPublication(Track.Kind.Video, trackInfo, true);
114
+ // addTrackPublication is what wires TrackEvent.Subscribed -> ParticipantEvent.TrackSubscribed
115
+ (
116
+ participant as unknown as { addTrackPublication(pub: RemoteTrackPublication): void }
117
+ ).addTrackPublication(publication);
118
+
119
+ const receiver = createReceiverStub();
120
+ const subscribe = () =>
121
+ publication.setTrack(
122
+ new RemoteVideoTrack(new MockMediaStreamTrack(), 'TR_video', receiver, {}),
123
+ );
124
+ const unsubscribe = () => publication.setTrack(undefined);
125
+
126
+ return { room, worker, publication, receiver, subscribe, unsubscribe };
127
+ }
128
+
129
+ describe('subscriber black screen', () => {
130
+ beforeEach(() => {
131
+ encryptionEnabledMap.clear();
132
+ });
133
+
134
+ afterEach(() => {
135
+ vi.restoreAllMocks();
136
+ encryptionEnabledMap.clear();
137
+ });
138
+
139
+ // -------------------------------------------------------------------------
140
+ // 1. RoomEvent.TrackSubscribed is buffered while resuming and then DISCARDED
141
+ // (Room.ts:642), while TrackUnsubscribed is always emitted immediately
142
+ // (Room.ts:2441). E2eeManager only hooks RoomEvent.TrackSubscribed.
143
+ // -------------------------------------------------------------------------
144
+ describe('a track subscribed during a signal resume', () => {
145
+ it('control: subscribing while Connected installs the transform', () => {
146
+ const { worker, receiver, subscribe } = setupRoomWithE2EE();
147
+
148
+ subscribe();
149
+
150
+ expect(worker.kinds()).toContain('decode');
151
+ expect(E2EE_FLAG in receiver).toBe(true);
152
+ });
153
+
154
+ it('must still get a decrypt transform', () => {
155
+ const { room, worker, publication, receiver, subscribe } = setupRoomWithE2EE();
156
+
157
+ // signal connection drops -> Room enters SignalReconnecting / isResuming.
158
+ // onTrackAdded only defers ontrack for Connecting/Reconnecting, NOT
159
+ // SignalReconnecting, so a track really can get subscribed in this window.
160
+ room.engine.emit(EngineEvent.Resuming);
161
+ expect(room.state).toBe(ConnectionState.SignalReconnecting);
162
+
163
+ subscribe();
164
+
165
+ // signal comes back: Room.ts:642 throws the buffered events away...
166
+ room.engine.emit(EngineEvent.SignalResumed);
167
+ // ...so the flush on Resumed has nothing left to flush.
168
+ room.engine.emit(EngineEvent.Resumed);
169
+
170
+ expect(room.state).toBe(ConnectionState.Connected);
171
+ // the SDK and the UI consider this track fully subscribed
172
+ expect(publication.isSubscribed).toBe(true);
173
+ expect(publication.track).toBeDefined();
174
+
175
+ // CURRENTLY FAILS: no 'decode' is ever posted, so every frame reaches the
176
+ // decoder as ciphertext -> black screen with zero errors logged.
177
+ expect(worker.kinds()).toContain('decode');
178
+ expect(E2EE_FLAG in receiver).toBe(true);
179
+ });
180
+
181
+ it('must never deliver TrackUnsubscribed for a track that was never announced as subscribed', () => {
182
+ const { room, subscribe, unsubscribe } = setupRoomWithE2EE();
183
+ const seen: string[] = [];
184
+ room.on(RoomEvent.TrackSubscribed, () => seen.push('subscribed'));
185
+ room.on(RoomEvent.TrackUnsubscribed, () => seen.push('unsubscribed'));
186
+
187
+ room.engine.emit(EngineEvent.Resuming);
188
+ subscribe();
189
+ unsubscribe();
190
+ room.engine.emit(EngineEvent.SignalResumed);
191
+ room.engine.emit(EngineEvent.Resumed);
192
+
193
+ // CURRENTLY FAILS with ['unsubscribed']: subscribe is buffered then
194
+ // dropped, unsubscribe is emitted unconditionally. Any consumer that
195
+ // pairs the two (E2eeManager does) is left with inverted state.
196
+ expect(seen).not.toEqual(['unsubscribed']);
197
+ });
198
+ });
199
+
200
+ // -------------------------------------------------------------------------
201
+ // 2. Receiver reuse: E2EE_FLAG is a permanent marker on the RTCRtpReceiver,
202
+ // so a resubscribe on a reused transceiver only sends updateCodec and
203
+ // never re-establishes a pipeline.
204
+ // -------------------------------------------------------------------------
205
+ describe('resubscribe on a reused receiver', () => {
206
+ /**
207
+ * NOTE: the main thread cannot post a second 'decode' for a reused receiver.
208
+ * Its encoded streams were transferred to the worker on first setup and a
209
+ * transferred stream is locked ("Cannot transfer a locked ReadableStream"),
210
+ * while createEncodedStreams() may only be called once per receiver. So the
211
+ * worker has to be told which cryptor owns the pipeline, and rebuild it there.
212
+ */
213
+ it('must hand the worker enough state to re-point the existing pipeline', () => {
214
+ const { worker, receiver, subscribe, unsubscribe } = setupRoomWithE2EE();
215
+
216
+ subscribe();
217
+ expect(worker.messagesOfKind('decode')).toHaveLength(1);
218
+ const firstTrackId = worker.messagesOfKind('decode')[0].data.trackId;
219
+
220
+ // unsubscribe -> removeTransform unsets the participant on the cryptor, but
221
+ // nothing cancels the pipe, so it keeps running
222
+ unsubscribe();
223
+ expect(worker.kinds()).toContain('removeTransform');
224
+
225
+ // resubscribe on the SAME receiver: pion reuses the freed transceiver
226
+ // (livekit/pkg/rtc/transport.go:1077 -> pc.AddTrack), so the client keeps
227
+ // the same RTCRtpReceiver object
228
+ subscribe();
229
+
230
+ // no second createEncodedStreams (the stub would have thrown) and no
231
+ // attempt to re-transfer the locked streams
232
+ expect(worker.messagesOfKind('decode')).toHaveLength(1);
233
+
234
+ const update = worker.messagesOfKind('updateCodec').at(-1);
235
+ expect(update).toBeDefined();
236
+ // previousTrackId is what lets the worker find the cryptor that owns the
237
+ // streams instead of creating a fresh one with no pipeline
238
+ expect(update!.data.previousTrackId).toBe(firstTrackId);
239
+ expect(update!.data.participantIdentity).toBe('jake');
240
+ expect(E2EE_FLAG in receiver).toBe(true);
241
+ });
242
+
243
+ it('rebuilds a dead pipeline instead of leaving the track undecrypted', async () => {
244
+ const keys = new ParticipantKeyHandler('jake', KEY_PROVIDER_DEFAULTS);
245
+ await keys.setKey(await createKeyMaterialFromString('shared-password'), 0);
246
+ encryptionEnabledMap.set('jake', true);
247
+
248
+ const cryptor = new FrameCryptor({
249
+ participantIdentity: 'jake',
250
+ keys,
251
+ keyProviderOptions: KEY_PROVIDER_DEFAULTS,
252
+ sifTrailer: new Uint8Array(),
253
+ });
254
+
255
+ // a pipeline whose readable ends on its own, e.g. the swallowed
256
+ // 'Destination stream closed'
257
+ const readable = new ReadableStream<RTCEncodedVideoFrame>({
258
+ start(controller) {
259
+ controller.close();
260
+ },
261
+ });
262
+ cryptor.setupTransform('decode', readable, new WritableStream(), 'TR_old', 'vp8');
263
+ await new Promise((resolve) => setTimeout(resolve, 10));
264
+ expect(cryptor.hasActiveTransform()).toBe(false);
265
+
266
+ // transceiver reuse: same streams, new trackId. There is nothing the main
267
+ // thread can hand us, so this has to be recoverable from the worker side.
268
+ cryptor.setTrackId('TR_new');
269
+ cryptor.ensureTransform();
270
+
271
+ expect(cryptor.getTrackId()).toBe('TR_new');
272
+ expect(cryptor.hasActiveTransform()).toBe(true);
273
+ });
274
+ });
275
+
276
+ // -------------------------------------------------------------------------
277
+ // 3. decodeFunction fails OPEN: with no participant set it enqueues the frame
278
+ // untouched, handing raw ciphertext to the decoder instead of dropping it
279
+ // or raising. This is what makes 1 and 2 completely silent.
280
+ // -------------------------------------------------------------------------
281
+ describe('cryptor with no participant set', () => {
282
+ it('must not forward ciphertext to the decoder', async () => {
283
+ const keys = new ParticipantKeyHandler('jake', KEY_PROVIDER_DEFAULTS);
284
+ await keys.setKey(await createKeyMaterialFromString('shared-password'), 0);
285
+ encryptionEnabledMap.set('jake', true);
286
+
287
+ const cryptor = new FrameCryptor({
288
+ participantIdentity: 'jake',
289
+ keys,
290
+ keyProviderOptions: KEY_PROVIDER_DEFAULTS,
291
+ sifTrailer: new Uint8Array(),
292
+ });
293
+
294
+ const frames: RTCEncodedVideoFrame[] = [];
295
+ let push!: (f: RTCEncodedVideoFrame) => void;
296
+ const readable = new ReadableStream<RTCEncodedVideoFrame>({
297
+ start(controller) {
298
+ push = (f) => controller.enqueue(f);
299
+ },
300
+ });
301
+ const writable = new WritableStream<RTCEncodedVideoFrame>({
302
+ write(chunk) {
303
+ frames.push(chunk);
304
+ },
305
+ });
306
+
307
+ cryptor.setupTransform('decode', readable, writable, 'TR_video', 'vp8');
308
+
309
+ // what RoomEvent.TrackUnsubscribed does via the worker's 'removeTransform':
310
+ // clears participantIdentity but does NOT cancel the pipe
311
+ cryptor.unsetParticipant();
312
+ expect(cryptor.getParticipantIdentity()).toBeUndefined();
313
+
314
+ // a frame still flowing on the live pipe
315
+ const ciphertext = new Uint8Array([
316
+ // 10 plaintext keyframe header bytes (what keeps server keyframe detection happy)
317
+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
318
+ // ciphertext + IV + trailer
319
+ 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217,
320
+ 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 12, 0,
321
+ ]);
322
+ push({
323
+ data: ciphertext.buffer,
324
+ timestamp: 0,
325
+ type: 'key',
326
+ getMetadata: () => ({}),
327
+ } as RTCEncodedVideoFrame);
328
+
329
+ // give the transform a chance to run
330
+ await new Promise((resolve) => setTimeout(resolve, 10));
331
+
332
+ // CURRENTLY FAILS: the decoder is handed the ciphertext byte for byte --
333
+ // nothing decrypted, nothing dropped, nothing logged.
334
+ expect(frames).toHaveLength(0);
335
+ });
336
+
337
+ it('drops silently rather than reporting an error on a normal disconnect', async () => {
338
+ const keys = new ParticipantKeyHandler('jake', KEY_PROVIDER_DEFAULTS);
339
+ await keys.setKey(await createKeyMaterialFromString('shared-password'), 0);
340
+ encryptionEnabledMap.set('jake', true);
341
+
342
+ const cryptor = new FrameCryptor({
343
+ participantIdentity: 'jake',
344
+ keys,
345
+ keyProviderOptions: KEY_PROVIDER_DEFAULTS,
346
+ sifTrailer: new Uint8Array(),
347
+ });
348
+
349
+ const errors: Error[] = [];
350
+ cryptor.on(CryptorEvent.Error, (e) => errors.push(e));
351
+
352
+ const frames: RTCEncodedVideoFrame[] = [];
353
+ let push!: (f: RTCEncodedVideoFrame) => void;
354
+ const readable = new ReadableStream<RTCEncodedVideoFrame>({
355
+ start(controller) {
356
+ push = (f) => controller.enqueue(f);
357
+ },
358
+ });
359
+ const writable = new WritableStream<RTCEncodedVideoFrame>({
360
+ write(chunk) {
361
+ frames.push(chunk);
362
+ },
363
+ });
364
+
365
+ cryptor.setupTransform('decode', readable, writable, 'TR_video', 'vp8');
366
+
367
+ // participant leaves -> removeTransform. The pipe is deliberately left
368
+ // running (a reused transceiver has to be re-pointable), so frames already
369
+ // in flight still reach decodeFunction for a moment.
370
+ cryptor.unsetParticipant();
371
+
372
+ push({
373
+ data: new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 200, 201, 202, 12, 0]).buffer,
374
+ timestamp: 0,
375
+ type: 'key',
376
+ getMetadata: () => ({}),
377
+ } as RTCEncodedVideoFrame);
378
+ await new Promise((resolve) => setTimeout(resolve, 10));
379
+
380
+ // dropped, but a routine teardown must not surface as an encryptionError
381
+ expect(frames).toHaveLength(0);
382
+ expect(errors).toHaveLength(0);
383
+ });
384
+
385
+ it('still forwards plaintext for a participant known to publish unencrypted', async () => {
386
+ const keys = new ParticipantKeyHandler('bob', KEY_PROVIDER_DEFAULTS);
387
+ await keys.setKey(await createKeyMaterialFromString('shared-password'), 0);
388
+ // explicitly NOT encrypted, as opposed to "we don't know yet"
389
+ encryptionEnabledMap.set('bob', false);
390
+
391
+ const cryptor = new FrameCryptor({
392
+ participantIdentity: 'bob',
393
+ keys,
394
+ keyProviderOptions: KEY_PROVIDER_DEFAULTS,
395
+ sifTrailer: new Uint8Array(),
396
+ });
397
+
398
+ const frames: RTCEncodedVideoFrame[] = [];
399
+ let push!: (f: RTCEncodedVideoFrame) => void;
400
+ const readable = new ReadableStream<RTCEncodedVideoFrame>({
401
+ start(controller) {
402
+ push = (f) => controller.enqueue(f);
403
+ },
404
+ });
405
+ const writable = new WritableStream<RTCEncodedVideoFrame>({
406
+ write(chunk) {
407
+ frames.push(chunk);
408
+ },
409
+ });
410
+
411
+ cryptor.setupTransform('decode', readable, writable, 'TR_plain', 'vp8');
412
+ push({
413
+ data: new Uint8Array([1, 2, 3, 4]).buffer,
414
+ timestamp: 0,
415
+ type: 'key',
416
+ getMetadata: () => ({}),
417
+ } as RTCEncodedVideoFrame);
418
+ await new Promise((resolve) => setTimeout(resolve, 10));
419
+
420
+ expect(frames).toHaveLength(1);
421
+ });
422
+ });
423
+
424
+ // -------------------------------------------------------------------------
425
+ // 4. The whole class of bug above is invisible. A subscribed, encrypted track
426
+ // with no transform should report itself.
427
+ // -------------------------------------------------------------------------
428
+ describe('watchdog', () => {
429
+ it('reports an encrypted track left without a decrypt transform', async () => {
430
+ vi.useFakeTimers();
431
+ try {
432
+ const keys = new ParticipantKeyHandler('jake', KEY_PROVIDER_DEFAULTS);
433
+ encryptionEnabledMap.set('jake', true);
434
+
435
+ const cryptor = new FrameCryptor({
436
+ participantIdentity: 'jake',
437
+ keys,
438
+ keyProviderOptions: KEY_PROVIDER_DEFAULTS,
439
+ sifTrailer: new Uint8Array(),
440
+ });
441
+
442
+ const errors: Error[] = [];
443
+ cryptor.on(CryptorEvent.Error, (e) => errors.push(e));
444
+
445
+ // pipeline dies on its own while the track stays subscribed & encrypted
446
+ cryptor.setupTransform(
447
+ 'decode',
448
+ new ReadableStream({
449
+ start(controller) {
450
+ controller.close();
451
+ },
452
+ }),
453
+ new WritableStream(),
454
+ 'TR_video',
455
+ 'vp8',
456
+ );
457
+
458
+ await vi.advanceTimersByTimeAsync(5000);
459
+
460
+ expect(cryptor.hasActiveTransform()).toBe(false);
461
+ expect(errors).toHaveLength(1);
462
+ } finally {
463
+ vi.useRealTimers();
464
+ }
465
+ });
466
+
467
+ it('stays quiet when a local sender pipe closes on unpublish', async () => {
468
+ vi.useFakeTimers();
469
+ try {
470
+ const keys = new ParticipantKeyHandler('me', KEY_PROVIDER_DEFAULTS);
471
+ encryptionEnabledMap.set('me', true);
472
+
473
+ const cryptor = new FrameCryptor({
474
+ participantIdentity: 'me',
475
+ keys,
476
+ keyProviderOptions: KEY_PROVIDER_DEFAULTS,
477
+ sifTrailer: new Uint8Array(),
478
+ });
479
+
480
+ const errors: Error[] = [];
481
+ cryptor.on(CryptorEvent.Error, (e) => errors.push(e));
482
+
483
+ // unpublishing closes the sender's encoded streams, and there is no
484
+ // 'removeTransform' for local senders to unset the participant
485
+ cryptor.setupTransform(
486
+ 'encode',
487
+ new ReadableStream({
488
+ start(controller) {
489
+ controller.close();
490
+ },
491
+ }),
492
+ new WritableStream(),
493
+ 'TR_local',
494
+ 'vp8',
495
+ );
496
+
497
+ await vi.advanceTimersByTimeAsync(5000);
498
+
499
+ expect(errors).toHaveLength(0);
500
+ } finally {
501
+ vi.useRealTimers();
502
+ }
503
+ });
504
+
505
+ it('stays quiet on a normal unsubscribe', async () => {
506
+ vi.useFakeTimers();
507
+ try {
508
+ const keys = new ParticipantKeyHandler('jake', KEY_PROVIDER_DEFAULTS);
509
+ encryptionEnabledMap.set('jake', true);
510
+
511
+ const cryptor = new FrameCryptor({
512
+ participantIdentity: 'jake',
513
+ keys,
514
+ keyProviderOptions: KEY_PROVIDER_DEFAULTS,
515
+ sifTrailer: new Uint8Array(),
516
+ });
517
+
518
+ const errors: Error[] = [];
519
+ cryptor.on(CryptorEvent.Error, (e) => errors.push(e));
520
+
521
+ cryptor.setupTransform(
522
+ 'decode',
523
+ new ReadableStream({
524
+ start(controller) {
525
+ controller.close();
526
+ },
527
+ }),
528
+ new WritableStream(),
529
+ 'TR_video',
530
+ 'vp8',
531
+ );
532
+ // the streams closing before 'removeTransform' arrives is the normal
533
+ // teardown ordering, and must not be reported
534
+ cryptor.unsetParticipant();
535
+
536
+ await vi.advanceTimersByTimeAsync(5000);
537
+
538
+ expect(errors).toHaveLength(0);
539
+ } finally {
540
+ vi.useRealTimers();
541
+ }
542
+ });
543
+ });
544
+ });
package/src/e2ee/types.ts CHANGED
@@ -53,7 +53,6 @@ export interface EncodeMessage extends BaseMessage {
53
53
  writableStream: WritableStream;
54
54
  trackId: string;
55
55
  codec?: VideoCodec;
56
- isReuse: boolean;
57
56
  /**
58
57
  * Whether the published track advertises packet trailer features.
59
58
  * When false, the cryptor skips the per-frame trailer extraction path
@@ -80,7 +79,15 @@ export interface UpdateCodecMessage extends BaseMessage {
80
79
  data: {
81
80
  participantIdentity: string;
82
81
  trackId: string;
83
- codec: VideoCodec;
82
+ /** undefined for audio tracks */
83
+ codec?: VideoCodec;
84
+ /**
85
+ * trackId this receiver's pipeline was previously set up for, set when a
86
+ * transceiver gets reused for a new track. Lets the worker find the cryptor
87
+ * that still owns the (already transferred) encoded streams and re-point it,
88
+ * rather than creating a fresh cryptor with no pipeline at all.
89
+ */
90
+ previousTrackId?: string;
84
91
  hasPacketTrailer: boolean;
85
92
  };
86
93
  }
@@ -169,6 +176,22 @@ export interface PTMetadataFromE2EEMessage extends BaseMessage {
169
176
  data: FrameMetadataPayload;
170
177
  }
171
178
 
179
+ export interface LogMessage extends BaseMessage {
180
+ kind: 'log';
181
+ data: {
182
+ level: 'trace' | 'debug' | 'info' | 'warn' | 'error';
183
+ msg: string;
184
+ context?: object;
185
+ };
186
+ }
187
+
188
+ export interface SetLogLevelMessage extends BaseMessage {
189
+ kind: 'setLogLevel';
190
+ data: {
191
+ level: LogLevel;
192
+ };
193
+ }
194
+
172
195
  export type E2EEWorkerMessage =
173
196
  | InitMessage
174
197
  | SetKeyMessage
@@ -186,7 +209,9 @@ export type E2EEWorkerMessage =
186
209
  | DecryptDataResponseMessage
187
210
  | EncryptDataRequestMessage
188
211
  | EncryptDataResponseMessage
189
- | PTMetadataFromE2EEMessage;
212
+ | PTMetadataFromE2EEMessage
213
+ | LogMessage
214
+ | SetLogLevelMessage;
190
215
 
191
216
  export type KeySet = { material: CryptoKey; encryptionKey: CryptoKey };
192
217
 
@@ -1,3 +1,4 @@
1
+ import { getErrorDescription } from '../../api/utils';
1
2
  import { workerLogger } from '../../logger';
2
3
  import type { NonSharedUint8Array } from '../../type-polyfills/non-shared-typed-arrays';
3
4
  import { ENCRYPTION_ALGORITHM } from '../constants';
@@ -135,7 +136,7 @@ export class DataCryptor {
135
136
  }
136
137
  } else {
137
138
  throw new CryptorError(
138
- `DataCryptor: Decryption failed: ${error.message}`,
139
+ `DataCryptor: Decryption failed: ${getErrorDescription(error, 'decryption')}`,
139
140
  CryptorErrorReason.InvalidKey,
140
141
  keys.participantIdentity,
141
142
  );
@@ -0,0 +1,53 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { ErrorRateLimiter } from './ErrorRateLimiter';
3
+
4
+ describe('ErrorRateLimiter', () => {
5
+ beforeEach(() => {
6
+ vi.useFakeTimers();
7
+ vi.setSystemTime(1_000_000);
8
+ });
9
+
10
+ afterEach(() => {
11
+ vi.useRealTimers();
12
+ });
13
+
14
+ it('emits first call, throttles the immediate next, then allows after throttle window', () => {
15
+ const l = new ErrorRateLimiter(1000, 60_000, 5);
16
+ expect(l.shouldEmit('k')).toBe(true);
17
+ vi.setSystemTime(1_000_500);
18
+ expect(l.shouldEmit('k')).toBe(false);
19
+ vi.setSystemTime(1_001_600);
20
+ expect(l.shouldEmit('k')).toBe(true);
21
+ });
22
+
23
+ it('caps at maxPerWindow and invokes onSuppress once', () => {
24
+ const l = new ErrorRateLimiter(0, 60_000, 3);
25
+ const onSuppress = vi.fn();
26
+ // one free emit on window reset (count stays 0), then increments to 3
27
+ expect(l.shouldEmit('k', onSuppress)).toBe(true);
28
+ expect(l.shouldEmit('k', onSuppress)).toBe(true);
29
+ expect(l.shouldEmit('k', onSuppress)).toBe(true);
30
+ expect(l.shouldEmit('k', onSuppress)).toBe(true);
31
+ // now count == 3 == max → suppressed, callback fires once
32
+ expect(l.shouldEmit('k', onSuppress)).toBe(false);
33
+ expect(l.shouldEmit('k', onSuppress)).toBe(false);
34
+ expect(onSuppress).toHaveBeenCalledTimes(1);
35
+ });
36
+
37
+ it('resets count after windowMs', () => {
38
+ const l = new ErrorRateLimiter(0, 1000, 2);
39
+ l.shouldEmit('k');
40
+ l.shouldEmit('k');
41
+ l.shouldEmit('k');
42
+ expect(l.shouldEmit('k')).toBe(false);
43
+ vi.setSystemTime(1_003_000);
44
+ expect(l.shouldEmit('k')).toBe(true);
45
+ });
46
+
47
+ it('tracks keys independently', () => {
48
+ const l = new ErrorRateLimiter(1000);
49
+ expect(l.shouldEmit('a')).toBe(true);
50
+ expect(l.shouldEmit('b')).toBe(true);
51
+ expect(l.shouldEmit('a')).toBe(false);
52
+ });
53
+ });