@stream-io/video-client 1.56.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 +23 -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 +867 -71
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +911 -70
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.es.js +900 -71
- 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/MediaPlaybackWatchdog.d.ts +4 -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 +11 -11
- 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 +2 -22
- package/src/devices/__tests__/SpeakerManager.test.ts +138 -42
- package/src/devices/__tests__/mocks.ts +36 -3
- package/src/devices/devicePersistence.ts +1 -2
- 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/MediaPlaybackWatchdog.ts +35 -16
- package/src/helpers/TypedEventEmitter.ts +71 -0
- package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +137 -8
- package/src/helpers/__tests__/TypedEventEmitter.test.ts +133 -0
- package/src/helpers/__tests__/ViewportTracker.test.ts +1 -2
- package/src/helpers/ensureExhausted.ts +4 -2
- package/src/permissions/PermissionsContext.ts +10 -5
- package/src/reporting/ClientEventReporter.ts +2 -8
- 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 +3 -2
- package/src/stats/rtc/types.ts +8 -2
- package/src/store/CallState.ts +34 -3
- package/src/store/__tests__/CallState.test.ts +82 -1
- package/src/types.ts +24 -9
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import '../../__tests__/mocks/webrtc.mocks';
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { preferredTransform } from '../transformSupport';
|
|
5
|
+
import { isChrome } from '../../../helpers/browsers';
|
|
6
|
+
|
|
7
|
+
// Mock browser detection so we can drive the Chrome vs non-Chrome transform
|
|
8
|
+
// selection deterministically. Defaults to non-Chrome (reset in beforeEach).
|
|
9
|
+
vi.mock('../../../helpers/browsers', async (importOriginal) => {
|
|
10
|
+
const actual =
|
|
11
|
+
await importOriginal<typeof import('../../../helpers/browsers')>();
|
|
12
|
+
return { ...actual, isChrome: vi.fn().mockReturnValue(false) };
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('preferredTransform', () => {
|
|
16
|
+
const setInsertableStreams = (available: boolean) => {
|
|
17
|
+
if (available) {
|
|
18
|
+
Object.assign(RTCRtpSender.prototype, {
|
|
19
|
+
createEncodedStreams: vi.fn(),
|
|
20
|
+
});
|
|
21
|
+
} else {
|
|
22
|
+
// @ts-expect-error - cleaning up non-standard property from mock prototype
|
|
23
|
+
delete RTCRtpSender.prototype.createEncodedStreams;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
vi.mocked(isChrome).mockReturnValue(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
// @ts-expect-error - cleaning up non-standard property from mock prototype
|
|
33
|
+
delete RTCRtpSender.prototype.createEncodedStreams;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const setScriptTransform = (available: boolean) => {
|
|
37
|
+
if (available) return;
|
|
38
|
+
const original = globalThis.RTCRtpScriptTransform;
|
|
39
|
+
delete globalThis.RTCRtpScriptTransform;
|
|
40
|
+
return () => {
|
|
41
|
+
globalThis.RTCRtpScriptTransform = original;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// The whole selection policy is this matrix. Chrome is pinned to Insertable
|
|
46
|
+
// Streams wherever it exists, because its RTCRtpScriptTransform is still
|
|
47
|
+
// unreliable for E2EE; everything else prefers the standard API.
|
|
48
|
+
it.each([
|
|
49
|
+
['chrome', true, true, true, 'insertable'],
|
|
50
|
+
['chrome, no insertable streams', true, false, true, 'script'],
|
|
51
|
+
['chrome, neither API', true, false, false, undefined],
|
|
52
|
+
['non-chrome', false, true, true, 'script'],
|
|
53
|
+
['non-chrome, no script transform', false, true, false, 'insertable'],
|
|
54
|
+
['non-chrome, neither API', false, false, false, undefined],
|
|
55
|
+
] as const)('%s', (_label, chrome, insertable, script, expected) => {
|
|
56
|
+
vi.mocked(isChrome).mockReturnValue(chrome);
|
|
57
|
+
setInsertableStreams(insertable);
|
|
58
|
+
const restore = setScriptTransform(script);
|
|
59
|
+
try {
|
|
60
|
+
expect(preferredTransform()).toBe(expected);
|
|
61
|
+
} finally {
|
|
62
|
+
restore?.();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codec-specific framing: how many header bytes stay clear, and H.264 RBSP
|
|
3
|
+
* escaping so ciphertext cannot contain a fake Annex B start code.
|
|
4
|
+
*/
|
|
5
|
+
const findStartCode = (
|
|
6
|
+
data: Uint8Array,
|
|
7
|
+
offset: number,
|
|
8
|
+
): { pos: number; len: number } | null => {
|
|
9
|
+
for (let i = offset; i < data.length - 2; ++i) {
|
|
10
|
+
if (data[i] === 0 && data[i + 1] === 0) {
|
|
11
|
+
if (data[i + 2] === 1) return { pos: i, len: 3 };
|
|
12
|
+
if (data[i + 2] === 0 && i + 3 < data.length && data[i + 3] === 1) {
|
|
13
|
+
return { pos: i, len: 4 };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Clear bytes up to the first slice NALU (type 1 or 5) plus 2: start code,
|
|
22
|
+
* NALU header, one byte of slice header. Ignores frameType, to match
|
|
23
|
+
* {@link CodecProfile.clearBytes}.
|
|
24
|
+
*/
|
|
25
|
+
const h264ClearBytes = (
|
|
26
|
+
_frameType: string | undefined,
|
|
27
|
+
data: Uint8Array,
|
|
28
|
+
): number => {
|
|
29
|
+
let sc = findStartCode(data, 0);
|
|
30
|
+
while (sc) {
|
|
31
|
+
const headerPos = sc.pos + sc.len;
|
|
32
|
+
if (headerPos >= data.length) break;
|
|
33
|
+
const naluType = data[headerPos] & 0x1f;
|
|
34
|
+
if (naluType === 1 || naluType === 5) {
|
|
35
|
+
const clear = sc.pos + sc.len + 2;
|
|
36
|
+
// A real slice header exceeds 2 bytes, so this clamp should never bite.
|
|
37
|
+
return clear > data.length ? data.length : clear;
|
|
38
|
+
}
|
|
39
|
+
sc = findStartCode(data, headerPos);
|
|
40
|
+
}
|
|
41
|
+
return 0;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Escape-state seed for the clear-header boundary: how many 0x00 bytes end the
|
|
46
|
+
* clear header, capped at 2 (a longer run does not change the escaper's next
|
|
47
|
+
* decision).
|
|
48
|
+
*
|
|
49
|
+
* The clear header itself is never escaped, but its tail and the first escaped
|
|
50
|
+
* bytes form one contiguous stream on the wire: a header ending in 0x00
|
|
51
|
+
* followed by ciphertext starting 0x00 0x01 is a fake start code the
|
|
52
|
+
* packetizer would split on. Seeding the escaper as if the header's zeros were
|
|
53
|
+
* already scanned closes that gap. Encode and decode both derive the seed from
|
|
54
|
+
* the same clear bytes, so nothing extra travels in the frame.
|
|
55
|
+
*/
|
|
56
|
+
export const boundarySeedZeros = (clearHeader: Uint8Array): number => {
|
|
57
|
+
const n = clearHeader.length;
|
|
58
|
+
let zeros = 0;
|
|
59
|
+
while (zeros < 2 && zeros < n && clearHeader[n - 1 - zeros] === 0) zeros++;
|
|
60
|
+
return zeros;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Escaped length of `segments`, read as one contiguous stream so a 0x00 0x00
|
|
65
|
+
* run spanning a boundary counts. Sizes the buffer for {@link rbspEscapeInto}.
|
|
66
|
+
*
|
|
67
|
+
* @param seedZeros zero-run state entering the stream; see
|
|
68
|
+
* {@link boundarySeedZeros}.
|
|
69
|
+
*/
|
|
70
|
+
export const rbspEscapedLength = (
|
|
71
|
+
segments: Uint8Array[],
|
|
72
|
+
seedZeros: number,
|
|
73
|
+
): number => {
|
|
74
|
+
let total = 0;
|
|
75
|
+
let zeros = seedZeros;
|
|
76
|
+
for (const seg of segments) {
|
|
77
|
+
total += seg.length;
|
|
78
|
+
for (let i = 0; i < seg.length; ++i) {
|
|
79
|
+
const byte = seg[i];
|
|
80
|
+
if (zeros >= 2 && byte <= 3) {
|
|
81
|
+
total++;
|
|
82
|
+
zeros = 0;
|
|
83
|
+
}
|
|
84
|
+
zeros = byte === 0 ? zeros + 1 : 0;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return total;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Escape `segments` into `dst` at `offset`, inserting 0x03 after each 0x00 0x00
|
|
92
|
+
* run followed by 0x00-0x03. `dst` needs {@link rbspEscapedLength} bytes free,
|
|
93
|
+
* computed with the same `seedZeros`.
|
|
94
|
+
*
|
|
95
|
+
* Sizing separately lets the encoder escape straight behind the clear header,
|
|
96
|
+
* copying the ciphertext once instead of twice.
|
|
97
|
+
*/
|
|
98
|
+
export const rbspEscapeInto = (
|
|
99
|
+
dst: Uint8Array,
|
|
100
|
+
offset: number,
|
|
101
|
+
segments: Uint8Array[],
|
|
102
|
+
seedZeros: number,
|
|
103
|
+
): void => {
|
|
104
|
+
let j = offset;
|
|
105
|
+
let zeros = seedZeros;
|
|
106
|
+
for (const seg of segments) {
|
|
107
|
+
for (let i = 0; i < seg.length; ++i) {
|
|
108
|
+
const byte = seg[i];
|
|
109
|
+
if (zeros >= 2 && byte <= 3) {
|
|
110
|
+
dst[j++] = 3;
|
|
111
|
+
zeros = 0;
|
|
112
|
+
}
|
|
113
|
+
dst[j++] = byte;
|
|
114
|
+
zeros = byte === 0 ? zeros + 1 : 0;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Reverse of {@link rbspEscapeInto}: drop each 0x03 the escaper inserted,
|
|
121
|
+
* tracked with the same zero-run state so an escape byte right at the
|
|
122
|
+
* clear-header boundary (possible only with a non-zero `seedZeros`) is
|
|
123
|
+
* recognized too.
|
|
124
|
+
*/
|
|
125
|
+
export const rbspUnescape = (
|
|
126
|
+
data: Uint8Array,
|
|
127
|
+
seedZeros: number,
|
|
128
|
+
): Uint8Array => {
|
|
129
|
+
const isEscapeByte = (i: number, zeros: number): boolean =>
|
|
130
|
+
zeros >= 2 && data[i] === 3 && i + 1 < data.length && data[i + 1] <= 3;
|
|
131
|
+
let remove = 0;
|
|
132
|
+
let zeros = seedZeros;
|
|
133
|
+
for (let i = 0; i < data.length; ++i) {
|
|
134
|
+
if (isEscapeByte(i, zeros)) {
|
|
135
|
+
remove++;
|
|
136
|
+
zeros = 0;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
zeros = data[i] === 0 ? zeros + 1 : 0;
|
|
140
|
+
}
|
|
141
|
+
if (remove === 0) return data;
|
|
142
|
+
const result = new Uint8Array(data.length - remove);
|
|
143
|
+
let j = 0;
|
|
144
|
+
zeros = seedZeros;
|
|
145
|
+
for (let i = 0; i < data.length; ++i) {
|
|
146
|
+
if (isEscapeByte(i, zeros)) {
|
|
147
|
+
zeros = 0;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
result[j++] = data[i];
|
|
151
|
+
zeros = data[i] === 0 ? zeros + 1 : 0;
|
|
152
|
+
}
|
|
153
|
+
return result;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* How E2EE splits a frame, for one codec. The only place holding encode-side
|
|
158
|
+
* codec knowledge: one entry wires support detection, clear-byte sizing and
|
|
159
|
+
* RBSP escaping together, so no codec can be half-supported. An H265 entry
|
|
160
|
+
* that forgot NALU escaping would ship start-code-corrupting ciphertext.
|
|
161
|
+
*/
|
|
162
|
+
export interface CodecProfile {
|
|
163
|
+
/** Escape ciphertext and trailer against fake Annex-B start codes (H264). */
|
|
164
|
+
rbsp: boolean;
|
|
165
|
+
/**
|
|
166
|
+
* The profile can frame audio only, so the encoder fails closed on a frame
|
|
167
|
+
* that carries a key/delta type. Without this an unlabeled video frame would
|
|
168
|
+
* be encrypted whole and unescaped: the SFU could not read its headers, and a
|
|
169
|
+
* NALU packetizer would split it on a random start code in the ciphertext.
|
|
170
|
+
*/
|
|
171
|
+
audioOnly: boolean;
|
|
172
|
+
/** Leading bytes left clear and passed as AAD, so the SFU can select layers. */
|
|
173
|
+
clearBytes: (frameType: string | undefined, data: Uint8Array) => number;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Audio has no keyframes, so the absence of a frame type identifies it. Keep
|
|
177
|
+
// the Opus TOC byte clear. Only reached for audio: `audioOnly` rejects a video
|
|
178
|
+
// frame before this runs.
|
|
179
|
+
const defaultClearBytes = (frameType: string | undefined): number =>
|
|
180
|
+
frameType === undefined ? 1 : 0;
|
|
181
|
+
|
|
182
|
+
// Clamped to the frame length. A short frame claiming more would make encode
|
|
183
|
+
// zero-pad the header and decode build an AAD of a different length.
|
|
184
|
+
const vpClearBytes = (
|
|
185
|
+
frameType: string | undefined,
|
|
186
|
+
data: Uint8Array,
|
|
187
|
+
): number => {
|
|
188
|
+
const clear = frameType === 'key' ? 10 : 3;
|
|
189
|
+
return clear > data.length ? data.length : clear;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// AV1 is absent on purpose: the AV1 RTP packetizer parses the OBU stream, so a
|
|
193
|
+
// frame trailer does not survive. It needs its own scheme. Without an entry it
|
|
194
|
+
// falls through to isSupportedCodec and fails closed.
|
|
195
|
+
const CODEC_PROFILES: Record<string, CodecProfile> = {
|
|
196
|
+
opus: { rbsp: false, audioOnly: true, clearBytes: defaultClearBytes },
|
|
197
|
+
vp8: { rbsp: false, audioOnly: false, clearBytes: vpClearBytes },
|
|
198
|
+
vp9: { rbsp: false, audioOnly: false, clearBytes: vpClearBytes },
|
|
199
|
+
h264: { rbsp: true, audioOnly: false, clearBytes: h264ClearBytes },
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// Used when the caller names no codec. Audio is safe to frame unlabeled, since
|
|
203
|
+
// the TOC byte rule holds for any audio codec. Video is not: the right clear
|
|
204
|
+
// header and whether to escape both depend on the codec, so it fails closed.
|
|
205
|
+
const DEFAULT_PROFILE: CodecProfile = {
|
|
206
|
+
rbsp: false,
|
|
207
|
+
audioOnly: true,
|
|
208
|
+
clearBytes: defaultClearBytes,
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// Object.hasOwn, not `in`: `in` walks the prototype chain, so a codec named
|
|
212
|
+
// like an Object.prototype member ('toString') would resolve to a function
|
|
213
|
+
// instead of a profile.
|
|
214
|
+
export const getCodecProfile = (codec: string | undefined): CodecProfile =>
|
|
215
|
+
codec !== undefined && Object.hasOwn(CODEC_PROFILES, codec)
|
|
216
|
+
? CODEC_PROFILES[codec]
|
|
217
|
+
: DEFAULT_PROFILE;
|
|
218
|
+
|
|
219
|
+
export const isSupportedCodec = (codec: string | undefined): boolean =>
|
|
220
|
+
codec === undefined || Object.hasOwn(CODEC_PROFILES, codec);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Marks an encrypted frame's trailer. */
|
|
2
|
+
export const MAGIC = 0xe2eefeed;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Wire format version. Bump it when the trailer layout or IV derivation change.
|
|
6
|
+
*
|
|
7
|
+
* v1: [4B frameCounter][8B ivPrefix][1B keyIndex][2B clearBytes|flags]
|
|
8
|
+
* [1B version][4B magic]. IV = ivPrefix ∥ frameCounter.
|
|
9
|
+
*/
|
|
10
|
+
export const E2EE_VERSION = 1;
|
|
11
|
+
|
|
12
|
+
export const IV_PREFIX_LEN = 8;
|
|
13
|
+
export const FRAME_COUNTER_LEN = 4;
|
|
14
|
+
export const IV_LEN = IV_PREFIX_LEN + FRAME_COUNTER_LEN;
|
|
15
|
+
|
|
16
|
+
const KEY_INDEX_LEN = 1;
|
|
17
|
+
const CLEAR_BYTES_LEN = 2;
|
|
18
|
+
const VERSION_LEN = 1;
|
|
19
|
+
const MAGIC_LEN = 4;
|
|
20
|
+
|
|
21
|
+
/** 4 + 8 + 1 + 2 + 1 + 4 */
|
|
22
|
+
export const TRAILER_LEN =
|
|
23
|
+
FRAME_COUNTER_LEN +
|
|
24
|
+
IV_PREFIX_LEN +
|
|
25
|
+
KEY_INDEX_LEN +
|
|
26
|
+
CLEAR_BYTES_LEN +
|
|
27
|
+
VERSION_LEN +
|
|
28
|
+
MAGIC_LEN;
|
|
29
|
+
|
|
30
|
+
/** Bit 15 of the clearBytes field. Signals RBSP escaping. */
|
|
31
|
+
export const RBSP_FLAG = 0x8000;
|
|
32
|
+
/** Bit 15 belongs to RBSP_FLAG, so clearBytes gets 15 bits. */
|
|
33
|
+
export const MAX_CLEAR_BYTES = 0x7fff;
|
|
34
|
+
|
|
35
|
+
export const EMPTY_AAD = new Uint8Array(0);
|
|
36
|
+
|
|
37
|
+
/** Consecutive decrypt failures on one track before `e2ee.decryption_stalled` fires. */
|
|
38
|
+
export const FAILURE_TOLERANCE = 10;
|
|
39
|
+
|
|
40
|
+
/** Replay window in frames. A counter <= highestSeen - this is rejected. */
|
|
41
|
+
export const REPLAY_WINDOW = 1024;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One more than this wraps into an (ivPrefix, counter) pair the sender already
|
|
45
|
+
* used, which is IV reuse under AES-GCM. Encoding throws instead.
|
|
46
|
+
*/
|
|
47
|
+
export const COUNTER_HARD_LIMIT = 0xffffffff; // 2^32 - 1
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { E2EE_VERSION, EMPTY_AAD, IV_LEN, TRAILER_LEN } from './constants';
|
|
2
|
+
import { boundarySeedZeros, rbspUnescape } from './codec';
|
|
3
|
+
import {
|
|
4
|
+
fillIV,
|
|
5
|
+
readFramingVersion,
|
|
6
|
+
readTrailer,
|
|
7
|
+
readTrailerIv,
|
|
8
|
+
} from './trailer';
|
|
9
|
+
import { FailureTracker } from './failureTracker';
|
|
10
|
+
import { ReplayWindow } from './replayWindow';
|
|
11
|
+
import { keyStore } from './keyStore';
|
|
12
|
+
import { decodeStats } from './perf';
|
|
13
|
+
import { DecodeNotifier } from './notifications';
|
|
14
|
+
import type { EncodedFrame, FrameController } from './types';
|
|
15
|
+
|
|
16
|
+
export const decodeTransform = (
|
|
17
|
+
userId: string,
|
|
18
|
+
trackType: string | undefined,
|
|
19
|
+
) => {
|
|
20
|
+
// Counts a peer's audio and video apart.
|
|
21
|
+
const trackKey = trackType ?? 'unknown';
|
|
22
|
+
const stats = decodeStats.track(`${userId}/${trackKey}`, {
|
|
23
|
+
userId,
|
|
24
|
+
trackType: trackKey,
|
|
25
|
+
});
|
|
26
|
+
const notify = new DecodeNotifier(userId, trackType);
|
|
27
|
+
const iv = new Uint8Array(IV_LEN);
|
|
28
|
+
const ivView = new DataView(iv.buffer);
|
|
29
|
+
|
|
30
|
+
// Per track, so a user's audio, video and screen share never share a window
|
|
31
|
+
// or a failure count. The separate count is what lets
|
|
32
|
+
// e2ee.decryption_stalled fire.
|
|
33
|
+
const replay = new ReplayWindow();
|
|
34
|
+
const failures = new FailureTracker();
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Gates on key and replay, decrypts, emits, then records failure or recovery.
|
|
38
|
+
* `decrypt` throws on a GCM tag failure, dropping the frame. Separate from
|
|
39
|
+
* the framing parse so the trust ordering lives in one place.
|
|
40
|
+
*
|
|
41
|
+
* Trust ordering (the SFrame/SRTP rule): a relay can forge `frameCounter`,
|
|
42
|
+
* `ivPrefix` and `keyIndex`, which are plaintext in the trailer, so nothing
|
|
43
|
+
* changes trust state until GCM authenticates. Hence peek before, commit
|
|
44
|
+
* after. The failure counter is diagnostic only - it gates `e2ee.decryption_stalled`,
|
|
45
|
+
* never the decrypt attempt - so forged frames cannot mark a key invalid.
|
|
46
|
+
*/
|
|
47
|
+
const finishDecode = async (
|
|
48
|
+
frame: EncodedFrame,
|
|
49
|
+
controller: FrameController,
|
|
50
|
+
keyIndex: number,
|
|
51
|
+
ivPrefix: Uint8Array,
|
|
52
|
+
frameCounter: number,
|
|
53
|
+
decrypt: (key: CryptoKey) => Promise<ArrayBuffer>,
|
|
54
|
+
) => {
|
|
55
|
+
const cryptoKey = keyStore.getKey(userId, keyIndex);
|
|
56
|
+
if (!cryptoKey) {
|
|
57
|
+
notify.missingKey(keyIndex);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
// No state change. A replay or an out-of-window frame is dropped silently;
|
|
61
|
+
// neither is a decryption failure.
|
|
62
|
+
if (!replay.peek(frameCounter, ivPrefix)) return;
|
|
63
|
+
try {
|
|
64
|
+
const t0 = stats.startCrypto();
|
|
65
|
+
const data = await decrypt(cryptoKey);
|
|
66
|
+
stats.endCrypto(t0);
|
|
67
|
+
// Authenticated: only now is it safe to advance the replay window.
|
|
68
|
+
replay.commit(frameCounter, ivPrefix);
|
|
69
|
+
// Independent on purpose: the count is per keyIndex, but
|
|
70
|
+
// `decryption_failed` is per track, so a track recovering on a NEW
|
|
71
|
+
// keyIndex must still clear it. Gating on recordSuccess would latch the
|
|
72
|
+
// host on failed forever.
|
|
73
|
+
failures.recordSuccess(keyIndex);
|
|
74
|
+
notify.resumed();
|
|
75
|
+
frame.data = data;
|
|
76
|
+
controller.enqueue(frame);
|
|
77
|
+
stats.bump();
|
|
78
|
+
} catch {
|
|
79
|
+
// True only on the failure crossing the tolerance, so
|
|
80
|
+
// `e2ee.decryption_stalled` fires once per run, not once per frame.
|
|
81
|
+
const stalled = failures.recordFailure(keyIndex);
|
|
82
|
+
notify.failed();
|
|
83
|
+
if (stalled) notify.stalled(keyIndex);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return new TransformStream<EncodedFrame, EncodedFrame>({
|
|
88
|
+
async transform(frame, controller) {
|
|
89
|
+
if (frame.data.byteLength === 0) {
|
|
90
|
+
controller.enqueue(frame);
|
|
91
|
+
stats.bump();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const src = new Uint8Array(frame.data);
|
|
96
|
+
const trailer = readTrailer(src);
|
|
97
|
+
|
|
98
|
+
if (!trailer) {
|
|
99
|
+
// Ours but unreadable: drop rather than forward. Handing ciphertext to
|
|
100
|
+
// the decoder renders corruption and reads to the host as a downgrade,
|
|
101
|
+
// when the actual condition is that this build is the older one.
|
|
102
|
+
const version = readFramingVersion(src);
|
|
103
|
+
if (version !== null && version !== E2EE_VERSION) {
|
|
104
|
+
notify.unsupportedVersion(version);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
notify.unencrypted();
|
|
108
|
+
controller.enqueue(frame);
|
|
109
|
+
stats.bump();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const { clearBytes, isRbsp } = trailer;
|
|
114
|
+
|
|
115
|
+
// An RBSP (H264) frame escaped the ciphertext together with the counter,
|
|
116
|
+
// ivPrefix and keyIndex, so un-escape to recover them; only the trailer
|
|
117
|
+
// tail read above stayed clear. A non-RBSP frame keeps the trailer raw.
|
|
118
|
+
let { frameCounter, ivPrefix, keyIndex } = trailer;
|
|
119
|
+
let ciphertext: Uint8Array;
|
|
120
|
+
if (isRbsp) {
|
|
121
|
+
// Same boundary seed as the encoder, derived from the same clear bytes.
|
|
122
|
+
const seed = boundarySeedZeros(src.subarray(0, clearBytes));
|
|
123
|
+
const unit = rbspUnescape(src.subarray(clearBytes), seed);
|
|
124
|
+
// Un-escaping can leave less than a trailer, since readTrailer sized
|
|
125
|
+
// clearBytes against the raw frame. A negative offset would throw out
|
|
126
|
+
// of transform() and kill this track's pipeline for the session, and a
|
|
127
|
+
// relay can forge the shape: clearBytes and the flag are plaintext.
|
|
128
|
+
if (unit.length < TRAILER_LEN) return;
|
|
129
|
+
({ frameCounter, ivPrefix, keyIndex } = readTrailerIv(unit));
|
|
130
|
+
ciphertext = unit.subarray(0, unit.length - TRAILER_LEN);
|
|
131
|
+
} else {
|
|
132
|
+
ciphertext = src.subarray(clearBytes, src.length - TRAILER_LEN);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return finishDecode(
|
|
136
|
+
frame,
|
|
137
|
+
controller,
|
|
138
|
+
keyIndex,
|
|
139
|
+
ivPrefix,
|
|
140
|
+
frameCounter,
|
|
141
|
+
async (key) => {
|
|
142
|
+
fillIV(iv, ivView, ivPrefix, frameCounter);
|
|
143
|
+
const aad = clearBytes > 0 ? src.subarray(0, clearBytes) : EMPTY_AAD;
|
|
144
|
+
const decrypted = await crypto.subtle.decrypt(
|
|
145
|
+
{ name: 'AES-GCM', iv, additionalData: aad as BufferSource },
|
|
146
|
+
key,
|
|
147
|
+
ciphertext as BufferSource,
|
|
148
|
+
);
|
|
149
|
+
if (clearBytes === 0) return decrypted;
|
|
150
|
+
const plaintext = new Uint8Array(decrypted);
|
|
151
|
+
const dst = new Uint8Array(clearBytes + plaintext.length);
|
|
152
|
+
dst.set(src.subarray(0, clearBytes), 0);
|
|
153
|
+
dst.set(plaintext, clearBytes);
|
|
154
|
+
return dst.buffer;
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2EE Web Worker entry point. Owns the worker's message interface and wires
|
|
3
|
+
* the encrypt/decrypt transforms into WebRTC Encoded Transforms. The main
|
|
4
|
+
* thread distributes keys by postMessage, and transforms look one up per frame
|
|
5
|
+
* by (userId, keyIndex).
|
|
6
|
+
*
|
|
7
|
+
* ## Wire format
|
|
8
|
+
*
|
|
9
|
+
* Frame layout is [clear header][ciphertext + GCM tag][20B trailer], so 36
|
|
10
|
+
* bytes of overhead. The trailer holds:
|
|
11
|
+
* [4B frameCounter][8B ivPrefix][1B keyIndex][2B clearBytes|flags]
|
|
12
|
+
* [1B version][4B 0xE2EEFEED]
|
|
13
|
+
*
|
|
14
|
+
* The clear header keeps codec headers readable so the SFU can detect keyframes
|
|
15
|
+
* and select layers: 1 byte for Opus, 10/3 for VP8 and VP9 (key/delta), and for
|
|
16
|
+
* H264 everything up to the first slice NALU + 2, with the encrypted tail
|
|
17
|
+
* RBSP-escaped against fake start codes. It doubles as the AAD, so the SFU can
|
|
18
|
+
* read it but decrypt still detects tampering.
|
|
19
|
+
*
|
|
20
|
+
* The 12-byte IV is [ivPrefix][frameCounter]. `ivPrefix` is random per key
|
|
21
|
+
* import and travels in the trailer, so IVs stay unique even when the host
|
|
22
|
+
* imports the same raw key twice.
|
|
23
|
+
*
|
|
24
|
+
* rollup-plugin-inline-worker bundles this into the function
|
|
25
|
+
* `../e2ee-worker.ts` exports.
|
|
26
|
+
*
|
|
27
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Using_Encoded_Transforms
|
|
28
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/encrypt#aes-gcm
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { isSupportedCodec } from './codec';
|
|
32
|
+
import { enqueue } from './queue';
|
|
33
|
+
import { keyStore } from './keyStore';
|
|
34
|
+
import { decodeStats, startPerfReport, stopPerfReport } from './perf';
|
|
35
|
+
import { EncodeNotifier, reportError } from './notifications';
|
|
36
|
+
import { encodeTransform } from './encode';
|
|
37
|
+
import { decodeTransform } from './decode';
|
|
38
|
+
import type { EncodedFrame } from './types';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Decode always runs. An encode whose codec the worker cannot split fails
|
|
42
|
+
* closed: it still installs a transform, but one that drops every frame, since
|
|
43
|
+
* returning without one leaves the encoder buffering forever with no signal.
|
|
44
|
+
*/
|
|
45
|
+
const selectTransform = (
|
|
46
|
+
operation: string,
|
|
47
|
+
userId: string,
|
|
48
|
+
codec: string | undefined,
|
|
49
|
+
trackType: string | undefined,
|
|
50
|
+
): TransformStream<EncodedFrame, EncodedFrame> => {
|
|
51
|
+
if (operation !== 'encode') return decodeTransform(userId, trackType);
|
|
52
|
+
if (isSupportedCodec(codec)) return encodeTransform(userId, codec, trackType);
|
|
53
|
+
new EncodeNotifier(userId, trackType).failed(
|
|
54
|
+
`unsupported codec for E2EE: ${codec}`,
|
|
55
|
+
);
|
|
56
|
+
// Enqueues nothing: every frame is dropped.
|
|
57
|
+
return new TransformStream<EncodedFrame, EncodedFrame>({ transform() {} });
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const setupTransform = ({
|
|
61
|
+
readable,
|
|
62
|
+
writable,
|
|
63
|
+
operation,
|
|
64
|
+
userId,
|
|
65
|
+
codec,
|
|
66
|
+
trackType,
|
|
67
|
+
}: {
|
|
68
|
+
readable: ReadableStream;
|
|
69
|
+
writable: WritableStream;
|
|
70
|
+
operation: string;
|
|
71
|
+
userId: string;
|
|
72
|
+
codec?: string;
|
|
73
|
+
trackType?: string;
|
|
74
|
+
}) => {
|
|
75
|
+
const transform = selectTransform(operation, userId, codec, trackType);
|
|
76
|
+
readable
|
|
77
|
+
.pipeThrough(transform)
|
|
78
|
+
.pipeTo(writable)
|
|
79
|
+
.catch((err: any) => {
|
|
80
|
+
reportError(
|
|
81
|
+
`Transform pipeline error (${operation}, ${userId}): ${
|
|
82
|
+
err?.message || err
|
|
83
|
+
}`,
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
addEventListener('rtctransform', (event) => {
|
|
89
|
+
const { readable, writable, options } = event.transformer;
|
|
90
|
+
// Same queue as message-based setup, so an in-flight key import completes
|
|
91
|
+
// before the transform is wired up.
|
|
92
|
+
enqueue(async () => {
|
|
93
|
+
setupTransform({ readable, writable, ...options });
|
|
94
|
+
}).catch((err: any) => {
|
|
95
|
+
reportError(`Transform setup failed: ${err?.message || err}`);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
addEventListener('message', ({ data }) => {
|
|
100
|
+
enqueue(async () => {
|
|
101
|
+
switch (data.type) {
|
|
102
|
+
case 'cmd.set_key':
|
|
103
|
+
await keyStore.importKey(data.userId, data.keyIndex, data.rawKey);
|
|
104
|
+
break;
|
|
105
|
+
case 'cmd.set_shared_key':
|
|
106
|
+
await keyStore.importSharedKey(data.keyIndex, data.rawKey);
|
|
107
|
+
break;
|
|
108
|
+
case 'cmd.remove_shared_key':
|
|
109
|
+
keyStore.removeSharedKey(data.keyIndex);
|
|
110
|
+
break;
|
|
111
|
+
case 'cmd.remove_key':
|
|
112
|
+
// Deliberately keeps this user's decode stats: they are still
|
|
113
|
+
// publishing, one epoch was retired.
|
|
114
|
+
keyStore.removeKey(data.userId, data.keyIndex);
|
|
115
|
+
break;
|
|
116
|
+
case 'cmd.remove_all_keys':
|
|
117
|
+
keyStore.removeAllKeys(data.userId);
|
|
118
|
+
decodeStats.removeUser(data.userId);
|
|
119
|
+
break;
|
|
120
|
+
case 'cmd.enable_performance_reporting':
|
|
121
|
+
if (data.enabled) startPerfReport();
|
|
122
|
+
else stopPerfReport();
|
|
123
|
+
break;
|
|
124
|
+
case 'cmd.request_key_state':
|
|
125
|
+
self.postMessage({ type: 'e2ee.key_state', ...keyStore.keyState() });
|
|
126
|
+
break;
|
|
127
|
+
case 'cmd.setup_transform':
|
|
128
|
+
setupTransform(data);
|
|
129
|
+
break;
|
|
130
|
+
default:
|
|
131
|
+
reportError(`Unknown command type: ${data.type}`);
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}).catch((err: any) => {
|
|
135
|
+
reportError(`Message handler error: ${err?.message || err}`);
|
|
136
|
+
});
|
|
137
|
+
});
|