Skip to content

feat(client): add E2EE support via WebRTC Encoded Transforms#2198

Open
oliverlaz wants to merge 81 commits into
mainfrom
feat/e2ee-encoded-transforms
Open

feat(client): add E2EE support via WebRTC Encoded Transforms#2198
oliverlaz wants to merge 81 commits into
mainfrom
feat/e2ee-encoded-transforms

Conversation

@oliverlaz

@oliverlaz oliverlaz commented Apr 10, 2026

Copy link
Copy Markdown
Member

💡 Overview

Add end-to-end encryption for media tracks using a symmetric XOR transform applied to every encoded frame in a dedicated Web Worker.

📝 Implementation notes

  • New packages/client/src/rtc/e2ee/index.ts module with a single shared Worker that handles both encode and decode transforms
  • Uses RTCRtpScriptTransform (W3C standard) on Safari/Firefox, falls back to Insertable Streams (createEncodedStreams) on Chrome where RTCRtpScriptTransform is unreliable
  • Worker handles both paths: onrtctransform event for the standard API, onmessage for Insertable Streams
  • XOR transform uses DataView + charCodeAt, following the webrtc/samples reference
  • BasePeerConnection conditionally sets encodedInsertableStreams: true on the PC config for Chrome
  • Publisher.addTransceiver attaches encryptor to sender, Subscriber.handleOnTrack attaches decryptor to receiver
  • WeakSet guard prevents double-piping on createEncodedStreams (which can only be called once per sender/receiver)
  • encryptionKey option added to ClientPublishOptions and wired through the dogfood app via ?encryption_key= query param

🎫 Ticket: https://linear.app/stream/issue/XYZ-123
Docs: https://github.com/GetStream/docs-content/pull/1399

Summary by CodeRabbit

  • New Features

    • End-to-end encryption (E2EE) for published and subscribed media streams with key management and runtime support detection.
    • Public API to attach an encryption manager to calls and enable E2EE via query parameters for easy demo/config setup.
    • New E2EE demo app showcasing key controls, participant management, and event logging.
  • Tests

    • Added unit tests covering E2EE attach/detach behavior for publishers and subscribers.
  • Chores

    • Build and test infra updates to support E2EE worker/scripted transforms.

Add end-to-end encryption for media tracks using a symmetric XOR
transform applied to every encoded frame in a dedicated Web Worker.

Uses RTCRtpScriptTransform (W3C standard) on browsers that support it
(Safari, Firefox) and falls back to Insertable Streams
(createEncodedStreams) on Chrome where RTCRtpScriptTransform is
unreliable.
@changeset-bot

changeset-bot Bot commented Apr 10, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 4b53e3e

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Apr 10, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds end-to-end encryption (E2EE) for WebRTC encoded frames: introduces an inline worker and EncryptionManager, integrates E2EE into peer connection creation, Publisher/Subscriber flows, Call API, tests, mocks, build config, and a new React E2EE demo app and utilities.

Changes

Cohort / File(s) Summary
E2EE Core
packages/client/src/rtc/e2ee/EncryptionManager.ts, packages/client/src/rtc/e2ee/worker.ts
New EncryptionManager API and inline worker source implementing AES-128-GCM encrypt/decrypt for encoded WebRTC frames, key lifecycle messages (setKey, setSharedKey, removeKeys, dispose), per-user counters, trailer format, codec-specific clear-byte handling, and decryption failure reporting.
RTC Integration
packages/client/src/rtc/BasePeerConnection.ts, packages/client/src/rtc/Publisher.ts, packages/client/src/rtc/Subscriber.ts, packages/client/src/rtc/types.ts
Peer connection creation optionally sets encodedInsertableStreams when an EncryptionManager is provided (Chrome-only); Publisher.addTransceiver calls e2ee.encrypt(...) for senders; Subscriber.handleOnTrack calls e2ee.decrypt(...) for receivers; types extended with e2ee?: EncryptionManager.
Call & Exports
packages/client/src/Call.ts, packages/client/index.ts
Call stores optional e2eeManager, adds setE2EEManager() to configure it before join, and package entry now re-exports EncryptionManager.
Tests & Mocks
packages/client/src/rtc/__tests__/Publisher.test.ts, packages/client/src/rtc/__tests__/Subscriber.test.ts, packages/client/src/rtc/__tests__/mocks/webrtc.mocks.ts
Added tests asserting encrypt/decrypt wiring; expanded WebRTC mocks to include transform fields, RTCRtpScriptTransform, Worker stub, and URL.createObjectURL polyfill.
Sample App: react-dogfood
sample-apps/react/react-dogfood/lib/queryConfigParams.ts, sample-apps/react/react-dogfood/components/MeetingUI.tsx, sample-apps/react/react-dogfood/pages/bare/join/[callId].tsx
Query param encryption_key parsed; applyQueryConfigParams became async and derives a 128-bit AES key via PBKDF2 (salt stream-e2ee, 100k iterations) to initialize EncryptionManager and attach it to Call; call sites updated to await and handle errors.
Sample App: new E2EE demo
sample-apps/react/e2ee-demo/...
Added a new React E2EE demo app (components, hooks, key utilities, types, configs, vite/tsconfig, HTML) implementing UI for participant/key management, in-memory key transport demo, and useE2EEDemo hook wired to EncryptionManager.
Key Management Utilities
sample-apps/react/e2ee-demo/src/e2ee/keys.ts, sample-apps/react/e2ee-demo/src/types.ts
New key utilities and lifecycle functions: key generation, hex/passphrase parsing and PBKDF2 derivation, initialize/distribute/exchange/rotate/set/revoke helpers, and demo types for participants and event logs.
Build config
packages/client/rollup.config.mjs
Browser output changed to directory-based dist with explicit entry/chunk names; enabled inlineDynamicImports for node build configs.

Sequence Diagrams

sequenceDiagram
    participant Client
    participant Publisher
    participant BasePeerConnection
    participant RTCPeerConnection
    participant EncryptionManager
    participant Worker
    participant RTCRtpSender

    Client->>Publisher: publish(track, codec)
    Publisher->>BasePeerConnection: createPeerConnection(config, e2ee?)
    BasePeerConnection->>BasePeerConnection: clone RTCConfiguration
    alt e2ee present and Chrome
        BasePeerConnection->>BasePeerConnection: set encodedInsertableStreams = true
    end
    BasePeerConnection->>RTCPeerConnection: new RTCPeerConnection(modified config)
    Publisher->>RTCRtpSender: addTransceiver -> sender
    Publisher->>EncryptionManager: encrypt(sender, codec)
    EncryptionManager->>Worker: postMessage(attach encode streams / transfer streams)
    Worker->>RTCRtpSender: attach encode TransformStream
    RTCRtpSender->>Worker: encoded frames -> Worker (transform)
    Worker->>Worker: AES-128-GCM encrypt, append trailer
    Worker->>RTCRtpSender: output encrypted frames
Loading
sequenceDiagram
    participant Network
    participant RTCRtpReceiver
    participant Worker
    participant EncryptionManager
    participant Subscriber
    participant Client

    Network->>RTCRtpReceiver: RTP packets (encrypted payload)
    RTCRtpReceiver->>Worker: encoded frames -> Worker (transform)
    Worker->>Worker: validate trailer, extract keyIndex/counter
    Worker->>Worker: AES-128-GCM decrypt, RBSP unescape if needed
    Worker->>RTCRtpReceiver: decrypted frames
    RTCRtpReceiver->>Subscriber: ontrack(receiver, stream)
    Subscriber->>EncryptionManager: decrypt(receiver, userId)
    EncryptionManager->>Worker: postMessage(attach decode streams)
    Subscriber->>Client: deliver media track for playback
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Poem

🐰 I stitched keys in rabbit seams,

Workers hum and AES dreams,
Frames go secret, wrapped in light,
Transforms dance through day and night,
Private hops in encrypted streams 🥕🔐

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main feature: adding E2EE support via WebRTC Encoded Transforms.
Description check ✅ Passed The PR description follows the required template with overview, implementation notes, ticket, and docs sections filled in.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/e2ee-encoded-transforms

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@oliverlaz
oliverlaz requested a review from jdimovska April 10, 2026 08:52

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (5)
packages/client/src/rtc/Publisher.ts (1)

141-149: Consider wrapping createEncryptor in a try-catch.

If createEncryptor throws (e.g., Worker instantiation failure, invalid key), the entire addTransceiver flow will fail and prevent track publishing. A defensive try-catch with a warning log would allow graceful degradation.

