From 61fdaa7bc201038ad6b0c37b6bb6272db87395ae Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Mon, 17 Aug 2026 14:47:08 +0200 Subject: [PATCH 01/11] 145.16 pre-release update --- android/build.gradle | 4 +-- .../ios/GumTestApp.xcodeproj/project.pbxproj | 10 ++---- stream-react-native-webrtc.podspec | 34 +++++++++++++++++-- 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/android/build.gradle b/android/build.gradle index 8aa2bd996..7baba6411 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -25,7 +25,7 @@ rootProject.allprojects { url = 'https://central.sonatype.com/repository/maven-snapshots/' content { // This efficiently tells Gradle to only look for this specific dependency here - includeModule('io.getstream', 'stream-webrtc-android') + includeModule('io.getstream', 'stream-video-webrtc-android') } } } @@ -84,7 +84,7 @@ def kotlin_version = getExtOrDefault('kotlinVersion', '1.8.10') println "Building Stream WebRTC React Native module with Kotlin version: $kotlin_version" dependencies { - api 'io.getstream:stream-video-webrtc-android:145.9.0' + api 'io.getstream:stream-video-webrtc-android:146.16.0-SNAPSHOT' implementation "com.facebook.react:react-android:+" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "androidx.core:core:1.7.0" diff --git a/examples/GumTestApp/ios/GumTestApp.xcodeproj/project.pbxproj b/examples/GumTestApp/ios/GumTestApp.xcodeproj/project.pbxproj index b1dfb3660..6ad7cc4de 100644 --- a/examples/GumTestApp/ios/GumTestApp.xcodeproj/project.pbxproj +++ b/examples/GumTestApp/ios/GumTestApp.xcodeproj/project.pbxproj @@ -192,17 +192,13 @@ ); inputPaths = ( "${PODS_ROOT}/Target Support Files/Pods-GumTestApp/Pods-GumTestApp-frameworks.sh", - "${PODS_XCFRAMEWORKS_BUILD_DIR}/React-Core-prebuilt/React.framework/React", - "${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies", - "${PODS_XCFRAMEWORKS_BUILD_DIR}/StreamWebRTC/WebRTC.framework/WebRTC", - "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermesvm.framework/hermesvm", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/stream-react-native-webrtc/WebRTC.framework/WebRTC", ); name = "[CP] Embed Pods Frameworks"; outputPaths = ( - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/WebRTC.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermesvm.framework", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; diff --git a/stream-react-native-webrtc.podspec b/stream-react-native-webrtc.podspec index 398d67432..6d0835578 100644 --- a/stream-react-native-webrtc.podspec +++ b/stream-react-native-webrtc.podspec @@ -1,7 +1,36 @@ require 'json' +require 'fileutils' package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) +# WebRTC version from https://github.com/GetStream/stream-video-swift-webrtc releases. +# 148.0.0 is not on CocoaPods trunk, so the xcframework is vendored directly instead of +# depending on the `StreamWebRTC` pod. The release asset below is the same one the +# `StreamWebRTC` podspec in that repo points at. CocoaPods skips `prepare_command` for +# `:path` pods (which is how React Native autolinks this module), so the fetch happens +# here — the podspec is plain Ruby, evaluated on every `pod install`. +webrtc_version = '146.16.0' +webrtc_url = "https://github.com/GetStream/stream-video-swift-webrtc/releases/download/#{webrtc_version}/WebRTC.xcframework.zip" +webrtc_dir = File.join(__dir__, 'third_party') +webrtc_framework = File.join(webrtc_dir, 'WebRTC.xcframework') +webrtc_stamp = File.join(webrtc_dir, '.webrtc-version') + +unless File.directory?(webrtc_framework) && File.exist?(webrtc_stamp) && File.read(webrtc_stamp).strip == webrtc_url + webrtc_zip = File.join(webrtc_dir, 'WebRTC.xcframework.zip') + + Pod::UI.puts "[stream-react-native-webrtc] Downloading WebRTC.xcframework #{webrtc_version}" + + FileUtils.mkdir_p(webrtc_dir) + FileUtils.rm_rf([webrtc_framework, webrtc_stamp, webrtc_zip]) + + raise "Failed to download #{webrtc_url}" unless system('curl', '-fSL', '--retry', '3', '-o', webrtc_zip, webrtc_url) + raise "Failed to unzip #{webrtc_zip}" unless system('unzip', '-q', '-o', webrtc_zip, '-x', '__MACOSX/*', '-d', webrtc_dir) + raise "#{webrtc_zip} did not contain WebRTC.xcframework" unless File.directory?(webrtc_framework) + + FileUtils.rm_f(webrtc_zip) + File.write(webrtc_stamp, webrtc_url) +end + Pod::Spec.new do |s| s.name = 'stream-react-native-webrtc' s.version = package['version'] @@ -20,8 +49,9 @@ Pod::Spec.new do |s| s.framework = 'AudioToolbox','AVFoundation', 'CoreAudio', 'CoreGraphics', 'CoreVideo', 'GLKit', 'VideoToolbox' s.swift_version = '5.0' s.dependency 'React-Core' - # WebRTC version from https://github.com/GetStream/stream-video-swift-webrtc releases - s.dependency 'StreamWebRTC', '= 145.15.0' + # WebRTC version from https://github.com/GetStream/stream-video-swift-webrtc releases. + # 148.0.0 is not yet published to CocoaPods trunk. so a temporary way to point at the pre-release xcframework published on GitHub releases. + s.vendored_frameworks = 'third_party/WebRTC.xcframework' # Swift/Objective-C compatibility #https://blog.cocoapods.org/CocoaPods-1.5.0/ s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' From ea57aa8c5a415e38e005f14fae7bbdcadbca0b81 Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Thu, 20 Aug 2026 13:08:44 +0200 Subject: [PATCH 02/11] typo fix --- android/build.gradle | 2 +- stream-react-native-webrtc.podspec | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/android/build.gradle b/android/build.gradle index 7baba6411..147a15f28 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -84,7 +84,7 @@ def kotlin_version = getExtOrDefault('kotlinVersion', '1.8.10') println "Building Stream WebRTC React Native module with Kotlin version: $kotlin_version" dependencies { - api 'io.getstream:stream-video-webrtc-android:146.16.0-SNAPSHOT' + api 'io.getstream:stream-video-webrtc-android:145.16.0-SNAPSHOT' implementation "com.facebook.react:react-android:+" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "androidx.core:core:1.7.0" diff --git a/stream-react-native-webrtc.podspec b/stream-react-native-webrtc.podspec index 6d0835578..e130dba11 100644 --- a/stream-react-native-webrtc.podspec +++ b/stream-react-native-webrtc.podspec @@ -9,7 +9,7 @@ package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) # `StreamWebRTC` podspec in that repo points at. CocoaPods skips `prepare_command` for # `:path` pods (which is how React Native autolinks this module), so the fetch happens # here — the podspec is plain Ruby, evaluated on every `pod install`. -webrtc_version = '146.16.0' +webrtc_version = '145.16.0' webrtc_url = "https://github.com/GetStream/stream-video-swift-webrtc/releases/download/#{webrtc_version}/WebRTC.xcframework.zip" webrtc_dir = File.join(__dir__, 'third_party') webrtc_framework = File.join(webrtc_dir, 'WebRTC.xcframework') From a906de19fd5134668240cbfaa9aeadbc3d9cbea2 Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Thu, 20 Aug 2026 13:36:45 +0200 Subject: [PATCH 03/11] add gitnore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 743ca6449..b4cd08331 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ android/build Podfile.lock WebRTC.xcframework WebRTC.dSYMs +third_party/.webrtc-version examples/GumTestApp/package-lock.json examples/GumTestApp_macOS/package-lock.json **/.xcode.env.local From e6b6a1749ccbe10a4e1c545dcdfcd9096ea05ae4 Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Mon, 7 Sep 2026 13:39:59 +0200 Subject: [PATCH 04/11] feat: e2ee support --- .../WebRTCModule/EncryptionManagerBridge.java | 433 ++++++++++++++++ .../WebRTCModule/PeerConnectionObserver.java | 14 + .../com/oney/WebRTCModule/WebRTCModule.java | 97 ++++ ios/RCTWebRTC/WebRTCModule+RTCEncryption.m | 484 ++++++++++++++++++ .../WebRTCModule+RTCPeerConnection.h | 5 + .../WebRTCModule+RTCPeerConnection.m | 34 ++ ios/RCTWebRTC/WebRTCModule.h | 3 + ios/RCTWebRTC/WebRTCModule.m | 14 +- src/RTCEncryptionManager.ts | 202 ++++++++ src/RTCEncryptionManagerEvents.ts | 100 ++++ src/index.ts | 31 ++ 11 files changed, 1416 insertions(+), 1 deletion(-) create mode 100644 android/src/main/java/com/oney/WebRTCModule/EncryptionManagerBridge.java create mode 100644 ios/RCTWebRTC/WebRTCModule+RTCEncryption.m create mode 100644 src/RTCEncryptionManager.ts create mode 100644 src/RTCEncryptionManagerEvents.ts diff --git a/android/src/main/java/com/oney/WebRTCModule/EncryptionManagerBridge.java b/android/src/main/java/com/oney/WebRTCModule/EncryptionManagerBridge.java new file mode 100644 index 000000000..f376529e3 --- /dev/null +++ b/android/src/main/java/com/oney/WebRTCModule/EncryptionManagerBridge.java @@ -0,0 +1,433 @@ +package com.oney.WebRTCModule; + +import android.util.Base64; +import android.util.Log; + +import androidx.annotation.Nullable; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.ReadableType; +import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.WritableMap; + +import org.webrtc.EncryptionManager; +import org.webrtc.RtpReceiver; +import org.webrtc.RtpSender; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Bridge over the native {@link EncryptionManager} (framed AES-GCM E2EE). Only control operations + * and events cross the bridge, never media frames: the transforms run entirely inside libwebrtc. + * + *

The native binding reports failures by throwing unchecked exceptions while iOS returns an + * error object. Both are normalized here into a {@code {error}} result map so the JS surface is + * identical on both platforms. + */ +class EncryptionManagerBridge { + static final String TAG = EncryptionManagerBridge.class.getCanonicalName(); + + private final WebRTCModule webRTCModule; + private final Map managers = new HashMap<>(); + + EncryptionManagerBridge(WebRTCModule webRTCModule) { + this.webRTCModule = webRTCModule; + } + + boolean isSupported() { + return EncryptionManager.isSupported(); + } + + WritableMap create(ReadableMap options) { + String userId = getString(options, "userId"); + if (userId == null || userId.isEmpty()) { + return error("encryptionManagerCreate() requires a non-empty userId"); + } + + try { + Integer algorithm = getInt(options, "algorithm"); + EncryptionManager manager = algorithm != null + ? EncryptionManager.create(userId, EncryptionManager.Algorithm.values()[algorithm]) + : EncryptionManager.create(userId); + + String handle = UUID.randomUUID().toString(); + // The observer is wired before the handle is returned so no event can be missed. + manager.setObserver(new EventObserver(handle)); + managers.put(handle, manager); + + WritableMap result = Arguments.createMap(); + result.putString("handle", handle); + return result; + } catch (RuntimeException e) { + return error(e); + } + } + + WritableMap dispose(ReadableMap options) { + String handle = getString(options, "handle"); + EncryptionManager manager = handle != null ? managers.remove(handle) : null; + if (manager == null) { + // Disposing twice, or after a reload, is not an error. + return Arguments.createMap(); + } + + try { + manager.setObserver(null); + manager.dispose(); + return Arguments.createMap(); + } catch (RuntimeException e) { + return error(e); + } + } + + /** + * Disposes every live manager. Called on module teardown (e.g. a bundle reload), where the peer + * connections are about to be closed underneath any attached transform and nothing in JS can + * survive to dispose them itself. + */ + void disposeAll() { + for (Map.Entry entry : managers.entrySet()) { + try { + entry.getValue().setObserver(null); + entry.getValue().dispose(); + } catch (RuntimeException e) { + Log.w(TAG, "disposeAll(): error disposing manager " + entry.getKey(), e); + } + } + managers.clear(); + } + + WritableMap setKey(ReadableMap options) { + EncryptionManager manager = getManager(options); + if (manager == null) { + return error("encryptionManagerSetKey(): manager not found"); + } + + String userId = getString(options, "userId"); + Integer keyIndex = getInt(options, "keyIndex"); + byte[] key = getKey(options); + if (userId == null || userId.isEmpty() || keyIndex == null || key == null) { + return error("encryptionManagerSetKey() requires userId, keyIndex and key"); + } + + try { + manager.setKey(userId, keyIndex, key); + return Arguments.createMap(); + } catch (RuntimeException e) { + return error(e); + } + } + + WritableMap setSharedKey(ReadableMap options) { + EncryptionManager manager = getManager(options); + if (manager == null) { + return error("encryptionManagerSetSharedKey(): manager not found"); + } + + Integer keyIndex = getInt(options, "keyIndex"); + byte[] key = getKey(options); + if (keyIndex == null || key == null) { + return error("encryptionManagerSetSharedKey() requires keyIndex and key"); + } + + try { + manager.setSharedKey(keyIndex, key); + return Arguments.createMap(); + } catch (RuntimeException e) { + return error(e); + } + } + + WritableMap removeKey(ReadableMap options) { + EncryptionManager manager = getManager(options); + if (manager == null) { + return error("encryptionManagerRemoveKey(): manager not found"); + } + + String userId = getString(options, "userId"); + Integer keyIndex = getInt(options, "keyIndex"); + if (userId == null || userId.isEmpty() || keyIndex == null) { + return error("encryptionManagerRemoveKey() requires userId and keyIndex"); + } + + try { + manager.removeKey(userId, keyIndex); + return Arguments.createMap(); + } catch (RuntimeException e) { + return error(e); + } + } + + WritableMap removeAllKeys(ReadableMap options) { + EncryptionManager manager = getManager(options); + if (manager == null) { + return error("encryptionManagerRemoveAllKeys(): manager not found"); + } + + String userId = getString(options, "userId"); + if (userId == null || userId.isEmpty()) { + return error("encryptionManagerRemoveAllKeys() requires userId"); + } + + try { + manager.removeAllKeys(userId); + return Arguments.createMap(); + } catch (RuntimeException e) { + return error(e); + } + } + + WritableMap removeSharedKey(ReadableMap options) { + EncryptionManager manager = getManager(options); + if (manager == null) { + return error("encryptionManagerRemoveSharedKey(): manager not found"); + } + + Integer keyIndex = getInt(options, "keyIndex"); + if (keyIndex == null) { + return error("encryptionManagerRemoveSharedKey() requires keyIndex"); + } + + try { + manager.removeSharedKey(keyIndex); + return Arguments.createMap(); + } catch (RuntimeException e) { + return error(e); + } + } + + WritableMap encrypt(ReadableMap options) { + EncryptionManager manager = getManager(options); + if (manager == null) { + return error("encryptionManagerEncrypt(): manager not found"); + } + + Integer peerConnectionId = getInt(options, "peerConnectionId"); + String senderId = getString(options, "senderId"); + if (peerConnectionId == null || senderId == null) { + return error("encryptionManagerEncrypt() requires peerConnectionId and senderId"); + } + + PeerConnectionObserver pco = webRTCModule.getPeerConnectionObserver(peerConnectionId); + if (pco == null) { + return error("encryptionManagerEncrypt(): peer connection " + peerConnectionId + " not found"); + } + + RtpSender sender = pco.getSender(senderId); + if (sender == null) { + return error("encryptionManagerEncrypt(): sender " + senderId + " not found"); + } + + try { + manager.encrypt(sender, getString(options, "codec"), parseTrackType(options)); + return Arguments.createMap(); + } catch (RuntimeException e) { + return error(e); + } + } + + WritableMap decrypt(ReadableMap options) { + EncryptionManager manager = getManager(options); + if (manager == null) { + return error("encryptionManagerDecrypt(): manager not found"); + } + + Integer peerConnectionId = getInt(options, "peerConnectionId"); + String receiverId = getString(options, "receiverId"); + String userId = getString(options, "userId"); + if (peerConnectionId == null || receiverId == null || userId == null || userId.isEmpty()) { + return error("encryptionManagerDecrypt() requires peerConnectionId, receiverId and userId"); + } + + PeerConnectionObserver pco = webRTCModule.getPeerConnectionObserver(peerConnectionId); + if (pco == null) { + return error("encryptionManagerDecrypt(): peer connection " + peerConnectionId + " not found"); + } + + RtpReceiver receiver = pco.getReceiver(receiverId); + if (receiver == null) { + return error("encryptionManagerDecrypt(): receiver " + receiverId + " not found"); + } + + try { + manager.decrypt(receiver, userId, parseTrackType(options)); + return Arguments.createMap(); + } catch (RuntimeException e) { + return error(e); + } + } + + void enablePerformanceReporting(String handle, boolean enabled) { + EncryptionManager manager = managers.get(handle); + if (manager == null) { + throw new IllegalStateException("manager not found"); + } + + manager.enablePerformanceReporting(enabled); + } + + void requestKeyState(String handle) { + EncryptionManager manager = managers.get(handle); + if (manager == null) { + throw new IllegalStateException("manager not found"); + } + + manager.requestKeyState(); + } + + private class EventObserver implements EncryptionManager.Observer { + private final String handle; + + EventObserver(String handle) { + this.handle = handle; + } + + @Override + public void onE2eeEvent(EncryptionManager.E2eeEvent event) { + WritableMap params = Arguments.createMap(); + params.putString("managerId", handle); + params.putString("type", event.type.name); + params.putString("userId", event.userId); + + if (event.trackType != null) { + params.putInt("trackType", event.trackType.getValue()); + } + if (event.keyIndex != null) { + params.putInt("keyIndex", event.keyIndex); + } + if (event.version != null) { + params.putInt("version", event.version); + } + if (event.reason != null) { + params.putString("reason", event.reason); + } + if (event.keyState != null) { + params.putMap("keyState", keyStateToMap(event.keyState)); + } + if (event.encode != null) { + params.putArray("encode", trackPerfToArray(event.encode)); + } + if (event.decode != null) { + params.putArray("decode", trackPerfToArray(event.decode)); + } + + // This callback runs on the crypto worker; events must be emitted off it. + ThreadUtils.runOnExecutor(() -> webRTCModule.sendEvent("encryptionManagerEvent", params)); + } + } + + /** Key fingerprints only; raw key material never crosses the bridge. */ + private static WritableMap keyStateToMap(EncryptionManager.KeyStateReport keyState) { + WritableArray perUserKeys = Arguments.createArray(); + for (EncryptionManager.UserKey key : keyState.perUserKeys) { + WritableMap row = Arguments.createMap(); + row.putString("userId", key.userId); + row.putInt("keyIndex", key.keyIndex); + row.putString("fingerprint", key.fingerprint); + perUserKeys.pushMap(row); + } + + WritableArray sharedKeys = Arguments.createArray(); + for (EncryptionManager.SharedKey key : keyState.sharedKeys) { + WritableMap row = Arguments.createMap(); + row.putInt("keyIndex", key.keyIndex); + row.putString("fingerprint", key.fingerprint); + row.putBoolean("isActive", key.isActive); + sharedKeys.pushMap(row); + } + + WritableMap map = Arguments.createMap(); + map.putArray("perUserKeys", perUserKeys); + map.putArray("sharedKeys", sharedKeys); + return map; + } + + private static WritableArray trackPerfToArray(java.util.List samples) { + WritableArray rows = Arguments.createArray(); + for (EncryptionManager.TrackPerf sample : samples) { + WritableMap row = Arguments.createMap(); + row.putString("userId", sample.userId); + row.putInt("trackType", sample.trackType.getValue()); + if (sample.codec != null) { + row.putString("codec", sample.codec); + } + row.putDouble("fps", sample.fps); + row.putDouble("maxCryptoMs", sample.maxCryptoMs); + rows.pushMap(row); + } + + return rows; + } + + @Nullable + private EncryptionManager getManager(ReadableMap options) { + String handle = getString(options, "handle"); + return handle != null ? managers.get(handle) : null; + } + + @Nullable + private static byte[] getKey(ReadableMap options) { + String key = getString(options, "key"); + if (key == null) { + return null; + } + + try { + return Base64.decode(key, Base64.NO_WRAP); + } catch (IllegalArgumentException e) { + Log.w(TAG, "Failed to decode the key: " + e.getMessage()); + return null; + } + } + + /** + * Absent means "read audio vs video from the sender/receiver"; screenshare must be explicit. A + * value that is present but unusable is rejected rather than inferred: silently falling back + * would group a screen-share-audio track with the microphone and share its replay window. + */ + @Nullable + private static EncryptionManager.TrackType parseTrackType(ReadableMap options) { + if (!options.hasKey("trackType") || options.isNull("trackType")) { + return null; + } + + Integer trackType = getInt(options, "trackType"); + if (trackType == null || trackType < 0 || trackType >= EncryptionManager.TrackType.values().length) { + throw new IllegalArgumentException("got an unusable trackType"); + } + + return EncryptionManager.TrackType.values()[trackType]; + } + + @Nullable + private static String getString(ReadableMap options, String key) { + if (!options.hasKey(key) || options.getType(key) != ReadableType.String) { + return null; + } + + return options.getString(key); + } + + @Nullable + private static Integer getInt(ReadableMap options, String key) { + if (!options.hasKey(key) || options.getType(key) != ReadableType.Number) { + return null; + } + + return options.getInt(key); + } + + private static WritableMap error(RuntimeException e) { + String message = e.getMessage(); + return error(message != null ? message : e.getClass().getSimpleName()); + } + + private static WritableMap error(String message) { + WritableMap result = Arguments.createMap(); + result.putString("error", message); + return result; + } +} diff --git a/android/src/main/java/com/oney/WebRTCModule/PeerConnectionObserver.java b/android/src/main/java/com/oney/WebRTCModule/PeerConnectionObserver.java index d060ff3b1..cb34ff378 100644 --- a/android/src/main/java/com/oney/WebRTCModule/PeerConnectionObserver.java +++ b/android/src/main/java/com/oney/WebRTCModule/PeerConnectionObserver.java @@ -146,6 +146,20 @@ RtpSender getSender(String id) { return null; } + RtpReceiver getReceiver(String id) { + if (this.peerConnection == null) { + return null; + } + + for (RtpReceiver receiver : this.peerConnection.getReceivers()) { + if (receiver.id().equals(id)) { + return receiver; + } + } + + return null; + } + RtpTransceiver getTransceiver(String id) { if (this.peerConnection == null) { return null; diff --git a/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java b/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java index 4c903d8bc..6988f203c 100644 --- a/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java +++ b/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java @@ -59,6 +59,8 @@ public class WebRTCModule extends ReactContextBaseJavaModule { private final GetUserMediaImpl getUserMediaImpl; + private final EncryptionManagerBridge encryptionManagerBridge = new EncryptionManagerBridge(this); + @Nullable private RTCCameraPreviewView activeCameraPreview; @@ -211,6 +213,10 @@ public void invalidate() { try { ThreadUtils .submitToExecutor(() -> { + // 0. Dispose E2EE managers: their frame transforms are held by the senders and + // receivers of the PeerConnections disposed next. + encryptionManagerBridge.disposeAll(); + // 1. Dispose PeerConnections (dispose() calls close() internally) for (int i = 0; i < mPeerConnectionObservers.size(); i++) { try { @@ -1701,6 +1707,97 @@ public void dataChannelSend(int peerConnectionId, String reactTag, String data, }); } + /** + * The E2EE key and attach operations below are blocking-synchronous on purpose. An async attach + * would leave a window where a sender exists before its transform is installed, which is a + * plaintext window. Only the observational calls use promises. + */ + private WritableMap submitEncryptionCall(Callable callable) { + try { + return ThreadUtils.submitToExecutor(callable).get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } catch (ExecutionException e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + + @ReactMethod(isBlockingSynchronousMethod = true) + public boolean encryptionManagerIsSupported() { + return encryptionManagerBridge.isSupported(); + } + + @ReactMethod(isBlockingSynchronousMethod = true) + public WritableMap encryptionManagerCreate(ReadableMap options) { + return submitEncryptionCall(() -> encryptionManagerBridge.create(options)); + } + + @ReactMethod(isBlockingSynchronousMethod = true) + public WritableMap encryptionManagerDispose(ReadableMap options) { + return submitEncryptionCall(() -> encryptionManagerBridge.dispose(options)); + } + + @ReactMethod(isBlockingSynchronousMethod = true) + public WritableMap encryptionManagerSetKey(ReadableMap options) { + return submitEncryptionCall(() -> encryptionManagerBridge.setKey(options)); + } + + @ReactMethod(isBlockingSynchronousMethod = true) + public WritableMap encryptionManagerSetSharedKey(ReadableMap options) { + return submitEncryptionCall(() -> encryptionManagerBridge.setSharedKey(options)); + } + + @ReactMethod(isBlockingSynchronousMethod = true) + public WritableMap encryptionManagerRemoveKey(ReadableMap options) { + return submitEncryptionCall(() -> encryptionManagerBridge.removeKey(options)); + } + + @ReactMethod(isBlockingSynchronousMethod = true) + public WritableMap encryptionManagerRemoveAllKeys(ReadableMap options) { + return submitEncryptionCall(() -> encryptionManagerBridge.removeAllKeys(options)); + } + + @ReactMethod(isBlockingSynchronousMethod = true) + public WritableMap encryptionManagerRemoveSharedKey(ReadableMap options) { + return submitEncryptionCall(() -> encryptionManagerBridge.removeSharedKey(options)); + } + + @ReactMethod(isBlockingSynchronousMethod = true) + public WritableMap encryptionManagerEncrypt(ReadableMap options) { + return submitEncryptionCall(() -> encryptionManagerBridge.encrypt(options)); + } + + @ReactMethod(isBlockingSynchronousMethod = true) + public WritableMap encryptionManagerDecrypt(ReadableMap options) { + return submitEncryptionCall(() -> encryptionManagerBridge.decrypt(options)); + } + + @ReactMethod + public void encryptionManagerEnablePerformanceReporting(String handle, boolean enabled, Promise promise) { + ThreadUtils.runOnExecutor(() -> { + try { + encryptionManagerBridge.enablePerformanceReporting(handle, enabled); + promise.resolve(null); + } catch (RuntimeException e) { + promise.reject("encryptionManagerEnablePerformanceReportingFailed", e.getMessage(), e); + } + }); + } + + @ReactMethod + public void encryptionManagerRequestKeyState(String handle, Promise promise) { + ThreadUtils.runOnExecutor(() -> { + try { + encryptionManagerBridge.requestKeyState(handle); + promise.resolve(null); + } catch (RuntimeException e) { + promise.reject("encryptionManagerRequestKeyStateFailed", e.getMessage(), e); + } + }); + } + @ReactMethod public void addListener(String eventName) { // Keep: Required for RN built in Event Emitter Calls. diff --git a/ios/RCTWebRTC/WebRTCModule+RTCEncryption.m b/ios/RCTWebRTC/WebRTCModule+RTCEncryption.m new file mode 100644 index 000000000..4f6f60d09 --- /dev/null +++ b/ios/RCTWebRTC/WebRTCModule+RTCEncryption.m @@ -0,0 +1,484 @@ +#import + +#import +#import + +#import "WebRTCModule+RTCPeerConnection.h" +#import "WebRTCModule.h" + +/* + * Bridge over the native `RTCEncryptionManager` (framed AES-GCM E2EE). Only control operations and + * events cross the bridge, never media frames: the transforms run entirely inside libwebrtc. + * + * The attach path (`encrypt`/`decrypt`) and every key operation are blocking-synchronous. An async + * attach would leave a window where a sender exists before its transform is installed, which is a + * plaintext window. Only the observational calls use promises. + */ + +static char kEncryptionManagerHandleKey; + +@interface WebRTCModule (RTCEncryption) +@end + +@implementation WebRTCModule (RTCEncryption) + +#pragma mark - Helpers + +/* Values omitted by JS arrive as nil or NSNull; both mean "let native decide". */ +static id RTCEncryptionOptionalValue(NSDictionary *options, NSString *key, Class expectedClass) { + id value = options[key]; + if (value == nil || value == (id)kCFNull || ![value isKindOfClass:expectedClass]) { + return nil; + } + + return value; +} + +static NSDictionary *RTCEncryptionErrorResult(NSError *error, NSString *fallback) { + NSString *message = error.localizedDescription.length > 0 ? error.localizedDescription : fallback; + return @{@"error" : message}; +} + +- (nullable RTC_OBJC_TYPE(RTCEncryptionManager) *)encryptionManagerForOptions:(NSDictionary *)options { + NSString *handle = RTCEncryptionOptionalValue(options, @"handle", [NSString class]); + if (handle == nil) { + return nil; + } + + return self.encryptionManagers[handle]; +} + +/* + * Absent means "read audio vs video from the sender/receiver"; screenshare must be explicit. A value + * that is present but unusable is rejected rather than inferred: silently falling back would group a + * screen-share-audio track with the microphone and share its replay window. + */ +static BOOL RTCEncryptionTrackTypeFromOptions(NSDictionary *options, NSNumber **trackType) { + id value = options[@"trackType"]; + if (value == nil || value == (id)kCFNull) { + *trackType = nil; + return YES; + } + + if (![value isKindOfClass:[NSNumber class]] || [value integerValue] < RTCEncryptionTrackTypeAudio || + [value integerValue] > RTCEncryptionTrackTypeScreenshareAudio) { + return NO; + } + + *trackType = value; + return YES; +} + +- (nullable NSData *)encryptionKeyFromOptions:(NSDictionary *)options { + NSString *key = RTCEncryptionOptionalValue(options, @"key", [NSString class]); + if (key == nil) { + return nil; + } + + return [[NSData alloc] initWithBase64EncodedString:key options:0]; +} + +#pragma mark - Lifecycle + +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(encryptionManagerIsSupported) { + return @([RTC_OBJC_TYPE(RTCEncryptionManager) isSupported]); +} + +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(encryptionManagerCreate : (nonnull NSDictionary *)options) { + __block NSDictionary *result = nil; + + dispatch_sync(self.workerQueue, ^{ + NSString *userId = RTCEncryptionOptionalValue(options, @"userId", [NSString class]); + if (userId.length == 0) { + result = @{@"error" : @"encryptionManagerCreate() requires a non-empty userId"}; + return; + } + + NSNumber *algorithm = RTCEncryptionOptionalValue(options, @"algorithm", [NSNumber class]); + if (algorithm != nil && algorithm.integerValue != RTCEncryptionAlgorithmAes128Gcm && + algorithm.integerValue != RTCEncryptionAlgorithmAes256Gcm) { + result = @{@"error" : @"encryptionManagerCreate() got an unknown algorithm"}; + return; + } + + NSError *error = nil; + RTC_OBJC_TYPE(RTCEncryptionManager) * manager; + if (algorithm != nil) { + manager = [RTC_OBJC_TYPE(RTCEncryptionManager) createWithUserId:userId + algorithm:algorithm.integerValue + error:&error]; + } else { + manager = [RTC_OBJC_TYPE(RTCEncryptionManager) createWithUserId:userId error:&error]; + } + + if (manager == nil) { + result = RTCEncryptionErrorResult(error, @"Failed to create the encryption manager"); + return; + } + + NSString *handle = [[NSUUID UUID] UUIDString]; + objc_setAssociatedObject(manager, &kEncryptionManagerHandleKey, handle, OBJC_ASSOCIATION_COPY); + /* The delegate is wired before the handle is returned so no event can be missed. */ + manager.delegate = self; + self.encryptionManagers[handle] = manager; + + result = @{@"handle" : handle}; + }); + + return result; +} + +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(encryptionManagerDispose : (nonnull NSDictionary *)options) { + __block NSDictionary *result = @{}; + + dispatch_sync(self.workerQueue, ^{ + NSString *handle = RTCEncryptionOptionalValue(options, @"handle", [NSString class]); + RTC_OBJC_TYPE(RTCEncryptionManager) *manager = handle != nil ? self.encryptionManagers[handle] : nil; + if (manager == nil) { + /* Disposing twice, or after a reload, is not an error. */ + return; + } + + manager.delegate = nil; + [manager dispose]; + objc_setAssociatedObject(manager, &kEncryptionManagerHandleKey, nil, OBJC_ASSOCIATION_COPY); + [self.encryptionManagers removeObjectForKey:handle]; + }); + + return result; +} + +#pragma mark - Keys + +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(encryptionManagerSetKey : (nonnull NSDictionary *)options) { + __block NSDictionary *result = @{}; + + dispatch_sync(self.workerQueue, ^{ + RTC_OBJC_TYPE(RTCEncryptionManager) *manager = [self encryptionManagerForOptions:options]; + if (manager == nil) { + result = @{@"error" : @"encryptionManagerSetKey(): manager not found"}; + return; + } + + NSString *userId = RTCEncryptionOptionalValue(options, @"userId", [NSString class]); + NSNumber *keyIndex = RTCEncryptionOptionalValue(options, @"keyIndex", [NSNumber class]); + NSData *key = [self encryptionKeyFromOptions:options]; + if (userId.length == 0 || keyIndex == nil || key == nil) { + result = @{@"error" : @"encryptionManagerSetKey() requires userId, keyIndex and key"}; + return; + } + + NSError *error = nil; + if (![manager setKey:userId keyIndex:keyIndex.intValue rawKey:key error:&error]) { + result = RTCEncryptionErrorResult(error, @"setKey failed"); + } + }); + + return result; +} + +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(encryptionManagerSetSharedKey : (nonnull NSDictionary *)options) { + __block NSDictionary *result = @{}; + + dispatch_sync(self.workerQueue, ^{ + RTC_OBJC_TYPE(RTCEncryptionManager) *manager = [self encryptionManagerForOptions:options]; + if (manager == nil) { + result = @{@"error" : @"encryptionManagerSetSharedKey(): manager not found"}; + return; + } + + NSNumber *keyIndex = RTCEncryptionOptionalValue(options, @"keyIndex", [NSNumber class]); + NSData *key = [self encryptionKeyFromOptions:options]; + if (keyIndex == nil || key == nil) { + result = @{@"error" : @"encryptionManagerSetSharedKey() requires keyIndex and key"}; + return; + } + + NSError *error = nil; + if (![manager setSharedKey:keyIndex.intValue rawKey:key error:&error]) { + result = RTCEncryptionErrorResult(error, @"setSharedKey failed"); + } + }); + + return result; +} + +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(encryptionManagerRemoveKey : (nonnull NSDictionary *)options) { + __block NSDictionary *result = @{}; + + dispatch_sync(self.workerQueue, ^{ + RTC_OBJC_TYPE(RTCEncryptionManager) *manager = [self encryptionManagerForOptions:options]; + if (manager == nil) { + result = @{@"error" : @"encryptionManagerRemoveKey(): manager not found"}; + return; + } + + NSString *userId = RTCEncryptionOptionalValue(options, @"userId", [NSString class]); + NSNumber *keyIndex = RTCEncryptionOptionalValue(options, @"keyIndex", [NSNumber class]); + if (userId.length == 0 || keyIndex == nil) { + result = @{@"error" : @"encryptionManagerRemoveKey() requires userId and keyIndex"}; + return; + } + + NSError *error = nil; + if (![manager removeKey:userId keyIndex:keyIndex.intValue error:&error]) { + result = RTCEncryptionErrorResult(error, @"removeKey failed"); + } + }); + + return result; +} + +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(encryptionManagerRemoveAllKeys : (nonnull NSDictionary *)options) { + __block NSDictionary *result = @{}; + + dispatch_sync(self.workerQueue, ^{ + RTC_OBJC_TYPE(RTCEncryptionManager) *manager = [self encryptionManagerForOptions:options]; + if (manager == nil) { + result = @{@"error" : @"encryptionManagerRemoveAllKeys(): manager not found"}; + return; + } + + NSString *userId = RTCEncryptionOptionalValue(options, @"userId", [NSString class]); + if (userId.length == 0) { + result = @{@"error" : @"encryptionManagerRemoveAllKeys() requires userId"}; + return; + } + + NSError *error = nil; + if (![manager removeAllKeys:userId error:&error]) { + result = RTCEncryptionErrorResult(error, @"removeAllKeys failed"); + } + }); + + return result; +} + +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(encryptionManagerRemoveSharedKey : (nonnull NSDictionary *)options) { + __block NSDictionary *result = @{}; + + dispatch_sync(self.workerQueue, ^{ + RTC_OBJC_TYPE(RTCEncryptionManager) *manager = [self encryptionManagerForOptions:options]; + if (manager == nil) { + result = @{@"error" : @"encryptionManagerRemoveSharedKey(): manager not found"}; + return; + } + + NSNumber *keyIndex = RTCEncryptionOptionalValue(options, @"keyIndex", [NSNumber class]); + if (keyIndex == nil) { + result = @{@"error" : @"encryptionManagerRemoveSharedKey() requires keyIndex"}; + return; + } + + NSError *error = nil; + if (![manager removeSharedKey:keyIndex.intValue error:&error]) { + result = RTCEncryptionErrorResult(error, @"removeSharedKey failed"); + } + }); + + return result; +} + +#pragma mark - Attach + +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(encryptionManagerEncrypt : (nonnull NSDictionary *)options) { + __block NSDictionary *result = @{}; + + dispatch_sync(self.workerQueue, ^{ + RTC_OBJC_TYPE(RTCEncryptionManager) *manager = [self encryptionManagerForOptions:options]; + if (manager == nil) { + result = @{@"error" : @"encryptionManagerEncrypt(): manager not found"}; + return; + } + + NSNumber *peerConnectionId = RTCEncryptionOptionalValue(options, @"peerConnectionId", [NSNumber class]); + NSString *senderId = RTCEncryptionOptionalValue(options, @"senderId", [NSString class]); + if (peerConnectionId == nil || senderId == nil) { + result = @{@"error" : @"encryptionManagerEncrypt() requires peerConnectionId and senderId"}; + return; + } + + RTCRtpSender *sender = [self getSenderByPeerConnectionId:peerConnectionId senderId:senderId]; + if (sender == nil) { + result = + @{@"error" : [NSString stringWithFormat:@"encryptionManagerEncrypt(): sender %@ not found", senderId]}; + return; + } + + NSNumber *trackType = nil; + if (!RTCEncryptionTrackTypeFromOptions(options, &trackType)) { + result = @{@"error" : @"encryptionManagerEncrypt() got an unusable trackType"}; + return; + } + + NSError *error = nil; + NSString *codec = RTCEncryptionOptionalValue(options, @"codec", [NSString class]); + if (![manager encrypt:sender codec:codec trackType:trackType error:&error]) { + result = RTCEncryptionErrorResult(error, @"Failed to attach the encrypt transform"); + } + }); + + return result; +} + +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(encryptionManagerDecrypt : (nonnull NSDictionary *)options) { + __block NSDictionary *result = @{}; + + dispatch_sync(self.workerQueue, ^{ + RTC_OBJC_TYPE(RTCEncryptionManager) *manager = [self encryptionManagerForOptions:options]; + if (manager == nil) { + result = @{@"error" : @"encryptionManagerDecrypt(): manager not found"}; + return; + } + + NSNumber *peerConnectionId = RTCEncryptionOptionalValue(options, @"peerConnectionId", [NSNumber class]); + NSString *receiverId = RTCEncryptionOptionalValue(options, @"receiverId", [NSString class]); + NSString *userId = RTCEncryptionOptionalValue(options, @"userId", [NSString class]); + if (peerConnectionId == nil || receiverId == nil || userId.length == 0) { + result = @{@"error" : @"encryptionManagerDecrypt() requires peerConnectionId, receiverId and userId"}; + return; + } + + RTCRtpReceiver *receiver = [self getReceiverByPeerConnectionId:peerConnectionId receiverId:receiverId]; + if (receiver == nil) { + result = @{ + @"error" : [NSString stringWithFormat:@"encryptionManagerDecrypt(): receiver %@ not found", receiverId] + }; + return; + } + + NSNumber *trackType = nil; + if (!RTCEncryptionTrackTypeFromOptions(options, &trackType)) { + result = @{@"error" : @"encryptionManagerDecrypt() got an unusable trackType"}; + return; + } + + NSError *error = nil; + if (![manager decrypt:receiver userId:userId trackType:trackType error:&error]) { + result = RTCEncryptionErrorResult(error, @"Failed to attach the decrypt transform"); + } + }); + + return result; +} + +#pragma mark - Diagnostics + +RCT_EXPORT_METHOD(encryptionManagerEnablePerformanceReporting : (nonnull NSString *)handle enabled : (BOOL) + enabled resolve : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) { + RTC_OBJC_TYPE(RTCEncryptionManager) *manager = self.encryptionManagers[handle]; + if (manager == nil) { + reject(@"encryptionManagerEnablePerformanceReportingFailed", @"manager not found", nil); + return; + } + + NSError *error = nil; + if (![manager enablePerformanceReporting:enabled error:&error]) { + reject(@"encryptionManagerEnablePerformanceReportingFailed", @"enablePerformanceReporting failed", error); + return; + } + + resolve(nil); +} + +RCT_EXPORT_METHOD(encryptionManagerRequestKeyState : (nonnull NSString *)handle resolve : (RCTPromiseResolveBlock) + resolve reject : (RCTPromiseRejectBlock)reject) { + RTC_OBJC_TYPE(RTCEncryptionManager) *manager = self.encryptionManagers[handle]; + if (manager == nil) { + reject(@"encryptionManagerRequestKeyStateFailed", @"manager not found", nil); + return; + } + + NSError *error = nil; + if (![manager requestKeyState:&error]) { + reject(@"encryptionManagerRequestKeyStateFailed", @"requestKeyState failed", error); + return; + } + + resolve(nil); +} + +#pragma mark - RTCEncryptionManagerDelegate + +/* Key fingerprints only; raw key material never crosses the bridge. */ +- (NSArray *)keyStatePerUserKeysToJSON:(RTC_OBJC_TYPE(RTCEncryptionKeyState) *)keyState { + NSMutableArray *keys = [NSMutableArray arrayWithCapacity:keyState.perUserKeys.count]; + for (RTC_OBJC_TYPE(RTCEncryptionUserKey) * key in keyState.perUserKeys) { + [keys addObject:@{@"userId" : key.userId, @"keyIndex" : @(key.keyIndex), @"fingerprint" : key.fingerprint}]; + } + + return keys; +} + +- (NSArray *)keyStateSharedKeysToJSON:(RTC_OBJC_TYPE(RTCEncryptionKeyState) *)keyState { + NSMutableArray *keys = [NSMutableArray arrayWithCapacity:keyState.sharedKeys.count]; + for (RTC_OBJC_TYPE(RTCEncryptionSharedKey) * key in keyState.sharedKeys) { + [keys addObject:@{ + @"keyIndex" : @(key.keyIndex), + @"fingerprint" : key.fingerprint, + @"isActive" : @(key.isActive) + }]; + } + + return keys; +} + +- (NSArray *)trackPerfToJSON:(NSArray *)samples { + NSMutableArray *rows = [NSMutableArray arrayWithCapacity:samples.count]; + for (RTC_OBJC_TYPE(RTCEncryptionTrackPerf) * sample in samples) { + NSMutableDictionary *row = [NSMutableDictionary dictionaryWithDictionary:@{ + @"userId" : sample.userId, + @"trackType" : @(sample.trackType), + @"fps" : @(sample.fps), + @"maxCryptoMs" : @(sample.maxCryptoMs) + }]; + if (sample.codec != nil) { + row[@"codec"] = sample.codec; + } + + [rows addObject:row]; + } + + return rows; +} + +- (void)encryptionManager:(RTC_OBJC_TYPE(RTCEncryptionManager) *)manager + didReceiveEvent:(RTC_OBJC_TYPE(RTCE2eeEvent) *)event { + id handle = objc_getAssociatedObject(manager, &kEncryptionManagerHandleKey); + if (![handle isKindOfClass:[NSString class]]) { + RCTLogWarn(@"E2EE event for a manager without a handle"); + return; + } + + NSMutableDictionary *body = [NSMutableDictionary + dictionaryWithDictionary:@{@"managerId" : (NSString *)handle, @"type" : event.name, @"userId" : event.userId}]; + + if (event.trackType != nil) { + body[@"trackType"] = event.trackType; + } + if (event.keyIndex != nil) { + body[@"keyIndex"] = event.keyIndex; + } + if (event.version != nil) { + body[@"version"] = event.version; + } + if (event.reason != nil) { + body[@"reason"] = event.reason; + } + if (event.keyState != nil) { + body[@"keyState"] = @{ + @"perUserKeys" : [self keyStatePerUserKeysToJSON:event.keyState], + @"sharedKeys" : [self keyStateSharedKeysToJSON:event.keyState] + }; + } + if (event.encode != nil) { + body[@"encode"] = [self trackPerfToJSON:event.encode]; + } + if (event.decode != nil) { + body[@"decode"] = [self trackPerfToJSON:event.decode]; + } + + [self sendEventWithName:kEventEncryptionManagerEvent body:body]; +} + +@end diff --git a/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.h b/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.h index 60099a12d..d7d1aa720 100644 --- a/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.h +++ b/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.h @@ -19,4 +19,9 @@ - (void)peerConnectionClose:(nonnull NSNumber *)objectID; - (void)peerConnectionDispose:(nonnull NSNumber *)objectID; +- (nullable RTCRtpSender *)getSenderByPeerConnectionId:(nonnull NSNumber *)peerConnectionId + senderId:(nonnull NSString *)senderId; +- (nullable RTCRtpReceiver *)getReceiverByPeerConnectionId:(nonnull NSNumber *)peerConnectionId + receiverId:(nonnull NSString *)receiverId; + @end diff --git a/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.m b/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.m index 167c9e94f..4f14214f0 100644 --- a/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.m +++ b/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.m @@ -95,6 +95,40 @@ + (RTCCertificate *)getCertificate:(NSString *)certId { int _transceiverNextId = 0; +- (nullable RTCRtpSender *)getSenderByPeerConnectionId:(nonnull NSNumber *)peerConnectionId + senderId:(nonnull NSString *)senderId { + RTCPeerConnection *peerConnection = self.peerConnections[peerConnectionId]; + if (!peerConnection) { + RCTLogWarn(@"PeerConnection %@ not found", peerConnectionId); + return nil; + } + + for (RTCRtpSender *s in peerConnection.senders) { + if ([senderId isEqual:s.senderId]) { + return s; + } + } + + return nil; +} + +- (nullable RTCRtpReceiver *)getReceiverByPeerConnectionId:(nonnull NSNumber *)peerConnectionId + receiverId:(nonnull NSString *)receiverId { + RTCPeerConnection *peerConnection = self.peerConnections[peerConnectionId]; + if (!peerConnection) { + RCTLogWarn(@"PeerConnection %@ not found", peerConnectionId); + return nil; + } + + for (RTCRtpReceiver *r in peerConnection.receivers) { + if ([receiverId isEqual:r.receiverId]) { + return r; + } + } + + return nil; +} + /* * This method is synchronous and blocking. This is done so we can implement createDataChannel * in the same way (synchronous) since the peer connection needs to exist before. diff --git a/ios/RCTWebRTC/WebRTCModule.h b/ios/RCTWebRTC/WebRTCModule.h index 2dc567d11..6395db79a 100644 --- a/ios/RCTWebRTC/WebRTCModule.h +++ b/ios/RCTWebRTC/WebRTCModule.h @@ -32,6 +32,7 @@ static NSString *const kEventAudioDeviceModuleEngineWillRelease = @"audioDeviceM static NSString *const kEventAudioDeviceModuleDevicesUpdated = @"audioDeviceModuleDevicesUpdated"; static NSString *const kEventAudioDeviceModuleAudioProcessingStateUpdated = @"audioDeviceModuleAudioProcessingStateUpdated"; +static NSString *const kEventEncryptionManagerEvent = @"encryptionManagerEvent"; @class AudioDeviceModule; @class CaptureController; @@ -52,6 +53,8 @@ static NSString *const kEventAudioDeviceModuleAudioProcessingStateUpdated = @property(nonatomic, strong) NSMutableDictionary *peerConnections; @property(nonatomic, strong) NSMutableDictionary *localStreams; @property(nonatomic, strong) NSMutableDictionary *localTracks; +@property(nonatomic, strong) + NSMutableDictionary *encryptionManagers; @property(nonatomic, weak) id activeCameraPreview; diff --git a/ios/RCTWebRTC/WebRTCModule.m b/ios/RCTWebRTC/WebRTCModule.m index ffde484d5..f8902de2e 100644 --- a/ios/RCTWebRTC/WebRTCModule.m +++ b/ios/RCTWebRTC/WebRTCModule.m @@ -37,6 +37,16 @@ + (BOOL)requiresMainQueueSetup { } - (void)dealloc { + // E2EE managers first: their frame transforms are held by the senders and receivers of the peer + // connections closed below, and nothing in JS survives to dispose them. + for (NSString *handle in _encryptionManagers) { + RTC_OBJC_TYPE(RTCEncryptionManager) *manager = _encryptionManagers[handle]; + manager.delegate = nil; + [manager dispose]; + } + [_encryptionManagers removeAllObjects]; + _encryptionManagers = nil; + [_localTracks removeAllObjects]; _localTracks = nil; [_localStreams removeAllObjects]; @@ -120,6 +130,7 @@ - (instancetype)init { _peerConnections = [NSMutableDictionary new]; _localStreams = [NSMutableDictionary new]; _localTracks = [NSMutableDictionary new]; + _encryptionManagers = [NSMutableDictionary new]; dispatch_queue_attr_t attributes = dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INITIATED, -1); @@ -296,7 +307,8 @@ - (BOOL)disposeCurrentFactoryOrdered { kEventAudioDeviceModuleEngineDidDisable, kEventAudioDeviceModuleEngineWillRelease, kEventAudioDeviceModuleDevicesUpdated, - kEventAudioDeviceModuleAudioProcessingStateUpdated + kEventAudioDeviceModuleAudioProcessingStateUpdated, + kEventEncryptionManagerEvent ]; } diff --git a/src/RTCEncryptionManager.ts b/src/RTCEncryptionManager.ts new file mode 100644 index 000000000..9934bfd3d --- /dev/null +++ b/src/RTCEncryptionManager.ts @@ -0,0 +1,202 @@ +import * as base64 from 'base64-js'; +import { NativeModules, type EmitterSubscription } from 'react-native'; + +import { + encryptionManagerEvents, + type RTCEncryptionEventData, + type RTCEncryptionEventType, + RTCEncryptionTrackType, +} from './RTCEncryptionManagerEvents'; +import RTCRtpReceiver from './RTCRtpReceiver'; +import RTCRtpSender from './RTCRtpSender'; + +const { WebRTCModule } = NativeModules; + +/** AES-GCM key size. Default is AES-128, i.e. 16-byte keys. */ +export enum RTCEncryptionAlgorithm { + AES_128_GCM = 0, + AES_256_GCM = 1, +} + +export interface RTCEncryptionManagerOptions { + algorithm?: RTCEncryptionAlgorithm; +} + +type NativeResult = { handle?: string, error?: string }; + +/** + * End-to-end encryption of already-encoded media frames (framed AES-GCM). One manager holds the + * keys and attaches encrypt/decrypt transforms to RTP senders and receivers; the SFU then forwards + * ciphertext it cannot read. + * + * The key and attach operations are synchronous by design: an async attach would leave a window + * where a sender exists before its transform is installed, which is a plaintext window. They throw + * on failure so a caller can never mistake a failed attach for a successful one. + * + * There is no detach API. Once attached, the only exits are destroying the sender/receiver or + * calling {@link dispose}, which drops in-flight frames rather than draining them. Nothing native + * cleans a manager up when a peer connection closes, so callers must dispose explicitly. + */ +export default class RTCEncryptionManager { + _handle: string; + _disposed = false; + _subscription: EmitterSubscription | null = null; + _listeners: Map void>> = new Map(); + + /** Native always supports encoded transforms, unlike the browser Encoded Transform API. */ + static isSupported(): boolean { + return Boolean(WebRTCModule?.encryptionManagerIsSupported?.()); + } + + static create(userId: string, options: RTCEncryptionManagerOptions = {}): RTCEncryptionManager { + const result: NativeResult = WebRTCModule.encryptionManagerCreate({ + userId, + ...(options.algorithm === undefined ? {} : { algorithm: options.algorithm }), + }); + + if (result?.error || !result?.handle) { + throw new Error(result?.error ?? 'Failed to create the encryption manager'); + } + + return new RTCEncryptionManager(result.handle); + } + + constructor(handle: string) { + this._handle = handle; + this._registerEvents(); + } + + /** + * Install a key for a participant. `rawKey` must be 16 bytes for AES-128 or 32 for AES-256, and + * `keyIndex` must be in the 0-255 range. + */ + setKey(userId: string, keyIndex: number, rawKey: Uint8Array): void { + this._invoke('encryptionManagerSetKey', { + userId, + keyIndex, + key: base64.fromByteArray(rawKey), + }); + } + + /** Install a key shared by every participant of the call. */ + setSharedKey(keyIndex: number, rawKey: Uint8Array): void { + this._invoke('encryptionManagerSetSharedKey', { + keyIndex, + key: base64.fromByteArray(rawKey), + }); + } + + removeKey(userId: string, keyIndex: number): void { + this._invoke('encryptionManagerRemoveKey', { userId, keyIndex }); + } + + removeAllKeys(userId: string): void { + this._invoke('encryptionManagerRemoveAllKeys', { userId }); + } + + removeSharedKey(keyIndex: number): void { + this._invoke('encryptionManagerRemoveSharedKey', { keyIndex }); + } + + /** + * Attach the encrypt transform to a sender. + * + * `codec` is an exact lowercase pin (`opus`/`vp8`/`vp9`/`h264`); anything else fails closed. + * Omitting it reads the codec from the frame. Omitting `trackType` defaults to audio vs video + * from the sender, so screen-share types must be passed explicitly. + */ + encrypt(sender: RTCRtpSender, codec?: string, trackType?: RTCEncryptionTrackType): void { + this._invoke('encryptionManagerEncrypt', { + peerConnectionId: sender._peerConnectionId, + senderId: sender._id, + ...(codec === undefined ? {} : { codec }), + ...(trackType === undefined ? {} : { trackType }), + }); + } + + /** Attach the decrypt transform to a receiver carrying `userId`'s media. */ + decrypt(receiver: RTCRtpReceiver, userId: string, trackType?: RTCEncryptionTrackType): void { + this._invoke('encryptionManagerDecrypt', { + peerConnectionId: receiver._peerConnectionId, + receiverId: receiver._id, + userId, + ...(trackType === undefined ? {} : { trackType }), + }); + } + + /** When enabled, `e2ee.perf_report` is emitted once per second. */ + enablePerformanceReporting(enabled: boolean): Promise { + this._assertNotDisposed(); + + return WebRTCModule.encryptionManagerEnablePerformanceReporting(this._handle, enabled); + } + + /** Ask for an `e2ee.key_state` event carrying the current key fingerprints. */ + requestKeyState(): Promise { + this._assertNotDisposed(); + + return WebRTCModule.encryptionManagerRequestKeyState(this._handle); + } + + on(type: RTCEncryptionEventType, listener: (data: RTCEncryptionEventData) => void): void { + this._assertNotDisposed(); + + let listeners = this._listeners.get(type); + + if (!listeners) { + listeners = new Set(); + this._listeners.set(type, listeners); + } + + listeners.add(listener); + } + + off(type: RTCEncryptionEventType, listener: (data: RTCEncryptionEventData) => void): void { + this._listeners.get(type)?.delete(listener); + } + + /** Drops in-flight frames. Keys and transforms are released; the handle becomes unusable. */ + dispose(): void { + if (this._disposed) { + return; + } + + try { + this._invoke('encryptionManagerDispose', {}); + } finally { + this._disposed = true; + this._subscription?.remove(); + this._subscription = null; + this._listeners.clear(); + } + } + + _assertNotDisposed(): void { + if (this._disposed) { + throw new Error('RTCEncryptionManager has been disposed'); + } + } + + _invoke(method: string, params: Record): void { + this._assertNotDisposed(); + + const result: NativeResult = WebRTCModule[method]({ handle: this._handle, ...params }); + + if (result?.error) { + throw new Error(result.error); + } + } + + _registerEvents(): void { + // Idempotent, and keeps this class usable when imported directly rather than via index.ts. + encryptionManagerEvents.setupListeners(); + + this._subscription = encryptionManagerEvents.addEncryptionManagerEventListener(ev => { + if (ev.managerId !== this._handle) { + return; + } + + this._listeners.get(ev.type)?.forEach(listener => listener(ev)); + }); + } +} diff --git a/src/RTCEncryptionManagerEvents.ts b/src/RTCEncryptionManagerEvents.ts new file mode 100644 index 000000000..9862b6491 --- /dev/null +++ b/src/RTCEncryptionManagerEvents.ts @@ -0,0 +1,100 @@ +import { NativeEventEmitter, NativeModules } from 'react-native'; + +const { WebRTCModule } = NativeModules; + +/** + * Track type of an encrypted stream. Screen-share audio is distinct from microphone audio: replay + * state is kept per (userId, trackType), so collapsing the two mis-groups it. + */ +export enum RTCEncryptionTrackType { + AUDIO = 0, + VIDEO = 1, + SCREEN_SHARE = 2, + SCREEN_SHARE_AUDIO = 3, +} + +export type RTCEncryptionEventType = + | 'e2ee.decryption_failed' + | 'e2ee.decryption_resumed' + | 'e2ee.decryption_stalled' + | 'e2ee.encryption_failed' + | 'e2ee.missing_key' + | 'e2ee.unencrypted_frame' + | 'e2ee.unsupported_version' + | 'e2ee.key_state' + | 'e2ee.perf_report'; + +export interface RTCEncryptionUserKey { + userId: string; + keyIndex: number; + /** 16-char hex of SHA-256(rawKey)[:8]. Never key material. */ + fingerprint: string; +} + +export interface RTCEncryptionSharedKey { + keyIndex: number; + fingerprint: string; + isActive: boolean; +} + +/** Payload of `e2ee.key_state`. At most one shared key is active. */ +export interface RTCEncryptionKeyState { + perUserKeys: RTCEncryptionUserKey[]; + sharedKeys: RTCEncryptionSharedKey[]; +} + +/** One row of `e2ee.perf_report`. `codec` is set on encode samples only. */ +export interface RTCEncryptionTrackPerf { + userId: string; + trackType: RTCEncryptionTrackType; + codec?: string; + fps: number; + maxCryptoMs: number; +} + +export interface RTCEncryptionEventData { + /** Handle of the manager the event belongs to. */ + managerId: string; + type: RTCEncryptionEventType; + userId: string; + trackType?: RTCEncryptionTrackType; + keyIndex?: number; + version?: number; + reason?: string; + keyState?: RTCEncryptionKeyState; + encode?: RTCEncryptionTrackPerf[]; + decode?: RTCEncryptionTrackPerf[]; +} + +/** + * Event emitter for native `RTCEncryptionManager` events. This is a dedicated emitter rather than an + * entry in the NATIVE_EVENTS allowlist of EventEmitter.ts, so the allowlist does not have to stay + * hand-synced with the native event constants. + */ +class RTCEncryptionManagerEventEmitter { + private eventEmitter: NativeEventEmitter | null = null; + + public setupListeners() { + // Only setup once (idempotent) + if (this.eventEmitter !== null) { + return; + } + + if (WebRTCModule) { + this.eventEmitter = new NativeEventEmitter(WebRTCModule); + } + } + + /** + * Subscribe to every `e2ee.*` event of every manager. Consumers filter by `managerId`. + */ + addEncryptionManagerEventListener(listener: (data: RTCEncryptionEventData) => void) { + if (!this.eventEmitter) { + throw new Error('RTCEncryptionManagerEvents: native module not available'); + } + + return this.eventEmitter.addListener('encryptionManagerEvent', listener); + } +} + +export const encryptionManagerEvents = new RTCEncryptionManagerEventEmitter(); diff --git a/src/index.ts b/src/index.ts index 6b3499629..5a1f177a3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,20 @@ import permissions from './Permissions'; import RTCAudioSession from './RTCAudioSession'; import RTCCameraPreviewView from './RTCCameraPreviewView'; import RTCCertificate from './RTCCertificate'; +import RTCEncryptionManager, { + RTCEncryptionAlgorithm, + type RTCEncryptionManagerOptions, +} from './RTCEncryptionManager'; +import { + encryptionManagerEvents, + type RTCEncryptionEventData, + type RTCEncryptionEventType, + type RTCEncryptionKeyState, + type RTCEncryptionSharedKey, + RTCEncryptionTrackType, + type RTCEncryptionTrackPerf, + type RTCEncryptionUserKey, +} from './RTCEncryptionManagerEvents'; import RTCErrorEvent from './RTCErrorEvent'; import RTCIceCandidate from './RTCIceCandidate'; import RTCPeerConnection from './RTCPeerConnection'; @@ -43,6 +57,9 @@ setupNativeEvents(); // Ensure audioDeviceModuleEvents is initialized and event listeners are registered audioDeviceModuleEvents.setupListeners(); +// Ensure encryptionManagerEvents is initialized and event listeners are registered +encryptionManagerEvents.setupListeners(); + export { RTCIceCandidate, RTCPeerConnection, @@ -71,6 +88,17 @@ export { AudioDeviceModule, AudioEngineMuteMode, audioDeviceModuleEvents, + RTCEncryptionManager, + RTCEncryptionAlgorithm, + RTCEncryptionTrackType, + encryptionManagerEvents, + type RTCEncryptionManagerOptions, + type RTCEncryptionEventData, + type RTCEncryptionEventType, + type RTCEncryptionKeyState, + type RTCEncryptionUserKey, + type RTCEncryptionSharedKey, + type RTCEncryptionTrackPerf, }; declare const global: any; @@ -103,4 +131,7 @@ function registerGlobals(): void { // Ensure audioDeviceModuleEvents is initialized and event listeners are registered audioDeviceModuleEvents.setupListeners(); + + // Ensure encryptionManagerEvents is initialized and event listeners are registered + encryptionManagerEvents.setupListeners(); } From 0a7452db8a2d816f28d58149fff20ae6b92f03ea Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Mon, 7 Sep 2026 14:20:37 +0200 Subject: [PATCH 05/11] chore(deps): bump Stream WebRTC to 145.17.0 145.17.0 is the first stable release carrying the framed AES-GCM E2EE work (GetStream/webrtc#110), which previously shipped only as the 145.16.0 pre-release. The bindings are identical between the two, so the E2EE bridge needed no changes: the Android AAR's classes.jar has the same SHA-256 and RTCEncryptionManager.h is unchanged. The only functional delta is in the audio receive path, where clearing a frame transformer now reaches the media channel instead of being dropped (or hitting a DCHECK); nothing we call exercises that path today. Android resolves from Maven Central now that the version is a release rather than a snapshot. iOS goes back to depending on the StreamWebRTC pod. That pod is published to CocoaPods trunk for this version, and the trunk podspec's source is the same stream-video-swift-webrtc release asset the podspec was downloading by hand, so dropping the vendoring block and third_party/ changes nothing about what gets linked. Also drops the .gitignore entry for the download stamp the block wrote. --- .gitignore | 1 - android/build.gradle | 2 +- stream-react-native-webrtc.podspec | 34 ++---------------------------- 3 files changed, 3 insertions(+), 34 deletions(-) diff --git a/.gitignore b/.gitignore index b4cd08331..743ca6449 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,6 @@ android/build Podfile.lock WebRTC.xcframework WebRTC.dSYMs -third_party/.webrtc-version examples/GumTestApp/package-lock.json examples/GumTestApp_macOS/package-lock.json **/.xcode.env.local diff --git a/android/build.gradle b/android/build.gradle index 147a15f28..32a3106a8 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -84,7 +84,7 @@ def kotlin_version = getExtOrDefault('kotlinVersion', '1.8.10') println "Building Stream WebRTC React Native module with Kotlin version: $kotlin_version" dependencies { - api 'io.getstream:stream-video-webrtc-android:145.16.0-SNAPSHOT' + api 'io.getstream:stream-video-webrtc-android:145.17.0' implementation "com.facebook.react:react-android:+" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "androidx.core:core:1.7.0" diff --git a/stream-react-native-webrtc.podspec b/stream-react-native-webrtc.podspec index e130dba11..546743fa7 100644 --- a/stream-react-native-webrtc.podspec +++ b/stream-react-native-webrtc.podspec @@ -1,36 +1,7 @@ require 'json' -require 'fileutils' package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) -# WebRTC version from https://github.com/GetStream/stream-video-swift-webrtc releases. -# 148.0.0 is not on CocoaPods trunk, so the xcframework is vendored directly instead of -# depending on the `StreamWebRTC` pod. The release asset below is the same one the -# `StreamWebRTC` podspec in that repo points at. CocoaPods skips `prepare_command` for -# `:path` pods (which is how React Native autolinks this module), so the fetch happens -# here — the podspec is plain Ruby, evaluated on every `pod install`. -webrtc_version = '145.16.0' -webrtc_url = "https://github.com/GetStream/stream-video-swift-webrtc/releases/download/#{webrtc_version}/WebRTC.xcframework.zip" -webrtc_dir = File.join(__dir__, 'third_party') -webrtc_framework = File.join(webrtc_dir, 'WebRTC.xcframework') -webrtc_stamp = File.join(webrtc_dir, '.webrtc-version') - -unless File.directory?(webrtc_framework) && File.exist?(webrtc_stamp) && File.read(webrtc_stamp).strip == webrtc_url - webrtc_zip = File.join(webrtc_dir, 'WebRTC.xcframework.zip') - - Pod::UI.puts "[stream-react-native-webrtc] Downloading WebRTC.xcframework #{webrtc_version}" - - FileUtils.mkdir_p(webrtc_dir) - FileUtils.rm_rf([webrtc_framework, webrtc_stamp, webrtc_zip]) - - raise "Failed to download #{webrtc_url}" unless system('curl', '-fSL', '--retry', '3', '-o', webrtc_zip, webrtc_url) - raise "Failed to unzip #{webrtc_zip}" unless system('unzip', '-q', '-o', webrtc_zip, '-x', '__MACOSX/*', '-d', webrtc_dir) - raise "#{webrtc_zip} did not contain WebRTC.xcframework" unless File.directory?(webrtc_framework) - - FileUtils.rm_f(webrtc_zip) - File.write(webrtc_stamp, webrtc_url) -end - Pod::Spec.new do |s| s.name = 'stream-react-native-webrtc' s.version = package['version'] @@ -49,9 +20,8 @@ Pod::Spec.new do |s| s.framework = 'AudioToolbox','AVFoundation', 'CoreAudio', 'CoreGraphics', 'CoreVideo', 'GLKit', 'VideoToolbox' s.swift_version = '5.0' s.dependency 'React-Core' - # WebRTC version from https://github.com/GetStream/stream-video-swift-webrtc releases. - # 148.0.0 is not yet published to CocoaPods trunk. so a temporary way to point at the pre-release xcframework published on GitHub releases. - s.vendored_frameworks = 'third_party/WebRTC.xcframework' + # WebRTC version from https://github.com/GetStream/stream-video-swift-webrtc releases + s.dependency 'StreamWebRTC', '145.17.0' # Swift/Objective-C compatibility #https://blog.cocoapods.org/CocoaPods-1.5.0/ s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' From e53dca5105ffae9b4d27db742ef7e54a5a619a15 Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Mon, 7 Sep 2026 15:36:23 +0200 Subject: [PATCH 06/11] fix(e2ee): validate key indices before narrowing them Both bridges narrowed keyIndex with a truncating conversion (ReadableMap.getInt on Android, NSNumber.intValue on iOS) and left the range check to the native binding, which only sees the result. Any fractional value in (-1, 1) therefore landed silently on slot 0 rather than being rejected, so removeSharedKey(-0.5) removed a live key; NaN did the same, since it converts to 0 in both languages. Validate on the double, before narrowing, for every key operation. NaN fails the integrality comparison and the 0-255 range check covers both infinities. Rejections reuse the existing {error} result, with the messages naming the accepted range so a present-but-invalid index no longer reports as a missing one. The JS surface documents the range but does not enforce it, which makes the bridge the trust boundary here. --- .../WebRTCModule/EncryptionManagerBridge.java | 36 +++++++++--- ios/RCTWebRTC/WebRTCModule+RTCEncryption.m | 57 +++++++++++++------ 2 files changed, 69 insertions(+), 24 deletions(-) diff --git a/android/src/main/java/com/oney/WebRTCModule/EncryptionManagerBridge.java b/android/src/main/java/com/oney/WebRTCModule/EncryptionManagerBridge.java index f376529e3..4dd7a65a2 100644 --- a/android/src/main/java/com/oney/WebRTCModule/EncryptionManagerBridge.java +++ b/android/src/main/java/com/oney/WebRTCModule/EncryptionManagerBridge.java @@ -107,10 +107,10 @@ WritableMap setKey(ReadableMap options) { } String userId = getString(options, "userId"); - Integer keyIndex = getInt(options, "keyIndex"); + Integer keyIndex = getKeyIndex(options); byte[] key = getKey(options); if (userId == null || userId.isEmpty() || keyIndex == null || key == null) { - return error("encryptionManagerSetKey() requires userId, keyIndex and key"); + return error("encryptionManagerSetKey() requires userId, a keyIndex in 0-255 and key"); } try { @@ -127,10 +127,10 @@ WritableMap setSharedKey(ReadableMap options) { return error("encryptionManagerSetSharedKey(): manager not found"); } - Integer keyIndex = getInt(options, "keyIndex"); + Integer keyIndex = getKeyIndex(options); byte[] key = getKey(options); if (keyIndex == null || key == null) { - return error("encryptionManagerSetSharedKey() requires keyIndex and key"); + return error("encryptionManagerSetSharedKey() requires a keyIndex in 0-255 and key"); } try { @@ -148,9 +148,9 @@ WritableMap removeKey(ReadableMap options) { } String userId = getString(options, "userId"); - Integer keyIndex = getInt(options, "keyIndex"); + Integer keyIndex = getKeyIndex(options); if (userId == null || userId.isEmpty() || keyIndex == null) { - return error("encryptionManagerRemoveKey() requires userId and keyIndex"); + return error("encryptionManagerRemoveKey() requires userId and a keyIndex in 0-255"); } try { @@ -186,9 +186,9 @@ WritableMap removeSharedKey(ReadableMap options) { return error("encryptionManagerRemoveSharedKey(): manager not found"); } - Integer keyIndex = getInt(options, "keyIndex"); + Integer keyIndex = getKeyIndex(options); if (keyIndex == null) { - return error("encryptionManagerRemoveSharedKey() requires keyIndex"); + return error("encryptionManagerRemoveSharedKey() requires a keyIndex in 0-255"); } try { @@ -411,6 +411,26 @@ private static String getString(ReadableMap options, String key) { return options.getString(key); } + /** + * A key index is validated before it is narrowed: {@link ReadableMap#getInt} truncates, so a + * fractional or non-finite index would otherwise land silently on a valid slot instead of being + * rejected -- {@code removeSharedKey(-0.5)} would delete slot 0. + */ + @Nullable + private static Integer getKeyIndex(ReadableMap options) { + if (!options.hasKey("keyIndex") || options.getType("keyIndex") != ReadableType.Number) { + return null; + } + + // NaN fails the first comparison; the range check covers both infinities. + double keyIndex = options.getDouble("keyIndex"); + if (keyIndex != Math.floor(keyIndex) || keyIndex < 0 || keyIndex > 255) { + return null; + } + + return (int) keyIndex; + } + @Nullable private static Integer getInt(ReadableMap options, String key) { if (!options.hasKey(key) || options.getType(key) != ReadableType.Number) { diff --git a/ios/RCTWebRTC/WebRTCModule+RTCEncryption.m b/ios/RCTWebRTC/WebRTCModule+RTCEncryption.m index 4f6f60d09..32cde71ce 100644 --- a/ios/RCTWebRTC/WebRTCModule+RTCEncryption.m +++ b/ios/RCTWebRTC/WebRTCModule+RTCEncryption.m @@ -69,6 +69,27 @@ static BOOL RTCEncryptionTrackTypeFromOptions(NSDictionary *options, NSNumber ** return YES; } +/* + * A key index is validated before it is narrowed: `intValue` truncates, so a fractional or non-finite + * index would otherwise land silently on a valid slot instead of being rejected -- a `keyIndex` of + * -0.5 would remove slot 0. + */ +static BOOL RTCEncryptionKeyIndexFromOptions(NSDictionary *options, int *keyIndex) { + NSNumber *value = RTCEncryptionOptionalValue(options, @"keyIndex", [NSNumber class]); + if (value == nil) { + return NO; + } + + /* NaN fails the first comparison; the range check covers both infinities. */ + double index = value.doubleValue; + if (index != trunc(index) || index < 0 || index > 255) { + return NO; + } + + *keyIndex = (int)index; + return YES; +} + - (nullable NSData *)encryptionKeyFromOptions:(NSDictionary *)options { NSString *key = RTCEncryptionOptionalValue(options, @"key", [NSString class]); if (key == nil) { @@ -161,15 +182,16 @@ - (nullable NSData *)encryptionKeyFromOptions:(NSDictionary *)options { } NSString *userId = RTCEncryptionOptionalValue(options, @"userId", [NSString class]); - NSNumber *keyIndex = RTCEncryptionOptionalValue(options, @"keyIndex", [NSNumber class]); + int keyIndex = 0; + BOOL hasKeyIndex = RTCEncryptionKeyIndexFromOptions(options, &keyIndex); NSData *key = [self encryptionKeyFromOptions:options]; - if (userId.length == 0 || keyIndex == nil || key == nil) { - result = @{@"error" : @"encryptionManagerSetKey() requires userId, keyIndex and key"}; + if (userId.length == 0 || !hasKeyIndex || key == nil) { + result = @{@"error" : @"encryptionManagerSetKey() requires userId, a keyIndex in 0-255 and key"}; return; } NSError *error = nil; - if (![manager setKey:userId keyIndex:keyIndex.intValue rawKey:key error:&error]) { + if (![manager setKey:userId keyIndex:keyIndex rawKey:key error:&error]) { result = RTCEncryptionErrorResult(error, @"setKey failed"); } }); @@ -187,15 +209,16 @@ - (nullable NSData *)encryptionKeyFromOptions:(NSDictionary *)options { return; } - NSNumber *keyIndex = RTCEncryptionOptionalValue(options, @"keyIndex", [NSNumber class]); + int keyIndex = 0; + BOOL hasKeyIndex = RTCEncryptionKeyIndexFromOptions(options, &keyIndex); NSData *key = [self encryptionKeyFromOptions:options]; - if (keyIndex == nil || key == nil) { - result = @{@"error" : @"encryptionManagerSetSharedKey() requires keyIndex and key"}; + if (!hasKeyIndex || key == nil) { + result = @{@"error" : @"encryptionManagerSetSharedKey() requires a keyIndex in 0-255 and key"}; return; } NSError *error = nil; - if (![manager setSharedKey:keyIndex.intValue rawKey:key error:&error]) { + if (![manager setSharedKey:keyIndex rawKey:key error:&error]) { result = RTCEncryptionErrorResult(error, @"setSharedKey failed"); } }); @@ -214,14 +237,15 @@ - (nullable NSData *)encryptionKeyFromOptions:(NSDictionary *)options { } NSString *userId = RTCEncryptionOptionalValue(options, @"userId", [NSString class]); - NSNumber *keyIndex = RTCEncryptionOptionalValue(options, @"keyIndex", [NSNumber class]); - if (userId.length == 0 || keyIndex == nil) { - result = @{@"error" : @"encryptionManagerRemoveKey() requires userId and keyIndex"}; + int keyIndex = 0; + BOOL hasKeyIndex = RTCEncryptionKeyIndexFromOptions(options, &keyIndex); + if (userId.length == 0 || !hasKeyIndex) { + result = @{@"error" : @"encryptionManagerRemoveKey() requires userId and a keyIndex in 0-255"}; return; } NSError *error = nil; - if (![manager removeKey:userId keyIndex:keyIndex.intValue error:&error]) { + if (![manager removeKey:userId keyIndex:keyIndex error:&error]) { result = RTCEncryptionErrorResult(error, @"removeKey failed"); } }); @@ -264,14 +288,15 @@ - (nullable NSData *)encryptionKeyFromOptions:(NSDictionary *)options { return; } - NSNumber *keyIndex = RTCEncryptionOptionalValue(options, @"keyIndex", [NSNumber class]); - if (keyIndex == nil) { - result = @{@"error" : @"encryptionManagerRemoveSharedKey() requires keyIndex"}; + int keyIndex = 0; + BOOL hasKeyIndex = RTCEncryptionKeyIndexFromOptions(options, &keyIndex); + if (!hasKeyIndex) { + result = @{@"error" : @"encryptionManagerRemoveSharedKey() requires a keyIndex in 0-255"}; return; } NSError *error = nil; - if (![manager removeSharedKey:keyIndex.intValue error:&error]) { + if (![manager removeSharedKey:keyIndex error:&error]) { result = RTCEncryptionErrorResult(error, @"removeSharedKey failed"); } }); From 396e1e947634dd05611243da32794177c5895627 Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Mon, 7 Sep 2026 15:47:48 +0200 Subject: [PATCH 07/11] fix(e2ee): keep a manager registered until its native cleanup succeeds dispose() removed the handle from the registry before calling setObserver(null) and dispose() on it, so a throwing native cleanup dropped the only reference to the manager: the C++ object leaked, and neither a retried dispose() nor disposeAll() on teardown could still reach it. Deregister only once both native calls succeed, which is also what the iOS bridge already did. --- .../com/oney/WebRTCModule/EncryptionManagerBridge.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/android/src/main/java/com/oney/WebRTCModule/EncryptionManagerBridge.java b/android/src/main/java/com/oney/WebRTCModule/EncryptionManagerBridge.java index 4dd7a65a2..bf9649655 100644 --- a/android/src/main/java/com/oney/WebRTCModule/EncryptionManagerBridge.java +++ b/android/src/main/java/com/oney/WebRTCModule/EncryptionManagerBridge.java @@ -68,7 +68,7 @@ WritableMap create(ReadableMap options) { WritableMap dispose(ReadableMap options) { String handle = getString(options, "handle"); - EncryptionManager manager = handle != null ? managers.remove(handle) : null; + EncryptionManager manager = handle != null ? managers.get(handle) : null; if (manager == null) { // Disposing twice, or after a reload, is not an error. return Arguments.createMap(); @@ -77,10 +77,14 @@ WritableMap dispose(ReadableMap options) { try { manager.setObserver(null); manager.dispose(); - return Arguments.createMap(); } catch (RuntimeException e) { + // Deregistered only once the native cleanup succeeds, so a failed manager stays + // reachable for a later dispose() or for disposeAll() on teardown. return error(e); } + + managers.remove(handle); + return Arguments.createMap(); } /** From 95a4add0df530c42af6c4b58ada204ed0ad3b98d Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Mon, 7 Sep 2026 15:47:48 +0200 Subject: [PATCH 08/11] chore(example): point the embed-frameworks input path at StreamWebRTC The xcframework is staged under XCFrameworkIntermediates by its owning pod, which went back to StreamWebRTC when the podspec returned to the pod dependency. The path had been left pointing at the vendoring-era stream-react-native-webrtc directory. CocoaPods regenerates this block, and both CI workflows run pod install before building, so this only keeps the tracked file consistent with what the current podspec produces. --- examples/GumTestApp/ios/GumTestApp.xcodeproj/project.pbxproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/GumTestApp/ios/GumTestApp.xcodeproj/project.pbxproj b/examples/GumTestApp/ios/GumTestApp.xcodeproj/project.pbxproj index 6ad7cc4de..0085287d7 100644 --- a/examples/GumTestApp/ios/GumTestApp.xcodeproj/project.pbxproj +++ b/examples/GumTestApp/ios/GumTestApp.xcodeproj/project.pbxproj @@ -193,7 +193,7 @@ inputPaths = ( "${PODS_ROOT}/Target Support Files/Pods-GumTestApp/Pods-GumTestApp-frameworks.sh", "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes", - "${PODS_XCFRAMEWORKS_BUILD_DIR}/stream-react-native-webrtc/WebRTC.framework/WebRTC", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/StreamWebRTC/WebRTC.framework/WebRTC", ); name = "[CP] Embed Pods Frameworks"; outputPaths = ( From dfe642b8136f99e56eb70a3149b1a0bfa4033aef Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Mon, 7 Sep 2026 15:50:28 +0200 Subject: [PATCH 09/11] 145.4.0-alpha.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0c0e0cfa0..51b0bf6a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@stream-io/react-native-webrtc", - "version": "145.3.1", + "version": "145.4.0-alpha.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@stream-io/react-native-webrtc", - "version": "145.3.1", + "version": "145.4.0-alpha.1", "license": "MIT", "dependencies": { "base64-js": "^1.5.1", diff --git a/package.json b/package.json index af8ef2cf8..e3f08d9dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@stream-io/react-native-webrtc", - "version": "145.3.1", + "version": "145.4.0-alpha.1", "repository": { "type": "git", "url": "git+https://github.com/GetStream/react-native-webrtc.git" From 21800e0307b4bd637c091264ec09ac0131201aac Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Thu, 10 Sep 2026 14:33:31 +0200 Subject: [PATCH 10/11] fix(e2ee): keep a failed dispose() retryable dispose() marked the manager disposed from a finally block, so a native cleanup failure still flipped the flag and every later dispose() returned early. That defeats the native registries, which deliberately keep a manager registered when its cleanup throws so the call can be retried. The flag and the listener teardown now run only after the native dispose succeeds. The event subscription stays attached on failure, which is correct: the manager still exists natively and can still emit. --- src/RTCEncryptionManager.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/RTCEncryptionManager.ts b/src/RTCEncryptionManager.ts index 9934bfd3d..45f74d17a 100644 --- a/src/RTCEncryptionManager.ts +++ b/src/RTCEncryptionManager.ts @@ -161,14 +161,14 @@ export default class RTCEncryptionManager { return; } - try { - this._invoke('encryptionManagerDispose', {}); - } finally { - this._disposed = true; - this._subscription?.remove(); - this._subscription = null; - this._listeners.clear(); - } + // Marked disposed only once the native cleanup succeeds: the native side keeps a manager + // registered when its cleanup throws, so a failed dispose() has to stay retryable here too. + this._invoke('encryptionManagerDispose', {}); + + this._disposed = true; + this._subscription?.remove(); + this._subscription = null; + this._listeners.clear(); } _assertNotDisposed(): void { From 5b3eaa9e93cab2462a80c0fb89f8f8162a2c7153 Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Thu, 10 Sep 2026 14:33:31 +0200 Subject: [PATCH 11/11] fix(e2ee): validate track types before narrowing them Both bridges range-checked the track type only after truncating it: Android compared the result of ReadableMap.getInt, iOS range-checked [value integerValue] and then forwarded the original NSNumber. A fractional or non-finite value therefore landed on a valid enum entry instead of being rejected -- -0.5 and NaN both became AUDIO, 1.5 became VIDEO. Silently selecting audio is the misgrouping the comment above the parser warns about: a screen-share-audio track would join the microphone and share its replay window. Both now validate the double before narrowing, matching the key-index helper -- the floor/trunc comparison rejects NaN and the range check covers both infinities. --- .../oney/WebRTCModule/EncryptionManagerBridge.java | 11 ++++++++--- ios/RCTWebRTC/WebRTCModule+RTCEncryption.m | 13 ++++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/android/src/main/java/com/oney/WebRTCModule/EncryptionManagerBridge.java b/android/src/main/java/com/oney/WebRTCModule/EncryptionManagerBridge.java index bf9649655..30e692cd7 100644 --- a/android/src/main/java/com/oney/WebRTCModule/EncryptionManagerBridge.java +++ b/android/src/main/java/com/oney/WebRTCModule/EncryptionManagerBridge.java @@ -398,12 +398,17 @@ private static EncryptionManager.TrackType parseTrackType(ReadableMap options) { return null; } - Integer trackType = getInt(options, "trackType"); - if (trackType == null || trackType < 0 || trackType >= EncryptionManager.TrackType.values().length) { + // Validated before it is narrowed, like the key index: getInt truncates, so a fractional value + // would land on a valid enum entry instead of being rejected -- 1.5 would become VIDEO. + // NaN fails the first comparison; the range check covers both infinities. + double trackType = + options.getType("trackType") == ReadableType.Number ? options.getDouble("trackType") : Double.NaN; + if (trackType != Math.floor(trackType) || trackType < 0 + || trackType >= EncryptionManager.TrackType.values().length) { throw new IllegalArgumentException("got an unusable trackType"); } - return EncryptionManager.TrackType.values()[trackType]; + return EncryptionManager.TrackType.values()[(int) trackType]; } @Nullable diff --git a/ios/RCTWebRTC/WebRTCModule+RTCEncryption.m b/ios/RCTWebRTC/WebRTCModule+RTCEncryption.m index 32cde71ce..c461d4c59 100644 --- a/ios/RCTWebRTC/WebRTCModule+RTCEncryption.m +++ b/ios/RCTWebRTC/WebRTCModule+RTCEncryption.m @@ -60,12 +60,19 @@ static BOOL RTCEncryptionTrackTypeFromOptions(NSDictionary *options, NSNumber ** return YES; } - if (![value isKindOfClass:[NSNumber class]] || [value integerValue] < RTCEncryptionTrackTypeAudio || - [value integerValue] > RTCEncryptionTrackTypeScreenshareAudio) { + if (![value isKindOfClass:[NSNumber class]]) { return NO; } - *trackType = value; + /* Validated before it is narrowed, like the key index: `integerValue` truncates, so a fractional + * value would land on a valid enum entry instead of being rejected -- 1.5 would become video. + * NaN fails the first comparison; the range check covers both infinities. */ + double type = [(NSNumber *)value doubleValue]; + if (type != trunc(type) || type < RTCEncryptionTrackTypeAudio || type > RTCEncryptionTrackTypeScreenshareAudio) { + return NO; + } + + *trackType = @((NSInteger)type); return YES; }