@stream-io/video-client 1.56.0 → 1.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/e2ee-worker.browser.es.js +1342 -0
  3. package/dist/e2ee-worker.browser.es.js.map +1 -0
  4. package/dist/index.browser.es.js +867 -71
  5. package/dist/index.browser.es.js.map +1 -1
  6. package/dist/index.cjs.js +911 -70
  7. package/dist/index.cjs.js.map +1 -1
  8. package/dist/index.d.ts +3 -0
  9. package/dist/index.es.js +900 -71
  10. package/dist/index.es.js.map +1 -1
  11. package/dist/src/Call.d.ts +52 -6
  12. package/dist/src/coordinator/connection/connection.d.ts +1 -1
  13. package/dist/src/devices/CameraManager.d.ts +6 -0
  14. package/dist/src/devices/DeviceManager.d.ts +1 -0
  15. package/dist/src/devices/MicrophoneManager.d.ts +6 -0
  16. package/dist/src/devices/SpeakerManager.d.ts +2 -1
  17. package/dist/src/events/participant.d.ts +10 -3
  18. package/dist/src/gen/coordinator/index.d.ts +1363 -243
  19. package/dist/src/gen/video/sfu/models/models.d.ts +8 -0
  20. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +4 -0
  21. package/dist/src/helpers/TypedEventEmitter.d.ts +23 -0
  22. package/dist/src/helpers/ensureExhausted.d.ts +1 -1
  23. package/dist/src/permissions/PermissionsContext.d.ts +2 -2
  24. package/dist/src/rtc/BasePeerConnection.d.ts +3 -1
  25. package/dist/src/rtc/e2ee/E2EEManager.d.ts +12 -0
  26. package/dist/src/rtc/e2ee/EncryptionManager.d.ts +148 -0
  27. package/dist/src/rtc/e2ee/e2ee-worker.d.ts +25 -0
  28. package/dist/src/rtc/e2ee/events.d.ts +163 -0
  29. package/dist/src/rtc/e2ee/transformSupport.d.ts +21 -0
  30. package/dist/src/rtc/helpers/rtcConfiguration.d.ts +2 -2
  31. package/dist/src/rtc/index.d.ts +1 -0
  32. package/dist/src/rtc/mediaEngine.d.ts +23 -0
  33. package/dist/src/rtc/types.d.ts +2 -0
  34. package/dist/src/store/CallState.d.ts +21 -0
  35. package/dist/src/types.d.ts +15 -6
  36. package/index.ts +3 -0
  37. package/package.json +11 -11
  38. package/src/Call.ts +160 -26
  39. package/src/__tests__/Call.test.ts +15 -0
  40. package/src/coordinator/connection/connection.ts +6 -3
  41. package/src/devices/AudioDeviceManager.ts +6 -0
  42. package/src/devices/CameraManager.ts +51 -4
  43. package/src/devices/DeviceManager.ts +17 -0
  44. package/src/devices/MicrophoneManager.ts +55 -5
  45. package/src/devices/ScreenShareManager.ts +3 -0
  46. package/src/devices/SpeakerManager.ts +42 -12
  47. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +2 -22
  48. package/src/devices/__tests__/SpeakerManager.test.ts +138 -42
  49. package/src/devices/__tests__/mocks.ts +36 -3
  50. package/src/devices/devicePersistence.ts +1 -2
  51. package/src/events/__tests__/participant.test.ts +134 -1
  52. package/src/events/callEventHandlers.ts +9 -3
  53. package/src/events/participant.ts +42 -7
  54. package/src/gen/coordinator/index.ts +1384 -244
  55. package/src/gen/video/sfu/models/models.ts +15 -0
  56. package/src/helpers/MediaPlaybackWatchdog.ts +35 -16
  57. package/src/helpers/TypedEventEmitter.ts +71 -0
  58. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +137 -8
  59. package/src/helpers/__tests__/TypedEventEmitter.test.ts +133 -0
  60. package/src/helpers/__tests__/ViewportTracker.test.ts +1 -2
  61. package/src/helpers/ensureExhausted.ts +4 -2
  62. package/src/permissions/PermissionsContext.ts +10 -5
  63. package/src/reporting/ClientEventReporter.ts +2 -8
  64. package/src/rtc/BasePeerConnection.ts +12 -1
  65. package/src/rtc/Publisher.ts +8 -0
  66. package/src/rtc/Subscriber.ts +5 -0
  67. package/src/rtc/__tests__/Publisher.test.ts +101 -0
  68. package/src/rtc/__tests__/Subscriber.test.ts +89 -0
  69. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +31 -0
  70. package/src/rtc/e2ee/E2EEManager.ts +12 -0
  71. package/src/rtc/e2ee/EncryptionManager.ts +336 -0
  72. package/src/rtc/e2ee/SPEC.md +586 -0
  73. package/src/rtc/e2ee/__tests__/E2EEManager.contract.test.ts +39 -0
  74. package/src/rtc/e2ee/__tests__/EncryptionManager.test.ts +490 -0
  75. package/src/rtc/e2ee/__tests__/codec.test.ts +247 -0
  76. package/src/rtc/e2ee/__tests__/conformance.test.ts +184 -0
  77. package/src/rtc/e2ee/__tests__/failureTracker.test.ts +45 -0
  78. package/src/rtc/e2ee/__tests__/frameCounter.test.ts +90 -0
  79. package/src/rtc/e2ee/__tests__/keyStore.test.ts +221 -0
  80. package/src/rtc/e2ee/__tests__/notifications.test.ts +166 -0
  81. package/src/rtc/e2ee/__tests__/perf.test.ts +139 -0
  82. package/src/rtc/e2ee/__tests__/queue.test.ts +61 -0
  83. package/src/rtc/e2ee/__tests__/replayWindow.test.ts +133 -0
  84. package/src/rtc/e2ee/__tests__/trailer.test.ts +143 -0
  85. package/src/rtc/e2ee/__tests__/transform-pipeline.test.ts +839 -0
  86. package/src/rtc/e2ee/__tests__/transformSupport.test.ts +65 -0
  87. package/src/rtc/e2ee/e2ee-worker/codec.ts +220 -0
  88. package/src/rtc/e2ee/e2ee-worker/constants.ts +47 -0
  89. package/src/rtc/e2ee/e2ee-worker/decode.ts +159 -0
  90. package/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts +137 -0
  91. package/src/rtc/e2ee/e2ee-worker/encode.ts +147 -0
  92. package/src/rtc/e2ee/e2ee-worker/failureTracker.ts +26 -0
  93. package/src/rtc/e2ee/e2ee-worker/frameCounter.ts +37 -0
  94. package/src/rtc/e2ee/e2ee-worker/keyStore.ts +211 -0
  95. package/src/rtc/e2ee/e2ee-worker/notifications.ts +190 -0
  96. package/src/rtc/e2ee/e2ee-worker/perf.ts +129 -0
  97. package/src/rtc/e2ee/e2ee-worker/queue.ts +12 -0
  98. package/src/rtc/e2ee/e2ee-worker/replayWindow.ts +131 -0
  99. package/src/rtc/e2ee/e2ee-worker/trailer.ts +155 -0
  100. package/src/rtc/e2ee/e2ee-worker/tsconfig.json +8 -0
  101. package/src/rtc/e2ee/e2ee-worker/types.ts +29 -0
  102. package/src/rtc/e2ee/e2ee-worker.ts +27 -0
  103. package/src/rtc/e2ee/events.ts +179 -0
  104. package/src/rtc/e2ee/transformSupport.ts +35 -0
  105. package/src/rtc/helpers/rtcConfiguration.ts +4 -2
  106. package/src/rtc/index.ts +1 -0
  107. package/src/rtc/mediaEngine.ts +44 -0
  108. package/src/rtc/types.ts +3 -2
  109. package/src/stats/rtc/types.ts +8 -2
  110. package/src/store/CallState.ts +34 -3
  111. package/src/store/__tests__/CallState.test.ts +82 -1
  112. package/src/types.ts +24 -9
