@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.
Files changed (112) hide show
  1. package/CHANGELOG.md +23 -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 +867 -71
  5. package/dist/index.browser.es.js.map +1 -1
  6. package/dist/index.cjs.js +911 -70
  7. package/dist/index.cjs.js.map +1 -1
  8. package/dist/index.d.ts +3 -0
  9. package/dist/index.es.js +900 -71
  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/MediaPlaybackWatchdog.d.ts +4 -0
  21. package/dist/src/helpers/TypedEventEmitter.d.ts +23 -0
  22. package/dist/src/helpers/ensureExhausted.d.ts +1 -1
  23. package/dist/src/permissions/PermissionsContext.d.ts +2 -2
  24. package/dist/src/rtc/BasePeerConnection.d.ts +3 -1
  25. package/dist/src/rtc/e2ee/E2EEManager.d.ts +12 -0
  26. package/dist/src/rtc/e2ee/EncryptionManager.d.ts +148 -0
  27. package/dist/src/rtc/e2ee/e2ee-worker.d.ts +25 -0
  28. package/dist/src/rtc/e2ee/events.d.ts +163 -0
  29. package/dist/src/rtc/e2ee/transformSupport.d.ts +21 -0
  30. package/dist/src/rtc/helpers/rtcConfiguration.d.ts +2 -2
  31. package/dist/src/rtc/index.d.ts +1 -0
  32. package/dist/src/rtc/mediaEngine.d.ts +23 -0
  33. package/dist/src/rtc/types.d.ts +2 -0
  34. package/dist/src/store/CallState.d.ts +21 -0
  35. package/dist/src/types.d.ts +15 -6
  36. package/index.ts +3 -0
  37. package/package.json +11 -11
  38. package/src/Call.ts +160 -26
  39. package/src/__tests__/Call.test.ts +15 -0
  40. package/src/coordinator/connection/connection.ts +6 -3
  41. package/src/devices/AudioDeviceManager.ts +6 -0
  42. package/src/devices/CameraManager.ts +51 -4
  43. package/src/devices/DeviceManager.ts +17 -0
  44. package/src/devices/MicrophoneManager.ts +55 -5
  45. package/src/devices/ScreenShareManager.ts +3 -0
  46. package/src/devices/SpeakerManager.ts +42 -12
  47. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +2 -22
  48. package/src/devices/__tests__/SpeakerManager.test.ts +138 -42
  49. package/src/devices/__tests__/mocks.ts +36 -3
  50. package/src/devices/devicePersistence.ts +1 -2
  51. package/src/events/__tests__/participant.test.ts +134 -1
  52. package/src/events/callEventHandlers.ts +9 -3
  53. package/src/events/participant.ts +42 -7
  54. package/src/gen/coordinator/index.ts +1384 -244
  55. package/src/gen/video/sfu/models/models.ts +15 -0
  56. package/src/helpers/MediaPlaybackWatchdog.ts +35 -16
  57. package/src/helpers/TypedEventEmitter.ts +71 -0
  58. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +137 -8
  59. package/src/helpers/__tests__/TypedEventEmitter.test.ts +133 -0
  60. package/src/helpers/__tests__/ViewportTracker.test.ts +1 -2
  61. package/src/helpers/ensureExhausted.ts +4 -2
  62. package/src/permissions/PermissionsContext.ts +10 -5
  63. package/src/reporting/ClientEventReporter.ts +2 -8
  64. package/src/rtc/BasePeerConnection.ts +12 -1
  65. package/src/rtc/Publisher.ts +8 -0
  66. package/src/rtc/Subscriber.ts +5 -0
  67. package/src/rtc/__tests__/Publisher.test.ts +101 -0
  68. package/src/rtc/__tests__/Subscriber.test.ts +89 -0
  69. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +31 -0
  70. package/src/rtc/e2ee/E2EEManager.ts +12 -0
  71. package/src/rtc/e2ee/EncryptionManager.ts +336 -0
  72. package/src/rtc/e2ee/SPEC.md +586 -0
  73. package/src/rtc/e2ee/__tests__/E2EEManager.contract.test.ts +39 -0
  74. package/src/rtc/e2ee/__tests__/EncryptionManager.test.ts +490 -0
  75. package/src/rtc/e2ee/__tests__/codec.test.ts +247 -0
  76. package/src/rtc/e2ee/__tests__/conformance.test.ts +184 -0
  77. package/src/rtc/e2ee/__tests__/failureTracker.test.ts +45 -0
  78. package/src/rtc/e2ee/__tests__/frameCounter.test.ts +90 -0
  79. package/src/rtc/e2ee/__tests__/keyStore.test.ts +221 -0
  80. package/src/rtc/e2ee/__tests__/notifications.test.ts +166 -0
  81. package/src/rtc/e2ee/__tests__/perf.test.ts +139 -0
  82. package/src/rtc/e2ee/__tests__/queue.test.ts +61 -0
  83. package/src/rtc/e2ee/__tests__/replayWindow.test.ts +133 -0
  84. package/src/rtc/e2ee/__tests__/trailer.test.ts +143 -0
  85. package/src/rtc/e2ee/__tests__/transform-pipeline.test.ts +839 -0
  86. package/src/rtc/e2ee/__tests__/transformSupport.test.ts +65 -0
  87. package/src/rtc/e2ee/e2ee-worker/codec.ts +220 -0
  88. package/src/rtc/e2ee/e2ee-worker/constants.ts +47 -0
  89. package/src/rtc/e2ee/e2ee-worker/decode.ts +159 -0
  90. package/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts +137 -0
  91. package/src/rtc/e2ee/e2ee-worker/encode.ts +147 -0
  92. package/src/rtc/e2ee/e2ee-worker/failureTracker.ts +26 -0
  93. package/src/rtc/e2ee/e2ee-worker/frameCounter.ts +37 -0
  94. package/src/rtc/e2ee/e2ee-worker/keyStore.ts +211 -0
  95. package/src/rtc/e2ee/e2ee-worker/notifications.ts +190 -0
  96. package/src/rtc/e2ee/e2ee-worker/perf.ts +129 -0
  97. package/src/rtc/e2ee/e2ee-worker/queue.ts +12 -0
  98. package/src/rtc/e2ee/e2ee-worker/replayWindow.ts +131 -0
  99. package/src/rtc/e2ee/e2ee-worker/trailer.ts +155 -0
  100. package/src/rtc/e2ee/e2ee-worker/tsconfig.json +8 -0
  101. package/src/rtc/e2ee/e2ee-worker/types.ts +29 -0
  102. package/src/rtc/e2ee/e2ee-worker.ts +27 -0
  103. package/src/rtc/e2ee/events.ts +179 -0
  104. package/src/rtc/e2ee/transformSupport.ts +35 -0
  105. package/src/rtc/helpers/rtcConfiguration.ts +4 -2
  106. package/src/rtc/index.ts +1 -0
  107. package/src/rtc/mediaEngine.ts +44 -0
  108. package/src/rtc/types.ts +3 -2
  109. package/src/stats/rtc/types.ts +8 -2
  110. package/src/store/CallState.ts +34 -3
  111. package/src/store/__tests__/CallState.test.ts +82 -1
  112. package/src/types.ts +24 -9
