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