🛡️ Proposed defensive handling
     const { encryptionKey } = this.clientPublishOptions || {};
     if (encryptionKey) {
       if (supportsE2EE()) {
-        createEncryptor(transceiver.sender, encryptionKey);
-        this.logger.debug('E2EE encryptor attached to sender');
+        try {
+          createEncryptor(transceiver.sender, encryptionKey);
+          this.logger.debug('E2EE encryptor attached to sender');
+        } catch (err) {
+          this.logger.warn('Failed to attach E2EE encryptor', err);
+        }
       } else {
         this.logger.warn(`E2EE requested but not supported`);
       }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/client/src/rtc/Publisher.ts` around lines 141 - 149, Wrap the call
to createEncryptor(transceiver.sender, encryptionKey) inside a try-catch so that
exceptions from encryptor creation (e.g., Worker instantiation or invalid key)
do not abort the addTransceiver/publish flow; on catch, log a warning via
this.logger.warn including the error and continue without attaching the
encryptor, leaving behavior unchanged when supportsE2EE() is false, and keep the
debug log only on successful creation.
packages/client/src/rtc/Subscriber.ts (1)

97-105: Consider wrapping createDecryptor in a try-catch for consistency with Publisher.

Same reasoning as Publisher: if createDecryptor throws, the track handling will fail entirely. A defensive try-catch would allow the track to still be processed (unencrypted) rather than dropping it.

🛡️ Proposed defensive handling
     const { encryptionKey } = this.clientPublishOptions || {};
     if (encryptionKey) {
       if (supportsE2EE()) {
-        createDecryptor(e.receiver, encryptionKey);
-        this.logger.debug('E2EE decryptor attached to receiver');
+        try {
+          createDecryptor(e.receiver, encryptionKey);
+          this.logger.debug('E2EE decryptor attached to receiver');
+        } catch (err) {
+          this.logger.warn('Failed to attach E2EE decryptor', err);
+        }
       } else {
         this.logger.warn(`E2EE requested but not supported`);
       }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/client/src/rtc/Subscriber.ts` around lines 97 - 105, The
createDecryptor invocation in Subscriber (using createDecryptor(e.receiver,
encryptionKey) guarded by supportsE2EE()) should be wrapped in a try-catch like
Publisher to avoid losing the entire track if decryption initialization throws;
update the block where this.clientPublishOptions?.encryptionKey is checked to
call createDecryptor inside a try, log a warning or debug on failure via
this.logger (e.g., this.logger.warn('E2EE decryptor failed to attach', error)),
and continue processing the track if an exception occurs so unencrypted handling
still proceeds.
sample-apps/react/react-dogfood/lib/queryConfigParams.ts (1)

19-19: Security note: encryption key in URL query string.

Exposing the encryption key via ?encryption_key= makes it visible in browser history, server logs, and referrer headers. This is acceptable for a dogfood/testing app, but ensure this pattern isn't replicated in production integrations where the key should be exchanged out-of-band.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sample-apps/react/react-dogfood/lib/queryConfigParams.ts` at line 19, The
code currently reads the encryption key from the URL query (encryptionKey:
query['encryption_key']) which exposes secrets; change this to not pull
sensitive keys from the query string — remove or default encryptionKey to
undefined in queryConfigParams.ts and instead accept the key via a secure
channel (e.g., an explicit config parameter passed from server-side code or
process.env when in safe dev/dogfood only); if you must keep a dev-only
shortcut, gate it behind a strict environment check (e.g., NODE_ENV ===
'development' or a DOGFOOD flag) and document that production must supply keys
out-of-band.
packages/client/src/rtc/e2ee/index.ts (2)

31-63: Add error handling for pipe operations and consider key encoding.

Two concerns with the worker implementation:

  1. Missing error handling: pipeTo() returns a Promise that can reject on stream errors. Silent failures during encryption/decryption would be difficult to debug.

  2. Key encoding: charCodeAt() returns values > 255 for non-ASCII characters, which setInt8 truncates. While this works symmetrically, it's worth documenting that keys should be ASCII-only, or encoding the key to bytes explicitly.

♻️ Suggested improvement for error handling
 function handleTransform({ readable, writable, key }) {
-  readable.pipeThrough(xorTransform(key)).pipeTo(writable);
+  readable.pipeThrough(xorTransform(key)).pipeTo(writable).catch((err) => {
+    console.error('[stream-video-e2ee] transform pipeline error:', err);
+  });
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/client/src/rtc/e2ee/index.ts` around lines 31 - 63, The worker lacks
stream error handling and uses charCodeAt which mishandles non-ASCII key bytes;
update xorTransform to explicitly encode the key to a byte array (e.g., via
TextEncoder) and use unsigned byte access (getUint8/setUint8 or Uint8Array) so
key bytes are 0-255, and in handleTransform where readable.pipeTo(writable) is
used (and in the onrtctransform/onmessage dispatch paths) attach a rejection
handler (pipeTo(...).catch(err => { /* report via postMessage or console.error
*/ })) or await and try/catch to surface errors back to the main thread for
debugging; reference functions: xorTransform, handleTransform, the
self.onrtctransform handler, and self.onmessage handler.

83-99: The operation parameter is passed but unused in the worker.

The operation value ('encode'/'decode') is included in the message but the worker's handleTransform function ignores it since XOR is symmetric. Consider either:

  • Removing it to avoid confusion
  • Adding a comment explaining it's reserved for future asymmetric transforms

This is cosmetic since the current implementation works correctly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/client/src/rtc/e2ee/index.ts` around lines 83 - 99, The worker
message includes an unused 'operation' field which is confusing; update
attachTransform (and its getWorker/w.postMessage call) to stop sending the
operation value and remove it from the function signature, and also remove or
stop depending on operation elsewhere; alternatively, if you prefer to keep the
API, add a concise comment in attachTransform and in the worker's
handleTransform noting that XOR is symmetric so 'operation' is currently unused
and reserved for potential future asymmetric transforms (reference
attachTransform, w.postMessage, and handleTransform).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/client/src/rtc/e2ee/index.ts`:
- Around line 94-95: The current code initializes piped with a Set (if ((piped
??= new Set()).has(target) return; piped.add(target);) but it should be a
WeakSet to avoid memory leaks for object targets; change the initializer from
new Set() to new WeakSet() and update the piped variable's declared type (where
it's defined) to WeakSet<typeof target> (or WeakSet<object>) so has/add calls on
piped remain valid.
- Around line 65-68: Change piped from a Set to a WeakSet to avoid retaining
strong references to RTCRtpSender/RTCRtpReceiver (declare piped as
WeakSet<RTCRtpSender | RTCRtpReceiver> and initialize it accordingly), and add
explicit cleanup to release worker resources by terminating/disposing the Worker
and clearing worker and workerUrl when the associated Call/Publisher/Subscriber
is disposed; specifically, in the code paths for Call.dispose, Publisher.dispose
and Subscriber.dispose (or any e2ee teardown function), call worker.terminate()
or the worker's disposal method if available, then set worker = undefined and
workerUrl = undefined and set piped = undefined to allow GC.

---

Nitpick comments:
In `@packages/client/src/rtc/e2ee/index.ts`:
- Around line 31-63: The worker lacks stream error handling and uses charCodeAt
which mishandles non-ASCII key bytes; update xorTransform to explicitly encode
the key to a byte array (e.g., via TextEncoder) and use unsigned byte access
(getUint8/setUint8 or Uint8Array) so key bytes are 0-255, and in handleTransform
where readable.pipeTo(writable) is used (and in the onrtctransform/onmessage
dispatch paths) attach a rejection handler (pipeTo(...).catch(err => { /* report
via postMessage or console.error */ })) or await and try/catch to surface errors
back to the main thread for debugging; reference functions: xorTransform,
handleTransform, the self.onrtctransform handler, and self.onmessage handler.
- Around line 83-99: The worker message includes an unused 'operation' field
which is confusing; update attachTransform (and its getWorker/w.postMessage
call) to stop sending the operation value and remove it from the function
signature, and also remove or stop depending on operation elsewhere;
alternatively, if you prefer to keep the API, add a concise comment in
attachTransform and in the worker's handleTransform noting that XOR is symmetric
so 'operation' is currently unused and reserved for potential future asymmetric
transforms (reference attachTransform, w.postMessage, and handleTransform).

In `@packages/client/src/rtc/Publisher.ts`:
- Around line 141-149: Wrap the call to createEncryptor(transceiver.sender,
encryptionKey) inside a try-catch so that exceptions from encryptor creation
(e.g., Worker instantiation or invalid key) do not abort the
addTransceiver/publish flow; on catch, log a warning via this.logger.warn
including the error and continue without attaching the encryptor, leaving
behavior unchanged when supportsE2EE() is false, and keep the debug log only on
successful creation.

In `@packages/client/src/rtc/Subscriber.ts`:
- Around line 97-105: The createDecryptor invocation in Subscriber (using
createDecryptor(e.receiver, encryptionKey) guarded by supportsE2EE()) should be
wrapped in a try-catch like Publisher to avoid losing the entire track if
decryption initialization throws; update the block where
this.clientPublishOptions?.encryptionKey is checked to call createDecryptor
inside a try, log a warning or debug on failure via this.logger (e.g.,
this.logger.warn('E2EE decryptor failed to attach', error)), and continue
processing the track if an exception occurs so unencrypted handling still
proceeds.

In `@sample-apps/react/react-dogfood/lib/queryConfigParams.ts`:
- Line 19: The code currently reads the encryption key from the URL query
(encryptionKey: query['encryption_key']) which exposes secrets; change this to
not pull sensitive keys from the query string — remove or default encryptionKey
to undefined in queryConfigParams.ts and instead accept the key via a secure
channel (e.g., an explicit config parameter passed from server-side code or
process.env when in safe dev/dogfood only); if you must keep a dev-only
shortcut, gate it behind a strict environment check (e.g., NODE_ENV ===
'development' or a DOGFOOD flag) and document that production must supply keys
out-of-band.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 34706f24-48cc-4daa-a7dc-7f51973b58e7

📥 Commits

Reviewing files that changed from the base of the PR and between b84416b and 4d6ba0e.

📒 Files selected for processing (9)
  • packages/client/src/rtc/BasePeerConnection.ts
  • packages/client/src/rtc/Publisher.ts
  • packages/client/src/rtc/Subscriber.ts
  • packages/client/src/rtc/__tests__/Publisher.test.ts
  • packages/client/src/rtc/__tests__/Subscriber.test.ts
  • packages/client/src/rtc/__tests__/mocks/webrtc.mocks.ts
  • packages/client/src/rtc/e2ee/index.ts
  • packages/client/src/types.ts
  • sample-apps/react/react-dogfood/lib/queryConfigParams.ts

Comment thread packages/client/src/rtc/e2ee/index.ts Outdated
Comment on lines +65 to +68
/** Tracks senders/receivers that already have encoded streams piped. */
let piped: Set<RTCRtpSender | RTCRtpReceiver> | undefined;
let worker: Worker | undefined;
let workerUrl: string | undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use WeakSet instead of Set to prevent memory leaks.

The piped variable holds strong references to RTCRtpSender/RTCRtpReceiver objects. These won't be garbage collected even after the peer connection is closed, causing a memory leak that grows with each call.

Additionally, there's no disposal mechanism for worker and workerUrl, which violates the coding guideline: "Always call dispose() on Call, Publisher, Subscriber when done for proper memory management."

🐛 Proposed fix using WeakSet and adding disposal
 /** Tracks senders/receivers that already have encoded streams piped. */
-let piped: Set<RTCRtpSender | RTCRtpReceiver> | undefined;
+let piped: WeakSet<RTCRtpSender | RTCRtpReceiver> | undefined;
 let worker: Worker | undefined;
 let workerUrl: string | undefined;
+
+/**
+ * Disposes E2EE resources. Call when encryption is no longer needed.
+ */
+export const disposeE2EE = () => {
+  if (worker) {
+    worker.terminate();
+    worker = undefined;
+  }
+  if (workerUrl) {
+    URL.revokeObjectURL(workerUrl);
+    workerUrl = undefined;
+  }
+  piped = undefined;
+};
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/** Tracks senders/receivers that already have encoded streams piped. */
let piped: Set<RTCRtpSender | RTCRtpReceiver> | undefined;
let worker: Worker | undefined;
let workerUrl: string | undefined;
/** Tracks senders/receivers that already have encoded streams piped. */
let piped: WeakSet<RTCRtpSender | RTCRtpReceiver> | undefined;
let worker: Worker | undefined;
let workerUrl: string | undefined;
/**
* Disposes E2EE resources. Call when encryption is no longer needed.
*/
export const disposeE2EE = () => {
if (worker) {
worker.terminate();
worker = undefined;
}
if (workerUrl) {
URL.revokeObjectURL(workerUrl);
workerUrl = undefined;
}
piped = undefined;
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/client/src/rtc/e2ee/index.ts` around lines 65 - 68, Change piped
from a Set to a WeakSet to avoid retaining strong references to
RTCRtpSender/RTCRtpReceiver (declare piped as WeakSet<RTCRtpSender |
RTCRtpReceiver> and initialize it accordingly), and add explicit cleanup to
release worker resources by terminating/disposing the Worker and clearing worker
and workerUrl when the associated Call/Publisher/Subscriber is disposed;
specifically, in the code paths for Call.dispose, Publisher.dispose and
Subscriber.dispose (or any e2ee teardown function), call worker.terminate() or
the worker's disposal method if available, then set worker = undefined and
workerUrl = undefined and set piped = undefined to allow GC.

Comment thread packages/client/src/rtc/e2ee/index.ts Outdated
Comment on lines +94 to +95
if ((piped ??= new Set()).has(target)) return;
piped.add(target);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Update to WeakSet here as well.

Per the fix suggested above, this line should create a WeakSet instead of Set.

🐛 Proposed fix
-  if ((piped ??= new Set()).has(target)) return;
+  if ((piped ??= new WeakSet()).has(target)) return;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if ((piped ??= new Set()).has(target)) return;
piped.add(target);
if ((piped ??= new WeakSet()).has(target)) return;
piped.add(target);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/client/src/rtc/e2ee/index.ts` around lines 94 - 95, The current code
initializes piped with a Set (if ((piped ??= new Set()).has(target) return;
piped.add(target);) but it should be a WeakSet to avoid memory leaks for object
targets; change the initializer from new Set() to new WeakSet() and update the
piped variable's declared type (where it's defined) to WeakSet<typeof target>
(or WeakSet<object>) so has/add calls on piped remain valid.

Split the monolithic e2ee/index.ts into three files:
- compatibility.ts: supportsE2EE() stays in the main bundle
- worker.ts: worker source string + lifecycle management
- e2ee.ts: createEncryptor/createDecryptor API

Publisher and Subscriber now dynamically import the heavy E2EE code,
so consumers who don't use E2EE pay zero bundle cost. The browser
build emits a separate e2ee chunk; node builds inline it.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
packages/client/src/rtc/e2ee/e2ee.ts (1)

7-11: Consider broadening the Chromium detection comment.

The navigator.userAgent?.includes('Chrome') check will match all Chromium-based browsers (Edge, Opera, Brave, etc.), not just Chrome. This is likely intentional since they share the same WebRTC implementation quirks, but a clarifying comment would help future maintainers.

📝 Suggested documentation improvement
 /**
- * Chrome exposes RTCRtpScriptTransform, but it doesn't seem to work reliably.
- * Use Insertable Streams (createEncodedStreams) there instead.
+ * Chromium-based browsers (Chrome, Edge, Opera, etc.) expose RTCRtpScriptTransform,
+ * but it doesn't work reliably. Use Insertable Streams (createEncodedStreams) instead.
+ * The "Chrome" UA substring check intentionally matches all Chromium derivatives.
  */
 const shouldUseInsertableStreams = (): boolean =>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/client/src/rtc/e2ee/e2ee.ts` around lines 7 - 11, The userAgent
check in shouldUseInsertableStreams() currently uses
navigator.userAgent?.includes('Chrome') which also matches other Chromium-based
browsers (Edge, Brave, Opera); update the code by adding a clarifying comment
above shouldUseInsertableStreams() stating that this check intentionally targets
Chromium-based browsers (not only Google Chrome) because they share the same
WebRTC/RTCRtpSender behavior (specifically the presence of createEncodedStreams
on RTCRtpSender.prototype), so future maintainers understand the broader match
and the rationale for keeping the existing navigator.userAgent check.
packages/client/src/rtc/e2ee/worker.ts (1)

223-237: Add worker disposal mechanism to prevent memory leaks.

The worker is lazily created but never cleaned up. Per coding guidelines, cleanup/disposal should be implemented for proper memory management. Also, workerUrl is never revoked.

♻️ Proposed addition for worker cleanup
 let worker: Worker | undefined;
 let workerUrl: string | undefined;

 export const getWorker = () => {
   if (!worker) {
     if (!workerUrl) {
       const blob = new Blob([WORKER_SOURCE], {
         type: 'application/javascript',
       });
       workerUrl = URL.createObjectURL(blob);
     }
     worker = new Worker(workerUrl, { name: 'stream-video-e2ee' });
   }
   return worker;
 };
+
+export const disposeWorker = () => {
+  if (worker) {
+    worker.terminate();
+    worker = undefined;
+  }
+  if (workerUrl) {
+    URL.revokeObjectURL(workerUrl);
+    workerUrl = undefined;
+  }
+};

The disposeWorker function should then be called from Publisher.dispose() / Subscriber.dispose() or a higher-level cleanup.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/client/src/rtc/e2ee/worker.ts` around lines 223 - 237, The module
lazily creates a Worker in getWorker but never disposes it or revokes workerUrl,
causing memory leaks; add a disposeWorker function that checks the module-scoped
worker and workerUrl and if present calls worker.terminate(),
URL.revokeObjectURL(workerUrl), and sets both worker and workerUrl to undefined,
and invoke disposeWorker from higher-level cleanup such as Publisher.dispose() /
Subscriber.dispose() to ensure proper teardown of the WORKER_SOURCE-backed
worker.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/client/src/rtc/e2ee/worker.ts`:
- Around line 126-132: The transform method currently bypasses encryption for
AV1 frames (check: transform function, condition codec === 'av1') which can
leave AV1 streams in plaintext; modify transform to emit a clear runtime warning
via the module's logger (or console.warn if no logger exists) whenever codec ===
'av1' and E2EE is enabled, and update the public API docs/comments to explicitly
state that AV1 frames are not encrypted by this E2EE implementation so users are
aware of the caveat.
- Around line 115-119: Add a disposal and recovery mechanism for the E2EE
worker: implement a dispose() that unregisters event listeners registered on the
worker (rtctransform, message), calls worker.terminate(), and calls
URL.revokeObjectURL(workerUrl); add error and 'onerror'/'onmessageerror'
handling in getWorker() so the worker can be reset/recreated on failure;
document via JSDoc on the module/getWorker()/xorPayload() that this
XOR-with-repeating-key implementation (and the 0xDEADBEEF frame marker) is a
reference/demo only and not cryptographically secure and recommend authenticated
encryption (e.g., IETF SFrame / AES‑GCM per RFC 9605) for production; finally
add a small recovery API (e.g., resetWorker()/ensureWorker()) to recreate the
worker after errors.

In `@packages/client/src/rtc/Subscriber.ts`:
- Around line 97-107: The dynamic import in Subscriber (where supportsE2EE()
leads to import('./e2ee/e2ee').then(({ createDecryptor }) => {
createDecryptor(e.receiver, encryptionKey); this.logger.debug('E2EE decryptor
attached to receiver'); })) lacks error handling; add a .catch handler on the
import promise to log the failure via this.logger.error (include the error and
context like "failed to load E2EE module" or "failed to initialize decryptor")
and, if appropriate, call any fallback or cleanup (e.g., notify caller or avoid
attaching decryptor) so unhandled promise rejections are prevented and failures
are visible.

---

Nitpick comments:
In `@packages/client/src/rtc/e2ee/e2ee.ts`:
- Around line 7-11: The userAgent check in shouldUseInsertableStreams()
currently uses navigator.userAgent?.includes('Chrome') which also matches other
Chromium-based browsers (Edge, Brave, Opera); update the code by adding a
clarifying comment above shouldUseInsertableStreams() stating that this check
intentionally targets Chromium-based browsers (not only Google Chrome) because
they share the same WebRTC/RTCRtpSender behavior (specifically the presence of
createEncodedStreams on RTCRtpSender.prototype), so future maintainers
understand the broader match and the rationale for keeping the existing
navigator.userAgent check.

In `@packages/client/src/rtc/e2ee/worker.ts`:
- Around line 223-237: The module lazily creates a Worker in getWorker but never
disposes it or revokes workerUrl, causing memory leaks; add a disposeWorker
function that checks the module-scoped worker and workerUrl and if present calls
worker.terminate(), URL.revokeObjectURL(workerUrl), and sets both worker and
workerUrl to undefined, and invoke disposeWorker from higher-level cleanup such
as Publisher.dispose() / Subscriber.dispose() to ensure proper teardown of the
WORKER_SOURCE-backed worker.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 72ec27ec-5586-4e79-9399-0b0d70c779d6

📥 Commits

Reviewing files that changed from the base of the PR and between 138501a and 86148a3.

📒 Files selected for processing (7)
  • packages/client/rollup.config.mjs
  • packages/client/src/rtc/Publisher.ts
  • packages/client/src/rtc/Subscriber.ts
  • packages/client/src/rtc/__tests__/Subscriber.test.ts
  • packages/client/src/rtc/e2ee/compatibility.ts
  • packages/client/src/rtc/e2ee/e2ee.ts
  • packages/client/src/rtc/e2ee/worker.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/client/src/rtc/Publisher.ts
  • packages/client/src/rtc/tests/Subscriber.test.ts

Comment thread packages/client/src/rtc/e2ee/worker.ts Outdated
Comment thread packages/client/src/rtc/e2ee/worker.ts Outdated
Comment thread packages/client/src/rtc/Subscriber.ts Outdated
Replace standalone E2EE function exports and ClientPublishOptions.e2ee
config with a single EncryptionManager class that owns the full E2EE
lifecycle: key distribution, transform attachment, and worker management.

- Add EncryptionManager with factory create(), static isSupported(),
  and instance methods: setKey, setSharedKey, removeKeys, encrypt, decrypt
- Upgrade worker crypto from XOR to AES-128-GCM with per-user key store,
  shared key fallback, counter-based IVs, and codec-aware clear bytes
- Add Call.setE2EEManager() for externally-provided manager injection
- Pass manager reference through BasePeerConnectionOpts to Publisher/Subscriber
- Delete e2ee.ts and compatibility.ts (absorbed into EncryptionManager)
- Remove e2ee field from ClientPublishOptions
- worker.ts now exports only WORKER_SOURCE (manager owns worker lifecycle)
- Drop frames when encryption key is missing instead of leaking plaintext
- Drop frames when decryption key is missing instead of feeding ciphertext
  to the decoder
- Expand clearBytes trailer field to 2 bytes (max 32767) to support H.264
  frames with large SPS/PPS/SEI headers
- Add key material length validation (must be exactly 16 bytes for AES-128)
- Add worker error reporting to main thread via postMessage
- Use scoped logger (videoLoggerSystem) instead of console.error
- Add dispose guard to prevent double-termination
- Block AV1 codec in encrypt() instead of silently passing through
- Make dogfood applyQueryConfigParams async to ensure E2EE is initialized
  before join()
- Upgrade sample app key derivation from naive byte cycling to PBKDF2
- Remove unused sharedKeyIndex variable
- Simplify frame counter back to single uint32

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
packages/client/src/rtc/e2ee/EncryptionManager.ts (1)

139-144: Document that rawKey is transferred and becomes unusable after the call.

The [rawKey] transfer array moves ownership to the worker, making the original ArrayBuffer detached (zero-length). Callers who try to reuse the buffer will get unexpected behavior.

📝 Suggested JSDoc addition
  * `@param` rawKey - 16-byte raw AES-128 key material. Transferred to the worker (zero-copy).
+ *         The ArrayBuffer becomes detached after this call and cannot be reused.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/client/src/rtc/e2ee/EncryptionManager.ts` around lines 139 - 144,
The setKey method transfers ownership of rawKey via this.worker.postMessage(...,
[rawKey]) which detaches the original ArrayBuffer; update the JSDoc for setKey
(and mention validateKeyLength behavior) to clearly state that rawKey is
transferred and will become unusable after the call and advise callers to pass a
clone if they need to reuse it (or remove the transfer if non-destructive
behavior is required); ensure the comment references setKey and the postMessage
transfer semantics so future maintainers understand the detachment risk.
packages/client/src/Call.ts (1)

2001-2011: Consider adding runtime validation for timing constraint.

The JSDoc correctly documents that setE2EEManager must be called before join(), but there's no runtime enforcement. If called after join(), the manager won't be passed to the peer connections (they're already created), and E2EE silently won't work.

Consider adding a warning or error when called after joining:

🛡️ Suggested defensive check
 setE2EEManager = (e2ee: EncryptionManager) => {
+  if (this.state.callingState === CallingState.JOINED || 
+      this.state.callingState === CallingState.JOINING) {
+    this.logger.warn(
+      'setE2EEManager called after join - E2EE will not be active for this session'
+    );
+  }
   this.e2eeManager = e2ee;
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/client/src/Call.ts` around lines 2001 - 2011, setE2EEManager can be
called after the call has already joined which silently makes E2EE ineffective;
add a runtime check in the setE2EEManager method that detects whether the call
has already created peer connections (e.g. check a join flag like this.joined or
existing peer connection map/array such as this.peerConnections) and when true,
either throw an Error or at minimum log a clear warning (e.g. "setE2EEManager
must be called before join() — E2EE will not be applied") and avoid silently
failing; still assign this.e2eeManager when safe, but ensure the check
references setE2EEManager, join, this.e2eeManager and the join/peer-connection
indicator so future callers get immediate feedback.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/client/src/rtc/e2ee/worker.ts`:
- Around line 105-118: Frame counter resets on worker recreation causing
potential IV reuse for the same key; update buildIV and nextFrameCounter to
prevent reuse by adding a random nonce component to the IV (e.g., make IV_LEN
12, fill bytes 0-3 with crypto.getRandomValues(4) and bytes 4-7 or 8-11 with the
4-byte frame counter) so each worker instantiation produces distinct (key, IV)
pairs even if counters restart, and ensure buildIV uses the same byte layout as
nextFrameCounter; alternatively consider persisting frameCounters externally or
requiring key rotation on worker recreation and document that constraint in
comments near buildIV/nextFrameCounter.

In `@sample-apps/react/react-dogfood/pages/bare/join/`[callId].tsx:
- Around line 65-67: The call to applyQueryConfigParams using router.query
currently swallows E2EE initialization errors by only logging them; change this
to await applyQueryConfigParams(router.query) inside a try/catch (mirroring
MeetingUI.tsx) and on catch either set a visible error state (e.g., setInitError
/ render an error screen) or prevent proceeding to join (e.g., navigate away or
disable join) so the call does not continue without requested E2EE; ensure the
error handling path surfaces the error to the user instead of only
console.error.

---

Nitpick comments:
In `@packages/client/src/Call.ts`:
- Around line 2001-2011: setE2EEManager can be called after the call has already
joined which silently makes E2EE ineffective; add a runtime check in the
setE2EEManager method that detects whether the call has already created peer
connections (e.g. check a join flag like this.joined or existing peer connection
map/array such as this.peerConnections) and when true, either throw an Error or
at minimum log a clear warning (e.g. "setE2EEManager must be called before
join() — E2EE will not be applied") and avoid silently failing; still assign
this.e2eeManager when safe, but ensure the check references setE2EEManager,
join, this.e2eeManager and the join/peer-connection indicator so future callers
get immediate feedback.

In `@packages/client/src/rtc/e2ee/EncryptionManager.ts`:
- Around line 139-144: The setKey method transfers ownership of rawKey via
this.worker.postMessage(..., [rawKey]) which detaches the original ArrayBuffer;
update the JSDoc for setKey (and mention validateKeyLength behavior) to clearly
state that rawKey is transferred and will become unusable after the call and
advise callers to pass a clone if they need to reuse it (or remove the transfer
if non-destructive behavior is required); ensure the comment references setKey
and the postMessage transfer semantics so future maintainers understand the
detachment risk.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f60c8fa0-f27a-4082-b311-018d76a5c700

📥 Commits

Reviewing files that changed from the base of the PR and between 86148a3 and e32bd0d.

📒 Files selected for processing (13)
  • packages/client/index.ts
  • packages/client/src/Call.ts
  • packages/client/src/rtc/BasePeerConnection.ts
  • packages/client/src/rtc/Publisher.ts
  • packages/client/src/rtc/Subscriber.ts
  • packages/client/src/rtc/__tests__/Publisher.test.ts
  • packages/client/src/rtc/__tests__/Subscriber.test.ts
  • packages/client/src/rtc/e2ee/EncryptionManager.ts
  • packages/client/src/rtc/e2ee/worker.ts
  • packages/client/src/rtc/types.ts
  • sample-apps/react/react-dogfood/components/MeetingUI.tsx
  • sample-apps/react/react-dogfood/lib/queryConfigParams.ts
  • sample-apps/react/react-dogfood/pages/bare/join/[callId].tsx
✅ Files skipped from review due to trivial changes (3)
  • packages/client/src/rtc/types.ts
  • packages/client/src/rtc/tests/Publisher.test.ts
  • packages/client/src/rtc/tests/Subscriber.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/client/src/rtc/Publisher.ts
  • packages/client/src/rtc/BasePeerConnection.ts
  • packages/client/src/rtc/Subscriber.ts

Comment thread packages/client/src/rtc/e2ee/worker.ts Outdated
Comment on lines +65 to +67
applyQueryConfigParams(_call, router.query).catch((e) =>
console.error('Failed to apply query config params', e),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

E2EE initialization failures are silently ignored.

Unlike MeetingUI.tsx which awaits applyQueryConfigParams within a try/catch and shows an error screen on failure, this bare join page only logs to console. If E2EE key derivation or manager creation fails, the call proceeds without encryption while the user may expect it to be enabled.

Consider either awaiting and handling the error to prevent joining without E2EE when it was explicitly requested, or documenting this as intentional behavior for the minimal "bare" implementation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sample-apps/react/react-dogfood/pages/bare/join/`[callId].tsx around lines 65
- 67, The call to applyQueryConfigParams using router.query currently swallows
E2EE initialization errors by only logging them; change this to await
applyQueryConfigParams(router.query) inside a try/catch (mirroring
MeetingUI.tsx) and on catch either set a visible error state (e.g., setInitError
/ render an error screen) or prevent proceeding to join (e.g., navigate away or
disable join) so the call does not continue without requested E2EE; ensure the
error handling path surfaces the error to the user instead of only
console.error.

Add a standalone Vite + React sample app for testing E2EE with multiple
participants in a single browser window. Each participant has their own
StreamVideoClient, Call, and EncryptionManager with per-user keys.

Demo features:
- Add/remove up to 4 participants sharing the same call
- Per-user key generation, manual key input (hex or passphrase), rotation
- Automatic cross-participant key distribution
- "Local only" mode to simulate key mismatch
- Decryption failure toast notification with manual dismiss
- Event log showing all key operations in real time

SDK changes:
- Subscriber uses participant.userId for decrypt key lookup instead of
  trackLookupPrefix, fixing per-user key matching
- Worker posts throttled decryptionFailed messages on decrypt failure
- EncryptionManager exposes onDecryptionFailed callback
…tices

Centralize all E2EE key operations into e2ee/keys.ts with a pluggable
SendKeyFn transport abstraction. Extract React state management into
a useE2EEDemo hook, leaving App.tsx as a thin layout shell.

- Make Call.e2eeManager public for direct access
- Add E2EEParticipant interface (pure key state, no SDK refs)
- Wrap ParticipantPanel in React.memo with stable callbacks
- Rename ParticipantState to ParticipantSession
- Fix side-effects-in-state-updater anti-pattern
- Delete crypto.ts (superseded by e2ee/keys.ts)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (6)
sample-apps/react/e2ee-demo/src/e2ee/keys.ts (1)

119-140: Consider documenting the hardcoded salt limitation.

The PBKDF2 derivation uses a static salt 'stream-e2ee'. While acceptable for a demo, a production implementation should use a unique salt per user or session. Consider adding a brief comment noting this.

📝 Suggested documentation addition
 export const deriveKeyFromPassphrase = async (
   passphrase: string,
 ): Promise<ArrayBuffer> => {
   const enc = new TextEncoder();
   const baseKey = await crypto.subtle.importKey(
     'raw',
     enc.encode(passphrase),
     'PBKDF2',
     false,
     ['deriveBits'],
   );
+  // Note: Static salt is acceptable for this demo. In production, use a
+  // unique salt per user/session stored alongside the derived key metadata.
   return crypto.subtle.deriveBits(
     {
       name: 'PBKDF2',
       salt: enc.encode('stream-e2ee'),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sample-apps/react/e2ee-demo/src/e2ee/keys.ts` around lines 119 - 140, The
deriveKeyFromPassphrase function currently uses a hardcoded salt ('stream-e2ee')
in the PBKDF2 parameters; add a concise comment above deriveKeyFromPassphrase
noting that the static salt is only suitable for demo purposes and that
production should use a unique, per-user or per-session random salt (persisted
or transmitted alongside the derived key material) to prevent cross-user attacks
and rainbow-table vulnerabilities; reference the function name
deriveKeyFromPassphrase and the literal 'stream-e2ee' in the comment so
reviewers can quickly find and understand the limitation.
sample-apps/react/e2ee-demo/src/components/KeyControls.tsx (1)

13-29: Consider memoizing toHex computation and stabilizing handlers.

For consistency with coding guidelines, toHex(currentKey) could be memoized with useMemo, and handleSetKey could use useCallback. However, since this is a demo app with limited re-render frequency, this is a minor optimization.

♻️ Optional optimization
-import { useState } from 'react';
+import { useState, useMemo, useCallback } from 'react';
 import { toHex } from '../e2ee/keys';
 import './KeyControls.css';
 
 // ... props interface ...
 
 export const KeyControls = ({
   currentKey,
   keyIndex,
   color,
   onRotate,
   onSetKey,
 }: KeyControlsProps) => {
   const [input, setInput] = useState('');
   const [localOnly, setLocalOnly] = useState(false);
-  const hex = toHex(currentKey);
+  const hex = useMemo(() => toHex(currentKey), [currentKey]);
 
-  const handleSetKey = () => {
+  const handleSetKey = useCallback(() => {
     const trimmed = input.trim();
     if (!trimmed) return;
     onSetKey(trimmed, localOnly);
     setInput('');
-  };
+  }, [input, localOnly, onSetKey]);

As per coding guidelines: "Use useMemo for expensive computations in React components" and "Use useCallback hook to stabilize function references passed to child components."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sample-apps/react/e2ee-demo/src/components/KeyControls.tsx` around lines 13 -
29, The component KeyControls computes hex with toHex(currentKey) on every
render and defines handleSetKey inline; memoize the hex value using React's
useMemo referencing currentKey and stabilize handleSetKey with useCallback
referencing input, localOnly and onSetKey so the function identity is stable
when passed to children; ensure imports include useMemo and useCallback and keep
existing behavior (trim input, early return, call onSetKey(trimmed, localOnly),
then setInput('')) while referencing the memoized hex variable.
sample-apps/react/e2ee-demo/src/hooks/useE2EEDemo.ts (2)

30-36: Token fetch lacks explicit error handling.

If the fetch fails or returns non-JSON, the error will propagate up. While the caller has a try/catch, consider adding basic validation for clearer error messages in the demo.

♻️ Suggested improvement
 const createTokenProvider = (userId: string) => async () => {
   const url = new URL(TOKEN_ENDPOINT);
   url.searchParams.set('api_key', API_KEY);
   url.searchParams.set('user_id', userId);
-  const { token } = await fetch(url).then((r) => r.json());
+  const response = await fetch(url);
+  if (!response.ok) {
+    throw new Error(`Token fetch failed: ${response.status}`);
+  }
+  const { token } = await response.json();
   return token as string;
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sample-apps/react/e2ee-demo/src/hooks/useE2EEDemo.ts` around lines 30 - 36,
The createTokenProvider function lacks explicit error handling for network
failures and non-OK/non-JSON responses; wrap the fetch call in a try/catch,
validate response.ok and content-type before parsing, and throw descriptive
errors including the URL/userId and status/text or parse error so callers get
clearer messages (update createTokenProvider to catch fetch/network exceptions,
check response.ok, inspect headers for application/json, attempt json() inside a
try and throw a clear error if parsing fails).

270-273: Minor inconsistency in accessing e2eeManager.

Line 272 accesses target.call.e2eeManager?.dispose(), while the cleanup effect (line 61) accesses p.e2eeManager.dispose() directly. Since target already has the e2eeManager reference, using it directly is clearer and avoids the optional chaining.

♻️ Suggested fix
       // SDK cleanup
       target.call.leave().catch(() => {});
-      target.call.e2eeManager?.dispose();
+      target.e2eeManager.dispose();
       target.client.disconnectUser().catch(() => {});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sample-apps/react/e2ee-demo/src/hooks/useE2EEDemo.ts` around lines 270 - 273,
The cleanup block is inconsistently accessing the E2EE manager; change the call
from using optional chaining on the call object
(target.call.e2eeManager?.dispose()) to use the direct reference already present
on the target (target.e2eeManager.dispose()), mirroring the earlier cleanup
pattern (p.e2eeManager.dispose()); ensure you call dispose() on
target.e2eeManager and remove the unnecessary optional chaining so the code
consistently uses the same E2EE manager reference.
sample-apps/react/e2ee-demo/src/components/ParticipantPanel.tsx (2)

23-37: Consider memoizing CallUI to avoid unnecessary re-renders.

CallUI is rendered inside each ParticipantPanel. Since the parent is memoized and this component relies only on hook state, wrapping it with memo ensures it won't re-render when parent props change.

♻️ Suggested refactor
-const CallUI = () => {
+const CallUI = memo(function CallUI() {
   const { useCallCallingState } = useCallStateHooks();
   const callingState = useCallCallingState();

   if (callingState !== CallingState.JOINED) {
     return <div className="participant-panel__loading">Connecting...</div>;
   }

   return (
     <StreamTheme>
       <PaginatedGridLayout />
       <CallControls />
     </StreamTheme>
   );
-};
+});

Also update the import:

-import { memo, useCallback } from 'react';
+import { memo, useCallback } from 'react';

Based on learnings: "Use React.memo for components rendered in large participant lists to optimize re-render performance"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sample-apps/react/e2ee-demo/src/components/ParticipantPanel.tsx` around lines
23 - 37, Wrap the CallUI functional component with React.memo so it only
re-renders when its hook-derived state changes: import React, { memo } (or add
memo to the existing React import) and export/define CallUI as memo(CallUI) (or
assign const MemoizedCallUI = memo(CallUI) and use that). Target the CallUI
definition that uses useCallCallingState and is rendered inside ParticipantPanel
so it doesn't re-render when parent props change; no other behavior changes to
StreamTheme, PaginatedGridLayout, or CallControls are needed.

72-74: Truncation logic may produce misleading UI for short user IDs.

If userId is shorter than 24 characters, the display will still append ... even though nothing was truncated. Consider conditional truncation.

♻️ Suggested fix
           <span className="participant-panel__user-id" title={userId}>
-            {userId.slice(0, 24)}...
+            {userId.length > 24 ? `${userId.slice(0, 24)}...` : userId}
           </span>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sample-apps/react/e2ee-demo/src/components/ParticipantPanel.tsx` around lines
72 - 74, The span in ParticipantPanel.tsx currently always appends "..." to the
displayed userId via {userId.slice(0, 24)}..., which is misleading when
userId.length <= 24; update the rendering in the component (look for the element
with className "participant-panel__user-id" in ParticipantPanel) to
conditionally truncate and append "..." only when userId.length > 24, otherwise
render the full userId (and preserve the title={userId} behavior).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/client/src/rtc/__tests__/Subscriber.test.ts`:
- Around line 218-247: The test assumes the '123' trackLookupPrefix is absent in
shared state; make it deterministic by using a unique prefix that cannot be
registered elsewhere in tests (e.g. 'orphan-<unique>' or a random string) when
setting mediaStream.id and in the expected decrypt call. Concretely, update the
mediaStream.id assignment in the test that calls Subscriber and handleOnTrack to
use a unique prefix (replace '123:TRACK_TYPE_VIDEO' with something like
'orphan-XYZ:TRACK_TYPE_VIDEO') and update the
expect(e2eeMock.decrypt).toHaveBeenCalledWith(...) assertion to expect that same
unique prefix ('orphan-XYZ') instead of '123', so the fallback-to-trackId path
is deterministic; alternatively clear or reset the relevant lookup in shared
state before invoking subscriber['handleOnTrack'] if you prefer to keep a fixed
id.

In `@packages/client/src/rtc/e2ee/worker.ts`:
- Around line 52-54: The sharedKey fallback reuses the same AES-GCM key across
participants while buildIV() only uses the frame counter, causing nonce reuse;
fix by deriving a participant-scoped subkey or adding a sender-unique IV prefix
that both sender and receiver can reconstruct. Locate the sharedKey variable and
the buildIV() function and implement one of these: 1) derive per-participant AES
keys from the shared secret (e.g., HKDF using userId or senderId as info) and
replace use of sharedKey with that derived CryptoKey, or 2) prepend a
sender-unique prefix (derived from the shared secret and senderId) to the IV
generated by buildIV() so the full IV includes both the prefix and the frame
counter; ensure the receiver performs the same derivation when decrypting so IVs
and keys match.
- Around line 56-63: importKey is async and can complete out of order, causing
latestKeyIndex to be rolled back; serialize imports per participant by adding a
per-user pending promise/lock (e.g., a Map like pendingImports keyed by userId)
and chain await the previous import before executing the body of importKey (or
acquire a per-user mutex) so imports for the same user run sequentially;
additionally, when updating latestKeyIndex inside importKey, only advance it if
the incoming keyIndex is newer than the stored value (or use the serialized
ordering guarantee so simple assignment is safe), referencing importKey,
latestKeyIndex, and keyStore to locate where to apply the serialization and
conditional update.
- Around line 46-54: The three maps keyStore, latestKeyIndex, and frameCounters
(and their comments) must be keyed by sessionId instead of userId to avoid
cross-session collisions when a user has multiple devices; change their usage so
every get/set/delete uses sessionId as the unique identifier, update any code
that installs/reads keys or increments frame counters to pass sessionId, and
ensure session-scoped cleanup when a session ends; keep sharedKey semantics as a
true fallback but document it explicitly as shared across sessions only when
intended.

In `@sample-apps/react/e2ee-demo/src/App.css`:
- Line 11: The CSS custom property --font-mono in App.css uses mixed case font
family names which triggers Stylelint's value-keyword-case rule; update the
value of --font-mono (the font-family string assigned to the --font-mono
variable) to use lowercase keywords (e.g., 'sf mono', 'cascadia code', 'fira
code', consolas, monospace) so all font names are lowercase and the linter
passes.

In `@sample-apps/react/e2ee-demo/src/hooks/useE2EEDemo.ts`:
- Around line 230-258: The async call to setE2EEKeyFromInput inside
setKeyFromInput can reject but has no error handling; update setKeyFromInput to
add a .catch handler (or use try/catch if converting to async) for the Promise
returned by setE2EEKeyFromInput(target.e2eeManager, ...), and in that handler
log the error via logEvent (or console/error logger), surface a user-visible
error state for the target (e.g., update participants state to reflect failure
or set an error message), and ensure any partial UI updates are not applied on
failure so the UI remains consistent.

---

Nitpick comments:
In `@sample-apps/react/e2ee-demo/src/components/KeyControls.tsx`:
- Around line 13-29: The component KeyControls computes hex with
toHex(currentKey) on every render and defines handleSetKey inline; memoize the
hex value using React's useMemo referencing currentKey and stabilize
handleSetKey with useCallback referencing input, localOnly and onSetKey so the
function identity is stable when passed to children; ensure imports include
useMemo and useCallback and keep existing behavior (trim input, early return,
call onSetKey(trimmed, localOnly), then setInput('')) while referencing the
memoized hex variable.

In `@sample-apps/react/e2ee-demo/src/components/ParticipantPanel.tsx`:
- Around line 23-37: Wrap the CallUI functional component with React.memo so it
only re-renders when its hook-derived state changes: import React, { memo } (or
add memo to the existing React import) and export/define CallUI as memo(CallUI)
(or assign const MemoizedCallUI = memo(CallUI) and use that). Target the CallUI
definition that uses useCallCallingState and is rendered inside ParticipantPanel
so it doesn't re-render when parent props change; no other behavior changes to
StreamTheme, PaginatedGridLayout, or CallControls are needed.
- Around line 72-74: The span in ParticipantPanel.tsx currently always appends
"..." to the displayed userId via {userId.slice(0, 24)}..., which is misleading
when userId.length <= 24; update the rendering in the component (look for the
element with className "participant-panel__user-id" in ParticipantPanel) to
conditionally truncate and append "..." only when userId.length > 24, otherwise
render the full userId (and preserve the title={userId} behavior).

In `@sample-apps/react/e2ee-demo/src/e2ee/keys.ts`:
- Around line 119-140: The deriveKeyFromPassphrase function currently uses a
hardcoded salt ('stream-e2ee') in the PBKDF2 parameters; add a concise comment
above deriveKeyFromPassphrase noting that the static salt is only suitable for
demo purposes and that production should use a unique, per-user or per-session
random salt (persisted or transmitted alongside the derived key material) to
prevent cross-user attacks and rainbow-table vulnerabilities; reference the
function name deriveKeyFromPassphrase and the literal 'stream-e2ee' in the
comment so reviewers can quickly find and understand the limitation.

In `@sample-apps/react/e2ee-demo/src/hooks/useE2EEDemo.ts`:
- Around line 30-36: The createTokenProvider function lacks explicit error
handling for network failures and non-OK/non-JSON responses; wrap the fetch call
in a try/catch, validate response.ok and content-type before parsing, and throw
descriptive errors including the URL/userId and status/text or parse error so
callers get clearer messages (update createTokenProvider to catch fetch/network
exceptions, check response.ok, inspect headers for application/json, attempt
json() inside a try and throw a clear error if parsing fails).
- Around line 270-273: The cleanup block is inconsistently accessing the E2EE
manager; change the call from using optional chaining on the call object
(target.call.e2eeManager?.dispose()) to use the direct reference already present
on the target (target.e2eeManager.dispose()), mirroring the earlier cleanup
pattern (p.e2eeManager.dispose()); ensure you call dispose() on
target.e2eeManager and remove the unnecessary optional chaining so the code
consistently uses the same E2EE manager reference.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2330f332-3438-4070-82fe-411a79bdab61

📥 Commits

Reviewing files that changed from the base of the PR and between e32bd0d and 9a5e9f5.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (28)
  • packages/client/src/Call.ts
  • packages/client/src/rtc/Subscriber.ts
  • packages/client/src/rtc/__tests__/Subscriber.test.ts
  • packages/client/src/rtc/e2ee/EncryptionManager.ts
  • packages/client/src/rtc/e2ee/worker.ts
  • sample-apps/react/e2ee-demo/index.html
  • sample-apps/react/e2ee-demo/package.json
  • sample-apps/react/e2ee-demo/src/App.css
  • sample-apps/react/e2ee-demo/src/App.tsx
  • sample-apps/react/e2ee-demo/src/components/EventLog.css
  • sample-apps/react/e2ee-demo/src/components/EventLog.tsx
  • sample-apps/react/e2ee-demo/src/components/Header.css
  • sample-apps/react/e2ee-demo/src/components/Header.tsx
  • sample-apps/react/e2ee-demo/src/components/KeyControls.css
  • sample-apps/react/e2ee-demo/src/components/KeyControls.tsx
  • sample-apps/react/e2ee-demo/src/components/ParticipantGrid.css
  • sample-apps/react/e2ee-demo/src/components/ParticipantGrid.tsx
  • sample-apps/react/e2ee-demo/src/components/ParticipantPanel.css
  • sample-apps/react/e2ee-demo/src/components/ParticipantPanel.tsx
  • sample-apps/react/e2ee-demo/src/config.ts
  • sample-apps/react/e2ee-demo/src/e2ee/keys.ts
  • sample-apps/react/e2ee-demo/src/hooks/useE2EEDemo.ts
  • sample-apps/react/e2ee-demo/src/main.tsx
  • sample-apps/react/e2ee-demo/src/types.ts
  • sample-apps/react/e2ee-demo/src/vite-env.d.ts
  • sample-apps/react/e2ee-demo/tsconfig.json
  • sample-apps/react/e2ee-demo/tsconfig.node.json
  • sample-apps/react/e2ee-demo/vite.config.ts
✅ Files skipped from review due to trivial changes (13)
  • sample-apps/react/e2ee-demo/src/vite-env.d.ts
  • sample-apps/react/e2ee-demo/vite.config.ts
  • sample-apps/react/e2ee-demo/tsconfig.node.json
  • sample-apps/react/e2ee-demo/index.html
  • sample-apps/react/e2ee-demo/tsconfig.json
  • sample-apps/react/e2ee-demo/src/components/EventLog.css
  • sample-apps/react/e2ee-demo/src/components/ParticipantGrid.css
  • sample-apps/react/e2ee-demo/src/components/Header.css
  • sample-apps/react/e2ee-demo/package.json
  • sample-apps/react/e2ee-demo/src/main.tsx
  • sample-apps/react/e2ee-demo/src/components/KeyControls.css
  • sample-apps/react/e2ee-demo/src/types.ts
  • sample-apps/react/e2ee-demo/src/components/ParticipantPanel.css
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/client/src/rtc/Subscriber.ts
  • packages/client/src/Call.ts
  • packages/client/src/rtc/e2ee/EncryptionManager.ts

Comment thread packages/client/src/rtc/__tests__/Subscriber.test.ts Outdated
Comment thread packages/client/src/rtc/e2ee/worker.ts Outdated
Comment thread packages/client/src/rtc/e2ee/worker.ts Outdated
Comment thread packages/client/src/rtc/e2ee/worker.ts Outdated
--color-text-mono: #7dd3fc;
--radius-md: 8px;
--radius-sm: 4px;
--font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', Consolas, monospace;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix font name casing for Stylelint compliance.

Stylelint reports value-keyword-case error. While CSS font-family values are case-insensitive, the linter expects lowercase for consistency.

🔧 Proposed fix
-  --font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', Consolas, monospace;
+  --font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', consolas, monospace;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
--font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', Consolas, monospace;
--font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', consolas, monospace;
🧰 Tools
🪛 Stylelint (17.7.0)

[error] 11-11: Expected "Consolas" to be "consolas" (value-keyword-case)

(value-keyword-case)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sample-apps/react/e2ee-demo/src/App.css` at line 11, The CSS custom property
--font-mono in App.css uses mixed case font family names which triggers
Stylelint's value-keyword-case rule; update the value of --font-mono (the
font-family string assigned to the --font-mono variable) to use lowercase
keywords (e.g., 'sf mono', 'cascadia code', 'fira code', consolas, monospace) so
all font names are lowercase and the linter passes.

Comment on lines +230 to +258
const setKeyFromInput = useCallback(
(targetUserId: string, input: string, localOnly: boolean) => {
const allParticipants = participantsRef.current;
const target = allParticipants.find((p) => p.userId === targetUserId);
if (!target) return;

setE2EEKeyFromInput(
target.e2eeManager,
target,
input,
allParticipants,
sendKey,
{ localOnly },
).then(({ key, keyIndex }) => {
logEvent(
`${target.name} set key (#${keyIndex}): ${toHex(key).slice(0, 16)}...${localOnly ? ' [LOCAL ONLY]' : ''}`,
'key-set',
);

// Pure state update
setParticipants((prev) =>
prev.map((p) =>
p.userId === targetUserId ? { ...p, currentKey: key, keyIndex } : p,
),
);
});
},
[logEvent, sendKey],
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing error handling for async key operation.

setE2EEKeyFromInput returns a Promise that could reject (e.g., on invalid passphrase derivation), but there's no .catch() handler. Errors will be silently swallowed, potentially leaving the UI in an inconsistent state.

🛡️ Suggested fix
       setE2EEKeyFromInput(
         target.e2eeManager,
         target,
         input,
         allParticipants,
         sendKey,
         { localOnly },
-      ).then(({ key, keyIndex }) => {
+      )
+        .then(({ key, keyIndex }) => {
           logEvent(
             `${target.name} set key (#${keyIndex}): ${toHex(key).slice(0, 16)}...${localOnly ? ' [LOCAL ONLY]' : ''}`,
             'key-set',
           );

           // Pure state update
           setParticipants((prev) =>
             prev.map((p) =>
               p.userId === targetUserId ? { ...p, currentKey: key, keyIndex } : p,
             ),
           );
+        })
+        .catch((err) => {
+          logEvent(`${target.name} failed to set key: ${err}`, 'error');
         });
-      });
     },
     [logEvent, sendKey],
   );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sample-apps/react/e2ee-demo/src/hooks/useE2EEDemo.ts` around lines 230 - 258,
The async call to setE2EEKeyFromInput inside setKeyFromInput can reject but has
no error handling; update setKeyFromInput to add a .catch handler (or use
try/catch if converting to async) for the Promise returned by
setE2EEKeyFromInput(target.e2eeManager, ...), and in that handler log the error
via logEvent (or console/error logger), surface a user-visible error state for
the target (e.g., update participants state to reflect failure or set an error
message), and ensure any partial UI updates are not applied on failure so the UI
remains consistent.

When participant B joins, sendKey could not deliver A's key to B
because B wasn't in participantsRef yet. Split the exchange: use
sendKey for the outbound direction (A gets B's key) and set existing
keys on B's e2eeManager directly.
…iliation

When a track arrives before the participantJoined event, the receiver
was never wired to the E2EE decryptor, leaving frames permanently
encrypted. Store the RTCRtpReceiver with orphaned tracks and call
decrypt() when the participant identity becomes known during
reconciliation in the participant event handlers.

Also adds a comprehensive test suite for EncryptionManager.
…porting

- Derive IV prefix from SHA-256(rawKey + userId) to prevent IV reuse
  across users with shared keys and across key rotations
- Add version byte to frame trailer (v1) for forward compatibility
- Add perf-report toggle: worker reports encode/decode FPS when enabled
- Expose setPerfReport() and onPerfReport callback on EncryptionManager

Also updates the E2EE demo app:
- Use ?environment=pronto instead of hardcoded mmhfdzb5evj2 API key
- Add E2EE on/off toggle in header
- Show perf metrics in the event log
- Fix stale closure in addParticipant
Runtime toggle:
- Add EncryptionManager.setEnabled() to toggle E2EE without reconnecting
- Worker e2eeActive flag gates encoder only; decoder always decrypts
  encrypted frames (valid trailer + keys) and passes plaintext through
- Demo app: per-participant and global E2EE toggles with key revocation

Performance optimizations:
- Synchronous IV prefix lookup on the hot path (eliminates one await
  per frame — no more unnecessary Promise/microtask overhead)
- Pre-compute shared key IV prefixes at transform creation time
- Zero-copy AAD via subarray instead of slice
- Single-pass RBSP escape (removed counting pass)
- Rename worker.ts → e2ee-worker.ts and worker/ → e2ee-worker/
- Export bundled worker as a function instead of a template literal string
- EncryptionManager creates Worker from e2eeWorker.toString()
- Remove unnecessary tsconfig exclusion for worker directory
…rospection

- Add codec selector (VP8/VP9/H.264/AV1) in e2ee-demo header
- Add shared key support with passphrase input and per-user key overrides
- Auto-hide decryption error banner on E2EE disable or decryption resume
- Add onDecryptionResumed callback to EncryptionManager
- Fix lazy IV prefix recomputation in encode/decode transforms
- Add requestKeyDump() for worker key state introspection
- Export KeyStateReport type from client package
- Frame counter fails closed at the AES-GCM 32-bit ceiling and fires
  `rekeyRequested` once per user at 2^31, preventing IV reuse.
- Surface persistent decryption failures via `onE2EEBroken` so hosts
  can terminate the session instead of silently passing through.
- Add `onRotationNeeded` callback for counter-threshold rekey signals.
- Opt-in AES-256-GCM via `EncryptionManager.create(userId,
  { algorithm: 'AES-256-GCM' })`; default stays AES-128-GCM.
- Two-pass RBSP escape (exact sizing, no over-allocation) and explicit
  codec allowlist (opus/vp8/vp9/h264).
- Test coverage for rollover, rekey signalling, AES-256 opt-in,
  codec/utils modules, and crypto key state.
- Demo: rename `keyHex` field to `fingerprint` to match the SDK's
  non-reversible introspection surface.
…ncode latch

2.2: decryption-failure counting was a worker-global map keyed (userId,
keyIndex) but shared across all of a user's tracks, while replay/throttle state
is per track. A user's healthy audio reset the count their broken video
accumulated, so FAILURE_TOLERANCE was never crossed (e2ee.broken never fired)
and the unthrottled e2ee.decryption_resumed flapped once per audio frame.
Replace the global counter + isKeyInvalid/record/has/reset functions with a
per-transform createFailureTracker (scoped like the replay window, keyed by
keyIndex): recordFailure returns true only on the tolerance-crossing edge,
recordSuccess clears and reports the recovery edge. decodeTransform owns its
tracker and throttles resumed. The import/remove/dispose reset coupling is gone
- a fresh key recovers via the per-track success path.

2.3: encodeFailureSignaled was a worker-lifetime latch set once and never reset,
so one transient encrypt error consumed the only e2ee.encryption_failed the host
would ever get and a later permanent fail-closed dropped every frame silently.
Move the latch and finishEncode into encodeTransform (per track) and re-arm it
on every successful emit, so a new failure run signals again.

Tests: regression tests for both (cross-track broken/no-spurious-resumed, and
encode re-signal after recovery), plus createFailureTracker units replacing the
old global-counter tests.

Fixes E2EE_REVIEW findings 2.2 and 2.3.
…ail chain

enqueue serialized worker message handling and transform setup via a msgQueue
array + a running flag + a drain loop. A promise tail chain keeps identical
FIFO ordering, one-at-a-time serialization, error isolation (a rejecting task
never stalls the queue), and return-value/rejection propagation, with far less
machinery. Memory is backlog-bounded exactly as before - settled chain links are
released, and a never-settling task degrades both implementations equally.

The only behavioral nuance: a task now starts on a microtask rather than
synchronously when the queue is empty. No caller observes this - both worker
event handlers enqueue(...).catch() and return, and the tests rely only on FIFO.

Adds a serialization (non-overlap) test to guard the one-at-a-time property the
ordering test only weakly implied.

Fixes E2EE_REVIEW finding 3.7.
…nd tests

The fixes carried parenthetical pointers (finding 11), (review finding 2.2),
etc. into comments, JSDoc, and test titles. They reference an internal,
ephemeral review document that does not ship and means nothing to future
readers. Remove the pointers while keeping all the explanatory prose and the
self-describing test names. No behavior change.
Set E2EE_VERSION to 1 for the initial (unreleased) wire format. The trailer/IV
layout is unchanged - this is only the version byte writeTrailer stamps and
readTrailer checks, and both read the same constant, so sender and receiver
move together.
readTrailer no longer returns the `version` field since nothing read it. The
version is still validated internally (unknown versions are rejected as "not
our trailer"); it is just no longer surfaced on the Trailer type or the parsed
object.

Removed the single-buffer rbspEscape wrapper from the shipped worker. The
encode path escapes the [ciphertext, trailer] pair directly via
rbspEscapedLength + rbspEscapeInto, so the wrapper was test-only; it now lives
in codec.test.ts.

Also tidied two now-stale comments in the decode transform.
# Conflicts:
#	packages/client/tsconfig.json
@github-actions

github-actions Bot commented Jun 30, 2026

Copy link
Copy Markdown

Bundle size

Built package output. Sizes in KB; delta vs main@ccb2273.

Package Unminified Minified Δ min vs main
@stream-io/video-client 768.5 KB 270.4 KB +7.0 KB (+2.6%)
@stream-io/video-react-sdk 182.5 KB 89.6 KB +47 B (+0.1%)
↳ install total (+ client + react-bindings) 980.9 KB 369.2 KB +7.0 KB (+1.9%)
@stream-io/video-react-sdk (embedded) 193.1 KB 93.9 KB +46 B (+0.0%)
@stream-io/video-react-native-sdk 402.5 KB 192.7 KB 0 KB
↳ install total (+ client + react-bindings) 1200.8 KB 472.2 KB +7.0 KB (+1.5%)

oliverlaz added 22 commits July 14, 2026 14:29
The SFU rejects a join whose e2ee flag does not match the call's
encryption configuration. Send e2ee=true whenever an E2EEManager has
been attached so encrypted calls are joinable.
Create every call with encryption.enabled so it matches the e2ee join
the SDK now sends. The spy attaches a manager too (joining as e2ee) but
never receives keys, so she still renders gibberish. Add an
?environment= query param to override the token environment.
The spy now joins as a full e2ee participant, so frame her UI around key
possession rather than being an outsider: admission to the call does not
grant the ability to decrypt without the keys.
Add a deploy matrix entry for the e2ee-demo (project stream-e2ee-demo,
domain stream-e2ee-demo.vercel.app) and a vercel.json SPA rewrite,
mirroring the other React sample apps.
Remove the per-participant and global E2EE toggles (the backend now
requires E2EE on every join), dropping the e2eeEnabled config and the
dead setEnabled handler. Surface the existing per-participant Set Key
control with a clearer heading. Unlock the KeyMode selector so a shared
key can be set up after participants have joined, not only before.
…sting

Add a floating, header-toggled 'Manual key override' panel that lists
everyone in the call via the SFU roster (local and remote peers from
other tabs). Setting a participant's key applies it at a fixed key index
on every local manager - encode key if local, decode key otherwise - so
pasting the same value across tabs/browsers lets them decrypt each other.
Keys are not auto-distributed; override and shared-key inputs retain
their value so they can be copied.
Shared keys were indexed by a per-tab counter, so re-setting the key in
one tab drifted its index and broke decryption against another tab (the
receiver resolves a key by userId+index from the frame trailer). Use a
fixed index for both shared keys and manual overrides so the same
passphrase/value lines up across tabs regardless of how many times each
side sets it.
Bucket the worker's perf stats per track instead of one global figure:
encode by (userId, trackType, codec) and decode by (userId, trackType).
This stops audio, video, and same-codec senders (camera vs screen share)
being summed into one misleading number. Thread an optional trackType
label through encrypt/decrypt from the Publisher/Subscriber. PerfReport
now shares a TrackPerf base: encode is TrackPerf & { codec }, decode is
TrackPerf.
Surface the new per-track encode/decode perf (fps + maxCryptoMs) under
each participant, and stable-sort the perf rows, key fingerprints, and
key-override roster so they no longer swap places between reports.
Add an opt-in E2EE experience to the dogfood app, limited to the pronto
environment.

- Lobby: an E2EE switch below the Join button that auto-generates a
  three-word shared key, labels and explains it, and offers a compact
  copy-invite-link action.
- Enabling/disabling soft-navigates to a fresh call id carrying the key
  in ?encryption_key= (encryption is fixed at call creation); editing the
  key updates the URL in place.
- Encryption-aware getOrCreate creates the call with encryption enabled
  when a key is present; join attaches the shared-key EncryptionManager
  via applyQueryConfigParams, gated to pronto.
- Active call header shows a prominent E2EE badge with a tooltip.
- Extract a keyed CallScope so swapping calls remounts call-scoped
  providers cleanly, fixing noise-cancellation init on call swap.
Replace the navigation-based E2EE toggle with an in-place call swap and
polish the lobby interaction.

- Toggling E2EE now swaps the active call for a freshly created one (of the
  same type) in place and rewrites the URL via the History API, instead of a
  Next.js navigation - no page transition and no CallScope remount. getOrCreate
  is awaited before the swap so the ready call has no noise-cancellation
  capability race.
- Add LobbyE2EEContext to drive the swap from the lobby and feed the shared key
  to the join flow (the key no longer lives in router.query after a
  history-only URL update).
- Lobby: top-align the content so expanding the key panel pushes only the
  content below the toggle down; animate the reveal (grid-rows 0fr -> 1fr);
  reintroduce a soft green encrypted-state highlight; give the copy button a
  fixed width so the label swap does not resize the key input.
… page

The join page had accumulated call-ownership and E2EE swap glue. Move it out
so the page reads as plain wiring (~150 lines, down from ~340).

- hooks/useLobbyCall: owns the active call (create/getOrCreate, in-place swap
  lifecycle + cleanup) and the E2EE toggle controls (URL rewrite via History
  API, encrypted-call swap), returning { call, callError, e2eeControls }.
- components/CallScope: the call-scoped provider subtree (noise cancellation +
  Tour/BackgroundFilters/NoiseCancellation providers + MeetingUI) and <Head>.
- pages/join/[callId]: now just parses the URL, sets up client/chat, calls
  useLobbyCall, and renders.

No behavior change.
Harden the lobby E2EE control so only the call creator manages encryption,
and polish the joiner experience.

- Creator sees the interactive toggle; a 2nd+ participant (joiner) cannot
  change a call's encryption (fixed at creation), so instead of a confusing
  grayed-out toggle they get a clear informational banner on an encrypted
  call, or nothing at all on a plain call.
- Joiner on an encrypted call whose link lacked the key can type it in; the
  switch reflects the call's actual encryption, not just key presence.
- Gate the Join button while an encrypted call has no key, so the backend's
  e2ee-mismatch rejection can't be triggered.
- Add an inline "refresh" button to regenerate the shared key (creator only).
- Render the control only once the call response resolves (creator vs joiner
  known) to avoid briefly flashing the wrong view.
- Style: single frosted-glass card matching the display-name panel; green
  encrypted-state highlight; read-only key styling for joiners.
Remove the unused setEnabled runtime toggle. It gated the encoder only
(no coherent "off" state), is now a footgun since E2EE is pinned at join
time, and had a single no-op caller. The encoder always encrypts once a
manager is attached.

Rename setPerfReport to enablePerformanceReporting, and the worker command
cmd.set_perf_report to cmd.enable_performance_reporting, for a clearer and
consistent public API. These symbols are unreleased, so no deprecation.

Also update the Publisher, Subscriber, and participant event test
assertions to expect the trackType argument on E2EEManager.encrypt and
decrypt.
The Slack `/create` command now accepts `--e2ee` (or `--private`) to seed a
shared encryption key into the join URL. The hook generates a memorable
passphrase and appends it as `encryption_key`, matching the existing
`?encryption_key` flow the lobby uses, so everyone who opens the link joins
the same end-to-end encrypted call.

Also allow E2EE on `pronto-staging` in addition to `pronto`, via a shared
isE2EEEnvironment helper used by the join page and MeetingUI, so the
encrypted links work on staging too.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant