livekit-client 2.22.1 → 2.22.2

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 (42) 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 +177 -39
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +85 -60
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.umd.js +1 -1
  8. package/dist/livekit-client.umd.js.map +1 -1
  9. package/dist/src/api/SignalClient.d.ts.map +1 -1
  10. package/dist/src/e2ee/E2eeManager.d.ts +7 -0
  11. package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
  12. package/dist/src/e2ee/constants.d.ts +5 -0
  13. package/dist/src/e2ee/constants.d.ts.map +1 -1
  14. package/dist/src/e2ee/types.d.ts +9 -2
  15. package/dist/src/e2ee/types.d.ts.map +1 -1
  16. package/dist/src/e2ee/worker/FrameCryptor.d.ts +42 -1
  17. package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
  18. package/dist/src/room/PCTransportManager.d.ts +12 -0
  19. package/dist/src/room/PCTransportManager.d.ts.map +1 -1
  20. package/dist/src/room/data-stream/incoming/StreamReader.d.ts +17 -17
  21. package/dist/src/room/data-stream/incoming/StreamReader.d.ts.map +1 -1
  22. package/dist/ts4.2/e2ee/E2eeManager.d.ts +7 -0
  23. package/dist/ts4.2/e2ee/constants.d.ts +5 -0
  24. package/dist/ts4.2/e2ee/types.d.ts +9 -2
  25. package/dist/ts4.2/e2ee/worker/FrameCryptor.d.ts +42 -1
  26. package/dist/ts4.2/room/PCTransportManager.d.ts +12 -0
  27. package/dist/ts4.2/room/data-stream/incoming/StreamReader.d.ts +17 -17
  28. package/package.json +1 -1
  29. package/src/api/SignalClient.ts +2 -1
  30. package/src/e2ee/E2eeManager.ts +38 -14
  31. package/src/e2ee/constants.ts +6 -0
  32. package/src/e2ee/subscriberBlackScreen.test.ts +544 -0
  33. package/src/e2ee/types.ts +9 -2
  34. package/src/e2ee/worker/FrameCryptor.race.test.ts +9 -26
  35. package/src/e2ee/worker/FrameCryptor.test.ts +0 -1
  36. package/src/e2ee/worker/FrameCryptor.ts +185 -52
  37. package/src/e2ee/worker/e2ee.worker.ts +38 -6
  38. package/src/room/PCTransportManager.test.ts +35 -0
  39. package/src/room/PCTransportManager.ts +12 -4
  40. package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +171 -0
  41. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +17 -18
  42. package/src/room/data-stream/incoming/StreamReader.ts +20 -50
@@ -12,6 +12,7 @@ import { EngineEvent, ParticipantEvent, RoomEvent } from '../room/events';
12
12
  import type RemoteTrack from '../room/track/RemoteTrack';
13
13
  import RemoteVideoTrack from '../room/track/RemoteVideoTrack';
14
14
  import type { Track } from '../room/track/Track';
15
+ import type { TrackPublication } from '../room/track/TrackPublication';
15
16
  import type { TrackPublishOptions, VideoCodec } from '../room/track/options';
16
17
  import { mimeTypeToVideoCodecString } from '../room/track/utils';