@@ -0,0 +1,1342 @@
1
+ function e2eeWorker() {
2
+ /**
3
+ * Codec-specific framing: how many header bytes stay clear, and H.264 RBSP
4
+ * escaping so ciphertext cannot contain a fake Annex B start code.
5
+ */
6
+ const findStartCode = (data, offset) => {
7
+ for (let i = offset; i < data.length - 2; ++i) {
8
+ if (data[i] === 0 && data[i + 1] === 0) {
9
+ if (data[i + 2] === 1) return { pos: i, len: 3 };
10
+ if (data[i + 2] === 0 && i + 3 < data.length && data[i + 3] === 1) {
11
+ return { pos: i, len: 4 };
12
+ }
13
+ }
14
+ }
15
+ return null;
16
+ };
17
+ /**
18
+ * Clear bytes up to the first slice NALU (type 1 or 5) plus 2: start code,
19
+ * NALU header, one byte of slice header. Ignores frameType, to match
20
+ * {@link CodecProfile.clearBytes}.
21
+ */
22
+ const h264ClearBytes = (_frameType, data) => {
23
+ let sc = findStartCode(data, 0);
24
+ while (sc) {
25
+ const headerPos = sc.pos + sc.len;
26
+ if (headerPos >= data.length) break;
27
+ const naluType = data[headerPos] & 0x1f;
28
+ if (naluType === 1 || naluType === 5) {
29
+ const clear = sc.pos + sc.len + 2;
30
+ // A real slice header exceeds 2 bytes, so this clamp should never bite.
31
+ return clear > data.length ? data.length : clear;
32
+ }
33
+ sc = findStartCode(data, headerPos);
34
+ }
35
+ return 0;
36
+ };
37
+ /**
38
+ * Escape-state seed for the clear-header boundary: how many 0x00 bytes end the
39
+ * clear header, capped at 2 (a longer run does not change the escaper's next
40
+ * decision).
41
+ *
42
+ * The clear header itself is never escaped, but its tail and the first escaped
43
+ * bytes form one contiguous stream on the wire: a header ending in 0x00
44
+ * followed by ciphertext starting 0x00 0x01 is a fake start code the
45
+ * packetizer would split on. Seeding the escaper as if the header's zeros were
46
+ * already scanned closes that gap. Encode and decode both derive the seed from
47
+ * the same clear bytes, so nothing extra travels in the frame.
48
+ */
49
+ const boundarySeedZeros = (clearHeader) => {
50
+ const n = clearHeader.length;
51
+ let zeros = 0;
52
+ while (zeros < 2 && zeros < n && clearHeader[n - 1 - zeros] === 0) zeros++;
53
+ return zeros;
54
+ };
55
+ /**
56
+ * Escaped length of `segments`, read as one contiguous stream so a 0x00 0x00
57
+ * run spanning a boundary counts. Sizes the buffer for {@link rbspEscapeInto}.
58
+ *
59
+ * @param seedZeros zero-run state entering the stream; see
60
+ * {@link boundarySeedZeros}.
61
+ */
62
+ const rbspEscapedLength = (segments, seedZeros) => {
63
+ let total = 0;
64
+ let zeros = seedZeros;
65
+ for (const seg of segments) {
66
+ total += seg.length;
67
+ for (let i = 0; i < seg.length; ++i) {
68
+ const byte = seg[i];
69
+ if (zeros >= 2 && byte <= 3) {
70
+ total++;
71
+ zeros = 0;
72
+ }
73
+ zeros = byte === 0 ? zeros + 1 : 0;
74
+ }
75
+ }
76
+ return total;
77
+ };
78
+ /**
79
+ * Escape `segments` into `dst` at `offset`, inserting 0x03 after each 0x00 0x00
80
+ * run followed by 0x00-0x03. `dst` needs {@link rbspEscapedLength} bytes free,
81
+ * computed with the same `seedZeros`.
82
+ *
83
+ * Sizing separately lets the encoder escape straight behind the clear header,
84
+ * copying the ciphertext once instead of twice.
85
+ */
86
+ const rbspEscapeInto = (dst, offset, segments, seedZeros) => {
87
+ let j = offset;
88
+ let zeros = seedZeros;
89
+ for (const seg of segments) {
90
+ for (let i = 0; i < seg.length; ++i) {
91
+ const byte = seg[i];
92
+ if (zeros >= 2 && byte <= 3) {
93
+ dst[j++] = 3;
94
+ zeros = 0;
95
+ }
96
+ dst[j++] = byte;
97
+ zeros = byte === 0 ? zeros + 1 : 0;
98
+ }
99
+ }
100
+ };
101
+ /**
102
+ * Reverse of {@link rbspEscapeInto}: drop each 0x03 the escaper inserted,
103
+ * tracked with the same zero-run state so an escape byte right at the
104
+ * clear-header boundary (possible only with a non-zero `seedZeros`) is
105
+ * recognized too.
106
+ */
107
+ const rbspUnescape = (data, seedZeros) => {
108
+ const isEscapeByte = (i, zeros) =>
109
+ zeros >= 2 && data[i] === 3 && i + 1 < data.length && data[i + 1] <= 3;
110
+ let remove = 0;
111
+ let zeros = seedZeros;
112
+ for (let i = 0; i < data.length; ++i) {
113
+ if (isEscapeByte(i, zeros)) {
114
+ remove++;
115
+ zeros = 0;
116
+ continue;
117
+ }
118
+ zeros = data[i] === 0 ? zeros + 1 : 0;
119
+ }
120
+ if (remove === 0) return data;
121
+ const result = new Uint8Array(data.length - remove);
122
+ let j = 0;
123
+ zeros = seedZeros;
124
+ for (let i = 0; i < data.length; ++i) {
125
+ if (isEscapeByte(i, zeros)) {
126
+ zeros = 0;
127
+ continue;
128
+ }
129
+ result[j++] = data[i];
130
+ zeros = data[i] === 0 ? zeros + 1 : 0;
131
+ }
132
+ return result;
133
+ };
134
+ // Audio has no keyframes, so the absence of a frame type identifies it. Keep
135
+ // the Opus TOC byte clear. Only reached for audio: `audioOnly` rejects a video
136
+ // frame before this runs.
137
+ const defaultClearBytes = (frameType) => (frameType === undefined ? 1 : 0);
138
+ // Clamped to the frame length. A short frame claiming more would make encode
139
+ // zero-pad the header and decode build an AAD of a different length.
140
+ const vpClearBytes = (frameType, data) => {
141
+ const clear = frameType === 'key' ? 10 : 3;
142
+ return clear > data.length ? data.length : clear;
143
+ };
144
+ // AV1 is absent on purpose: the AV1 RTP packetizer parses the OBU stream, so a
145
+ // frame trailer does not survive. It needs its own scheme. Without an entry it
146
+ // falls through to isSupportedCodec and fails closed.
147
+ const CODEC_PROFILES = {
148
+ opus: { rbsp: false, audioOnly: true, clearBytes: defaultClearBytes },
149
+ vp8: { rbsp: false, audioOnly: false, clearBytes: vpClearBytes },
150
+ vp9: { rbsp: false, audioOnly: false, clearBytes: vpClearBytes },
151
+ h264: { rbsp: true, audioOnly: false, clearBytes: h264ClearBytes },
152
+ };
153
+ // Used when the caller names no codec. Audio is safe to frame unlabeled, since
154
+ // the TOC byte rule holds for any audio codec. Video is not: the right clear
155
+ // header and whether to escape both depend on the codec, so it fails closed.
156
+ const DEFAULT_PROFILE = {
157
+ rbsp: false,
158
+ audioOnly: true,
159
+ clearBytes: defaultClearBytes,
160
+ };
161
+ // Object.hasOwn, not `in`: `in` walks the prototype chain, so a codec named
162
+ // like an Object.prototype member ('toString') would resolve to a function
163
+ // instead of a profile.
164
+ const getCodecProfile = (codec) =>
165
+ codec !== undefined && Object.hasOwn(CODEC_PROFILES, codec)
166
+ ? CODEC_PROFILES[codec]
167
+ : DEFAULT_PROFILE;
168
+ const isSupportedCodec = (codec) =>
169
+ codec === undefined || Object.hasOwn(CODEC_PROFILES, codec);
170
+
171
+ let tail = Promise.resolve();
172
+ /**
173
+ * Run tasks FIFO, one at a time, so a `setKey` cannot race transform setup.
174
+ * `tail` swallows errors so one rejection cannot stall the queue; the returned
175
+ * promise still carries that task's own outcome.
176
+ */
177
+ const enqueue = (fn) => {
178
+ const run = tail.then(fn);
179
+ tail = run.catch(() => {});
180
+ return run;
181
+ };
182
+
183
+ /** Marks an encrypted frame's trailer. */
184
+ const MAGIC = 0xe2eefeed;
185
+ /**
186
+ * Wire format version. Bump it when the trailer layout or IV derivation change.
187
+ *
188
+ * v1: [4B frameCounter][8B ivPrefix][1B keyIndex][2B clearBytes|flags]
189
+ * [1B version][4B magic]. IV = ivPrefix ∥ frameCounter.
190
+ */
191
+ const E2EE_VERSION = 1;
192
+ const IV_PREFIX_LEN = 8;
193
+ const FRAME_COUNTER_LEN = 4;
194
+ const IV_LEN = IV_PREFIX_LEN + FRAME_COUNTER_LEN;
195
+ const KEY_INDEX_LEN = 1;
196
+ const CLEAR_BYTES_LEN = 2;
197
+ const VERSION_LEN = 1;
198
+ const MAGIC_LEN = 4;
199
+ /** 4 + 8 + 1 + 2 + 1 + 4 */
200
+ const TRAILER_LEN =
201
+ FRAME_COUNTER_LEN +
202
+ IV_PREFIX_LEN +
203
+ KEY_INDEX_LEN +
204
+ CLEAR_BYTES_LEN +
205
+ VERSION_LEN +
206
+ MAGIC_LEN;
207
+ /** Bit 15 of the clearBytes field. Signals RBSP escaping. */
208
+ const RBSP_FLAG = 0x8000;
209
+ /** Bit 15 belongs to RBSP_FLAG, so clearBytes gets 15 bits. */
210
+ const MAX_CLEAR_BYTES = 0x7fff;
211
+ const EMPTY_AAD = new Uint8Array(0);
212
+ /** Consecutive decrypt failures on one track before `e2ee.decryption_stalled` fires. */
213
+ const FAILURE_TOLERANCE = 10;
214
+ /** Replay window in frames. A counter <= highestSeen - this is rejected. */
215
+ const REPLAY_WINDOW = 1024;
216
+ /**
217
+ * One more than this wraps into an (ivPrefix, counter) pair the sender already
218
+ * used, which is IV reuse under AES-GCM. Encoding throws instead.
219
+ */
220
+ const COUNTER_HARD_LIMIT = 0xffffffff; // 2^32 - 1
221
+
222
+ /** At most one notification per second per key. */
223
+ const THROTTLE_INTERVAL_MS = 1000;
224
+ /**
225
+ * Rate limiter keyed by an arbitrary string, so one throttle can cover several
226
+ * independent conditions (a keyIndex, a userId) without them muting each other.
227
+ *
228
+ * Lives here because throttling is a delivery rule, not a general utility -
229
+ * every user of it is in this file.
230
+ */
231
+ class Throttle {
232
+ intervalMs;
233
+ lastFiredAt = new Map();
234
+ constructor(intervalMs) {
235
+ this.intervalMs = intervalMs;
236
+ }
237
+ /**
238
+ * True at most once per `intervalMs` for that key, so a sustained failure
239
+ * cannot flood the host with notifications.
240
+ */
241
+ tryFire = (key) => {
242
+ const now = Date.now();
243
+ if (now - (this.lastFiredAt.get(key) ?? 0) > this.intervalMs) {
244
+ this.lastFiredAt.set(key, now);
245
+ return true;
246
+ }
247
+ return false;
248
+ };
249
+ }
250
+ /**
251
+ * Internal log-only channel: no `E2EEEventMap` entry, so the manager logs it
252
+ * rather than emitting it to the host.
253
+ */
254
+ const reportError = (message) => {
255
+ self.postMessage({ type: 'e2ee.error', message });
256
+ };
257
+ /**
258
+ * The encoder holds no key, so every outgoing frame is dropped. Without this
259
+ * the host just sees black video with nothing to act on. Throttled per user,
260
+ * and stops on its own once a key arrives.
261
+ *
262
+ * Module-scoped rather than per transform: this condition is per user by
263
+ * definition, so every track sharing one throttle is the point.
264
+ */
265
+ const missingKeyThrottle = new Throttle(THROTTLE_INTERVAL_MS);
266
+ const notifyMissingEncodeKey = (userId) => {
267
+ if (missingKeyThrottle.tryFire(userId)) {
268
+ self.postMessage({ type: 'e2ee.missing_key', userId });
269
+ }
270
+ };
271
+ /** Encode-side signals for one track. */
272
+ class EncodeNotifier {
273
+ userId;
274
+ trackType;
275
+ /** True once a failure was reported, until {@link recovered} re-arms it. */
276
+ latched = false;
277
+ constructor(userId, trackType) {
278
+ this.userId = userId;
279
+ this.trackType = trackType;
280
+ }
281
+ /** First failure of a run; silent until {@link recovered} re-arms it. */
282
+ failed = (reason) => {
283
+ if (this.latched) return;
284
+ this.latched = true;
285
+ self.postMessage({
286
+ type: 'e2ee.encryption_failed',
287
+ userId: this.userId,
288
+ trackType: this.trackType,
289
+ reason,
290
+ });
291
+ };
292
+ /**
293
+ * A frame encrypted again, so the next failure is worth reporting. Re-arming
294
+ * is deliberate: it stops one early transient error from hiding a later
295
+ * permanent one, such as the frame-counter hard limit.
296
+ */
297
+ recovered = () => {
298
+ this.latched = false;
299
+ };
300
+ }
301
+ /**
302
+ * Decode-side signals for one track.
303
+ *
304
+ * One notifier is one track, so `userId` is constant: each throttle holds a
305
+ * single entry and limits that track alone.
306
+ */
307
+ class DecodeNotifier {
308
+ userId;
309
+ trackType;
310
+ failureThrottle = new Throttle(THROTTLE_INTERVAL_MS);
311
+ /**
312
+ * A key in flight, or a rotation whose keyIndex has not arrived, are both
313
+ * normal. Keyed by keyIndex: one signal per key epoch.
314
+ */
315
+ missingKeyThrottle = new Throttle(THROTTLE_INTERVAL_MS);
316
+ cleartextThrottle = new Throttle(THROTTLE_INTERVAL_MS);
317
+ /**
318
+ * Keyed by user, deliberately not by version. A sender's version is a
319
+ * compile-time constant, so a track only ever sees one value legitimately -
320
+ * while the byte is plaintext, so a relay rewriting it per frame would
321
+ * otherwise get one event per distinct value and multiply the rate by 255.
322
+ */
323
+ versionThrottle = new Throttle(THROTTLE_INTERVAL_MS);
324
+ /**
325
+ * True once a `decryption_failed` reached the host. Pairs the two signals:
326
+ * only a delivered failure needs clearing, and clearing it re-arms this.
327
+ */
328
+ failureReported = false;
329
+ constructor(userId, trackType) {
330
+ this.userId = userId;
331
+ this.trackType = trackType;
332
+ }
333
+ /** GCM tag failure. Throttled; records that a failure reached the host. */
334
+ failed = () => {
335
+ if (!this.failureThrottle.tryFire(this.userId)) return;
336
+ this.failureReported = true;
337
+ self.postMessage({
338
+ type: 'e2ee.decryption_failed',
339
+ userId: this.userId,
340
+ trackType: this.trackType,
341
+ });
342
+ };
343
+ /** Paired with {@link failed}: no-op unless a failure was delivered. */
344
+ resumed = () => {
345
+ if (!this.failureReported) return;
346
+ this.failureReported = false;
347
+ self.postMessage({
348
+ type: 'e2ee.decryption_resumed',
349
+ userId: this.userId,
350
+ trackType: this.trackType,
351
+ });
352
+ };
353
+ /** A frame named a key this peer does not hold. Throttled per keyIndex. */
354
+ missingKey = (keyIndex) => {
355
+ if (!this.missingKeyThrottle.tryFire(String(keyIndex))) return;
356
+ self.postMessage({
357
+ type: 'e2ee.missing_key',
358
+ userId: this.userId,
359
+ trackType: this.trackType,
360
+ keyIndex,
361
+ });
362
+ };
363
+ /**
364
+ * A frame carried this format but a version this build cannot decrypt, so it
365
+ * was dropped. Throttled per track.
366
+ */
367
+ unsupportedVersion = (version) => {
368
+ if (!this.versionThrottle.tryFire(this.userId)) return;
369
+ self.postMessage({
370
+ type: 'e2ee.unsupported_version',
371
+ userId: this.userId,
372
+ trackType: this.trackType,
373
+ version,
374
+ });
375
+ };
376
+ /** A frame carried no E2EE framing and was forwarded as-is. Throttled. */
377
+ unencrypted = () => {
378
+ if (!this.cleartextThrottle.tryFire(this.userId)) return;
379
+ self.postMessage({
380
+ type: 'e2ee.unencrypted_frame',
381
+ userId: this.userId,
382
+ trackType: this.trackType,
383
+ });
384
+ };
385
+ /** Consecutive failures crossed the tolerance. Already once-per-run. */
386
+ stalled = (keyIndex) => {
387
+ self.postMessage({
388
+ type: 'e2ee.decryption_stalled',
389
+ userId: this.userId,
390
+ trackType: this.trackType,
391
+ keyIndex,
392
+ });
393
+ };
394
+ }
395
+
396
+ const getOrCreate = (map, userId) => {
397
+ let inner = map.get(userId);
398
+ if (!inner) {
399
+ inner = new Map();
400
+ map.set(userId, inner);
401
+ }
402
+ return inner;
403
+ };
404
+ const randomBytes = (n) => {
405
+ const bytes = new Uint8Array(n);
406
+ crypto.getRandomValues(bytes);
407
+ return bytes;
408
+ };
409
+ const fingerprint = async (rawKey) => {
410
+ const hash = await crypto.subtle.digest('SHA-256', rawKey);
411
+ return new Uint8Array(hash, 0, 8);
412
+ };
413
+ /**
414
+ * The buffer length picks the variant; EncryptionManager already validated it
415
+ * as 16 or 32 bytes. Passing `length` keeps WebCrypto unambiguous across
416
+ * browsers.
417
+ */
418
+ const aesGcmParams = (rawKey) => ({
419
+ name: 'AES-GCM',
420
+ length: rawKey.byteLength * 8,
421
+ });
422
+ /**
423
+ * Import into a non-extractable CryptoKey, with a fresh random IV prefix and
424
+ * the fingerprint. The fresh prefix is what lets the same raw key be imported
425
+ * again without IV reuse.
426
+ */
427
+ const importKeyMaterial = async (rawKey) => {
428
+ const [key, fp] = await Promise.all([
429
+ crypto.subtle.importKey('raw', rawKey, aesGcmParams(rawKey), false, [
430
+ 'encrypt',
431
+ 'decrypt',
432
+ ]),
433
+ fingerprint(rawKey),
434
+ ]);
435
+ return { key, ivPrefix: randomBytes(IV_PREFIX_LEN), fingerprint: fp };
436
+ };
437
+ const toHex = (bytes) =>
438
+ Array.from(bytes)
439
+ .map((b) => b.toString(16).padStart(2, '0'))
440
+ .join('');
441
+ class KeyStore {
442
+ perUserKeys = new Map();
443
+ latestKeyIndex = new Map();
444
+ /** Shared receive keys retained by epoch so in-flight frames survive rotation. */
445
+ sharedKeys = new Map();
446
+ /** The shared epoch used for encode fallback. Older retained epochs never win. */
447
+ activeSharedKeyIndex;
448
+ /**
449
+ * Decryption key for (userId, keyIndex): per-user entry, else the shared key
450
+ * at that index. The keyIndex comes from the frame trailer.
451
+ */
452
+ getKey = (userId, keyIndex) => {
453
+ const perUser = this.perUserKeys.get(userId)?.get(keyIndex);
454
+ if (perUser) return perUser.key;
455
+ return this.sharedKeys.get(keyIndex)?.key;
456
+ };
457
+ /**
458
+ * The encode path's only lookup: latest per-user key, else the explicitly
459
+ * active shared key. Retained shared epochs are receive-only. The `ivPrefix`
460
+ * rides along so the encoder never resolves the same material twice.
461
+ */
462
+ getLatestKey = (userId) => {
463
+ const idx = this.latestKeyIndex.get(userId);
464
+ if (idx !== undefined) {
465
+ const km = this.perUserKeys.get(userId)?.get(idx);
466
+ if (km) return { key: km.key, keyIndex: idx, ivPrefix: km.ivPrefix };
467
+ }
468
+ if (this.activeSharedKeyIndex === undefined) return null;
469
+ const shared = this.sharedKeys.get(this.activeSharedKeyIndex);
470
+ return shared
471
+ ? {
472
+ key: shared.key,
473
+ keyIndex: this.activeSharedKeyIndex,
474
+ ivPrefix: shared.ivPrefix,
475
+ }
476
+ : null;
477
+ };
478
+ importKey = async (userId, keyIndex, rawKey) => {
479
+ try {
480
+ getOrCreate(this.perUserKeys, userId).set(
481
+ keyIndex,
482
+ await importKeyMaterial(rawKey),
483
+ );
484
+ this.latestKeyIndex.set(userId, keyIndex);
485
+ } catch (e) {
486
+ reportError(
487
+ `Failed to import key for user ${userId}: ${e?.message || e}`,
488
+ );
489
+ }
490
+ };
491
+ importSharedKey = async (keyIndex, rawKey) => {
492
+ try {
493
+ const material = await importKeyMaterial(rawKey);
494
+ this.sharedKeys.set(keyIndex, material);
495
+ this.activeSharedKeyIndex = keyIndex;
496
+ } catch (e) {
497
+ reportError(`Failed to import shared key: ${e?.message || e}`);
498
+ }
499
+ };
500
+ /**
501
+ * Retire exactly one of a user's epochs, leaving the rest usable.
502
+ *
503
+ * Clearing `latestKeyIndex` along with the slot is load-bearing. Left on a
504
+ * removed index, {@link getLatestKey} resolves nothing there and falls
505
+ * through to the active shared epoch, so the local user's frames would
506
+ * quietly move from a key only they hold to one every participant holds. No
507
+ * older epoch is promoted either, matching {@link removeSharedKey}.
508
+ */
509
+ removeKey = (userId, keyIndex) => {
510
+ const perKeyIndex = this.perUserKeys.get(userId);
511
+ if (!perKeyIndex?.delete(keyIndex)) return;
512
+ if (perKeyIndex.size === 0) this.perUserKeys.delete(userId);
513
+ if (this.latestKeyIndex.get(userId) === keyIndex) {
514
+ this.latestKeyIndex.delete(userId);
515
+ }
516
+ };
517
+ /**
518
+ * Leaves the frame counters intact on purpose: a reset counter would reuse
519
+ * IVs if the same raw key is imported again later. They live in
520
+ * `frameCounter.ts`, so this cannot reach them even by accident.
521
+ */
522
+ removeAllKeys = (userId) => {
523
+ this.perUserKeys.delete(userId);
524
+ this.latestKeyIndex.delete(userId);
525
+ };
526
+ /**
527
+ * Removes exactly one shared receive epoch. Removing the active epoch also
528
+ * disables shared-key encode fallback; an older epoch is never reactivated
529
+ * implicitly because doing so could resume sending with a retired key.
530
+ */
531
+ removeSharedKey = (keyIndex) => {
532
+ this.sharedKeys.delete(keyIndex);
533
+ if (this.activeSharedKeyIndex === keyIndex) {
534
+ this.activeSharedKeyIndex = undefined;
535
+ }
536
+ };
537
+ /**
538
+ * Debug snapshot. Fingerprints only: enough to confirm a sender and receiver
539
+ * hold matching key material, and it exposes no key.
540
+ */
541
+ keyState = () => ({
542
+ perUserKeys: Array.from(this.perUserKeys).flatMap(
543
+ ([userId, perKeyIndex]) =>
544
+ Array.from(perKeyIndex, ([keyIndex, km]) => ({
545
+ userId,
546
+ keyIndex,
547
+ fingerprint: toHex(km.fingerprint),
548
+ })),
549
+ ),
550
+ sharedKeys: Array.from(this.sharedKeys, ([keyIndex, material]) => ({
551
+ keyIndex,
552
+ fingerprint: toHex(material.fingerprint),
553
+ isActive: keyIndex === this.activeSharedKeyIndex,
554
+ })),
555
+ });
556
+ /**
557
+ * @internal Test-only. Production teardown is `Worker.terminate()`, which
558
+ * reclaims the whole worker.
559
+ */
560
+ clear = () => {
561
+ this.perUserKeys.clear();
562
+ this.latestKeyIndex.clear();
563
+ this.sharedKeys.clear();
564
+ this.activeSharedKeyIndex = undefined;
565
+ };
566
+ }
567
+ /** The worker has exactly one. */
568
+ const keyStore = new KeyStore();
569
+
570
+ /**
571
+ * Whether reporting is on. Module-scoped rather than per registry: the worker
572
+ * has exactly one of each, and a single flag keeps encode and decode from
573
+ * drifting apart.
574
+ */
575
+ let perfEnabled = false;
576
+ let perfInterval = null;
577
+ let perfLastTick = 0;
578
+ /**
579
+ * Counter handle for one track, held by that track's transform for its
580
+ * lifetime. Every method no-ops while reporting is off.
581
+ */
582
+ class TrackStats {
583
+ stats;
584
+ key;
585
+ labels;
586
+ constructor(stats, key, labels) {
587
+ this.stats = stats;
588
+ this.key = key;
589
+ this.labels = labels;
590
+ }
591
+ /**
592
+ * This track's accumulator, created on first use. Lazy on purpose: the entry
593
+ * is what puts a row in the next report, so a track that goes idle after a
594
+ * flush drops out instead of reporting 0 fps forever.
595
+ */
596
+ entry = () => {
597
+ if (!perfEnabled) return undefined;
598
+ let stat = this.stats.get(this.key);
599
+ if (!stat) {
600
+ stat = { ...this.labels, count: 0, maxCryptoMs: 0 };
601
+ this.stats.set(this.key, stat);
602
+ }
603
+ return stat;
604
+ };
605
+ /** Count one frame through the transform. */
606
+ bump = () => {
607
+ const stat = this.entry();
608
+ if (stat) stat.count++;
609
+ };
610
+ /** Timestamp to hand back to {@link endCrypto}; 0 while disabled. */
611
+ startCrypto = () => (perfEnabled ? performance.now() : 0);
612
+ endCrypto = (startedAt) => {
613
+ const stat = this.entry();
614
+ if (!stat) return;
615
+ stat.maxCryptoMs = Math.max(
616
+ stat.maxCryptoMs,
617
+ performance.now() - startedAt,
618
+ );
619
+ };
620
+ }
621
+ /**
622
+ * Per-track counters for one direction. The key is unique per track, so a vp8
623
+ * camera and a vp8 screen share (encode), or a peer's audio and video (decode),
624
+ * are reported apart instead of summed.
625
+ */
626
+ class StatsRegistry {
627
+ stats = new Map();
628
+ track = (key, labels) => new TrackStats(this.stats, key, labels);
629
+ /** Drain all accumulators into per-track rates. */
630
+ flush = (dtSec) => {
631
+ const samples = Array.from(
632
+ this.stats.values(),
633
+ ({ count, maxCryptoMs, ...labels }) => ({
634
+ ...labels,
635
+ fps: count / dtSec,
636
+ maxCryptoMs,
637
+ }),
638
+ );
639
+ this.stats.clear();
640
+ return samples;
641
+ };
642
+ clear = () => this.stats.clear();
643
+ removeUser = (userId) => {
644
+ for (const [key, stat] of this.stats) {
645
+ if (stat.userId === userId) this.stats.delete(key);
646
+ }
647
+ };
648
+ }
649
+ const encodeStats = new StatsRegistry();
650
+ const decodeStats = new StatsRegistry();
651
+ const startPerfReport = () => {
652
+ if (perfInterval) return; // a second interval would leak
653
+ perfEnabled = true;
654
+ perfLastTick = performance.now();
655
+ perfInterval = setInterval(() => {
656
+ const now = performance.now();
657
+ const dtSec = Math.max(0.001, (now - perfLastTick) / 1000);
658
+ perfLastTick = now;
659
+ self.postMessage({
660
+ type: 'e2ee.perf_report',
661
+ encode: encodeStats.flush(dtSec),
662
+ decode: decodeStats.flush(dtSec),
663
+ });
664
+ }, 1000);
665
+ };
666
+ const stopPerfReport = () => {
667
+ perfEnabled = false;
668
+ if (perfInterval) {
669
+ clearInterval(perfInterval);
670
+ perfInterval = null;
671
+ }
672
+ encodeStats.clear();
673
+ decodeStats.clear();
674
+ };
675
+
676
+ /**
677
+ * The on-wire framing: the 20-byte trailer appended to every encrypted frame,
678
+ * and the 12-byte IV both directions derive.
679
+ *
680
+ * Write and read live together on purpose. They are one contract - the encoder
681
+ * lays out bytes the decoder must find at the same offsets - so the layout is
682
+ * defined once, here, and a change to it is a wire break: bump
683
+ * {@link E2EE_VERSION} and regenerate the SPEC section 11 vectors.
684
+ *
685
+ * Layout, from the trailer's first byte:
686
+ * [4B frameCounter][8B ivPrefix][1B keyIndex][2B clearBytes|flags]
687
+ * [1B version][4B magic]
688
+ */
689
+ const OFF_IV_PREFIX = FRAME_COUNTER_LEN; // 4
690
+ const OFF_KEY_INDEX = OFF_IV_PREFIX + IV_PREFIX_LEN; // 12
691
+ const OFF_CLEAR_BYTES = OFF_KEY_INDEX + 1; // 13
692
+ const OFF_VERSION = OFF_CLEAR_BYTES + 2; // 15
693
+ const OFF_MAGIC = OFF_VERSION + 1; // 16
694
+ /**
695
+ * Fill a pre-allocated 12-byte IV: `[8B ivPrefix][4B frameCounter, BE]`.
696
+ *
697
+ * The encoder builds it from its own prefix and counter; the decoder rebuilds
698
+ * the identical bytes from the trailer. The buffer is reused across frames
699
+ * rather than allocated per frame - this runs once per frame on every track.
700
+ */
701
+ const fillIV = (iv, ivView, prefix, frameCounter) => {
702
+ iv.set(prefix, 0);
703
+ ivView.setUint32(IV_PREFIX_LEN, frameCounter);
704
+ };
705
+ const writeTrailer = (
706
+ dst,
707
+ offset,
708
+ frameCounter,
709
+ ivPrefix,
710
+ keyIndex,
711
+ clearBytes,
712
+ isRbsp,
713
+ ) => {
714
+ if (clearBytes > MAX_CLEAR_BYTES) {
715
+ throw new Error(
716
+ `clearBytes ${clearBytes} exceeds 15-bit max ${MAX_CLEAR_BYTES}`,
717
+ );
718
+ }
719
+ if (ivPrefix.length !== IV_PREFIX_LEN) {
720
+ throw new Error(
721
+ `ivPrefix must be ${IV_PREFIX_LEN} bytes, got ${ivPrefix.length}`,
722
+ );
723
+ }
724
+ const view = new DataView(dst.buffer, dst.byteOffset, dst.byteLength);
725
+ view.setUint32(offset, frameCounter);
726
+ dst.set(ivPrefix, offset + OFF_IV_PREFIX);
727
+ dst[offset + OFF_KEY_INDEX] = keyIndex;
728
+ view.setUint16(
729
+ offset + OFF_CLEAR_BYTES,
730
+ isRbsp ? clearBytes | RBSP_FLAG : clearBytes,
731
+ );
732
+ dst[offset + OFF_VERSION] = E2EE_VERSION;
733
+ view.setUint32(offset + OFF_MAGIC, MAGIC);
734
+ };
735
+ /**
736
+ * IV fields only, from an already-recognized trailer. An H264 RBSP frame
737
+ * escapes these three with the ciphertext, so un-escape the unit before
738
+ * calling this.
739
+ */
740
+ const readTrailerIv = (buf) => {
741
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
742
+ const start = buf.length - TRAILER_LEN;
743
+ return {
744
+ frameCounter: view.getUint32(start),
745
+ ivPrefix: buf.subarray(start + OFF_IV_PREFIX, start + OFF_KEY_INDEX),
746
+ keyIndex: buf[start + OFF_KEY_INDEX],
747
+ };
748
+ };
749
+ /**
750
+ * The frozen identification suffix: `version (1) || magic (4)`. A literal, not
751
+ * `TRAILER_LEN - OFF_VERSION`, because this length is frozen at 5 for every
752
+ * version present and future while those two constants describe v1 only - a
753
+ * later layout change must break the round-trip test, not silently move the
754
+ * suffix older receivers depend on. `trailer.test.ts` pins it to the bytes
755
+ * `writeTrailer` actually emits.
756
+ *
757
+ * Read from the end rather than from a trailer start, because a future version
758
+ * may not use a 20-byte trailer, and a receiver that cannot decrypt such a
759
+ * frame still has to recognize it.
760
+ */
761
+ const IDENT_SUFFIX_LEN = 5;
762
+ /** Within the suffix: the magic follows the single version byte. */
763
+ const OFF_SUFFIX_MAGIC = 1;
764
+ /**
765
+ * The framing version of a frame carrying this format, or `null` if the frame
766
+ * is not ours at all.
767
+ *
768
+ * This is what separates the two reasons {@link readTrailer} returns `null`:
769
+ * an unrelated cleartext frame, versus an encrypted frame from a peer on a
770
+ * version this build cannot decrypt.
771
+ *
772
+ * @param view - An existing view over exactly `src`, to avoid a second
773
+ * allocation on the per-frame decode path. Built here when omitted.
774
+ */
775
+ const readFramingVersion = (src, view) => {
776
+ if (src.length < IDENT_SUFFIX_LEN) return null;
777
+ const bytes =
778
+ view ?? new DataView(src.buffer, src.byteOffset, src.byteLength);
779
+ const start = src.length - IDENT_SUFFIX_LEN;
780
+ if (bytes.getUint32(start + OFF_SUFFIX_MAGIC) !== MAGIC) return null;
781
+ return src[start];
782
+ };
783
+ const readTrailer = (src) => {
784
+ if (src.length < TRAILER_LEN) return null;
785
+ const view = new DataView(src.buffer, src.byteOffset, src.byteLength);
786
+ if (readFramingVersion(src, view) !== E2EE_VERSION) return null;
787
+ const start = src.length - TRAILER_LEN;
788
+ const raw = view.getUint16(start + OFF_CLEAR_BYTES);
789
+ const clearBytes = raw & MAX_CLEAR_BYTES;
790
+ // Bail out before allocating; the decrypt would fail anyway.
791
+ if (clearBytes > src.length - TRAILER_LEN) return null;
792
+ // The last 7 bytes survive escaping untouched: the RBSP flag holds the
793
+ // clearBytes high byte >= 0x80 and breaks any zero run. The three below are
794
+ // valid only on a non-RBSP frame; see {@link readTrailerIv}.
795
+ return {
796
+ frameCounter: view.getUint32(start),
797
+ ivPrefix: src.subarray(start + OFF_IV_PREFIX, start + OFF_KEY_INDEX),
798
+ keyIndex: src[start + OFF_KEY_INDEX],
799
+ clearBytes,
800
+ isRbsp: (raw & RBSP_FLAG) !== 0,
801
+ };
802
+ };
803
+
804
+ /**
805
+ * Monotonic, and deliberately shared across every track and codec this sender
806
+ * publishes: the IV is `ivPrefix ∥ counter`, so two tracks drawing from
807
+ * separate counters would encrypt different frames under the same IV.
808
+ *
809
+ * Survives `removeAllKeys` on purpose - if the same raw key is imported again the
810
+ * counter keeps climbing, so no (ivPrefix, counter) pair repeats. Second guard
811
+ * against IV reuse, after the per-import random prefix.
812
+ */
813
+ let counter = 0;
814
+ const nextFrameCounter = () => {
815
+ const next = counter + 1;
816
+ if (next > COUNTER_HARD_LIMIT) {
817
+ throw new Error(
818
+ 'frame counter exhausted, create a new EncryptionManager',
819
+ );
820
+ }
821
+ counter = next;
822
+ return next;
823
+ };
824
+
825
+ const encodeTransform = (userId, codec, trackType) => {
826
+ const profile = getCodecProfile(codec);
827
+ const isNalu = profile.rbsp;
828
+ // trackType is unique per sender; fall back to codec when it is unlabeled.
829
+ const trackKey = trackType ?? codec ?? 'unknown';
830
+ const codecKey = codec ?? 'unknown';
831
+ const stats = encodeStats.track(trackKey, {
832
+ userId,
833
+ trackType: trackKey,
834
+ codec: codecKey,
835
+ });
836
+ const notify = new EncodeNotifier(userId, trackType);
837
+ const iv = new Uint8Array(IV_LEN);
838
+ const ivView = new DataView(iv.buffer);
839
+ /**
840
+ * Times the encryption, emits the frame, reports failures. `produce` returns
841
+ * the new bytes, or null when it already dropped the frame with its own
842
+ * reason. Any throw drops the frame; it is never emitted in the clear.
843
+ */
844
+ const finishEncode = async (frame, controller, produce) => {
845
+ try {
846
+ const t0 = stats.startCrypto();
847
+ const out = await produce();
848
+ stats.endCrypto(t0);
849
+ if (!out) return;
850
+ frame.data = out.buffer;
851
+ controller.enqueue(frame);
852
+ notify.recovered();
853
+ stats.bump();
854
+ } catch (err) {
855
+ notify.failed(err?.message || String(err));
856
+ }
857
+ };
858
+ return new TransformStream({
859
+ async transform(frame, controller) {
860
+ // No payload to encrypt.
861
+ if (frame.data.byteLength === 0) {
862
+ controller.enqueue(frame);
863
+ stats.bump();
864
+ return;
865
+ }
866
+ const entry = keyStore.getLatestKey(userId);
867
+ if (!entry) {
868
+ notifyMissingEncodeKey(userId);
869
+ return;
870
+ }
871
+ const { key: cryptoKey, keyIndex, ivPrefix: prefix } = entry;
872
+ return finishEncode(frame, controller, async () => {
873
+ // A key/delta type marks a video frame. An audio-only profile has no
874
+ // clear-byte rule for one, so drop it rather than ship a whole-frame,
875
+ // unescaped encrypt the SFU cannot read and a NALU packetizer would
876
+ // split. Checked before the counter, so a dropped frame costs no IV.
877
+ if (profile.audioOnly && frame.type !== undefined) {
878
+ notify.failed(`no clear-byte rule for video on codec ${codecKey}`);
879
+ return null;
880
+ }
881
+ const src = new Uint8Array(frame.data);
882
+ const clearBytes = profile.clearBytes(frame.type, src);
883
+ if (clearBytes > MAX_CLEAR_BYTES) {
884
+ // Writing this would overflow into the RBSP flag bit.
885
+ notify.failed('clearBytes exceeds trailer capacity');
886
+ return null;
887
+ }
888
+ // Throws at the 32-bit ceiling; finishEncode catches it, so the track
889
+ // fails closed instead of reusing an IV.
890
+ const counter = nextFrameCounter();
891
+ fillIV(iv, ivView, prefix, counter);
892
+ const aad = clearBytes > 0 ? src.subarray(0, clearBytes) : EMPTY_AAD;
893
+ const plaintext = clearBytes > 0 ? src.subarray(clearBytes) : src;
894
+ const encrypted = await crypto.subtle.encrypt(
895
+ { name: 'AES-GCM', iv, additionalData: aad },
896
+ cryptoKey,
897
+ plaintext,
898
+ );
899
+ const ciphertext = new Uint8Array(encrypted);
900
+ if (isNalu && clearBytes > 0) {
901
+ // Escape ciphertext and trailer as one unit: random ciphertext or
902
+ // the counter bytes could otherwise form a fake Annex-B start code
903
+ // that libwebrtc's H264 packetizer would split on. The escaper is
904
+ // seeded with the clear header's trailing zeros so a start code
905
+ // cannot form across the clear/encrypted boundary either.
906
+ //
907
+ // The last 7 trailer bytes survive escaping untouched: the RBSP flag
908
+ // holds the clearBytes high byte at >= 0x80, which breaks any zero
909
+ // run before it can reach them. The decoder reads them off the raw
910
+ // frame tail to locate the unit.
911
+ //
912
+ // Escaping straight behind the clear header copies the ciphertext
913
+ // once, instead of staging it through an intermediate unit buffer
914
+ // and copying it again.
915
+ const trailer = new Uint8Array(TRAILER_LEN);
916
+ writeTrailer(
917
+ trailer,
918
+ 0,
919
+ counter,
920
+ prefix,
921
+ keyIndex,
922
+ clearBytes,
923
+ true,
924
+ );
925
+ const body = [ciphertext, trailer];
926
+ const seed = boundarySeedZeros(aad);
927
+ const dst = new Uint8Array(
928
+ clearBytes + rbspEscapedLength(body, seed),
929
+ );
930
+ dst.set(aad, 0);
931
+ rbspEscapeInto(dst, clearBytes, body, seed);
932
+ return dst;
933
+ }
934
+ const dst = new Uint8Array(
935
+ clearBytes + ciphertext.length + TRAILER_LEN,
936
+ );
937
+ if (clearBytes > 0) dst.set(aad, 0);
938
+ dst.set(ciphertext, clearBytes);
939
+ writeTrailer(
940
+ dst,
941
+ clearBytes + ciphertext.length,
942
+ counter,
943
+ prefix,
944
+ keyIndex,
945
+ clearBytes,
946
+ false,
947
+ );
948
+ return dst;
949
+ });
950
+ },
951
+ });
952
+ };
953
+
954
+ /**
955
+ * Consecutive decryption failures on one track, keyed by keyIndex so a rotation
956
+ * starts fresh.
957
+ */
958
+ class FailureTracker {
959
+ counts = new Map();
960
+ /**
961
+ * True only on the failure crossing {@link FAILURE_TOLERANCE}, so
962
+ * `e2ee.decryption_stalled` fires once per run.
963
+ */
964
+ recordFailure = (keyIndex) => {
965
+ const next = (this.counts.get(keyIndex) ?? 0) + 1;
966
+ this.counts.set(keyIndex, next);
967
+ return next === FAILURE_TOLERANCE + 1;
968
+ };
969
+ /**
970
+ * True if there was a count to clear. Do NOT gate `e2ee.decryption_resumed`
971
+ * on it: that event names a track, not a key epoch, so a track recovering on
972
+ * a new keyIndex must still clear it.
973
+ */
974
+ recordSuccess = (keyIndex) => this.counts.delete(keyIndex);
975
+ }
976
+
977
+ /** Length-safe byte comparison, for matching a frame's prefix to an epoch. */
978
+ const bytesEqual = (a, b) => {
979
+ if (a.length !== b.length) return false;
980
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
981
+ return true;
982
+ };
983
+ const REPLAY_WINDOW_WORDS = REPLAY_WINDOW >>> 5;
984
+ /**
985
+ * One sender IV-prefix epoch: a high-water mark plus a bitmap over the
986
+ * preceding {@link REPLAY_WINDOW} counters, where bit `counter % REPLAY_WINDOW`
987
+ * marks a seen counter. O(1) checks and in-order advance, where a `Set` would
988
+ * need an O(REPLAY_WINDOW) prune per frame.
989
+ */
990
+ class ReplayEpoch {
991
+ /** Copied, since the caller's view points into a frame buffer that is reused. */
992
+ prefix;
993
+ highest;
994
+ bitmap = new Uint32Array(REPLAY_WINDOW_WORDS);
995
+ constructor(prefix, counter) {
996
+ this.prefix = prefix.slice();
997
+ this.highest = counter;
998
+ this.mark(counter);
999
+ }
1000
+ /** Above the high-water mark, or inside the window and not yet seen. */
1001
+ accepts = (counter) => {
1002
+ if (counter > this.highest) return true;
1003
+ if (counter <= this.highest - REPLAY_WINDOW) return false;
1004
+ return !this.isMarked(counter);
1005
+ };
1006
+ record = (counter) => {
1007
+ if (counter > this.highest) {
1008
+ // Slots repeat every REPLAY_WINDOW counters, so skipped ones can hold a
1009
+ // stale bit and must be cleared. In-order frames skip none; a large jump
1010
+ // makes the whole bitmap stale.
1011
+ if (counter - this.highest >= REPLAY_WINDOW) {
1012
+ this.bitmap.fill(0);
1013
+ } else {
1014
+ for (let c = this.highest + 1; c < counter; c++) this.clear(c);
1015
+ }
1016
+ this.highest = counter;
1017
+ }
1018
+ this.mark(counter);
1019
+ };
1020
+ slot = (counter) => {
1021
+ const idx = counter % REPLAY_WINDOW;
1022
+ return { word: idx >>> 5, mask: 1 << (idx & 31) };
1023
+ };
1024
+ isMarked = (counter) => {
1025
+ const { word, mask } = this.slot(counter);
1026
+ return (this.bitmap[word] & mask) !== 0;
1027
+ };
1028
+ mark = (counter) => {
1029
+ const { word, mask } = this.slot(counter);
1030
+ this.bitmap[word] |= mask;
1031
+ };
1032
+ clear = (counter) => {
1033
+ const { word, mask } = this.slot(counter);
1034
+ this.bitmap[word] &= ~mask;
1035
+ };
1036
+ }
1037
+ /**
1038
+ * Sender IV-prefix "epochs" one track's guard keeps. One is normal; a second or
1039
+ * third appears briefly around a key re-import or sender restart, while old and
1040
+ * new prefixes interleave in the jitter buffer.
1041
+ *
1042
+ * Eviction is safe because the sender never reuses an (ivPrefix, counter) pair.
1043
+ * Only `commit` creates and evicts epochs, and only authenticated frames reach
1044
+ * it, so a relay cannot forge new-prefix frames to evict a genuine epoch.
1045
+ */
1046
+ const REPLAY_EPOCHS = 3;
1047
+ /**
1048
+ * Replay guard for one remote track.
1049
+ *
1050
+ * Shared across tracks it would couple them: independent SSRCs and jitter
1051
+ * buffers mean delivery skew could advance the high-water mark far enough to
1052
+ * reject a lagging track's frames, dropping media and reporting false failures.
1053
+ *
1054
+ * Inside a track the sender's IV prefix partitions the window further, so a
1055
+ * sender restart (fresh prefix, counter near 0) opens a clean window instead of
1056
+ * losing its low counters to a stale mark.
1057
+ *
1058
+ * Only receive-side bookkeeping is per track. The sender's counter stays global
1059
+ * per user (see `frameCounter.ts`), which is what keeps IVs unique across a
1060
+ * user's tracks and the wire format identical for other SDKs.
1061
+ */
1062
+ class ReplayWindow {
1063
+ epochs = [];
1064
+ find = (ivPrefix) =>
1065
+ this.epochs.find((e) => bytesEqual(e.prefix, ivPrefix));
1066
+ /**
1067
+ * True when this prefix can accept `counter`: new prefix, above the
1068
+ * high-water mark, or inside the window and not yet committed.
1069
+ *
1070
+ * Changes no state. A relay can forge the trailer fields this reads, so only
1071
+ * an authenticated frame advances the window. See {@link commit}.
1072
+ */
1073
+ peek = (counter, ivPrefix) => {
1074
+ // A prefix with no committed frame yet opens a clean window.
1075
+ return this.find(ivPrefix)?.accepts(counter) ?? true;
1076
+ };
1077
+ /**
1078
+ * Record `counter` as seen, advancing the high-water mark. Call it only after
1079
+ * AES-GCM authenticates, so unauthenticated bytes cannot wedge the window or
1080
+ * evict a genuine epoch.
1081
+ */
1082
+ commit = (counter, ivPrefix) => {
1083
+ const epoch = this.find(ivPrefix);
1084
+ if (epoch) {
1085
+ epoch.record(counter);
1086
+ return;
1087
+ }
1088
+ this.epochs.unshift(new ReplayEpoch(ivPrefix, counter));
1089
+ if (this.epochs.length > REPLAY_EPOCHS) this.epochs.pop();
1090
+ };
1091
+ }
1092
+
1093
+ const decodeTransform = (userId, trackType) => {
1094
+ // Counts a peer's audio and video apart.
1095
+ const trackKey = trackType ?? 'unknown';
1096
+ const stats = decodeStats.track(`${userId}/${trackKey}`, {
1097
+ userId,
1098
+ trackType: trackKey,
1099
+ });
1100
+ const notify = new DecodeNotifier(userId, trackType);
1101
+ const iv = new Uint8Array(IV_LEN);
1102
+ const ivView = new DataView(iv.buffer);
1103
+ // Per track, so a user's audio, video and screen share never share a window
1104
+ // or a failure count. The separate count is what lets
1105
+ // e2ee.decryption_stalled fire.
1106
+ const replay = new ReplayWindow();
1107
+ const failures = new FailureTracker();
1108
+ /**
1109
+ * Gates on key and replay, decrypts, emits, then records failure or recovery.
1110
+ * `decrypt` throws on a GCM tag failure, dropping the frame. Separate from
1111
+ * the framing parse so the trust ordering lives in one place.
1112
+ *
1113
+ * Trust ordering (the SFrame/SRTP rule): a relay can forge `frameCounter`,
1114
+ * `ivPrefix` and `keyIndex`, which are plaintext in the trailer, so nothing
1115
+ * changes trust state until GCM authenticates. Hence peek before, commit
1116
+ * after. The failure counter is diagnostic only - it gates `e2ee.decryption_stalled`,
1117
+ * never the decrypt attempt - so forged frames cannot mark a key invalid.
1118
+ */
1119
+ const finishDecode = async (
1120
+ frame,
1121
+ controller,
1122
+ keyIndex,
1123
+ ivPrefix,
1124
+ frameCounter,
1125
+ decrypt,
1126
+ ) => {
1127
+ const cryptoKey = keyStore.getKey(userId, keyIndex);
1128
+ if (!cryptoKey) {
1129
+ notify.missingKey(keyIndex);
1130
+ return;
1131
+ }
1132
+ // No state change. A replay or an out-of-window frame is dropped silently;
1133
+ // neither is a decryption failure.
1134
+ if (!replay.peek(frameCounter, ivPrefix)) return;
1135
+ try {
1136
+ const t0 = stats.startCrypto();
1137
+ const data = await decrypt(cryptoKey);
1138
+ stats.endCrypto(t0);
1139
+ // Authenticated: only now is it safe to advance the replay window.
1140
+ replay.commit(frameCounter, ivPrefix);
1141
+ // Independent on purpose: the count is per keyIndex, but
1142
+ // `decryption_failed` is per track, so a track recovering on a NEW
1143
+ // keyIndex must still clear it. Gating on recordSuccess would latch the
1144
+ // host on failed forever.
1145
+ failures.recordSuccess(keyIndex);
1146
+ notify.resumed();
1147
+ frame.data = data;
1148
+ controller.enqueue(frame);
1149
+ stats.bump();
1150
+ } catch {
1151
+ // True only on the failure crossing the tolerance, so
1152
+ // `e2ee.decryption_stalled` fires once per run, not once per frame.
1153
+ const stalled = failures.recordFailure(keyIndex);
1154
+ notify.failed();
1155
+ if (stalled) notify.stalled(keyIndex);
1156
+ }
1157
+ };
1158
+ return new TransformStream({
1159
+ async transform(frame, controller) {
1160
+ if (frame.data.byteLength === 0) {
1161
+ controller.enqueue(frame);
1162
+ stats.bump();
1163
+ return;
1164
+ }
1165
+ const src = new Uint8Array(frame.data);
1166
+ const trailer = readTrailer(src);
1167
+ if (!trailer) {
1168
+ // Ours but unreadable: drop rather than forward. Handing ciphertext to
1169
+ // the decoder renders corruption and reads to the host as a downgrade,
1170
+ // when the actual condition is that this build is the older one.
1171
+ const version = readFramingVersion(src);
1172
+ if (version !== null && version !== E2EE_VERSION) {
1173
+ notify.unsupportedVersion(version);
1174
+ return;
1175
+ }
1176
+ notify.unencrypted();
1177
+ controller.enqueue(frame);
1178
+ stats.bump();
1179
+ return;
1180
+ }
1181
+ const { clearBytes, isRbsp } = trailer;
1182
+ // An RBSP (H264) frame escaped the ciphertext together with the counter,
1183
+ // ivPrefix and keyIndex, so un-escape to recover them; only the trailer
1184
+ // tail read above stayed clear. A non-RBSP frame keeps the trailer raw.
1185
+ let { frameCounter, ivPrefix, keyIndex } = trailer;
1186
+ let ciphertext;
1187
+ if (isRbsp) {
1188
+ // Same boundary seed as the encoder, derived from the same clear bytes.
1189
+ const seed = boundarySeedZeros(src.subarray(0, clearBytes));
1190
+ const unit = rbspUnescape(src.subarray(clearBytes), seed);
1191
+ // Un-escaping can leave less than a trailer, since readTrailer sized
1192
+ // clearBytes against the raw frame. A negative offset would throw out
1193
+ // of transform() and kill this track's pipeline for the session, and a
1194
+ // relay can forge the shape: clearBytes and the flag are plaintext.
1195
+ if (unit.length < TRAILER_LEN) return;
1196
+ ({ frameCounter, ivPrefix, keyIndex } = readTrailerIv(unit));
1197
+ ciphertext = unit.subarray(0, unit.length - TRAILER_LEN);
1198
+ } else {
1199
+ ciphertext = src.subarray(clearBytes, src.length - TRAILER_LEN);
1200
+ }
1201
+ return finishDecode(
1202
+ frame,
1203
+ controller,
1204
+ keyIndex,
1205
+ ivPrefix,
1206
+ frameCounter,
1207
+ async (key) => {
1208
+ fillIV(iv, ivView, ivPrefix, frameCounter);
1209
+ const aad =
1210
+ clearBytes > 0 ? src.subarray(0, clearBytes) : EMPTY_AAD;
1211
+ const decrypted = await crypto.subtle.decrypt(
1212
+ { name: 'AES-GCM', iv, additionalData: aad },
1213
+ key,
1214
+ ciphertext,
1215
+ );
1216
+ if (clearBytes === 0) return decrypted;
1217
+ const plaintext = new Uint8Array(decrypted);
1218
+ const dst = new Uint8Array(clearBytes + plaintext.length);
1219
+ dst.set(src.subarray(0, clearBytes), 0);
1220
+ dst.set(plaintext, clearBytes);
1221
+ return dst.buffer;
1222
+ },
1223
+ );
1224
+ },
1225
+ });
1226
+ };
1227
+
1228
+ /**
1229
+ * E2EE Web Worker entry point. Owns the worker's message interface and wires
1230
+ * the encrypt/decrypt transforms into WebRTC Encoded Transforms. The main
1231
+ * thread distributes keys by postMessage, and transforms look one up per frame
1232
+ * by (userId, keyIndex).
1233
+ *
1234
+ * ## Wire format
1235
+ *
1236
+ * Frame layout is [clear header][ciphertext + GCM tag][20B trailer], so 36
1237
+ * bytes of overhead. The trailer holds:
1238
+ * [4B frameCounter][8B ivPrefix][1B keyIndex][2B clearBytes|flags]
1239
+ * [1B version][4B 0xE2EEFEED]
1240
+ *
1241
+ * The clear header keeps codec headers readable so the SFU can detect keyframes
1242
+ * and select layers: 1 byte for Opus, 10/3 for VP8 and VP9 (key/delta), and for
1243
+ * H264 everything up to the first slice NALU + 2, with the encrypted tail
1244
+ * RBSP-escaped against fake start codes. It doubles as the AAD, so the SFU can
1245
+ * read it but decrypt still detects tampering.
1246
+ *
1247
+ * The 12-byte IV is [ivPrefix][frameCounter]. `ivPrefix` is random per key
1248
+ * import and travels in the trailer, so IVs stay unique even when the host
1249
+ * imports the same raw key twice.
1250
+ *
1251
+ * rollup-plugin-inline-worker bundles this into the function
1252
+ * `../e2ee-worker.ts` exports.
1253
+ *
1254
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Using_Encoded_Transforms
1255
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/encrypt#aes-gcm
1256
+ */
1257
+ /**
1258
+ * Decode always runs. An encode whose codec the worker cannot split fails
1259
+ * closed: it still installs a transform, but one that drops every frame, since
1260
+ * returning without one leaves the encoder buffering forever with no signal.
1261
+ */
1262
+ const selectTransform = (operation, userId, codec, trackType) => {
1263
+ if (operation !== 'encode') return decodeTransform(userId, trackType);
1264
+ if (isSupportedCodec(codec))
1265
+ return encodeTransform(userId, codec, trackType);
1266
+ new EncodeNotifier(userId, trackType).failed(
1267
+ `unsupported codec for E2EE: ${codec}`,
1268
+ );
1269
+ // Enqueues nothing: every frame is dropped.
1270
+ return new TransformStream({ transform() {} });
1271
+ };
1272
+ const setupTransform = ({
1273
+ readable,
1274
+ writable,
1275
+ operation,
1276
+ userId,
1277
+ codec,
1278
+ trackType,
1279
+ }) => {
1280
+ const transform = selectTransform(operation, userId, codec, trackType);
1281
+ readable
1282
+ .pipeThrough(transform)
1283
+ .pipeTo(writable)
1284
+ .catch((err) => {
1285
+ reportError(
1286
+ `Transform pipeline error (${operation}, ${userId}): ${err?.message || err}`,
1287
+ );
1288
+ });
1289
+ };
1290
+ addEventListener('rtctransform', (event) => {
1291
+ const { readable, writable, options } = event.transformer;
1292
+ // Same queue as message-based setup, so an in-flight key import completes
1293
+ // before the transform is wired up.
1294
+ enqueue(async () => {
1295
+ setupTransform({ readable, writable, ...options });
1296
+ }).catch((err) => {
1297
+ reportError(`Transform setup failed: ${err?.message || err}`);
1298
+ });
1299
+ });
1300
+ addEventListener('message', ({ data }) => {
1301
+ enqueue(async () => {
1302
+ switch (data.type) {
1303
+ case 'cmd.set_key':
1304
+ await keyStore.importKey(data.userId, data.keyIndex, data.rawKey);
1305
+ break;
1306
+ case 'cmd.set_shared_key':
1307
+ await keyStore.importSharedKey(data.keyIndex, data.rawKey);
1308
+ break;
1309
+ case 'cmd.remove_shared_key':
1310
+ keyStore.removeSharedKey(data.keyIndex);
1311
+ break;
1312
+ case 'cmd.remove_key':
1313
+ // Deliberately keeps this user's decode stats: they are still
1314
+ // publishing, one epoch was retired.
1315
+ keyStore.removeKey(data.userId, data.keyIndex);
1316
+ break;
1317
+ case 'cmd.remove_all_keys':
1318
+ keyStore.removeAllKeys(data.userId);
1319
+ decodeStats.removeUser(data.userId);
1320
+ break;
1321
+ case 'cmd.enable_performance_reporting':
1322
+ if (data.enabled) startPerfReport();
1323
+ else stopPerfReport();
1324
+ break;
1325
+ case 'cmd.request_key_state':
1326
+ self.postMessage({ type: 'e2ee.key_state', ...keyStore.keyState() });
1327
+ break;
1328
+ case 'cmd.setup_transform':
1329
+ setupTransform(data);
1330
+ break;
1331
+ default:
1332
+ reportError(`Unknown command type: ${data.type}`);
1333
+ break;
1334
+ }
1335
+ }).catch((err) => {
1336
+ reportError(`Message handler error: ${err?.message || err}`);
1337
+ });
1338
+ });
1339
+ }
1340
+
1341
+ export { e2eeWorker };
1342
+ //# sourceMappingURL=e2ee-worker.browser.es.js.map