@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.
Files changed (105) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/e2ee-worker.browser.es.js +1342 -0
  3. package/dist/e2ee-worker.browser.es.js.map +1 -0
  4. package/dist/index.browser.es.js +831 -55
  5. package/dist/index.browser.es.js.map +1 -1
  6. package/dist/index.cjs.js +875 -54
  7. package/dist/index.cjs.js.map +1 -1
  8. package/dist/index.d.ts +3 -0
  9. package/dist/index.es.js +864 -55
  10. package/dist/index.es.js.map +1 -1
  11. package/dist/src/Call.d.ts +52 -6
  12. package/dist/src/coordinator/connection/connection.d.ts +1 -1
  13. package/dist/src/devices/CameraManager.d.ts +6 -0
  14. package/dist/src/devices/DeviceManager.d.ts +1 -0
  15. package/dist/src/devices/MicrophoneManager.d.ts +6 -0
  16. package/dist/src/devices/SpeakerManager.d.ts +2 -1
  17. package/dist/src/events/participant.d.ts +10 -3
  18. package/dist/src/gen/coordinator/index.d.ts +1363 -243
  19. package/dist/src/gen/video/sfu/models/models.d.ts +8 -0
  20. package/dist/src/helpers/TypedEventEmitter.d.ts +23 -0
  21. package/dist/src/helpers/ensureExhausted.d.ts +1 -1
  22. package/dist/src/permissions/PermissionsContext.d.ts +2 -2
  23. package/dist/src/rtc/BasePeerConnection.d.ts +3 -1
  24. package/dist/src/rtc/e2ee/E2EEManager.d.ts +12 -0
  25. package/dist/src/rtc/e2ee/EncryptionManager.d.ts +148 -0
  26. package/dist/src/rtc/e2ee/e2ee-worker.d.ts +25 -0
  27. package/dist/src/rtc/e2ee/events.d.ts +163 -0
  28. package/dist/src/rtc/e2ee/transformSupport.d.ts +21 -0
  29. package/dist/src/rtc/helpers/rtcConfiguration.d.ts +2 -2
  30. package/dist/src/rtc/index.d.ts +1 -0
  31. package/dist/src/rtc/mediaEngine.d.ts +23 -0
  32. package/dist/src/rtc/types.d.ts +2 -0
  33. package/dist/src/store/CallState.d.ts +21 -0
  34. package/dist/src/types.d.ts +15 -6
  35. package/index.ts +3 -0
  36. package/package.json +1 -1
  37. package/src/Call.ts +160 -26
  38. package/src/__tests__/Call.test.ts +15 -0
  39. package/src/coordinator/connection/connection.ts +6 -3
  40. package/src/devices/AudioDeviceManager.ts +6 -0
  41. package/src/devices/CameraManager.ts +51 -4
  42. package/src/devices/DeviceManager.ts +17 -0
  43. package/src/devices/MicrophoneManager.ts +55 -5
  44. package/src/devices/ScreenShareManager.ts +3 -0
  45. package/src/devices/SpeakerManager.ts +42 -12
  46. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +1 -20
  47. package/src/devices/__tests__/SpeakerManager.test.ts +138 -42
  48. package/src/devices/__tests__/mocks.ts +36 -3
  49. package/src/events/__tests__/participant.test.ts +134 -1
  50. package/src/events/callEventHandlers.ts +9 -3
  51. package/src/events/participant.ts +42 -7
  52. package/src/gen/coordinator/index.ts +1384 -244
  53. package/src/gen/video/sfu/models/models.ts +15 -0
  54. package/src/helpers/TypedEventEmitter.ts +71 -0
  55. package/src/helpers/__tests__/TypedEventEmitter.test.ts +133 -0
  56. package/src/helpers/ensureExhausted.ts +4 -2
  57. package/src/permissions/PermissionsContext.ts +10 -5
  58. package/src/rtc/BasePeerConnection.ts +12 -1
  59. package/src/rtc/Publisher.ts +8 -0
  60. package/src/rtc/Subscriber.ts +5 -0
  61. package/src/rtc/__tests__/Publisher.test.ts +101 -0
  62. package/src/rtc/__tests__/Subscriber.test.ts +89 -0
  63. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +31 -0
  64. package/src/rtc/e2ee/E2EEManager.ts +12 -0
  65. package/src/rtc/e2ee/EncryptionManager.ts +336 -0
  66. package/src/rtc/e2ee/SPEC.md +586 -0
  67. package/src/rtc/e2ee/__tests__/E2EEManager.contract.test.ts +39 -0
  68. package/src/rtc/e2ee/__tests__/EncryptionManager.test.ts +490 -0
  69. package/src/rtc/e2ee/__tests__/codec.test.ts +247 -0
  70. package/src/rtc/e2ee/__tests__/conformance.test.ts +184 -0
  71. package/src/rtc/e2ee/__tests__/failureTracker.test.ts +45 -0
  72. package/src/rtc/e2ee/__tests__/frameCounter.test.ts +90 -0
  73. package/src/rtc/e2ee/__tests__/keyStore.test.ts +221 -0
  74. package/src/rtc/e2ee/__tests__/notifications.test.ts +166 -0
  75. package/src/rtc/e2ee/__tests__/perf.test.ts +139 -0
  76. package/src/rtc/e2ee/__tests__/queue.test.ts +61 -0
  77. package/src/rtc/e2ee/__tests__/replayWindow.test.ts +133 -0
  78. package/src/rtc/e2ee/__tests__/trailer.test.ts +143 -0
  79. package/src/rtc/e2ee/__tests__/transform-pipeline.test.ts +839 -0
  80. package/src/rtc/e2ee/__tests__/transformSupport.test.ts +65 -0
  81. package/src/rtc/e2ee/e2ee-worker/codec.ts +220 -0
  82. package/src/rtc/e2ee/e2ee-worker/constants.ts +47 -0
  83. package/src/rtc/e2ee/e2ee-worker/decode.ts +159 -0
  84. package/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts +137 -0
  85. package/src/rtc/e2ee/e2ee-worker/encode.ts +147 -0
  86. package/src/rtc/e2ee/e2ee-worker/failureTracker.ts +26 -0
  87. package/src/rtc/e2ee/e2ee-worker/frameCounter.ts +37 -0
  88. package/src/rtc/e2ee/e2ee-worker/keyStore.ts +211 -0
  89. package/src/rtc/e2ee/e2ee-worker/notifications.ts +190 -0
  90. package/src/rtc/e2ee/e2ee-worker/perf.ts +129 -0
  91. package/src/rtc/e2ee/e2ee-worker/queue.ts +12 -0
  92. package/src/rtc/e2ee/e2ee-worker/replayWindow.ts +131 -0
  93. package/src/rtc/e2ee/e2ee-worker/trailer.ts +155 -0
  94. package/src/rtc/e2ee/e2ee-worker/tsconfig.json +8 -0
  95. package/src/rtc/e2ee/e2ee-worker/types.ts +29 -0
  96. package/src/rtc/e2ee/e2ee-worker.ts +27 -0
  97. package/src/rtc/e2ee/events.ts +179 -0
  98. package/src/rtc/e2ee/transformSupport.ts +35 -0
  99. package/src/rtc/helpers/rtcConfiguration.ts +4 -2
  100. package/src/rtc/index.ts +1 -0
  101. package/src/rtc/mediaEngine.ts +44 -0
  102. package/src/rtc/types.ts +2 -0
  103. package/src/store/CallState.ts +33 -1
  104. package/src/store/__tests__/CallState.test.ts +82 -1
  105. package/src/types.ts +23 -5
@@ -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
+ });