@stream-io/video-client 1.57.0 → 1.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/dist/e2ee-worker.browser.es.js +1342 -0
- package/dist/e2ee-worker.browser.es.js.map +1 -0
- package/dist/index.browser.es.js +1141 -117
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +1196 -115
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.es.js +1174 -117
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +66 -9
- package/dist/src/StreamVideoClient.d.ts +9 -1
- package/dist/src/coordinator/connection/connection.d.ts +1 -1
- package/dist/src/devices/CameraManager.d.ts +8 -1
- package/dist/src/devices/DeviceManager.d.ts +2 -1
- package/dist/src/devices/MicrophoneManager.d.ts +8 -1
- package/dist/src/devices/SpeakerManager.d.ts +2 -1
- package/dist/src/events/participant.d.ts +10 -3
- package/dist/src/gen/coordinator/index.d.ts +1386 -240
- package/dist/src/gen/video/sfu/models/models.d.ts +8 -0
- package/dist/src/helpers/MediaStreamRecorder.d.ts +26 -0
- package/dist/src/helpers/TypedEventEmitter.d.ts +23 -0
- package/dist/src/helpers/concurrency.d.ts +8 -0
- package/dist/src/helpers/ensureExhausted.d.ts +1 -1
- package/dist/src/helpers/loopback.d.ts +55 -0
- package/dist/src/permissions/PermissionsContext.d.ts +2 -2
- package/dist/src/reporting/ClientEventReporter.d.ts +0 -2
- package/dist/src/rtc/BasePeerConnection.d.ts +3 -1
- package/dist/src/rtc/Subscriber.d.ts +5 -0
- package/dist/src/rtc/e2ee/E2EEManager.d.ts +12 -0
- package/dist/src/rtc/e2ee/EncryptionManager.d.ts +148 -0
- package/dist/src/rtc/e2ee/e2ee-worker.d.ts +25 -0
- package/dist/src/rtc/e2ee/events.d.ts +163 -0
- package/dist/src/rtc/e2ee/transformSupport.d.ts +21 -0
- package/dist/src/rtc/helpers/rtcConfiguration.d.ts +2 -2
- package/dist/src/rtc/index.d.ts +1 -0
- package/dist/src/rtc/mediaEngine.d.ts +23 -0
- package/dist/src/rtc/types.d.ts +2 -0
- package/dist/src/store/CallState.d.ts +21 -0
- package/dist/src/types.d.ts +15 -6
- package/index.ts +5 -0
- package/package.json +1 -1
- package/src/Call.ts +311 -155
- package/src/StreamVideoClient.ts +35 -8
- package/src/__tests__/Call.lifecycle.test.ts +33 -2
- package/src/__tests__/Call.publishing.test.ts +42 -1
- package/src/__tests__/Call.test.ts +15 -0
- package/src/__tests__/StreamVideoClient.api.test.ts +33 -0
- package/src/__tests__/StreamVideoClient.rewatch.test.ts +213 -0
- package/src/__tests__/StreamVideoClient.ringing.test.ts +11 -4
- package/src/__tests__/StreamVideoClient.test.ts +14 -12
- package/src/coordinator/connection/connection.ts +6 -3
- package/src/devices/AudioDeviceManager.ts +6 -0
- package/src/devices/CameraManager.ts +67 -8
- package/src/devices/DeviceManager.ts +24 -4
- package/src/devices/MicrophoneManager.ts +66 -8
- package/src/devices/ScreenShareManager.ts +3 -0
- package/src/devices/SpeakerManager.ts +42 -12
- package/src/devices/__tests__/CameraManager.test.ts +27 -1
- package/src/devices/__tests__/DeviceManager.test.ts +49 -1
- package/src/devices/__tests__/MicrophoneManager.test.ts +23 -1
- package/src/devices/__tests__/MicrophoneManagerRN.test.ts +1 -20
- package/src/devices/__tests__/SpeakerManager.test.ts +138 -42
- package/src/devices/__tests__/mocks.ts +36 -3
- package/src/events/__tests__/participant.test.ts +134 -1
- package/src/events/call.ts +6 -3
- package/src/events/callEventHandlers.ts +9 -3
- package/src/events/participant.ts +42 -7
- package/src/gen/coordinator/index.ts +1402 -236
- package/src/gen/video/sfu/models/models.ts +15 -0
- package/src/helpers/MediaStreamRecorder.ts +104 -0
- package/src/helpers/TypedEventEmitter.ts +71 -0
- package/src/helpers/__tests__/MediaStreamRecorder.test.ts +324 -0
- package/src/helpers/__tests__/TypedEventEmitter.test.ts +133 -0
- package/src/helpers/__tests__/concurrency.test.ts +74 -0
- package/src/helpers/__tests__/loopback.test.ts +367 -0
- package/src/helpers/concurrency.ts +24 -0
- package/src/helpers/ensureExhausted.ts +4 -2
- package/src/helpers/loopback.ts +200 -0
- package/src/permissions/PermissionsContext.ts +10 -5
- package/src/reporting/ClientEventReporter.ts +15 -28
- package/src/reporting/__tests__/ClientEventReporter.test.ts +3 -7
- package/src/rtc/BasePeerConnection.ts +12 -1
- package/src/rtc/Publisher.ts +8 -0
- package/src/rtc/Subscriber.ts +13 -0
- package/src/rtc/__tests__/Publisher.test.ts +101 -0
- package/src/rtc/__tests__/Subscriber.test.ts +190 -0
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +31 -0
- package/src/rtc/e2ee/E2EEManager.ts +12 -0
- package/src/rtc/e2ee/EncryptionManager.ts +336 -0
- package/src/rtc/e2ee/SPEC.md +586 -0
- package/src/rtc/e2ee/__tests__/E2EEManager.contract.test.ts +39 -0
- package/src/rtc/e2ee/__tests__/EncryptionManager.test.ts +490 -0
- package/src/rtc/e2ee/__tests__/codec.test.ts +247 -0
- package/src/rtc/e2ee/__tests__/conformance.test.ts +184 -0
- package/src/rtc/e2ee/__tests__/failureTracker.test.ts +45 -0
- package/src/rtc/e2ee/__tests__/frameCounter.test.ts +90 -0
- package/src/rtc/e2ee/__tests__/keyStore.test.ts +221 -0
- package/src/rtc/e2ee/__tests__/notifications.test.ts +166 -0
- package/src/rtc/e2ee/__tests__/perf.test.ts +139 -0
- package/src/rtc/e2ee/__tests__/queue.test.ts +61 -0
- package/src/rtc/e2ee/__tests__/replayWindow.test.ts +133 -0
- package/src/rtc/e2ee/__tests__/trailer.test.ts +143 -0
- package/src/rtc/e2ee/__tests__/transform-pipeline.test.ts +839 -0
- package/src/rtc/e2ee/__tests__/transformSupport.test.ts +65 -0
- package/src/rtc/e2ee/e2ee-worker/codec.ts +220 -0
- package/src/rtc/e2ee/e2ee-worker/constants.ts +47 -0
- package/src/rtc/e2ee/e2ee-worker/decode.ts +159 -0
- package/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts +137 -0
- package/src/rtc/e2ee/e2ee-worker/encode.ts +147 -0
- package/src/rtc/e2ee/e2ee-worker/failureTracker.ts +26 -0
- package/src/rtc/e2ee/e2ee-worker/frameCounter.ts +37 -0
- package/src/rtc/e2ee/e2ee-worker/keyStore.ts +211 -0
- package/src/rtc/e2ee/e2ee-worker/notifications.ts +190 -0
- package/src/rtc/e2ee/e2ee-worker/perf.ts +129 -0
- package/src/rtc/e2ee/e2ee-worker/queue.ts +12 -0
- package/src/rtc/e2ee/e2ee-worker/replayWindow.ts +131 -0
- package/src/rtc/e2ee/e2ee-worker/trailer.ts +155 -0
- package/src/rtc/e2ee/e2ee-worker/tsconfig.json +8 -0
- package/src/rtc/e2ee/e2ee-worker/types.ts +29 -0
- package/src/rtc/e2ee/e2ee-worker.ts +27 -0
- package/src/rtc/e2ee/events.ts +179 -0
- package/src/rtc/e2ee/transformSupport.ts +35 -0
- package/src/rtc/helpers/rtcConfiguration.ts +4 -2
- package/src/rtc/index.ts +1 -0
- package/src/rtc/mediaEngine.ts +44 -0
- package/src/rtc/types.ts +2 -0
- package/src/store/CallState.ts +33 -1
- package/src/store/__tests__/CallState.test.ts +82 -1
- 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,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
|
+
};
|