From c79df9cd3f5345f530bc1ea4a2c45d594de2af3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Braz=CC=87ewicz?= Date: Thu, 20 Aug 2026 13:23:22 +0200 Subject: [PATCH 1/3] encription manager bridge --- Package.swift | 2 +- android/build.gradle | 2 +- .../flutter/FlutterRTCEncryptionManager.java | 502 ++++++++++++++++ .../webrtc/flutter/MethodCallHandlerImpl.java | 22 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- ios/stream_webrtc_flutter.podspec | 2 +- ios/stream_webrtc_flutter/Package.swift | 2 +- .../FlutterRTCEncryptionManager.m | 537 ++++++++++++++++++ .../FlutterWebRTCPlugin.m | 18 +- .../FlutterRTCEncryptionManager.h | 55 ++ lib/src/e2ee/encryption_manager.dart | 123 ++++ lib/src/e2ee/encryption_types.dart | 265 +++++++++ lib/src/native/encryption_manager_impl.dart | 345 +++++++++++ lib/src/web/encryption_manager_impl.dart | 86 +++ lib/stream_webrtc_flutter.dart | 8 +- macos/stream_webrtc_flutter.podspec | 2 +- macos/stream_webrtc_flutter/Package.swift | 2 +- .../FlutterRTCEncryptionManager.m | 537 ++++++++++++++++++ .../FlutterWebRTCPlugin.m | 20 +- .../FlutterRTCEncryptionManager.h | 55 ++ 21 files changed, 2545 insertions(+), 48 deletions(-) create mode 100644 android/src/main/java/io/getstream/webrtc/flutter/FlutterRTCEncryptionManager.java create mode 100644 ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCEncryptionManager.m create mode 100644 ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCEncryptionManager.h create mode 100644 lib/src/e2ee/encryption_manager.dart create mode 100644 lib/src/e2ee/encryption_types.dart create mode 100644 lib/src/native/encryption_manager_impl.dart create mode 100644 lib/src/web/encryption_manager_impl.dart create mode 100644 macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCEncryptionManager.m create mode 100644 macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCEncryptionManager.h diff --git a/Package.swift b/Package.swift index ffddb2138e..ded0c93974 100644 --- a/Package.swift +++ b/Package.swift @@ -12,7 +12,7 @@ let package = Package( dependencies: [ .package(name: "FlutterFramework", path: "../FlutterFramework"), .package( - url: "https://github.com/GetStream/stream-video-swift-webrtc.git", exact: "145.9.0" + url: "https://github.com/GetStream/stream-video-swift-webrtc.git", exact: "145.16.0" ) ], targets: [ diff --git a/android/build.gradle b/android/build.gradle index 5bc9d8eb03..08b947f33d 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -77,7 +77,7 @@ if (kotlinExt?.hasProperty('compilerOptions')) { } dependencies { - implementation("io.getstream:stream-video-webrtc-android:145.9.0") + implementation("io.getstream:stream-video-webrtc-android:145.16.0-SNAPSHOT") implementation 'com.github.davidliu:audioswitch:89582c47c9a04c62f90aa5e57251af4800a62c9a' implementation 'androidx.annotation:annotation:1.1.0' } diff --git a/android/src/main/java/io/getstream/webrtc/flutter/FlutterRTCEncryptionManager.java b/android/src/main/java/io/getstream/webrtc/flutter/FlutterRTCEncryptionManager.java new file mode 100644 index 0000000000..68a5602a1d --- /dev/null +++ b/android/src/main/java/io/getstream/webrtc/flutter/FlutterRTCEncryptionManager.java @@ -0,0 +1,502 @@ +package io.getstream.webrtc.flutter; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.webrtc.EncryptionManager; +import org.webrtc.RtpReceiver; +import org.webrtc.RtpSender; + +import io.flutter.plugin.common.EventChannel; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel.Result; +import io.getstream.webrtc.flutter.utils.AnyThreadSink; + +/** Bridges {@link EncryptionManager} to Dart using Stream's implementation via a per-instance event channel. */ +class FlutterRTCEncryptionManager { + + /** Dart sends -1 when it wants native to infer audio vs video from RTP. */ + private static final int TRACK_TYPE_UNSPECIFIED = -1; + + private final StateProvider stateProvider; + private final ConcurrentHashMap handles = new ConcurrentHashMap<>(); + + /** + * Releases native managers off the platform thread. + * + *

Disposing joins the manager's frame-crypto worker, so doing it inline + * would block the Android main thread for as long as that worker takes to + * drain — an ANR if it is mid-frame or wedged. Nothing after teardown needs + * the manager, so the join can finish in the background. + */ + private final ExecutorService disposeExecutor = + Executors.newSingleThreadExecutor( + runnable -> { + Thread thread = new Thread(runnable, "E2EEManagerDispose"); + thread.setDaemon(true); + return thread; + }); + + FlutterRTCEncryptionManager(StateProvider stateProvider) { + this.stateProvider = stateProvider; + } + + private static class Handle implements EventChannel.StreamHandler { + final EncryptionManager manager; + final EventChannel eventChannel; + @Nullable EventChannel.EventSink sink; + + Handle(EncryptionManager manager, EventChannel eventChannel) { + this.manager = manager; + this.eventChannel = eventChannel; + } + + @Override + public void onListen(Object arguments, EventChannel.EventSink events) { + sink = new AnyThreadSink(events); + } + + @Override + public void onCancel(Object arguments) { + sink = null; + } + + void send(Map event) { + final EventChannel.EventSink target = sink; + if (target != null) { + target.success(event); + } + } + + /** + * Detaches the event channel. Must run on the platform thread, and must + * happen before {@link #releaseNative()} so no event races the teardown. + */ + void detach() { + eventChannel.setStreamHandler(null); + sink = null; + manager.setObserver(null); + } + + /** Releases the native manager. Blocks on its frame-crypto worker. */ + void releaseNative() { + manager.dispose(); + } + } + + /** + * @return {@code true} when {@code call} was an encryption-manager method, + * handled (successfully or with an error) by this class. + */ + boolean handleMethodCall(@NonNull MethodCall call, @NonNull Result result) { + final String method = call.method; + if (method == null || !method.startsWith("encryptionManager")) { + return false; + } + + switch (method) { + case "encryptionManagerCreate": + create(call, result); + return true; + case "encryptionManagerSetKey": + setKey(call, result); + return true; + case "encryptionManagerSetSharedKey": + setSharedKey(call, result); + return true; + case "encryptionManagerRemoveKey": + removeKey(call, result); + return true; + case "encryptionManagerRemoveAllKeys": + removeAllKeys(call, result); + return true; + case "encryptionManagerRemoveSharedKey": + removeSharedKey(call, result); + return true; + case "encryptionManagerEncrypt": + encrypt(call, result); + return true; + case "encryptionManagerDecrypt": + decrypt(call, result); + return true; + case "encryptionManagerEnablePerformanceReporting": + enablePerformanceReporting(call, result); + return true; + case "encryptionManagerRequestKeyState": + requestKeyState(call, result); + return true; + case "encryptionManagerDispose": + dispose(call, result); + return true; + default: + return false; + } + } + + /** Releases every manager, e.g. when the plugin detaches from the engine. */ + void disposeAll() { + final List pending = new ArrayList<>(handles.values()); + handles.clear(); + for (Handle handle : pending) { + handle.detach(); + disposeExecutor.execute(handle::releaseNative); + } + } + + // MARK: - Methods + + private void create(MethodCall call, Result result) { + final String userId = call.argument("userId"); + if (userId == null || userId.isEmpty()) { + result.error("encryptionManagerCreateFailed", "userId is required", null); + return; + } + + final Integer algorithmValue = call.argument("algorithm"); + final EncryptionManager.Algorithm algorithm = + algorithmValue != null && algorithmValue == EncryptionManager.Algorithm.AES_256_GCM.getValue() + ? EncryptionManager.Algorithm.AES_256_GCM + : EncryptionManager.Algorithm.AES_128_GCM; + + try { + final String managerId = UUID.randomUUID().toString(); + final EncryptionManager manager = EncryptionManager.create(userId, algorithm); + final EventChannel eventChannel = + new EventChannel(stateProvider.getMessenger(), "FlutterWebRTC/e2ee/" + managerId); + final Handle handle = new Handle(manager, eventChannel); + + eventChannel.setStreamHandler(handle); + manager.setObserver(event -> handle.send(eventToMap(event))); + handles.put(managerId, handle); + + final Map response = new HashMap<>(); + response.put("managerId", managerId); + result.success(response); + } catch (Exception e) { + result.error("encryptionManagerCreateFailed", e.getMessage(), null); + } + } + + private void setKey(MethodCall call, Result result) { + final EncryptionManager manager = requireManager(call, result); + if (manager == null) { + return; + } + final String userId = call.argument("userId"); + final Integer keyIndex = call.argument("keyIndex"); + final byte[] rawKey = call.argument("rawKey"); + if (userId == null || keyIndex == null || rawKey == null) { + result.error(call.method + "Failed", "userId, keyIndex and rawKey are required", null); + return; + } + try { + manager.setKey(userId, keyIndex, rawKey); + result.success(null); + } catch (Exception e) { + result.error(call.method + "Failed", e.getMessage(), null); + } + } + + private void setSharedKey(MethodCall call, Result result) { + final EncryptionManager manager = requireManager(call, result); + if (manager == null) { + return; + } + final Integer keyIndex = call.argument("keyIndex"); + final byte[] rawKey = call.argument("rawKey"); + if (keyIndex == null || rawKey == null) { + result.error(call.method + "Failed", "keyIndex and rawKey are required", null); + return; + } + try { + manager.setSharedKey(keyIndex, rawKey); + result.success(null); + } catch (Exception e) { + result.error(call.method + "Failed", e.getMessage(), null); + } + } + + private void removeKey(MethodCall call, Result result) { + final EncryptionManager manager = requireManager(call, result); + if (manager == null) { + return; + } + final String userId = call.argument("userId"); + final Integer keyIndex = call.argument("keyIndex"); + if (userId == null || keyIndex == null) { + result.error(call.method + "Failed", "userId and keyIndex are required", null); + return; + } + try { + manager.removeKey(userId, keyIndex); + result.success(null); + } catch (Exception e) { + result.error(call.method + "Failed", e.getMessage(), null); + } + } + + private void removeAllKeys(MethodCall call, Result result) { + final EncryptionManager manager = requireManager(call, result); + if (manager == null) { + return; + } + final String userId = call.argument("userId"); + if (userId == null) { + result.error(call.method + "Failed", "userId is required", null); + return; + } + try { + manager.removeAllKeys(userId); + result.success(null); + } catch (Exception e) { + result.error(call.method + "Failed", e.getMessage(), null); + } + } + + private void removeSharedKey(MethodCall call, Result result) { + final EncryptionManager manager = requireManager(call, result); + if (manager == null) { + return; + } + final Integer keyIndex = call.argument("keyIndex"); + if (keyIndex == null) { + result.error(call.method + "Failed", "keyIndex is required", null); + return; + } + try { + manager.removeSharedKey(keyIndex); + result.success(null); + } catch (Exception e) { + result.error(call.method + "Failed", e.getMessage(), null); + } + } + + private void encrypt(MethodCall call, Result result) { + final EncryptionManager manager = requireManager(call, result); + if (manager == null) { + return; + } + + final PeerConnectionObserver pco = requireObserver(call, result); + if (pco == null) { + return; + } + + final String senderId = call.argument("rtpSenderId"); + if (senderId == null) { + result.error(call.method + "Failed", "rtpSenderId is required", null); + return; + } + + final RtpSender sender = pco.getRtpSenderById(senderId); + if (sender == null) { + result.error(call.method + "Failed", "sender " + senderId + " not found", null); + return; + } + + try { + manager.encrypt(sender, call.argument("codec"), trackType(call)); + result.success(null); + } catch (Exception e) { + result.error(call.method + "Failed", e.getMessage(), null); + } + } + + private void decrypt(MethodCall call, Result result) { + final EncryptionManager manager = requireManager(call, result); + if (manager == null) { + return; + } + + final PeerConnectionObserver pco = requireObserver(call, result); + if (pco == null) { + return; + } + + final String receiverId = call.argument("rtpReceiverId"); + final String userId = call.argument("userId"); + if (receiverId == null || userId == null || userId.isEmpty()) { + result.error(call.method + "Failed", "rtpReceiverId and userId are required", null); + return; + } + + final RtpReceiver receiver = pco.getRtpReceiverById(receiverId); + if (receiver == null) { + result.error(call.method + "Failed", "receiver " + receiverId + " not found", null); + return; + } + + try { + manager.decrypt(receiver, userId, trackType(call)); + result.success(null); + } catch (Exception e) { + result.error(call.method + "Failed", e.getMessage(), null); + } + } + + private void enablePerformanceReporting(MethodCall call, Result result) { + final EncryptionManager manager = requireManager(call, result); + if (manager == null) { + return; + } + final Boolean enabled = call.argument("enabled"); + try { + manager.enablePerformanceReporting(Boolean.TRUE.equals(enabled)); + result.success(null); + } catch (Exception e) { + result.error(call.method + "Failed", e.getMessage(), null); + } + } + + private void requestKeyState(MethodCall call, Result result) { + final EncryptionManager manager = requireManager(call, result); + if (manager == null) { + return; + } + try { + manager.requestKeyState(); + result.success(null); + } catch (Exception e) { + result.error(call.method + "Failed", e.getMessage(), null); + } + } + + private void dispose(MethodCall call, Result result) { + final String managerId = call.argument("managerId"); + final Handle handle = managerId == null ? null : handles.remove(managerId); + if (handle != null) { + handle.detach(); + // Off the platform thread: releasing joins the frame-crypto worker. + disposeExecutor.execute(handle::releaseNative); + } + // Disposing an unknown manager is not an error: Dart may retry teardown. + // Answering before the join completes is fine — the handle is already out + // of the registry, so nothing can reach it again. + result.success(null); + } + + // MARK: - Helpers + + @Nullable + private EncryptionManager requireManager(MethodCall call, Result result) { + final String managerId = call.argument("managerId"); + final Handle handle = managerId == null ? null : handles.get(managerId); + if (handle == null) { + result.error( + call.method + "Failed", "EncryptionManager " + managerId + " not found", null); + return null; + } + return handle.manager; + } + + @Nullable + private PeerConnectionObserver requireObserver(MethodCall call, Result result) { + final String peerConnectionId = call.argument("peerConnectionId"); + final PeerConnectionObserver pco = + peerConnectionId == null ? null : stateProvider.getPeerConnectionObserver(peerConnectionId); + if (pco == null) { + result.error( + call.method + "Failed", "peerConnection " + peerConnectionId + " not found", null); + return null; + } + return pco; + } + + /** Maps Dart's {@code trackType} to the enum, or {@code null} to let RTP decide. */ + @Nullable + private static EncryptionManager.TrackType trackType(MethodCall call) { + final Integer value = call.argument("trackType"); + if (value == null || value == TRACK_TYPE_UNSPECIFIED) { + return null; + } + for (EncryptionManager.TrackType type : EncryptionManager.TrackType.values()) { + if (type.getValue() == value) { + return type; + } + } + return null; + } + + // MARK: - Event serialization + + private static Map eventToMap(EncryptionManager.E2eeEvent event) { + final Map map = new HashMap<>(); + map.put("type", event.type.getValue()); + map.put("name", event.name); + map.put("userId", event.userId); + if (event.trackType != null) { + map.put("trackType", event.trackType.getValue()); + } + if (event.keyIndex != null) { + map.put("keyIndex", event.keyIndex); + } + if (event.version != null) { + map.put("version", event.version); + } + if (event.reason != null) { + map.put("reason", event.reason); + } + if (event.keyState != null) { + map.put("keyState", keyStateToMap(event.keyState)); + } + if (event.encode != null) { + map.put("encode", perfToList(event.encode)); + } + if (event.decode != null) { + map.put("decode", perfToList(event.decode)); + } + return map; + } + + private static Map keyStateToMap(EncryptionManager.KeyStateReport report) { + final List perUserKeys = new ArrayList<>(); + for (EncryptionManager.UserKey key : report.perUserKeys) { + final Map entry = new HashMap<>(); + entry.put("userId", key.userId); + entry.put("keyIndex", key.keyIndex); + entry.put("fingerprint", key.fingerprint); + perUserKeys.add(entry); + } + + final List sharedKeys = new ArrayList<>(); + for (EncryptionManager.SharedKey key : report.sharedKeys) { + final Map entry = new HashMap<>(); + entry.put("keyIndex", key.keyIndex); + entry.put("fingerprint", key.fingerprint); + entry.put("isActive", key.isActive); + sharedKeys.add(entry); + } + + final Map map = new HashMap<>(); + map.put("perUserKeys", perUserKeys); + map.put("sharedKeys", sharedKeys); + return map; + } + + private static List perfToList(List samples) { + final List list = new ArrayList<>(); + for (EncryptionManager.TrackPerf sample : samples) { + final Map entry = new HashMap<>(); + entry.put("userId", sample.userId); + if (sample.trackType != null) { + entry.put("trackType", sample.trackType.getValue()); + } + if (sample.codec != null) { + entry.put("codec", sample.codec); + } + entry.put("fps", sample.fps); + entry.put("maxCryptoMs", sample.maxCryptoMs); + list.add(entry); + } + return list; + } +} diff --git a/android/src/main/java/io/getstream/webrtc/flutter/MethodCallHandlerImpl.java b/android/src/main/java/io/getstream/webrtc/flutter/MethodCallHandlerImpl.java index e54fdd9c1b..5512faddcb 100644 --- a/android/src/main/java/io/getstream/webrtc/flutter/MethodCallHandlerImpl.java +++ b/android/src/main/java/io/getstream/webrtc/flutter/MethodCallHandlerImpl.java @@ -113,9 +113,13 @@ public class MethodCallHandlerImpl implements MethodCallHandler, StateProvider { private AudioFocusManager audioFocusManager; - // Frame cryptor + data packet cryptor deactivated until per-call factory wiring lands. - // private FlutterRTCFrameCryptor frameCryptor; - // private FlutterDataPacketCryptor dataPacketCryptor; + /** + * Common Stream implementation for AES-GCM end-to-end encryption, + * used across all Stream SDKs (JS, iOS, Android, Flutter). + * This is independent of per-call factories: the manager owns keys, + * not media. + */ + private final FlutterRTCEncryptionManager encryptionManager; private Activity activity; @@ -144,6 +148,7 @@ public void onLogMessage(String message, Severity sev, String tag) { this.context = context; this.textures = textureRegistry; this.messenger = messenger; + this.encryptionManager = new FlutterRTCEncryptionManager(this); } static private void resultError(String method, String error, Result result) { @@ -159,6 +164,8 @@ static private void resultError(String method, String error, Result result) { * otherwise libwebrtc native state crashes when the factory's ADM is already disposed. */ void dispose() { + encryptionManager.disposeAll(); + if (AudioSwitchManager.instance != null) { AudioSwitchManager.instance.setAudioFocusChangeListener(null); } @@ -1628,12 +1635,9 @@ public void onInterruptionEnd() { break; } default: - // Frame cryptor + data packet cryptor deactivated until per-call factory wiring lands. - // if(frameCryptor.handleMethodCall(call, result)) { - // break; - // } else if(dataPacketCryptor.handleMethodCall(call, result)) { - // break; - // } + if (encryptionManager.handleMethodCall(call, result)) { + break; + } result.notImplemented(); break; } diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0b1fba10bd..c22cd88548 100644 --- a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/GetStream/stream-video-swift-webrtc.git", "state" : { - "revision" : "f8689677681ed6b8d0f17b409cbbde67273017b2", - "version" : "145.9.0" + "revision" : "db72db9ac7e606d024ff03002c9fc1f9998dbee1", + "version" : "145.16.0" } } ], diff --git a/example/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/example/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0b1fba10bd..c22cd88548 100644 --- a/example/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/example/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/GetStream/stream-video-swift-webrtc.git", "state" : { - "revision" : "f8689677681ed6b8d0f17b409cbbde67273017b2", - "version" : "145.9.0" + "revision" : "db72db9ac7e606d024ff03002c9fc1f9998dbee1", + "version" : "145.16.0" } } ], diff --git a/ios/stream_webrtc_flutter.podspec b/ios/stream_webrtc_flutter.podspec index f4e35114e1..5ceba10117 100644 --- a/ios/stream_webrtc_flutter.podspec +++ b/ios/stream_webrtc_flutter.podspec @@ -18,7 +18,7 @@ A new flutter plugin project. s.vendored_frameworks = 'Frameworks/StreamWebRTC.xcframework' s.prepare_command = <<-CMD mkdir -p Frameworks/ - curl -sL "https://github.com/GetStream/stream-video-swift-webrtc/releases/download/145.9.0/StreamWebRTC.xcframework.zip" -o Frameworks/StreamWebRTC.zip + curl -sL "https://github.com/GetStream/stream-video-swift-webrtc/releases/download/145.16.0/StreamWebRTC.xcframework.zip" -o Frameworks/StreamWebRTC.zip unzip -o Frameworks/StreamWebRTC.zip -d Frameworks/ rm Frameworks/StreamWebRTC.zip CMD diff --git a/ios/stream_webrtc_flutter/Package.swift b/ios/stream_webrtc_flutter/Package.swift index be374d9461..f2c43d5f52 100644 --- a/ios/stream_webrtc_flutter/Package.swift +++ b/ios/stream_webrtc_flutter/Package.swift @@ -12,7 +12,7 @@ let package = Package( dependencies: [ .package(name: "FlutterFramework", path: "../FlutterFramework"), .package( - url: "https://github.com/GetStream/stream-video-swift-webrtc.git", exact: "145.9.0" + url: "https://github.com/GetStream/stream-video-swift-webrtc.git", exact: "145.16.0" ) ], targets: [ diff --git a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCEncryptionManager.m b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCEncryptionManager.m new file mode 100644 index 0000000000..f6249315f1 --- /dev/null +++ b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCEncryptionManager.m @@ -0,0 +1,537 @@ +#import "include/stream_webrtc_flutter/FlutterRTCEncryptionManager.h" + +/** Dart sends -1 when it wants native to infer audio vs video from RTP. */ +static const NSInteger kTrackTypeUnspecified = -1; + +/** + * Manager registry. + * + * The plugin is a singleton (`+[FlutterWebRTCPlugin sharedSingleton]`) and a + * category cannot add storage, so the registry lives here. Guarded by + * @synchronized because Dart calls arrive on the platform thread while + * `dispose` can run from teardown. + */ +static NSMutableDictionary* gHandles; + +static NSMutableDictionary* handleRegistry(void) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + gHandles = [NSMutableDictionary dictionary]; + }); + return gHandles; +} + +#pragma mark - Event serialization + +static NSDictionary* userKeyToMap(RTCEncryptionUserKey* key) { + return @{ + @"userId" : key.userId ?: @"", + @"keyIndex" : @(key.keyIndex), + @"fingerprint" : key.fingerprint ?: @"" + }; +} + +static NSDictionary* sharedKeyToMap(RTCEncryptionSharedKey* key) { + return @{ + @"keyIndex" : @(key.keyIndex), + @"fingerprint" : key.fingerprint ?: @"", + @"isActive" : @(key.isActive) + }; +} + +static NSDictionary* keyStateToMap(RTCEncryptionKeyState* keyState) { + NSMutableArray* perUserKeys = [NSMutableArray array]; + for (RTCEncryptionUserKey* key in keyState.perUserKeys) { + [perUserKeys addObject:userKeyToMap(key)]; + } + + NSMutableArray* sharedKeys = [NSMutableArray array]; + for (RTCEncryptionSharedKey* key in keyState.sharedKeys) { + [sharedKeys addObject:sharedKeyToMap(key)]; + } + + return @{@"perUserKeys" : perUserKeys, @"sharedKeys" : sharedKeys}; +} + +static NSArray* perfToList(NSArray* samples) { + NSMutableArray* list = [NSMutableArray array]; + for (RTCEncryptionTrackPerf* sample in samples) { + NSMutableDictionary* entry = [NSMutableDictionary dictionary]; + entry[@"userId"] = sample.userId ?: @""; + entry[@"trackType"] = @(sample.trackType); + if (sample.codec != nil) { + entry[@"codec"] = sample.codec; + } + entry[@"fps"] = @(sample.fps); + entry[@"maxCryptoMs"] = @(sample.maxCryptoMs); + [list addObject:entry]; + } + return list; +} + +static NSDictionary* eventToMap(RTCE2eeEvent* event) { + NSMutableDictionary* map = [NSMutableDictionary dictionary]; + map[@"type"] = @(event.type); + map[@"name"] = event.name ?: @""; + map[@"userId"] = event.userId ?: @""; + if (event.trackType != nil) { + map[@"trackType"] = event.trackType; + } + if (event.keyIndex != nil) { + map[@"keyIndex"] = event.keyIndex; + } + if (event.version != nil) { + map[@"version"] = event.version; + } + if (event.reason != nil) { + map[@"reason"] = event.reason; + } + if (event.keyState != nil) { + map[@"keyState"] = keyStateToMap(event.keyState); + } + if (event.encode != nil) { + map[@"encode"] = perfToList(event.encode); + } + if (event.decode != nil) { + map[@"decode"] = perfToList(event.decode); + } + return map; +} + +#pragma mark - Handle + +@implementation FlutterRTCEncryptionManagerHandle { + FlutterEventSink _eventSink; +} + +- (instancetype)initWithManager:(RTCEncryptionManager*)manager + eventChannel:(FlutterEventChannel*)eventChannel { + self = [super init]; + if (self) { + _manager = manager; + _eventChannel = eventChannel; + } + return self; +} + +- (FlutterError*)onListenWithArguments:(id)arguments eventSink:(FlutterEventSink)events { + _eventSink = events; + return nil; +} + +- (FlutterError*)onCancelWithArguments:(id)arguments { + _eventSink = nil; + return nil; +} + +- (void)encryptionManager:(RTCEncryptionManager*)manager didReceiveEvent:(RTCE2eeEvent*)event { + postEvent(_eventSink, eventToMap(event)); +} + +- (void)detach { + // Dropping the delegate first stops events racing the teardown. + _manager.delegate = nil; + [_eventChannel setStreamHandler:nil]; + _eventSink = nil; +} + +- (void)releaseNative { + [_manager dispose]; +} + +@end + +#pragma mark - Plugin category + +@implementation FlutterWebRTCPlugin (EncryptionManager) + +- (BOOL)handleEncryptionManagerMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { + NSString* method = call.method; + if (![method hasPrefix:@"encryptionManager"]) { + return NO; + } + + if ([@"encryptionManagerCreate" isEqualToString:method]) { + [self encryptionManagerCreate:call result:result]; + } else if ([@"encryptionManagerSetKey" isEqualToString:method]) { + [self encryptionManagerSetKey:call result:result]; + } else if ([@"encryptionManagerSetSharedKey" isEqualToString:method]) { + [self encryptionManagerSetSharedKey:call result:result]; + } else if ([@"encryptionManagerRemoveKey" isEqualToString:method]) { + [self encryptionManagerRemoveKey:call result:result]; + } else if ([@"encryptionManagerRemoveAllKeys" isEqualToString:method]) { + [self encryptionManagerRemoveAllKeys:call result:result]; + } else if ([@"encryptionManagerRemoveSharedKey" isEqualToString:method]) { + [self encryptionManagerRemoveSharedKey:call result:result]; + } else if ([@"encryptionManagerEncrypt" isEqualToString:method]) { + [self encryptionManagerEncrypt:call result:result]; + } else if ([@"encryptionManagerDecrypt" isEqualToString:method]) { + [self encryptionManagerDecrypt:call result:result]; + } else if ([@"encryptionManagerEnablePerformanceReporting" isEqualToString:method]) { + [self encryptionManagerEnablePerformanceReporting:call result:result]; + } else if ([@"encryptionManagerRequestKeyState" isEqualToString:method]) { + [self encryptionManagerRequestKeyState:call result:result]; + } else if ([@"encryptionManagerDispose" isEqualToString:method]) { + [self encryptionManagerDispose:call result:result]; + } else { + return NO; + } + + return YES; +} + +- (void)disposeAllEncryptionManagers { + NSArray* handles; + @synchronized(handleRegistry()) { + handles = handleRegistry().allValues; + [handleRegistry() removeAllObjects]; + } + for (FlutterRTCEncryptionManagerHandle* handle in handles) { + [handle detach]; + } + // Releasing joins the manager's frame-crypto worker, which would block the + // platform thread for as long as that worker takes to drain. + dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ + for (FlutterRTCEncryptionManagerHandle* handle in handles) { + [handle releaseNative]; + } + }); +} + +#pragma mark - Methods + +- (void)encryptionManagerCreate:(FlutterMethodCall*)call result:(FlutterResult)result { + NSDictionary* args = call.arguments; + NSString* userId = args[@"userId"]; + if (![userId isKindOfClass:[NSString class]] || userId.length == 0) { + result([FlutterError errorWithCode:@"encryptionManagerCreateFailed" + message:@"userId is required" + details:nil]); + return; + } + + NSNumber* algorithmValue = args[@"algorithm"]; + RTCEncryptionAlgorithm algorithm = algorithmValue.integerValue == RTCEncryptionAlgorithmAes256Gcm + ? RTCEncryptionAlgorithmAes256Gcm + : RTCEncryptionAlgorithmAes128Gcm; + + NSError* error = nil; + RTCEncryptionManager* manager = [RTCEncryptionManager createWithUserId:userId + algorithm:algorithm + error:&error]; + if (manager == nil) { + result([FlutterError errorWithCode:@"encryptionManagerCreateFailed" + message:error.localizedDescription ?: @"create failed" + details:nil]); + return; + } + + NSString* managerId = [[NSUUID UUID] UUIDString]; + FlutterEventChannel* eventChannel = [FlutterEventChannel + eventChannelWithName:[NSString stringWithFormat:@"FlutterWebRTC/e2ee/%@", managerId] + binaryMessenger:self.messenger]; + + FlutterRTCEncryptionManagerHandle* handle = + [[FlutterRTCEncryptionManagerHandle alloc] initWithManager:manager eventChannel:eventChannel]; + [eventChannel setStreamHandler:handle]; + manager.delegate = handle; + + @synchronized(handleRegistry()) { + handleRegistry()[managerId] = handle; + } + + result(@{@"managerId" : managerId}); +} + +- (void)encryptionManagerSetKey:(FlutterMethodCall*)call result:(FlutterResult)result { + RTCEncryptionManager* manager = [self requireManagerForCall:call result:result]; + if (manager == nil) { + return; + } + + NSDictionary* args = call.arguments; + NSString* userId = args[@"userId"]; + NSNumber* keyIndex = args[@"keyIndex"]; + FlutterStandardTypedData* rawKey = args[@"rawKey"]; + if (userId == nil || keyIndex == nil || rawKey == nil) { + [self failCall:call result:result message:@"userId, keyIndex and rawKey are required"]; + return; + } + + NSError* error = nil; + if (![manager setKey:userId keyIndex:keyIndex.intValue rawKey:rawKey.data error:&error]) { + [self failCall:call result:result message:error.localizedDescription ?: @"setKey failed"]; + return; + } + result(nil); +} + +- (void)encryptionManagerSetSharedKey:(FlutterMethodCall*)call result:(FlutterResult)result { + RTCEncryptionManager* manager = [self requireManagerForCall:call result:result]; + if (manager == nil) { + return; + } + + NSDictionary* args = call.arguments; + NSNumber* keyIndex = args[@"keyIndex"]; + FlutterStandardTypedData* rawKey = args[@"rawKey"]; + if (keyIndex == nil || rawKey == nil) { + [self failCall:call result:result message:@"keyIndex and rawKey are required"]; + return; + } + + NSError* error = nil; + if (![manager setSharedKey:keyIndex.intValue rawKey:rawKey.data error:&error]) { + [self failCall:call result:result message:error.localizedDescription ?: @"setSharedKey failed"]; + return; + } + result(nil); +} + +- (void)encryptionManagerRemoveKey:(FlutterMethodCall*)call result:(FlutterResult)result { + RTCEncryptionManager* manager = [self requireManagerForCall:call result:result]; + if (manager == nil) { + return; + } + + NSDictionary* args = call.arguments; + NSString* userId = args[@"userId"]; + NSNumber* keyIndex = args[@"keyIndex"]; + if (userId == nil || keyIndex == nil) { + [self failCall:call result:result message:@"userId and keyIndex are required"]; + return; + } + + NSError* error = nil; + if (![manager removeKey:userId keyIndex:keyIndex.intValue error:&error]) { + [self failCall:call result:result message:error.localizedDescription ?: @"removeKey failed"]; + return; + } + result(nil); +} + +- (void)encryptionManagerRemoveAllKeys:(FlutterMethodCall*)call result:(FlutterResult)result { + RTCEncryptionManager* manager = [self requireManagerForCall:call result:result]; + if (manager == nil) { + return; + } + + NSString* userId = call.arguments[@"userId"]; + if (userId == nil) { + [self failCall:call result:result message:@"userId is required"]; + return; + } + + NSError* error = nil; + if (![manager removeAllKeys:userId error:&error]) { + [self failCall:call + result:result + message:error.localizedDescription ?: @"removeAllKeys failed"]; + return; + } + result(nil); +} + +- (void)encryptionManagerRemoveSharedKey:(FlutterMethodCall*)call result:(FlutterResult)result { + RTCEncryptionManager* manager = [self requireManagerForCall:call result:result]; + if (manager == nil) { + return; + } + + NSNumber* keyIndex = call.arguments[@"keyIndex"]; + if (keyIndex == nil) { + [self failCall:call result:result message:@"keyIndex is required"]; + return; + } + + NSError* error = nil; + if (![manager removeSharedKey:keyIndex.intValue error:&error]) { + [self failCall:call + result:result + message:error.localizedDescription ?: @"removeSharedKey failed"]; + return; + } + result(nil); +} + +- (void)encryptionManagerEncrypt:(FlutterMethodCall*)call result:(FlutterResult)result { + RTCEncryptionManager* manager = [self requireManagerForCall:call result:result]; + if (manager == nil) { + return; + } + + RTCPeerConnection* peerConnection = [self requirePeerConnectionForCall:call result:result]; + if (peerConnection == nil) { + return; + } + + NSString* senderId = call.arguments[@"rtpSenderId"]; + if (senderId == nil) { + [self failCall:call result:result message:@"rtpSenderId is required"]; + return; + } + + RTCRtpSender* sender = [self getRtpSenderById:peerConnection Id:senderId]; + if (sender == nil) { + [self failCall:call + result:result + message:[NSString stringWithFormat:@"sender %@ not found", senderId]]; + return; + } + + NSString* codec = call.arguments[@"codec"]; + if (![codec isKindOfClass:[NSString class]]) { + codec = nil; + } + + NSError* error = nil; + if (![manager encrypt:sender codec:codec trackType:[self trackTypeForCall:call] error:&error]) { + [self failCall:call result:result message:error.localizedDescription ?: @"encrypt failed"]; + return; + } + result(nil); +} + +- (void)encryptionManagerDecrypt:(FlutterMethodCall*)call result:(FlutterResult)result { + RTCEncryptionManager* manager = [self requireManagerForCall:call result:result]; + if (manager == nil) { + return; + } + + RTCPeerConnection* peerConnection = [self requirePeerConnectionForCall:call result:result]; + if (peerConnection == nil) { + return; + } + + NSDictionary* args = call.arguments; + NSString* receiverId = args[@"rtpReceiverId"]; + NSString* userId = args[@"userId"]; + if (receiverId == nil || userId == nil || userId.length == 0) { + [self failCall:call result:result message:@"rtpReceiverId and userId are required"]; + return; + } + + RTCRtpReceiver* receiver = [self getRtpReceiverById:peerConnection Id:receiverId]; + if (receiver == nil) { + [self failCall:call + result:result + message:[NSString stringWithFormat:@"receiver %@ not found", receiverId]]; + return; + } + + NSError* error = nil; + if (![manager decrypt:receiver + userId:userId + trackType:[self trackTypeForCall:call] + error:&error]) { + [self failCall:call result:result message:error.localizedDescription ?: @"decrypt failed"]; + return; + } + result(nil); +} + +- (void)encryptionManagerEnablePerformanceReporting:(FlutterMethodCall*)call + result:(FlutterResult)result { + RTCEncryptionManager* manager = [self requireManagerForCall:call result:result]; + if (manager == nil) { + return; + } + + NSNumber* enabled = call.arguments[@"enabled"]; + NSError* error = nil; + if (![manager enablePerformanceReporting:enabled.boolValue error:&error]) { + [self failCall:call + result:result + message:error.localizedDescription ?: @"enablePerformanceReporting failed"]; + return; + } + result(nil); +} + +- (void)encryptionManagerRequestKeyState:(FlutterMethodCall*)call result:(FlutterResult)result { + RTCEncryptionManager* manager = [self requireManagerForCall:call result:result]; + if (manager == nil) { + return; + } + + NSError* error = nil; + if (![manager requestKeyState:&error]) { + [self failCall:call + result:result + message:error.localizedDescription ?: @"requestKeyState failed"]; + return; + } + result(nil); +} + +- (void)encryptionManagerDispose:(FlutterMethodCall*)call result:(FlutterResult)result { + NSString* managerId = call.arguments[@"managerId"]; + FlutterRTCEncryptionManagerHandle* handle = nil; + if (managerId != nil) { + @synchronized(handleRegistry()) { + handle = handleRegistry()[managerId]; + [handleRegistry() removeObjectForKey:managerId]; + } + } + [handle detach]; + if (handle != nil) { + // Off the platform thread: releasing joins the frame-crypto worker. + // Answering first is fine — the handle is already out of the registry. + dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ + [handle releaseNative]; + }); + } + // Disposing an unknown manager is not an error: Dart may retry teardown. + result(nil); +} + +#pragma mark - Helpers + +- (nullable RTCEncryptionManager*)requireManagerForCall:(FlutterMethodCall*)call + result:(FlutterResult)result { + NSString* managerId = call.arguments[@"managerId"]; + FlutterRTCEncryptionManagerHandle* handle = nil; + if (managerId != nil) { + @synchronized(handleRegistry()) { + handle = handleRegistry()[managerId]; + } + } + if (handle == nil) { + [self failCall:call + result:result + message:[NSString stringWithFormat:@"EncryptionManager %@ not found", managerId]]; + return nil; + } + return handle.manager; +} + +- (nullable RTCPeerConnection*)requirePeerConnectionForCall:(FlutterMethodCall*)call + result:(FlutterResult)result { + NSString* peerConnectionId = call.arguments[@"peerConnectionId"]; + RTCPeerConnection* peerConnection = + peerConnectionId == nil ? nil : self.peerConnections[peerConnectionId]; + if (peerConnection == nil) { + [self failCall:call + result:result + message:[NSString stringWithFormat:@"peerConnection %@ not found", peerConnectionId]]; + return nil; + } + return peerConnection; +} + +/** Maps Dart's `trackType` to a boxed enum, or nil to let RTP decide. */ +- (nullable NSNumber*)trackTypeForCall:(FlutterMethodCall*)call { + NSNumber* value = call.arguments[@"trackType"]; + if (value == nil || value.integerValue == kTrackTypeUnspecified) { + return nil; + } + return value; +} + +- (void)failCall:(FlutterMethodCall*)call result:(FlutterResult)result message:(NSString*)message { + result([FlutterError errorWithCode:[NSString stringWithFormat:@"%@Failed", call.method] + message:message + details:nil]); +} + +@end diff --git a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m index e6923a6b92..9d7012daff 100644 --- a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m +++ b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m @@ -6,6 +6,7 @@ #import "include/stream_webrtc_flutter/FlutterDataPacketCryptor.h" #import "include/stream_webrtc_flutter/FlutterRTCDataChannel.h" #import "include/stream_webrtc_flutter/FlutterRTCDesktopCapturer.h" +#import "include/stream_webrtc_flutter/FlutterRTCEncryptionManager.h" #import "include/stream_webrtc_flutter/FlutterRTCMediaRecorder.h" #import "include/stream_webrtc_flutter/FlutterRTCMediaStream.h" #import "include/stream_webrtc_flutter/FlutterRTCPeerConnection.h" @@ -233,6 +234,8 @@ - (instancetype)initWithChannel:(FlutterMethodChannel*)channel } - (void)detachFromEngineForRegistrar:(NSObject*)registrar { + [self disposeAllEncryptionManagers]; + for (RTCPeerConnection* peerConnection in _peerConnections.allValues) { for (RTCDataChannel* dataChannel in peerConnection.dataChannels) { dataChannel.eventSink = nil; @@ -2091,18 +2094,9 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { } result(nil); } else { - // Frame cryptor was deactivated alongside the iOS ambient-factory removal — - // it routed factory creation through the ambient ADM and Stream SDK does - // not use it. Reviving requires wiring a per-PC factoryId through every - // FlutterRTCFrameCryptor entry point. Until then frame-cryptor calls bubble - // through data-packet cryptor (which does not recognize them) and - // ultimately receive FlutterMethodNotImplemented. - - // if ([self handleFrameCryptorMethodCall:call result:result]) { - // return; - // } else { - // [self handleDataPacketCryptorMethodCall:call result:result]; - // } + if ([self handleEncryptionManagerMethodCall:call result:result]) { + return; + } [self handleDataPacketCryptorMethodCall:call result:result]; } diff --git a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCEncryptionManager.h b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCEncryptionManager.h new file mode 100644 index 0000000000..4734de9c0f --- /dev/null +++ b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCEncryptionManager.h @@ -0,0 +1,55 @@ +#import +#import + +#import "FlutterWebRTCPlugin.h" + +NS_ASSUME_NONNULL_BEGIN + +/// Dart-visible EncryptionManager: wraps native manager and its event channel +/// (`FlutterWebRTC/e2ee/`), created via `encryptionManagerCreate` and stored by +/// `managerId`. +@interface FlutterRTCEncryptionManagerHandle + : NSObject + +@property(nonatomic, strong, readonly) RTCEncryptionManager* manager; +@property(nonatomic, strong, readonly) FlutterEventChannel* eventChannel; + +- (instancetype)initWithManager:(RTCEncryptionManager*)manager + eventChannel:(FlutterEventChannel*)eventChannel NS_DESIGNATED_INITIALIZER; + +- (instancetype)init NS_UNAVAILABLE; + +/** + * Detaches the event channel. Platform thread only, and before + * `releaseNative` so no event races the teardown. + */ +- (void)detach; + +/** Releases the native manager. Blocks on its frame-crypto worker. */ +- (void)releaseNative; + +@end + +/** + * Common Stream implementation for AES-GCM end-to-end encryption, + * used across all Stream SDKs (JS, iOS, Android, Flutter). + * This is independent of per-call factories: the manager owns keys, + * not media. + */ +@interface FlutterWebRTCPlugin (EncryptionManager) + +/** + * Handles every `encryptionManager*` method. + * + * @return `YES` when `call` belonged to this bridge and `result` was already + * invoked, `NO` when the caller should keep dispatching. + */ +- (BOOL)handleEncryptionManagerMethodCall:(nonnull FlutterMethodCall*)call + result:(nonnull FlutterResult)result; + +/** Releases every manager, e.g. when the plugin detaches from the engine. */ +- (void)disposeAllEncryptionManagers; + +@end + +NS_ASSUME_NONNULL_END diff --git a/lib/src/e2ee/encryption_manager.dart b/lib/src/e2ee/encryption_manager.dart new file mode 100644 index 0000000000..92dfa6ee97 --- /dev/null +++ b/lib/src/e2ee/encryption_manager.dart @@ -0,0 +1,123 @@ +import 'dart:typed_data'; + +import 'package:webrtc_interface/webrtc_interface.dart'; + +import '../native/encryption_manager_impl.dart' + if (dart.library.js_interop) '../web/encryption_manager_impl.dart'; +import 'encryption_types.dart'; + +/// Attaches AES-GCM end-to-end encryption to RTP senders and receivers. +/// +/// One manager holds every key for a call and wraps senders and receivers +/// with the native encoded transform. +/// +/// ## Usage +/// +/// ```dart +/// final e2ee = EncryptionManager.create(userId: myUserId); +/// await e2ee.setSharedKey(0, keyBytes); // 16 bytes for AES-128 +/// +/// // Publishing. +/// await e2ee.encrypt(sender, codec: 'vp8', trackType: E2eeTrackType.video); +/// +/// // Subscribing. Match the receiver by `track.id`, never by identity. +/// await e2ee.decrypt(receiver, userId: remoteUserId, +/// trackType: E2eeTrackType.video); +/// ``` +abstract class EncryptionManager { + /// Creates a manager that encrypts outgoing frames as [userId]. + /// + /// [userId] must be non-empty and must be the local user's id — remote + /// participants select a decryption key by it. + factory EncryptionManager.create({ + required String userId, + EncryptionAlgorithm algorithm = EncryptionAlgorithm.aes128Gcm, + }) { + if (userId.isEmpty) { + throw ArgumentError.value(userId, 'userId', 'must not be empty'); + } + return createEncryptionManager(userId: userId, algorithm: algorithm); + } + + /// Whether the running platform can attach encoded transforms. + /// + /// `true` on Android, iOS and macOS; `false` on web, Windows and Linux, + /// where every other method throws [UnsupportedError]. + static bool get isSupported => encryptionManagerIsSupported; + + /// The local user id passed to [EncryptionManager.create]. + String get userId; + + /// The key size this manager was created with. + EncryptionAlgorithm get algorithm; + + /// Whether [dispose] has run. A disposed manager rejects every call. + bool get isDisposed; + + /// Diagnostic `e2ee.*` events for this manager. + Stream get events; + + /// Registers [rawKey] at [keyIndex] for a single user. + /// + /// [rawKey] must be exactly [EncryptionAlgorithm.keyLengthBytes] long and + /// [keyIndex] must be in `0..255`. + Future setKey(String userId, int keyIndex, Uint8List rawKey); + + /// Registers [rawKey] at [keyIndex] for every participant. + /// + /// This is the passphrase-style setup: every participant derives the same + /// bytes and calls this with the same index. + Future setSharedKey(int keyIndex, Uint8List rawKey); + + /// Drops the key registered for [userId] at [keyIndex]. + Future removeKey(String userId, int keyIndex); + + /// Drops every key registered for [userId]. + Future removeAllKeys(String userId); + + /// Drops the shared key at [keyIndex]. + Future removeSharedKey(int keyIndex); + + /// Encrypts everything [sender] publishes from now on. + /// + /// Attach this right after `addTransceiver`, before the first frame is + /// encoded, otherwise the opening frames leave in cleartext. + /// + /// [codec] is an exact lowercase pin — `opus`, `vp8`, `vp9` or `h264`. + /// Passing `null` reads the codec from each frame instead. + /// + /// [trackType] groups replay windows. Pass + /// [E2eeTrackType.screenShare] explicitly so a screen share and a camera + /// from the same user do not share one window; `null` lets native pick + /// audio vs video from the sender. + Future encrypt( + RTCRtpSender sender, { + String? codec, + E2eeTrackType? trackType, + }); + + /// Decrypts everything [receiver] delivers from now on. + /// + /// [userId] is the *remote* participant's id — it selects the key that + /// participant encrypted with. Passing the local user's id here decrypts + /// with the wrong key and yields `e2ee.decryption_failed`. + /// + /// [trackType] groups replay windows the same way it does for [encrypt]. + Future decrypt( + RTCRtpReceiver receiver, { + required String userId, + E2eeTrackType? trackType, + }); + + /// Starts or stops periodic `e2ee.perf_report` events on [events]. + Future enablePerformanceReporting(bool enabled); + + /// Requests one `e2ee.key_state` event on [events]. + Future requestKeyState(); + + /// Releases the native manager and closes [events]. + /// + /// Senders and receivers already attached keep the transform they were + /// given; this only stops new attachments and frees the key store. + Future dispose(); +} diff --git a/lib/src/e2ee/encryption_types.dart b/lib/src/e2ee/encryption_types.dart new file mode 100644 index 0000000000..b45e95dcec --- /dev/null +++ b/lib/src/e2ee/encryption_types.dart @@ -0,0 +1,265 @@ +/// Shared value types for the native `EncryptionManager` bridge. +/// +/// These mirror `org.webrtc.EncryptionManager` (Android) and +/// `RTCEncryptionManager` (ObjC) one-for-one so the same wire format is used +/// across all the SDKs. +library; + +/// AES-GCM key size. Default is AES-128. +enum EncryptionAlgorithm { + /// 16-byte keys. + aes128Gcm(0), + + /// 32-byte keys. + aes256Gcm(1); + + const EncryptionAlgorithm(this.value); + + final int value; + + /// Key length, in bytes, that [EncryptionManager.setKey] expects. + int get keyLengthBytes => this == EncryptionAlgorithm.aes256Gcm ? 32 : 16; +} + +/// Enumerates the grouping used for an encrypted track's replay window. +/// +/// Used in encrypt/decrypt operations: +/// - If omitted, the native implementation infers audio or video from the RTP sender/receiver. +/// - Screenshare must be specified explicitly to ensure its replay window remains separate from the camera stream. +enum E2eeTrackType { + audio(0), + video(1), + screenShare(2), + screenShareAudio(3); + + const E2eeTrackType(this.value); + + final int value; + + /// Resolves a native ordinal, or `null` when [value] is out of range. + static E2eeTrackType? fromValue(int? value) { + if (value == null) return null; + for (final type in E2eeTrackType.values) { + if (type.value == value) return type; + } + return null; + } +} + +/// Kinds of `e2ee.*` event emitted by the native manager. +enum E2eeEventType { + decryptionFailed(0, 'e2ee.decryption_failed'), + decryptionResumed(1, 'e2ee.decryption_resumed'), + decryptionStalled(2, 'e2ee.decryption_stalled'), + encryptionFailed(3, 'e2ee.encryption_failed'), + missingKey(4, 'e2ee.missing_key'), + unencryptedFrame(5, 'e2ee.unencrypted_frame'), + unsupportedVersion(6, 'e2ee.unsupported_version'), + keyState(7, 'e2ee.key_state'), + perfReport(8, 'e2ee.perf_report'); + + const E2eeEventType(this.value, this.eventName); + + final int value; + final String eventName; + + /// Resolves a native ordinal, or `null` when [value] is out of range. + static E2eeEventType? fromValue(int? value) { + if (value == null) return null; + for (final type in E2eeEventType.values) { + if (type.value == value) return type; + } + return null; + } +} + +/// A key registered for a specific remote user. +class E2eeUserKey { + const E2eeUserKey({ + required this.userId, + required this.keyIndex, + required this.fingerprint, + }); + + factory E2eeUserKey.fromMap(Map map) { + return E2eeUserKey( + userId: map['userId'] as String? ?? '', + keyIndex: map['keyIndex'] as int? ?? 0, + fingerprint: map['fingerprint'] as String? ?? '', + ); + } + + final String userId; + final int keyIndex; + final String fingerprint; + + @override + String toString() => 'E2eeUserKey(userId: $userId, keyIndex: $keyIndex, ' + 'fingerprint: $fingerprint)'; +} + +/// A key registered for every participant at a given index. +class E2eeSharedKey { + const E2eeSharedKey({ + required this.keyIndex, + required this.fingerprint, + required this.isActive, + }); + + factory E2eeSharedKey.fromMap(Map map) { + return E2eeSharedKey( + keyIndex: map['keyIndex'] as int? ?? 0, + fingerprint: map['fingerprint'] as String? ?? '', + isActive: map['isActive'] as bool? ?? false, + ); + } + + final int keyIndex; + final String fingerprint; + + /// Whether this index is the one used to encrypt outgoing frames. + final bool isActive; + + @override + String toString() => + 'E2eeSharedKey(keyIndex: $keyIndex, fingerprint: $fingerprint, ' + 'isActive: $isActive)'; +} + +/// Payload of an `e2ee.key_state` event. +class E2eeKeyState { + const E2eeKeyState({ + required this.perUserKeys, + required this.sharedKeys, + }); + + factory E2eeKeyState.fromMap(Map map) { + return E2eeKeyState( + perUserKeys: (map['perUserKeys'] as List? ?? const []) + .map((e) => E2eeUserKey.fromMap(e as Map)) + .toList(growable: false), + sharedKeys: (map['sharedKeys'] as List? ?? const []) + .map((e) => E2eeSharedKey.fromMap(e as Map)) + .toList(growable: false), + ); + } + + final List perUserKeys; + final List sharedKeys; + + @override + String toString() => + 'E2eeKeyState(perUserKeys: $perUserKeys, sharedKeys: $sharedKeys)'; +} + +/// One row of an `e2ee.perf_report` event. +class E2eeTrackPerf { + const E2eeTrackPerf({ + required this.userId, + required this.trackType, + required this.codec, + required this.fps, + required this.maxCryptoMs, + }); + + factory E2eeTrackPerf.fromMap(Map map) { + return E2eeTrackPerf( + userId: map['userId'] as String? ?? '', + trackType: E2eeTrackType.fromValue(map['trackType'] as int?), + codec: map['codec'] as String?, + fps: (map['fps'] as num?)?.toDouble() ?? 0, + maxCryptoMs: (map['maxCryptoMs'] as num?)?.toDouble() ?? 0, + ); + } + + final String userId; + final E2eeTrackType? trackType; + + /// Set on encode samples only. + final String? codec; + final double fps; + final double maxCryptoMs; + + @override + String toString() => + 'E2eeTrackPerf(userId: $userId, trackType: $trackType, codec: $codec, ' + 'fps: $fps, maxCryptoMs: $maxCryptoMs)'; +} + +/// A single `e2ee.*` event emitted by the native manager. +/// +/// Optional fields are `null` when the native event omits them. +class E2eeEvent { + const E2eeEvent({ + required this.type, + required this.name, + required this.userId, + this.trackType, + this.keyIndex, + this.version, + this.reason, + this.keyState, + this.encode, + this.decode, + }); + + factory E2eeEvent.fromMap(Map map) { + final type = E2eeEventType.fromValue(map['type'] as int?); + final keyState = map['keyState'] as Map?; + final encode = map['encode'] as List?; + final decode = map['decode'] as List?; + + return E2eeEvent( + type: type, + name: map['name'] as String? ?? type?.eventName ?? 'e2ee.unknown', + userId: map['userId'] as String? ?? '', + trackType: E2eeTrackType.fromValue(map['trackType'] as int?), + keyIndex: map['keyIndex'] as int?, + version: map['version'] as int?, + reason: map['reason'] as String?, + keyState: keyState == null ? null : E2eeKeyState.fromMap(keyState), + encode: encode + ?.map((e) => E2eeTrackPerf.fromMap(e as Map)) + .toList(growable: false), + decode: decode + ?.map((e) => E2eeTrackPerf.fromMap(e as Map)) + .toList(growable: false), + ); + } + + /// `null` when native reports a type this version does not know about. + final E2eeEventType? type; + + /// Wire name, e.g. `e2ee.missing_key`. + final String name; + + /// The key owner the event is about. Empty for manager-wide events. + final String userId; + final E2eeTrackType? trackType; + final int? keyIndex; + final int? version; + final String? reason; + + /// Set on `e2ee.key_state` only. + final E2eeKeyState? keyState; + + /// Set on `e2ee.perf_report` only. + final List? encode; + + /// Set on `e2ee.perf_report` only. + final List? decode; + + @override + String toString() { + final buffer = StringBuffer('E2eeEvent($name'); + if (userId.isNotEmpty) buffer.write(', userId: $userId'); + if (trackType != null) buffer.write(', trackType: $trackType'); + if (keyIndex != null) buffer.write(', keyIndex: $keyIndex'); + if (version != null) buffer.write(', version: $version'); + if (reason != null) buffer.write(', reason: $reason'); + if (keyState != null) buffer.write(', keyState: $keyState'); + if (encode != null) buffer.write(', encode: $encode'); + if (decode != null) buffer.write(', decode: $decode'); + return (buffer..write(')')).toString(); + } +} diff --git a/lib/src/native/encryption_manager_impl.dart b/lib/src/native/encryption_manager_impl.dart new file mode 100644 index 0000000000..1b855ee324 --- /dev/null +++ b/lib/src/native/encryption_manager_impl.dart @@ -0,0 +1,345 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; + +import 'package:webrtc_interface/webrtc_interface.dart'; + +import '../e2ee/encryption_manager.dart'; +import '../e2ee/encryption_types.dart'; +import 'rtc_rtp_receiver_impl.dart'; +import 'rtc_rtp_sender_impl.dart'; +import 'utils.dart'; + +/// Whether this platform ships the native `EncryptionManager`. +/// +/// Android, iOS and macOS bundle it. Windows and Linux run the C++ plugin, +/// which has no encoded-transform bridge, so they report `false` here even +/// though they otherwise take the native code path. +bool get encryptionManagerIsSupported => + WebRTC.platformIsAndroid || WebRTC.platformIsIOS || WebRTC.platformIsMacOS; + +/// Builds the native-backed [EncryptionManager]. +EncryptionManager createEncryptionManager({ + required String userId, + required EncryptionAlgorithm algorithm, +}) { + if (!encryptionManagerIsSupported) { + throw UnsupportedError( + 'EncryptionManager is only available on Android, iOS and macOS. ' + 'Check EncryptionManager.isSupported before creating one.', + ); + } + return EncryptionManagerNative._(userId, algorithm); +} + +/// Method-channel backed [EncryptionManager] for Android, iOS and macOS. +/// +/// Every operation runs on a single serialized queue so ordering matches the +/// order the calls were made in, even when the caller does not await each +/// one. That is what lets `setSharedKey` followed by `encrypt` be correct +/// without explicit awaits. +class EncryptionManagerNative implements EncryptionManager { + EncryptionManagerNative._(this.userId, this.algorithm) { + _queue = _create(); + } + + @override + final String userId; + + @override + final EncryptionAlgorithm algorithm; + + final StreamController _events = + StreamController.broadcast(); + + /// Serializes every native call, including the initial create. + late Future _queue; + + /// Native handle, `null` until the create call lands. + String? _managerId; + + StreamSubscription? _eventSubscription; + bool _disposed = false; + + @override + bool get isDisposed => _disposed; + + @override + Stream get events => _events.stream; + + Future _create() async { + final response = await WebRTC.invokeMethod, dynamic>( + 'encryptionManagerCreate', + { + 'userId': userId, + 'algorithm': algorithm.value, + }, + ); + + final managerId = response?['managerId'] as String?; + if (managerId == null) { + throw StateError('Failed to create EncryptionManager for $userId'); + } + + // Subscribing only once the id is known keeps the channel name stable and + // avoids a second manager ever sharing this stream. + _eventSubscription = EventChannel('FlutterWebRTC/e2ee/$managerId') + .receiveBroadcastStream() + .listen(_onNativeEvent, onError: _onNativeError); + + _managerId = managerId; + } + + void _onNativeEvent(dynamic event) { + if (event is! Map || _events.isClosed) return; + _events.add(E2eeEvent.fromMap(event)); + } + + void _onNativeError(Object error, StackTrace stackTrace) { + if (_events.isClosed) return; + _events.addError(error, stackTrace); + } + + /// Runs [action] after every previously queued operation has settled. + /// + /// A failure inside one operation is reported to that caller only; the + /// queue stays usable for the operations behind it. + Future _enqueue(Future Function(String managerId) action) { + final completer = Completer(); + + void fail(Object error, StackTrace stackTrace) { + if (!completer.isCompleted) completer.completeError(error, stackTrace); + } + + _queue = _queue.then( + (_) async { + try { + // Check handle, not [_disposed]: queued ops must run until teardown completes. + final managerId = _managerId; + if (managerId == null) { + throw StateError( + 'EncryptionManager for $userId is disposed or was never created', + ); + } + completer.complete(await action(managerId)); + } catch (error, stackTrace) { + fail(error, stackTrace); + } + }, + // The create call (or an earlier operation) failed. Report it here and + // return normally so the queue does not stay poisoned. + onError: fail, + ); + + return completer.future; + } + + void _validateKeyIndex(int keyIndex) { + if (keyIndex < 0 || keyIndex > 255) { + throw ArgumentError.value( + keyIndex, 'keyIndex', 'must be between 0 and 255'); + } + } + + void _validateKey(Uint8List rawKey) { + final expected = algorithm.keyLengthBytes; + if (rawKey.length != expected) { + throw ArgumentError.value( + rawKey.length, + 'rawKey.length', + 'must be exactly $expected bytes for ${algorithm.name}', + ); + } + } + + @override + Future setKey(String userId, int keyIndex, Uint8List rawKey) { + if (userId.isEmpty) { + throw ArgumentError.value(userId, 'userId', 'must not be empty'); + } + _validateKeyIndex(keyIndex); + _validateKey(rawKey); + + return _enqueue((managerId) async { + await WebRTC.invokeMethod('encryptionManagerSetKey', { + 'managerId': managerId, + 'userId': userId, + 'keyIndex': keyIndex, + 'rawKey': rawKey, + }); + }); + } + + @override + Future setSharedKey(int keyIndex, Uint8List rawKey) { + _validateKeyIndex(keyIndex); + _validateKey(rawKey); + + return _enqueue((managerId) async { + await WebRTC.invokeMethod( + 'encryptionManagerSetSharedKey', + { + 'managerId': managerId, + 'keyIndex': keyIndex, + 'rawKey': rawKey, + }, + ); + }); + } + + @override + Future removeKey(String userId, int keyIndex) { + _validateKeyIndex(keyIndex); + + return _enqueue((managerId) async { + await WebRTC.invokeMethod('encryptionManagerRemoveKey', { + 'managerId': managerId, + 'userId': userId, + 'keyIndex': keyIndex, + }); + }); + } + + @override + Future removeAllKeys(String userId) { + return _enqueue((managerId) async { + await WebRTC.invokeMethod( + 'encryptionManagerRemoveAllKeys', + {'managerId': managerId, 'userId': userId}, + ); + }); + } + + @override + Future removeSharedKey(int keyIndex) { + _validateKeyIndex(keyIndex); + + return _enqueue((managerId) async { + await WebRTC.invokeMethod( + 'encryptionManagerRemoveSharedKey', + {'managerId': managerId, 'keyIndex': keyIndex}, + ); + }); + } + + @override + Future encrypt( + RTCRtpSender sender, { + String? codec, + E2eeTrackType? trackType, + }) { + if (sender is! RTCRtpSenderNative) { + throw ArgumentError.value( + sender, 'sender', 'expected a native RTCRtpSender'); + } + final peerConnectionId = sender.peerConnectionId; + final senderId = sender.senderId; + + return _enqueue((managerId) async { + await WebRTC.invokeMethod('encryptionManagerEncrypt', { + 'managerId': managerId, + 'peerConnectionId': peerConnectionId, + 'rtpSenderId': senderId, + 'codec': codec, + // -1 tells native to pick audio vs video from the sender's media type. + 'trackType': trackType?.value ?? -1, + }); + }); + } + + @override + Future decrypt( + RTCRtpReceiver receiver, { + required String userId, + E2eeTrackType? trackType, + }) { + if (receiver is! RTCRtpReceiverNative) { + throw ArgumentError.value( + receiver, 'receiver', 'expected a native RTCRtpReceiver'); + } + if (userId.isEmpty) { + throw ArgumentError.value(userId, 'userId', 'must not be empty'); + } + final peerConnectionId = receiver.peerConnectionId; + final receiverId = receiver.receiverId; + + return _enqueue((managerId) async { + await WebRTC.invokeMethod('encryptionManagerDecrypt', { + 'managerId': managerId, + 'peerConnectionId': peerConnectionId, + 'rtpReceiverId': receiverId, + 'userId': userId, + 'trackType': trackType?.value ?? -1, + }); + }); + } + + @override + Future enablePerformanceReporting(bool enabled) { + return _enqueue((managerId) async { + await WebRTC.invokeMethod( + 'encryptionManagerEnablePerformanceReporting', + {'managerId': managerId, 'enabled': enabled}, + ); + }); + } + + @override + Future requestKeyState() { + return _enqueue((managerId) async { + await WebRTC.invokeMethod( + 'encryptionManagerRequestKeyState', + {'managerId': managerId}, + ); + }); + } + + @override + Future dispose() { + if (_disposed) return Future.value(); + // Flipped before the queue drains so operations queued after this call + // fail fast instead of racing the native teardown. + _disposed = true; + + final completer = Completer(); + + void finish([Object? error, StackTrace? stackTrace]) { + if (completer.isCompleted) return; + if (error != null) { + completer.completeError(error, stackTrace ?? StackTrace.current); + } else { + completer.complete(); + } + } + + _queue = _queue.then( + (_) async { + try { + await _eventSubscription?.cancel(); + _eventSubscription = null; + + final managerId = _managerId; + _managerId = null; + if (managerId != null) { + await WebRTC.invokeMethod( + 'encryptionManagerDispose', + {'managerId': managerId}, + ); + } + finish(); + } catch (error, stackTrace) { + finish(error, stackTrace); + } finally { + await _events.close(); + } + }, + // Nothing native to release if the manager never came up. + onError: (Object _, StackTrace __) async { + await _events.close(); + finish(); + }, + ); + + return completer.future; + } +} diff --git a/lib/src/web/encryption_manager_impl.dart b/lib/src/web/encryption_manager_impl.dart new file mode 100644 index 0000000000..ab32c1b33a --- /dev/null +++ b/lib/src/web/encryption_manager_impl.dart @@ -0,0 +1,86 @@ +import 'dart:typed_data'; + +import 'package:webrtc_interface/webrtc_interface.dart'; + +import '../e2ee/encryption_manager.dart'; +import '../e2ee/encryption_types.dart'; + +/// The browser path would need Encoded Transform plus a worker implementing +/// the JS wire format; that is the JS SDK's job, not this plugin's. +bool get encryptionManagerIsSupported => false; + +/// Builds the unsupported-platform [EncryptionManager]. +EncryptionManager createEncryptionManager({ + required String userId, + required EncryptionAlgorithm algorithm, +}) { + return EncryptionManagerWeb._(userId, algorithm); +} + +/// Placeholder that reports the platform cannot encrypt. +/// +/// Constructing it succeeds so callers can branch on +/// [EncryptionManager.isSupported]; every operation throws. +class EncryptionManagerWeb implements EncryptionManager { + EncryptionManagerWeb._(this.userId, this.algorithm); + + @override + final String userId; + + @override + final EncryptionAlgorithm algorithm; + + @override + bool get isDisposed => false; + + @override + Stream get events => const Stream.empty(); + + Never _unsupported() { + throw UnsupportedError( + 'EncryptionManager is only available on Android, iOS and macOS. ' + 'Check EncryptionManager.isSupported before creating one.', + ); + } + + @override + Future setKey(String userId, int keyIndex, Uint8List rawKey) => + _unsupported(); + + @override + Future setSharedKey(int keyIndex, Uint8List rawKey) => _unsupported(); + + @override + Future removeKey(String userId, int keyIndex) => _unsupported(); + + @override + Future removeAllKeys(String userId) => _unsupported(); + + @override + Future removeSharedKey(int keyIndex) => _unsupported(); + + @override + Future encrypt( + RTCRtpSender sender, { + String? codec, + E2eeTrackType? trackType, + }) => + _unsupported(); + + @override + Future decrypt( + RTCRtpReceiver receiver, { + required String userId, + E2eeTrackType? trackType, + }) => + _unsupported(); + + @override + Future enablePerformanceReporting(bool enabled) => _unsupported(); + + @override + Future requestKeyState() => _unsupported(); + + @override + Future dispose() async {} +} diff --git a/lib/stream_webrtc_flutter.dart b/lib/stream_webrtc_flutter.dart index 6168994f65..f731fc529c 100644 --- a/lib/stream_webrtc_flutter.dart +++ b/lib/stream_webrtc_flutter.dart @@ -3,6 +3,8 @@ library flutter_webrtc; export 'package:webrtc_interface/webrtc_interface.dart' hide MediaDevices, MediaRecorder, Navigator; +export 'src/e2ee/encryption_manager.dart'; +export 'src/e2ee/encryption_types.dart'; export 'src/helper.dart'; export 'src/desktop_capturer.dart'; export 'src/media_devices.dart'; @@ -27,8 +29,8 @@ export 'src/native/ios/audio_management.dart'; export 'src/native/rtc_video_platform_view_controller.dart'; export 'src/native/rtc_video_platform_view.dart'; -const String androidWebRTCVersion = '145.9.0'; -const String iosWebRTCVersion = '145.9.0'; -const String macOsWebRTCVersion = '145.9.0'; +const String androidWebRTCVersion = '145.16.0'; +const String iosWebRTCVersion = '145.16.0'; +const String macOsWebRTCVersion = '145.16.0'; const String windowsWebRTCVersion = '144.7559.09'; const String linuxWebRTCVersion = '144.7559.09'; diff --git a/macos/stream_webrtc_flutter.podspec b/macos/stream_webrtc_flutter.podspec index de701dc44a..ee86a904e4 100644 --- a/macos/stream_webrtc_flutter.podspec +++ b/macos/stream_webrtc_flutter.podspec @@ -19,7 +19,7 @@ A new flutter plugin project. s.vendored_frameworks = 'Frameworks/StreamWebRTC.xcframework' s.prepare_command = <<-CMD mkdir -p Frameworks/ - curl -sL "https://github.com/GetStream/stream-video-swift-webrtc/releases/download/145.9.0/StreamWebRTC.xcframework.zip" -o Frameworks/StreamWebRTC.zip + curl -sL "https://github.com/GetStream/stream-video-swift-webrtc/releases/download/145.16.0/StreamWebRTC.xcframework.zip" -o Frameworks/StreamWebRTC.zip unzip -o Frameworks/StreamWebRTC.zip -d Frameworks/ rm Frameworks/StreamWebRTC.zip CMD diff --git a/macos/stream_webrtc_flutter/Package.swift b/macos/stream_webrtc_flutter/Package.swift index bb8b709fdf..7f34802935 100644 --- a/macos/stream_webrtc_flutter/Package.swift +++ b/macos/stream_webrtc_flutter/Package.swift @@ -12,7 +12,7 @@ let package = Package( dependencies: [ .package(name: "FlutterFramework", path: "../FlutterFramework"), .package( - url: "https://github.com/GetStream/stream-video-swift-webrtc.git", exact: "145.9.0" + url: "https://github.com/GetStream/stream-video-swift-webrtc.git", exact: "145.16.0" ) ], targets: [ diff --git a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCEncryptionManager.m b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCEncryptionManager.m new file mode 100644 index 0000000000..f6249315f1 --- /dev/null +++ b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCEncryptionManager.m @@ -0,0 +1,537 @@ +#import "include/stream_webrtc_flutter/FlutterRTCEncryptionManager.h" + +/** Dart sends -1 when it wants native to infer audio vs video from RTP. */ +static const NSInteger kTrackTypeUnspecified = -1; + +/** + * Manager registry. + * + * The plugin is a singleton (`+[FlutterWebRTCPlugin sharedSingleton]`) and a + * category cannot add storage, so the registry lives here. Guarded by + * @synchronized because Dart calls arrive on the platform thread while + * `dispose` can run from teardown. + */ +static NSMutableDictionary* gHandles; + +static NSMutableDictionary* handleRegistry(void) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + gHandles = [NSMutableDictionary dictionary]; + }); + return gHandles; +} + +#pragma mark - Event serialization + +static NSDictionary* userKeyToMap(RTCEncryptionUserKey* key) { + return @{ + @"userId" : key.userId ?: @"", + @"keyIndex" : @(key.keyIndex), + @"fingerprint" : key.fingerprint ?: @"" + }; +} + +static NSDictionary* sharedKeyToMap(RTCEncryptionSharedKey* key) { + return @{ + @"keyIndex" : @(key.keyIndex), + @"fingerprint" : key.fingerprint ?: @"", + @"isActive" : @(key.isActive) + }; +} + +static NSDictionary* keyStateToMap(RTCEncryptionKeyState* keyState) { + NSMutableArray* perUserKeys = [NSMutableArray array]; + for (RTCEncryptionUserKey* key in keyState.perUserKeys) { + [perUserKeys addObject:userKeyToMap(key)]; + } + + NSMutableArray* sharedKeys = [NSMutableArray array]; + for (RTCEncryptionSharedKey* key in keyState.sharedKeys) { + [sharedKeys addObject:sharedKeyToMap(key)]; + } + + return @{@"perUserKeys" : perUserKeys, @"sharedKeys" : sharedKeys}; +} + +static NSArray* perfToList(NSArray* samples) { + NSMutableArray* list = [NSMutableArray array]; + for (RTCEncryptionTrackPerf* sample in samples) { + NSMutableDictionary* entry = [NSMutableDictionary dictionary]; + entry[@"userId"] = sample.userId ?: @""; + entry[@"trackType"] = @(sample.trackType); + if (sample.codec != nil) { + entry[@"codec"] = sample.codec; + } + entry[@"fps"] = @(sample.fps); + entry[@"maxCryptoMs"] = @(sample.maxCryptoMs); + [list addObject:entry]; + } + return list; +} + +static NSDictionary* eventToMap(RTCE2eeEvent* event) { + NSMutableDictionary* map = [NSMutableDictionary dictionary]; + map[@"type"] = @(event.type); + map[@"name"] = event.name ?: @""; + map[@"userId"] = event.userId ?: @""; + if (event.trackType != nil) { + map[@"trackType"] = event.trackType; + } + if (event.keyIndex != nil) { + map[@"keyIndex"] = event.keyIndex; + } + if (event.version != nil) { + map[@"version"] = event.version; + } + if (event.reason != nil) { + map[@"reason"] = event.reason; + } + if (event.keyState != nil) { + map[@"keyState"] = keyStateToMap(event.keyState); + } + if (event.encode != nil) { + map[@"encode"] = perfToList(event.encode); + } + if (event.decode != nil) { + map[@"decode"] = perfToList(event.decode); + } + return map; +} + +#pragma mark - Handle + +@implementation FlutterRTCEncryptionManagerHandle { + FlutterEventSink _eventSink; +} + +- (instancetype)initWithManager:(RTCEncryptionManager*)manager + eventChannel:(FlutterEventChannel*)eventChannel { + self = [super init]; + if (self) { + _manager = manager; + _eventChannel = eventChannel; + } + return self; +} + +- (FlutterError*)onListenWithArguments:(id)arguments eventSink:(FlutterEventSink)events { + _eventSink = events; + return nil; +} + +- (FlutterError*)onCancelWithArguments:(id)arguments { + _eventSink = nil; + return nil; +} + +- (void)encryptionManager:(RTCEncryptionManager*)manager didReceiveEvent:(RTCE2eeEvent*)event { + postEvent(_eventSink, eventToMap(event)); +} + +- (void)detach { + // Dropping the delegate first stops events racing the teardown. + _manager.delegate = nil; + [_eventChannel setStreamHandler:nil]; + _eventSink = nil; +} + +- (void)releaseNative { + [_manager dispose]; +} + +@end + +#pragma mark - Plugin category + +@implementation FlutterWebRTCPlugin (EncryptionManager) + +- (BOOL)handleEncryptionManagerMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { + NSString* method = call.method; + if (![method hasPrefix:@"encryptionManager"]) { + return NO; + } + + if ([@"encryptionManagerCreate" isEqualToString:method]) { + [self encryptionManagerCreate:call result:result]; + } else if ([@"encryptionManagerSetKey" isEqualToString:method]) { + [self encryptionManagerSetKey:call result:result]; + } else if ([@"encryptionManagerSetSharedKey" isEqualToString:method]) { + [self encryptionManagerSetSharedKey:call result:result]; + } else if ([@"encryptionManagerRemoveKey" isEqualToString:method]) { + [self encryptionManagerRemoveKey:call result:result]; + } else if ([@"encryptionManagerRemoveAllKeys" isEqualToString:method]) { + [self encryptionManagerRemoveAllKeys:call result:result]; + } else if ([@"encryptionManagerRemoveSharedKey" isEqualToString:method]) { + [self encryptionManagerRemoveSharedKey:call result:result]; + } else if ([@"encryptionManagerEncrypt" isEqualToString:method]) { + [self encryptionManagerEncrypt:call result:result]; + } else if ([@"encryptionManagerDecrypt" isEqualToString:method]) { + [self encryptionManagerDecrypt:call result:result]; + } else if ([@"encryptionManagerEnablePerformanceReporting" isEqualToString:method]) { + [self encryptionManagerEnablePerformanceReporting:call result:result]; + } else if ([@"encryptionManagerRequestKeyState" isEqualToString:method]) { + [self encryptionManagerRequestKeyState:call result:result]; + } else if ([@"encryptionManagerDispose" isEqualToString:method]) { + [self encryptionManagerDispose:call result:result]; + } else { + return NO; + } + + return YES; +} + +- (void)disposeAllEncryptionManagers { + NSArray* handles; + @synchronized(handleRegistry()) { + handles = handleRegistry().allValues; + [handleRegistry() removeAllObjects]; + } + for (FlutterRTCEncryptionManagerHandle* handle in handles) { + [handle detach]; + } + // Releasing joins the manager's frame-crypto worker, which would block the + // platform thread for as long as that worker takes to drain. + dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ + for (FlutterRTCEncryptionManagerHandle* handle in handles) { + [handle releaseNative]; + } + }); +} + +#pragma mark - Methods + +- (void)encryptionManagerCreate:(FlutterMethodCall*)call result:(FlutterResult)result { + NSDictionary* args = call.arguments; + NSString* userId = args[@"userId"]; + if (![userId isKindOfClass:[NSString class]] || userId.length == 0) { + result([FlutterError errorWithCode:@"encryptionManagerCreateFailed" + message:@"userId is required" + details:nil]); + return; + } + + NSNumber* algorithmValue = args[@"algorithm"]; + RTCEncryptionAlgorithm algorithm = algorithmValue.integerValue == RTCEncryptionAlgorithmAes256Gcm + ? RTCEncryptionAlgorithmAes256Gcm + : RTCEncryptionAlgorithmAes128Gcm; + + NSError* error = nil; + RTCEncryptionManager* manager = [RTCEncryptionManager createWithUserId:userId + algorithm:algorithm + error:&error]; + if (manager == nil) { + result([FlutterError errorWithCode:@"encryptionManagerCreateFailed" + message:error.localizedDescription ?: @"create failed" + details:nil]); + return; + } + + NSString* managerId = [[NSUUID UUID] UUIDString]; + FlutterEventChannel* eventChannel = [FlutterEventChannel + eventChannelWithName:[NSString stringWithFormat:@"FlutterWebRTC/e2ee/%@", managerId] + binaryMessenger:self.messenger]; + + FlutterRTCEncryptionManagerHandle* handle = + [[FlutterRTCEncryptionManagerHandle alloc] initWithManager:manager eventChannel:eventChannel]; + [eventChannel setStreamHandler:handle]; + manager.delegate = handle; + + @synchronized(handleRegistry()) { + handleRegistry()[managerId] = handle; + } + + result(@{@"managerId" : managerId}); +} + +- (void)encryptionManagerSetKey:(FlutterMethodCall*)call result:(FlutterResult)result { + RTCEncryptionManager* manager = [self requireManagerForCall:call result:result]; + if (manager == nil) { + return; + } + + NSDictionary* args = call.arguments; + NSString* userId = args[@"userId"]; + NSNumber* keyIndex = args[@"keyIndex"]; + FlutterStandardTypedData* rawKey = args[@"rawKey"]; + if (userId == nil || keyIndex == nil || rawKey == nil) { + [self failCall:call result:result message:@"userId, keyIndex and rawKey are required"]; + return; + } + + NSError* error = nil; + if (![manager setKey:userId keyIndex:keyIndex.intValue rawKey:rawKey.data error:&error]) { + [self failCall:call result:result message:error.localizedDescription ?: @"setKey failed"]; + return; + } + result(nil); +} + +- (void)encryptionManagerSetSharedKey:(FlutterMethodCall*)call result:(FlutterResult)result { + RTCEncryptionManager* manager = [self requireManagerForCall:call result:result]; + if (manager == nil) { + return; + } + + NSDictionary* args = call.arguments; + NSNumber* keyIndex = args[@"keyIndex"]; + FlutterStandardTypedData* rawKey = args[@"rawKey"]; + if (keyIndex == nil || rawKey == nil) { + [self failCall:call result:result message:@"keyIndex and rawKey are required"]; + return; + } + + NSError* error = nil; + if (![manager setSharedKey:keyIndex.intValue rawKey:rawKey.data error:&error]) { + [self failCall:call result:result message:error.localizedDescription ?: @"setSharedKey failed"]; + return; + } + result(nil); +} + +- (void)encryptionManagerRemoveKey:(FlutterMethodCall*)call result:(FlutterResult)result { + RTCEncryptionManager* manager = [self requireManagerForCall:call result:result]; + if (manager == nil) { + return; + } + + NSDictionary* args = call.arguments; + NSString* userId = args[@"userId"]; + NSNumber* keyIndex = args[@"keyIndex"]; + if (userId == nil || keyIndex == nil) { + [self failCall:call result:result message:@"userId and keyIndex are required"]; + return; + } + + NSError* error = nil; + if (![manager removeKey:userId keyIndex:keyIndex.intValue error:&error]) { + [self failCall:call result:result message:error.localizedDescription ?: @"removeKey failed"]; + return; + } + result(nil); +} + +- (void)encryptionManagerRemoveAllKeys:(FlutterMethodCall*)call result:(FlutterResult)result { + RTCEncryptionManager* manager = [self requireManagerForCall:call result:result]; + if (manager == nil) { + return; + } + + NSString* userId = call.arguments[@"userId"]; + if (userId == nil) { + [self failCall:call result:result message:@"userId is required"]; + return; + } + + NSError* error = nil; + if (![manager removeAllKeys:userId error:&error]) { + [self failCall:call + result:result + message:error.localizedDescription ?: @"removeAllKeys failed"]; + return; + } + result(nil); +} + +- (void)encryptionManagerRemoveSharedKey:(FlutterMethodCall*)call result:(FlutterResult)result { + RTCEncryptionManager* manager = [self requireManagerForCall:call result:result]; + if (manager == nil) { + return; + } + + NSNumber* keyIndex = call.arguments[@"keyIndex"]; + if (keyIndex == nil) { + [self failCall:call result:result message:@"keyIndex is required"]; + return; + } + + NSError* error = nil; + if (![manager removeSharedKey:keyIndex.intValue error:&error]) { + [self failCall:call + result:result + message:error.localizedDescription ?: @"removeSharedKey failed"]; + return; + } + result(nil); +} + +- (void)encryptionManagerEncrypt:(FlutterMethodCall*)call result:(FlutterResult)result { + RTCEncryptionManager* manager = [self requireManagerForCall:call result:result]; + if (manager == nil) { + return; + } + + RTCPeerConnection* peerConnection = [self requirePeerConnectionForCall:call result:result]; + if (peerConnection == nil) { + return; + } + + NSString* senderId = call.arguments[@"rtpSenderId"]; + if (senderId == nil) { + [self failCall:call result:result message:@"rtpSenderId is required"]; + return; + } + + RTCRtpSender* sender = [self getRtpSenderById:peerConnection Id:senderId]; + if (sender == nil) { + [self failCall:call + result:result + message:[NSString stringWithFormat:@"sender %@ not found", senderId]]; + return; + } + + NSString* codec = call.arguments[@"codec"]; + if (![codec isKindOfClass:[NSString class]]) { + codec = nil; + } + + NSError* error = nil; + if (![manager encrypt:sender codec:codec trackType:[self trackTypeForCall:call] error:&error]) { + [self failCall:call result:result message:error.localizedDescription ?: @"encrypt failed"]; + return; + } + result(nil); +} + +- (void)encryptionManagerDecrypt:(FlutterMethodCall*)call result:(FlutterResult)result { + RTCEncryptionManager* manager = [self requireManagerForCall:call result:result]; + if (manager == nil) { + return; + } + + RTCPeerConnection* peerConnection = [self requirePeerConnectionForCall:call result:result]; + if (peerConnection == nil) { + return; + } + + NSDictionary* args = call.arguments; + NSString* receiverId = args[@"rtpReceiverId"]; + NSString* userId = args[@"userId"]; + if (receiverId == nil || userId == nil || userId.length == 0) { + [self failCall:call result:result message:@"rtpReceiverId and userId are required"]; + return; + } + + RTCRtpReceiver* receiver = [self getRtpReceiverById:peerConnection Id:receiverId]; + if (receiver == nil) { + [self failCall:call + result:result + message:[NSString stringWithFormat:@"receiver %@ not found", receiverId]]; + return; + } + + NSError* error = nil; + if (![manager decrypt:receiver + userId:userId + trackType:[self trackTypeForCall:call] + error:&error]) { + [self failCall:call result:result message:error.localizedDescription ?: @"decrypt failed"]; + return; + } + result(nil); +} + +- (void)encryptionManagerEnablePerformanceReporting:(FlutterMethodCall*)call + result:(FlutterResult)result { + RTCEncryptionManager* manager = [self requireManagerForCall:call result:result]; + if (manager == nil) { + return; + } + + NSNumber* enabled = call.arguments[@"enabled"]; + NSError* error = nil; + if (![manager enablePerformanceReporting:enabled.boolValue error:&error]) { + [self failCall:call + result:result + message:error.localizedDescription ?: @"enablePerformanceReporting failed"]; + return; + } + result(nil); +} + +- (void)encryptionManagerRequestKeyState:(FlutterMethodCall*)call result:(FlutterResult)result { + RTCEncryptionManager* manager = [self requireManagerForCall:call result:result]; + if (manager == nil) { + return; + } + + NSError* error = nil; + if (![manager requestKeyState:&error]) { + [self failCall:call + result:result + message:error.localizedDescription ?: @"requestKeyState failed"]; + return; + } + result(nil); +} + +- (void)encryptionManagerDispose:(FlutterMethodCall*)call result:(FlutterResult)result { + NSString* managerId = call.arguments[@"managerId"]; + FlutterRTCEncryptionManagerHandle* handle = nil; + if (managerId != nil) { + @synchronized(handleRegistry()) { + handle = handleRegistry()[managerId]; + [handleRegistry() removeObjectForKey:managerId]; + } + } + [handle detach]; + if (handle != nil) { + // Off the platform thread: releasing joins the frame-crypto worker. + // Answering first is fine — the handle is already out of the registry. + dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ + [handle releaseNative]; + }); + } + // Disposing an unknown manager is not an error: Dart may retry teardown. + result(nil); +} + +#pragma mark - Helpers + +- (nullable RTCEncryptionManager*)requireManagerForCall:(FlutterMethodCall*)call + result:(FlutterResult)result { + NSString* managerId = call.arguments[@"managerId"]; + FlutterRTCEncryptionManagerHandle* handle = nil; + if (managerId != nil) { + @synchronized(handleRegistry()) { + handle = handleRegistry()[managerId]; + } + } + if (handle == nil) { + [self failCall:call + result:result + message:[NSString stringWithFormat:@"EncryptionManager %@ not found", managerId]]; + return nil; + } + return handle.manager; +} + +- (nullable RTCPeerConnection*)requirePeerConnectionForCall:(FlutterMethodCall*)call + result:(FlutterResult)result { + NSString* peerConnectionId = call.arguments[@"peerConnectionId"]; + RTCPeerConnection* peerConnection = + peerConnectionId == nil ? nil : self.peerConnections[peerConnectionId]; + if (peerConnection == nil) { + [self failCall:call + result:result + message:[NSString stringWithFormat:@"peerConnection %@ not found", peerConnectionId]]; + return nil; + } + return peerConnection; +} + +/** Maps Dart's `trackType` to a boxed enum, or nil to let RTP decide. */ +- (nullable NSNumber*)trackTypeForCall:(FlutterMethodCall*)call { + NSNumber* value = call.arguments[@"trackType"]; + if (value == nil || value.integerValue == kTrackTypeUnspecified) { + return nil; + } + return value; +} + +- (void)failCall:(FlutterMethodCall*)call result:(FlutterResult)result message:(NSString*)message { + result([FlutterError errorWithCode:[NSString stringWithFormat:@"%@Failed", call.method] + message:message + details:nil]); +} + +@end diff --git a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m index c5c06ede47..daea7eeda7 100644 --- a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m +++ b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m @@ -5,6 +5,7 @@ #import "include/stream_webrtc_flutter/FlutterDataPacketCryptor.h" #import "include/stream_webrtc_flutter/FlutterRTCDataChannel.h" #import "include/stream_webrtc_flutter/FlutterRTCDesktopCapturer.h" +#import "include/stream_webrtc_flutter/FlutterRTCEncryptionManager.h" #import "include/stream_webrtc_flutter/FlutterRTCMediaStream.h" #import "include/stream_webrtc_flutter/FlutterRTCPeerConnection.h" #import "include/stream_webrtc_flutter/FlutterRTCVideoRenderer.h" @@ -153,7 +154,6 @@ - (instancetype)initWithChannel:(FlutterMethodChannel*)channel registrar:(NSObject*)registrar messenger:(NSObject*)messenger withTextures:(NSObject*)textures { - self = [super init]; sharedSingleton = self; @@ -170,7 +170,6 @@ - (instancetype)initWithChannel:(FlutterMethodChannel*)channel _speakerOnButPreferBluetooth = NO; _eventChannel = eventChannel; _audioManager = AudioManager.sharedInstance; - } NSDictionary* fieldTrials = @{kRTCFieldTrialUseNWPathMonitor : kRTCFieldTrialEnabledValue}; @@ -193,6 +192,8 @@ - (instancetype)initWithChannel:(FlutterMethodChannel*)channel } - (void)detachFromEngineForRegistrar:(NSObject*)registrar { + [self disposeAllEncryptionManagers]; + for (RTCPeerConnection* peerConnection in _peerConnections.allValues) { for (RTCDataChannel* dataChannel in peerConnection.dataChannels) { dataChannel.eventSink = nil; @@ -1858,18 +1859,9 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { } result(nil); } else { - // Frame cryptor was deactivated alongside the iOS ambient-factory removal — - // it routed factory creation through the ambient ADM and Stream SDK does - // not use it. Reviving requires wiring a per-PC factoryId through every - // FlutterRTCFrameCryptor entry point. Until then frame-cryptor calls bubble - // through data-packet cryptor (which does not recognize them) and - // ultimately receive FlutterMethodNotImplemented. - - // if ([self handleFrameCryptorMethodCall:call result:result]) { - // return; - // } else { - // [self handleDataPacketCryptorMethodCall:call result:result]; - // } + if ([self handleEncryptionManagerMethodCall:call result:result]) { + return; + } [self handleDataPacketCryptorMethodCall:call result:result]; } diff --git a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCEncryptionManager.h b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCEncryptionManager.h new file mode 100644 index 0000000000..caa2da126c --- /dev/null +++ b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/FlutterRTCEncryptionManager.h @@ -0,0 +1,55 @@ +#import +#import + +#import "FlutterWebRTCPlugin.h" + +NS_ASSUME_NONNULL_BEGIN + +/// Dart-visible EncryptionManager: wraps native manager and its event channel +/// (`FlutterWebRTC/e2ee/`), created via `encryptionManagerCreate` and stored by +/// `managerId`. +@interface FlutterRTCEncryptionManagerHandle + : NSObject + +@property(nonatomic, strong, readonly) RTCEncryptionManager* manager; +@property(nonatomic, strong, readonly) FlutterEventChannel* eventChannel; + +- (instancetype)initWithManager:(RTCEncryptionManager*)manager + eventChannel:(FlutterEventChannel*)eventChannel NS_DESIGNATED_INITIALIZER; + +- (instancetype)init NS_UNAVAILABLE; + +/** + * Detaches the event channel. Platform thread only, and before + * `releaseNative` so no event races the teardown. + */ +- (void)detach; + +/** Releases the native manager. Blocks on its frame-crypto worker. */ +- (void)releaseNative; + +@end + +/** + * Common Stream implementation for AES-GCM end-to-end encryption, + * used across all Stream SDKs (JS, iOS, Android, Flutter). + * This is independent of per-call factories: the manager owns keys, + * not media. + */ +@interface FlutterWebRTCPlugin (EncryptionManager) + +/** + * Handles every `encryptionManager*` method. + * + * @return `YES` when `call` belonged to this bridge and `result` was already + * invoked, `NO` when the caller should keep dispatching. + */ +- (BOOL)handleEncryptionManagerMethodCall:(nonnull FlutterMethodCall*)call + result:(nonnull FlutterResult)result; + +/** Releases every manager, e.g. when the plugin detaches from the engine. */ +- (void)disposeAllEncryptionManagers; + +@end + +NS_ASSUME_NONNULL_END From 6ccbbfa5ec9a8a60a7b0cd35107591d9d9539d5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Braz=CC=87ewicz?= Date: Tue, 25 Aug 2026 09:24:03 +0200 Subject: [PATCH 2/3] fix --- lib/src/e2ee/encryption_manager.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/src/e2ee/encryption_manager.dart b/lib/src/e2ee/encryption_manager.dart index 92dfa6ee97..103b67d3dd 100644 --- a/lib/src/e2ee/encryption_manager.dart +++ b/lib/src/e2ee/encryption_manager.dart @@ -2,9 +2,10 @@ import 'dart:typed_data'; import 'package:webrtc_interface/webrtc_interface.dart'; +import 'encryption_types.dart'; + import '../native/encryption_manager_impl.dart' if (dart.library.js_interop) '../web/encryption_manager_impl.dart'; -import 'encryption_types.dart'; /// Attaches AES-GCM end-to-end encryption to RTP senders and receivers. /// From 147de44977a585f8588e045446a2b6b5d7122c47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Braz=CC=87ewicz?= Date: Tue, 25 Aug 2026 10:14:28 +0200 Subject: [PATCH 3/3] tweaks --- .../FlutterRTCEncryptionManager.m | 47 +++++++++++++++---- lib/src/web/encryption_manager_impl.dart | 8 +++- .../FlutterRTCEncryptionManager.m | 47 +++++++++++++++---- 3 files changed, 80 insertions(+), 22 deletions(-) diff --git a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCEncryptionManager.m b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCEncryptionManager.m index f6249315f1..b4c362ed8d 100644 --- a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCEncryptionManager.m +++ b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCEncryptionManager.m @@ -21,6 +21,21 @@ return gHandles; } +#pragma mark - Argument coercion + +/** + * Method-channel arguments are untrusted: Dart's `null` arrives as `NSNull`, + * which raises on `integerValue`/`boolValue`. Returns nil unless the value is + * a real number, so callers can reject or default without converting first. + */ +static NSNumber* _Nullable numberArg(id args, NSString* key) { + if (![args isKindOfClass:[NSDictionary class]]) { + return nil; + } + id value = ((NSDictionary*)args)[key]; + return [value isKindOfClass:[NSNumber class]] ? (NSNumber*)value : nil; +} + #pragma mark - Event serialization static NSDictionary* userKeyToMap(RTCEncryptionUserKey* key) { @@ -210,10 +225,17 @@ - (void)encryptionManagerCreate:(FlutterMethodCall*)call result:(FlutterResult)r return; } - NSNumber* algorithmValue = args[@"algorithm"]; - RTCEncryptionAlgorithm algorithm = algorithmValue.integerValue == RTCEncryptionAlgorithmAes256Gcm - ? RTCEncryptionAlgorithmAes256Gcm - : RTCEncryptionAlgorithmAes128Gcm; + // A missing algorithm keeps the AES-128 default; a malformed one is rejected + // rather than silently downgraded. + id algorithmValue = [args isKindOfClass:[NSDictionary class]] ? args[@"algorithm"] : nil; + if (algorithmValue != nil && ![algorithmValue isKindOfClass:[NSNumber class]]) { + [self failCall:call result:result message:@"algorithm must be a number"]; + return; + } + RTCEncryptionAlgorithm algorithm = + [algorithmValue integerValue] == RTCEncryptionAlgorithmAes256Gcm + ? RTCEncryptionAlgorithmAes256Gcm + : RTCEncryptionAlgorithmAes128Gcm; NSError* error = nil; RTCEncryptionManager* manager = [RTCEncryptionManager createWithUserId:userId @@ -251,7 +273,7 @@ - (void)encryptionManagerSetKey:(FlutterMethodCall*)call result:(FlutterResult)r NSDictionary* args = call.arguments; NSString* userId = args[@"userId"]; - NSNumber* keyIndex = args[@"keyIndex"]; + NSNumber* keyIndex = numberArg(args, @"keyIndex"); FlutterStandardTypedData* rawKey = args[@"rawKey"]; if (userId == nil || keyIndex == nil || rawKey == nil) { [self failCall:call result:result message:@"userId, keyIndex and rawKey are required"]; @@ -273,7 +295,7 @@ - (void)encryptionManagerSetSharedKey:(FlutterMethodCall*)call result:(FlutterRe } NSDictionary* args = call.arguments; - NSNumber* keyIndex = args[@"keyIndex"]; + NSNumber* keyIndex = numberArg(args, @"keyIndex"); FlutterStandardTypedData* rawKey = args[@"rawKey"]; if (keyIndex == nil || rawKey == nil) { [self failCall:call result:result message:@"keyIndex and rawKey are required"]; @@ -296,7 +318,7 @@ - (void)encryptionManagerRemoveKey:(FlutterMethodCall*)call result:(FlutterResul NSDictionary* args = call.arguments; NSString* userId = args[@"userId"]; - NSNumber* keyIndex = args[@"keyIndex"]; + NSNumber* keyIndex = numberArg(args, @"keyIndex"); if (userId == nil || keyIndex == nil) { [self failCall:call result:result message:@"userId and keyIndex are required"]; return; @@ -338,7 +360,7 @@ - (void)encryptionManagerRemoveSharedKey:(FlutterMethodCall*)call result:(Flutte return; } - NSNumber* keyIndex = call.arguments[@"keyIndex"]; + NSNumber* keyIndex = numberArg(call.arguments, @"keyIndex"); if (keyIndex == nil) { [self failCall:call result:result message:@"keyIndex is required"]; return; @@ -437,7 +459,12 @@ - (void)encryptionManagerEnablePerformanceReporting:(FlutterMethodCall*)call return; } - NSNumber* enabled = call.arguments[@"enabled"]; + NSNumber* enabled = numberArg(call.arguments, @"enabled"); + if (enabled == nil) { + [self failCall:call result:result message:@"enabled is required"]; + return; + } + NSError* error = nil; if (![manager enablePerformanceReporting:enabled.boolValue error:&error]) { [self failCall:call @@ -521,7 +548,7 @@ - (nullable RTCPeerConnection*)requirePeerConnectionForCall:(FlutterMethodCall*) /** Maps Dart's `trackType` to a boxed enum, or nil to let RTP decide. */ - (nullable NSNumber*)trackTypeForCall:(FlutterMethodCall*)call { - NSNumber* value = call.arguments[@"trackType"]; + NSNumber* value = numberArg(call.arguments, @"trackType"); if (value == nil || value.integerValue == kTrackTypeUnspecified) { return nil; } diff --git a/lib/src/web/encryption_manager_impl.dart b/lib/src/web/encryption_manager_impl.dart index ab32c1b33a..1ab11a3452 100644 --- a/lib/src/web/encryption_manager_impl.dart +++ b/lib/src/web/encryption_manager_impl.dart @@ -30,8 +30,10 @@ class EncryptionManagerWeb implements EncryptionManager { @override final EncryptionAlgorithm algorithm; + bool _disposed = false; + @override - bool get isDisposed => false; + bool get isDisposed => _disposed; @override Stream get events => const Stream.empty(); @@ -82,5 +84,7 @@ class EncryptionManagerWeb implements EncryptionManager { Future requestKeyState() => _unsupported(); @override - Future dispose() async {} + Future dispose() async { + _disposed = true; + } } diff --git a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCEncryptionManager.m b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCEncryptionManager.m index f6249315f1..b4c362ed8d 100644 --- a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCEncryptionManager.m +++ b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterRTCEncryptionManager.m @@ -21,6 +21,21 @@ return gHandles; } +#pragma mark - Argument coercion + +/** + * Method-channel arguments are untrusted: Dart's `null` arrives as `NSNull`, + * which raises on `integerValue`/`boolValue`. Returns nil unless the value is + * a real number, so callers can reject or default without converting first. + */ +static NSNumber* _Nullable numberArg(id args, NSString* key) { + if (![args isKindOfClass:[NSDictionary class]]) { + return nil; + } + id value = ((NSDictionary*)args)[key]; + return [value isKindOfClass:[NSNumber class]] ? (NSNumber*)value : nil; +} + #pragma mark - Event serialization static NSDictionary* userKeyToMap(RTCEncryptionUserKey* key) { @@ -210,10 +225,17 @@ - (void)encryptionManagerCreate:(FlutterMethodCall*)call result:(FlutterResult)r return; } - NSNumber* algorithmValue = args[@"algorithm"]; - RTCEncryptionAlgorithm algorithm = algorithmValue.integerValue == RTCEncryptionAlgorithmAes256Gcm - ? RTCEncryptionAlgorithmAes256Gcm - : RTCEncryptionAlgorithmAes128Gcm; + // A missing algorithm keeps the AES-128 default; a malformed one is rejected + // rather than silently downgraded. + id algorithmValue = [args isKindOfClass:[NSDictionary class]] ? args[@"algorithm"] : nil; + if (algorithmValue != nil && ![algorithmValue isKindOfClass:[NSNumber class]]) { + [self failCall:call result:result message:@"algorithm must be a number"]; + return; + } + RTCEncryptionAlgorithm algorithm = + [algorithmValue integerValue] == RTCEncryptionAlgorithmAes256Gcm + ? RTCEncryptionAlgorithmAes256Gcm + : RTCEncryptionAlgorithmAes128Gcm; NSError* error = nil; RTCEncryptionManager* manager = [RTCEncryptionManager createWithUserId:userId @@ -251,7 +273,7 @@ - (void)encryptionManagerSetKey:(FlutterMethodCall*)call result:(FlutterResult)r NSDictionary* args = call.arguments; NSString* userId = args[@"userId"]; - NSNumber* keyIndex = args[@"keyIndex"]; + NSNumber* keyIndex = numberArg(args, @"keyIndex"); FlutterStandardTypedData* rawKey = args[@"rawKey"]; if (userId == nil || keyIndex == nil || rawKey == nil) { [self failCall:call result:result message:@"userId, keyIndex and rawKey are required"]; @@ -273,7 +295,7 @@ - (void)encryptionManagerSetSharedKey:(FlutterMethodCall*)call result:(FlutterRe } NSDictionary* args = call.arguments; - NSNumber* keyIndex = args[@"keyIndex"]; + NSNumber* keyIndex = numberArg(args, @"keyIndex"); FlutterStandardTypedData* rawKey = args[@"rawKey"]; if (keyIndex == nil || rawKey == nil) { [self failCall:call result:result message:@"keyIndex and rawKey are required"]; @@ -296,7 +318,7 @@ - (void)encryptionManagerRemoveKey:(FlutterMethodCall*)call result:(FlutterResul NSDictionary* args = call.arguments; NSString* userId = args[@"userId"]; - NSNumber* keyIndex = args[@"keyIndex"]; + NSNumber* keyIndex = numberArg(args, @"keyIndex"); if (userId == nil || keyIndex == nil) { [self failCall:call result:result message:@"userId and keyIndex are required"]; return; @@ -338,7 +360,7 @@ - (void)encryptionManagerRemoveSharedKey:(FlutterMethodCall*)call result:(Flutte return; } - NSNumber* keyIndex = call.arguments[@"keyIndex"]; + NSNumber* keyIndex = numberArg(call.arguments, @"keyIndex"); if (keyIndex == nil) { [self failCall:call result:result message:@"keyIndex is required"]; return; @@ -437,7 +459,12 @@ - (void)encryptionManagerEnablePerformanceReporting:(FlutterMethodCall*)call return; } - NSNumber* enabled = call.arguments[@"enabled"]; + NSNumber* enabled = numberArg(call.arguments, @"enabled"); + if (enabled == nil) { + [self failCall:call result:result message:@"enabled is required"]; + return; + } + NSError* error = nil; if (![manager enablePerformanceReporting:enabled.boolValue error:&error]) { [self failCall:call @@ -521,7 +548,7 @@ - (nullable RTCPeerConnection*)requirePeerConnectionForCall:(FlutterMethodCall*) /** Maps Dart's `trackType` to a boxed enum, or nil to let RTP decide. */ - (nullable NSNumber*)trackTypeForCall:(FlutterMethodCall*)call { - NSNumber* value = call.arguments[@"trackType"]; + NSNumber* value = numberArg(call.arguments, @"trackType"); if (value == nil || value.integerValue == kTrackTypeUnspecified) { return nil; }