@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,247 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
boundarySeedZeros,
|
|
4
|
+
getCodecProfile,
|
|
5
|
+
isSupportedCodec,
|
|
6
|
+
rbspEscapeInto,
|
|
7
|
+
rbspEscapedLength,
|
|
8
|
+
rbspUnescape,
|
|
9
|
+
} from '../e2ee-worker/codec';
|
|
10
|
+
|
|
11
|
+
// Single-buffer escape helper. Production only ever escapes the
|
|
12
|
+
// [ciphertext, trailer] segment pair via rbspEscapedLength + rbspEscapeInto, so
|
|
13
|
+
// this convenience wrapper lives in the test rather than the shipped worker.
|
|
14
|
+
const rbspEscape = (data: Uint8Array, seedZeros = 0): Uint8Array => {
|
|
15
|
+
const out = new Uint8Array(rbspEscapedLength([data], seedZeros));
|
|
16
|
+
rbspEscapeInto(out, 0, [data], seedZeros);
|
|
17
|
+
return out;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe('rbspEscape + rbspUnescape', () => {
|
|
21
|
+
// deterministic "random" to exercise many byte values
|
|
22
|
+
const pseudoRandom = Array.from({ length: 256 }, (_, i) => (i * 31) & 0xff);
|
|
23
|
+
|
|
24
|
+
it.each([
|
|
25
|
+
['no escapable sequence', [1, 2, 3, 4, 5]],
|
|
26
|
+
['a run of zeros', [0, 0, 0, 0, 0, 0]],
|
|
27
|
+
['mixed content', [0xaa, 0, 0, 1, 0xbb, 0xcc, 0, 0, 2, 0xdd]],
|
|
28
|
+
['empty input', []],
|
|
29
|
+
['a 256-byte buffer', pseudoRandom],
|
|
30
|
+
])('round-trips %s', (_label, input) => {
|
|
31
|
+
const escaped = rbspEscape(new Uint8Array(input));
|
|
32
|
+
expect(Array.from(rbspUnescape(escaped, 0))).toEqual(input);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('is byte-identical when nothing needs escaping', () => {
|
|
36
|
+
// No zero pairs → no emulation-prevention bytes inserted.
|
|
37
|
+
expect(rbspEscape(new Uint8Array([1, 2, 3, 4, 5]))).toEqual(
|
|
38
|
+
new Uint8Array([1, 2, 3, 4, 5]),
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('inserts 0x03 between 00 00 and 00-03', () => {
|
|
43
|
+
// [0, 0, 1] → [0, 0, 3, 1]
|
|
44
|
+
const out = rbspEscape(new Uint8Array([0, 0, 1]));
|
|
45
|
+
expect(Array.from(out)).toEqual([0, 0, 3, 1]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('produces a buffer free of forbidden start-code-like sequences', () => {
|
|
49
|
+
// After RBSP escaping, the sequences 00 00 00, 00 00 01, and 00 00 02
|
|
50
|
+
// must not appear. 00 00 03 is allowed — it's the escape marker itself.
|
|
51
|
+
const escaped = rbspEscape(new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0]));
|
|
52
|
+
for (let i = 0; i < escaped.length - 2; i++) {
|
|
53
|
+
if (escaped[i] === 0 && escaped[i + 1] === 0) {
|
|
54
|
+
expect(escaped[i + 2]).toBeGreaterThanOrEqual(3);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('rbspEscapeInto + rbspEscapedLength (multi-segment)', () => {
|
|
61
|
+
// The H264 encode path escapes [ciphertext, trailer] as one stream straight
|
|
62
|
+
// behind the clear header. These lock in that escaping the segments is
|
|
63
|
+
// byte-identical to escaping their concatenation, including when an escape
|
|
64
|
+
// sequence straddles the segment boundary.
|
|
65
|
+
const concat = (...segs: number[][]) => new Uint8Array(segs.flat());
|
|
66
|
+
|
|
67
|
+
const escapeSegments = (segs: number[][], seedZeros = 0) => {
|
|
68
|
+
const segments = segs.map((s) => new Uint8Array(s));
|
|
69
|
+
const out = new Uint8Array(rbspEscapedLength(segments, seedZeros));
|
|
70
|
+
rbspEscapeInto(out, 0, segments, seedZeros);
|
|
71
|
+
return out;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
it('matches single-buffer escaping of the concatenation', () => {
|
|
75
|
+
const a = [0xaa, 0, 0, 1, 0xbb];
|
|
76
|
+
const b = [0, 0, 2, 0xcc];
|
|
77
|
+
expect(Array.from(escapeSegments([a, b]))).toEqual(
|
|
78
|
+
Array.from(rbspEscape(concat(a, b))),
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('escapes a 00 00 run that straddles the segment boundary', () => {
|
|
83
|
+
// a ends in 00 00, b starts with 01 -> the escape byte must be inserted at
|
|
84
|
+
// the boundary exactly as if the bytes were one buffer.
|
|
85
|
+
const a = [0xaa, 0, 0];
|
|
86
|
+
const b = [1, 0xbb];
|
|
87
|
+
const escaped = escapeSegments([a, b]);
|
|
88
|
+
expect(Array.from(escaped)).toEqual([0xaa, 0, 0, 3, 1, 0xbb]);
|
|
89
|
+
expect(Array.from(rbspUnescape(escaped, 0))).toEqual([...a, ...b]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('writes at a non-zero offset, leaving earlier bytes untouched', () => {
|
|
93
|
+
const segments = [new Uint8Array([0, 0, 1])];
|
|
94
|
+
const out = new Uint8Array(2 + rbspEscapedLength(segments, 0));
|
|
95
|
+
out[0] = 0x11;
|
|
96
|
+
out[1] = 0x22;
|
|
97
|
+
rbspEscapeInto(out, 2, segments, 0);
|
|
98
|
+
expect(Array.from(out)).toEqual([0x11, 0x22, 0, 0, 3, 1]);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('boundary seeding (clear header ending in zeros)', () => {
|
|
103
|
+
// The encoder never escapes the clear header itself, but on the wire the
|
|
104
|
+
// header's tail and the escaped unit are contiguous. Seeding the escaper
|
|
105
|
+
// with the header's trailing zeros keeps a start code from forming across
|
|
106
|
+
// that boundary, e.g. header ...00 + ciphertext 00 01 -> 00 00 01.
|
|
107
|
+
|
|
108
|
+
it('boundarySeedZeros counts trailing zeros, capped at 2', () => {
|
|
109
|
+
expect(boundarySeedZeros(new Uint8Array([1, 2, 3]))).toBe(0);
|
|
110
|
+
expect(boundarySeedZeros(new Uint8Array([1, 2, 0]))).toBe(1);
|
|
111
|
+
expect(boundarySeedZeros(new Uint8Array([1, 0, 0]))).toBe(2);
|
|
112
|
+
expect(boundarySeedZeros(new Uint8Array([0, 0, 0, 0]))).toBe(2);
|
|
113
|
+
expect(boundarySeedZeros(new Uint8Array([0]))).toBe(1);
|
|
114
|
+
expect(boundarySeedZeros(new Uint8Array([]))).toBe(0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('escapes a start code forming across the clear/encrypted boundary', () => {
|
|
118
|
+
// Header ends in one 0x00 (seed 1); the unit starts 00 01. Unseeded this
|
|
119
|
+
// would ship ...00 | 00 01 (a 3-byte start code); seeded, an escape byte
|
|
120
|
+
// lands before the 01.
|
|
121
|
+
const escaped = rbspEscape(new Uint8Array([0, 1, 0xbb]), 1);
|
|
122
|
+
expect(Array.from(escaped)).toEqual([0, 3, 1, 0xbb]);
|
|
123
|
+
expect(Array.from(rbspUnescape(escaped, 1))).toEqual([0, 1, 0xbb]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('escapes the very first unit byte when the header ends in 00 00', () => {
|
|
127
|
+
const escaped = rbspEscape(new Uint8Array([1, 0xbb]), 2);
|
|
128
|
+
expect(Array.from(escaped)).toEqual([3, 1, 0xbb]);
|
|
129
|
+
expect(Array.from(rbspUnescape(escaped, 2))).toEqual([1, 0xbb]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('leaves a safe boundary alone', () => {
|
|
133
|
+
// The unit starts with a non-start-code byte; nothing to escape.
|
|
134
|
+
const escaped = rbspEscape(new Uint8Array([0xaa, 0, 1]), 2);
|
|
135
|
+
expect(Array.from(escaped)).toEqual([0xaa, 0, 1]);
|
|
136
|
+
expect(Array.from(rbspUnescape(escaped, 2))).toEqual([0xaa, 0, 1]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('round-trips with any seed on content needing internal escapes', () => {
|
|
140
|
+
const input = [0, 0, 1, 0xaa, 0, 0, 0, 2, 0xbb];
|
|
141
|
+
for (const seed of [0, 1, 2]) {
|
|
142
|
+
const escaped = rbspEscape(new Uint8Array(input), seed);
|
|
143
|
+
expect(Array.from(rbspUnescape(escaped, seed))).toEqual(input);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('codec clear-byte rules', () => {
|
|
149
|
+
// The clear-byte count per codec, via the same profile.clearBytes path the
|
|
150
|
+
// encoder uses (getClearByteCount delegate removed to save a hot-path frame).
|
|
151
|
+
const clearBytes = (
|
|
152
|
+
codec: string | undefined,
|
|
153
|
+
frameType: string | undefined,
|
|
154
|
+
data: Uint8Array,
|
|
155
|
+
) => getCodecProfile(codec).clearBytes(frameType, data);
|
|
156
|
+
|
|
157
|
+
// Annex B: [00 00 00 01][SPS][00 00 00 01][slice NALU type 5][...]. The slice
|
|
158
|
+
// start code sits at byte 8 and is 4 bytes long, so clear = 8 + 4 + 2 = 14.
|
|
159
|
+
const h264Idr = new Uint8Array([
|
|
160
|
+
0x00, 0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x0a, 0x00, 0x00, 0x00, 0x01,
|
|
161
|
+
0x65, 0xb8, 0x40,
|
|
162
|
+
]);
|
|
163
|
+
// Only SPS (type 7), so no slice NALU to end the clear header on.
|
|
164
|
+
const h264NoSlice = new Uint8Array([0x00, 0x00, 0x00, 0x01, 0x67, 0x42]);
|
|
165
|
+
const big = new Uint8Array(50);
|
|
166
|
+
|
|
167
|
+
it.each([
|
|
168
|
+
// Audio is identified by the absence of a key/delta type; the Opus TOC
|
|
169
|
+
// byte stays clear so the SFU can read it.
|
|
170
|
+
['audio, codec unlabeled', undefined, undefined, big, 1],
|
|
171
|
+
['audio, opus', 'opus', undefined, big, 1],
|
|
172
|
+
['vp8 keyframe', 'vp8', 'key', big, 10],
|
|
173
|
+
['vp8 delta', 'vp8', 'delta', big, 3],
|
|
174
|
+
['vp9 keyframe', 'vp9', 'key', big, 10],
|
|
175
|
+
['vp9 delta', 'vp9', 'delta', big, 3],
|
|
176
|
+
// A frame shorter than the nominal count must not claim more clear bytes
|
|
177
|
+
// than it has, or encode zero-pads the header and decode builds an AAD of
|
|
178
|
+
// a different length, failing GCM for a frame that should round-trip.
|
|
179
|
+
['vp8 delta shorter than 3 bytes', 'vp8', 'delta', new Uint8Array(2), 2],
|
|
180
|
+
['vp9 keyframe shorter than 10 bytes', 'vp9', 'key', new Uint8Array(5), 5],
|
|
181
|
+
['h264 up to the first slice NALU', 'h264', 'key', h264Idr, 14],
|
|
182
|
+
['h264 with no slice NALU', 'h264', 'key', h264NoSlice, 0],
|
|
183
|
+
['an unknown codec', 'unknown', 'delta', big, 0],
|
|
184
|
+
])('%s -> %s clear bytes', (_label, codec, frameType, data, expected) => {
|
|
185
|
+
expect(clearBytes(codec, frameType, data)).toBe(expected);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('getCodecProfile', () => {
|
|
190
|
+
it('marks only h264 for RBSP escaping', () => {
|
|
191
|
+
// The load-bearing invariant of the table: a codec is fully described in one
|
|
192
|
+
// place, so a half-wired codec (e.g. NALU escaping forgotten) is impossible.
|
|
193
|
+
expect(getCodecProfile('h264')).toMatchObject({ rbsp: true });
|
|
194
|
+
for (const codec of ['opus', 'vp8', 'vp9']) {
|
|
195
|
+
expect(getCodecProfile(codec)).toMatchObject({ rbsp: false });
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('falls back to a passthrough profile for unknown / absent codecs', () => {
|
|
200
|
+
for (const codec of [undefined, 'h265', 'video/vp8']) {
|
|
201
|
+
expect(getCodecProfile(codec)).toMatchObject({ rbsp: false });
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('does not resolve Object.prototype members to a profile', () => {
|
|
206
|
+
// Looked up with `in` rather than Object.hasOwn, 'toString' resolves to a
|
|
207
|
+
// function: getCodecProfile returns it and profile.clearBytes is undefined,
|
|
208
|
+
// so every frame on that track throws inside the encode path.
|
|
209
|
+
for (const codec of ['toString', 'constructor', 'valueOf', '__proto__']) {
|
|
210
|
+
expect(getCodecProfile(codec)).toBe(getCodecProfile('no-such-codec'));
|
|
211
|
+
expect(typeof getCodecProfile(codec).clearBytes).toBe('function');
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('isSupportedCodec', () => {
|
|
217
|
+
it('accepts the known codecs, and undefined for unlabeled audio', () => {
|
|
218
|
+
for (const codec of ['opus', 'vp8', 'vp9', 'h264', undefined]) {
|
|
219
|
+
expect(isSupportedCodec(codec)).toBe(true);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('rejects unknown or mis-cased codecs', () => {
|
|
224
|
+
expect(isSupportedCodec('H264')).toBe(false);
|
|
225
|
+
expect(isSupportedCodec('video/vp8')).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('rejects Object.prototype members', () => {
|
|
229
|
+
// `codec in CODEC_PROFILES` walks the prototype chain and would report
|
|
230
|
+
// these as supported, sending the track down the encode path with no
|
|
231
|
+
// usable profile behind it.
|
|
232
|
+
for (const codec of [
|
|
233
|
+
'toString',
|
|
234
|
+
'constructor',
|
|
235
|
+
'valueOf',
|
|
236
|
+
'hasOwnProperty',
|
|
237
|
+
])
|
|
238
|
+
expect(isSupportedCodec(codec)).toBe(false);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('rejects av1, which has no E2EE framing scheme yet', () => {
|
|
242
|
+
// The encode path turns this into a fail-closed transform: every frame is
|
|
243
|
+
// dropped and e2ee.encryption_failed is emitted, so an AV1 track can never
|
|
244
|
+
// be published in the clear on an encrypted call.
|
|
245
|
+
expect(isSupportedCodec('av1')).toBe(false);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SPEC.md §11 conformance vectors, pinned byte-for-byte.
|
|
5
|
+
*
|
|
6
|
+
* These lock the wire format: a refactor of the trailer writer, IV derivation,
|
|
7
|
+
* clear-byte rules or RBSP escaping that changes any output byte breaks
|
|
8
|
+
* interop with every other SDK, and the symmetric round-trip tests would not
|
|
9
|
+
* notice. Any intentional change here is a wire break: bump the trailer
|
|
10
|
+
* `version` and regenerate the vectors in SPEC.md §11.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// The worker registers its listeners at import time and uses self.postMessage;
|
|
14
|
+
// capture both so the tests can drive it through its real message interface.
|
|
15
|
+
const handlers: Record<string, (e: { data: unknown }) => void> = {};
|
|
16
|
+
vi.stubGlobal(
|
|
17
|
+
'addEventListener',
|
|
18
|
+
(type: string, h: (e: { data: unknown }) => void) => {
|
|
19
|
+
handlers[type] = h;
|
|
20
|
+
},
|
|
21
|
+
);
|
|
22
|
+
vi.stubGlobal('self', { postMessage: () => undefined });
|
|
23
|
+
|
|
24
|
+
// The vectors fix `ivPrefix` = 11 11 11 11 11 11 11 11, but the prefix is
|
|
25
|
+
// drawn from the CSPRNG on key import, so pin the RNG. SubtleCrypto stays real:
|
|
26
|
+
// the ciphertext bytes come from actual AES-GCM.
|
|
27
|
+
const realCrypto = globalThis.crypto;
|
|
28
|
+
vi.stubGlobal('crypto', {
|
|
29
|
+
subtle: realCrypto.subtle,
|
|
30
|
+
getRandomValues: (arr: Uint8Array) => arr.fill(0x11),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
await import('../e2ee-worker/e2ee-worker-impl');
|
|
34
|
+
const { enqueue } = await import('../e2ee-worker/queue');
|
|
35
|
+
const { keyStore } = await import('../e2ee-worker/keyStore');
|
|
36
|
+
const { __resetFrameCounterForTest } =
|
|
37
|
+
await import('../e2ee-worker/frameCounter');
|
|
38
|
+
|
|
39
|
+
type Frame = {
|
|
40
|
+
data: ArrayBuffer;
|
|
41
|
+
type?: 'key' | 'delta' | 'empty';
|
|
42
|
+
timestamp: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// key = 000102030405060708090a0b0c0d0e0f (AES-128-GCM), keyIndex = 0.
|
|
46
|
+
const KEY = Array.from({ length: 16 }, (_, i) => i);
|
|
47
|
+
|
|
48
|
+
const hex = (s: string): Uint8Array => {
|
|
49
|
+
const clean = s.replace(/\s+/g, '');
|
|
50
|
+
const out = new Uint8Array(clean.length / 2);
|
|
51
|
+
for (let i = 0; i < out.length; i++) {
|
|
52
|
+
out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
};
|
|
56
|
+
const toHex = (b: Uint8Array): string =>
|
|
57
|
+
Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
|
|
58
|
+
|
|
59
|
+
const message = (data: unknown) => handlers.message({ data });
|
|
60
|
+
const flush = () => enqueue(async () => undefined);
|
|
61
|
+
|
|
62
|
+
const setKey = async (userId: string) => {
|
|
63
|
+
message({
|
|
64
|
+
type: 'cmd.set_key',
|
|
65
|
+
userId,
|
|
66
|
+
keyIndex: 0,
|
|
67
|
+
rawKey: new Uint8Array(KEY).buffer,
|
|
68
|
+
});
|
|
69
|
+
await flush();
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const drive = async (
|
|
73
|
+
operation: 'encode' | 'decode',
|
|
74
|
+
userId: string,
|
|
75
|
+
codec: string | undefined,
|
|
76
|
+
frames: Frame[],
|
|
77
|
+
): Promise<Frame[]> => {
|
|
78
|
+
const out: Frame[] = [];
|
|
79
|
+
const readable = new ReadableStream<Frame>({
|
|
80
|
+
start(c) {
|
|
81
|
+
for (const f of frames) c.enqueue(f);
|
|
82
|
+
c.close();
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
let resolveDone!: () => void;
|
|
86
|
+
const done = new Promise<void>((r) => (resolveDone = r));
|
|
87
|
+
const writable = new WritableStream<Frame>({
|
|
88
|
+
write(f) {
|
|
89
|
+
out.push(f);
|
|
90
|
+
},
|
|
91
|
+
close: () => resolveDone(),
|
|
92
|
+
abort: () => resolveDone(),
|
|
93
|
+
});
|
|
94
|
+
message({
|
|
95
|
+
type: 'cmd.setup_transform',
|
|
96
|
+
readable,
|
|
97
|
+
writable,
|
|
98
|
+
operation,
|
|
99
|
+
userId,
|
|
100
|
+
codec,
|
|
101
|
+
});
|
|
102
|
+
await done;
|
|
103
|
+
return out;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
let nextUser = 0;
|
|
107
|
+
// A fresh user starts at frame counter 0, so the first frame uses counter 1,
|
|
108
|
+
// matching the vectors.
|
|
109
|
+
const freshUser = () => `vector-user-${nextUser++}`;
|
|
110
|
+
|
|
111
|
+
afterEach(async () => {
|
|
112
|
+
await flush();
|
|
113
|
+
keyStore.clear();
|
|
114
|
+
__resetFrameCounterForTest();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
interface Vector {
|
|
118
|
+
codec: string;
|
|
119
|
+
frameType: Frame['type'];
|
|
120
|
+
input: string;
|
|
121
|
+
output: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const VECTORS: Record<string, Vector> = {
|
|
125
|
+
'opus (audio, clearBytes 1)': {
|
|
126
|
+
codec: 'opus',
|
|
127
|
+
frameType: undefined,
|
|
128
|
+
input: '78aabbccdd',
|
|
129
|
+
output:
|
|
130
|
+
'78' +
|
|
131
|
+
'd02bf795e85c0bed034f7b282ca617cf76d57eb0' +
|
|
132
|
+
'00000001 1111111111111111 00 0001 01 e2eefeed',
|
|
133
|
+
},
|
|
134
|
+
'vp8 keyframe (clearBytes 10)': {
|
|
135
|
+
codec: 'vp8',
|
|
136
|
+
frameType: 'key',
|
|
137
|
+
input: '10111213141516171819aabb',
|
|
138
|
+
output:
|
|
139
|
+
'10111213141516171819' +
|
|
140
|
+
'd02b0484ce4aa2b21a4a83cbfe2ed6511c68' +
|
|
141
|
+
'00000001 1111111111111111 00 000a 01 e2eefeed',
|
|
142
|
+
},
|
|
143
|
+
'h264 IDR (clearBytes 6, RBSP escaping)': {
|
|
144
|
+
codec: 'h264',
|
|
145
|
+
frameType: 'key',
|
|
146
|
+
input: '00000001 65 8884deadbe',
|
|
147
|
+
output:
|
|
148
|
+
'000000016588' +
|
|
149
|
+
'fe4e96f631df11f57a43ed2003eaad0c5d6db632' +
|
|
150
|
+
// Escaped trailer: 0x03 inserted into the counter's 00 00 00 01 run,
|
|
151
|
+
// and clearBytes carries the RBSP flag (0x8006).
|
|
152
|
+
'0000030001 1111111111111111 00 8006 01 e2eefeed',
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
describe('SPEC §11 conformance vectors', () => {
|
|
157
|
+
it.each(Object.entries(VECTORS))(
|
|
158
|
+
'encodes %s to the exact spec bytes',
|
|
159
|
+
async (_label, v) => {
|
|
160
|
+
const user = freshUser();
|
|
161
|
+
await setKey(user);
|
|
162
|
+
const [encrypted] = await drive('encode', user, v.codec, [
|
|
163
|
+
{ data: hex(v.input).buffer, type: v.frameType, timestamp: 1 },
|
|
164
|
+
]);
|
|
165
|
+
expect(encrypted).toBeDefined();
|
|
166
|
+
expect(toHex(new Uint8Array(encrypted.data))).toBe(toHex(hex(v.output)));
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
it.each(Object.entries(VECTORS))(
|
|
171
|
+
'decodes the %s spec bytes back to the input',
|
|
172
|
+
async (_label, v) => {
|
|
173
|
+
const user = freshUser();
|
|
174
|
+
await setKey(user);
|
|
175
|
+
// Decode the pinned bytes, not this build's encode output, so the
|
|
176
|
+
// decoder is checked against the format other SDKs will send.
|
|
177
|
+
const [decrypted] = await drive('decode', user, undefined, [
|
|
178
|
+
{ data: hex(v.output).buffer, type: v.frameType, timestamp: 1 },
|
|
179
|
+
]);
|
|
180
|
+
expect(decrypted).toBeDefined();
|
|
181
|
+
expect(toHex(new Uint8Array(decrypted.data))).toBe(toHex(hex(v.input)));
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { FAILURE_TOLERANCE } from '../e2ee-worker/constants';
|
|
3
|
+
import { FailureTracker } from '../e2ee-worker/failureTracker';
|
|
4
|
+
|
|
5
|
+
describe('FailureTracker', () => {
|
|
6
|
+
it('flags the break only on the failure that crosses tolerance', () => {
|
|
7
|
+
const tracker = new FailureTracker();
|
|
8
|
+
// The first FAILURE_TOLERANCE failures stay under the bar.
|
|
9
|
+
for (let i = 0; i < FAILURE_TOLERANCE; i++) {
|
|
10
|
+
expect(tracker.recordFailure(1)).toBe(false);
|
|
11
|
+
}
|
|
12
|
+
// The next one crosses it - the break transition fires exactly once.
|
|
13
|
+
expect(tracker.recordFailure(1)).toBe(true);
|
|
14
|
+
expect(tracker.recordFailure(1)).toBe(false); // already stalled, no re-fire
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('recordSuccess clears the count and reports whether there were failures', () => {
|
|
18
|
+
const tracker = new FailureTracker();
|
|
19
|
+
expect(tracker.recordSuccess(1)).toBe(false); // nothing to resume
|
|
20
|
+
tracker.recordFailure(1);
|
|
21
|
+
expect(tracker.recordSuccess(1)).toBe(true); // had a failure -> recovered
|
|
22
|
+
expect(tracker.recordSuccess(1)).toBe(false); // already clear
|
|
23
|
+
// After a reset the tolerance bar can be crossed (and reported) again.
|
|
24
|
+
for (let i = 0; i < FAILURE_TOLERANCE; i++) tracker.recordFailure(1);
|
|
25
|
+
expect(tracker.recordFailure(1)).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('counts each keyIndex independently within a track', () => {
|
|
29
|
+
const tracker = new FailureTracker();
|
|
30
|
+
for (let i = 0; i <= FAILURE_TOLERANCE; i++) tracker.recordFailure(1);
|
|
31
|
+
// keyIndex 2 starts fresh: a key rotation does not inherit index 1's count.
|
|
32
|
+
expect(tracker.recordFailure(2)).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('scopes failures per tracker so one track cannot reset another', () => {
|
|
36
|
+
const video = new FailureTracker();
|
|
37
|
+
const audio = new FailureTracker();
|
|
38
|
+
for (let i = 0; i <= FAILURE_TOLERANCE; i++) video.recordFailure(1);
|
|
39
|
+
// The audio track shares neither the count nor the recovery edge.
|
|
40
|
+
expect(audio.recordFailure(1)).toBe(false);
|
|
41
|
+
expect(audio.recordSuccess(1)).toBe(true); // only its own single failure
|
|
42
|
+
// ...and video's break state is untouched by audio's activity.
|
|
43
|
+
expect(video.recordSuccess(1)).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { COUNTER_HARD_LIMIT } from '../e2ee-worker/constants';
|
|
3
|
+
|
|
4
|
+
// The counter itself posts nothing, but importing a key to prove a rekey does
|
|
5
|
+
// not reset it goes through notifications.ts on failure.
|
|
6
|
+
const postMessage = vi.fn();
|
|
7
|
+
vi.stubGlobal('self', { postMessage });
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
__resetFrameCounterForTest,
|
|
11
|
+
__setFrameCounterForTest,
|
|
12
|
+
nextFrameCounter,
|
|
13
|
+
} from '../e2ee-worker/frameCounter';
|
|
14
|
+
import { keyStore } from '../e2ee-worker/keyStore';
|
|
15
|
+
|
|
16
|
+
const rawKey = (seed = 0xab): ArrayBuffer => {
|
|
17
|
+
const buf = new ArrayBuffer(16);
|
|
18
|
+
const bytes = new Uint8Array(buf);
|
|
19
|
+
for (let i = 0; i < 16; i++) bytes[i] = (seed + i) & 0xff;
|
|
20
|
+
return buf;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
__resetFrameCounterForTest();
|
|
25
|
+
keyStore.clear();
|
|
26
|
+
postMessage.mockClear();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('nextFrameCounter', () => {
|
|
30
|
+
it('increments monotonically, shared across every track', () => {
|
|
31
|
+
// One counter per worker, drawn from by every encode transform. Separate
|
|
32
|
+
// counters per track would let two of this sender's tracks land on the
|
|
33
|
+
// same (ivPrefix, counter) pair under one key.
|
|
34
|
+
expect(nextFrameCounter()).toBe(1);
|
|
35
|
+
expect(nextFrameCounter()).toBe(2);
|
|
36
|
+
expect(nextFrameCounter()).toBe(3);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('survives removeAllKeys — counter is never rolled back', async () => {
|
|
40
|
+
await keyStore.importKey('alice', 1, rawKey());
|
|
41
|
+
expect(nextFrameCounter()).toBe(1);
|
|
42
|
+
expect(nextFrameCounter()).toBe(2);
|
|
43
|
+
|
|
44
|
+
keyStore.removeAllKeys('alice');
|
|
45
|
+
|
|
46
|
+
// Re-import the same raw key. Counter must NOT restart — otherwise
|
|
47
|
+
// we'd reuse IVs on the new import's first frames.
|
|
48
|
+
await keyStore.importKey('alice', 1, rawKey());
|
|
49
|
+
expect(nextFrameCounter()).toBe(3);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('throws at the 32-bit hard limit and stays exhausted', () => {
|
|
53
|
+
__setFrameCounterForTest(COUNTER_HARD_LIMIT);
|
|
54
|
+
expect(() => nextFrameCounter()).toThrow(/counter exhausted/);
|
|
55
|
+
// Every later frame must fail identically rather than wrapping into a
|
|
56
|
+
// counter that was already used with this ivPrefix.
|
|
57
|
+
expect(() => nextFrameCounter()).toThrow(/counter exhausted/);
|
|
58
|
+
expect(() => nextFrameCounter()).toThrow(/counter exhausted/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('a rekey does not recover an exhausted counter', async () => {
|
|
62
|
+
__setFrameCounterForTest(COUNTER_HARD_LIMIT);
|
|
63
|
+
expect(() => nextFrameCounter()).toThrow(/new EncryptionManager/);
|
|
64
|
+
|
|
65
|
+
// Importing fresh key material is the remedy an integrator would reach
|
|
66
|
+
// for first. It gives a new ivPrefix, but the counter is scoped to the
|
|
67
|
+
// worker rather than to the key, so encryption stays dead.
|
|
68
|
+
await keyStore.importKey('alice', 7, rawKey());
|
|
69
|
+
expect(() => nextFrameCounter()).toThrow(/counter exhausted/);
|
|
70
|
+
|
|
71
|
+
// Same after dropping the user's keys entirely.
|
|
72
|
+
keyStore.removeAllKeys('alice');
|
|
73
|
+
await keyStore.importKey('alice', 8, rawKey());
|
|
74
|
+
expect(() => nextFrameCounter()).toThrow(/counter exhausted/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('never posts a rotation warning ahead of the hard limit', () => {
|
|
78
|
+
// The 2^31 soft threshold and its `e2ee.rotation_needed` event were
|
|
79
|
+
// removed: rotating cannot buy back counter budget, so the signal named a
|
|
80
|
+
// remedy that does nothing. Only the fail-closed ceiling remains.
|
|
81
|
+
__setFrameCounterForTest(0x80000000 - 1);
|
|
82
|
+
nextFrameCounter();
|
|
83
|
+
nextFrameCounter();
|
|
84
|
+
expect(
|
|
85
|
+
postMessage.mock.calls.filter(([msg]) =>
|
|
86
|
+
String(msg?.type).startsWith('e2ee.'),
|
|
87
|
+
),
|
|
88
|
+
).toHaveLength(0);
|
|
89
|
+
});
|
|
90
|
+
});
|