17
18
  import {
@@ -23,7 +24,7 @@ import {
23
24
  } from '../room/utils';
24
25
  import type { NonSharedUint8Array } from '../type-polyfills/non-shared-typed-arrays';
25
26
  import type { BaseKeyProvider } from './KeyProvider';
26
- import { E2EE_FLAG } from './constants';
27
+ import { E2EE_FLAG, E2EE_TRACK_ID } from './constants';
27
28
  import { type E2EEManagerCallbacks, EncryptionEvent, KeyProviderEvent } from './events';
28
29
  import type {
29
30
  DecryptDataRequestMessage,
@@ -279,20 +280,14 @@ export class E2EEManager
279
280
 
280
281
  private setupEventListeners(room: Room, keyProvider: BaseKeyProvider) {
281
282
  room.on(RoomEvent.TrackPublished, (pub, participant) =>
282
- this.setParticipantCryptorEnabled(
283
- pub.trackInfo!.encryption !== Encryption_Type.NONE,
284
- participant.identity,
285
- ),
283
+ this.setParticipantCryptorEnabledForPublication(pub, participant.identity),
286
284
  );
287
285
  room
288
286
  .on(RoomEvent.ConnectionStateChanged, (state) => {
289
287
  if (state === ConnectionState.Connected) {
290
288
  room.remoteParticipants.forEach((participant) => {
291
289
  participant.trackPublications.forEach((pub) => {
292
- this.setParticipantCryptorEnabled(
293
- pub.trackInfo!.encryption !== Encryption_Type.NONE,
294
- participant.identity,
295
- );
290
+ this.setParticipantCryptorEnabledForPublication(pub, participant.identity);
296
291
  });
297
292
  });
298
293
  }
@@ -482,6 +477,29 @@ export class E2EEManager
482
477
  this.worker.postMessage(msg);
483
478
  }
484
479
 
480
+ /**
481
+ * Derive the participant's encryption state from one of its publications.
482
+ * Publications without trackInfo are skipped rather than thrown on: throwing
483
+ * inside the forEach above would leave every remaining participant without an
484
+ * encryption state, and a cryptor with unknown state can no longer decrypt.
485
+ */
486
+ private setParticipantCryptorEnabledForPublication(
487
+ pub: TrackPublication,
488
+ participantIdentity: string,
489
+ ) {
490
+ if (!pub.trackInfo) {
491
+ log.warn('skipping e2ee enabled update for publication without trackInfo', {
492
+ participant: participantIdentity,
493
+ trackSid: pub.trackSid,
494
+ });
495
+ return;
496
+ }
497
+ this.setParticipantCryptorEnabled(
498
+ pub.trackInfo.encryption !== Encryption_Type.NONE,
499
+ participantIdentity,
500
+ );
501
+ }
502
+
485
503
  private setupE2EEReceiver(track: RemoteTrack, remoteId: string, trackInfo?: TrackInfo) {
486
504
  if (!track.receiver) {
487
505
  return;
@@ -541,21 +559,27 @@ export class E2EEManager
541
559
  codec,
542
560
  hasPacketTrailer,
543
561
  };
544
- // @ts-ignore
545
562
  receiver.transform = new RTCRtpScriptTransform(this.worker, options);
546
563
  } else {
547
- if (E2EE_FLAG in receiver && codec) {
548
- // update track-specific state when the transceiver is reused
564
+ if (E2EE_FLAG in receiver && E2EE_TRACK_ID in receiver) {
565
+ // The transceiver is being reused for a new track. We cannot set up a new
566
+ // pipeline from here: this receiver's encoded streams were transferred to
567
+ // the worker on first setup and a transferred stream can't be transferred
568
+ // again, while createEncodedStreams() may only be called once per receiver.
569
+ // So hand the worker the new track/participant and let it re-point (and if
570
+ // necessary rebuild) the cryptor that still owns those streams.
549
571
  const msg: UpdateCodecMessage = {
550
572
  kind: 'updateCodec',
551
573
  data: {
552
574
  trackId,
575
+ previousTrackId: receiver[E2EE_TRACK_ID] as string,
553
576
  codec,
554
577
  participantIdentity,
555
578
  hasPacketTrailer,
556
579
  },
557
580
  };
558
581
  this.worker.postMessage(msg);
582
+ receiver[E2EE_TRACK_ID] = trackId;
559
583
  return;
560
584
  }
561
585
  // @ts-ignore
@@ -582,7 +606,6 @@ export class E2EEManager
582
606
  trackId: trackId,
583
607
  codec,
584
608
  participantIdentity: participantIdentity,
585
- isReuse: E2EE_FLAG in receiver,
586
609
  hasPacketTrailer,
587
610
  },
588
611
  };
@@ -591,6 +614,8 @@ export class E2EEManager
591
614
 
592
615
  // @ts-ignore
593
616
  receiver[E2EE_FLAG] = true;
617
+ // @ts-ignore
618
+ receiver[E2EE_TRACK_ID] = trackId;
594
619
  }
595
620
 
596
621
  /**
@@ -636,7 +661,6 @@ export class E2EEManager
636
661
  codec,
637
662
  trackId,
638
663
  participantIdentity: this.room.localParticipant.identity,
639
- isReuse: false,
640
664
  hasPacketTrailer: hasFrameMetadataPublishOptions(frameMetadata),
641
665
  packetTrailer: frameMetadata,
642
666
  },
@@ -29,6 +29,12 @@ export const IV_LENGTH = 12;
29
29
  // flag set to indicate that e2ee has been setup for sender/receiver;
30
30
  export const E2EE_FLAG = 'lk_e2ee';
31
31
 
32
+ /**
33
+ * trackId a receiver's/sender's encoded stream pipeline was last set up for.
34
+ * Needed to recover the right cryptor when a transceiver gets reused.
35
+ */
36
+ export const E2EE_TRACK_ID = 'lk_e2ee_track_id';
37
+
32
38
  export const SALT = 'LKFrameEncryptionKey';
33
39
 
34
40
  export const KEY_PROVIDER_DEFAULTS: KeyProviderOptions = {
@@ -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
+ });