diff --git a/android/build.gradle b/android/build.gradle index cb7698b37..7ff9b3b7e 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -31,7 +31,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') } } } @@ -90,7 +90,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: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/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..30e692cd7 --- /dev/null +++ b/android/src/main/java/com/oney/WebRTCModule/EncryptionManagerBridge.java @@ -0,0 +1,462 @@ +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.get(handle) : null; + if (manager == null) { + // Disposing twice, or after a reload, is not an error. + return Arguments.createMap(); + } + + try { + manager.setObserver(null); + manager.dispose(); + } 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(); + } + + /** + * 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 = getKeyIndex(options); + byte[] key = getKey(options); + if (userId == null || userId.isEmpty() || keyIndex == null || key == null) { + return error("encryptionManagerSetKey() requires userId, a keyIndex in 0-255 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 = getKeyIndex(options); + byte[] key = getKey(options); + if (keyIndex == null || key == null) { + return error("encryptionManagerSetSharedKey() requires a keyIndex in 0-255 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 = getKeyIndex(options); + if (userId == null || userId.isEmpty() || keyIndex == null) { + return error("encryptionManagerRemoveKey() requires userId and a keyIndex in 0-255"); + } + + 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 = getKeyIndex(options); + if (keyIndex == null) { + return error("encryptionManagerRemoveSharedKey() requires a keyIndex in 0-255"); + } + + 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; + } + + // 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()[(int) 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); + } + + /** + * 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) { + 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 29156265b..3a9c9d4e2 100644 --- a/android/src/main/java/com/oney/WebRTCModule/PeerConnectionObserver.java +++ b/android/src/main/java/com/oney/WebRTCModule/PeerConnectionObserver.java @@ -159,6 +159,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 24092baae..a0b5baf10 100644 --- a/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java +++ b/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java @@ -65,6 +65,8 @@ public class WebRTCModule extends ReactContextBaseJavaModule { private final GetUserMediaImpl getUserMediaImpl; + private final EncryptionManagerBridge encryptionManagerBridge = new EncryptionManagerBridge(this); + @Nullable private RTCCameraPreviewView activeCameraPreview; @@ -265,6 +267,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 { @@ -1773,6 +1779,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/examples/GumTestApp/ios/GumTestApp.xcodeproj/project.pbxproj b/examples/GumTestApp/ios/GumTestApp.xcodeproj/project.pbxproj index b1dfb3660..0085287d7 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}/hermes-engine/Pre-built/hermes.framework/hermes", "${PODS_XCFRAMEWORKS_BUILD_DIR}/StreamWebRTC/WebRTC.framework/WebRTC", - "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermesvm.framework/hermesvm", ); 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/ios/RCTWebRTC/WebRTCModule+RTCEncryption.m b/ios/RCTWebRTC/WebRTCModule+RTCEncryption.m new file mode 100644 index 000000000..c461d4c59 --- /dev/null +++ b/ios/RCTWebRTC/WebRTCModule+RTCEncryption.m @@ -0,0 +1,516 @@ +#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]]) { + return NO; + } + + /* 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; +} + +/* + * 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) { + 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]); + int keyIndex = 0; + BOOL hasKeyIndex = RTCEncryptionKeyIndexFromOptions(options, &keyIndex); + NSData *key = [self encryptionKeyFromOptions:options]; + 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 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; + } + + int keyIndex = 0; + BOOL hasKeyIndex = RTCEncryptionKeyIndexFromOptions(options, &keyIndex); + NSData *key = [self encryptionKeyFromOptions:options]; + if (!hasKeyIndex || key == nil) { + result = @{@"error" : @"encryptionManagerSetSharedKey() requires a keyIndex in 0-255 and key"}; + return; + } + + NSError *error = nil; + if (![manager setSharedKey:keyIndex 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]); + 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 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; + } + + 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 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/package-lock.json b/package-lock.json index 7e12e03bf..51b0bf6a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@stream-io/react-native-webrtc", - "version": "145.3.3", + "version": "145.4.0-alpha.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@stream-io/react-native-webrtc", - "version": "145.3.3", + "version": "145.4.0-alpha.1", "license": "MIT", "dependencies": { "base64-js": "^1.5.1", diff --git a/package.json b/package.json index 4e23445e1..e3f08d9dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@stream-io/react-native-webrtc", - "version": "145.3.3", + "version": "145.4.0-alpha.1", "repository": { "type": "git", "url": "git+https://github.com/GetStream/react-native-webrtc.git" diff --git a/src/RTCEncryptionManager.ts b/src/RTCEncryptionManager.ts new file mode 100644 index 000000000..45f74d17a --- /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; + } + + // 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 { + 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(); } diff --git a/stream-react-native-webrtc.podspec b/stream-react-native-webrtc.podspec index 398d67432..546743fa7 100644 --- a/stream-react-native-webrtc.podspec +++ b/stream-react-native-webrtc.podspec @@ -21,7 +21,7 @@ Pod::Spec.new do |s| 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' + 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'