@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,129 @@
1
+ /** Labels a perf sample carries. `codec` is known on the encode side only. */
2
+ type StatLabels = { userId: string; trackType: string; codec?: string };
3
+
4
+ /** Accumulator for one track, reset by each {@link StatsRegistry.flush}. */
5
+ type StatEntry = StatLabels & { count: number; maxCryptoMs: number };
6
+
7
+ /** One track's rates, as reported to the host. */
8
+ type StatSample = StatLabels & { fps: number; maxCryptoMs: number };
9
+
10
+ /**
11
+ * Whether reporting is on. Module-scoped rather than per registry: the worker
12
+ * has exactly one of each, and a single flag keeps encode and decode from
13
+ * drifting apart.
14
+ */
15
+ let perfEnabled = false;
16
+ let perfInterval: ReturnType<typeof setInterval> | null = null;
17
+ let perfLastTick = 0;
18
+
19
+ /**
20
+ * Counter handle for one track, held by that track's transform for its
21
+ * lifetime. Every method no-ops while reporting is off.
22
+ */
23
+ class TrackStats {
24
+ private stats: Map<string, StatEntry>;
25
+ private readonly key: string;
26
+ private readonly labels: StatLabels;
27
+
28
+ constructor(stats: Map<string, StatEntry>, key: string, labels: StatLabels) {
29
+ this.stats = stats;
30
+ this.key = key;
31
+ this.labels = labels;
32
+ }
33
+
34
+ /**
35
+ * This track's accumulator, created on first use. Lazy on purpose: the entry
36
+ * is what puts a row in the next report, so a track that goes idle after a
37
+ * flush drops out instead of reporting 0 fps forever.
38
+ */
39
+ private entry = (): StatEntry | undefined => {
40
+ if (!perfEnabled) return undefined;
41
+ let stat = this.stats.get(this.key);
42
+ if (!stat) {
43
+ stat = { ...this.labels, count: 0, maxCryptoMs: 0 };
44
+ this.stats.set(this.key, stat);
45
+ }
46
+ return stat;
47
+ };
48
+
49
+ /** Count one frame through the transform. */
50
+ bump = (): void => {
51
+ const stat = this.entry();
52
+ if (stat) stat.count++;
53
+ };
54
+
55
+ /** Timestamp to hand back to {@link endCrypto}; 0 while disabled. */
56
+ startCrypto = (): number => (perfEnabled ? performance.now() : 0);
57
+
58
+ endCrypto = (startedAt: number): void => {
59
+ const stat = this.entry();
60
+ if (!stat) return;
61
+ stat.maxCryptoMs = Math.max(
62
+ stat.maxCryptoMs,
63
+ performance.now() - startedAt,
64
+ );
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Per-track counters for one direction. The key is unique per track, so a vp8
70
+ * camera and a vp8 screen share (encode), or a peer's audio and video (decode),
71
+ * are reported apart instead of summed.
72
+ */
73
+ class StatsRegistry {
74
+ private stats: Map<string, StatEntry> = new Map();
75
+
76
+ track = (key: string, labels: StatLabels): TrackStats =>
77
+ new TrackStats(this.stats, key, labels);
78
+
79
+ /** Drain all accumulators into per-track rates. */
80
+ flush = (dtSec: number): StatSample[] => {
81
+ const samples = Array.from(
82
+ this.stats.values(),
83
+ ({ count, maxCryptoMs, ...labels }) => ({
84
+ ...labels,
85
+ fps: count / dtSec,
86
+ maxCryptoMs,
87
+ }),
88
+ );
89
+ this.stats.clear();
90
+ return samples;
91
+ };
92
+
93
+ clear = (): void => this.stats.clear();
94
+
95
+ removeUser = (userId: string): void => {
96
+ for (const [key, stat] of this.stats) {
97
+ if (stat.userId === userId) this.stats.delete(key);
98
+ }
99
+ };
100
+ }
101
+
102
+ export const encodeStats = new StatsRegistry();
103
+ export const decodeStats = new StatsRegistry();
104
+
105
+ export const startPerfReport = () => {
106
+ if (perfInterval) return; // a second interval would leak
107
+ perfEnabled = true;
108
+ perfLastTick = performance.now();
109
+ perfInterval = setInterval(() => {
110
+ const now = performance.now();
111
+ const dtSec = Math.max(0.001, (now - perfLastTick) / 1000);
112
+ perfLastTick = now;
113
+ self.postMessage({
114
+ type: 'e2ee.perf_report',
115
+ encode: encodeStats.flush(dtSec),
116
+ decode: decodeStats.flush(dtSec),
117
+ });
118
+ }, 1000);
119
+ };
120
+
121
+ export const stopPerfReport = () => {
122
+ perfEnabled = false;
123
+ if (perfInterval) {
124
+ clearInterval(perfInterval);
125
+ perfInterval = null;
126
+ }
127
+ encodeStats.clear();
128
+ decodeStats.clear();
129
+ };
@@ -0,0 +1,12 @@
1
+ let tail: Promise<unknown> = Promise.resolve();
2
+
3
+ /**
4
+ * Run tasks FIFO, one at a time, so a `setKey` cannot race transform setup.
5
+ * `tail` swallows errors so one rejection cannot stall the queue; the returned
6
+ * promise still carries that task's own outcome.
7
+ */
8
+ export const enqueue = <T>(fn: () => Promise<T>): Promise<T> => {
9
+ const run = tail.then(fn);
10
+ tail = run.catch(() => {});
11
+ return run;
12
+ };
@@ -0,0 +1,131 @@
1
+ import { REPLAY_WINDOW } from './constants';
2
+
3
+ /** Length-safe byte comparison, for matching a frame's prefix to an epoch. */
4
+ const bytesEqual = (a: Uint8Array, b: Uint8Array): boolean => {
5
+ if (a.length !== b.length) return false;
6
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
7
+ return true;
8
+ };
9
+
10
+ const REPLAY_WINDOW_WORDS = REPLAY_WINDOW >>> 5;
11
+
12
+ /**
13
+ * One sender IV-prefix epoch: a high-water mark plus a bitmap over the
14
+ * preceding {@link REPLAY_WINDOW} counters, where bit `counter % REPLAY_WINDOW`
15
+ * marks a seen counter. O(1) checks and in-order advance, where a `Set` would
16
+ * need an O(REPLAY_WINDOW) prune per frame.
17
+ */
18
+ class ReplayEpoch {
19
+ /** Copied, since the caller's view points into a frame buffer that is reused. */
20
+ readonly prefix: Uint8Array;
21
+ private highest: number;
22
+ private bitmap: Uint32Array = new Uint32Array(REPLAY_WINDOW_WORDS);
23
+
24
+ constructor(prefix: Uint8Array, counter: number) {
25
+ this.prefix = prefix.slice();
26
+ this.highest = counter;
27
+ this.mark(counter);
28
+ }
29
+
30
+ /** Above the high-water mark, or inside the window and not yet seen. */
31
+ accepts = (counter: number): boolean => {
32
+ if (counter > this.highest) return true;
33
+ if (counter <= this.highest - REPLAY_WINDOW) return false;
34
+ return !this.isMarked(counter);
35
+ };
36
+
37
+ record = (counter: number): void => {
38
+ if (counter > this.highest) {
39
+ // Slots repeat every REPLAY_WINDOW counters, so skipped ones can hold a
40
+ // stale bit and must be cleared. In-order frames skip none; a large jump
41
+ // makes the whole bitmap stale.
42
+ if (counter - this.highest >= REPLAY_WINDOW) {
43
+ this.bitmap.fill(0);
44
+ } else {
45
+ for (let c = this.highest + 1; c < counter; c++) this.clear(c);
46
+ }
47
+ this.highest = counter;
48
+ }
49
+ this.mark(counter);
50
+ };
51
+
52
+ private slot = (counter: number) => {
53
+ const idx = counter % REPLAY_WINDOW;
54
+ return { word: idx >>> 5, mask: 1 << (idx & 31) };
55
+ };
56
+
57
+ private isMarked = (counter: number): boolean => {
58
+ const { word, mask } = this.slot(counter);
59
+ return (this.bitmap[word] & mask) !== 0;
60
+ };
61
+
62
+ private mark = (counter: number): void => {
63
+ const { word, mask } = this.slot(counter);
64
+ this.bitmap[word] |= mask;
65
+ };
66
+
67
+ private clear = (counter: number): void => {
68
+ const { word, mask } = this.slot(counter);
69
+ this.bitmap[word] &= ~mask;
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Sender IV-prefix "epochs" one track's guard keeps. One is normal; a second or
75
+ * third appears briefly around a key re-import or sender restart, while old and
76
+ * new prefixes interleave in the jitter buffer.
77
+ *
78
+ * Eviction is safe because the sender never reuses an (ivPrefix, counter) pair.
79
+ * Only `commit` creates and evicts epochs, and only authenticated frames reach
80
+ * it, so a relay cannot forge new-prefix frames to evict a genuine epoch.
81
+ */
82
+ const REPLAY_EPOCHS = 3;
83
+
84
+ /**
85
+ * Replay guard for one remote track.
86
+ *
87
+ * Shared across tracks it would couple them: independent SSRCs and jitter
88
+ * buffers mean delivery skew could advance the high-water mark far enough to
89
+ * reject a lagging track's frames, dropping media and reporting false failures.
90
+ *
91
+ * Inside a track the sender's IV prefix partitions the window further, so a
92
+ * sender restart (fresh prefix, counter near 0) opens a clean window instead of
93
+ * losing its low counters to a stale mark.
94
+ *
95
+ * Only receive-side bookkeeping is per track. The sender's counter stays global
96
+ * per user (see `frameCounter.ts`), which is what keeps IVs unique across a
97
+ * user's tracks and the wire format identical for other SDKs.
98
+ */
99
+ export class ReplayWindow {
100
+ private epochs: ReplayEpoch[] = [];
101
+
102
+ private find = (ivPrefix: Uint8Array): ReplayEpoch | undefined =>
103
+ this.epochs.find((e) => bytesEqual(e.prefix, ivPrefix));
104
+
105
+ /**
106
+ * True when this prefix can accept `counter`: new prefix, above the
107
+ * high-water mark, or inside the window and not yet committed.
108
+ *
109
+ * Changes no state. A relay can forge the trailer fields this reads, so only
110
+ * an authenticated frame advances the window. See {@link commit}.
111
+ */
112
+ peek = (counter: number, ivPrefix: Uint8Array): boolean => {
113
+ // A prefix with no committed frame yet opens a clean window.
114
+ return this.find(ivPrefix)?.accepts(counter) ?? true;
115
+ };
116
+
117
+ /**
118
+ * Record `counter` as seen, advancing the high-water mark. Call it only after
119
+ * AES-GCM authenticates, so unauthenticated bytes cannot wedge the window or
120
+ * evict a genuine epoch.
121
+ */
122
+ commit = (counter: number, ivPrefix: Uint8Array): void => {
123
+ const epoch = this.find(ivPrefix);
124
+ if (epoch) {
125
+ epoch.record(counter);
126
+ return;
127
+ }
128
+ this.epochs.unshift(new ReplayEpoch(ivPrefix, counter));
129
+ if (this.epochs.length > REPLAY_EPOCHS) this.epochs.pop();
130
+ };
131
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The on-wire framing: the 20-byte trailer appended to every encrypted frame,
3
+ * and the 12-byte IV both directions derive.
4
+ *
5
+ * Write and read live together on purpose. They are one contract - the encoder
6
+ * lays out bytes the decoder must find at the same offsets - so the layout is
7
+ * defined once, here, and a change to it is a wire break: bump
8
+ * {@link E2EE_VERSION} and regenerate the SPEC section 11 vectors.
9
+ *
10
+ * Layout, from the trailer's first byte:
11
+ * [4B frameCounter][8B ivPrefix][1B keyIndex][2B clearBytes|flags]
12
+ * [1B version][4B magic]
13
+ */
14
+
15
+ import {
16
+ E2EE_VERSION,
17
+ FRAME_COUNTER_LEN,
18
+ IV_PREFIX_LEN,
19
+ MAGIC,
20
+ MAX_CLEAR_BYTES,
21
+ RBSP_FLAG,
22
+ TRAILER_LEN,
23
+ } from './constants';
24
+ import type { Trailer } from './types';
25
+
26
+ const OFF_IV_PREFIX = FRAME_COUNTER_LEN; // 4
27
+ const OFF_KEY_INDEX = OFF_IV_PREFIX + IV_PREFIX_LEN; // 12
28
+ const OFF_CLEAR_BYTES = OFF_KEY_INDEX + 1; // 13
29
+ const OFF_VERSION = OFF_CLEAR_BYTES + 2; // 15
30
+ const OFF_MAGIC = OFF_VERSION + 1; // 16
31
+
32
+ /**
33
+ * Fill a pre-allocated 12-byte IV: `[8B ivPrefix][4B frameCounter, BE]`.
34
+ *
35
+ * The encoder builds it from its own prefix and counter; the decoder rebuilds
36
+ * the identical bytes from the trailer. The buffer is reused across frames
37
+ * rather than allocated per frame - this runs once per frame on every track.
38
+ */
39
+ export const fillIV = (
40
+ iv: Uint8Array,
41
+ ivView: DataView,
42
+ prefix: Uint8Array,
43
+ frameCounter: number,
44
+ ) => {
45
+ iv.set(prefix, 0);
46
+ ivView.setUint32(IV_PREFIX_LEN, frameCounter);
47
+ };
48
+
49
+ export const writeTrailer = (
50
+ dst: Uint8Array,
51
+ offset: number,
52
+ frameCounter: number,
53
+ ivPrefix: Uint8Array,
54
+ keyIndex: number,
55
+ clearBytes: number,
56
+ isRbsp: boolean,
57
+ ) => {
58
+ if (clearBytes > MAX_CLEAR_BYTES) {
59
+ throw new Error(
60
+ `clearBytes ${clearBytes} exceeds 15-bit max ${MAX_CLEAR_BYTES}`,
61
+ );
62
+ }
63
+ if (ivPrefix.length !== IV_PREFIX_LEN) {
64
+ throw new Error(
65
+ `ivPrefix must be ${IV_PREFIX_LEN} bytes, got ${ivPrefix.length}`,
66
+ );
67
+ }
68
+ const view = new DataView(dst.buffer, dst.byteOffset, dst.byteLength);
69
+ view.setUint32(offset, frameCounter);
70
+ dst.set(ivPrefix, offset + OFF_IV_PREFIX);
71
+ dst[offset + OFF_KEY_INDEX] = keyIndex;
72
+ view.setUint16(
73
+ offset + OFF_CLEAR_BYTES,
74
+ isRbsp ? clearBytes | RBSP_FLAG : clearBytes,
75
+ );
76
+ dst[offset + OFF_VERSION] = E2EE_VERSION;
77
+ view.setUint32(offset + OFF_MAGIC, MAGIC);
78
+ };
79
+
80
+ /**
81
+ * IV fields only, from an already-recognized trailer. An H264 RBSP frame
82
+ * escapes these three with the ciphertext, so un-escape the unit before
83
+ * calling this.
84
+ */
85
+ export const readTrailerIv = (
86
+ buf: Uint8Array,
87
+ ): Pick<Trailer, 'frameCounter' | 'ivPrefix' | 'keyIndex'> => {
88
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
89
+ const start = buf.length - TRAILER_LEN;
90
+ return {
91
+ frameCounter: view.getUint32(start),
92
+ ivPrefix: buf.subarray(start + OFF_IV_PREFIX, start + OFF_KEY_INDEX),
93
+ keyIndex: buf[start + OFF_KEY_INDEX],
94
+ };
95
+ };
96
+
97
+ /**
98
+ * The frozen identification suffix: `version (1) || magic (4)`. A literal, not
99
+ * `TRAILER_LEN - OFF_VERSION`, because this length is frozen at 5 for every
100
+ * version present and future while those two constants describe v1 only - a
101
+ * later layout change must break the round-trip test, not silently move the
102
+ * suffix older receivers depend on. `trailer.test.ts` pins it to the bytes
103
+ * `writeTrailer` actually emits.
104
+ *
105
+ * Read from the end rather than from a trailer start, because a future version
106
+ * may not use a 20-byte trailer, and a receiver that cannot decrypt such a
107
+ * frame still has to recognize it.
108
+ */
109
+ const IDENT_SUFFIX_LEN = 5;
110
+ /** Within the suffix: the magic follows the single version byte. */
111
+ const OFF_SUFFIX_MAGIC = 1;
112
+
113
+ /**
114
+ * The framing version of a frame carrying this format, or `null` if the frame
115
+ * is not ours at all.
116
+ *
117
+ * This is what separates the two reasons {@link readTrailer} returns `null`:
118
+ * an unrelated cleartext frame, versus an encrypted frame from a peer on a
119
+ * version this build cannot decrypt.
120
+ *
121
+ * @param view - An existing view over exactly `src`, to avoid a second
122
+ * allocation on the per-frame decode path. Built here when omitted.
123
+ */
124
+ export const readFramingVersion = (
125
+ src: Uint8Array,
126
+ view?: DataView,
127
+ ): number | null => {
128
+ if (src.length < IDENT_SUFFIX_LEN) return null;
129
+ const bytes =
130
+ view ?? new DataView(src.buffer, src.byteOffset, src.byteLength);
131
+ const start = src.length - IDENT_SUFFIX_LEN;
132
+ if (bytes.getUint32(start + OFF_SUFFIX_MAGIC) !== MAGIC) return null;
133
+ return src[start];
134
+ };
135
+
136
+ export const readTrailer = (src: Uint8Array): Trailer | null => {
137
+ if (src.length < TRAILER_LEN) return null;
138
+ const view = new DataView(src.buffer, src.byteOffset, src.byteLength);
139
+ if (readFramingVersion(src, view) !== E2EE_VERSION) return null;
140
+ const start = src.length - TRAILER_LEN;
141
+ const raw = view.getUint16(start + OFF_CLEAR_BYTES);
142
+ const clearBytes = raw & MAX_CLEAR_BYTES;
143
+ // Bail out before allocating; the decrypt would fail anyway.
144
+ if (clearBytes > src.length - TRAILER_LEN) return null;
145
+ // The last 7 bytes survive escaping untouched: the RBSP flag holds the
146
+ // clearBytes high byte >= 0x80 and breaks any zero run. The three below are
147
+ // valid only on a non-RBSP frame; see {@link readTrailerIv}.
148
+ return {
149
+ frameCounter: view.getUint32(start),
150
+ ivPrefix: src.subarray(start + OFF_IV_PREFIX, start + OFF_KEY_INDEX),
151
+ keyIndex: src[start + OFF_KEY_INDEX],
152
+ clearBytes,
153
+ isRbsp: (raw & RBSP_FLAG) !== 0,
154
+ };
155
+ };
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "@stream-io/typescript-config/library-web.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "lib": ["ES2022", "WebWorker"]
6
+ },
7
+ "include": ["./**/*.ts"]
8
+ }
@@ -0,0 +1,29 @@
1
+ /** Minimal shape of an RTCEncodedVideo/AudioFrame. */
2
+ export interface EncodedFrame {
3
+ data: ArrayBuffer;
4
+ /** Absent on audio: the lack of a key/delta type is how audio is recognized. */
5
+ type?: 'key' | 'delta' | 'empty';
6
+ timestamp: number;
7
+ }
8
+
9
+ /** The subset of TransformStreamDefaultController the transforms use. */
10
+ export type FrameController = {
11
+ enqueue(frame: EncodedFrame): void;
12
+ terminate(): void;
13
+ };
14
+
15
+ /** A resolved encryption key paired with its rotation index. */
16
+ export interface ResolvedKey {
17
+ key: CryptoKey;
18
+ keyIndex: number;
19
+ }
20
+
21
+ /** Parsed 20-byte frame trailer appended to every encrypted frame (v1). */
22
+ export interface Trailer {
23
+ frameCounter: number;
24
+ /** View of the 8-byte IV prefix inside the source frame buffer. */
25
+ ivPrefix: Uint8Array;
26
+ keyIndex: number;
27
+ clearBytes: number;
28
+ isRbsp: boolean;
29
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * E2EE worker. The build populates it.
3
+ *
4
+ * The actual implementation lives in `e2ee-worker/e2ee-worker-impl.ts` and its
5
+ * sibling modules (`constants.ts`, `codec.ts`, `crypto.ts`, `utils.ts`,
6
+ * `types.ts`), all written as real TypeScript with full type checking
7
+ * and import support.
8
+ *
9
+ * ## How it works
10
+ *
11
+ * 1. You edit files in `e2ee-worker/` as normal TypeScript
12
+ * 2. `yarn build` runs Rollup, which invokes `rollup-plugin-inline-worker`
13
+ * 3. The plugin bundles `e2ee-worker/e2ee-worker-impl.ts` (and its imports)
14
+ * into a single function via a nested Rollup + TypeScript build
15
+ * 4. The function replaces this file's export at build time
16
+ * 5. `EncryptionManager.create()` creates a Worker from `e2eeWorker.toString()`
17
+ *
18
+ * In tests, this module is mocked (see `__tests__/EncryptionManager.test.ts`),
19
+ * so the placeholder is never evaluated.
20
+ *
21
+ * @see e2ee-worker/e2ee-worker-impl.ts - the entry point
22
+ * @see ../../plugins/rollup-plugin-inline-worker.ts - the Rollup plugin
23
+ * @see EncryptionManager.ts - consumes e2eeWorker
24
+ */
25
+ export function e2eeWorker() {
26
+ // will be populated at build time
27
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Throughput sample for one track. `userId` is the local sender on encode, the
3
+ * remote sender on decode. The (userId, trackType) pair keeps a peer's audio
4
+ * and video reported apart instead of summed.
5
+ */
6
+ export type TrackPerf = {
7
+ userId: string;
8
+ trackType: string;
9
+ fps: number;
10
+ maxCryptoMs: number;
11
+ };
12
+
13
+ /**
14
+ * Encode entries carry the `codec` the publisher knows; decode entries cannot,
15
+ * since a remote sender's codec is not reliably known locally.
16
+ */
17
+ export type PerfReport = {
18
+ encode: (TrackPerf & { codec: string })[];
19
+ decode: TrackPerf[];
20
+ };
21
+
22
+ /**
23
+ * Fired when a needed key is not held. The `keyIndex` tells the two cases apart:
24
+ *
25
+ * - Without it, the local encoder has no key and drops every outgoing frame.
26
+ * The host never provided a key, or a key import failed.
27
+ * - With it, a remote sender's frame named a key this peer does not hold, so
28
+ * the frame was dropped. This is normal while a key is in flight, or while a
29
+ * rotation propagates.
30
+ */
31
+ export type MissingKeyEvent = {
32
+ /** The local user, or the remote sender. */
33
+ userId: string;
34
+ /** Decode direction only; absent when the local encoder has no key. */
35
+ keyIndex?: number;
36
+ /**
37
+ * Decode direction only, which reports per track: a peer's tracks can sit on
38
+ * different key epochs, so one may stall while another plays. The encode
39
+ * direction stops every outgoing track at once and is reported per user.
40
+ */
41
+ trackType?: string;
42
+ };
43
+
44
+ /**
45
+ * A remote frame arrived unencrypted and went to the decoder as-is. Expected
46
+ * when the call's mode is `available` and that peer publishes plain; where every
47
+ * peer must encrypt, it means media renders without authentication.
48
+ */
49
+ export type UnencryptedFrameEvent = {
50
+ userId: string;
51
+ trackType?: string;
52
+ };
53
+
54
+ /**
55
+ * A remote frame carried this format's framing but a `version` this build
56
+ * cannot decrypt, so it was dropped.
57
+ *
58
+ * **This client is the one that needs updating**, not the peer: the peer wrote
59
+ * a newer format than this SDK implements. Prompt the local user to update the
60
+ * app; no key changes anything.
61
+ */
62
+ export type UnsupportedVersionEvent = {
63
+ userId: string;
64
+ trackType?: string;
65
+ /** The framing version read from the frame. */
66
+ version: number;
67
+ };
68
+
69
+ /** The worker could not decrypt a remote frame. Throttled per track. */
70
+ export type DecryptionFailedEvent = {
71
+ userId: string;
72
+ trackType?: string;
73
+ };
74
+
75
+ /**
76
+ * Fired when a track decrypts again after a reported failure. See
77
+ * {@link E2EEEventMap} for how it pairs with `e2ee.decryption_failed`.
78
+ */
79
+ export type DecryptionResumedEvent = {
80
+ userId: string;
81
+ trackType?: string;
82
+ };
83
+
84
+ /**
85
+ * An outgoing frame could not be encrypted, so that track publishes nothing.
86
+ * Latched per track, so a permanently failing track reports once.
87
+ */
88
+ export type EncryptionFailedEvent = {
89
+ userId: string;
90
+ /** Only this track is affected; the sender's others keep publishing. */
91
+ trackType?: string;
92
+ /** Short, human-readable reason. */
93
+ reason: string;
94
+ };
95
+
96
+ /**
97
+ * Fired when a remote track passes the internal failure tolerance: decryption
98
+ * has failed on that many consecutive frames, so the track renders nothing until
99
+ * it recovers. The cause is not known here: a key mismatch is the common one,
100
+ * but a tampered or truncated frame looks the same to the decryptor.
101
+ */
102
+ export type DecryptionStalledEvent = {
103
+ userId: string;
104
+ /** The keyIndex that crossed the tolerance. */
105
+ keyIndex: number;
106
+ trackType?: string;
107
+ };
108
+
109
+ /**
110
+ * Answer to {@link EncryptionManager.requestKeyState}. `fingerprint` is hex of
111
+ * the first 8 bytes of SHA-256(rawKey): not reversible, so safe to log. Key
112
+ * material is never returned.
113
+ */
114
+ export type KeyStateReport = {
115
+ perUserKeys: Array<{
116
+ userId: string;
117
+ keyIndex: number;
118
+ fingerprint: string;
119
+ }>;
120
+ /** Every shared receive epoch, including the one currently used to encode. */
121
+ sharedKeys: Array<{
122
+ keyIndex: number;
123
+ fingerprint: string;
124
+ isActive: boolean;
125
+ }>;
126
+ };
127
+
128
+ /**
129
+ * Events that the E2EE {@link EncryptionManager} emits.
130
+ *
131
+ * Subscribe with `manager.on(eventName, handler)`. To unsubscribe, call the
132
+ * function it returns, or `manager.off(eventName, handler)`.
133
+ *
134
+ * Every name follows the `e2ee.<snake_case>` convention. That makes them easy
135
+ * to grep, and keeps them distinct from SFU and coordinator events.
136
+ */
137
+ export type E2EEEventMap = {
138
+ /** Key mismatch, rotation in progress, or a tampered frame. */
139
+ 'e2ee.decryption_failed': DecryptionFailedEvent;
140
+
141
+ /**
142
+ * Pairs one-to-one with `e2ee.decryption_failed` and is never throttled, so a
143
+ * host can drive its UI from the pair alone. Also fires for a track that
144
+ * recovers on a new keyIndex.
145
+ */
146
+ 'e2ee.decryption_resumed': DecryptionResumedEvent;
147
+
148
+ /**
149
+ * Consecutive decrypt failures on one track crossed the internal tolerance.
150
+ * Fires once per (userId, keyIndex) entering that state, and
151
+ * `e2ee.decryption_resumed` is what clears it.
152
+ */
153
+ 'e2ee.decryption_stalled': DecryptionStalledEvent;
154
+
155
+ /** That track is publishing nothing. Latched, so it reports once. */
156
+ 'e2ee.encryption_failed': EncryptionFailedEvent;
157
+
158
+ /**
159
+ * The host must set or distribute a key. Distinct from
160
+ * `e2ee.encryption_failed`, where a key was present but the crypto threw.
161
+ * Throttled, and stops once the key arrives.
162
+ */
163
+ 'e2ee.missing_key': MissingKeyEvent;
164
+
165
+ 'e2ee.unencrypted_frame': UnencryptedFrameEvent;
166
+
167
+ /**
168
+ * A peer is publishing a framing version this build cannot read, so its
169
+ * frames are dropped. Throttled per track; surface it as a prompt to update
170
+ * **this** client.
171
+ */
172
+ 'e2ee.unsupported_version': UnsupportedVersionEvent;
173
+
174
+ /** Once per second while {@link EncryptionManager.enablePerformanceReporting} is on. */
175
+ 'e2ee.perf_report': PerfReport;
176
+
177
+ /** Answer to {@link EncryptionManager.requestKeyState}. */
178
+ 'e2ee.key_state': KeyStateReport;
179
+ };