@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,147 @@
1
+ import { EMPTY_AAD, IV_LEN, MAX_CLEAR_BYTES, TRAILER_LEN } from './constants';
2
+ import {
3
+ boundarySeedZeros,
4
+ getCodecProfile,
5
+ rbspEscapeInto,
6
+ rbspEscapedLength,
7
+ } from './codec';
8
+ import { fillIV, writeTrailer } from './trailer';
9
+ import { nextFrameCounter } from './frameCounter';
10
+ import { keyStore } from './keyStore';
11
+ import { encodeStats } from './perf';
12
+ import { EncodeNotifier, notifyMissingEncodeKey } from './notifications';
13
+ import type { EncodedFrame, FrameController } from './types';
14
+
15
+ export const encodeTransform = (
16
+ userId: string,
17
+ codec: string | undefined,
18
+ trackType: string | undefined,
19
+ ) => {
20
+ const profile = getCodecProfile(codec);
21
+ const isNalu = profile.rbsp;
22
+ // trackType is unique per sender; fall back to codec when it is unlabeled.
23
+ const trackKey = trackType ?? codec ?? 'unknown';
24
+ const codecKey = codec ?? 'unknown';
25
+ const stats = encodeStats.track(trackKey, {
26
+ userId,
27
+ trackType: trackKey,
28
+ codec: codecKey,
29
+ });
30
+ const notify = new EncodeNotifier(userId, trackType);
31
+ const iv = new Uint8Array(IV_LEN);
32
+ const ivView = new DataView(iv.buffer);
33
+
34
+ /**
35
+ * Times the encryption, emits the frame, reports failures. `produce` returns
36
+ * the new bytes, or null when it already dropped the frame with its own
37
+ * reason. Any throw drops the frame; it is never emitted in the clear.
38
+ */
39
+ const finishEncode = async (
40
+ frame: EncodedFrame,
41
+ controller: FrameController,
42
+ produce: () => Promise<Uint8Array<ArrayBuffer> | null>,
43
+ ) => {
44
+ try {
45
+ const t0 = stats.startCrypto();
46
+ const out = await produce();
47
+ stats.endCrypto(t0);
48
+ if (!out) return;
49
+ frame.data = out.buffer;
50
+ controller.enqueue(frame);
51
+ notify.recovered();
52
+ stats.bump();
53
+ } catch (err: any) {
54
+ notify.failed(err?.message || String(err));
55
+ }
56
+ };
57
+
58
+ return new TransformStream<EncodedFrame, EncodedFrame>({
59
+ async transform(frame, controller) {
60
+ // No payload to encrypt.
61
+ if (frame.data.byteLength === 0) {
62
+ controller.enqueue(frame);
63
+ stats.bump();
64
+ return;
65
+ }
66
+
67
+ const entry = keyStore.getLatestKey(userId);
68
+ if (!entry) {
69
+ notifyMissingEncodeKey(userId);
70
+ return;
71
+ }
72
+
73
+ const { key: cryptoKey, keyIndex, ivPrefix: prefix } = entry;
74
+
75
+ return finishEncode(frame, controller, async () => {
76
+ // A key/delta type marks a video frame. An audio-only profile has no
77
+ // clear-byte rule for one, so drop it rather than ship a whole-frame,
78
+ // unescaped encrypt the SFU cannot read and a NALU packetizer would
79
+ // split. Checked before the counter, so a dropped frame costs no IV.
80
+ if (profile.audioOnly && frame.type !== undefined) {
81
+ notify.failed(`no clear-byte rule for video on codec ${codecKey}`);
82
+ return null;
83
+ }
84
+ const src = new Uint8Array(frame.data);
85
+ const clearBytes = profile.clearBytes(frame.type, src);
86
+ if (clearBytes > MAX_CLEAR_BYTES) {
87
+ // Writing this would overflow into the RBSP flag bit.
88
+ notify.failed('clearBytes exceeds trailer capacity');
89
+ return null;
90
+ }
91
+ // Throws at the 32-bit ceiling; finishEncode catches it, so the track
92
+ // fails closed instead of reusing an IV.
93
+ const counter = nextFrameCounter();
94
+ fillIV(iv, ivView, prefix, counter);
95
+ const aad = clearBytes > 0 ? src.subarray(0, clearBytes) : EMPTY_AAD;
96
+ const plaintext = clearBytes > 0 ? src.subarray(clearBytes) : src;
97
+ const encrypted = await crypto.subtle.encrypt(
98
+ { name: 'AES-GCM', iv, additionalData: aad as BufferSource },
99
+ cryptoKey,
100
+ plaintext as BufferSource,
101
+ );
102
+ const ciphertext = new Uint8Array(encrypted);
103
+ if (isNalu && clearBytes > 0) {
104
+ // Escape ciphertext and trailer as one unit: random ciphertext or
105
+ // the counter bytes could otherwise form a fake Annex-B start code
106
+ // that libwebrtc's H264 packetizer would split on. The escaper is
107
+ // seeded with the clear header's trailing zeros so a start code
108
+ // cannot form across the clear/encrypted boundary either.
109
+ //
110
+ // The last 7 trailer bytes survive escaping untouched: the RBSP flag
111
+ // holds the clearBytes high byte at >= 0x80, which breaks any zero
112
+ // run before it can reach them. The decoder reads them off the raw
113
+ // frame tail to locate the unit.
114
+ //
115
+ // Escaping straight behind the clear header copies the ciphertext
116
+ // once, instead of staging it through an intermediate unit buffer
117
+ // and copying it again.
118
+ const trailer = new Uint8Array(TRAILER_LEN);
119
+ writeTrailer(trailer, 0, counter, prefix, keyIndex, clearBytes, true);
120
+ const body = [ciphertext, trailer];
121
+ const seed = boundarySeedZeros(aad);
122
+ const dst = new Uint8Array(
123
+ clearBytes + rbspEscapedLength(body, seed),
124
+ );
125
+ dst.set(aad, 0);
126
+ rbspEscapeInto(dst, clearBytes, body, seed);
127
+ return dst;
128
+ }
129
+ const dst = new Uint8Array(
130
+ clearBytes + ciphertext.length + TRAILER_LEN,
131
+ );
132
+ if (clearBytes > 0) dst.set(aad, 0);
133
+ dst.set(ciphertext, clearBytes);
134
+ writeTrailer(
135
+ dst,
136
+ clearBytes + ciphertext.length,
137
+ counter,
138
+ prefix,
139
+ keyIndex,
140
+ clearBytes,
141
+ false,
142
+ );
143
+ return dst;
144
+ });
145
+ },
146
+ });
147
+ };
@@ -0,0 +1,26 @@
1
+ import { FAILURE_TOLERANCE } from './constants';
2
+
3
+ /**
4
+ * Consecutive decryption failures on one track, keyed by keyIndex so a rotation
5
+ * starts fresh.
6
+ */
7
+ export class FailureTracker {
8
+ private counts: Map<number, number> = new Map();
9
+
10
+ /**
11
+ * True only on the failure crossing {@link FAILURE_TOLERANCE}, so
12
+ * `e2ee.decryption_stalled` fires once per run.
13
+ */
14
+ recordFailure = (keyIndex: number): boolean => {
15
+ const next = (this.counts.get(keyIndex) ?? 0) + 1;
16
+ this.counts.set(keyIndex, next);
17
+ return next === FAILURE_TOLERANCE + 1;
18
+ };
19
+
20
+ /**
21
+ * True if there was a count to clear. Do NOT gate `e2ee.decryption_resumed`
22
+ * on it: that event names a track, not a key epoch, so a track recovering on
23
+ * a new keyIndex must still clear it.
24
+ */
25
+ recordSuccess = (keyIndex: number): boolean => this.counts.delete(keyIndex);
26
+ }
@@ -0,0 +1,37 @@
1
+ import { COUNTER_HARD_LIMIT } from './constants';
2
+
3
+ /**
4
+ * Monotonic, and deliberately shared across every track and codec this sender
5
+ * publishes: the IV is `ivPrefix ∥ counter`, so two tracks drawing from
6
+ * separate counters would encrypt different frames under the same IV.
7
+ *
8
+ * Survives `removeAllKeys` on purpose - if the same raw key is imported again the
9
+ * counter keeps climbing, so no (ivPrefix, counter) pair repeats. Second guard
10
+ * against IV reuse, after the per-import random prefix.
11
+ */
12
+ let counter = 0;
13
+
14
+ export const nextFrameCounter = (): number => {
15
+ const next = counter + 1;
16
+ if (next > COUNTER_HARD_LIMIT) {
17
+ throw new Error('frame counter exhausted, create a new EncryptionManager');
18
+ }
19
+ counter = next;
20
+ return next;
21
+ };
22
+
23
+ /**
24
+ * @internal Test-only. Reaches counter values that would otherwise need 2^32
25
+ * frames. Unused in production, so the bundler drops it.
26
+ */
27
+ export const __setFrameCounterForTest = (value: number) => {
28
+ counter = value;
29
+ };
30
+
31
+ /**
32
+ * @internal Test-only. Resets between test cases. Production teardown is
33
+ * `Worker.terminate()`, which reclaims the whole worker.
34
+ */
35
+ export const __resetFrameCounterForTest = () => {
36
+ counter = 0;
37
+ };
@@ -0,0 +1,211 @@
1
+ import { IV_PREFIX_LEN } from './constants';
2
+ import { reportError } from './notifications';
3
+ import type { ResolvedKey } from './types';
4
+
5
+ /** Nested rather than a `"userId:keyIndex"` string key, which a colon in the
6
+ * userId would make ambiguous. */
7
+ type UserKeyMap<V> = Map<string, Map<number, V>>;
8
+
9
+ const getOrCreate = <V>(map: UserKeyMap<V>, userId: string): Map<number, V> => {
10
+ let inner = map.get(userId);
11
+ if (!inner) {
12
+ inner = new Map();
13
+ map.set(userId, inner);
14
+ }
15
+ return inner;
16
+ };
17
+
18
+ /** One imported key. Always written and deleted as a unit. */
19
+ interface KeyMaterial {
20
+ key: CryptoKey;
21
+ /**
22
+ * Sender-side, fresh per import, so two imports of the same raw key get
23
+ * different prefixes and cannot reuse an IV. Receivers read the prefix off
24
+ * the frame trailer instead.
25
+ */
26
+ ivPrefix: Uint8Array;
27
+ /** First 8 bytes of SHA-256(rawKey). Not reversible, so safe to expose. */
28
+ fingerprint: Uint8Array;
29
+ }
30
+
31
+ const randomBytes = (n: number): Uint8Array => {
32
+ const bytes = new Uint8Array(n);
33
+ crypto.getRandomValues(bytes);
34
+ return bytes;
35
+ };
36
+
37
+ const fingerprint = async (rawKey: ArrayBuffer): Promise<Uint8Array> => {
38
+ const hash = await crypto.subtle.digest('SHA-256', rawKey);
39
+ return new Uint8Array(hash, 0, 8);
40
+ };
41
+
42
+ /**
43
+ * The buffer length picks the variant; EncryptionManager already validated it
44
+ * as 16 or 32 bytes. Passing `length` keeps WebCrypto unambiguous across
45
+ * browsers.
46
+ */
47
+ const aesGcmParams = (rawKey: ArrayBuffer): AesKeyAlgorithm => ({
48
+ name: 'AES-GCM',
49
+ length: rawKey.byteLength * 8,
50
+ });
51
+
52
+ /**
53
+ * Import into a non-extractable CryptoKey, with a fresh random IV prefix and
54
+ * the fingerprint. The fresh prefix is what lets the same raw key be imported
55
+ * again without IV reuse.
56
+ */
57
+ const importKeyMaterial = async (rawKey: ArrayBuffer): Promise<KeyMaterial> => {
58
+ const [key, fp] = await Promise.all([
59
+ crypto.subtle.importKey('raw', rawKey, aesGcmParams(rawKey), false, [
60
+ 'encrypt',
61
+ 'decrypt',
62
+ ]),
63
+ fingerprint(rawKey),
64
+ ]);
65
+ return { key, ivPrefix: randomBytes(IV_PREFIX_LEN), fingerprint: fp };
66
+ };
67
+
68
+ const toHex = (bytes: Uint8Array): string =>
69
+ Array.from(bytes)
70
+ .map((b) => b.toString(16).padStart(2, '0'))
71
+ .join('');
72
+
73
+ export class KeyStore {
74
+ private perUserKeys: UserKeyMap<KeyMaterial> = new Map();
75
+ private latestKeyIndex = new Map<string, number>();
76
+ /** Shared receive keys retained by epoch so in-flight frames survive rotation. */
77
+ private sharedKeys = new Map<number, KeyMaterial>();
78
+ /** The shared epoch used for encode fallback. Older retained epochs never win. */
79
+ private activeSharedKeyIndex: number | undefined;
80
+
81
+ /**
82
+ * Decryption key for (userId, keyIndex): per-user entry, else the shared key
83
+ * at that index. The keyIndex comes from the frame trailer.
84
+ */
85
+ getKey = (userId: string, keyIndex: number): CryptoKey | undefined => {
86
+ const perUser = this.perUserKeys.get(userId)?.get(keyIndex);
87
+ if (perUser) return perUser.key;
88
+ return this.sharedKeys.get(keyIndex)?.key;
89
+ };
90
+
91
+ /**
92
+ * The encode path's only lookup: latest per-user key, else the explicitly
93
+ * active shared key. Retained shared epochs are receive-only. The `ivPrefix`
94
+ * rides along so the encoder never resolves the same material twice.
95
+ */
96
+ getLatestKey = (
97
+ userId: string,
98
+ ): (ResolvedKey & { ivPrefix: Uint8Array }) | null => {
99
+ const idx = this.latestKeyIndex.get(userId);
100
+ if (idx !== undefined) {
101
+ const km = this.perUserKeys.get(userId)?.get(idx);
102
+ if (km) return { key: km.key, keyIndex: idx, ivPrefix: km.ivPrefix };
103
+ }
104
+ if (this.activeSharedKeyIndex === undefined) return null;
105
+ const shared = this.sharedKeys.get(this.activeSharedKeyIndex);
106
+ return shared
107
+ ? {
108
+ key: shared.key,
109
+ keyIndex: this.activeSharedKeyIndex,
110
+ ivPrefix: shared.ivPrefix,
111
+ }
112
+ : null;
113
+ };
114
+
115
+ importKey = async (userId: string, keyIndex: number, rawKey: ArrayBuffer) => {
116
+ try {
117
+ getOrCreate(this.perUserKeys, userId).set(
118
+ keyIndex,
119
+ await importKeyMaterial(rawKey),
120
+ );
121
+ this.latestKeyIndex.set(userId, keyIndex);
122
+ } catch (e: any) {
123
+ reportError(
124
+ `Failed to import key for user ${userId}: ${e?.message || e}`,
125
+ );
126
+ }
127
+ };
128
+
129
+ importSharedKey = async (keyIndex: number, rawKey: ArrayBuffer) => {
130
+ try {
131
+ const material = await importKeyMaterial(rawKey);
132
+ this.sharedKeys.set(keyIndex, material);
133
+ this.activeSharedKeyIndex = keyIndex;
134
+ } catch (e: any) {
135
+ reportError(`Failed to import shared key: ${e?.message || e}`);
136
+ }
137
+ };
138
+
139
+ /**
140
+ * Retire exactly one of a user's epochs, leaving the rest usable.
141
+ *
142
+ * Clearing `latestKeyIndex` along with the slot is load-bearing. Left on a
143
+ * removed index, {@link getLatestKey} resolves nothing there and falls
144
+ * through to the active shared epoch, so the local user's frames would
145
+ * quietly move from a key only they hold to one every participant holds. No
146
+ * older epoch is promoted either, matching {@link removeSharedKey}.
147
+ */
148
+ removeKey = (userId: string, keyIndex: number) => {
149
+ const perKeyIndex = this.perUserKeys.get(userId);
150
+ if (!perKeyIndex?.delete(keyIndex)) return;
151
+ if (perKeyIndex.size === 0) this.perUserKeys.delete(userId);
152
+ if (this.latestKeyIndex.get(userId) === keyIndex) {
153
+ this.latestKeyIndex.delete(userId);
154
+ }
155
+ };
156
+
157
+ /**
158
+ * Leaves the frame counters intact on purpose: a reset counter would reuse
159
+ * IVs if the same raw key is imported again later. They live in
160
+ * `frameCounter.ts`, so this cannot reach them even by accident.
161
+ */
162
+ removeAllKeys = (userId: string) => {
163
+ this.perUserKeys.delete(userId);
164
+ this.latestKeyIndex.delete(userId);
165
+ };
166
+
167
+ /**
168
+ * Removes exactly one shared receive epoch. Removing the active epoch also
169
+ * disables shared-key encode fallback; an older epoch is never reactivated
170
+ * implicitly because doing so could resume sending with a retired key.
171
+ */
172
+ removeSharedKey = (keyIndex: number) => {
173
+ this.sharedKeys.delete(keyIndex);
174
+ if (this.activeSharedKeyIndex === keyIndex) {
175
+ this.activeSharedKeyIndex = undefined;
176
+ }
177
+ };
178
+
179
+ /**
180
+ * Debug snapshot. Fingerprints only: enough to confirm a sender and receiver
181
+ * hold matching key material, and it exposes no key.
182
+ */
183
+ keyState = () => ({
184
+ perUserKeys: Array.from(this.perUserKeys).flatMap(([userId, perKeyIndex]) =>
185
+ Array.from(perKeyIndex, ([keyIndex, km]) => ({
186
+ userId,
187
+ keyIndex,
188
+ fingerprint: toHex(km.fingerprint),
189
+ })),
190
+ ),
191
+ sharedKeys: Array.from(this.sharedKeys, ([keyIndex, material]) => ({
192
+ keyIndex,
193
+ fingerprint: toHex(material.fingerprint),
194
+ isActive: keyIndex === this.activeSharedKeyIndex,
195
+ })),
196
+ });
197
+
198
+ /**
199
+ * @internal Test-only. Production teardown is `Worker.terminate()`, which
200
+ * reclaims the whole worker.
201
+ */
202
+ clear = () => {
203
+ this.perUserKeys.clear();
204
+ this.latestKeyIndex.clear();
205
+ this.sharedKeys.clear();
206
+ this.activeSharedKeyIndex = undefined;
207
+ };
208
+ }
209
+
210
+ /** The worker has exactly one. */
211
+ export const keyStore = new KeyStore();
@@ -0,0 +1,190 @@
1
+ /** At most one notification per second per key. */
2
+ const THROTTLE_INTERVAL_MS = 1000;
3
+
4
+ /**
5
+ * Rate limiter keyed by an arbitrary string, so one throttle can cover several
6
+ * independent conditions (a keyIndex, a userId) without them muting each other.
7
+ *
8
+ * Lives here because throttling is a delivery rule, not a general utility -
9
+ * every user of it is in this file.
10
+ */
11
+ class Throttle {
12
+ private readonly intervalMs: number;
13
+ private lastFiredAt: Map<string, number> = new Map();
14
+
15
+ constructor(intervalMs: number) {
16
+ this.intervalMs = intervalMs;
17
+ }
18
+
19
+ /**
20
+ * True at most once per `intervalMs` for that key, so a sustained failure
21
+ * cannot flood the host with notifications.
22
+ */
23
+ tryFire = (key: string): boolean => {
24
+ const now = Date.now();
25
+ if (now - (this.lastFiredAt.get(key) ?? 0) > this.intervalMs) {
26
+ this.lastFiredAt.set(key, now);
27
+ return true;
28
+ }
29
+ return false;
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Internal log-only channel: no `E2EEEventMap` entry, so the manager logs it
35
+ * rather than emitting it to the host.
36
+ */
37
+ export const reportError = (message: string): void => {
38
+ self.postMessage({ type: 'e2ee.error', message });
39
+ };
40
+
41
+ /**
42
+ * The encoder holds no key, so every outgoing frame is dropped. Without this
43
+ * the host just sees black video with nothing to act on. Throttled per user,
44
+ * and stops on its own once a key arrives.
45
+ *
46
+ * Module-scoped rather than per transform: this condition is per user by
47
+ * definition, so every track sharing one throttle is the point.
48
+ */
49
+ const missingKeyThrottle = new Throttle(THROTTLE_INTERVAL_MS);
50
+ export const notifyMissingEncodeKey = (userId: string): void => {
51
+ if (missingKeyThrottle.tryFire(userId)) {
52
+ self.postMessage({ type: 'e2ee.missing_key', userId });
53
+ }
54
+ };
55
+
56
+ /** Encode-side signals for one track. */
57
+ export class EncodeNotifier {
58
+ private readonly userId: string;
59
+ private readonly trackType: string | undefined;
60
+ /** True once a failure was reported, until {@link recovered} re-arms it. */
61
+ private latched = false;
62
+
63
+ constructor(userId: string, trackType: string | undefined) {
64
+ this.userId = userId;
65
+ this.trackType = trackType;
66
+ }
67
+
68
+ /** First failure of a run; silent until {@link recovered} re-arms it. */
69
+ failed = (reason: string): void => {
70
+ if (this.latched) return;
71
+ this.latched = true;
72
+ self.postMessage({
73
+ type: 'e2ee.encryption_failed',
74
+ userId: this.userId,
75
+ trackType: this.trackType,
76
+ reason,
77
+ });
78
+ };
79
+
80
+ /**
81
+ * A frame encrypted again, so the next failure is worth reporting. Re-arming
82
+ * is deliberate: it stops one early transient error from hiding a later
83
+ * permanent one, such as the frame-counter hard limit.
84
+ */
85
+ recovered = (): void => {
86
+ this.latched = false;
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Decode-side signals for one track.
92
+ *
93
+ * One notifier is one track, so `userId` is constant: each throttle holds a
94
+ * single entry and limits that track alone.
95
+ */
96
+ export class DecodeNotifier {
97
+ private readonly userId: string;
98
+ private readonly trackType: string | undefined;
99
+ private readonly failureThrottle = new Throttle(THROTTLE_INTERVAL_MS);
100
+ /**
101
+ * A key in flight, or a rotation whose keyIndex has not arrived, are both
102
+ * normal. Keyed by keyIndex: one signal per key epoch.
103
+ */
104
+ private readonly missingKeyThrottle = new Throttle(THROTTLE_INTERVAL_MS);
105
+ private readonly cleartextThrottle = new Throttle(THROTTLE_INTERVAL_MS);
106
+ /**
107
+ * Keyed by user, deliberately not by version. A sender's version is a
108
+ * compile-time constant, so a track only ever sees one value legitimately -
109
+ * while the byte is plaintext, so a relay rewriting it per frame would
110
+ * otherwise get one event per distinct value and multiply the rate by 255.
111
+ */
112
+ private readonly versionThrottle = new Throttle(THROTTLE_INTERVAL_MS);
113
+ /**
114
+ * True once a `decryption_failed` reached the host. Pairs the two signals:
115
+ * only a delivered failure needs clearing, and clearing it re-arms this.
116
+ */
117
+ private failureReported = false;
118
+
119
+ constructor(userId: string, trackType: string | undefined) {
120
+ this.userId = userId;
121
+ this.trackType = trackType;
122
+ }
123
+
124
+ /** GCM tag failure. Throttled; records that a failure reached the host. */
125
+ failed = (): void => {
126
+ if (!this.failureThrottle.tryFire(this.userId)) return;
127
+ this.failureReported = true;
128
+ self.postMessage({
129
+ type: 'e2ee.decryption_failed',
130
+ userId: this.userId,
131
+ trackType: this.trackType,
132
+ });
133
+ };
134
+
135
+ /** Paired with {@link failed}: no-op unless a failure was delivered. */
136
+ resumed = (): void => {
137
+ if (!this.failureReported) return;
138
+ this.failureReported = false;
139
+ self.postMessage({
140
+ type: 'e2ee.decryption_resumed',
141
+ userId: this.userId,
142
+ trackType: this.trackType,
143
+ });
144
+ };
145
+
146
+ /** A frame named a key this peer does not hold. Throttled per keyIndex. */
147
+ missingKey = (keyIndex: number): void => {
148
+ if (!this.missingKeyThrottle.tryFire(String(keyIndex))) return;
149
+ self.postMessage({
150
+ type: 'e2ee.missing_key',
151
+ userId: this.userId,
152
+ trackType: this.trackType,
153
+ keyIndex,
154
+ });
155
+ };
156
+
157
+ /**
158
+ * A frame carried this format but a version this build cannot decrypt, so it
159
+ * was dropped. Throttled per track.
160
+ */
161
+ unsupportedVersion = (version: number): void => {
162
+ if (!this.versionThrottle.tryFire(this.userId)) return;
163
+ self.postMessage({
164
+ type: 'e2ee.unsupported_version',
165
+ userId: this.userId,
166
+ trackType: this.trackType,
167
+ version,
168
+ });
169
+ };
170
+
171
+ /** A frame carried no E2EE framing and was forwarded as-is. Throttled. */
172
+ unencrypted = (): void => {
173
+ if (!this.cleartextThrottle.tryFire(this.userId)) return;
174
+ self.postMessage({
175
+ type: 'e2ee.unencrypted_frame',
176
+ userId: this.userId,
177
+ trackType: this.trackType,
178
+ });
179
+ };
180
+
181
+ /** Consecutive failures crossed the tolerance. Already once-per-run. */
182
+ stalled = (keyIndex: number): void => {
183
+ self.postMessage({
184
+ type: 'e2ee.decryption_stalled',
185
+ userId: this.userId,
186
+ trackType: this.trackType,
187
+ keyIndex,
188
+ });
189
+ };
190
+ }