@@ -0,0 +1,586 @@
1
+ # Stream Video E2EE: cross-SDK implementation spec
2
+
3
+ **Status:** draft, derived from the JS reference implementation in `packages/client/src/rtc/e2ee`.
4
+ **Audience:** SDK teams implementing E2EE for iOS, Android, Flutter, Unity.
5
+ **Goal:** every SDK exposes the same public API and produces byte-identical frames, so any two platforms interoperate in the same call.
6
+
7
+ ---
8
+
9
+ ## 1. Overview
10
+
11
+ Media frames are encrypted with **AES-GCM** inside a WebRTC _encoded transform_ (frame-level hook between encoder and packetizer, and between depacketizer and decoder). The SFU forwards ciphertext and never holds a key.
12
+
13
+ There is **one on-wire scheme**, `version = 1`, used by every supported codec (Opus, VP8, VP9, H.264):
14
+
15
+ ```
16
+ [ clear header ][ AES-GCM ciphertext + tag ][ 20-byte trailer ]
17
+ ```
18
+
19
+ **AV1 is not supported.**
20
+
21
+ **Endianness:** all multi-byte integers are **big-endian**.
22
+
23
+ ---
24
+
25
+ ## 2. Public API contract
26
+
27
+ Every SDK should expose the same shape. Names may be idiomatic per platform (`isSupported` -> `isSupported()`, `is_supported`, etc.), but semantics must match.
28
+
29
+ ```ts
30
+ // capability check, before showing any E2EE UI
31
+ EncryptionManager.isSupported(): boolean
32
+
33
+ // construction; binds the manager to the local user
34
+ EncryptionManager.create(userId, {
35
+ algorithm?: 'AES-128-GCM' | 'AES-256-GCM', // default AES-128-GCM
36
+ }): Promise<EncryptionManager>
37
+
38
+ // key distribution (host-owned: the SDK never derives or exchanges keys)
39
+ setKey(userId: string, keyIndex: number, rawKey: bytes): void
40
+ removeKey(userId: string, keyIndex: number): void
41
+ removeAllKeys(userId: string): void
42
+
43
+ setSharedKey(keyIndex: number, rawKey: bytes): void
44
+ removeSharedKey(keyIndex: number): void
45
+
46
+ // diagnostics
47
+ enablePerformanceReporting(enabled: boolean): void
48
+ requestKeyState(): void
49
+
50
+ // lifecycle
51
+ dispose(): void
52
+
53
+ // events (see §10)
54
+ on(event, handler) / off(event, handler)
55
+ ```
56
+
57
+ Wiring into a call:
58
+
59
+ ```ts
60
+ import { EncryptionManager } from '@stream-io/video-react-sdk';
61
+
62
+ const call = client.call(type, id);
63
+ if (EncryptionManager.isSupported()) {
64
+ const e2ee = await EncryptionManager.create(call.currentUserId);
65
+ e2ee.setSharedKey(0, keyBytes);
66
+ call.setE2EEManager(e2ee); // MUST be called before join()
67
+ }
68
+
69
+ await call.join();
70
+ ```
71
+
72
+ ### Rules
73
+
74
+ - **`create` throws when E2EE cannot run**, rather than degrading: no Encoded Transform API in this browser, or the worker could not be constructed (on web, a CSP `worker-src` that omits `blob:` is the common cause). Guard with `isSupported()` and handle the rejection. There is deliberately no silent-fallback mode, because falling back means publishing cleartext on a call the user was told is encrypted.
75
+ - **`setE2EEManager` before `join`.** The peer connections must be created with the transform hook in place. Calling it after join throws.
76
+ - **`keyIndex` is 0-255** (one trailer byte). Reject anything else at the API boundary. The index identifies a key slot, not a monotonic sequence, so a long-running rotation may wrap 255 → 0 and re-use low indices. Reusing an occupied index replaces that slot immediately, so the host must not reuse it until frames from the old epoch can no longer be in flight.
77
+ - **`rawKey` is exactly 16 bytes** (AES-128) or **32 bytes** (AES-256). Reject other lengths.
78
+ - **The key buffer is copied, not consumed.** Callers may re-import the same bytes.
79
+ - **Removal comes in per-epoch and per-user forms, and the names must stay distinct.** `removeKey(userId, keyIndex)` retires one epoch; `removeAllKeys(userId)` revokes the participant entirely. Do not render these as overloads of one name: dropping the index argument would then silently mean "revoke everything", which is the opposite of the caller's intent and impossible to catch in review.
80
+ - **Every key operation's outcome is tabulated in Appendix B.** Implement the edge cases (removing the latest or active epoch, a failed import, an absent index) from that table rather than from prose.
81
+ - **Join request carries `e2ee: true`** so the backend knows the call is encrypted.
82
+ - The internal attach points (`encrypt(sender, codec, trackType)` / `decrypt(receiver, userId, trackType)`) are called by the RTC layer, not by apps. Keeping them behind a small interface lets an integrator plug in a different scheme (e.g. SFrame).
83
+
84
+ ---
85
+
86
+ ## 3. Key management and identity
87
+
88
+ A per-user key is identified by **`(userId, keyIndex)`**. A shared key is identified by
89
+ **`keyIndex`**.
90
+
91
+ - **Per-user keys** (`setKey`): the encoder looks up the local user's latest key; each decoder looks up the remote sender's key by the `keyIndex` carried in the frame.
92
+ - **Shared keys** (`setSharedKey`): an indexed fallback receive-key ring used for any user without a per-user key at the requested index. The most recently imported shared epoch is explicitly active for encryption; older epochs remain receive-only so delayed frames survive a rotation.
93
+ - **Resolution on decode:** per-user entry at that `keyIndex` first; else the shared-key entry at that index; else no key (frame dropped, `missing_key` fired).
94
+ - **Resolution on encode:** the most recently imported per-user key for the local user; else the active shared key. Retained inactive shared epochs must never be selected for encryption.
95
+ - **No key operation resets the frame counter** (§9).
96
+
97
+ The state after any individual key call is tabulated in **Appendix B**.
98
+
99
+ ### 3.1 Rotation
100
+
101
+ The host owns the grace period and retires old epochs explicitly. Both key kinds follow the same shape - import the new epoch, let frames on the old one drain, then retire it:
102
+
103
+ ```ts
104
+ await api.distributeToAll(nextKey);
105
+ manager.setSharedKey(7, nextKey); // epoch 7 becomes active; older epochs remain
106
+ // allow delayed frames on epoch 6 to drain
107
+ manager.removeSharedKey(6); // epoch 6 can no longer decrypt
108
+
109
+ await api.distributeToAll(nextAliceKey);
110
+ manager.setKey(alice, 7, nextAliceKey); // epoch 7 becomes alice's latest
111
+ manager.removeKey(alice, 6); // alice's epoch 6 can no longer decrypt
112
+ ```
113
+
114
+ Retiring one epoch is what `removeKey` is for; `removeAllKeys` is for a participant leaving. Reaching for `removeAllKeys` mid-rotation would drop the epoch that is currently working along with the one being retired.
115
+
116
+ All participants must use the same `keyIndex` for the same shared key. Do not reuse an
117
+ index while frames encrypted with its previous material may still arrive: the new import
118
+ replaces that slot, so those delayed frames would resolve the replacement key and fail
119
+ authentication.
120
+
121
+ ### 3.2 Per-import state
122
+
123
+ Every key import generates:
124
+
125
+ - a fresh **random 8-byte `ivPrefix`** (sender-side only), and
126
+ - an 8-byte **fingerprint** = first 8 bytes of `SHA-256(rawKey)`, for diagnostics only.
127
+
128
+ The fresh prefix per import is what makes re-importing the same raw key safe: it cannot reproduce an `(ivPrefix, counter)` pair from an earlier import. **Receivers never consult a local prefix** - they read it from the frame.
129
+
130
+ The prefix must come from a **cryptographic RNG**, never from a user id, session id, timestamp or counter. Under a shared key it is the only thing separating one participant's IVs from another's (§9 rule 5, Appendix A.2).
131
+
132
+ ---
133
+
134
+ ## 4. Crypto primitives
135
+
136
+ - **Cipher:** AES-GCM, 128-bit tag (the default; the tag is appended to the ciphertext).
137
+ - **Key sizes:** 128-bit or 256-bit, selected at manager construction.
138
+ - **IV:** 12 bytes, `ivPrefix (8) || frameCounter (4, big-endian)`.
139
+ - **Frame counter:** a single monotonic counter per manager, **shared across all of the local user's tracks and codecs**. Starts at 0, first frame uses 1.
140
+ - **AAD:** the frame's clear header. Authenticated, not encrypted - see §5.3.
141
+
142
+ > **IV-uniqueness contract.** For a given key, never encrypt two different frames under the same **IV**, where `IV = ivPrefix ∥ counter`. Reusing one is catastrophic under AES-GCM.
143
+ >
144
+ > Note the invariant is on the IV, not on the counter. **`(key, counter)` repeats constantly and that is expected**: under a shared key every participant starts at counter 1, as does the same user on a second device, and so does any sender that reconnects with a fresh manager. Two mechanisms cover two different scopes, and both are load-bearing:
145
+ >
146
+ > - **Within one sender**, the monotonic counter separates frames. It must be **one counter for the whole manager, not one per track** - a per-track counter would let one user's audio and video frames land on the same counter under the same key.
147
+ > - **Between senders**, the counter separates nothing, because they all start near 0. Only the random per-import `ivPrefix` keeps them apart, which is why §9 rule 5 requires a cryptographic RNG (Appendix A.2).
148
+
149
+ ---
150
+
151
+ ## 5. Trailer format (`version = 1`)
152
+
153
+ Applies to Opus, VP8, VP9, H.264, and audio frames whose codec was not supplied. Video with no codec fails closed (§5.1).
154
+
155
+ ```
156
+ +------------------+----------------------------+-------------------+
157
+ | clear header | AES-GCM ciphertext + tag | trailer (20 B) |
158
+ | (clearBytes) | | |
159
+ +------------------+----------------------------+-------------------+
160
+ |
161
+ +-- plaintext, and passed as AAD (§5.3)
162
+ ```
163
+
164
+ ### 5.1 Clear bytes per codec
165
+
166
+ The leading `clearBytes` bytes stay in plaintext so the SFU can still read frame headers (keyframe detection, layer selection). They are passed as AAD, so the SFU can read them but cannot alter them undetected (§5.3).
167
+
168
+ | Codec (`codec` string) | Clear bytes |
169
+ | ---------------------------- | ------------------------------------------------------------------------------------------------ |
170
+ | `opus` / any audio frame | **1** (the Opus TOC byte) |
171
+ | `vp8` | **10** on keyframes, **3** on delta frames |
172
+ | `vp9` | **10** on keyframes, **3** on delta frames |
173
+ | `h264` | offset of the first slice NALU's **header byte + 2** (see §5.4); **0** if no slice NALU is found |
174
+ | codec not supplied/supported | **fail closed**: drop the frame, emit `encryption_failed` |
175
+
176
+ **Clamp `clearBytes` to the frame length, in every rule.** A frame shorter than its nominal clear header is rare but not impossible, and the clamp is part of the wire format rather than a defensive nicety: an SDK that omits it computes a larger `clearBytes` than one that applies it, so the two sides build AADs of different **lengths**, and a length mismatch fails the tag every time (§5.3, consequence 2). The encoder would also read past the end of the frame to assemble the header.
177
+
178
+ ### 5.2 Trailer layout (20 bytes, appended at the end of the frame)
179
+
180
+ | Offset | Size | Field | Notes |
181
+ | ------ | ---- | --------------------- | -------------------------------------------------------- |
182
+ | 0 | 4 | `frameCounter` | big-endian, low 32 bits of the IV |
183
+ | 4 | 8 | `ivPrefix` | sender's random prefix for this key import |
184
+ | 12 | 1 | `keyIndex` | 0-255 |
185
+ | 13 | 2 | `clearBytes \| flags` | bit 15 = `RBSP_FLAG`, bits 0-14 = clearBytes (max 32767) |
186
+ | 15 | 1 | `version` | `0x01`; frozen position, see below |
187
+ | 16 | 4 | `magic` | `0xE2EEFEED`; frozen position, see below |
188
+
189
+ **Overhead:** 16 (GCM tag) + 20 (trailer) = **36 bytes per frame**, plus RBSP escape bytes for H.264.
190
+
191
+ **The identification suffix is frozen across versions.** The last 5 bytes of the frame are `version ∥ magic` in **every** version of this format, present and future, and are read relative to the **end of the frame** rather than to a trailer start. That is what lets a receiver recognize "encrypted, but written by a version I do not implement" without understanding the trailer that carries it (§12), and it is why a future version may lengthen the trailer without stranding older receivers.
192
+
193
+ ### 5.3 AAD (Additional Authenticated Data)
194
+
195
+ AES-GCM is an AEAD cipher: it takes a third input alongside the key and the plaintext, called the _additional authenticated data_. The AAD is **covered by the authentication tag but not encrypted**. It is also **not part of the output** - GCM does not transmit it. Both sides must supply the identical bytes independently, or the tag check fails.
196
+
197
+ ```
198
+ AAD = frame[0 .. clearBytes) # the clear header; empty when clearBytes == 0
199
+ plaintext = frame[clearBytes .. end]
200
+ ```
201
+
202
+ The receiver does not need the AAD delivered separately: it is the literal first `clearBytes` bytes of the frame it just received, and `clearBytes` is in the trailer.
203
+
204
+ **Why the clear header is AAD and not just plaintext.** The SFU must read codec headers to detect keyframes and select layers, so those bytes cannot be encrypted. But leaving them merely unencrypted would let any relay rewrite them undetected - flip a frame's keyframe bit and the receiver's decoder desynchronises. Making them AAD gives the SFU **read access without write access**: it can parse the header, and any modification to it breaks the tag on the very next decrypt.
205
+
206
+ **Three consequences implementers must respect:**
207
+
208
+ 1. **Byte-exact agreement.** One differing byte in the AAD makes decryption fail. There is no partial match.
209
+ 2. **Length is part of the agreement.** An AAD of a different _length_ fails just as hard as different content.
210
+ 3. **Empty AAD is normal**, not an error case. It happens on H.264 with no slice NALU, and on video with a codec the encoder does not recognize. Pass a zero-length buffer; in GCM that is equivalent to supplying no AAD at all, so either form interoperates.
211
+
212
+ **What is not in the AAD.** The 20-byte trailer is excluded. Every field except `version` and `magic` still fails closed:
213
+
214
+ | Tampered field | Result |
215
+ | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
216
+ | `frameCounter`, `ivPrefix` | different IV, tag fails, frame dropped |
217
+ | `keyIndex` | wrong key or no key, tag fails or `missing_key`, frame dropped |
218
+ | `clearBytes` / RBSP flag | different AAD and a different ciphertext split, tag fails, frame dropped |
219
+ | `version`, `magic` | the frame is no longer recognized as encrypted. It is **forwarded to the decoder as ciphertext** and reported as `unencrypted_frame` (§7) |
220
+
221
+ The last row is the one worth knowing: it is not a decryption failure, it is a downgrade. Media renders as garbage rather than being dropped.
222
+
223
+ ### 5.4 H.264 quirk: RBSP escaping
224
+
225
+ **Problem.** Ciphertext is uniformly random and will contain `00 00 01` / `00 00 00 01` byte runs. `libwebrtc` H.264 packetizer scans the frame for Annex-B start codes and would split it at those false boundaries, destroying the frame.
226
+
227
+ **Rule.** For H.264 with `clearBytes > 0`, apply RBSP emulation-prevention over **ciphertext and trailer as one contiguous stream** before appending:
228
+
229
+ - Insert `0x03` after any `00 00` run when the next byte is `0x00`-`0x03`.
230
+ - **Seed the escaper with the clear header's trailing `0x00` count, capped at 2.** The packetizer scans the whole frame, and the header's tail and the escaped stream are contiguous on the wire: a header ending in `0x00` followed by ciphertext starting `00 01` would otherwise form a start code across the boundary that the escaper never saw. Browser encoders do not produce a header ending in `0x00` (the last clear byte is the first slice-header byte, and `first_mb_in_slice = 0` forces its top bit on), but multi-slice hardware encoders can, so the seed is part of the wire format. Both sides derive it from the same clear bytes; nothing extra travels in the frame.
231
+ - Set `RBSP_FLAG` (bit 15 of the `clearBytes` field).
232
+ - Frame becomes `[clear header][ escaped(ciphertext || trailer) ]`.
233
+
234
+ **Why the trailer is escaped with the ciphertext, yet still readable.** The last **7** trailer bytes (`clearBytes|flag`, `version`, `magic`) are start-code-safe by construction: the RBSP flag forces the `clearBytes` high byte to `>= 0x80`, which breaks any zero run reaching them. They therefore pass through escaping unchanged, and the decoder can read them straight off the raw frame tail to discover `clearBytes` and the RBSP flag. The first 13 bytes (`frameCounter`, `ivPrefix`, `keyIndex`) sit **inside** the escaped region and are only valid after un-escaping.
235
+
236
+ **Decode order for an RBSP frame:**
237
+
238
+ 1. Read the raw tail to get `clearBytes`, `isRbsp`, `version`, `magic`.
239
+ 2. Un-escape `frame[clearBytes .. end]`, seeded with the clear header's trailing `0x00` count (capped at 2) exactly like the encoder, so an escape byte sitting right at the boundary is recognized.
240
+ 3. If the un-escaped unit is shorter than 20 bytes, **drop the frame** (both `clearBytes` and the flag are plaintext, so a relay can forge exactly this shape).
241
+ 4. Re-read `frameCounter` / `ivPrefix` / `keyIndex` from the un-escaped unit's last 20 bytes.
242
+ 5. Ciphertext is the un-escaped unit minus its last 20 bytes.
243
+
244
+ **Finding the slice NALU.** Walk Annex-B start codes; the first NALU whose `nal_unit_type` (`byte & 0x1F`) is **1** (non-IDR slice) or **5** (IDR slice) ends the clear header at `startCodePos + startCodeLen + 2`, clamped to the frame length. That keeps the start code, the NALU header byte, and one byte of slice header in the clear.
245
+
246
+ > **Known limitation.** If no slice NALU is found, `clearBytes` is 0 and escaping is skipped, so the ciphertext may contain false start codes. In practice every encoder-emitted frame contains a slice NALU.
247
+
248
+ ---
249
+
250
+ ## 6. Encode algorithm
251
+
252
+ ```
253
+ on frame:
254
+ if frame.payload is empty: forward unchanged
255
+ key, keyIndex, ivPrefix = latestKeyFor(localUserId)
256
+ if no key: drop frame, emit missing_key (throttled); return
257
+
258
+ // A key/delta type marks video. Checked before the counter, so a dropped
259
+ // frame costs no IV.
260
+ if profileFor(codec).audioOnly and frame.type is set:
261
+ drop, emit encryption_failed; return
262
+
263
+ clearBytes = clearBytesFor(codec, frame.type, frame.data)
264
+ if clearBytes > 0x7FFF: drop, emit encryption_failed; return
265
+ isRbsp = isH264 && clearBytes > 0
266
+ counter = nextFrameCounter() // may throw at the ceiling
267
+ iv = ivPrefix || counter
268
+ aad = frame[0 .. clearBytes)
269
+ ct = AES-GCM-encrypt(key, iv, aad, frame[clearBytes ..])
270
+ trailer = writeTrailer(counter, ivPrefix, keyIndex, clearBytes, isRbsp)
271
+ out = isRbsp ? aad || rbspEscape(ct || trailer, seed = trailingZeros(aad, max 2))
272
+ : aad || ct || trailer
273
+ forward out
274
+ ```
275
+
276
+ **Fail closed, always.** Any error on the encode path drops the frame. A frame is never forwarded in the clear because encryption failed.
277
+
278
+ **Unsupported codecs.** A named codec with no clear-byte rule (AV1 today, H.265, anything unrecognized) installs a transform that drops every frame and emits `encryption_failed` once. This is the safety net, not the plan: the SFU must not negotiate AV1 on an encrypted call, because the net costs the track entirely.
279
+
280
+ ## 7. Decode algorithm
281
+
282
+ The decoder is **codec-agnostic**: the format is self-describing. Do not pass a codec hint to the decode transform.
283
+
284
+ ```
285
+ on frame:
286
+ if frame.payload is empty: forward unchanged
287
+
288
+ trailer = readTrailer(frame) // magic == 0xE2EEFEED && version == 1
289
+ if trailer == null:
290
+ // Two different conditions; the frozen suffix (§5.2) tells them apart.
291
+ v = readFramingVersion(frame) // version byte iff magic matches, else null
292
+ if v != null and v != 1:
293
+ drop, emit unsupported_version(v) (throttled per track); return
294
+ emit unencrypted_frame (throttled); forward unchanged; return
295
+ -> recover ciphertext + IV fields (RBSP path per §5.4)
296
+ -> decode with (keyIndex, ivPrefix, frameCounter)
297
+
298
+ decode with (keyIndex, ivPrefix, counter):
299
+ key = resolveKey(senderUserId, keyIndex)
300
+ if no key: drop, emit missing_key(keyIndex) (throttled); return
301
+ if not replayWindow.peek(counter, ivPrefix): drop silently; return
302
+ try:
303
+ plaintext = decrypt(...)
304
+ replayWindow.commit(counter, ivPrefix) // only after authentication
305
+ failures.clearFailures(keyIndex) // gates `decryption_stalled`, nothing else
306
+ emit decryption_resumed // unthrottled and paired; §10
307
+ forward clearHeader || plaintext
308
+ catch:
309
+ if failures.recordFailure(keyIndex) crosses tolerance: emit decryption_stalled
310
+ emit decryption_failed (throttled)
311
+ drop
312
+ ```
313
+
314
+ **`readTrailer` validation order:** length >= 20, then `magic == 0xE2EEFEED`, then `version == 1`, then `clearBytes <= frameLength - 20`. Any mismatch means "not a v1 trailer" - never attempt a decrypt.
315
+
316
+ **An unknown version is not a decryption failure, and not a cleartext frame either.** It gets its own disposition: drop the frame and emit `unsupported_version` carrying the observed version. Dropping rather than forwarding is the point - forwarding hands ciphertext to the decoder, which renders as corruption and reads to the host as a downgrade, when the actual condition is a peer on a newer SDK and the remedy is updating this client. Treating it as a decryption failure would be equally wrong: no key can fix it.
317
+
318
+ Everything else that fails `readTrailer` (short frame, magic mismatch, `clearBytes` overrunning the body) stays "not ours" and is forwarded as cleartext with `unencrypted_frame`. That is what keeps an unrelated frame which happens to end in `0xE2EEFEED` from producing spurious failures.
319
+
320
+ The magic is a heuristic, not a guarantee: any 4-byte value collides with random data at 2^-32. `0xE2EEFEED` is chosen to be an unlikely accident rather than a common one - a widespread debug fill such as `0xDEADBEEF` shows up in real buffers far more often than a value nothing else uses. No byte of it is `0x00` or `<= 0x03` either, which is what keeps the start-code-safe trailer tail safe (§5.4).
321
+
322
+ > **Consequence worth knowing.** The magic still collides with random data at 2^-32, so a cleartext frame can be mistaken for ours. If its version byte happens not to be 1 it is dropped rather than forwarded, at that same rate - no worse than the existing behavior for a collision whose version byte _is_ 1, which reaches a decrypt and fails.
323
+
324
+ ---
325
+
326
+ ## 8. Receiver hardening
327
+
328
+ ### Trust ordering (the SFrame / SRTP rule)
329
+
330
+ Everything read before the decrypt call (`frameCounter`, `ivPrefix`, `keyIndex`, `clearBytes`, the RBSP flag) is **plaintext and forgeable by a relay**. Nothing may mutate trust state until GCM authenticates the frame.
331
+
332
+ - The replay window is **peeked** before decrypt and **committed** only after success.
333
+ - The failure counter is diagnostic only. It gates the `decryption_stalled` signal; it never gates a decrypt attempt. A burst of forged frames must not be able to latch a genuine key invalid.
334
+
335
+ ### Replay window
336
+
337
+ Scoped **per remote track**, not per user. Remote tracks travel on independent SSRCs with independent jitter buffers; a shared window would let one track's delivery skew reject the other's frames.
338
+
339
+ - **Window:** 1024 frames, RFC 6479-style sliding bitmap over the counter.
340
+ - **Accept** if the counter is above the high-water mark, or within the window and not already seen. **Reject** if `counter <= highest - 1024` or already seen.
341
+ - Rejections are **silent drops**, not decryption failures.
342
+ - Within a track, the window is partitioned by **sender `ivPrefix` ("epochs")**. A sender restart or key re-import brings a fresh prefix and a counter that restarts low; a fresh epoch gives it a clean window instead of rejecting it against a stale high-water mark.
343
+ - Keep at most **3 epochs**, most-recent first, evicting the oldest. Epochs are created and evicted **only by `commit`**, i.e. only by authenticated frames, so a relay cannot fabricate novel-prefix frames to evict a genuine epoch.
344
+
345
+ ### Failure tolerance
346
+
347
+ Consecutive decryption failures are counted **per track, per `keyIndex`**. After **10** consecutive failures, the 11th fires `decryption_stalled` exactly once per failure run. A successful decrypt clears the count for that `keyIndex`, and also fires `decryption_resumed` - but keep the two independent. The count gates `decryption_stalled` and nothing else; the recovery is gated separately, on whether a failure was ever delivered to the host (§10).
348
+
349
+ Per-track scoping is load-bearing: a counter shared across a user's tracks lets one track's healthy frames reset another's failures, so the threshold is never crossed and `decryption_stalled` can never fire.
350
+
351
+ ### Throttling
352
+
353
+ The _level_ notifications (`missing_key`, `decryption_failed`, `unencrypted_frame`) are throttled to **at most one per second** per key (per user, or per keyIndex where noted), so a sustained failure cannot flood the host.
354
+
355
+ `decryption_resumed` is an _edge_ and is **never throttled** - throttling it would drop a state transition permanently. It is bounded by pairing instead: it is emitted only for a failure that actually reached the host, and those are throttled. See §10.
356
+
357
+ ---
358
+
359
+ ## 9. Counter exhaustion
360
+
361
+ The counter is a 32-bit IV field and **must never wrap**: a wrap folds into a previously used `(ivPrefix, counter)` pair, which is catastrophic under AES-GCM (Appendix A.1).
362
+
363
+ > **The five rules.** This is the whole contract; Appendix A carries the reasoning.
364
+ >
365
+ > 1. **One counter per manager**, shared across every track and codec. Hold it as a single value, never a map keyed by user id - a wrong or changed id would hand out a fresh counter starting at 1 under the same key and prefix.
366
+ > 2. **Check before incrementing**, and never store a value past the ceiling.
367
+ > 3. **Never reset it on a key operation.** Every method in §3 leaves it untouched; only a new manager starts at 0.
368
+ > 4. **Throw at the ceiling.** The frame is dropped and `encryption_failed` is emitted. This is the only counter threshold; nothing fires below it.
369
+ > 5. **Fresh 8-byte `ivPrefix` from a cryptographic RNG on every key import.** Never derived from a user id, session id, timestamp or counter; never reused across imports; never shortened.
370
+
371
+ ```
372
+ c = counter + 1
373
+ if c > 0xFFFFFFFF:
374
+ throw # do NOT store c: the counter stays pinned at the ceiling
375
+ counter = c
376
+ ```
377
+
378
+ Rules 1 and 5 are two independent guards, covering two different scopes (§4). Resetting the counter on import, as rule 3 forbids, collapses them into one.
379
+
380
+ | Action | Frame counter | `ivPrefix` |
381
+ | ------------------------- | ----------------------- | ------------------------- |
382
+ | `setKey` / `setSharedKey` | unchanged, keeps rising | fresh random for the slot |
383
+ | any `remove*` | unchanged, keeps rising | dropped with the slot |
384
+ | new manager | reset to 0 | - |
385
+
386
+ Key state per action is §3; this table covers only the counter and the prefix.
387
+
388
+ **Recovery is a new manager, and the error message must say so.** A rotation gives a disjoint IV space but not a fresh budget, and an exhausted counter does not advance, so a rekeyed track fails identically and publishes nothing for the rest of the manager's life. An error naming rekeying sends integrators down a path that cannot work. `encryption_failed` is latched per track, so the host sees one event per track and then silence rather than a per-frame flood.
389
+
390
+ **No early-warning signal below the ceiling.** The budget is roughly a year of continuous publishing (Appendix A.3), and no rotation can restore it, so a warning would fire once, months in, naming no remedy.
391
+
392
+ ---
393
+
394
+ ## 10. Events
395
+
396
+ Event names are listed below unprefixed. On the wire and in the JS API they carry an `e2ee.` prefix (`e2ee.missing_key`, `e2ee.decryption_stalled`, ...); keep that convention so E2EE events stay distinguishable from SFU and coordinator events.
397
+
398
+ | Event | Payload | Fires when | Host action |
399
+ | ------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
400
+ | `missing_key` (no `keyIndex`) | `userId` | encoder has no local key; **every outgoing track is dropped** | provide a key |
401
+ | `missing_key` (with `keyIndex`) | `userId`, `keyIndex`, `trackType` | a remote frame referenced a key this peer does not hold | usually benign: key distribution or rotation in flight |
402
+ | `decryption_failed` | `userId`, `trackType` | GCM tag failure on a remote frame | key mismatch, rotation, or tampering |
403
+ | `decryption_resumed` | `userId`, `trackType` | that track decrypts again | clear the warning raised by `decryption_failed` |
404
+ | `encryption_failed` | `userId`, `trackType`, `reason` | an outgoing frame could not be encrypted | that track is publishing nothing |
405
+ | `decryption_stalled` | `userId`, `keyIndex`, `trackType` | 10+ consecutive failures for `(track, keyIndex)` | surface to the user; redistribute keys |
406
+ | `unencrypted_frame` | `userId`, `trackType` | a remote frame carried no E2EE framing and was forwarded as-is | expected when the call's mode allows plain publishers; otherwise a downgrade |
407
+ | `unsupported_version` | `userId`, `version`, `trackType` | a remote frame carried our framing at a version this build cannot read; **the frame is dropped** | prompt the local user to update **this** app, not the peer; no key can fix it |
408
+ | `perf_report` | per-track encode/decode samples | once per second when perf reporting is on | diagnostics |
409
+ | `key_state` | `KeyStateReport` | in response to `requestKeyState` | diagnostics |
410
+
411
+ `missing_key` is deliberately distinct from `decryption_failed`: a host cannot otherwise tell "key not here yet" from "key mismatch or tampering".
412
+
413
+ ### Rules for emitting these
414
+
415
+ **Carry `trackType` on anything reported per track.** Every event above except the encode-side `missing_key` is raised inside one track's transform, so without it a peer's audio, video and screen share produce byte-identical messages the host cannot tell apart or act on. The encode-side `missing_key` is the one genuine exception: the local user holds no key at all, which stalls every outgoing track at once, so it is reported once for the user and carries no track.
416
+
417
+ **Levels may be throttled; edges may not.** `decryption_failed`, `missing_key`, `unencrypted_frame` and `unsupported_version` are _levels_ - they describe a condition that persists, so throttling them to one per second per track is safe, because the next frame re-raises the same condition.
418
+
419
+ `decryption_resumed` is an _edge_. Throttling it drops a state transition permanently and strands the host on `decryption_failed` for a track that has recovered. Emit it **unthrottled**, paired one-to-one with delivered failures:
420
+
421
+ ```
422
+ on decryption failure:
423
+ if failureThrottle.tryFire():
424
+ failureReported = true
425
+ emit decryption_failed
426
+
427
+ on successful decrypt:
428
+ clear the per-keyIndex failure count # gates `decryption_stalled`
429
+ if failureReported: # gates `resumed`
430
+ failureReported = false
431
+ emit decryption_resumed
432
+ ```
433
+
434
+ Pairing bounds the rate for free: a recovery can only be emitted for a failure that was emitted, and those are throttled. Two details follow from it, and both are load-bearing:
435
+
436
+ - Do **not** gate the recovery on the failing `keyIndex`'s own count. `decryption_failed` names a track, not a key epoch, so a track that recovers by rotating to a **new** `keyIndex` must still clear it - otherwise the host stays latched on failed forever.
437
+ - Do **not** emit a recovery for a failure that was throttled away. The host never heard about it, so there is nothing to clear.
438
+
439
+ **`encryption_failed` is latched per track**, re-arming when a frame encrypts again. A permanently dead track therefore reports once, not once per frame.
440
+
441
+ `key_state` returns per-user and shared keys with their **fingerprints only** (hex of the first 8 bytes of `SHA-256(rawKey)`). Raw key material must never leave the worker. At most one shared entry has `isActive: true`, and all of them being inactive is a valid state (§3).
442
+
443
+ ---
444
+
445
+ ## 11. Conformance test vectors
446
+
447
+ All vectors use:
448
+
449
+ - key = `000102030405060708090a0b0c0d0e0f` (AES-128-GCM)
450
+ - `keyIndex` = 0
451
+ - `frameCounter` = 1
452
+
453
+ All vectors use `ivPrefix` = `1111111111111111`.
454
+
455
+ **Opus** (audio frame, `clearBytes` = 1)
456
+
457
+ ```
458
+ in 78aabbccdd
459
+ out 78
460
+ d02bf795e85c0bed034f7b282ca617cf76d57eb0 <- ciphertext + tag
461
+ 00000001 1111111111111111 00 0001 01 e2eefeed
462
+ ```
463
+
464
+ **VP8 keyframe** (`clearBytes` = 10)
465
+
466
+ ```
467
+ in 10111213141516171819aabb
468
+ out 10111213141516171819
469
+ d02b0484ce4aa2b21a4a83cbfe2ed6511c68
470
+ 00000001 1111111111111111 00 000a 01 e2eefeed
471
+ ```
472
+
473
+ **H.264 IDR** (`clearBytes` = 6, RBSP path; note `0x03` inserted before the counter, and `clearBytes` encoded as `0x8006`)
474
+
475
+ ```
476
+ in 00000001 65 8884deadbe
477
+ out 000000016588
478
+ fe4e96f631df11f57a43ed2003eaad0c5d6db632 <- ciphertext + tag
479
+ 0000030001 1111111111111111 00 8006 01 e2eefeed <- escaped trailer
480
+ ```
481
+
482
+ A new implementation is conformant when it reproduces the bytes above exactly, decrypts them back to the inputs, and handles both fail-closed cases. All three vectors were regenerated from the reference implementation and verified to round-trip.
483
+
484
+ ---
485
+
486
+ ## 12. Versioning rules
487
+
488
+ - Bump `version` only when the trailer layout or IV derivation changes, and only in lockstep across SDKs.
489
+ - Any change to the AAD composition, the clear-byte rules, or the escaping rules is a wire break and requires a version bump plus new vectors in §11.
490
+
491
+ ### Forward compatibility
492
+
493
+ Lockstep releases do not give lockstep deployment: SDKs ship inside apps, app versions live in stores for months, and a participant on an old build will join a call with a new one. Version skew is therefore a state to be **detected**, not one that policy can prevent. Two commitments make it detectable, and both must be implemented in v1, before any v2 exists - a v1 receiver that ships without them cannot be fixed retroactively when v2 arrives.
494
+
495
+ 1. **The identification suffix never moves.** The last 5 bytes of every frame, in every version, are `version ∥ magic`, read relative to the end of the frame, with the byte 6 from the end never `0x00` (§5.2). A future version may change everything else in the trailer, including its length.
496
+ 2. **An unrecognized version is reported as itself.** Drop the frame and emit `unsupported_version` with the observed version (§7, §10). Never forward it as cleartext, and never report it as a decryption failure.
497
+
498
+ Together these turn skew from corrupt media that looks like a downgrade into an actionable "update this app". Note the direction: the peer wrote a format newer than this receiver implements, so the **local** client is the one to update - reporting it against the remote participant, whose build is already current, is the easy mistake to make here. Detection is receive-side only, which lands the signal in the right place for exactly that reason: the client that must update is the one that notices. It is a diagnostic, not a security control - a relay forging `version = 99` to force drops achieves nothing it could not achieve by discarding the frames itself.
499
+
500
+ The complete fix is to negotiate a call-level maximum version at join, so a newer client emits older framing while any older participant is present. That is out of scope here; the suffix commitment is what keeps the un-negotiated case legible.
501
+
502
+ ---
503
+
504
+ ## 13. Platform notes and open items
505
+
506
+ - **Transform API (web only):** Chrome ships `RTCRtpScriptTransform` but it is still unreliable for E2EE, so the JS SDK puts Chrome on the legacy Insertable Streams path (`createEncodedStreams`, which additionally requires the non-standard `encodedInsertableStreams` flag on the `RTCPeerConnection`). Firefox and Safari always use `RTCRtpScriptTransform`. The selection is not configurable. Native SDKs have their own frame-transformer hooks and can ignore this.
507
+ - **Key exchange is out of scope.** The SDK transports frames; the host owns key derivation, distribution and rotation.
508
+ - **RED (audio redundancy)** is applied by the packetizer, after the encode transform, so it does not affect this format.
509
+ - **H.264 with no slice NALU** falls back to whole-frame encryption without escaping (§5.4). Flag if any platform's encoder can actually produce this.
510
+ - **AV1 is out of scope for the initial release** (§1). The SFU must not negotiate it on an encrypted call; the client-side fail-closed is a net, not a fallback. Adding it later means a second framing scheme, a new version number, and new vectors.
511
+
512
+ ---
513
+
514
+ ## Appendix A: why
515
+
516
+ Reference material behind the IV rules in §4 and §9. Nothing here is normative - it is here so that an implementer who wants to know why the rules take this shape, or who is tempted to relax one, does not have to re-derive it.
517
+
518
+ ### A.1 Why IV reuse is catastrophic, not merely a leak
519
+
520
+ GCM is CTR mode plus GHASH, and a repeated IV breaks both halves.
521
+
522
+ The keystream is a function of `(key, IV)` alone, so two frames encrypted under the same one give `C1 ⊕ C2 = P1 ⊕ P2`: the keystream cancels and the plaintexts leak against each other. Video frames are highly correlated and partly predictable, so that XOR is close to recovering both.
523
+
524
+ The authentication failure is worse. The tag is `GHASH_H(A, C) ⊕ E_K(J0)`, and `J0` derives from the IV, so on a collision the `E_K(J0)` mask cancels too: `T1 ⊕ T2` leaves a polynomial whose only unknown is the GHASH subkey `H`. Solving it recovers `H`, and an attacker holding `H` can forge a valid tag for **any** frame under that key, not only the two that collided. This is the "forbidden attack", demonstrated in practice against TLS stacks that repeated a nonce.
525
+
526
+ A counter wrap is the guaranteed form of it: `ivPrefix` is fixed for the lifetime of an import, so the IV is a pure function of the counter, and wrapping replays the entire IV sequence in order.
527
+
528
+ ### A.2 Why the random `ivPrefix` carries the between-sender case
529
+
530
+ The counter separates IVs only _within_ one sender. Between senders it contributes nothing, and under a shared key that is exactly the case that matters: every participant holds the same AES key, and every participant's counter independently starts at 0, so A's first frame uses `P_A ∥ 1` and B's uses `P_B ∥ 1`. Only `P_A != P_B` keeps them apart.
531
+
532
+ With 8 random bytes the collision probability across _n_ participants is about `n² / 2^65` (~3e-16 for 100 participants), which is why 64 bits suffice - and why the prefix has to be a full 8 bytes of cryptographic randomness, fresh on every import. Deriving it from a user id, session id, timestamp or counter, reusing one across imports, or shortening it breaks AES-GCM for the whole call. This is the single easiest thing to get wrong when porting.
533
+
534
+ ### A.3 How long is the budget?
535
+
536
+ The counter is shared across **all** of a sender's tracks, so the aggregate frame rate is what matters:
537
+
538
+ ```
539
+ months ≈ 2^32 / (aggregate frames per second) / 2.6e6
540
+ ```
541
+
542
+ Worked example, a typical camera call: Opus at 20 ms ptime contributes 50 fps, and a 30 fps camera track with 3 simulcast layers contributes 90 fps (each layer's frames traverse the transform separately), so ~140 fps aggregate.
543
+
544
+ | Case | Aggregate | Hard stop at 2^32 |
545
+ | ---------------------------------------- | --------- | ----------------- |
546
+ | Camera + mic, 3 simulcast layers | ~140 fps | **~12 months** |
547
+ | Camera + mic, single stream (SVC, 1 rid) | ~80 fps | ~20 months |
548
+
549
+ That is continuous publishing within a single session, and the counter resets with each new manager, so no real call approaches it. The ceiling is a correctness guard, not an operational event.
550
+
551
+ ---
552
+
553
+ ## Appendix B: key operation outcomes
554
+
555
+ Normative, unlike Appendix A. One row per key call from §3. "Encode key" is the result of the encode resolution in §3, and only matters for the local user.
556
+
557
+ **Per-user keys.** Every epoch stays available to decrypt until removed.
558
+
559
+ | Action | When | Slots after | Encode key after |
560
+ | ------------------ | ----------------------- | --------------------- | ------------------------ |
561
+ | `setKey(u, i)` | import succeeds | `i` added or replaced | `i` |
562
+ | `setKey(u, i)` | bad index or key length | unchanged | unchanged |
563
+ | `setKey(u, i)` | import fails | unchanged | unchanged |
564
+ | `removeKey(u, i)` | `i` held, not latest | `i` gone | unchanged |
565
+ | `removeKey(u, i)` | `i` held, is latest | `i` gone | active shared, else none |
566
+ | `removeKey(u, i)` | `i` not held | unchanged | unchanged |
567
+ | `removeAllKeys(u)` | always | all of `u` gone | active shared, else none |
568
+
569
+ **Shared-key epochs.** One epoch is active for encryption; the rest are receive-only.
570
+
571
+ | Action | When | Ring after | Active epoch after |
572
+ | -------------------- | ----------------------- | --------------------- | ------------------ |
573
+ | `setSharedKey(i)` | import succeeds | `i` added or replaced | `i` |
574
+ | `setSharedKey(i)` | bad index or key length | unchanged | unchanged |
575
+ | `setSharedKey(i)` | import fails | unchanged | unchanged |
576
+ | `removeSharedKey(i)` | `i` held, not active | `i` gone | unchanged |
577
+ | `removeSharedKey(i)` | `i` held, is active | `i` gone | none |
578
+ | `removeSharedKey(i)` | `i` not held | unchanged | unchanged |
579
+
580
+ Behind the rows:
581
+
582
+ - **Nothing is promoted implicitly.** Removing the epoch that was encrypting falls to the next step of the resolution order, never to an older epoch of the same kind.
583
+ - **A failed import changes nothing.** A bad index or key length throws before the store is reached; a failed import is logged internally. Either way the previous epoch survives, including when it was the active one.
584
+ - **Removals are silent.** No key call emits an event. The effect shows on the next frame, or via `key_state` (§10). A frame naming a removed epoch raises `missing_key`.
585
+ - **Calls apply in order.** A serialized queue owns the store, so `setSharedKey(7)` then `removeSharedKey(7)` lands in that order.
586
+ - Each import gets a fresh `ivPrefix` (§3.2), no removal touches the frame counter (§9), and every call throws after `dispose()`.
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { E2EEManager } from '../E2EEManager';
3
+
4
+ /**
5
+ * `Call.setE2EEManager` accepts any {@link E2EEManager}, so a third party can
6
+ * plug in another scheme (RFC 9605 SFrame, say) instead of the built-in one.
7
+ *
8
+ * That contract is a compile-time property, so the assertion that matters here
9
+ * is the type annotation below, not the expectations in the test body: adding a
10
+ * required member to the interface breaks the build in this file, which is the
11
+ * signal worth raising, since it is a breaking change for implementors.
12
+ */
13
+ const attached: string[] = [];
14
+
15
+ const customImplementation: E2EEManager = {
16
+ // Parameters are contextually typed from the annotation, so a signature the
17
+ // interface does not describe fails to compile.
18
+ encrypt: (sender, codec, trackType) => {
19
+ attached.push(`encode:${sender.track?.kind}:${codec}:${trackType}`);
20
+ },
21
+ decrypt: (receiver, userId, trackType) => {
22
+ attached.push(`decode:${receiver.track?.kind}:${userId}:${trackType}`);
23
+ },
24
+ };
25
+
26
+ describe('E2EEManager contract', () => {
27
+ it('is satisfied by an implementation providing only encrypt and decrypt', () => {
28
+ const sender = { track: { kind: 'video' } } as RTCRtpSender;
29
+ const receiver = { track: { kind: 'audio' } } as RTCRtpReceiver;
30
+
31
+ customImplementation.encrypt(sender, 'vp8', 'VIDEO');
32
+ customImplementation.decrypt(receiver, 'bob', 'AUDIO');
33
+
34
+ expect(attached).toEqual([
35
+ 'encode:video:vp8:VIDEO',
36
+ 'decode:audio:bob:AUDIO',
37
+ ]);
38
+ });
39
+ });