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.
- 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 +663 -476
- package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
- package/dist/livekit-client.esm.mjs +480 -157
- package/dist/livekit-client.esm.mjs.map +1 -1
- package/dist/livekit-client.fm.worker.js +1 -1
- package/dist/livekit-client.fm.worker.js.map +1 -1
- package/dist/livekit-client.fm.worker.mjs +8 -1
- package/dist/livekit-client.fm.worker.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/api/WebSocketStream.d.ts.map +1 -1
- package/dist/src/api/utils.d.ts +1 -0
- package/dist/src/api/utils.d.ts.map +1 -1
- package/dist/src/e2ee/E2eeManager.d.ts +33 -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 +24 -3
- package/dist/src/e2ee/types.d.ts.map +1 -1
- package/dist/src/e2ee/worker/DataCryptor.d.ts.map +1 -1
- package/dist/src/e2ee/worker/ErrorRateLimiter.d.ts +21 -0
- package/dist/src/e2ee/worker/ErrorRateLimiter.d.ts.map +1 -0
- package/dist/src/e2ee/worker/FrameCryptor.d.ts +43 -19
- package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
- package/dist/src/logger.d.ts +4 -0
- package/dist/src/logger.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/RTCEngine.d.ts +1 -0
- package/dist/src/room/RTCEngine.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/src/room/participant/LocalParticipant.d.ts.map +1 -1
- package/dist/src/room/participant/publishUtils.d.ts +16 -0
- package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
- package/dist/src/room/track/LocalVideoTrack.d.ts +7 -0
- package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
- package/dist/src/room/track/options.d.ts +1 -1
- package/dist/src/room/utils.d.ts +34 -0
- package/dist/src/room/utils.d.ts.map +1 -1
- package/dist/ts4.2/api/utils.d.ts +1 -0
- package/dist/ts4.2/e2ee/E2eeManager.d.ts +33 -0
- package/dist/ts4.2/e2ee/constants.d.ts +5 -0
- package/dist/ts4.2/e2ee/types.d.ts +24 -3
- package/dist/ts4.2/e2ee/worker/ErrorRateLimiter.d.ts +21 -0
- package/dist/ts4.2/e2ee/worker/FrameCryptor.d.ts +43 -19
- package/dist/ts4.2/logger.d.ts +4 -0
- package/dist/ts4.2/room/PCTransportManager.d.ts +12 -0
- package/dist/ts4.2/room/RTCEngine.d.ts +1 -0
- package/dist/ts4.2/room/data-stream/incoming/StreamReader.d.ts +17 -17
- package/dist/ts4.2/room/participant/publishUtils.d.ts +16 -0
- package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +7 -0
- package/dist/ts4.2/room/track/options.d.ts +1 -1
- package/dist/ts4.2/room/utils.d.ts +34 -0
- package/package.json +1 -1
- package/src/api/SignalClient.ts +2 -1
- package/src/api/WebSocketStream.ts +3 -8
- package/src/api/utils.ts +10 -0
- package/src/e2ee/E2eeManager.test.ts +196 -0
- package/src/e2ee/E2eeManager.ts +150 -31
- package/src/e2ee/constants.ts +6 -0
- package/src/e2ee/subscriberBlackScreen.test.ts +544 -0
- package/src/e2ee/types.ts +28 -3
- package/src/e2ee/worker/DataCryptor.ts +2 -1
- package/src/e2ee/worker/ErrorRateLimiter.test.ts +53 -0
- package/src/e2ee/worker/ErrorRateLimiter.ts +52 -0
- 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 +202 -119
- package/src/e2ee/worker/e2ee.worker.ts +72 -17
- package/src/logger.ts +22 -0
- package/src/room/PCTransportManager.test.ts +35 -0
- package/src/room/PCTransportManager.ts +12 -4
- package/src/room/RTCEngine.ts +28 -7
- package/src/room/Room.ts +1 -1
- 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/room/participant/LocalParticipant.ts +30 -14
- package/src/room/participant/publishUtils.test.ts +133 -0
- package/src/room/participant/publishUtils.ts +54 -19
- package/src/room/track/LocalVideoTrack.ts +15 -5
- package/src/room/track/options.ts +1 -1
- package/src/room/utils.test.ts +87 -0
- 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
|
-
|
|
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
|
|
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
|
+
});
|