diff --git a/packages/client/src/rtc/e2ee/EncryptionManager.ts b/packages/client/src/rtc/e2ee/EncryptionManager.ts index 1fa1ca3655..3377530ed1 100644 --- a/packages/client/src/rtc/e2ee/EncryptionManager.ts +++ b/packages/client/src/rtc/e2ee/EncryptionManager.ts @@ -5,15 +5,16 @@ import type { E2EEManager } from './E2EEManager'; export type { E2EEEventMap, - E2EEBrokenEvent, DecryptionFailedEvent, DecryptionResumedEvent, + DecryptionStalledEvent, EncryptionFailedEvent, KeyStateReport, MissingKeyEvent, PerfReport, TrackPerf, UnencryptedFrameEvent, + UnsupportedVersionEvent, } from './events'; /** @@ -275,9 +276,9 @@ export class EncryptionManager * Request a snapshot of the worker's keys. It arrives later as the * `e2ee.key_state` event, listing fingerprints only, never key material. */ - requestKeyDump = (): void => { + requestKeyState = (): void => { this.assertUsable(); - this.worker.postMessage({ type: 'cmd.dump_key_state' }); + this.worker.postMessage({ type: 'cmd.request_key_state' }); }; private handleWorkerMessage = (e: MessageEvent) => { diff --git a/packages/client/src/rtc/e2ee/SPEC.md b/packages/client/src/rtc/e2ee/SPEC.md index ae714d35ff..7d9a749c61 100644 --- a/packages/client/src/rtc/e2ee/SPEC.md +++ b/packages/client/src/rtc/e2ee/SPEC.md @@ -43,7 +43,7 @@ removeSharedKey(keyIndex: number): void // diagnostics enablePerformanceReporting(enabled: boolean): void -requestKeyDump(): void +requestKeyState(): void // lifecycle dispose(): void @@ -117,7 +117,7 @@ Every key import generates: 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. -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 - see the warning in §9. +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). --- @@ -134,7 +134,7 @@ The prefix must come from a **cryptographic RNG**, never from a user id, session > 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: > > - **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. -> - **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 requires it to come from a cryptographic RNG. +> - **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). --- @@ -173,11 +173,13 @@ The leading `clearBytes` bytes stay in plaintext so the SFU can still read frame | 4 | 8 | `ivPrefix` | sender's random prefix for this key import | | 12 | 1 | `keyIndex` | 0-255 | | 13 | 2 | `clearBytes \| flags` | bit 15 = `RBSP_FLAG`, bits 0-14 = clearBytes (max 32767) | -| 15 | 1 | `version` | `0x01` | -| 16 | 4 | `magic` | `0xE2EEFEED` | +| 15 | 1 | `version` | `0x01`; frozen position, see below | +| 16 | 4 | `magic` | `0xE2EEFEED`; frozen position, see below | **Overhead:** 16 (GCM tag) + 20 (trailer) = **36 bytes per frame**, plus RBSP escape bytes for H.264. +**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. + ### 5.3 AAD (Additional Authenticated Data) 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. @@ -275,6 +277,10 @@ on frame: trailer = readTrailer(frame) // magic == 0xE2EEFEED && version == 1 if trailer == null: + // Two different conditions; the frozen suffix (§5.2) tells them apart. + v = readFramingVersion(frame) // version byte iff magic matches, else null + if v != null and v != 1: + drop, emit unsupported_version(v) (throttled per track); return emit unencrypted_frame (throttled); forward unchanged; return -> recover ciphertext + IV fields (RBSP path per §5.4) -> decode with (keyIndex, ivPrefix, frameCounter) @@ -286,22 +292,24 @@ decode with (keyIndex, ivPrefix, counter): try: plaintext = decrypt(...) replayWindow.commit(counter, ivPrefix) // only after authentication - failures.clearFailures(keyIndex) // gates `broken`, nothing else + failures.clearFailures(keyIndex) // gates `decryption_stalled`, nothing else emit decryption_resumed // unthrottled and paired; §10 forward clearHeader || plaintext catch: - if failures.recordFailure(keyIndex) crosses tolerance: emit broken + if failures.recordFailure(keyIndex) crosses tolerance: emit decryption_stalled emit decryption_failed (throttled) drop ``` -**`readTrailer` validation order:** length >= 20, then `magic == 0xE2EEFEED`, then `version == 1`, then `clearBytes <= frameLength - 20`. Any mismatch means "not our trailer" - forward the frame as cleartext rather than attempting a decrypt. +**`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. + +**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. -An unknown version must be treated as **not ours**, not as an error. That is what keeps unrelated frames that happen to end in `0xE2EEFEED` from producing spurious failures, and what lets a future version coexist. +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. 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). -> **Consequence worth knowing.** `unencrypted_frame` therefore also fires for a frame that _is_ encrypted but by a peer on a different `version`. The frame is then handed to the decoder as ciphertext, so the symptom is corrupt media plus an event that reads as a downgrade. Version skew across SDKs must be avoided rather than detected. +> **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. --- @@ -312,7 +320,7 @@ The magic is a heuristic, not a guarantee: any 4-byte value collides with random 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. - The replay window is **peeked** before decrypt and **committed** only after success. -- The failure counter is diagnostic only. It gates the `broken` signal; it never gates a decrypt attempt. A burst of forged frames must not be able to latch a genuine key invalid. +- 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. ### Replay window @@ -326,9 +334,9 @@ Scoped **per remote track**, not per user. Remote tracks travel on independent S ### Failure tolerance -Consecutive decryption failures are counted **per track, per `keyIndex`**. After **10** consecutive failures, the 11th fires `broken` 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 `broken` and nothing else; the recovery is gated separately, on whether a failure was ever delivered to the host (§10). +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). -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 `broken` can never fire. +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. ### Throttling @@ -340,79 +348,55 @@ The _level_ notifications (`missing_key`, `decryption_failed`, `unencrypted_fram ## 9. Counter exhaustion -The counter is a 32-bit IV field. **It must never wrap** - wrapping would fold into a previously used `(ivPrefix, counter)` pair, which is catastrophic under AES-GCM. Check before incrementing and fail closed: +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). + +> **The five rules.** This is the whole contract; Appendix A carries the reasoning. +> +> 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. +> 2. **Check before incrementing**, and never store a value past the ceiling. +> 3. **Never reset it on a key operation.** `setKey`, `setSharedKey`, `removeKeys` and `removeSharedKey` all leave it untouched; only a new manager starts at 0. +> 4. **Throw at the ceiling.** The frame is dropped and `encryption_failed` is emitted. This is the only counter threshold; nothing fires below it. +> 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. ``` c = counter + 1 if c > 0xFFFFFFFF: - throw # do NOT store c - the counter stays pinned at the ceiling + throw # do NOT store c: the counter stays pinned at the ceiling counter = c ``` -The throw propagates out of the encode path, so the frame is dropped and `encryption_failed` is emitted. **This ceiling is the only counter threshold; nothing fires below it.** - -**Hold the counter as a single value, not as a map keyed by user id.** A manager is bound to one local user at construction and only the encode path draws from the counter, so keying it buys nothing - but it costs a failure mode: a wrong or changed id hands out a _fresh_ counter starting at 1, which under the same key and `ivPrefix` is exactly the IV reuse this ceiling exists to prevent. A single value cannot do that. - -> **Why IV reuse is catastrophic, and not merely a leak.** GCM is CTR mode plus GHASH, and a repeated IV breaks both halves. -> -> 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. -> -> 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. -> -> A wrap is the guaranteed form of this: `ivPrefix` is fixed for the key's lifetime, so the IV is a pure function of the counter, and wrapping replays the entire IV sequence in order. Hence fail closed rather than wrap. - -**The counter is scoped to the manager, not to the key.** It must survive key imports and removals untouched, and reset only when the manager is torn down: - -| Action | Frame counter | `ivPrefix` | Key state | -| -------------------------------------- | ----------------------- | ------------ | -------------------------------- | -| `setKey` / `setSharedKey` | unchanged, keeps rising | fresh random | slot added or replaced | -| `removeKeys` | unchanged, keeps rising | dropped | user's slots removed | -| `removeSharedKey` on an inactive epoch | unchanged, keeps rising | dropped | shared slot removed | -| `removeSharedKey` on the active epoch | unchanged, keeps rising | dropped | slot removed; no active fallback | -| new manager instance | reset to 0 | - | empty | - -Two guards keep IVs unique within one sender: the persistent counter, and the fresh random `ivPrefix` per import. Resetting the counter on import would collapse them into one. - -> **The `ivPrefix` RNG is load-bearing on its own - do not weaken it.** The counter only separates IVs _within_ a single sender. It contributes nothing **between** senders, 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 participant A's first frame uses `P_A || 1` and B's uses `P_B || 1`. Only `P_A != P_B` keeps them apart. 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 - but it means the prefix must be **8 bytes from a cryptographic RNG, generated 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 outright for the entire call. This is the single easiest thing to get wrong when porting. - -**Consequence: a rekey cannot recover an exhausted sender.** Rotation gives a disjoint IV space but not a fresh budget, and the failing call does not advance the counter, so every later frame fails identically and the track publishes nothing for the rest of the manager's life. The only recovery is a new manager. Say so in the error message: one that points at rekeying sends integrators down a path that cannot work. - -Because `encryption_failed` is latched per track, the host sees one event per track and then silence, not a per-frame flood. +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. -### How long is the budget? +| Action | Frame counter | `ivPrefix` | +| -------------------------------- | ----------------------- | ------------------------- | +| `setKey` / `setSharedKey` | unchanged, keeps rising | fresh random for the slot | +| `removeKeys` / `removeSharedKey` | unchanged, keeps rising | dropped with the slot | +| new manager | reset to 0 | - | -The counter is shared across **all** of a sender's tracks, so the aggregate frame rate is what matters: +Key state per action is §3; this table covers only the counter and the prefix. -``` -months ≈ 2^32 / (aggregate frames per second) / 2.6e6 -``` +**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. -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. - -| Case | Aggregate | Hard stop at 2^32 | -| ---------------------------------------- | --------- | ----------------- | -| Camera + mic, 3 simulcast layers | ~140 fps | **~12 months** | -| Camera + mic, single stream (SVC, 1 rid) | ~80 fps | ~20 months | - -That is **continuous publishing in a single session**, and counters reset with each new manager, so no real call approaches it. This is a correctness guard, not an operational event. Do not add an early-warning signal below the ceiling: it would fire only after ~6 months, and it could name no remedy that works, since a rotation cannot restore the budget. Do not skip the ceiling check on the same reasoning: a per-track counter, or one that resets on rekey, turns "never happens" into IV reuse. +**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. --- ## 10. Events -Event names are listed below unprefixed. On the wire and in the JS API they carry an `e2ee.` prefix (`e2ee.missing_key`, `e2ee.broken`, ...); keep that convention so E2EE events stay distinguishable from SFU and coordinator events. - -| Event | Payload | Fires when | Host action | -| ------------------------------- | --------------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| `missing_key` (no `keyIndex`) | `userId` | encoder has no local key; **every outgoing track is dropped** | provide a key | -| `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 | -| `decryption_failed` | `userId`, `trackType` | GCM tag failure on a remote frame | key mismatch, rotation, or tampering | -| `decryption_resumed` | `userId`, `trackType` | that track decrypts again | clear the warning raised by `decryption_failed` | -| `encryption_failed` | `userId`, `trackType`, `reason` | an outgoing frame could not be encrypted | that track is publishing nothing | -| `broken` | `userId`, `keyIndex`, `trackType` | 10+ consecutive failures for `(track, keyIndex)` | surface to the user; redistribute keys | -| `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 | -| `perf_report` | per-track encode/decode samples | once per second when perf reporting is on | diagnostics | -| `key_state` | `KeyStateReport` | in response to `requestKeyDump` | diagnostics | +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. + +| Event | Payload | Fires when | Host action | +| ------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- | +| `missing_key` (no `keyIndex`) | `userId` | encoder has no local key; **every outgoing track is dropped** | provide a key | +| `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 | +| `decryption_failed` | `userId`, `trackType` | GCM tag failure on a remote frame | key mismatch, rotation, or tampering | +| `decryption_resumed` | `userId`, `trackType` | that track decrypts again | clear the warning raised by `decryption_failed` | +| `encryption_failed` | `userId`, `trackType`, `reason` | an outgoing frame could not be encrypted | that track is publishing nothing | +| `decryption_stalled` | `userId`, `keyIndex`, `trackType` | 10+ consecutive failures for `(track, keyIndex)` | surface to the user; redistribute keys | +| `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 | +| `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 | +| `perf_report` | per-track encode/decode samples | once per second when perf reporting is on | diagnostics | +| `key_state` | `KeyStateReport` | in response to `requestKeyState` | diagnostics | `missing_key` is deliberately distinct from `decryption_failed`: a host cannot otherwise tell "key not here yet" from "key mismatch or tampering". @@ -420,7 +404,7 @@ Event names are listed below unprefixed. On the wire and in the JS API they carr **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. -**Levels may be throttled; edges may not.** `decryption_failed`, `missing_key` and `unencrypted_frame` 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. +**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. `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: @@ -431,7 +415,7 @@ on decryption failure: emit decryption_failed on successful decrypt: - clear the per-keyIndex failure count # gates `broken` + clear the per-keyIndex failure count # gates `decryption_stalled` if failureReported: # gates `resumed` failureReported = false emit decryption_resumed @@ -444,8 +428,7 @@ Pairing bounds the rate for free: a recovery can only be emitted for a failure t **`encryption_failed` is latched per track**, re-arming when a frame encrypts again. A permanently dead track therefore reports once, not once per frame. -`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`. It is valid for every shared entry to be inactive after the active epoch is removed. +`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). --- @@ -493,9 +476,19 @@ A new implementation is conformant when it reproduces the bytes above exactly, d ## 12. Versioning rules - Bump `version` only when the trailer layout or IV derivation changes, and only in lockstep across SDKs. -- A receiver seeing an unknown version must treat the frame as **not ours** (forward as cleartext with `unencrypted_frame`), never as a decryption failure. - 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. +### Forward compatibility + +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. + +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. +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. + +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. + +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. + --- ## 13. Platform notes and open items @@ -505,3 +498,42 @@ A new implementation is conformant when it reproduces the bytes above exactly, d - **RED (audio redundancy)** is applied by the packetizer, after the encode transform, so it does not affect this format. - **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. - **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. + +--- + +## Appendix A: why + +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. + +### A.1 Why IV reuse is catastrophic, not merely a leak + +GCM is CTR mode plus GHASH, and a repeated IV breaks both halves. + +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. + +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. + +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. + +### A.2 Why the random `ivPrefix` carries the between-sender case + +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. + +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. + +### A.3 How long is the budget? + +The counter is shared across **all** of a sender's tracks, so the aggregate frame rate is what matters: + +``` +months ≈ 2^32 / (aggregate frames per second) / 2.6e6 +``` + +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. + +| Case | Aggregate | Hard stop at 2^32 | +| ---------------------------------------- | --------- | ----------------- | +| Camera + mic, 3 simulcast layers | ~140 fps | **~12 months** | +| Camera + mic, single stream (SVC, 1 rid) | ~80 fps | ~20 months | + +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. diff --git a/packages/client/src/rtc/e2ee/__tests__/EncryptionManager.test.ts b/packages/client/src/rtc/e2ee/__tests__/EncryptionManager.test.ts index 7bd3bfab77..c171d78119 100644 --- a/packages/client/src/rtc/e2ee/__tests__/EncryptionManager.test.ts +++ b/packages/client/src/rtc/e2ee/__tests__/EncryptionManager.test.ts @@ -103,9 +103,9 @@ describe('EncryptionManager', () => { { type: 'cmd.remove_keys', userId: 'remote-user' }, ], [ - 'requestKeyDump', - () => manager.requestKeyDump(), - { type: 'cmd.dump_key_state' }, + 'requestKeyState', + () => manager.requestKeyState(), + { type: 'cmd.request_key_state' }, ], [ 'enablePerformanceReporting', @@ -330,8 +330,15 @@ describe('EncryptionManager', () => { ['e2ee.decryption_resumed', { userId: 'bob', trackType: 'VIDEO' }], ['e2ee.encryption_failed', { userId: 'bob', reason: 'clear-bytes' }], ['e2ee.missing_key', { userId: 'local-user', keyIndex: 2 }], - ['e2ee.broken', { userId: 'bob', keyIndex: 3, trackType: 'AUDIO' }], + [ + 'e2ee.decryption_stalled', + { userId: 'bob', keyIndex: 3, trackType: 'AUDIO' }, + ], ['e2ee.unencrypted_frame', { userId: 'bob', trackType: 'VIDEO' }], + [ + 'e2ee.unsupported_version', + { userId: 'bob', version: 2, trackType: 'VIDEO' }, + ], [ 'e2ee.perf_report', { @@ -440,7 +447,7 @@ describe('EncryptionManager', () => { ); expect(() => manager.removeSharedKey(0)).toThrow(/is disposed/); expect(() => manager.removeKeys('user')).toThrow(/is disposed/); - expect(() => manager.requestKeyDump()).toThrow(/is disposed/); + expect(() => manager.requestKeyState()).toThrow(/is disposed/); expect(() => manager.enablePerformanceReporting(true)).toThrow( /is disposed/, ); diff --git a/packages/client/src/rtc/e2ee/__tests__/failureTracker.test.ts b/packages/client/src/rtc/e2ee/__tests__/failureTracker.test.ts index 06fd94d698..e0ff73ca67 100644 --- a/packages/client/src/rtc/e2ee/__tests__/failureTracker.test.ts +++ b/packages/client/src/rtc/e2ee/__tests__/failureTracker.test.ts @@ -11,7 +11,7 @@ describe('FailureTracker', () => { } // The next one crosses it - the break transition fires exactly once. expect(tracker.recordFailure(1)).toBe(true); - expect(tracker.recordFailure(1)).toBe(false); // already broken, no re-fire + expect(tracker.recordFailure(1)).toBe(false); // already stalled, no re-fire }); it('recordSuccess clears the count and reports whether there were failures', () => { diff --git a/packages/client/src/rtc/e2ee/__tests__/keyStore.test.ts b/packages/client/src/rtc/e2ee/__tests__/keyStore.test.ts index 18654cf024..05c5bb99c7 100644 --- a/packages/client/src/rtc/e2ee/__tests__/keyStore.test.ts +++ b/packages/client/src/rtc/e2ee/__tests__/keyStore.test.ts @@ -67,7 +67,7 @@ describe('dumpKeyState', () => { await keyStore.importKey('alice', 1, rawKey(0x01)); await keyStore.importSharedKey(0, rawKey(0x02)); - const dump = keyStore.dump(); + const dump = keyStore.keyState(); expect(dump.perUserKeys).toHaveLength(1); expect(dump.perUserKeys[0]).toMatchObject({ userId: 'alice', @@ -89,12 +89,12 @@ describe('dumpKeyState', () => { // What makes the dump useful: two peers can compare prints to confirm they // hold the same key, under any user id or key index. await keyStore.importKey('alice', 1, rawKey(0xaa)); - const alice = keyStore.dump().perUserKeys[0].fingerprint; + const alice = keyStore.keyState().perUserKeys[0].fingerprint; keyStore.clear(); await keyStore.importKey('bob', 99, rawKey(0xaa)); await keyStore.importKey('bob', 100, rawKey(0x02)); - const [same, different] = keyStore.dump().perUserKeys; + const [same, different] = keyStore.keyState().perUserKeys; expect(same.fingerprint).toBe(alice); expect(different.fingerprint).not.toBe(alice); @@ -154,7 +154,7 @@ describe('shared-key rotation', () => { expect(keyStore.getKey('alice', 1)).toBeDefined(); expect(keyStore.getKey('alice', 2)).toBeUndefined(); expect(keyStore.getLatestKey('alice')).toBeNull(); - expect(keyStore.dump()).toMatchObject({ + expect(keyStore.keyState()).toMatchObject({ sharedKeys: [{ keyIndex: 1, isActive: false }], }); }); diff --git a/packages/client/src/rtc/e2ee/__tests__/notifications.test.ts b/packages/client/src/rtc/e2ee/__tests__/notifications.test.ts index 79b8e3dd29..a497b920d5 100644 --- a/packages/client/src/rtc/e2ee/__tests__/notifications.test.ts +++ b/packages/client/src/rtc/e2ee/__tests__/notifications.test.ts @@ -64,6 +64,7 @@ describe('DecodeNotifier throttling', () => { it.each([ ['decryption_failed', (n: DecodeNotifier) => n.failed()], ['unencrypted_frame', (n: DecodeNotifier) => n.unencrypted()], + ['unsupported_version', (n: DecodeNotifier) => n.unsupportedVersion(2)], ])('delivers at most one %s per second', (type, raise) => { const notify = new DecodeNotifier('bob', 'VIDEO'); raise(notify); @@ -76,6 +77,18 @@ describe('DecodeNotifier throttling', () => { expect(types()).toEqual([`e2ee.${type}`, `e2ee.${type}`]); }); + // The version byte is plaintext, so a relay can rewrite it per frame. Keying + // the throttle by version would hand it one event per distinct value, 255x + // the intended rate, for free. + it('throttles unsupported_version per track, not per version', () => { + const notify = new DecodeNotifier('bob', 'VIDEO'); + notify.unsupportedVersion(2); + notify.unsupportedVersion(3); + notify.unsupportedVersion(99); + expect(types()).toEqual(['e2ee.unsupported_version']); + expect(postMessage.mock.calls[0][0].version).toBe(2); + }); + it('throttles missing_key per keyIndex, so a rotation still reports', () => { const notify = new DecodeNotifier('bob', 'VIDEO'); notify.missingKey(1); @@ -86,11 +99,14 @@ describe('DecodeNotifier throttling', () => { expect(postMessage.mock.calls.map(([m]) => m.keyIndex)).toEqual([1, 2]); }); - it('does not throttle broken: it is already once per failure run', () => { + it('does not throttle decryption_stalled: it is already once per failure run', () => { const notify = new DecodeNotifier('bob', 'VIDEO'); - notify.broken(0); - notify.broken(1); - expect(types()).toEqual(['e2ee.broken', 'e2ee.broken']); + notify.stalled(0); + notify.stalled(1); + expect(types()).toEqual([ + 'e2ee.decryption_stalled', + 'e2ee.decryption_stalled', + ]); }); it('scopes throttles per notifier, so one track cannot mute another', () => { diff --git a/packages/client/src/rtc/e2ee/__tests__/trailer.test.ts b/packages/client/src/rtc/e2ee/__tests__/trailer.test.ts index 15ced47672..1ca4c2f743 100644 --- a/packages/client/src/rtc/e2ee/__tests__/trailer.test.ts +++ b/packages/client/src/rtc/e2ee/__tests__/trailer.test.ts @@ -4,7 +4,11 @@ import { MAX_CLEAR_BYTES, TRAILER_LEN, } from '../e2ee-worker/constants'; -import { readTrailer, writeTrailer } from '../e2ee-worker/trailer'; +import { + readFramingVersion, + readTrailer, + writeTrailer, +} from '../e2ee-worker/trailer'; const makeFrame = (bodyLen: number): Uint8Array => new Uint8Array(bodyLen + TRAILER_LEN); @@ -81,3 +85,59 @@ describe('writeTrailer + readTrailer', () => { expect(readTrailer(corrupt(dst))).toBeNull(); }); }); + +// The identification suffix is frozen across versions, so this must keep +// working against a frame written by a version this build knows nothing about. +describe('readFramingVersion', () => { + const framed = (): Uint8Array => { + const body = 5; + const dst = makeFrame(body); + writeTrailer(dst, body, 1, randomPrefix(), 0, 0, false); + return dst; + }; + + // Frozen across every version (SPEC 5.2): an older receiver identifies our + // frames from these 5 bytes alone, so a layout change that moves them has to + // fail here rather than silently strand those receivers. Literal values on + // purpose - reading them from the constants would move with the break. + it('pins the identification suffix to the last 5 bytes written', () => { + const dst = framed(); + const view = new DataView(dst.buffer); + expect(dst[dst.length - 5]).toBe(1); + expect(view.getUint32(dst.length - 4)).toBe(0xe2eefeed); + }); + + it('reports the version of a frame carrying our framing', () => { + expect(readFramingVersion(framed())).toBe(1); + }); + + it('accepts a caller-supplied view over the same bytes', () => { + const dst = framed(); + const view = new DataView(dst.buffer, dst.byteOffset, dst.byteLength); + expect(readFramingVersion(dst, view)).toBe(readFramingVersion(dst)); + }); + + it('reports a version this build cannot read, where readTrailer only says null', () => { + const future = framed(); + future[future.length - 5] = 99; + expect(readFramingVersion(future)).toBe(99); + expect(readTrailer(future)).toBeNull(); + }); + + it('reads the suffix from the end, so a longer future trailer still resolves', () => { + const grown = new Uint8Array(framed().length + 4); + const src = framed(); + // Simulate a v2 trailer with 4 extra bytes ahead of the frozen suffix. + grown.set(src.subarray(0, src.length - 5), 0); + grown.set(src.subarray(src.length - 5), grown.length - 5); + grown[grown.length - 5] = 2; + expect(readFramingVersion(grown)).toBe(2); + }); + + it('returns null when the frame is not ours', () => { + const notOurs = framed(); + notOurs[notOurs.length - 1] ^= 0x01; + expect(readFramingVersion(notOurs)).toBeNull(); + expect(readFramingVersion(new Uint8Array(4))).toBeNull(); + }); +}); diff --git a/packages/client/src/rtc/e2ee/__tests__/transform-pipeline.test.ts b/packages/client/src/rtc/e2ee/__tests__/transform-pipeline.test.ts index ac8035dcdf..de456bbb88 100644 --- a/packages/client/src/rtc/e2ee/__tests__/transform-pipeline.test.ts +++ b/packages/client/src/rtc/e2ee/__tests__/transform-pipeline.test.ts @@ -166,11 +166,11 @@ afterEach(async () => { // covered by the module that owns it (perf.test.ts, crypto.test.ts); these // cover the dispatch reaching it. describe('worker command interface', () => { - it('answers cmd.dump_key_state with fingerprints, never key material', async () => { + it('answers cmd.request_key_state with fingerprints, never key material', async () => { const user = freshUser(); await setKey(user, 4); posted.length = 0; - message({ type: 'cmd.dump_key_state' }); + message({ type: 'cmd.request_key_state' }); await flush(); const dump = posted.find((m) => m.type === 'e2ee.key_state') as { perUserKeys: Array<{ userId: string; keyIndex: number }> } | undefined; @@ -333,6 +333,33 @@ describe('decode pipeline edge behaviors', () => { ]); }); + it('drops a frame from a newer framing version instead of forwarding it', async () => { + const user = freshUser(); + await setKey(user); + const [encrypted] = await drive('encode', user, 'vp8', [ + frame([1, 2, 3, 4, 5, 6, 7, 8], 'delta'), + ]); + const future = new Uint8Array(encrypted.data.slice(0)); + future[future.length - 5] = E2EE_VERSION + 1; + posted.length = 0; + + const out = await drive('decode', user, undefined, [ + { ...encrypted, data: future.buffer }, + ]); + + // Forwarding would hand ciphertext to the decoder: corrupt media, reported + // as a downgrade. The peer is simply newer, so drop and say which version. + expect(out).toEqual([]); + expect(posted).toEqual([ + { + type: 'e2ee.unsupported_version', + userId: user, + version: E2EE_VERSION + 1, + trackType: undefined, + }, + ]); + }); + it('drops and signals missing_key when the key is gone', async () => { const user = freshUser(); await setKey(user); @@ -569,8 +596,13 @@ describe('decode pipeline edge behaviors', () => { // The break is surfaced once (on the tolerance crossing) and recovery once. // Both name the track: a peer's audio and video are separate transforms // reported under one userId, so a host cannot pair them up without this. - expect(posted.filter((m) => m.type === 'e2ee.broken')).toEqual([ - { type: 'e2ee.broken', userId: user, keyIndex: 0, trackType: 'VIDEO' }, + expect(posted.filter((m) => m.type === 'e2ee.decryption_stalled')).toEqual([ + { + type: 'e2ee.decryption_stalled', + userId: user, + keyIndex: 0, + trackType: 'VIDEO', + }, ]); expect(posted.filter((m) => m.type === 'e2ee.decryption_resumed')).toEqual([ { type: 'e2ee.decryption_resumed', userId: user, trackType: 'VIDEO' }, @@ -617,12 +649,14 @@ describe('decode pipeline edge behaviors', () => { posted.length = 0; const vOut = await drive('decode', user, undefined, tamperedVideo); expect(vOut).toHaveLength(0); - expect(posted.filter((m) => m.type === 'e2ee.broken')).toHaveLength(1); + expect( + posted.filter((m) => m.type === 'e2ee.decryption_stalled'), + ).toHaveLength(1); // Audio decode transform (a SEPARATE track): the genuine frame decrypts and // must NOT emit decryption_resumed - this track never failed. With the old // per-(user, keyIndex) counter shared across tracks, the audio success reset - // the video failures and spuriously "resumed" (and kept e2ee.broken from + // the video failures and spuriously "resumed" (and kept e2ee.decryption_stalled from // ever firing). posted.length = 0; const aOut = await drive('decode', user, undefined, [audioEnc]); diff --git a/packages/client/src/rtc/e2ee/e2ee-worker/constants.ts b/packages/client/src/rtc/e2ee/e2ee-worker/constants.ts index 1614cf9b34..43057acdcf 100644 --- a/packages/client/src/rtc/e2ee/e2ee-worker/constants.ts +++ b/packages/client/src/rtc/e2ee/e2ee-worker/constants.ts @@ -34,7 +34,7 @@ export const MAX_CLEAR_BYTES = 0x7fff; export const EMPTY_AAD = new Uint8Array(0); -/** Consecutive decrypt failures on one track before `e2ee.broken` fires. */ +/** Consecutive decrypt failures on one track before `e2ee.decryption_stalled` fires. */ export const FAILURE_TOLERANCE = 10; /** Replay window in frames. A counter <= highestSeen - this is rejected. */ diff --git a/packages/client/src/rtc/e2ee/e2ee-worker/decode.ts b/packages/client/src/rtc/e2ee/e2ee-worker/decode.ts index 3cee23a1f5..b924ff73ed 100644 --- a/packages/client/src/rtc/e2ee/e2ee-worker/decode.ts +++ b/packages/client/src/rtc/e2ee/e2ee-worker/decode.ts @@ -1,6 +1,11 @@ -import { EMPTY_AAD, IV_LEN, TRAILER_LEN } from './constants'; +import { E2EE_VERSION, EMPTY_AAD, IV_LEN, TRAILER_LEN } from './constants'; import { boundarySeedZeros, rbspUnescape } from './codec'; -import { fillIV, readTrailer, readTrailerIv } from './trailer'; +import { + fillIV, + readFramingVersion, + readTrailer, + readTrailerIv, +} from './trailer'; import { FailureTracker } from './failureTracker'; import { ReplayWindow } from './replayWindow'; import { keyStore } from './keyStore'; @@ -23,7 +28,8 @@ export const decodeTransform = ( const ivView = new DataView(iv.buffer); // Per track, so a user's audio, video and screen share never share a window - // or a failure count. The separate count is what lets e2ee.broken fire. + // or a failure count. The separate count is what lets + // e2ee.decryption_stalled fire. const replay = new ReplayWindow(); const failures = new FailureTracker(); @@ -35,7 +41,7 @@ export const decodeTransform = ( * Trust ordering (the SFrame/SRTP rule): a relay can forge `frameCounter`, * `ivPrefix` and `keyIndex`, which are plaintext in the trailer, so nothing * changes trust state until GCM authenticates. Hence peek before, commit - * after. The failure counter is diagnostic only - it gates `e2ee.broken`, + * after. The failure counter is diagnostic only - it gates `e2ee.decryption_stalled`, * never the decrypt attempt - so forged frames cannot mark a key invalid. */ const finishDecode = async ( @@ -70,11 +76,11 @@ export const decodeTransform = ( controller.enqueue(frame); stats.bump(); } catch { - // True only on the failure crossing the tolerance, so `e2ee.broken` fires - // once per run, not once per frame. - const becameInvalid = failures.recordFailure(keyIndex); + // True only on the failure crossing the tolerance, so + // `e2ee.decryption_stalled` fires once per run, not once per frame. + const stalled = failures.recordFailure(keyIndex); notify.failed(); - if (becameInvalid) notify.broken(keyIndex); + if (stalled) notify.stalled(keyIndex); } }; @@ -90,6 +96,14 @@ export const decodeTransform = ( const trailer = readTrailer(src); if (!trailer) { + // Ours but unreadable: drop rather than forward. Handing ciphertext to + // the decoder renders corruption and reads to the host as a downgrade, + // when the actual condition is that this build is the older one. + const version = readFramingVersion(src); + if (version !== null && version !== E2EE_VERSION) { + notify.unsupportedVersion(version); + return; + } notify.unencrypted(); controller.enqueue(frame); stats.bump(); diff --git a/packages/client/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts b/packages/client/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts index ab3f9ef70b..0d7115a049 100644 --- a/packages/client/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts +++ b/packages/client/src/rtc/e2ee/e2ee-worker/e2ee-worker-impl.ts @@ -116,8 +116,8 @@ addEventListener('message', ({ data }) => { if (data.enabled) startPerfReport(); else stopPerfReport(); break; - case 'cmd.dump_key_state': - self.postMessage({ type: 'e2ee.key_state', ...keyStore.dump() }); + case 'cmd.request_key_state': + self.postMessage({ type: 'e2ee.key_state', ...keyStore.keyState() }); break; case 'cmd.setup_transform': setupTransform(data); diff --git a/packages/client/src/rtc/e2ee/e2ee-worker/failureTracker.ts b/packages/client/src/rtc/e2ee/e2ee-worker/failureTracker.ts index 1e6c29f34b..c98d97aec2 100644 --- a/packages/client/src/rtc/e2ee/e2ee-worker/failureTracker.ts +++ b/packages/client/src/rtc/e2ee/e2ee-worker/failureTracker.ts @@ -9,7 +9,7 @@ export class FailureTracker { /** * True only on the failure crossing {@link FAILURE_TOLERANCE}, so - * `e2ee.broken` fires once per run. + * `e2ee.decryption_stalled` fires once per run. */ recordFailure = (keyIndex: number): boolean => { const next = (this.counts.get(keyIndex) ?? 0) + 1; diff --git a/packages/client/src/rtc/e2ee/e2ee-worker/keyStore.ts b/packages/client/src/rtc/e2ee/e2ee-worker/keyStore.ts index f9e7259671..4b6b8affb3 100644 --- a/packages/client/src/rtc/e2ee/e2ee-worker/keyStore.ts +++ b/packages/client/src/rtc/e2ee/e2ee-worker/keyStore.ts @@ -162,7 +162,7 @@ export class KeyStore { * Debug snapshot. Fingerprints only: enough to confirm a sender and receiver * hold matching key material, and it exposes no key. */ - dump = () => ({ + keyState = () => ({ perUserKeys: Array.from(this.perUserKeys).flatMap(([userId, perKeyIndex]) => Array.from(perKeyIndex, ([keyIndex, km]) => ({ userId, diff --git a/packages/client/src/rtc/e2ee/e2ee-worker/notifications.ts b/packages/client/src/rtc/e2ee/e2ee-worker/notifications.ts index f4e783a92e..0036830438 100644 --- a/packages/client/src/rtc/e2ee/e2ee-worker/notifications.ts +++ b/packages/client/src/rtc/e2ee/e2ee-worker/notifications.ts @@ -103,6 +103,13 @@ export class DecodeNotifier { */ private readonly missingKeyThrottle = new Throttle(THROTTLE_INTERVAL_MS); private readonly cleartextThrottle = new Throttle(THROTTLE_INTERVAL_MS); + /** + * Keyed by user, deliberately not by version. A sender's version is a + * compile-time constant, so a track only ever sees one value legitimately - + * while the byte is plaintext, so a relay rewriting it per frame would + * otherwise get one event per distinct value and multiply the rate by 255. + */ + private readonly versionThrottle = new Throttle(THROTTLE_INTERVAL_MS); /** * True once a `decryption_failed` reached the host. Pairs the two signals: * only a delivered failure needs clearing, and clearing it re-arms this. @@ -142,8 +149,22 @@ export class DecodeNotifier { self.postMessage({ type: 'e2ee.missing_key', userId: this.userId, + trackType: this.trackType, keyIndex, + }); + }; + + /** + * A frame carried this format but a version this build cannot decrypt, so it + * was dropped. Throttled per track. + */ + unsupportedVersion = (version: number): void => { + if (!this.versionThrottle.tryFire(this.userId)) return; + self.postMessage({ + type: 'e2ee.unsupported_version', + userId: this.userId, trackType: this.trackType, + version, }); }; @@ -158,12 +179,12 @@ export class DecodeNotifier { }; /** Consecutive failures crossed the tolerance. Already once-per-run. */ - broken = (keyIndex: number): void => { + stalled = (keyIndex: number): void => { self.postMessage({ - type: 'e2ee.broken', + type: 'e2ee.decryption_stalled', userId: this.userId, - keyIndex, trackType: this.trackType, + keyIndex, }); }; } diff --git a/packages/client/src/rtc/e2ee/e2ee-worker/trailer.ts b/packages/client/src/rtc/e2ee/e2ee-worker/trailer.ts index 60a5b32849..2cee2ba7cd 100644 --- a/packages/client/src/rtc/e2ee/e2ee-worker/trailer.ts +++ b/packages/client/src/rtc/e2ee/e2ee-worker/trailer.ts @@ -94,15 +94,50 @@ export const readTrailerIv = ( }; }; +/** + * The frozen identification suffix: `version (1) || magic (4)`. A literal, not + * `TRAILER_LEN - OFF_VERSION`, because this length is frozen at 5 for every + * version present and future while those two constants describe v1 only - a + * later layout change must break the round-trip test, not silently move the + * suffix older receivers depend on. `trailer.test.ts` pins it to the bytes + * `writeTrailer` actually emits. + * + * Read from the end rather than from a trailer start, because a future version + * may not use a 20-byte trailer, and a receiver that cannot decrypt such a + * frame still has to recognize it. + */ +const IDENT_SUFFIX_LEN = 5; +/** Within the suffix: the magic follows the single version byte. */ +const OFF_SUFFIX_MAGIC = 1; + +/** + * The framing version of a frame carrying this format, or `null` if the frame + * is not ours at all. + * + * This is what separates the two reasons {@link readTrailer} returns `null`: + * an unrelated cleartext frame, versus an encrypted frame from a peer on a + * version this build cannot decrypt. + * + * @param view - An existing view over exactly `src`, to avoid a second + * allocation on the per-frame decode path. Built here when omitted. + */ +export const readFramingVersion = ( + src: Uint8Array, + view?: DataView, +): number | null => { + if (src.length < IDENT_SUFFIX_LEN) return null; + const bytes = + view ?? new DataView(src.buffer, src.byteOffset, src.byteLength); + const start = src.length - IDENT_SUFFIX_LEN; + if (bytes.getUint32(start + OFF_SUFFIX_MAGIC) !== MAGIC) return null; + return src[start]; +}; + export const readTrailer = (src: Uint8Array): Trailer | null => { if (src.length < TRAILER_LEN) return null; const view = new DataView(src.buffer, src.byteOffset, src.byteLength); + if (readFramingVersion(src, view) !== E2EE_VERSION) return null; const start = src.length - TRAILER_LEN; - if (view.getUint32(start + OFF_MAGIC) !== MAGIC) return null; - const version = src[start + OFF_VERSION]; - // Unknown version means not our trailer, so an unrelated frame that happens - // to end in MAGIC does not reach a decrypt. - if (version !== E2EE_VERSION) return null; const raw = view.getUint16(start + OFF_CLEAR_BYTES); const clearBytes = raw & MAX_CLEAR_BYTES; // Bail out before allocating; the decrypt would fail anyway. diff --git a/packages/client/src/rtc/e2ee/events.ts b/packages/client/src/rtc/e2ee/events.ts index 839082558e..83fc967e08 100644 --- a/packages/client/src/rtc/e2ee/events.ts +++ b/packages/client/src/rtc/e2ee/events.ts @@ -51,6 +51,21 @@ export type UnencryptedFrameEvent = { trackType?: string; }; +/** + * A remote frame carried this format's framing but a `version` this build + * cannot decrypt, so it was dropped. + * + * **This client is the one that needs updating**, not the peer: the peer wrote + * a newer format than this SDK implements. Prompt the local user to update the + * app; no key changes anything. + */ +export type UnsupportedVersionEvent = { + userId: string; + trackType?: string; + /** The framing version read from the frame. */ + version: number; +}; + /** The worker could not decrypt a remote frame. Throttled per track. */ export type DecryptionFailedEvent = { userId: string; @@ -80,9 +95,11 @@ export type EncryptionFailedEvent = { /** * Fired when a remote track passes the internal failure tolerance: decryption - * has failed on that many consecutive frames. + * has failed on that many consecutive frames, so the track renders nothing until + * it recovers. The cause is not known here: a key mismatch is the common one, + * but a tampered or truncated frame looks the same to the decryptor. */ -export type E2EEBrokenEvent = { +export type DecryptionStalledEvent = { userId: string; /** The keyIndex that crossed the tolerance. */ keyIndex: number; @@ -90,7 +107,7 @@ export type E2EEBrokenEvent = { }; /** - * Answer to {@link EncryptionManager.requestKeyDump}. `fingerprint` is hex of + * Answer to {@link EncryptionManager.requestKeyState}. `fingerprint` is hex of * the first 8 bytes of SHA-256(rawKey): not reversible, so safe to log. Key * material is never returned. */ @@ -128,6 +145,13 @@ export type E2EEEventMap = { */ 'e2ee.decryption_resumed': DecryptionResumedEvent; + /** + * Consecutive decrypt failures on one track crossed the internal tolerance. + * Fires once per (userId, keyIndex) entering that state, and + * `e2ee.decryption_resumed` is what clears it. + */ + 'e2ee.decryption_stalled': DecryptionStalledEvent; + /** That track is publishing nothing. Latched, so it reports once. */ 'e2ee.encryption_failed': EncryptionFailedEvent; @@ -140,12 +164,16 @@ export type E2EEEventMap = { 'e2ee.unencrypted_frame': UnencryptedFrameEvent; + /** + * A peer is publishing a framing version this build cannot read, so its + * frames are dropped. Throttled per track; surface it as a prompt to update + * **this** client. + */ + 'e2ee.unsupported_version': UnsupportedVersionEvent; + /** Once per second while {@link EncryptionManager.enablePerformanceReporting} is on. */ 'e2ee.perf_report': PerfReport; - /** Fires once per (userId, keyIndex) entering the failed state. */ - 'e2ee.broken': E2EEBrokenEvent; - - /** Answer to {@link EncryptionManager.requestKeyDump}. */ + /** Answer to {@link EncryptionManager.requestKeyState}. */ 'e2ee.key_state': KeyStateReport; }; diff --git a/sample-apps/react/e2ee-demo/src/components/StatusReadout.tsx b/sample-apps/react/e2ee-demo/src/components/StatusReadout.tsx index 36ec58a32a..b38f9bce13 100644 --- a/sample-apps/react/e2ee-demo/src/components/StatusReadout.tsx +++ b/sample-apps/react/e2ee-demo/src/components/StatusReadout.tsx @@ -67,12 +67,12 @@ export const StatusReadout = ({ participant, nameByUserId }: Props) => { · fail {fmtNames(tracks.failingFrom, nameByUserId)} )} - {tracks.brokenFrom.length > 0 && ( + {tracks.stalledFrom.length > 0 && ( - · broken {fmtNames(tracks.brokenFrom, nameByUserId)} + · stalled {fmtNames(tracks.stalledFrom, nameByUserId)} )} diff --git a/sample-apps/react/e2ee-demo/src/harness/E2EEHarness.ts b/sample-apps/react/e2ee-demo/src/harness/E2EEHarness.ts index 4ff273afd0..4aeb13bfc1 100644 --- a/sample-apps/react/e2ee-demo/src/harness/E2EEHarness.ts +++ b/sample-apps/react/e2ee-demo/src/harness/E2EEHarness.ts @@ -97,7 +97,7 @@ interface EngineParticipant { keyStore: KeyStateReport | null; perf: PerfReport | null; failingFrom: Set; - brokenFrom: Set; + stalledFrom: Set; encryptionFailure: string | null; unsubscribes: Array<() => void>; } @@ -114,8 +114,7 @@ export class E2EEHarness { private activeSharedKeyIndex = -1; private sharedKeyBytes: ArrayBuffer | null = null; private resolvedEncryptionMode: - | EncryptionSettingsResponseModeEnum - | undefined; + EncryptionSettingsResponseModeEnum | undefined; private e2eeEnabled = false; constructor( @@ -212,7 +211,7 @@ export class E2EEHarness { p.enabled && (!!p.currentKey || this.activeSharedKeyIndex >= 0), decryptingFrom, failingFrom, - brokenFrom: [...p.brokenFrom], + stalledFrom: [...p.stalledFrom], }, perf: { encode: p.perf?.encode ?? [], @@ -311,7 +310,7 @@ export class E2EEHarness { keyStore: null, perf: null, failingFrom: new Set(), - brokenFrom: new Set(), + stalledFrom: new Set(), encryptionFailure: null, unsubscribes: [], }; @@ -331,7 +330,7 @@ export class E2EEHarness { call.setE2EEManager(manager); this.wireEvents(p, manager); manager.enablePerformanceReporting(true); - manager.requestKeyDump(); + manager.requestKeyState(); } if (isNormal && manager) { @@ -524,7 +523,7 @@ export class E2EEHarness { }`, 'key-rotate', ); - target.manager.requestKeyDump(); + target.manager.requestKeyState(); this.emit(); }; @@ -549,7 +548,7 @@ export class E2EEHarness { }`, 'key-set', ); - target.manager.requestKeyDump(); + target.manager.requestKeyState(); this.emit(); }; @@ -575,7 +574,7 @@ export class E2EEHarness { for (const p of this.participants) { if (p.role === 'spy') continue; // the spy stays keyless p.manager?.setKey(userId, FIXED_KEY_INDEX, key.slice(0)); - p.manager?.requestKeyDump(); + p.manager?.requestKeyState(); } const local = this.participants.find( (p) => p.userId === userId && p.role === 'normal', @@ -645,7 +644,7 @@ export class E2EEHarness { if (other.role === 'spy') continue; other.manager?.removeKeys(targetUserId); other.failingFrom.delete(targetUserId); - other.brokenFrom.delete(targetUserId); + other.stalledFrom.delete(targetUserId); this.addLog( other.userId, `Removed ${target.name}'s keys`, @@ -705,7 +704,7 @@ export class E2EEHarness { `Set WRONG key (#${keyIndex}) [not distributed]`, 'key-rotate', ); - target.manager.requestKeyDump(); + target.manager.requestKeyState(); this.emit(); }; @@ -736,7 +735,7 @@ export class E2EEHarness { }), m.on('e2ee.decryption_resumed', ({ userId: remoteUserId }) => { p.failingFrom.delete(remoteUserId); - p.brokenFrom.delete(remoteUserId); + p.stalledFrom.delete(remoteUserId); this.addLog( p.userId, `Decryption resumed from ${this.nameFor(remoteUserId)}`, @@ -744,11 +743,11 @@ export class E2EEHarness { ); this.emit(); }), - m.on('e2ee.broken', ({ userId: remoteUserId, keyIndex }) => { - p.brokenFrom.add(remoteUserId); + m.on('e2ee.decryption_stalled', ({ userId: remoteUserId, keyIndex }) => { + p.stalledFrom.add(remoteUserId); this.addLog( p.userId, - `E2EE broken from ${this.nameFor(remoteUserId)} (key #${keyIndex}): failures past tolerance`, + `E2EE decryption stalled from ${this.nameFor(remoteUserId)} (key #${keyIndex}): failures past tolerance`, 'error', ); this.emit(); @@ -767,6 +766,20 @@ export class E2EEHarness { ); this.emit(); }), + // A peer on a newer framing version: its frames are dropped rather than + // rendered as garbage. This build is the stale one, so the prompt belongs + // on this client, not on the peer. + m.on( + 'e2ee.unsupported_version', + ({ userId: remoteUserId, version, trackType }) => { + this.addLog( + p.userId, + `${this.nameFor(remoteUserId)} publishes E2EE version ${version}, which this build cannot read: ${trackType ?? 'track'} dropped. Update this app.`, + 'error', + ); + this.emit(); + }, + ), m.on('e2ee.encryption_failed', ({ reason, trackType }) => { p.encryptionFailure = reason; this.addLog( diff --git a/sample-apps/react/e2ee-demo/src/harness/snapshot.ts b/sample-apps/react/e2ee-demo/src/harness/snapshot.ts index c9e0b2e184..ea86e4b5fa 100644 --- a/sample-apps/react/e2ee-demo/src/harness/snapshot.ts +++ b/sample-apps/react/e2ee-demo/src/harness/snapshot.ts @@ -45,11 +45,11 @@ export interface HarnessParticipant { /** Remotes reporting `e2ee.decryption_failed`, possibly transient. */ failingFrom: string[]; /** - * Remotes whose session the SDK declared broken via `e2ee.broken` - - * decryption failed past the internal tolerance, so this is terminal - * until new key material arrives. + * Remotes the SDK reported via `e2ee.decryption_stalled` - decryption + * failed past the internal tolerance, so this is terminal until new key + * material arrives. */ - brokenFrom: string[]; + stalledFrom: string[]; }; perf: PerfReport; // Live SDK handles, for rendering only. Never serialized. diff --git a/sample-apps/react/react-dogfood/hooks/useE2eeKeyStatus.ts b/sample-apps/react/react-dogfood/hooks/useE2eeKeyStatus.ts index 0fa5e7f954..26522acad5 100644 --- a/sample-apps/react/react-dogfood/hooks/useE2eeKeyStatus.ts +++ b/sample-apps/react/react-dogfood/hooks/useE2eeKeyStatus.ts @@ -29,10 +29,10 @@ export type E2EEKeyStatus = * per remote peer - so the verdict comes from the breadth of the failures rather * than from any single event. * - * `e2ee.broken` is the trigger, not `e2ee.decryption_failed`: the latter fires - * once a second for any transient mismatch, including the brief window while a - * key change propagates, and would cry wolf. `broken` means the track has failed - * past the SDK's tolerance and is not recovering on its own. + * `e2ee.decryption_stalled` is the trigger, not `e2ee.decryption_failed`: the + * latter fires once a second for any transient mismatch, including the brief + * window while a key change propagates, and would cry wolf. Stalled means the + * track has failed past the SDK's tolerance and is not recovering on its own. * * Blind spots worth knowing: alone in the call, or with every peer muted and * camera-off, a wrong key is undetectable. And if two peers share the same wrong @@ -44,8 +44,8 @@ export const useE2eeKeyStatus = (): E2EEKeyStatus => { const remoteParticipants = useRemoteParticipants(); // Keyed per (userId, trackType) because the SDK counts failures per track: a // peer publishing audio and video reports them independently, and their video - // can recover while audio is still broken. - const [brokenTracks, setBrokenTracks] = useState>( + // can recover while audio is still stalled. + const [stalledTracks, setStalledTracks] = useState>( () => new Set(), ); @@ -59,15 +59,15 @@ export const useE2eeKeyStatus = (): E2EEKeyStatus => { `${userId}/${trackType ?? 'unknown'}`; const unsubscribes = [ - manager.on('e2ee.broken', ({ userId, trackType }) => { - setBrokenTracks((prev) => { + manager.on('e2ee.decryption_stalled', ({ userId, trackType }) => { + setStalledTracks((prev) => { const next = new Set(prev); next.add(trackKey(userId, trackType)); return next; }); }), manager.on('e2ee.decryption_resumed', ({ userId, trackType }) => { - setBrokenTracks((prev) => { + setStalledTracks((prev) => { const key = trackKey(userId, trackType); if (!prev.has(key)) return prev; const next = new Set(prev); @@ -81,9 +81,9 @@ export const useE2eeKeyStatus = (): E2EEKeyStatus => { }, [call]); return useMemo(() => { - if (brokenTracks.size === 0) return { kind: 'ok' }; - const brokenUserIds = new Set( - [...brokenTracks].map((key) => key.slice(0, key.lastIndexOf('/'))), + if (stalledTracks.size === 0) return { kind: 'ok' }; + const stalledUserIds = new Set( + [...stalledTracks].map((key) => key.slice(0, key.lastIndexOf('/'))), ); // Judge only against peers that are actually sending something: a muted, // camera-off peer produces no frames and so no evidence either way. Peers @@ -93,7 +93,7 @@ export const useE2eeKeyStatus = (): E2EEKeyStatus => { (participant) => participant.publishedTracks.length > 0, ); const failing = publishing.filter((participant) => - brokenUserIds.has(participant.userId), + stalledUserIds.has(participant.userId), ); if (failing.length === 0) return { kind: 'ok' }; if (failing.length === publishing.length) @@ -104,5 +104,5 @@ export const useE2eeKeyStatus = (): E2EEKeyStatus => { (participant) => participant.name || participant.userId, ), }; - }, [brokenTracks, remoteParticipants]); + }, [stalledTracks, remoteParticipants]); };