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.
- package/dist/livekit-client.e2ee.worker.js +1 -1
- package/dist/livekit-client.e2ee.worker.js.map +1 -1
- package/dist/livekit-client.e2ee.worker.mjs +177 -39
- package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
- package/dist/livekit-client.esm.mjs +85 -60
- package/dist/livekit-client.esm.mjs.map +1 -1
- package/dist/livekit-client.umd.js +1 -1
- package/dist/livekit-client.umd.js.map +1 -1
- package/dist/src/api/SignalClient.d.ts.map +1 -1
- package/dist/src/e2ee/E2eeManager.d.ts +7 -0
- package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
- package/dist/src/e2ee/constants.d.ts +5 -0
- package/dist/src/e2ee/constants.d.ts.map +1 -1
- package/dist/src/e2ee/types.d.ts +9 -2
- package/dist/src/e2ee/types.d.ts.map +1 -1
- package/dist/src/e2ee/worker/FrameCryptor.d.ts +42 -1
- package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
- package/dist/src/room/PCTransportManager.d.ts +12 -0
- package/dist/src/room/PCTransportManager.d.ts.map +1 -1
- package/dist/src/room/data-stream/incoming/StreamReader.d.ts +17 -17
- package/dist/src/room/data-stream/incoming/StreamReader.d.ts.map +1 -1
- package/dist/ts4.2/e2ee/E2eeManager.d.ts +7 -0
- package/dist/ts4.2/e2ee/constants.d.ts +5 -0
- package/dist/ts4.2/e2ee/types.d.ts +9 -2
- package/dist/ts4.2/e2ee/worker/FrameCryptor.d.ts +42 -1
- package/dist/ts4.2/room/PCTransportManager.d.ts +12 -0
- package/dist/ts4.2/room/data-stream/incoming/StreamReader.d.ts +17 -17
- package/package.json +1 -1
- package/src/api/SignalClient.ts +2 -1
- package/src/e2ee/E2eeManager.ts +38 -14
- package/src/e2ee/constants.ts +6 -0
- package/src/e2ee/subscriberBlackScreen.test.ts +544 -0
- package/src/e2ee/types.ts +9 -2
- package/src/e2ee/worker/FrameCryptor.race.test.ts +9 -26
- package/src/e2ee/worker/FrameCryptor.test.ts +0 -1
- package/src/e2ee/worker/FrameCryptor.ts +185 -52
- package/src/e2ee/worker/e2ee.worker.ts +38 -6
- package/src/room/PCTransportManager.test.ts +35 -0
- package/src/room/PCTransportManager.ts +12 -4
- package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +171 -0
- package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +17 -18
- package/src/room/data-stream/incoming/StreamReader.ts +20 -50
package/src/e2ee/E2eeManager.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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 &&
|
|
548
|
-
//
|
|
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
|
},
|
package/src/e2ee/constants.ts
CHANGED
|
@@ -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
|
+
});
|