diff --git a/dogfooding/lib/app/app_content.dart b/dogfooding/lib/app/app_content.dart index 3904fd011..decb8e1e5 100644 --- a/dogfooding/lib/app/app_content.dart +++ b/dogfooding/lib/app/app_content.dart @@ -15,6 +15,7 @@ import '../di/injector.dart'; import '../router/router.dart'; import '../router/routes.dart'; import '../theme/app_palette.dart'; +import '../utils/call_lookup.dart'; import '../utils/consts.dart'; import 'custom_video_localizations.dart'; import 'firebase_messaging_handler.dart'; @@ -114,6 +115,7 @@ class _StreamDogFoodingAppContentState call: call, connectOptions: null, effectsManager: null, + encryptionKey: null, ); _router.push(CallRoute($extra: extra).location, extra: extra); @@ -140,6 +142,7 @@ class _StreamDogFoodingAppContentState call: callToJoin, connectOptions: null, effectsManager: null, + encryptionKey: null, ); _router.push(CallRoute($extra: extra).location, extra: extra); @@ -156,6 +159,7 @@ class _StreamDogFoodingAppContentState call: call, connectOptions: null, effectsManager: null, + encryptionKey: null, ); _router.push(CallRoute($extra: extra).location, extra: extra); @@ -226,10 +230,25 @@ class _StreamDogFoodingAppContentState try { final streamVideo = locator.get(); final call = streamVideo.makeCall(callType: kCallType, id: callId); + final encryptionKey = uri.queryParameters['encryption_key']; + + // A link can point at a call that was never created β€” the lobby creates + // it, with the encryption mode chosen there. + final lookup = await lookupCallExists(call); + if (lookup is Failure) { + debugPrint('Error looking up call $callId: ${lookup.error}'); + return; + } - await call.getOrCreate(); - - await _router.push(LobbyRoute($extra: call).location, extra: call); + final extra = ( + call: call, + callExists: (lookup as Success).data, + encryptionKey: encryptionKey, + ); + await _router.push( + LobbyRoute($extra: extra).location, + extra: extra, + ); } catch (e, stk) { debugPrint('Error joining or creating call: $e'); debugPrint(stk.toString()); diff --git a/dogfooding/lib/core/model/environment.dart b/dogfooding/lib/core/model/environment.dart index 28acf2570..a350e14ec 100644 --- a/dogfooding/lib/core/model/environment.dart +++ b/dogfooding/lib/core/model/environment.dart @@ -73,18 +73,29 @@ enum Environment { /// Whether this is a Pronto environment. bool get isPronto => envName == 'pronto'; - String? getJoinUrl({required String callId, String? callType}) { - switch (this) { - case Environment.pronto: - case Environment.prontoStaging: - case Environment.staging: - return '${baseUrls.first}/join/$callId?type=${callType ?? 'default'}'; - case Environment.demo: - return '${baseUrls.first}/video/demos/join/$callId?type=${callType ?? 'default'}'; - case Environment.livestream: - return '${baseUrls.first}/?id=$callId&type=${callType ?? 'livestream'}'; - case Environment.custom: - return null; + /// The URL that joins [callId] on this environment, or null when it has no + /// public join page. + /// + /// [encryptionKey] is the shared passphrase, appended as `encryption_key`. + String? getJoinUrl({ + required String callId, + String? callType, + String? encryptionKey, + }) { + final url = switch (this) { + Environment.pronto || Environment.prontoStaging || Environment.staging => + '${baseUrls.first}/join/$callId?type=${callType ?? 'default'}', + Environment.demo => + '${baseUrls.first}/video/demos/join/$callId?type=${callType ?? 'default'}', + Environment.livestream => + '${baseUrls.first}/?id=$callId&type=${callType ?? 'livestream'}', + Environment.custom => null, + }; + + if (url == null || encryptionKey == null || encryptionKey.isEmpty) { + return url; } + + return '$url&encryption_key=${Uri.encodeQueryComponent(encryptionKey)}'; } } diff --git a/dogfooding/lib/di/injector.dart b/dogfooding/lib/di/injector.dart index 2e35c9c77..958d2e055 100644 --- a/dogfooding/lib/di/injector.dart +++ b/dogfooding/lib/di/injector.dart @@ -16,6 +16,7 @@ import '../core/repos/token_service.dart'; import '../core/repos/user_auth_repository.dart'; import '../core/repos/user_chat_repository.dart'; import '../log_config.dart'; +import '../utils/ringing_encryption.dart'; GetIt locator = GetIt.instance; @@ -164,6 +165,9 @@ StreamVideo _initStreamVideo( logPriority: Priority.debug, keepConnectionsAliveWhenInBackground: true, audioProcessor: NoiseCancellationAudioProcessor(), + defaultCallPreferences: DefaultCallPreferences( + encryptionKeyResolver: resolveRingingEncryptionKey, + ), ), pushNotificationManagerProvider: StreamVideoPushNotificationManager.create( iosPushProvider: const StreamVideoPushProvider.apn(name: 'flutter-apn'), diff --git a/dogfooding/lib/router/routes.dart b/dogfooding/lib/router/routes.dart index 8655df502..d192c9f05 100644 --- a/dogfooding/lib/router/routes.dart +++ b/dogfooding/lib/router/routes.dart @@ -36,22 +36,33 @@ class LoginRoute extends GoRouteData with $LoginRoute { class LobbyRoute extends GoRouteData with $LobbyRoute { const LobbyRoute({required this.$extra}); - final Call $extra; + final ({Call call, bool callExists, String? encryptionKey}) $extra; @override Widget build(BuildContext context, GoRouterState state) { return LobbyScreen( - call: $extra, - onJoinCallPressed: (connectOptions, effectsManager) { - // Navigate to the call screen. - CallRoute( - $extra: ( - call: $extra, - connectOptions: connectOptions, - effectsManager: effectsManager, - ), - ).replace(context); - }, + call: $extra.call, + callExists: $extra.callExists, + initialEncryptionKey: $extra.encryptionKey, + onJoinCallPressed: + ({ + required call, + required connectOptions, + required effectsManager, + encryptionKey, + }) { + // Navigate to the call screen. + CallRoute( + $extra: ( + call: call, + connectOptions: connectOptions, + effectsManager: effectsManager, + // The passphrase the lobby settled on, so the call screen can + // put it in the invite it offers. + encryptionKey: encryptionKey, + ), + ).replace(context); + }, ); } } @@ -78,6 +89,7 @@ class CallRoute extends GoRouteData with $CallRoute { Call call, CallConnectOptions? connectOptions, StreamVideoEffectsManager? effectsManager, + String? encryptionKey, }) $extra; @@ -87,6 +99,7 @@ class CallRoute extends GoRouteData with $CallRoute { call: $extra.call, connectOptions: $extra.connectOptions, videoEffectsManager: $extra.effectsManager, + encryptionKey: $extra.encryptionKey, ); } } diff --git a/dogfooding/lib/router/routes.g.dart b/dogfooding/lib/router/routes.g.dart index 6f5fb593f..ec50f86d6 100644 --- a/dogfooding/lib/router/routes.g.dart +++ b/dogfooding/lib/router/routes.g.dart @@ -72,8 +72,10 @@ RouteBase get $lobbyRoute => GoRouteData.$route( ); mixin $LobbyRoute on GoRouteData { - static LobbyRoute _fromState(GoRouterState state) => - LobbyRoute($extra: state.extra as Call); + static LobbyRoute _fromState(GoRouterState state) => LobbyRoute( + $extra: + state.extra as ({Call call, bool callExists, String? encryptionKey}), + ); LobbyRoute get _self => this as LobbyRoute; @@ -141,6 +143,7 @@ mixin $CallRoute on GoRouteData { Call call, CallConnectOptions? connectOptions, StreamVideoEffectsManager? effectsManager, + String? encryptionKey, }), ); diff --git a/dogfooding/lib/screens/call_screen.dart b/dogfooding/lib/screens/call_screen.dart index 06443ae83..0b3d3534c 100644 --- a/dogfooding/lib/screens/call_screen.dart +++ b/dogfooding/lib/screens/call_screen.dart @@ -21,6 +21,7 @@ import '../utils/feedback_dialog.dart'; import '../widgets/badged_call_option.dart'; import '../widgets/call_duration_title.dart'; import '../widgets/closed_captions_widget.dart'; +import '../widgets/e2ee_key_notification.dart'; import '../widgets/settings_menu/settings_menu.dart'; import '../widgets/share_call_card.dart'; @@ -32,18 +33,24 @@ class CallScreen extends StatefulWidget { required this.call, this.connectOptions, this.videoEffectsManager, + this.encryptionKey, }); final Call call; final CallConnectOptions? connectOptions; final StreamVideoEffectsManager? videoEffectsManager; + /// The passphrase [call]'s shared key was derived from. + final String? encryptionKey; + @override State createState() => _CallScreenState(); } class _CallScreenState extends State { late final _userChatRepo = locator.get(); + + late String? _encryptionKey = widget.encryptionKey; late final _videoEffectsManager = widget.videoEffectsManager ?? StreamVideoEffectsManager(widget.call); @@ -201,6 +208,14 @@ class _CallScreenState extends State { ClosedCaptionsWidget(call: call), ], ), + Align( + alignment: Alignment.bottomCenter, + child: E2eeKeyNotification( + call: call, + onKeyApplied: (key) => + setState(() => _encryptionKey = key), + ), + ), if (_moreMenuVisible) ...[ GestureDetector( onTap: () => setState(() => _moreMenuVisible = false), @@ -244,7 +259,10 @@ class _CallScreenState extends State { call: call, selector: (state) => state.otherParticipants.isEmpty, builder: (context, isEmpty) => isEmpty - ? ShareCallWelcomeCard(callId: call.id) + ? ShareCallWelcomeCard( + call: call, + encryptionKey: _encryptionKey, + ) : const SizedBox.shrink(), ), ), diff --git a/dogfooding/lib/screens/home_screen.dart b/dogfooding/lib/screens/home_screen.dart index 56db28da7..ce13d222a 100644 --- a/dogfooding/lib/screens/home_screen.dart +++ b/dogfooding/lib/screens/home_screen.dart @@ -17,6 +17,7 @@ import '../di/injector.dart'; import '../router/routes.dart'; import '../theme/app_palette.dart'; import '../utils/assets.dart'; +import '../utils/call_lookup.dart'; import '../utils/consts.dart'; import '../utils/loading_dialog.dart'; import '../widgets/environment_switcher.dart'; @@ -39,6 +40,10 @@ class _HomeScreenState extends State { late Call _call; + /// A shared passphrase that came in with a scanned or followed invite, and + /// the call id it was for. + ({String callId, String key})? _invitedEncryptionKey; + @override void initState() { if (CurrentPlatform.isMobile || CurrentPlatform.isWeb) { @@ -78,11 +83,13 @@ class _HomeScreenState extends State { var callId = _callIdController.text; // Always generate a new call id for ringing - if (callId.isEmpty || memberIds.isNotEmpty) { + final generatedCallId = callId.isEmpty || memberIds.isNotEmpty; + if (generatedCallId) { callId = generateAlphanumericString(12); } unawaited(showLoadingIndicator(context)); + _call = _streamVideo.makeCall( callType: kCallType, id: callId, @@ -100,36 +107,57 @@ class _HomeScreenState extends State { final isRinging = memberIds.isNotEmpty; try { - final result = await _call.getOrCreate( - memberIds: memberIds, - ringing: isRinging, - video: true, - ); + if (isRinging) { + final result = await _call.getOrCreate( + memberIds: memberIds, + ringing: true, + video: true, + ); - result.fold( - success: (success) { - if (mounted) { - if (isRinging) { - CallRoute( - $extra: ( - call: _call, - connectOptions: null, - effectsManager: null, - ), - ).push(context); - } else { - LobbyRoute($extra: _call).push(context); - } - } - }, - failure: (failure) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - duration: const Duration(seconds: 20), - content: Text('Error: ${failure.error.message}'), - ), - ); - }, + if (!mounted) return; + + result.fold( + success: (_) => unawaited( + CallRoute( + $extra: ( + call: _call, + connectOptions: null, + effectsManager: null, + // Ringing has no lobby to pick a key up in. + encryptionKey: null, + ), + ).push(context), + ), + failure: (failure) => _showError(failure.error.message), + ); + + return; + } + + // Creation is left to the lobby, because that is where the encryption + // mode is chosen and the mode is fixed at creation. + var callExists = false; + if (!generatedCallId) { + final lookup = await lookupCallExists(_call); + if (lookup is Failure) { + if (mounted) _showError(lookup.error.message); + return; + } + + callExists = (lookup as Success).data; + } + + if (!mounted) return; + + final invited = _invitedEncryptionKey; + unawaited( + LobbyRoute( + $extra: ( + call: _call, + callExists: callExists, + encryptionKey: invited?.callId == callId ? invited?.key : null, + ), + ).push(context), ); } catch (e, stk) { debugPrint('Error joining or creating call: $e'); @@ -141,6 +169,15 @@ class _HomeScreenState extends State { } } + void _showError(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + duration: const Duration(seconds: 20), + content: Text('Error: $message'), + ), + ); + } + Future _directCall(BuildContext context) async { final controller = TextEditingController(); final theme = Theme.of(context); @@ -291,6 +328,8 @@ class _HomeScreenState extends State { _JoinForm( callIdController: _callIdController, onJoinPressed: _getOrCreateCall, + onEncryptionKeyScanned: (invited) => + _invitedEncryptionKey = invited, onLogoutPressed: _userAuthController.logout, currentEnvironment: _appPreferences.environment, ), @@ -319,12 +358,15 @@ class _JoinForm extends StatelessWidget { const _JoinForm({ required this.callIdController, required this.onJoinPressed, + required this.onEncryptionKeyScanned, required this.onLogoutPressed, required this.currentEnvironment, }); final TextEditingController callIdController; final VoidCallback onJoinPressed; + + final ValueChanged<({String callId, String key})?> onEncryptionKeyScanned; final VoidCallback onLogoutPressed; final Environment currentEnvironment; @@ -480,11 +522,19 @@ class _JoinForm extends StatelessWidget { final callId = callPathId ?? callParameterId; - if (callId != null) { - callIdController.value = TextEditingValue( - text: callId, - selection: TextSelection.collapsed(offset: callId.length), - ); - } + if (callId == null) return; + + callIdController.value = TextEditingValue( + text: callId, + selection: TextSelection.collapsed(offset: callId.length), + ); + + // An encrypted call's invite carries the shared key. + final encryptionKey = uri.queryParameters['encryption_key']; + onEncryptionKeyScanned( + encryptionKey == null || encryptionKey.isEmpty + ? null + : (callId: callId, key: encryptionKey), + ); } } diff --git a/dogfooding/lib/screens/lobby_screen.dart b/dogfooding/lib/screens/lobby_screen.dart index 5c417db8e..ea6078109 100644 --- a/dogfooding/lib/screens/lobby_screen.dart +++ b/dogfooding/lib/screens/lobby_screen.dart @@ -10,19 +10,49 @@ import 'package:stream_video_flutter/stream_video_flutter.dart'; import '../app/user_auth_controller.dart'; import '../di/injector.dart'; import '../utils/assets.dart'; +import '../utils/call_encryption.dart'; +import '../utils/e2ee.dart'; +import '../utils/random_words.dart'; +import '../widgets/lobby_encryption.dart'; import '../widgets/stream_button.dart'; +/// Hands the call over to the call screen once the lobby is done with it. +/// +/// [encryptionKey] is the passphrase the call's shared key was derived from, +/// null for a call that is not encrypted or whose key arrived as raw bytes. It +/// travels with the call because the key itself cannot: the encryption manager +/// takes the derived bytes and never gives them back, so the passphrase has to +/// be carried by whoever wants to show it again. +typedef OnJoinCallPressed = + void Function({ + required Call call, + required CallConnectOptions connectOptions, + required StreamVideoEffectsManager effectsManager, + String? encryptionKey, + }); + class LobbyScreen extends StatefulWidget { const LobbyScreen({ super.key, required this.onJoinCallPressed, required this.call, + this.callExists = true, + this.initialEncryptionKey, }); - final void Function(CallConnectOptions, StreamVideoEffectsManager) - onJoinCallPressed; + final OnJoinCallPressed onJoinCallPressed; final Call call; + /// A shared passphrase that arrived with an invite β€” a scanned QR code or a + /// followed link. + final String? initialEncryptionKey; + + /// Whether [call] has already been created on the backend. + /// + /// When false this screen owns its creation, which happens on the way to + /// joining. + final bool callExists; + @override State createState() => _LobbyScreenState(); } @@ -42,13 +72,48 @@ class _LobbyScreenState extends State { final _userAuthController = locator.get(); late StreamVideoEffectsManager _videoEffectsManager; + /// The call about to be joined. + late final Call _call; + + /// Whether to create the call encrypted. Only meaningful until the call + /// exists, after which the call itself is the answer. + bool _encryptionEnabled = false; + + /// The shared passphrase, empty when encryption is off. + String _encryptionKey = ''; + final _encryptionKeyController = TextEditingController(); + + /// Whether the call is being created; both the switch and the join button + /// are inert meanwhile. + bool _creatingCall = false; + + /// Whether the call has been created from this screen. + bool _created = false; + + /// Whether the call exists: either it already did, or this screen made it. + bool get _callExists => widget.callExists || _created; + + /// Set once the call has been handed to the call screen, which owns the + /// manager from then on. + bool _joining = false; + bool _hasMicrophonePermission = false; bool _hasCameraPermission = false; @override void initState() { super.initState(); - _videoEffectsManager = StreamVideoEffectsManager(widget.call); + _call = widget.call; + _videoEffectsManager = StreamVideoEffectsManager(_call); + + // If an invite includes a key, the call should be encrypted and the user doesn't need to input anything. + // For new calls, an invite key will also trigger encrypted call creation. + final invitedKey = widget.initialEncryptionKey; + if (invitedKey != null && invitedKey.isNotEmpty) { + _encryptionEnabled = true; + _setEncryptionKey(invitedKey); + } + _deviceChangeSubscription = _deviceNotifier.onDeviceChange.listen( _handleDeviceChange, ); @@ -61,7 +126,25 @@ class _LobbyScreenState extends State { ); } - void joinCallPressed() { + Future joinCallPressed() async { + if (_creatingCall) return; + + // Creation is deferred to here so the switch above stays live for as long + // as it means anything: the encryption mode is fixed at creation, and this + // is the last moment before it is. + if (!_callExists) { + final created = await _createCall(); + if (!created || !mounted) return; + } + + // The manager has to be attached before any peer connection exists, and + // the join happens on the next screen β€” so this is the last moment. + final isEncrypted = isCallEncrypted(_call.state.value.settings); + if (isEncrypted && _encryptionKey.isNotEmpty) { + final attached = await _attachE2EE(); + if (!attached || !mounted) return; + } + var options = const CallConnectOptions(); final cameraTrack = _cameraTrack; @@ -82,7 +165,91 @@ class _LobbyScreenState extends State { options = options.copyWith(videoInputDevice: _selectedVideoInputDevice); } - widget.onJoinCallPressed(options, _videoEffectsManager); + _joining = true; + widget.onJoinCallPressed( + call: _call, + connectOptions: options, + effectsManager: _videoEffectsManager, + encryptionKey: isEncrypted && _encryptionKey.isNotEmpty + ? _encryptionKey + : null, + ); + } + + /// Derives the shared key and attaches a manager to [_call]. + Future _attachE2EE() async { + if (!EncryptionManager.isSupported) { + _showError('End-to-end encryption is not available on this platform.'); + return false; + } + + try { + final keyBytes = await deriveKeyFromPassphrase(_encryptionKey); + final e2ee = EncryptionManager.create( + userId: _userAuthController.currentUser!.id, + ); + + await e2ee.setSharedKey(kE2EESharedKeyIndex, keyBytes); + await _call.setE2EEManager(e2ee); + return true; + } catch (e, stk) { + debugPrint('Failed to enable E2EE: $e\n$stk'); + _showError('Could not enable encryption: $e'); + return false; + } + } + + Future _createCall() async { + setState(() => _creatingCall = true); + + try { + final result = await _call.getOrCreate( + video: true, + encryption: _encryptionEnabled + ? const StreamEncryptionSettings(mode: StreamEncryptionMode.autoOn) + : null, + ); + + if (result is Failure) { + _showError('Could not create the call: ${result.error.message}'); + return false; + } + + _created = true; + return true; + } catch (e) { + _showError('Could not create the call: $e'); + return false; + } finally { + if (mounted) setState(() => _creatingCall = false); + } + } + + /// Records the encryption mode to create the call with. + void _toggleEncryption(bool enabled) { + setState(() { + _encryptionEnabled = enabled; + _setEncryptionKey( + enabled + ? (_encryptionKey.isNotEmpty ? _encryptionKey : getRandomWords()) + : '', + ); + }); + } + + void _setEncryptionKey(String key) { + _encryptionKey = key; + _encryptionKeyController.text = key; + } + + void _showError(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + duration: const Duration(seconds: 6), + content: Text(message), + ), + ); } @override @@ -93,6 +260,10 @@ class _LobbyScreenState extends State { _cameraTrack = null; _microphoneTrack = null; _deviceChangeSubscription?.cancel(); + _encryptionKeyController.dispose(); + + if (!_joining) unawaited(_call.clearE2EEManager()); + super.dispose(); } @@ -153,18 +324,9 @@ class _LobbyScreenState extends State { if (!mounted) return; - _selectedVideoInputDevice = result; - - if (_selectedVideoInputDevice != null) { - _cameraTrack = await _cameraTrack?.selectVideoInput( - _selectedVideoInputDevice!, - [], - ); - } else { - _cameraTrack = await _cameraTrack?.recreate([]); - } - - setState(() {}); + setState(() { + _selectedVideoInputDevice = result; + }); } @override @@ -212,8 +374,11 @@ class _LobbyScreenState extends State { ), const SizedBox(height: 16), StreamLobbyVideo( - key: ValueKey(_cameraTrack), - call: widget.call, + // Keyed on the selected camera, since the preview owns the + // track it renders and has no way to be handed a different + // one β€” remounting is how the device change reaches it. + key: ValueKey(_selectedVideoInputDevice?.id), + call: _call, initialCameraDevice: _selectedVideoInputDevice, onMicrophoneTrackSet: (track) => _microphoneTrack = track, onCameraTrackSet: (track) { @@ -330,6 +495,24 @@ class _LobbyScreenState extends State { ], ), + const SizedBox(height: 24), + LobbyEncryption( + call: _call, + callExists: _callExists, + encryptionEnabled: _encryptionEnabled, + encryptionKey: _encryptionKey, + busy: _creatingCall, + keyController: _encryptionKeyController, + onEncryptionToggled: _toggleEncryption, + onEncryptionKeyChanged: (value) { + final next = value.trim(); + final wasEmpty = _encryptionKey.isEmpty; + _encryptionKey = next; + if (wasEmpty != next.isEmpty) setState(() {}); + }, + onGenerateKey: () => + setState(() => _setEncryptionKey(getRandomWords())), + ), const SizedBox(height: 24), Container( constraints: const BoxConstraints(maxWidth: 360), @@ -360,9 +543,44 @@ class _LobbyScreenState extends State { ], ), const SizedBox(height: 16), - StreamButton.active( - label: 'Start a test call', - onPressed: joinCallPressed, + // An `auto-on` call requires every participant to + // encrypt, so the server rejects a join without a + // key. + PartialCallStateBuilder( + call: _call, + selector: (state) => + isCallEncrypted(state.settings), + builder: (context, isEncrypted) { + final willBeEncrypted = _callExists + ? isEncrypted + : _encryptionEnabled; + final needsKey = + willBeEncrypted && _encryptionKey.isEmpty; + + return Column( + children: [ + if (needsKey) + Padding( + padding: const EdgeInsets.only( + bottom: 8, + ), + child: Text( + 'Enter the shared encryption key to join', + textAlign: TextAlign.center, + style: textTheme.footnote.copyWith( + color: colorTheme.textLowEmphasis, + ), + ), + ), + StreamButton.active( + label: 'Start a test call', + onPressed: needsKey || _creatingCall + ? null + : joinCallPressed, + ), + ], + ); + }, ), ], ), diff --git a/dogfooding/lib/utils/call_encryption.dart b/dogfooding/lib/utils/call_encryption.dart new file mode 100644 index 000000000..5e1f0a47f --- /dev/null +++ b/dogfooding/lib/utils/call_encryption.dart @@ -0,0 +1,6 @@ +// πŸ“¦ Package imports: +import 'package:stream_video_flutter/stream_video_flutter.dart'; + +/// Whether the call these settings describe is end-to-end encrypted. +bool isCallEncrypted(CallSettings settings) => + settings.encryption.mode == StreamEncryptionMode.autoOn; diff --git a/dogfooding/lib/utils/call_lookup.dart b/dogfooding/lib/utils/call_lookup.dart new file mode 100644 index 000000000..6d7d1c639 --- /dev/null +++ b/dogfooding/lib/utils/call_lookup.dart @@ -0,0 +1,32 @@ +// πŸ“¦ Package imports: +import 'package:stream_video_flutter/stream_video_flutter.dart'; + +/// Whether [call] already exists on the backend. +/// +/// The lobby needs this before it offers anything: encryption is fixed when a +/// call is created, so the mode is only a choice for a call that does not +/// exist yet. For one that does, it is already decided and the lobby's job is +/// to say what it is. +/// +/// A [Failure] means the lookup never got an answer. Callers must not read +/// that as "does not exist": creating over a call that is already running +/// would replace its settings β€” including its encryption mode β€” for everyone +/// already in it. +Future> lookupCallExists(Call call) async { + final lookup = await call.get(); + + return lookup.fold( + success: (_) => const Result.success(true), + failure: (failure) => + _isNotFound(failure.error) ? const Result.success(false) : failure, + ); +} + +/// The coordinator answers a lookup for a call that was never created with a +/// 404. Any other status is a real failure and says nothing about existence. +bool _isNotFound(VideoError error) { + if (error is! VideoErrorWithCause) return false; + + final cause = error.cause; + return cause is ApiException && cause.code == 404; +} diff --git a/dogfooding/lib/utils/consts.dart b/dogfooding/lib/utils/consts.dart index a7b74ed7c..828a305b6 100644 --- a/dogfooding/lib/utils/consts.dart +++ b/dogfooding/lib/utils/consts.dart @@ -6,6 +6,7 @@ final StreamCallType kCallType = StreamCallType.defaultType(); const String kMessageChannelType = 'videocall'; const String kAppName = 'Stream Dogfooding'; const double kMaxWidthRegularScreen = 500; + bool get kIsProd => switch (appFlavor) { 'dev' => false, 'beta' => false, diff --git a/dogfooding/lib/utils/e2ee.dart b/dogfooding/lib/utils/e2ee.dart new file mode 100644 index 000000000..efcd1abc6 --- /dev/null +++ b/dogfooding/lib/utils/e2ee.dart @@ -0,0 +1,71 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:cryptography/cryptography.dart'; + +/// The single key index this app uses for its shared E2EE key. +/// +/// Every participant derives the key from the same passphrase, so they have to +/// agree on the index too: a frame carries the index it was encrypted with, +/// and a receiver looking elsewhere fails every decrypt. +const int kE2EESharedKeyIndex = 0; + +/// Salt this app derives its shared key with. +/// +/// The salt and the iteration count are a contract between *participants*, not +/// between SDKs: change either and peers on the old build derive a different +/// key from the same passphrase, and nothing decrypts. +const streamE2eePassphraseSalt = 'stream-e2ee'; + +/// PBKDF2 iteration count this app derives its shared key with. +/// +/// See [streamE2eePassphraseSalt] on why this cannot change unilaterally. +const streamE2eePassphraseIterations = 100000; + +/// Derives an AES key from a human-typed [passphrase]. +/// +/// PBKDF2-HMAC-SHA256 with [streamE2eePassphraseSalt] and +/// [streamE2eePassphraseIterations]. +/// +/// [bits] must match the manager's algorithm: 128 for AES-128 (the default) +/// and 256 for AES-256. +/// +/// ```dart +/// final keyBytes = await deriveKeyFromPassphrase('noun-rover-waitress'); +/// await e2ee.setSharedKey(kE2EESharedKeyIndex, keyBytes); +/// ``` +Future deriveKeyFromPassphrase( + String passphrase, { + int bits = 128, + String salt = streamE2eePassphraseSalt, + int iterations = streamE2eePassphraseIterations, +}) async { + if (passphrase.isEmpty) { + throw ArgumentError.value(passphrase, 'passphrase', 'must not be empty'); + } + if (bits != 128 && bits != 256) { + throw ArgumentError.value(bits, 'bits', 'must be either 128 or 256'); + } + + final pbkdf2 = Pbkdf2( + macAlgorithm: Hmac.sha256(), + iterations: iterations, + bits: bits, + ); + + final derived = await pbkdf2.deriveKey( + secretKey: SecretKey(utf8.encode(passphrase)), + nonce: utf8.encode(salt), + ); + + return Uint8List.fromList(await derived.extractBytes()); +} + +/// A throwaway key for joining an encrypted call this device has no key for. +Uint8List randomEncryptionKey({int bytes = 16}) { + final random = Random.secure(); + return Uint8List.fromList( + List.generate(bytes, (_) => random.nextInt(256)), + ); +} diff --git a/dogfooding/lib/utils/random_words.dart b/dogfooding/lib/utils/random_words.dart new file mode 100644 index 000000000..8321382c4 --- /dev/null +++ b/dogfooding/lib/utils/random_words.dart @@ -0,0 +1,128 @@ +import 'dart:math'; + +/// Word pool for generated encryption passphrases. +/// +/// Short, unambiguous, easy to read aloud over a call β€” the key gets shared +/// by voice or chat, so anything easily misheard costs a failed decrypt. +const _words = [ + 'amber', + 'anchor', + 'arctic', + 'autumn', + 'badger', + 'bamboo', + 'beacon', + 'birch', + 'bishop', + 'blossom', + 'boulder', + 'brave', + 'breeze', + 'bridge', + 'bronze', + 'canyon', + 'cedar', + 'chorus', + 'cinder', + 'clever', + 'clover', + 'cobalt', + 'comet', + 'copper', + 'coral', + 'cosmic', + 'crimson', + 'crystal', + 'dagger', + 'delta', + 'desert', + 'dolphin', + 'dragon', + 'dusk', + 'eagle', + 'ember', + 'falcon', + 'fern', + 'forest', + 'fossil', + 'garnet', + 'glacier', + 'granite', + 'harbor', + 'hazel', + 'heron', + 'indigo', + 'island', + 'ivory', + 'jasper', + 'jungle', + 'kestrel', + 'lagoon', + 'lantern', + 'lilac', + 'lunar', + 'maple', + 'marble', + 'meadow', + 'meteor', + 'mint', + 'noble', + 'nomad', + 'ocean', + 'onyx', + 'orbit', + 'otter', + 'panther', + 'pebble', + 'pepper', + 'pilot', + 'pine', + 'prairie', + 'quartz', + 'quiet', + 'raven', + 'ridge', + 'river', + 'rover', + 'saffron', + 'sage', + 'sapphire', + 'shadow', + 'silver', + 'solar', + 'spruce', + 'stellar', + 'summit', + 'sunset', + 'thunder', + 'tiger', + 'timber', + 'topaz', + 'tundra', + 'valley', + 'velvet', + 'violet', + 'walnut', + 'waitress', + 'willow', + 'winter', + 'zenith', + 'zephyr', +]; + +final _random = Random(); + +/// Builds a memorable passphrase of [count] distinct words joined by `-`, +/// e.g. `noun-rover-waitress`. +/// +/// Seeds the E2EE shared key in the lobby. Readability is the point: the key +/// travels to other participants by voice or chat, and every platform derives +/// the same bytes from the same phrase. +/// +/// This is a demo convenience, not a security control β€” three words from a +/// ~100-word pool is roughly 20 bits, fine for a test call among people who +/// trust each other and nowhere near enough for a real secret. +String getRandomWords([int count = 3]) { + final pool = [..._words]..shuffle(_random); + return pool.take(min(count, pool.length)).join('-'); +} diff --git a/dogfooding/lib/utils/ringing_encryption.dart b/dogfooding/lib/utils/ringing_encryption.dart new file mode 100644 index 000000000..1d812d9c4 --- /dev/null +++ b/dogfooding/lib/utils/ringing_encryption.dart @@ -0,0 +1,23 @@ +// πŸ“¦ Package imports: +import 'package:stream_video_flutter/stream_video_flutter.dart'; + +import 'e2ee.dart'; + +/// Supplies a key for a call answered from a ringing notification. +Future resolveRingingEncryptionKey( + CallEncryptionKeyRequest request, +) async { + if (request.encryptionMode != StreamEncryptionMode.autoOn) return null; + + // Dogfooding does not currently support the proper flow of encrypted ringing calls, + // so we use a random key here. See [randomEncryptionKey] for details: using a wrong + // key allows the user to enter the call, and the correct key can later be provided + // through the in-call banner. + return CallEncryptionKey.shared( + bytes: randomEncryptionKey(), + // Spelled out even though it matches the default: the index is a contract + // between participants, not a default worth inheriting quietly. + // ignore: avoid_redundant_argument_values + keyIndex: kE2EESharedKeyIndex, + ); +} diff --git a/dogfooding/lib/widgets/call_duration_title.dart b/dogfooding/lib/widgets/call_duration_title.dart index fe327697a..4eb844d23 100644 --- a/dogfooding/lib/widgets/call_duration_title.dart +++ b/dogfooding/lib/widgets/call_duration_title.dart @@ -23,31 +23,66 @@ class _CallDurationTitleState extends State { borderRadius: BorderRadius.circular(20), ), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: StreamBuilder( - stream: widget.call.callDurationStream, - builder: (context, snapshot) { - final duration = snapshot.data ?? Duration.zero; - - return RichText( - text: TextSpan( - text: duration.inMinutes.toString().padLeft(2, '0'), - style: videoTheme.textTheme.bodyBold.copyWith( - color: AppColorPalette.secondaryText, - ), - children: [ - TextSpan( - text: - ':${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}', - style: const TextStyle( - fontWeight: FontWeight.bold, - color: AppColorPalette.primaryText, - ), - ), - ], - ), - ); - }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + PartialCallStateBuilder( + call: widget.call, + selector: (state) => state.isE2eeEnabled, + builder: (context, isEncrypted) => isEncrypted + ? Padding( + padding: const EdgeInsets.only(right: 6), + child: Tooltip( + message: 'This call is end-to-end encrypted', + child: Icon( + Icons.shield_rounded, + size: 16, + color: videoTheme.colorTheme.accentInfo, + ), + ), + ) + : const SizedBox.shrink(), + ), + _Duration(call: widget.call), + ], ), ); } } + +class _Duration extends StatelessWidget { + const _Duration({required this.call}); + + final Call call; + + @override + Widget build(BuildContext context) { + final videoTheme = StreamVideoTheme.of(context); + + return StreamBuilder( + stream: call.callDurationStream, + builder: (context, snapshot) { + final duration = snapshot.data ?? Duration.zero; + + return RichText( + text: TextSpan( + text: duration.inMinutes.toString().padLeft(2, '0'), + style: videoTheme.textTheme.bodyBold.copyWith( + color: AppColorPalette.secondaryText, + ), + children: [ + TextSpan( + text: + ':${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}', + style: const TextStyle( + fontWeight: FontWeight.bold, + color: AppColorPalette.primaryText, + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/dogfooding/lib/widgets/e2ee_key_notification.dart b/dogfooding/lib/widgets/e2ee_key_notification.dart new file mode 100644 index 000000000..29db42328 --- /dev/null +++ b/dogfooding/lib/widgets/e2ee_key_notification.dart @@ -0,0 +1,384 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:stream_video_flutter/stream_video_flutter.dart'; + +import '../theme/app_palette.dart'; +import '../utils/e2ee.dart'; + +/// What the local peer can conclude about key agreement from its own +/// decryption failures. +sealed class E2eeKeyStatus { + const E2eeKeyStatus(); +} + +/// Nothing is failing, or there is nothing to judge from yet. +class E2eeKeyOk extends E2eeKeyStatus { + const E2eeKeyOk(); +} + +/// Every publishing peer fails to decrypt. With a shared key that means *this* +/// peer holds the wrong one β€” a peer with the right key would still decrypt +/// the majority. +class E2eeLocalKeyMismatch extends E2eeKeyStatus { + const E2eeLocalKeyMismatch(); +} + +/// Only some peers fail, so their keys differ from ours. +class E2eePeerKeyMismatch extends E2eeKeyStatus { + const E2eePeerKeyMismatch(this.names); + + final List names; +} + +/// Surfaces a shared-key mismatch on an encrypted call. +/// +/// Without this a wrong meeting key looks like a broken call rather than a +/// wrong key: media arrives, fails its authentication tag and is dropped, so +/// tiles stay black and audio silent with nothing said about why. +/// +/// When the failure looks local the banner doubles as the fix β€” the key can be +/// re-entered here and goes straight to the native manager, so a mistyped key +/// does not cost a rejoin. +class E2eeKeyNotification extends StatefulWidget { + const E2eeKeyNotification({ + super.key, + required this.call, + this.onKeyApplied, + }); + + /// Called with the passphrase whenever a replacement key is installed. + /// + /// The derived bytes go to the encryption manager and never come back out, so + /// anything that has to show the key again β€” the invite the call screen + /// offers β€” only learns about it here. + final ValueChanged? onKeyApplied; + + final Call call; + + @override + State createState() => _E2eeKeyNotificationState(); +} + +class _E2eeKeyNotificationState extends State { + StreamSubscription? _subscription; + + /// Keyed per `(userId, trackType)` because the native manager counts + /// failures per track: a peer publishing audio and video reports them + /// independently, and their video can recover while audio is still stalled. + final Set _stalledTracks = {}; + + bool _dismissed = false; + bool _applyingKey = false; + final _keyController = TextEditingController(); + + @override + void initState() { + super.initState(); + _subscribe(); + } + + @override + void didUpdateWidget(E2eeKeyNotification oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.call != widget.call) _subscribe(); + } + + @override + void dispose() { + _subscription?.cancel(); + _keyController.dispose(); + super.dispose(); + } + + void _subscribe() { + _subscription?.cancel(); + _stalledTracks.clear(); + + final manager = widget.call.e2eeManager; + if (manager == null) return; + + // Stalled means the track has failed past the SDK's tolerance and is not recovering. + _subscription = manager.events.listen((event) { + final userId = event.userId; + if (userId.isEmpty) return; + + final key = '$userId/${event.trackType?.name ?? 'unknown'}'; + + switch (event.type) { + case E2eeEventType.decryptionStalled: + if (_stalledTracks.add(key)) setState(() {}); + case E2eeEventType.decryptionResumed: + if (_stalledTracks.remove(key)) setState(() {}); + case _: + break; + } + }); + } + + /// Derives and installs a replacement key without leaving the call. + Future _applyKey() async { + final passphrase = _keyController.text.trim(); + final manager = widget.call.e2eeManager; + if (passphrase.isEmpty || manager == null || _applyingKey) return; + + setState(() => _applyingKey = true); + try { + final keyBytes = await deriveKeyFromPassphrase(passphrase); + await manager.setSharedKey(kE2EESharedKeyIndex, keyBytes); + + // Replaces whatever the call was joined with, so the invite the share + // card offers hands out the key that is actually in use. + widget.onKeyApplied?.call(passphrase); + if (!mounted) return; + _keyController.clear(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Could not apply the key: $e')), + ); + } finally { + if (mounted) setState(() => _applyingKey = false); + } + } + + /// Judges the failures against the peers that are actually sending + /// something: a muted, camera-off peer produces no frames and so no evidence + /// either way. + /// + /// Blind spots worth knowing: alone in the call, or with every peer muted and + /// camera-off, a wrong key is undetectable. And if two peers share the same + /// wrong key they decrypt each other, so neither sees a full sweep of + /// failures. + E2eeKeyStatus _status(List remoteParticipants) { + if (_stalledTracks.isEmpty) return const E2eeKeyOk(); + + final stalledUserIds = _stalledTracks + .map((key) => key.substring(0, key.lastIndexOf('/'))) + .toSet(); + + // Participants who have left keep stale entries in the set, which is + // harmless β€” they are simply not part of this comparison. + final publishing = remoteParticipants + .where((it) => it.publishedTracks.isNotEmpty) + .toList(); + final failing = publishing + .where((it) => stalledUserIds.contains(it.userId)) + .toList(); + + if (failing.isEmpty) return const E2eeKeyOk(); + if (failing.length == publishing.length) { + return const E2eeLocalKeyMismatch(); + } + return E2eePeerKeyMismatch( + failing.map((it) => it.name.isNotEmpty ? it.name : it.userId).toList(), + ); + } + + @override + Widget build(BuildContext context) { + return PartialCallStateBuilder( + call: widget.call, + selector: (state) => state.otherParticipants, + builder: (context, remoteParticipants) { + final status = _status(remoteParticipants); + + // Re-arm once the call recovers, so a later mismatch is surfaced again + // rather than nagging about this one. + if (status is E2eeKeyOk) { + if (_dismissed) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _dismissed = false); + }); + } + return const SizedBox.shrink(); + } + + if (_dismissed) return const SizedBox.shrink(); + + return _Banner( + onDismiss: () => setState(() => _dismissed = true), + // Short enough to stay one or two lines in a card this size: the + // banner sits over someone's video, and the key field below it is + // the part that matters. + message: switch (status) { + E2eeLocalKeyMismatch() => + 'Nothing is decrypting β€” your meeting key looks wrong.', + E2eePeerKeyMismatch(:final names) => + "Can't decrypt ${names.join(', ')}", + E2eeKeyOk() => '', + }, + keyEntry: status is E2eeLocalKeyMismatch + ? _KeyEntry( + controller: _keyController, + busy: _applyingKey, + onSubmit: _applyKey, + ) + : null, + ); + }, + ); + } +} + +class _Banner extends StatelessWidget { + const _Banner({ + required this.message, + required this.onDismiss, + this.keyEntry, + }); + + final String message; + final VoidCallback onDismiss; + final Widget? keyEntry; + + @override + Widget build(BuildContext context) { + final theme = StreamVideoTheme.of(context); + final colorTheme = theme.colorTheme; + + return Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: ConstrainedBox( + // Sits over someone's video, so it takes the room it needs and no + // more. Without this it is as wide as the call. + constraints: const BoxConstraints(maxWidth: 420), + child: DecoratedBox( + decoration: BoxDecoration( + // The app's own floating-card surface, the same one the share + // card uses, with the failure carried by the icon and a hairline + // rather than by a block of red over the video. + color: AppColorPalette.buttonSecondary, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppColorPalette.appRed.withValues(alpha: 0.45), + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 8, 8), + child: Column( + // Loose vertical constraints come down from the Align this sits + // in, so the default (max) would stretch the card over the whole + // participant area. + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.no_encryption_gmailerrorred_rounded, + size: 18, + color: AppColorPalette.appRed, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: theme.textTheme.footnote.copyWith( + color: colorTheme.textHighEmphasis, + ), + ), + ), + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.close_rounded), + iconSize: 16, + color: colorTheme.textLowEmphasis, + padding: EdgeInsets.zero, + // An IconButton reserves a 48px tap target by default, + // which on its own makes this twice as tall as its text. + constraints: const BoxConstraints.tightFor( + width: 28, + height: 28, + ), + onPressed: onDismiss, + ), + ], + ), + if (keyEntry != null) + Padding( + padding: const EdgeInsets.only(left: 28, top: 6, right: 20), + child: keyEntry, + ), + ], + ), + ), + ), + ), + ); + } +} + +class _KeyEntry extends StatelessWidget { + const _KeyEntry({ + required this.controller, + required this.busy, + required this.onSubmit, + }); + + final TextEditingController controller; + final bool busy; + final VoidCallback onSubmit; + + @override + Widget build(BuildContext context) { + final theme = StreamVideoTheme.of(context); + final colorTheme = theme.colorTheme; + + return Row( + children: [ + Expanded( + child: TextField( + controller: controller, + enabled: !busy, + autocorrect: false, + enableSuggestions: false, + style: theme.textTheme.footnote.copyWith( + color: colorTheme.textHighEmphasis, + ), + decoration: InputDecoration( + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + hintText: 'Meeting key', + hintStyle: theme.textTheme.footnote.copyWith( + color: colorTheme.textLowEmphasis, + ), + filled: true, + fillColor: AppColorPalette.backgroundColor, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppColorPalette.primary), + ), + ), + onSubmitted: (_) => onSubmit(), + ), + ), + const SizedBox(width: 4), + TextButton( + style: TextButton.styleFrom( + foregroundColor: AppColorPalette.primary, + padding: const EdgeInsets.symmetric(horizontal: 12), + minimumSize: const Size(0, 34), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: busy ? null : onSubmit, + child: Text( + busy ? 'Applying…' : 'Apply', + style: theme.textTheme.footnoteBold, + ), + ), + ], + ); + } +} diff --git a/dogfooding/lib/widgets/lobby_encryption.dart b/dogfooding/lib/widgets/lobby_encryption.dart new file mode 100644 index 000000000..0f49ec2f4 --- /dev/null +++ b/dogfooding/lib/widgets/lobby_encryption.dart @@ -0,0 +1,256 @@ +// πŸ“¦ Package imports: +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:stream_video_flutter/stream_video_flutter.dart'; + +// 🌎 Project imports: +import '../utils/call_encryption.dart'; + +/// Lobby control that turns on end-to-end encryption and manages the shared +/// key. +/// +/// Encryption is fixed when a call is created, so what this offers depends +/// entirely on whether [call] exists yet: +/// - not created β†’ a live switch, since the mode is still ours to pick and the +/// call is created with it once the user joins; +/// - created and encrypted β†’ a read-only banner, plus the key field, which is +/// all that is left to collect; +/// - created and plain β†’ the switch, greyed out. +class LobbyEncryption extends StatelessWidget { + const LobbyEncryption({ + super.key, + required this.call, + required this.callExists, + required this.encryptionEnabled, + required this.encryptionKey, + required this.busy, + required this.onEncryptionToggled, + required this.onEncryptionKeyChanged, + required this.onGenerateKey, + required this.keyController, + }); + + final Call call; + + /// Whether [call] has already been created on the backend. + final bool callExists; + + /// The mode the switch is asking for, meaningful only before creation. + final bool encryptionEnabled; + + /// The shared passphrase, empty when encryption is off. + final String encryptionKey; + + /// Whether the call is being created; the switch is inert meanwhile. + final bool busy; + + final ValueChanged onEncryptionToggled; + final ValueChanged onEncryptionKeyChanged; + final VoidCallback onGenerateKey; + final TextEditingController keyController; + + @override + Widget build(BuildContext context) { + final streamVideoTheme = StreamVideoTheme.of(context); + final textTheme = streamVideoTheme.textTheme; + final colorTheme = streamVideoTheme.colorTheme; + + return PartialCallStateBuilder( + call: call, + selector: (state) => ( + settings: state.settings, + createdByUser: state.createdByUser, + ), + builder: (context, state) { + if (callExists && state.createdByUser.id.isEmpty) { + return const SizedBox.shrink(); + } + + final isOn = callExists + ? isCallEncrypted(state.settings) + : encryptionEnabled; + + final needsKey = callExists && isOn && encryptionKey.isEmpty; + + return Container( + constraints: const BoxConstraints(maxWidth: 360), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: StreamLobbyViewTheme.of(context).cardBackgroundColor, + border: isOn + ? Border.all( + color: colorTheme.accentInfo.withValues(alpha: 0.6), + ) + : null, + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (callExists && isOn) + const _Header( + title: 'End-to-end encryption', + isOn: true, + ) + else + _Header( + title: 'End-to-end encryption', + subtitle: switch ((callExists, isOn)) { + (true, _) => 'This call was created without encryption', + (false, true) => 'Only people with the key can join', + (false, false) => 'Encrypt this call with a shared key', + }, + isOn: isOn, + trailing: Switch.adaptive( + value: isOn, + activeTrackColor: colorTheme.accentInfo, + onChanged: callExists || busy ? null : onEncryptionToggled, + ), + ), + AnimatedSize( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + alignment: Alignment.topCenter, + child: !isOn + ? const SizedBox(width: double.infinity) + : Padding( + padding: const EdgeInsets.only(top: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: keyController, + onChanged: onEncryptionKeyChanged, + autocorrect: false, + enableSuggestions: false, + style: textTheme.body.copyWith( + color: colorTheme.textHighEmphasis, + ), + decoration: InputDecoration( + isDense: true, + hintText: 'Shared room key', + hintStyle: textTheme.body.copyWith( + color: colorTheme.textLowEmphasis, + ), + contentPadding: + const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + border: const OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(8), + ), + ), + ), + ), + ), + if (!callExists) ...[ + const SizedBox(width: 4), + IconButton( + tooltip: 'Generate a new key', + icon: const Icon( + Icons.refresh, + color: Colors.white, + ), + onPressed: busy ? null : onGenerateKey, + ), + ], + IconButton( + tooltip: 'Copy key', + icon: const Icon( + Icons.copy_rounded, + color: Colors.white, + ), + onPressed: encryptionKey.isEmpty + ? null + : () => _copyKey(context), + ), + ], + ), + const SizedBox(height: 8), + Text( + needsKey + ? 'Ask the call creator for the shared key, then enter it here.' + : 'Anyone with this key can join and decrypt the call. Share it only with people you trust.', + style: textTheme.footnote.copyWith( + color: colorTheme.textLowEmphasis, + ), + ), + ], + ), + ), + ), + ], + ), + ); + }, + ); + } + + void _copyKey(BuildContext context) { + Clipboard.setData(ClipboardData(text: encryptionKey)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + duration: Duration(seconds: 2), + content: Text('Encryption key copied'), + ), + ); + } +} + +class _Header extends StatelessWidget { + const _Header({ + required this.title, + this.subtitle, + required this.isOn, + this.trailing, + }); + + final String title; + final String? subtitle; + final bool isOn; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final streamVideoTheme = StreamVideoTheme.of(context); + final textTheme = streamVideoTheme.textTheme; + final colorTheme = streamVideoTheme.colorTheme; + + return Row( + children: [ + Icon( + isOn ? Icons.lock_rounded : Icons.lock_open_rounded, + color: isOn ? colorTheme.accentInfo : colorTheme.textLowEmphasis, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: textTheme.bodyBold.copyWith( + color: colorTheme.textHighEmphasis, + ), + ), + const SizedBox(height: 2), + if (subtitle != null) + Text( + subtitle!, + style: textTheme.footnote.copyWith( + color: colorTheme.textLowEmphasis, + ), + ), + ], + ), + ), + if (trailing != null) trailing!, + ], + ); + } +} diff --git a/dogfooding/lib/widgets/share_call_card.dart b/dogfooding/lib/widgets/share_call_card.dart index dab97c886..a5aa2ec41 100644 --- a/dogfooding/lib/widgets/share_call_card.dart +++ b/dogfooding/lib/widgets/share_call_card.dart @@ -7,12 +7,20 @@ import 'package:stream_video_flutter/stream_video_flutter.dart'; import '../core/repos/app_preferences.dart'; import '../di/injector.dart'; import '../theme/app_palette.dart'; +import '../utils/call_encryption.dart'; import 'stream_button.dart'; class ShareCallWelcomeCard extends StatefulWidget { - const ShareCallWelcomeCard({required this.callId, super.key}); + const ShareCallWelcomeCard({ + required this.call, + this.encryptionKey, + super.key, + }); - final String callId; + final Call call; + + /// The shared passphrase, put in the invite when [call] is encrypted. + final String? encryptionKey; @override State createState() => _ShareCallWelcomeCardState(); @@ -53,7 +61,12 @@ class _ShareCallWelcomeCardState extends State { ), childrenPadding: const EdgeInsets.all(16), onExpansionChanged: (value) => setState(() => _isExpanded = value), - children: [_ShareCardContent(callId: widget.callId)], + children: [ + _ShareCardContent( + call: widget.call, + encryptionKey: widget.encryptionKey, + ), + ], ), ), ), @@ -62,8 +75,15 @@ class _ShareCallWelcomeCardState extends State { } class ShareCallParticipantsCard extends StatelessWidget { - const ShareCallParticipantsCard({required this.callId, super.key}); - final String callId; + const ShareCallParticipantsCard({ + required this.call, + this.encryptionKey, + super.key, + }); + final Call call; + + /// The shared passphrase, put in the invite when [call] is encrypted. + final String? encryptionKey; @override Widget build(BuildContext context) { @@ -76,7 +96,7 @@ class ShareCallParticipantsCard extends StatelessWidget { children: [ Text('Share the link', style: theme.textTheme.title1), const SizedBox(height: 16), - _ShareCardContent(callId: callId), + _ShareCardContent(call: call, encryptionKey: encryptionKey), ], ), ); @@ -84,14 +104,24 @@ class ShareCallParticipantsCard extends StatelessWidget { } class _ShareCardContent extends StatelessWidget { - _ShareCardContent({required this.callId}); - final String callId; + _ShareCardContent({required this.call, this.encryptionKey}); + final Call call; + final String? encryptionKey; late final _appPreferences = locator.get(); @override Widget build(BuildContext context) { final theme = StreamVideoTheme.of(context); - final callUrl = _appPreferences.environment.getJoinUrl(callId: callId); + final callId = call.id; + + // An encrypted call cannot be joined without the key, so an invite to one + // has to carry it. + final callUrl = _appPreferences.environment.getJoinUrl( + callId: callId, + encryptionKey: isCallEncrypted(call.state.value.settings) + ? encryptionKey + : null, + ); return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/dogfooding/pubspec.yaml b/dogfooding/pubspec.yaml index 0e14d5eb5..7bdad2839 100644 --- a/dogfooding/pubspec.yaml +++ b/dogfooding/pubspec.yaml @@ -13,6 +13,7 @@ dependencies: app_links: ^7.2.1 collection: ^1.19.1 crypto: ^3.0.6 + cryptography: ^2.7.0 cupertino_icons: ^1.0.8 device_info_plus: ">=12.1.0 <14.0.0" envied: ^1.2.1 diff --git a/dogfooding/test/e2ee_passphrase_test.dart b/dogfooding/test/e2ee_passphrase_test.dart new file mode 100644 index 000000000..a495c7e30 --- /dev/null +++ b/dogfooding/test/e2ee_passphrase_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter_dogfooding/utils/e2ee.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('deriveKeyFromPassphrase', () { + // Reference vectors computed independently with Python's + // hashlib.pbkdf2_hmac('sha256', passphrase, b'stream-e2ee', 100000, n). + // Every Stream SDK derives the same bytes from the same passphrase, so a + // change here breaks interop with web, iOS and Android. + const passphrase = 'noun-rover-waitress'; + const expectedAes128 = 'd30b96af272a8791e237e83571639cf2'; + const expectedAes256 = + 'd30b96af272a8791e237e83571639cf2' + 'a10ffde6029c37dfd3ce00e57fd5b37b'; + + String hex(List bytes) => + bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + + test('derives the shared 16-byte AES-128 key', () async { + final key = await deriveKeyFromPassphrase(passphrase); + + expect(key, hasLength(16)); + expect(hex(key), expectedAes128); + }); + + test('derives the shared 32-byte AES-256 key', () async { + final key = await deriveKeyFromPassphrase(passphrase, bits: 256); + + expect(key, hasLength(32)); + expect(hex(key), expectedAes256); + }); + + test('AES-128 is the AES-256 key truncated, as PBKDF2 defines', () async { + final short = await deriveKeyFromPassphrase(passphrase); + final long = await deriveKeyFromPassphrase(passphrase, bits: 256); + + expect(hex(long).startsWith(hex(short)), isTrue); + }); + + test('different passphrases derive different keys', () async { + final a = await deriveKeyFromPassphrase(passphrase); + final b = await deriveKeyFromPassphrase('noun-rover-waitres'); + + expect(hex(a), isNot(hex(b))); + }); + + test('a different salt derives a different key', () async { + final a = await deriveKeyFromPassphrase(passphrase); + final b = await deriveKeyFromPassphrase(passphrase, salt: 'other-salt'); + + expect(hex(a), isNot(hex(b))); + }); + + test('rejects an empty passphrase', () { + expect( + () => deriveKeyFromPassphrase(''), + throwsA(isA()), + ); + }); + + test('rejects a key size no AES-GCM variant supports', () { + expect( + () => deriveKeyFromPassphrase(passphrase, bits: 192), + throwsA(isA()), + ); + }); + }); +} diff --git a/dogfooding/test/join_url_test.dart b/dogfooding/test/join_url_test.dart new file mode 100644 index 000000000..a30a7a396 --- /dev/null +++ b/dogfooding/test/join_url_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter_dogfooding/core/model/environment.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('getJoinUrl encryption_key', () { + test('is left off when there is no key', () { + final url = Environment.pronto.getJoinUrl(callId: 'abc'); + expect(url, isNot(contains('encryption_key'))); + }); + + test('is left off for an empty key, rather than sent empty', () { + // An empty parameter reads as "this call is encrypted with nothing", + // which the web demo would take as an encrypted call. + final url = Environment.pronto.getJoinUrl( + callId: 'abc', + encryptionKey: '', + ); + expect(url, isNot(contains('encryption_key'))); + }); + + test('is appended to the query the environment already has', () { + expect( + Environment.pronto.getJoinUrl( + callId: 'abc', + encryptionKey: 'noun-rover-waitress', + ), + 'https://pronto.getstream.io/join/abc' + '?type=default&encryption_key=noun-rover-waitress', + ); + }); + + test( + 'survives a round trip through Uri for every environment with a URL', + () { + const passphrase = 'a b&c=d#e'; + + for (final environment in Environment.values) { + final url = environment.getJoinUrl( + callId: 'abc', + encryptionKey: passphrase, + ); + if (url == null) continue; + + // The passphrase is typed by hand: an unencoded `&` or `#` would + // silently truncate the key rather than fail. + expect( + Uri.parse(url).queryParameters['encryption_key'], + passphrase, + reason: 'round trip failed for ${environment.name}', + ); + } + }, + ); + + test('custom has no join page, with or without a key', () { + expect( + Environment.custom.getJoinUrl(callId: 'abc', encryptionKey: 'key'), + isNull, + ); + }); + }); +} diff --git a/packages/stream_video/CHANGELOG.md b/packages/stream_video/CHANGELOG.md index 95ad7f613..ba29b6bd0 100644 --- a/packages/stream_video/CHANGELOG.md +++ b/packages/stream_video/CHANGELOG.md @@ -1,5 +1,10 @@ ## Upcoming +### βœ… Added + +- Added end-to-end encryption support: attach an `EncryptionManager` with `Call.setE2EEManager` before joining, request encryption at call creation with `StreamEncryptionSettings`, and read `CallState.isE2eeEnabled` to check whether it is in effect. Available on Android, iOS and macOS. See the [documentation](https://getstream.io/video/docs/flutter/guides/e2ee-encryption/) for details. +- Added `CallPreferences.encryptionKeyResolver`, which supplies the key for calls your app does not join itself, such as those answered from a ringing notification. See the [documentation](https://getstream.io/video/docs/flutter/guides/e2ee-encryption/#ringing-calls) for details. + ### 🐞 Fixed - [Web] Fixed the microphone not being published when Opus RED was enabled for the call, leaving the participant inaudible to everyone while their microphone still appeared active. Opus DTX and RED are no longer munged into the SDP. Both are negotiated by signalling them to the SFU with the published tracks. diff --git a/packages/stream_video/lib/open_api/video/coordinator/api.dart b/packages/stream_video/lib/open_api/video/coordinator/api.dart index 7db518081..4b18ad86d 100644 --- a/packages/stream_video/lib/open_api/video/coordinator/api.dart +++ b/packages/stream_video/lib/open_api/video/coordinator/api.dart @@ -154,6 +154,8 @@ part 'model/egress_hls_response.dart'; part 'model/egress_rtmp_response.dart'; part 'model/egress_response.dart'; part 'model/end_call_response.dart'; +part 'model/encryption_settings_request.dart'; +part 'model/encryption_settings_response.dart'; part 'model/feeds_preferences_response.dart'; part 'model/file_upload_config.dart'; part 'model/frame_recording_response.dart'; diff --git a/packages/stream_video/lib/open_api/video/coordinator/api_client.dart b/packages/stream_video/lib/open_api/video/coordinator/api_client.dart index 1f675bd1c..eeedacfbe 100644 --- a/packages/stream_video/lib/open_api/video/coordinator/api_client.dart +++ b/packages/stream_video/lib/open_api/video/coordinator/api_client.dart @@ -478,6 +478,10 @@ class ApiClient { return EgressResponse.fromJson(value); case 'EndCallResponse': return EndCallResponse.fromJson(value); + case 'EncryptionSettingsRequest': + return EncryptionSettingsRequest.fromJson(value); + case 'EncryptionSettingsResponse': + return EncryptionSettingsResponse.fromJson(value); case 'FeedsPreferencesResponse': return FeedsPreferencesResponse.fromJson(value); case 'FileUploadConfig': diff --git a/packages/stream_video/lib/open_api/video/coordinator/model/call_settings_request.dart b/packages/stream_video/lib/open_api/video/coordinator/model/call_settings_request.dart index a8c20728d..d8d684717 100644 --- a/packages/stream_video/lib/open_api/video/coordinator/model/call_settings_request.dart +++ b/packages/stream_video/lib/open_api/video/coordinator/model/call_settings_request.dart @@ -16,6 +16,7 @@ class CallSettingsRequest { this.audio, this.backstage, this.broadcasting, + this.encryption, this.frameRecording, this.geofencing, this.individualRecording, @@ -55,6 +56,14 @@ class CallSettingsRequest { /// BroadcastSettingsRequest? broadcasting; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + EncryptionSettingsRequest? encryption; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -166,6 +175,7 @@ class CallSettingsRequest { other.audio == audio && other.backstage == backstage && other.broadcasting == broadcasting && + other.encryption == encryption && other.frameRecording == frameRecording && other.geofencing == geofencing && other.individualRecording == individualRecording && @@ -186,6 +196,7 @@ class CallSettingsRequest { (audio == null ? 0 : audio!.hashCode) + (backstage == null ? 0 : backstage!.hashCode) + (broadcasting == null ? 0 : broadcasting!.hashCode) + + (encryption == null ? 0 : encryption!.hashCode) + (frameRecording == null ? 0 : frameRecording!.hashCode) + (geofencing == null ? 0 : geofencing!.hashCode) + (individualRecording == null ? 0 : individualRecording!.hashCode) + @@ -202,7 +213,7 @@ class CallSettingsRequest { @override String toString() => - 'CallSettingsRequest[audio=$audio, backstage=$backstage, broadcasting=$broadcasting, frameRecording=$frameRecording, geofencing=$geofencing, individualRecording=$individualRecording, ingress=$ingress, limits=$limits, rawRecording=$rawRecording, recording=$recording, ring=$ring, screensharing=$screensharing, session=$session, thumbnails=$thumbnails, transcription=$transcription, video=$video]'; + 'CallSettingsRequest[audio=$audio, backstage=$backstage, broadcasting=$broadcasting, encryption=$encryption, frameRecording=$frameRecording, geofencing=$geofencing, individualRecording=$individualRecording, ingress=$ingress, limits=$limits, rawRecording=$rawRecording, recording=$recording, ring=$ring, screensharing=$screensharing, session=$session, thumbnails=$thumbnails, transcription=$transcription, video=$video]'; Map toJson() { final json = {}; @@ -221,6 +232,11 @@ class CallSettingsRequest { } else { json[r'broadcasting'] = null; } + if (this.encryption != null) { + json[r'encryption'] = this.encryption; + } else { + json[r'encryption'] = null; + } if (this.frameRecording != null) { json[r'frame_recording'] = this.frameRecording; } else { @@ -307,6 +323,7 @@ class CallSettingsRequest { audio: AudioSettingsRequest.fromJson(json[r'audio']), backstage: BackstageSettingsRequest.fromJson(json[r'backstage']), broadcasting: BroadcastSettingsRequest.fromJson(json[r'broadcasting']), + encryption: EncryptionSettingsRequest.fromJson(json[r'encryption']), frameRecording: FrameRecordingSettingsRequest.fromJson(json[r'frame_recording']), geofencing: GeofenceSettingsRequest.fromJson(json[r'geofencing']), diff --git a/packages/stream_video/lib/open_api/video/coordinator/model/call_settings_response.dart b/packages/stream_video/lib/open_api/video/coordinator/model/call_settings_response.dart index 37d54fcc2..551f914d1 100644 --- a/packages/stream_video/lib/open_api/video/coordinator/model/call_settings_response.dart +++ b/packages/stream_video/lib/open_api/video/coordinator/model/call_settings_response.dart @@ -16,6 +16,7 @@ class CallSettingsResponse { required this.audio, required this.backstage, required this.broadcasting, + required this.encryption, required this.frameRecording, required this.geofencing, required this.individualRecording, @@ -37,6 +38,9 @@ class CallSettingsResponse { BroadcastSettingsResponse broadcasting; + /// EncryptionSettings is the payload for end-to-end encryption settings + EncryptionSettingsResponse encryption; + FrameRecordingSettingsResponse frameRecording; GeofenceSettingsResponse geofencing; @@ -76,6 +80,7 @@ class CallSettingsResponse { other.audio == audio && other.backstage == backstage && other.broadcasting == broadcasting && + other.encryption == encryption && other.frameRecording == frameRecording && other.geofencing == geofencing && other.individualRecording == individualRecording && @@ -96,6 +101,7 @@ class CallSettingsResponse { (audio.hashCode) + (backstage.hashCode) + (broadcasting.hashCode) + + (encryption.hashCode) + (frameRecording.hashCode) + (geofencing.hashCode) + (individualRecording.hashCode) + @@ -112,13 +118,14 @@ class CallSettingsResponse { @override String toString() => - 'CallSettingsResponse[audio=$audio, backstage=$backstage, broadcasting=$broadcasting, frameRecording=$frameRecording, geofencing=$geofencing, individualRecording=$individualRecording, ingress=$ingress, limits=$limits, rawRecording=$rawRecording, recording=$recording, ring=$ring, screensharing=$screensharing, session=$session, thumbnails=$thumbnails, transcription=$transcription, video=$video]'; + 'CallSettingsResponse[audio=$audio, backstage=$backstage, broadcasting=$broadcasting, encryption=$encryption, frameRecording=$frameRecording, geofencing=$geofencing, individualRecording=$individualRecording, ingress=$ingress, limits=$limits, rawRecording=$rawRecording, recording=$recording, ring=$ring, screensharing=$screensharing, session=$session, thumbnails=$thumbnails, transcription=$transcription, video=$video]'; Map toJson() { final json = {}; json[r'audio'] = this.audio; json[r'backstage'] = this.backstage; json[r'broadcasting'] = this.broadcasting; + json[r'encryption'] = this.encryption; json[r'frame_recording'] = this.frameRecording; json[r'geofencing'] = this.geofencing; json[r'individual_recording'] = this.individualRecording; @@ -162,6 +169,10 @@ class CallSettingsResponse { 'Required key "CallSettingsResponse[broadcasting]" is missing from JSON.'); assert(json[r'broadcasting'] != null, 'Required key "CallSettingsResponse[broadcasting]" has a null value in JSON.'); + assert(json.containsKey(r'encryption'), + 'Required key "CallSettingsResponse[encryption]" is missing from JSON.'); + assert(json[r'encryption'] != null, + 'Required key "CallSettingsResponse[encryption]" has a null value in JSON.'); assert(json.containsKey(r'frame_recording'), 'Required key "CallSettingsResponse[frame_recording]" is missing from JSON.'); assert(json[r'frame_recording'] != null, @@ -218,6 +229,7 @@ class CallSettingsResponse { backstage: BackstageSettingsResponse.fromJson(json[r'backstage'])!, broadcasting: BroadcastSettingsResponse.fromJson(json[r'broadcasting'])!, + encryption: EncryptionSettingsResponse.fromJson(json[r'encryption'])!, frameRecording: FrameRecordingSettingsResponse.fromJson(json[r'frame_recording'])!, geofencing: GeofenceSettingsResponse.fromJson(json[r'geofencing'])!, @@ -295,6 +307,7 @@ class CallSettingsResponse { 'audio', 'backstage', 'broadcasting', + 'encryption', 'frame_recording', 'geofencing', 'individual_recording', diff --git a/packages/stream_video/lib/open_api/video/coordinator/model/encryption_settings_request.dart b/packages/stream_video/lib/open_api/video/coordinator/model/encryption_settings_request.dart new file mode 100644 index 000000000..39ebed184 --- /dev/null +++ b/packages/stream_video/lib/open_api/video/coordinator/model/encryption_settings_request.dart @@ -0,0 +1,199 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class EncryptionSettingsRequest { + /// Returns a new [EncryptionSettingsRequest] instance. + EncryptionSettingsRequest({ + this.mode, + }); + + /// Encryption mode. One of: available, disabled, auto-on + EncryptionSettingsRequestModeEnum? mode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is EncryptionSettingsRequest && other.mode == mode; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (mode == null ? 0 : mode!.hashCode); + + @override + String toString() => 'EncryptionSettingsRequest[mode=$mode]'; + + Map toJson() { + final json = {}; + if (this.mode != null) { + json[r'mode'] = this.mode; + } else { + json[r'mode'] = null; + } + return json; + } + + /// Returns a new [EncryptionSettingsRequest] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static EncryptionSettingsRequest? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + // Ensure that the map contains the required keys. + // Note 1: the values aren't checked for validity beyond being non-null. + // Note 2: this code is stripped in release mode! + assert(() { + return true; + }()); + + return EncryptionSettingsRequest( + mode: EncryptionSettingsRequestModeEnum.fromJson(json[r'mode']), + ); + } + return null; + } + + static List listFromJson( + dynamic json, { + bool growable = false, + }) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = EncryptionSettingsRequest.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = EncryptionSettingsRequest.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of EncryptionSettingsRequest-objects as value to a dart map + static Map> mapListFromJson( + dynamic json, { + bool growable = false, + }) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = EncryptionSettingsRequest.listFromJson( + entry.value, + growable: growable, + ); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = {}; +} + +class EncryptionSettingsRequestModeEnum { + /// Instantiate a new enum with the provided [value]. + const EncryptionSettingsRequestModeEnum._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const available = EncryptionSettingsRequestModeEnum._(r'available'); + static const disabled = EncryptionSettingsRequestModeEnum._(r'disabled'); + static const autoOn = EncryptionSettingsRequestModeEnum._(r'auto-on'); + + /// List of all possible values in this [enum][EncryptionSettingsRequestModeEnum]. + static const values = [ + available, + disabled, + autoOn, + ]; + + static EncryptionSettingsRequestModeEnum? fromJson(dynamic value) => + EncryptionSettingsRequestModeEnumTypeTransformer().decode(value); + + static List listFromJson( + dynamic json, { + bool growable = false, + }) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = EncryptionSettingsRequestModeEnum.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +class EncryptionSettingsRequestModeEnumTypeTransformer { + factory EncryptionSettingsRequestModeEnumTypeTransformer() => + _instance ??= const EncryptionSettingsRequestModeEnumTypeTransformer._(); + + const EncryptionSettingsRequestModeEnumTypeTransformer._(); + + String encode(EncryptionSettingsRequestModeEnum data) => data.value; + + /// Decodes a [dynamic value][data] to a EncryptionSettingsRequestModeEnum. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + EncryptionSettingsRequestModeEnum? decode(dynamic data, + {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'available': + return EncryptionSettingsRequestModeEnum.available; + case r'disabled': + return EncryptionSettingsRequestModeEnum.disabled; + case r'auto-on': + return EncryptionSettingsRequestModeEnum.autoOn; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [EncryptionSettingsRequestModeEnumTypeTransformer] instance. + static EncryptionSettingsRequestModeEnumTypeTransformer? _instance; +} diff --git a/packages/stream_video/lib/open_api/video/coordinator/model/encryption_settings_response.dart b/packages/stream_video/lib/open_api/video/coordinator/model/encryption_settings_response.dart new file mode 100644 index 000000000..7418cfcbf --- /dev/null +++ b/packages/stream_video/lib/open_api/video/coordinator/model/encryption_settings_response.dart @@ -0,0 +1,203 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class EncryptionSettingsResponse { + /// Returns a new [EncryptionSettingsResponse] instance. + EncryptionSettingsResponse({ + required this.mode, + }); + + /// the resolved encryption mode for the call + EncryptionSettingsResponseModeEnum mode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is EncryptionSettingsResponse && other.mode == mode; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (mode.hashCode); + + @override + String toString() => 'EncryptionSettingsResponse[mode=$mode]'; + + Map toJson() { + final json = {}; + json[r'mode'] = this.mode; + return json; + } + + /// Returns a new [EncryptionSettingsResponse] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static EncryptionSettingsResponse? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + // Ensure that the map contains the required keys. + // Note 1: the values aren't checked for validity beyond being non-null. + // Note 2: this code is stripped in release mode! + assert(() { + requiredKeys.forEach((key) { + assert(json.containsKey(key), + 'Required key "EncryptionSettingsResponse[$key]" is missing from JSON.'); + assert(json[key] != null, + 'Required key "EncryptionSettingsResponse[$key]" has a null value in JSON.'); + }); + return true; + }()); + + return EncryptionSettingsResponse( + mode: EncryptionSettingsResponseModeEnum.fromJson(json[r'mode'])!, + ); + } + return null; + } + + static List listFromJson( + dynamic json, { + bool growable = false, + }) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = EncryptionSettingsResponse.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = EncryptionSettingsResponse.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of EncryptionSettingsResponse-objects as value to a dart map + static Map> mapListFromJson( + dynamic json, { + bool growable = false, + }) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = EncryptionSettingsResponse.listFromJson( + entry.value, + growable: growable, + ); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'mode', + }; +} + +class EncryptionSettingsResponseModeEnum { + /// Instantiate a new enum with the provided [value]. + const EncryptionSettingsResponseModeEnum._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const available = EncryptionSettingsResponseModeEnum._(r'available'); + static const disabled = EncryptionSettingsResponseModeEnum._(r'disabled'); + static const autoOn = EncryptionSettingsResponseModeEnum._(r'auto-on'); + + /// List of all possible values in this [enum][EncryptionSettingsResponseModeEnum]. + static const values = [ + available, + disabled, + autoOn, + ]; + + static EncryptionSettingsResponseModeEnum? fromJson(dynamic value) => + EncryptionSettingsResponseModeEnumTypeTransformer().decode(value); + + static List listFromJson( + dynamic json, { + bool growable = false, + }) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = EncryptionSettingsResponseModeEnum.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +class EncryptionSettingsResponseModeEnumTypeTransformer { + factory EncryptionSettingsResponseModeEnumTypeTransformer() => + _instance ??= const EncryptionSettingsResponseModeEnumTypeTransformer._(); + + const EncryptionSettingsResponseModeEnumTypeTransformer._(); + + String encode(EncryptionSettingsResponseModeEnum data) => data.value; + + /// Decodes a [dynamic value][data] to a EncryptionSettingsResponseModeEnum. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + EncryptionSettingsResponseModeEnum? decode(dynamic data, + {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'available': + return EncryptionSettingsResponseModeEnum.available; + case r'disabled': + return EncryptionSettingsResponseModeEnum.disabled; + case r'auto-on': + return EncryptionSettingsResponseModeEnum.autoOn; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [EncryptionSettingsResponseModeEnumTypeTransformer] instance. + static EncryptionSettingsResponseModeEnumTypeTransformer? _instance; +} diff --git a/packages/stream_video/lib/open_api/video/coordinator/model/join_call_request.dart b/packages/stream_video/lib/open_api/video/coordinator/model/join_call_request.dart index c7a433379..197437812 100644 --- a/packages/stream_video/lib/open_api/video/coordinator/model/join_call_request.dart +++ b/packages/stream_video/lib/open_api/video/coordinator/model/join_call_request.dart @@ -15,6 +15,7 @@ class JoinCallRequest { JoinCallRequest({ this.create, this.data, + this.e2ee, this.hintHighScaleLivestreamPublisher, required this.location, this.membersLimit, @@ -42,6 +43,15 @@ class JoinCallRequest { /// CallRequest? data; + /// the encryption mode the client intends to use for this join; the join is rejected if it does not match the call's encryption configuration + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? e2ee; + /// if true, the participant will be marked as publsihing to large audience /// /// Please note: This property should have been non-nullable! Since the specification file @@ -105,6 +115,7 @@ class JoinCallRequest { other is JoinCallRequest && other.create == create && other.data == data && + other.e2ee == e2ee && other.hintHighScaleLivestreamPublisher == hintHighScaleLivestreamPublisher && other.location == location && @@ -120,6 +131,7 @@ class JoinCallRequest { // ignore: unnecessary_parenthesis (create == null ? 0 : create!.hashCode) + (data == null ? 0 : data!.hashCode) + + (e2ee == null ? 0 : e2ee!.hashCode) + (hintHighScaleLivestreamPublisher == null ? 0 : hintHighScaleLivestreamPublisher!.hashCode) + @@ -133,7 +145,7 @@ class JoinCallRequest { @override String toString() => - 'JoinCallRequest[create=$create, data=$data, hintHighScaleLivestreamPublisher=$hintHighScaleLivestreamPublisher, location=$location, membersLimit=$membersLimit, migratingFrom=$migratingFrom, migratingFromList=$migratingFromList, notify=$notify, ring=$ring, video=$video]'; + 'JoinCallRequest[create=$create, data=$data, e2ee=$e2ee, hintHighScaleLivestreamPublisher=$hintHighScaleLivestreamPublisher, location=$location, membersLimit=$membersLimit, migratingFrom=$migratingFrom, migratingFromList=$migratingFromList, notify=$notify, ring=$ring, video=$video]'; Map toJson() { final json = {}; @@ -147,6 +159,11 @@ class JoinCallRequest { } else { json[r'data'] = null; } + if (this.e2ee != null) { + json[r'e2ee'] = this.e2ee; + } else { + json[r'e2ee'] = null; + } if (this.hintHighScaleLivestreamPublisher != null) { json[r'hint_high_scale_livestream_publisher'] = this.hintHighScaleLivestreamPublisher; @@ -204,6 +221,7 @@ class JoinCallRequest { return JoinCallRequest( create: mapValueOfType(json, r'create'), data: CallRequest.fromJson(json[r'data']), + e2ee: mapValueOfType(json, r'e2ee'), hintHighScaleLivestreamPublisher: mapValueOfType(json, r'hint_high_scale_livestream_publisher'), location: mapValueOfType(json, r'location')!, diff --git a/packages/stream_video/lib/protobuf/video/sfu/models/models.pb.dart b/packages/stream_video/lib/protobuf/video/sfu/models/models.pb.dart index 951ab48ab..2e665eeaf 100644 --- a/packages/stream_video/lib/protobuf/video/sfu/models/models.pb.dart +++ b/packages/stream_video/lib/protobuf/video/sfu/models/models.pb.dart @@ -32,12 +32,14 @@ class CallState extends $pb.GeneratedMessage { $0.Timestamp? startedAt, ParticipantCount? participantCount, $core.Iterable? pins, + $core.bool? e2eeEnabled, }) { final result = create(); if (participants != null) result.participants.addAll(participants); if (startedAt != null) result.startedAt = startedAt; if (participantCount != null) result.participantCount = participantCount; if (pins != null) result.pins.addAll(pins); + if (e2eeEnabled != null) result.e2eeEnabled = e2eeEnabled; return result; } @@ -62,6 +64,7 @@ class CallState extends $pb.GeneratedMessage { ..aOM(3, _omitFieldNames ? '' : 'participantCount', subBuilder: ParticipantCount.create) ..pPM(4, _omitFieldNames ? '' : 'pins', subBuilder: Pin.create) + ..aOB(5, _omitFieldNames ? '' : 'e2eeEnabled') ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -118,6 +121,18 @@ class CallState extends $pb.GeneratedMessage { /// Pins are ordered in descending order (most important first). @$pb.TagNumber(4) $pb.PbList get pins => $_getList(3); + + /// e2ee_enabled is true when the call uses end-to-end encryption. Clients + /// must enable their frame encryptor; the SFU forwards encrypted frames + /// opaquely and server-side recording/transcription/broadcasting are disabled. + @$pb.TagNumber(5) + $core.bool get e2eeEnabled => $_getBF(4); + @$pb.TagNumber(5) + set e2eeEnabled($core.bool value) => $_setBool(4, value); + @$pb.TagNumber(5) + $core.bool hasE2eeEnabled() => $_has(4); + @$pb.TagNumber(5) + void clearE2eeEnabled() => $_clearField(5); } class ParticipantCount extends $pb.GeneratedMessage { diff --git a/packages/stream_video/lib/protobuf/video/sfu/models/models.pbjson.dart b/packages/stream_video/lib/protobuf/video/sfu/models/models.pbjson.dart index 610120d5c..f577e39d7 100644 --- a/packages/stream_video/lib/protobuf/video/sfu/models/models.pbjson.dart +++ b/packages/stream_video/lib/protobuf/video/sfu/models/models.pbjson.dart @@ -381,6 +381,7 @@ const CallState$json = { '6': '.stream.video.sfu.models.Pin', '10': 'pins' }, + {'1': 'e2ee_enabled', '3': 5, '4': 1, '5': 8, '10': 'e2eeEnabled'}, ], }; @@ -391,7 +392,7 @@ final $typed_data.Uint8List callStateDescriptor = $convert.base64Decode( 'Z2xlLnByb3RvYnVmLlRpbWVzdGFtcFIJc3RhcnRlZEF0ElYKEXBhcnRpY2lwYW50X2NvdW50GA' 'MgASgLMikuc3RyZWFtLnZpZGVvLnNmdS5tb2RlbHMuUGFydGljaXBhbnRDb3VudFIQcGFydGlj' 'aXBhbnRDb3VudBIwCgRwaW5zGAQgAygLMhwuc3RyZWFtLnZpZGVvLnNmdS5tb2RlbHMuUGluUg' - 'RwaW5z'); + 'RwaW5zEiEKDGUyZWVfZW5hYmxlZBgFIAEoCFILZTJlZUVuYWJsZWQ='); @$core.Deprecated('Use participantCountDescriptor instead') const ParticipantCount$json = { diff --git a/packages/stream_video/lib/src/call/call.dart b/packages/stream_video/lib/src/call/call.dart index 8a798aa83..2be0c3209 100644 --- a/packages/stream_video/lib/src/call/call.dart +++ b/packages/stream_video/lib/src/call/call.dart @@ -45,6 +45,7 @@ import '../utils/none.dart'; import '../utils/result.dart'; import '../utils/standard.dart'; import '../utils/subscriptions.dart'; +import '../webrtc/e2ee/call_encryption_key.dart'; import '../webrtc/media/media_constraints.dart'; import '../webrtc/model/rtc_video_dimension.dart'; import '../webrtc/model/rtc_video_parameters.dart'; @@ -281,6 +282,22 @@ class Call { CallCredentials? _credentials; CallSession? _session; + + /// End-to-end encryption for this call, set via [setE2EEManager]. + EncryptionManager? _e2eeManager; + + /// Logs `e2ee.*` diagnostics for [_e2eeManager]. + StreamSubscription? _e2eeEventsSubscription; + + /// Tracks which [EncryptionManager] is attached to which [Call] via `callCid`. + /// Each manager must be mapped to just one call, and vice versa; both mappings are weak to allow cleanup. + static final Map _e2eeClaims = {}; + + /// Drops every recorded claim. Tests share one cid across cases, and the + /// registry outlives them. + @visibleForTesting + static void resetE2EEClaims() => _e2eeClaims.clear(); + CallSession? get callSession => _session; CallSession? _previousSession; StreamPeerConnectionFactory? _pcFactory; @@ -979,6 +996,243 @@ class Call { } } + /// The end-to-end encryption manager attached via [setE2EEManager]. + /// `null` when the call is unencrypted. + EncryptionManager? get e2eeManager => _e2eeManager; + + /// Turns on end-to-end encryption for this call. + /// Must run **before** [join]. The call's encryption mode must allow it. + /// + /// [EncryptionManager] takes raw key bytes β€” 16 for AES-128, 32 for + /// AES-256 β€” and has no opinion on how participants agreed on them. Deriving + /// them from a passphrase, or distributing them over your own channel, is up + /// to the app. + /// + /// ```dart + /// final e2ee = EncryptionManager.create(userId: client.currentUser.id); + /// await e2ee.setSharedKey(0, sharedKeyBytes); + /// + /// await call.setE2EEManager(e2ee); + /// await call.join(create: true); + /// ``` + /// + /// The manager is released and disposed when the call is left, so a fresh + /// one is needed per call. + Future setE2EEManager(EncryptionManager manager) async { + if (_session?.rtcManager != null) { + throw StateError( + 'setE2EEManager must be called before join(): this call already has ' + 'peer connections, and attaching now would leave the session ' + 'half-encrypted.', + ); + } + + if (manager.isDisposed) { + throw StateError( + 'setE2EEManager was given a disposed EncryptionManager; it can no ' + 'longer hold keys or attach transforms. Create a new one.', + ); + } + + final current = _e2eeManager; + if (current != null && !identical(current, manager)) { + throw StateError( + 'This call already has a different EncryptionManager. Overwriting it ' + 'would drop the current one while it still holds a native key store, ' + 'so release it first: clearE2EEManager() to dispose it, or ' + 'clearE2EEManager(dispose: false) to hand it to another call.', + ); + } + + final claimant = _e2eeClaims[callCid.value]?.call.target; + if (claimant != null && + !identical(claimant, this) && + !identical(claimant._e2eeManager, manager)) { + throw StateError( + 'Another Call instance for $callCid already has a different ' + 'EncryptionManager. Two managers on one call means two key stores, so ' + 'peers can only decrypt one of them. Reuse that Call, or release its ' + 'manager with clearE2EEManager() (or leave()) first.', + ); + } + + _e2eeClaims.removeWhere((_, claim) => claim.call.target == null); + for (final entry in _e2eeClaims.entries) { + if (entry.key == callCid.value) continue; + if (identical(entry.value.manager.target, manager)) { + throw StateError( + 'This EncryptionManager is already attached to call ${entry.key}. ' + 'Keys live on the manager, so sharing one between calls makes them ' + 'overwrite each other. Create a separate manager for $callCid.', + ); + } + } + + _logger.i(() => '[setE2EEManager] userId: ${manager.userId}'); + _e2eeClaims[callCid.value] = _E2eeClaim(this, manager); + _e2eeManager = manager; + + await _e2eeEventsSubscription?.cancel(); + _e2eeEventsSubscription = manager.events.listen( + _onE2eeEvent, + onError: (Object e) => _logger.w(() => '[e2ee] event stream error: $e'), + ); + } + + /// Builds and attaches an [EncryptionManager] from the app's key resolver, + /// for a call that does not have one yet. + Future> _resolveE2EEManager() async { + // An explicitly attached manager wins. + if (_e2eeManager != null) return const Result.success(none); + + final required = + state.value.settings.encryption.mode == StreamEncryptionMode.autoOn; + + final resolve = state.value.preferences.encryptionKeyResolver; + if (resolve == null) { + if (!required) return const Result.success(none); + + return Result.error( + 'This call requires end-to-end encryption, but no key was available. ' + 'Attach a manager with setE2EEManager() before join(), or set ' + 'encryptionKeyResolver on StreamVideoOptions.', + ); + } + + final CallEncryptionKey? key; + try { + key = await resolve( + CallEncryptionKeyRequest( + callCid: callCid, + encryptionMode: state.value.settings.encryption.mode, + ), + ); + } catch (e, stk) { + _logger.e(() => '[resolveE2EE] resolver threw: $e; $stk'); + return Result.error('The encryption key resolver failed: $e'); + } + + if (key == null) { + if (!required) { + _logger.d(() => '[resolveE2EE] no key, joining unencrypted'); + return const Result.success(none); + } + + return Result.error( + 'This call requires end-to-end encryption, but the key resolver ' + 'returned null for $callCid.', + ); + } + + if (!EncryptionManager.isSupported) { + return Result.error( + 'A key was provided for $callCid, but end-to-end encryption is not ' + 'available on this platform.', + ); + } + + final userId = _stateManager.callState.currentUserId; + + try { + final manager = EncryptionManager.create( + userId: userId, + algorithm: key.algorithm, + ); + + try { + switch (key) { + case SharedCallEncryptionKey(:final keyIndex, :final bytes): + await manager.setSharedKey(keyIndex, bytes); + } + + // A concurrent join, or a setE2EEManager the app made while the + // resolver was still running, may have attached one already. That one + // holds the keys the session will use, so this is the surplus manager + // and it is the one that has to give its handle back. + if (_e2eeManager != null) { + _logger.d(() => '[resolveE2EE] a manager was attached meanwhile'); + await manager.dispose().catchError((Object _) {}); + return const Result.success(none); + } + + await setE2EEManager(manager); + } catch (_) { + await manager.dispose().catchError((Object _) {}); + rethrow; + } + + _logger.i( + () => + '[resolveE2EE] attached, keyIndex: ${key!.keyIndex}, ' + 'algorithm: ${key.algorithm.name}', + ); + return const Result.success(none); + } catch (e, stk) { + _logger.e(() => '[resolveE2EE] failed: $e; $stk'); + return Result.error('Could not set up end-to-end encryption: $e'); + } + } + + /// Detaches the E2EE manager, so later joins are unencrypted again. + /// + /// Set [dispose] to `false` to keep the native manager alive, which is what + /// makes the hand-off work: [setE2EEManager] accepts it on another call + /// once this one has released it. + /// + /// ```dart + /// await callA.clearE2EEManager(dispose: false); + /// await callB.setE2EEManager(e2ee); // same keys, no re-derivation + /// ``` + /// + /// Does nothing when no manager is attached. + Future clearE2EEManager({bool dispose = true}) async { + final manager = _e2eeManager; + if (manager == null) return; + + if (dispose && _session?.rtcManager != null) { + _logger.w( + () => + '[clearE2EEManager] disposing while peer connections are still ' + 'up; this call will stop decrypting. Leave first.', + ); + } + + _logger.i(() => '[clearE2EEManager] dispose: $dispose'); + + _e2eeManager = null; + + if (identical(_e2eeClaims[callCid.value]?.call.target, this)) { + _e2eeClaims.remove(callCid.value); + } + + await _e2eeEventsSubscription?.cancel(); + _e2eeEventsSubscription = null; + + if (dispose) { + await manager.dispose().catchError((Object e) { + _logger.w(() => '[clearE2EEManager] dispose failed: $e'); + }); + } + } + + void _onE2eeEvent(E2eeEvent event) { + switch (event.type) { + case E2eeEventType.missingKey: + case E2eeEventType.decryptionFailed: + case E2eeEventType.encryptionFailed: + case E2eeEventType.unsupportedVersion: + case E2eeEventType.decryptionStalled: + _logger.e(() => '[e2ee] $event'); + case E2eeEventType.unencryptedFrame: + _logger.w(() => '[e2ee] $event'); + case E2eeEventType.decryptionResumed: + case E2eeEventType.keyState: + case E2eeEventType.perfReport: + case null: + _logger.d(() => '[e2ee] $event'); + } + } + /// Joins the call. /// /// - [connectOptions]: optional initial call configuration @@ -1037,6 +1291,14 @@ class Call { } } + // Before the call is marked active, because a call that cannot get its key + // is not going to be joined and should not look like it is being. + final e2eeResult = await _resolveE2EEManager(); + if (e2eeResult is Failure) { + _logger.e(() => '[join] rejected: ${e2eeResult.error.message}'); + return e2eeResult; + } + await _streamVideo.state.setActiveCall(this); _streamVideo.clientEventReporter @@ -1305,6 +1567,7 @@ class Call { streamVideo: _streamVideo, statsOptions: _sfuStatsOptions!, pcFactory: _ensurePcFactory(), + e2eeManager: _e2eeManager, leftoverTraceRecords: _previousSession ?.getTrace() @@ -1545,6 +1808,7 @@ class Call { video: video, membersLimit: membersLimit, hintHighScaleLivestreamPublisher: hintHighScaleLivestreamPublisher, + e2ee: _e2eeManager != null, ); if (joinResult is! Success) { @@ -1639,6 +1903,7 @@ class Call { /// - [frameRecording]: Frame recording settings for the call. /// - [individualRecording]: Individual recording settings for the call. /// - [rawRecording]: Raw recording settings for the call. + /// - [encryption]: Whether the call permits end-to-end encryption. Future> update({ Map? custom, DateTime? startsAt, @@ -1657,6 +1922,7 @@ class Call { StreamIndividualRecordingSettings? individualRecording, StreamRawRecordingSettings? rawRecording, StreamIngressSettings? ingress, + StreamEncryptionSettings? encryption, }) { return _coordinatorClient.updateCall( callCid: callCid, @@ -1677,6 +1943,7 @@ class Call { individualRecording: individualRecording, rawRecording: rawRecording, ingress: ingress, + encryption: encryption, ); } @@ -2492,6 +2759,7 @@ class Call { } await dynascaleManager.dispose(); + await clearE2EEManager(); await _streamVideo.state.removeActiveCall(this); if (_streamVideo.state.outgoingCall.valueOrNull?.callCid == callCid) { @@ -3041,6 +3309,7 @@ class Call { StreamFrameRecordingSettings? frameRecording, StreamIndividualRecordingSettings? individualRecording, StreamRawRecordingSettings? rawRecording, + StreamEncryptionSettings? encryption, Map custom = const {}, }) async { final settingsOverride = CallSettingsRequest( @@ -3059,6 +3328,7 @@ class Call { frameRecording: frameRecording?.toOpenDto(), individualRecording: individualRecording?.toOpenDto(), rawRecording: rawRecording?.toOpenDto(), + encryption: encryption?.toOpenDto(), ); final aggregatedMembers = [ @@ -4336,3 +4606,16 @@ class SessionConnectionFailure { final VideoError error; } + +/// One call cid's claim on an [EncryptionManager], held weakly. +/// +/// Both sides weak, so a claim never keeps either alive. [call] going null is +/// what makes a claim stale. +class _E2eeClaim { + _E2eeClaim(Call call, EncryptionManager manager) + : call = WeakReference(call), + manager = WeakReference(manager); + + final WeakReference call; + final WeakReference manager; +} diff --git a/packages/stream_video/lib/src/call/session/call_session.dart b/packages/stream_video/lib/src/call/session/call_session.dart index 540c35c9d..9c961dc5f 100644 --- a/packages/stream_video/lib/src/call/session/call_session.dart +++ b/packages/stream_video/lib/src/call/session/call_session.dart @@ -15,7 +15,6 @@ import '../../../protobuf/video/sfu/models/models.pbenum.dart'; import '../../../protobuf/video/sfu/signal_rpc/signal.pb.dart' as sfu; import '../../../stream_video.dart'; import '../../disposable.dart'; -import '../../errors/video_error.dart'; import '../../errors/video_error_composer.dart'; import '../../sfu/data/events/sfu_events.dart'; import '../../sfu/data/models/sfu_call_state.dart'; @@ -70,6 +69,7 @@ class CallSession extends Disposable { required Tracer tracer, required StreamPeerConnectionFactory pcFactory, this.clientPublishOptions, + this.e2eeManager, this.joinResponseTimeout = const Duration(seconds: 5), }) : _tracer = tracer, _streamVideo = streamVideo, @@ -115,6 +115,11 @@ class CallSession extends Disposable { final ClientPublishOptions? clientPublishOptions; final InternetConnection networkMonitor; final StatsOptions statsOptions; + + /// End-to-end encryption for this session, or `null` when the call is + /// unencrypted. Handed to every [RtcManager] this session builds. + final EncryptionManager? e2eeManager; + final Tracer _tracer; final Tracer _zonedTracer = Tracer(null); final StreamVideo _streamVideo; @@ -364,6 +369,7 @@ class CallSession extends Disposable { callSessionConfig: config, publishOptions: joinResponseEvent.publishOptions, clientEventRetryCount: clientEventRetryCount, + e2eeManager: e2eeManager, ) ..onSubscriberIceCandidate = _onLocalIceCandidate ..onRenegotiationNeeded = negotiateOrRecover @@ -391,6 +397,7 @@ class CallSession extends Disposable { statsOptions: statsOptions, callSessionConfig: config, clientEventRetryCount: clientEventRetryCount, + e2eeManager: e2eeManager, ) ..onPublisherIceCandidate = _onLocalIceCandidate ..onSubscriberIceCandidate = _onLocalIceCandidate @@ -405,6 +412,7 @@ class CallSession extends Disposable { _rtcManagerSubject!.add(rtcManager!); stateManager.sfuPinsUpdated(event.callState.pins); + stateManager.sfuE2eeEnabledUpdated(event.callState.e2eeEnabled); final environment = ClientEnvironment( sfu: config.sfuUrl, @@ -539,6 +547,7 @@ class CallSession extends Disposable { _logger.v(() => '[fastReconnect] fast-reconnect done'); stateManager.sfuPinsUpdated(event.callState.pins); + stateManager.sfuE2eeEnabledUpdated(event.callState.e2eeEnabled); result = Result.success( ( @@ -789,8 +798,12 @@ class CallSession extends Disposable { if (event is SfuJoinResponseEvent) { stateManager.sfuJoinResponse(event); + // The participant list just landed, so tracks that arrived before it + // can finally resolve a user id and attach their decryptor. + await rtcManager?.flushPendingDecryptors(); } else if (event is SfuParticipantJoinedEvent) { stateManager.sfuParticipantJoined(event); + await rtcManager?.flushPendingDecryptors(); } else if (event is SfuParticipantUpdatedEvent) { stateManager.sfuParticipantUpdated(event); } else if (event is SfuParticipantLeftEvent) { diff --git a/packages/stream_video/lib/src/call/session/call_session_factory.dart b/packages/stream_video/lib/src/call/session/call_session_factory.dart index a1ec789e3..361ae4d60 100644 --- a/packages/stream_video/lib/src/call/session/call_session_factory.dart +++ b/packages/stream_video/lib/src/call/session/call_session_factory.dart @@ -53,6 +53,7 @@ class CallSessionFactory { required StreamVideo streamVideo, required StreamPeerConnectionFactory pcFactory, ClientPublishOptions? clientPublishOptions, + EncryptionManager? e2eeManager, List leftoverTraceRecords = const [], }) async { final finalSessionId = sessionId ?? const Uuid().v4(); @@ -98,6 +99,7 @@ class CallSessionFactory { onReconnectionNeeded: onReconnectionNeeded, onSuspendedAudioTrackRecorded: onSuspendedAudioTrackRecorded, clientPublishOptions: clientPublishOptions, + e2eeManager: e2eeManager, networkMonitor: networkMonitor, statsOptions: statsOptions, streamVideo: streamVideo, diff --git a/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart b/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart index 7c91325f1..adf13535b 100644 --- a/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart +++ b/packages/stream_video/lib/src/call/state/mixins/state_sfu_mixin.dart @@ -169,6 +169,11 @@ mixin StateSfuMixin on StateNotifier, StatePendingTracksMixin { ); } + /// Records whether the SFU considers this call end-to-end encrypted. + void sfuE2eeEnabledUpdated(bool isE2eeEnabled) { + state = state.copyWith(isE2eeEnabled: isE2eeEnabled); + } + void sfuPinsUpdated( List pins, ) { diff --git a/packages/stream_video/lib/src/call_state.dart b/packages/stream_video/lib/src/call_state.dart index c23ae2141..f0ce8d8d4 100644 --- a/packages/stream_video/lib/src/call_state.dart +++ b/packages/stream_video/lib/src/call_state.dart @@ -66,6 +66,7 @@ class CallState extends Equatable { custom: const {}, isVideoModerated: false, isAudioSuspended: false, + isE2eeEnabled: false, ); } @@ -109,6 +110,7 @@ class CallState extends Equatable { required this.custom, required this.isVideoModerated, required this.isAudioSuspended, + required this.isE2eeEnabled, }); final CallPreferences preferences; @@ -155,6 +157,9 @@ class CallState extends Equatable { /// Whether audio tracks have been suspended for this call. final bool isAudioSuspended; + /// Whether the SFU reports this call as end-to-end encrypted. + final bool isE2eeEnabled; + String get callId => callCid.id; StreamCallType get callType => callCid.type; @@ -228,6 +233,7 @@ class CallState extends Equatable { Map? custom, bool? isVideoModerated, bool? isAudioSuspended, + bool? isE2eeEnabled, }) { return CallState._( preferences: preferences ?? this.preferences, @@ -272,6 +278,7 @@ class CallState extends Equatable { custom: custom ?? this.custom, isVideoModerated: isVideoModerated ?? this.isVideoModerated, isAudioSuspended: isAudioSuspended ?? this.isAudioSuspended, + isE2eeEnabled: isE2eeEnabled ?? this.isE2eeEnabled, ); } @@ -347,6 +354,7 @@ class CallState extends Equatable { custom, isVideoModerated, isAudioSuspended, + isE2eeEnabled, ]; @override @@ -356,6 +364,7 @@ class CallState extends Equatable { ' sessionId: $sessionId, isRecording: $isRecording,' ' isVideoModerated: $isVideoModerated,' ' isAudioSuspended: $isAudioSuspended,' + ' isE2eeEnabled: $isE2eeEnabled,' ' settings: $settings, egress: $egress, ' ' videoInputDevice: $videoInputDevice,' ' audioInputDevice: $audioInputDevice,' diff --git a/packages/stream_video/lib/src/coordinator/coordinator_client.dart b/packages/stream_video/lib/src/coordinator/coordinator_client.dart index 17165c4d4..0df0ea671 100644 --- a/packages/stream_video/lib/src/coordinator/coordinator_client.dart +++ b/packages/stream_video/lib/src/coordinator/coordinator_client.dart @@ -92,6 +92,12 @@ abstract class CoordinatorClient { bool? video, int? membersLimit, bool? hintHighScaleLivestreamPublisher, + + /// Whether this client publishes end-to-end encrypted media. + /// + /// The server rejects the join when it does not match the call's + /// encryption configuration. + bool? e2ee, }); Future>> ringCall({ @@ -325,6 +331,9 @@ abstract class CoordinatorClient { StreamIndividualRecordingSettings? individualRecording, StreamRawRecordingSettings? rawRecording, StreamIngressSettings? ingress, + + /// Whether the call permits end-to-end encryption. + StreamEncryptionSettings? encryption, }); Future> loadGuest({ diff --git a/packages/stream_video/lib/src/coordinator/open_api/coordinator_client_open_api.dart b/packages/stream_video/lib/src/coordinator/open_api/coordinator_client_open_api.dart index 0348a391d..34124f3d5 100644 --- a/packages/stream_video/lib/src/coordinator/open_api/coordinator_client_open_api.dart +++ b/packages/stream_video/lib/src/coordinator/open_api/coordinator_client_open_api.dart @@ -523,6 +523,7 @@ class CoordinatorClientOpenApi extends CoordinatorClient { bool? video, int? membersLimit, bool? hintHighScaleLivestreamPublisher, + bool? e2ee, }) async { try { _logger.d( @@ -550,6 +551,7 @@ class CoordinatorClientOpenApi extends CoordinatorClient { migratingFromList: migratingFromList, video: video, hintHighScaleLivestreamPublisher: hintHighScaleLivestreamPublisher, + e2ee: e2ee, ), ); _logger.v(() => '[joinCall] completed: $result'); @@ -1482,6 +1484,9 @@ class CoordinatorClientOpenApi extends CoordinatorClient { StreamIndividualRecordingSettings? individualRecording, StreamRawRecordingSettings? rawRecording, StreamIngressSettings? ingress, + + /// Whether the call permits end-to-end encryption. + StreamEncryptionSettings? encryption, }) async { try { final connectionResult = await _waitUntilConnected(); @@ -1508,6 +1513,7 @@ class CoordinatorClientOpenApi extends CoordinatorClient { broadcasting: broadcasting?.toOpenDto(), session: session?.toOpenDto(), frameRecording: frameRecording?.toOpenDto(), + encryption: encryption?.toOpenDto(), individualRecording: individualRecording?.toOpenDto(), rawRecording: rawRecording?.toOpenDto(), ingress: ingress?.toOpenDto(), diff --git a/packages/stream_video/lib/src/coordinator/open_api/open_api_extensions.dart b/packages/stream_video/lib/src/coordinator/open_api/open_api_extensions.dart index 2a1e0f51a..9b39480e1 100644 --- a/packages/stream_video/lib/src/coordinator/open_api/open_api_extensions.dart +++ b/packages/stream_video/lib/src/coordinator/open_api/open_api_extensions.dart @@ -249,6 +249,9 @@ extension CallSettingsExt on open.CallSettingsResponse { cameraFacing: video.cameraFacing.toRequestDomain(), targetResolution: video.targetResolution.toSettingsDomain(), ), + encryption: StreamEncryptionSettings( + mode: StreamEncryptionMode.fromString(encryption.mode.value), + ), screenShare: StreamScreenShareSettings( accessRequestEnabled: screensharing.accessRequestEnabled, enabled: screensharing.enabled, diff --git a/packages/stream_video/lib/src/coordinator/retry/coordinator_client_retry.dart b/packages/stream_video/lib/src/coordinator/retry/coordinator_client_retry.dart index 30a7a5236..93e93d88b 100644 --- a/packages/stream_video/lib/src/coordinator/retry/coordinator_client_retry.dart +++ b/packages/stream_video/lib/src/coordinator/retry/coordinator_client_retry.dart @@ -284,6 +284,7 @@ class CoordinatorClientRetry extends CoordinatorClient { bool? video, int? membersLimit, bool? hintHighScaleLivestreamPublisher, + bool? e2ee, }) { return _retryManager.execute( () => _delegate.joinCall( @@ -294,6 +295,7 @@ class CoordinatorClientRetry extends CoordinatorClient { migratingFromList: migratingFromList, video: video, hintHighScaleLivestreamPublisher: hintHighScaleLivestreamPublisher, + e2ee: e2ee, ), (error, nextAttemptDelay) async { _logRetry('joinCall', error, nextAttemptDelay); @@ -800,6 +802,7 @@ class CoordinatorClientRetry extends CoordinatorClient { StreamIndividualRecordingSettings? individualRecording, StreamRawRecordingSettings? rawRecording, StreamIngressSettings? ingress, + StreamEncryptionSettings? encryption, }) { return _retryManager.execute( () => _delegate.updateCall( @@ -820,6 +823,7 @@ class CoordinatorClientRetry extends CoordinatorClient { individualRecording: individualRecording, rawRecording: rawRecording, ingress: ingress, + encryption: encryption, ), (error, nextAttemptDelay) async { _logRetry('updateCall', error, nextAttemptDelay); diff --git a/packages/stream_video/lib/src/models/call_preferences.dart b/packages/stream_video/lib/src/models/call_preferences.dart index 6365c9887..913c13b24 100644 --- a/packages/stream_video/lib/src/models/call_preferences.dart +++ b/packages/stream_video/lib/src/models/call_preferences.dart @@ -1,3 +1,4 @@ +import '../webrtc/e2ee/call_encryption_key.dart'; import 'audio_configuration_policy.dart'; import 'call_client_publish_options.dart'; import 'moderation_blur_config.dart'; @@ -60,6 +61,27 @@ abstract class CallPreferences { /// native peer-connection factory is built with this policy instead of /// the client-level default `StreamVideoOptions.audioConfigurationPolicy`. AudioConfigurationPolicy? get audioConfigurationPolicy; + + /// Supplies the shared key for this call when no `EncryptionManager` has been + /// attached to it by hand. + /// + /// Mostly for the ringing flow, which is where the SDK, not the app, decides + /// when the join happens. Answering a call from a notification, or after the + /// process was killed, leaves nowhere to attach a manager and nobody to ask + /// for a key β€” so pass it in with the preferences the accept already carries: + /// + /// ```dart + /// streamVideo.observeCoreRingingEvents( + /// acceptCallPreferences: DefaultCallPreferences( + /// encryptionKeyResolver: myResolver, + /// ), + /// ); + /// ``` + /// + /// Set it on `StreamVideoOptions.defaultCallPreferences` to cover every call + /// instead, or on a single `makeCall`. A manager attached with + /// `setE2EEManager` always wins, and the resolver is not called at all. + CallEncryptionKeyResolver? get encryptionKeyResolver; } class DefaultCallPreferences implements CallPreferences { @@ -75,6 +97,7 @@ class DefaultCallPreferences implements CallPreferences { this.closedCaptionsVisibleCaptions = 2, this.videoModerationConfig = const VideoModerationConfig.disabled(), this.audioConfigurationPolicy, + this.encryptionKeyResolver, }); /// The maximum duration to wait when establishing a connection to the call. @@ -164,4 +187,9 @@ class DefaultCallPreferences implements CallPreferences { /// Defaults to null (falls back to `StreamVideoOptions.audioConfigurationPolicy`). @override final AudioConfigurationPolicy? audioConfigurationPolicy; + + /// Supplies the shared key for this call when no manager was attached by + /// hand. See [CallPreferences.encryptionKeyResolver]. + @override + final CallEncryptionKeyResolver? encryptionKeyResolver; } diff --git a/packages/stream_video/lib/src/models/call_settings.dart b/packages/stream_video/lib/src/models/call_settings.dart index 246dc38b7..92e78e655 100644 --- a/packages/stream_video/lib/src/models/call_settings.dart +++ b/packages/stream_video/lib/src/models/call_settings.dart @@ -19,6 +19,7 @@ class CallSettings extends Equatable { this.backstage = const StreamBackstageSettings(), this.geofencing = const StreamGeofencingSettings(), this.limits = const StreamLimitsSettings(), + this.encryption = const StreamEncryptionSettings(), this.session = const StreamSessionSettings(), this.frameRecording = const StreamFrameRecordingSettings(), this.individualRecording = const StreamIndividualRecordingSettings(), @@ -36,6 +37,10 @@ class CallSettings extends Equatable { final StreamBackstageSettings backstage; final StreamGeofencingSettings geofencing; final StreamLimitsSettings limits; + + /// Whether this call permits end-to-end encryption. + final StreamEncryptionSettings encryption; + final StreamSessionSettings session; final StreamFrameRecordingSettings frameRecording; final StreamIndividualRecordingSettings individualRecording; @@ -58,6 +63,7 @@ class CallSettings extends Equatable { StreamBackstageSettings? backstage, StreamGeofencingSettings? geofencing, StreamLimitsSettings? limits, + StreamEncryptionSettings? encryption, StreamSessionSettings? session, StreamFrameRecordingSettings? frameRecording, StreamIndividualRecordingSettings? individualRecording, @@ -75,6 +81,7 @@ class CallSettings extends Equatable { backstage: backstage ?? this.backstage, geofencing: geofencing ?? this.geofencing, limits: limits ?? this.limits, + encryption: encryption ?? this.encryption, session: session ?? this.session, frameRecording: frameRecording ?? this.frameRecording, individualRecording: individualRecording ?? this.individualRecording, @@ -238,6 +245,25 @@ class StreamBackstageSettings extends AbstractSettings { } } +/// Whether a call permits end-to-end encryption. +/// +/// A client that attaches an `EncryptionManager` joins with `e2ee: true`; the +/// server rejects that join unless the call's mode allows it. +class StreamEncryptionSettings extends AbstractSettings { + const StreamEncryptionSettings({ + this.mode = StreamEncryptionMode.disabled, + }); + + final StreamEncryptionMode mode; + + @override + List get props => [mode]; + + EncryptionSettingsRequest toOpenDto() { + return EncryptionSettingsRequest(mode: mode.toOpenDto()); + } +} + class StreamLimitsSettings extends AbstractSettings { const StreamLimitsSettings({ this.maxDurationSeconds, @@ -1002,6 +1028,51 @@ enum FrameRecordingSettingsMode { } } +/// Encryption modes a call can be configured with. +enum StreamEncryptionMode { + /// Clients may join encrypted or unencrypted. + available, + + /// Encryption is off; joining with `e2ee: true` is rejected. + disabled, + + /// Encryption is expected; server-side recording, transcription and + /// broadcasting are unavailable because the SFU only sees opaque frames. + autoOn; + + @override + String toString() => name; + + EncryptionSettingsRequestModeEnum toOpenDto() { + switch (this) { + case StreamEncryptionMode.available: + return EncryptionSettingsRequestModeEnum.available; + case StreamEncryptionMode.disabled: + return EncryptionSettingsRequestModeEnum.disabled; + case StreamEncryptionMode.autoOn: + return EncryptionSettingsRequestModeEnum.autoOn; + } + } + + /// Parses a wire value. + /// + /// Accepts the hyphenated wire spelling `auto-on` as well as the enum name, + /// and falls back to [StreamEncryptionMode.disabled] for anything else so an + /// unknown future mode never reads as "encryption is on". + static StreamEncryptionMode fromString(String value) { + switch (value) { + case 'available': + return StreamEncryptionMode.available; + case 'auto-on': + case 'autoOn': + return StreamEncryptionMode.autoOn; + case 'disabled': + default: + return StreamEncryptionMode.disabled; + } + } +} + enum RTMPSettingsQuality { n360p, n480p, diff --git a/packages/stream_video/lib/src/sfu/data/events/sfu_event_mapper_extensions.dart b/packages/stream_video/lib/src/sfu/data/events/sfu_event_mapper_extensions.dart index 74d2a87cb..18ec6206e 100644 --- a/packages/stream_video/lib/src/sfu/data/events/sfu_event_mapper_extensions.dart +++ b/packages/stream_video/lib/src/sfu/data/events/sfu_event_mapper_extensions.dart @@ -235,6 +235,7 @@ extension SfuCallStateExtension on sfu_models.CallState { participantCount: participantCount.toDomain(), startedAt: startedAt.toDateTime(), pins: pins.map((it) => it.toDomain()).toList(), + e2eeEnabled: e2eeEnabled, ); } } diff --git a/packages/stream_video/lib/src/sfu/data/models/sfu_call_state.dart b/packages/stream_video/lib/src/sfu/data/models/sfu_call_state.dart index 0815cba66..5d037da26 100644 --- a/packages/stream_video/lib/src/sfu/data/models/sfu_call_state.dart +++ b/packages/stream_video/lib/src/sfu/data/models/sfu_call_state.dart @@ -11,6 +11,7 @@ class SfuCallState extends Equatable { required this.participantCount, required this.startedAt, required this.pins, + required this.e2eeEnabled, }); final List participants; @@ -18,15 +19,18 @@ class SfuCallState extends Equatable { final DateTime startedAt; final List pins; + /// Whether the SFU negotiated this call as end-to-end encrypted. + final bool e2eeEnabled; + @override String toString() { return 'SfuCallState{participants: $participants, ' - 'participantCount: $participantCount}'; + 'participantCount: $participantCount, e2eeEnabled: $e2eeEnabled}'; } @override bool? get stringify => true; @override - List get props => [participants, participantCount]; + List get props => [participants, participantCount, e2eeEnabled]; } diff --git a/packages/stream_video/lib/src/webrtc/e2ee/call_encryption_key.dart b/packages/stream_video/lib/src/webrtc/e2ee/call_encryption_key.dart new file mode 100644 index 000000000..263b07da7 --- /dev/null +++ b/packages/stream_video/lib/src/webrtc/e2ee/call_encryption_key.dart @@ -0,0 +1,113 @@ +import 'dart:typed_data'; + +import 'package:equatable/equatable.dart'; +import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart' as rtc; + +import '../../models/call_cid.dart'; +import '../../models/call_settings.dart'; + +/// A key the SDK sets end-to-end encryption up with on the app's behalf. +/// +/// Returned from a [CallEncryptionKeyResolver] when a call needs a key and the +/// app has not attached an `EncryptionManager` to it by hand. +/// +/// Per-user keys are deliberately absent. `setKey(userId, ...)` needs a key for +/// each remote participant, and participants keep arriving after the join, so a +/// resolver called once beforehand cannot cover them. Set those through +/// `call.e2eeManager` as the participant list changes. +sealed class CallEncryptionKey extends Equatable { + const CallEncryptionKey._({required this.keyIndex, required this.algorithm}); + + /// One key that every participant on the call holds. + /// + /// This is the passphrase-style setup: everyone derives the same bytes and + /// imports them at the same index. + /// + /// [keyIndex] has to match what every other participant uses. Frames carry + /// the index they were encrypted with, and a receiver looking at another slot + /// fails every decrypt. + factory CallEncryptionKey.shared({ + required Uint8List bytes, + int keyIndex, + rtc.EncryptionAlgorithm algorithm, + }) = SharedCallEncryptionKey; + + /// The key slot this key occupies, written into every frame. + final int keyIndex; + + /// The AES-GCM variant, which fixes the required key length. + final rtc.EncryptionAlgorithm algorithm; +} + +/// One key shared by every participant on the call. +final class SharedCallEncryptionKey extends CallEncryptionKey { + SharedCallEncryptionKey({ + required this.bytes, + int keyIndex = 0, + rtc.EncryptionAlgorithm algorithm = rtc.EncryptionAlgorithm.aes128Gcm, + }) : super._(keyIndex: keyIndex, algorithm: algorithm) { + if (bytes.length != algorithm.keyLengthBytes) { + throw ArgumentError.value( + bytes.length, + 'bytes', + 'must be ${algorithm.keyLengthBytes} bytes for ${algorithm.name}', + ); + } + if (keyIndex < 0 || keyIndex > 255) { + throw ArgumentError.value(keyIndex, 'keyIndex', 'must be 0..255'); + } + } + + /// The raw key. Never logged and never sent anywhere by the SDK. + final Uint8List bytes; + + /// Deliberately excludes [bytes], so a key cannot reach a log through + /// `toString` or an equality mismatch report. + @override + List get props => [keyIndex, algorithm]; + + @override + String toString() => + 'SharedCallEncryptionKey(keyIndex: $keyIndex, ' + 'algorithm: ${algorithm.name})'; +} + +/// What the SDK knows about the call it is asking for a key for. +class CallEncryptionKeyRequest extends Equatable { + const CallEncryptionKeyRequest({ + required this.callCid, + required this.encryptionMode, + }); + + /// The call a key is being asked for. + final StreamCallCid callCid; + + /// The encryption mode the coordinator resolved, as far as this client knows. + final StreamEncryptionMode encryptionMode; + + @override + List get props => [callCid, encryptionMode]; + + @override + String toString() => + 'CallEncryptionKeyRequest(callCid: $callCid, mode: ${encryptionMode.name})'; +} + +/// Provides the shared key for a call the app has not set up by hand. +/// +/// The SDK calls this once per call, before the first join attempt, and only +/// when no `EncryptionManager` is attached. An attached manager always wins: +/// the app has already said which keys the call uses. +/// +/// Return `null` for a call that needs no key from you. That is fine for a call +/// whose encryption is `available`, which then joins unencrypted, and an error +/// for one that is `auto-on`, which cannot be joined without a key at all. +/// [CallEncryptionKeyRequest.encryptionMode] is there so a resolver can answer +/// only for the calls that need it. +/// +/// Keep it quick. It runs inside the join, and on platforms where answering a +/// call holds a system watchdog open β€” CallKit's answer action, on SDKs that +/// defer fulfilment until the join completes β€” a slow key fetch can get the +/// join timed out. +typedef CallEncryptionKeyResolver = + Future Function(CallEncryptionKeyRequest request); diff --git a/packages/stream_video/lib/src/webrtc/e2ee/e2ee_mapping.dart b/packages/stream_video/lib/src/webrtc/e2ee/e2ee_mapping.dart new file mode 100644 index 000000000..2aad590f6 --- /dev/null +++ b/packages/stream_video/lib/src/webrtc/e2ee/e2ee_mapping.dart @@ -0,0 +1,30 @@ +import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart' as rtc; + +import '../../sfu/data/models/sfu_codec.dart'; +import '../../sfu/data/models/sfu_track_type.dart'; + +extension SfuCodecE2EE on SfuCodec { + /// The codec pin for this track's frame transform, lowercased and exact. + String? get e2eeCodecPin { + final normalized = name.toLowerCase().trim(); + return normalized.isEmpty ? null : normalized; + } +} + +extension SfuTrackTypeE2EE on SfuTrackType { + /// The replay-window grouping for this track. + /// + /// Screenshare maps explicitly rather than collapsing into video, so a + /// screen share and a camera from the same user never share one window. + /// Returns `null` for [SfuTrackType.unspecified], letting native pick audio + /// vs video from the RTP sender or receiver. + rtc.E2eeTrackType? get e2eeTrackType { + if (this == SfuTrackType.audio) return rtc.E2eeTrackType.audio; + if (this == SfuTrackType.video) return rtc.E2eeTrackType.video; + if (this == SfuTrackType.screenShare) return rtc.E2eeTrackType.screenShare; + if (this == SfuTrackType.screenShareAudio) { + return rtc.E2eeTrackType.screenShareAudio; + } + return null; + } +} diff --git a/packages/stream_video/lib/src/webrtc/rtc_manager.dart b/packages/stream_video/lib/src/webrtc/rtc_manager.dart index 9b3fcf5aa..0893693c5 100644 --- a/packages/stream_video/lib/src/webrtc/rtc_manager.dart +++ b/packages/stream_video/lib/src/webrtc/rtc_manager.dart @@ -21,6 +21,7 @@ import '../telemetry/peer_connection_connect_reporter.dart'; import '../utils/extensions.dart'; import 'codecs_helper.dart' as codecs; import 'codecs_helper.dart'; +import 'e2ee/e2ee_mapping.dart'; import 'model/rtc_tracks_info.dart'; import 'model/rtc_video_encoding.dart'; import 'peer_connection.dart'; @@ -65,6 +66,7 @@ class RtcManager extends Disposable { required this.stateManager, required StreamVideo streamVideo, required this.pcFactory, + this.e2eeManager, this.sfuId, this.clientEventRetryCount = 0, }) : _streamVideo = streamVideo { @@ -90,6 +92,17 @@ class RtcManager extends Disposable { final StreamPeerConnectionFactory pcFactory; + /// End-to-end encryption for this session, or `null` when the call is + /// unencrypted. + /// + /// Set before the peer connections carry any media: local transceivers get + /// an encryptor as they are added, remote receivers get a decryptor as + /// tracks arrive. + final rtc.EncryptionManager? e2eeManager; + + /// Remote tracks that arrived before their participant was known. + final _pendingDecryptors = {}; + final transceiversManager = TransceiverManager(); bool _isDisposing = false; @@ -302,6 +315,8 @@ class RtcManager extends Disposable { transceiver: transceiver, ); + unawaited(attachDecryptor(remoteTrack)); + onRemoteTrackReceived?.call(pc, remoteTrack); tracks[remoteTrack.trackId] = remoteTrack; _logger.v(() => '[onRemoteTrack] published: ${remoteTrack.trackId}'); @@ -613,6 +628,89 @@ class RtcManager extends Disposable { ); } + /// The user id that owns the remote track under [trackIdPrefix]. + String? _userIdForTrackPrefix(String trackIdPrefix) { + return stateManager.callState.callParticipants + .firstWhereOrNull((it) => it.trackIdPrefix == trackIdPrefix) + ?.userId; + } + + /// Decrypts everything [track]'s receiver delivers, or queues the track + /// until its participant is known. + Future attachDecryptor(RtcRemoteTrack track) async { + final manager = e2eeManager; + if (manager == null) return; + + final userId = _userIdForTrackPrefix(track.trackIdPrefix); + if (userId == null) { + _logger.d( + () => + '[attachDecryptor] no participant yet for ' + '${track.trackIdPrefix}; queueing ${track.trackId}', + ); + _pendingDecryptors[track.trackId] = track; + return; + } + + final receiver = await _receiverForTrack(track); + if (receiver == null) { + _logger.w( + () => + '[attachDecryptor] no RTP receiver for ${track.trackId}; ' + 'frames from $userId stay encrypted', + ); + return; + } + + try { + await manager.decrypt( + receiver, + userId: userId, + trackType: track.trackType.e2eeTrackType, + ); + + _logger.d( + () => + '[attachDecryptor] attached for userId: $userId, ' + 'trackType: ${track.trackType}', + ); + } catch (e, stk) { + _logger.e( + () => '[attachDecryptor] failed for userId: $userId: $e', + ); + _logger.v(() => '[attachDecryptor] $stk'); + } + } + + /// Retries decryptors that were waiting on a participant. + Future flushPendingDecryptors() async { + if (e2eeManager == null || _pendingDecryptors.isEmpty) return; + + final pending = [..._pendingDecryptors.values]; + _pendingDecryptors.clear(); + + for (final track in pending) { + await attachDecryptor(track); + } + } + + /// The subscriber receiver carrying [track]. + Future _receiverForTrack(RtcRemoteTrack track) async { + final trackId = track.mediaTrack.id; + + try { + final transceivers = await subscriber.pc.getTransceivers(); + final match = transceivers.firstWhereOrNull( + (it) => it.receiver.track?.id == trackId, + ); + if (match != null) return match.receiver; + } catch (e) { + _logger.w(() => '[receiverForTrack] getTransceivers failed: $e'); + } + + return track.transceiver?.receiver; + } + @override Future dispose() async { _logger.d(() => '[dispose] no args'); @@ -635,6 +733,7 @@ class RtcManager extends Disposable { ); tracks.clear(); + _pendingDecryptors.clear(); onLocalTrackMuted = null; onLocalTrackPublished = null; @@ -1424,6 +1523,46 @@ extension PublisherRtcManager on RtcManager { }); } + /// Encrypts everything [transceiver]'s sender publishes. + Future> _attachEncryptor( + rtc.RTCRtpTransceiver transceiver, + SfuPublishOptions publishOptions, + ) async { + final manager = e2eeManager; + if (manager == null) return const Result.success(none); + + final codecPin = publishOptions.codec.e2eeCodecPin; + final trackType = publishOptions.trackType.e2eeTrackType; + + try { + await manager.encrypt( + transceiver.sender, + codec: codecPin, + trackType: trackType, + ); + + _logger.d( + () => + '[attachEncryptor] attached; codec: $codecPin, ' + 'trackType: $trackType', + ); + + return const Result.success(none); + } catch (e, stk) { + _logger.e( + () => + '[attachEncryptor] failed for trackType: ' + '${publishOptions.trackType}; refusing to publish cleartext: $e', + ); + _logger.v(() => '[attachEncryptor] $stk'); + + return Result.error( + 'Failed to attach the E2EE encryptor for trackType: ' + '${publishOptions.trackType}: $e', + ); + } + } + Future> _createTransceiver( RtcLocalTrack track, SfuPublishOptions publishOptions, @@ -1473,6 +1612,16 @@ extension PublisherRtcManager on RtcManager { final transceiver = transceiverResult.getDataOrNull()!; + // Attach before the answer is negotiated so the first encoded frame this + // sender produces is already encrypted. + final encryptorResult = await _attachEncryptor(transceiver, publishOptions); + if (encryptorResult is Failure) { + // Drop the sender: leaving it in place would negotiate an m-line that + // publishes unencrypted media. + await _stopTransceiver(transceiver); + return Result.error(encryptorResult.error.message); + } + final cached = transceiversManager.add( track, publishOptions, diff --git a/packages/stream_video/lib/src/webrtc/rtc_manager_factory.dart b/packages/stream_video/lib/src/webrtc/rtc_manager_factory.dart index 8354974dd..4f5db5983 100644 --- a/packages/stream_video/lib/src/webrtc/rtc_manager_factory.dart +++ b/packages/stream_video/lib/src/webrtc/rtc_manager_factory.dart @@ -40,6 +40,7 @@ class RtcManagerFactory { StatsOptions? statsOptions, CallSessionConfig? callSessionConfig, int clientEventRetryCount = 0, + EncryptionManager? e2eeManager, }) async { _logger.d(() => '[makeRtcManager] publisherId: $publisherId'); @@ -81,6 +82,7 @@ class RtcManagerFactory { pcFactory: pcFactory, sfuId: callSessionConfig?.sfuName, clientEventRetryCount: clientEventRetryCount, + e2eeManager: e2eeManager, ); } } diff --git a/packages/stream_video/lib/stream_video.dart b/packages/stream_video/lib/stream_video.dart index 6b920b847..f51f3cbea 100644 --- a/packages/stream_video/lib/stream_video.dart +++ b/packages/stream_video/lib/stream_video.dart @@ -5,6 +5,19 @@ /// library stream_video; +/// End-to-end encryption, backed by the native `EncryptionManager`. +export 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart' + show + E2eeEvent, + E2eeEventType, + E2eeKeyState, + E2eeSharedKey, + E2eeTrackPerf, + E2eeTrackType, + E2eeUserKey, + EncryptionAlgorithm, + EncryptionManager; + export 'open_api/video/coordinator/api.dart'; export 'src/audio_processing/audio_processor.dart'; export 'src/audio_processing/audio_recognition.dart'; @@ -19,6 +32,7 @@ export 'src/call/session/dynascale_manager.dart'; export 'src/call_state.dart'; export 'src/coordinator/coordinator_client.dart'; export 'src/coordinator/models/coordinator_events.dart'; +export 'src/errors/video_error.dart'; export 'src/logger/impl/console_logger.dart'; export 'src/logger/impl/file_logger.dart'; export 'src/logger/impl/tagged_logger.dart'; @@ -47,6 +61,7 @@ export 'src/utils/none.dart'; export 'src/utils/result.dart'; export 'src/utils/string.dart'; export 'src/utils/subscriptions.dart'; +export 'src/webrtc/e2ee/call_encryption_key.dart'; export 'src/webrtc/media/media_constraints.dart'; export 'src/webrtc/model/rtc_video_dimension.dart'; export 'src/webrtc/model/rtc_video_parameters.dart'; diff --git a/packages/stream_video/test/src/call/call_allow_multiple_active_calls_test.dart b/packages/stream_video/test/src/call/call_allow_multiple_active_calls_test.dart index d09ebaaa0..e91ba73a7 100644 --- a/packages/stream_video/test/src/call/call_allow_multiple_active_calls_test.dart +++ b/packages/stream_video/test/src/call/call_allow_multiple_active_calls_test.dart @@ -61,6 +61,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), ).thenAnswer((_) async => Result.error('join not implemented in test')); }); diff --git a/packages/stream_video/test/src/call/call_audio_processing_test.dart b/packages/stream_video/test/src/call/call_audio_processing_test.dart index 323b488b8..364848a7c 100644 --- a/packages/stream_video/test/src/call/call_audio_processing_test.dart +++ b/packages/stream_video/test/src/call/call_audio_processing_test.dart @@ -5,7 +5,6 @@ import 'package:internet_connection_checker_plus/internet_connection_checker_plu import 'package:mocktail/mocktail.dart'; import 'package:rxdart/rxdart.dart'; import 'package:stream_video/src/coordinator/models/coordinator_models.dart'; -import 'package:stream_video/src/errors/video_error.dart'; import 'package:stream_video/src/state_emitter.dart'; import 'package:stream_video/stream_video.dart'; @@ -461,6 +460,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), ).thenAnswer( (_) async => Result.success( @@ -551,6 +551,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), ).thenAnswer( (_) async => Result.success( @@ -595,6 +596,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), ).thenAnswer( (_) async => Result.success( @@ -803,6 +805,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), ).thenAnswer( (_) async => Result.success( @@ -881,6 +884,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), ).thenAnswer( (_) async => Result.success( @@ -938,6 +942,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), ).thenAnswer( (_) async => Result.success( @@ -1001,6 +1006,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), ).thenAnswer( (_) async => Result.success( diff --git a/packages/stream_video/test/src/call/call_e2ee_resolver_test.dart b/packages/stream_video/test/src/call/call_e2ee_resolver_test.dart new file mode 100644 index 000000000..46d2cdc78 --- /dev/null +++ b/packages/stream_video/test/src/call/call_e2ee_resolver_test.dart @@ -0,0 +1,254 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_video/stream_video.dart'; + +import 'fixtures/call_test_helpers.dart'; +import 'fixtures/data.dart'; + +class MockEncryptionManager extends Mock implements EncryptionManager {} + +/// A call state carrying [resolver], and the encryption mode the coordinator +/// resolved when one is given. +CallState _stateWith({ + CallEncryptionKeyResolver? resolver, + StreamEncryptionMode? mode, +}) { + final state = CallState( + preferences: DefaultCallPreferences(encryptionKeyResolver: resolver), + currentUserId: SampleCallData.defaultUserInfo.id, + callCid: SampleCallData.defaultCid, + ); + + if (mode == null) return state; + return state.copyWith( + settings: CallSettings(encryption: StreamEncryptionSettings(mode: mode)), + ); +} + +void main() { + setUpAll(() { + registerMockFallbackValues(); + registerFallbackValue(Uint8List(0)); + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('Call encryption key resolver', () { + late MockEncryptionManager manager; + + /// The requests the resolver was given. + late List asked; + + setUp(() { + manager = MockEncryptionManager(); + asked = []; + + when(() => manager.userId).thenReturn(SampleCallData.defaultUserInfo.id); + when(() => manager.algorithm).thenReturn(EncryptionAlgorithm.aes128Gcm); + when( + () => manager.events, + ).thenAnswer((_) => const Stream.empty()); + when(() => manager.isDisposed).thenReturn(false); + when(() => manager.dispose()).thenAnswer((_) async {}); + when(() => manager.setSharedKey(any(), any())).thenAnswer((_) async {}); + }); + + tearDown(Call.resetE2EEClaims); + + CallEncryptionKeyResolver resolverReturning( + CallEncryptionKey? key, { + Exception? throws, + }) { + return (request) async { + asked.add(request); + if (throws != null) throw throws; + return key; + }; + } + + CallEncryptionKey aes128([int keyIndex = 0]) => + CallEncryptionKey.shared(bytes: Uint8List(16), keyIndex: keyIndex); + + test('an attached manager wins and the resolver is never asked', () async { + final call = createTestCallWithState( + initialState: _stateWith(resolver: resolverReturning(aes128())), + ); + + await call.setE2EEManager(manager); + await call.join(); + + expect(asked, isEmpty); + expect(call.e2eeManager, same(manager)); + }); + + test('no key and no requirement joins unencrypted', () async { + final coordinatorClient = setupMockCoordinatorClient(); + final call = createTestCallWithState( + initialState: _stateWith(resolver: resolverReturning(null)), + coordinatorClient: coordinatorClient, + ); + + final result = await call.join(); + + expect(result.isSuccess, isTrue); + expect(call.e2eeManager, isNull); + expect(asked.single.callCid, SampleCallData.defaultCid); + }); + + test('the resolver is told the mode the coordinator resolved', () async { + final call = createTestCallWithState( + initialState: _stateWith( + resolver: resolverReturning(aes128()), + mode: StreamEncryptionMode.autoOn, + ), + ); + + await call.join(); + + // Without this a resolver cannot answer "only for calls that need it", + // and a key handed to a plain call gets the join rejected. + expect(asked.single.encryptionMode, StreamEncryptionMode.autoOn); + }); + + test('a call that already has a manager is not resolved again', () async { + late Call call; + call = createTestCallWithState( + initialState: _stateWith( + resolver: (request) async { + asked.add(request); + await call.setE2EEManager(manager); + return null; + }, + ), + ); + + await call.join(); + await call.join(); + + // How resolving once per call actually works: the attached manager is + // what the second join sees. Re-importing at a live key index would + // break decryption of frames still in flight. + expect(asked, hasLength(1)); + }); + + test('a manager the platform cannot build fails the join', () async { + final call = createTestCallWithState( + initialState: _stateWith(resolver: resolverReturning(aes128())), + ); + + final result = await call.join(); + + // No platform channel under test, so this stands in for any failure + // between "a key was resolved" and "a manager holds it". Either way the + // join has to fail rather than quietly proceed unencrypted. + expect(result, isA()); + expect(call.e2eeManager, isNull); + }); + + test('no key for a call that requires encryption fails the join', () async { + final coordinatorClient = setupMockCoordinatorClient(); + final call = createTestCallWithState( + initialState: _stateWith( + resolver: resolverReturning(null), + mode: StreamEncryptionMode.autoOn, + ), + coordinatorClient: coordinatorClient, + ); + + final result = await call.join(); + + expect(result, isA()); + // Fails here rather than being rejected by the server a round trip later. + verifyNever( + () => coordinatorClient.joinCall( + callCid: any(named: 'callCid'), + ringing: any(named: 'ringing'), + create: any(named: 'create'), + migratingFrom: any(named: 'migratingFrom'), + migratingFromList: any(named: 'migratingFromList'), + video: any(named: 'video'), + membersLimit: any(named: 'membersLimit'), + hintHighScaleLivestreamPublisher: any( + named: 'hintHighScaleLivestreamPublisher', + ), + e2ee: any(named: 'e2ee'), + ), + ); + }); + + test( + 'no resolver at all for a call that requires encryption fails', + () async { + final call = createTestCallWithState( + initialState: _stateWith(mode: StreamEncryptionMode.autoOn), + ); + + final result = await call.join(); + + expect(result, isA()); + expect( + (result as Failure).error.message, + contains('setE2EEManager'), + ); + }, + ); + + test( + 'a resolver that throws fails the join rather than downgrading it', + () async { + final call = createTestCallWithState( + initialState: _stateWith( + resolver: resolverReturning( + null, + throws: Exception('vault unreachable'), + ), + ), + ); + + final result = await call.join(); + + // Even though this call does not require encryption: the resolver + // failing says nothing about whether the call was meant to be protected. + expect(result, isA()); + expect(call.e2eeManager, isNull); + }, + ); + }); + + group('CallEncryptionKey', () { + test('rejects a key of the wrong length for its algorithm', () { + expect( + () => CallEncryptionKey.shared( + bytes: Uint8List(16), + algorithm: EncryptionAlgorithm.aes256Gcm, + ), + throwsArgumentError, + ); + expect( + () => CallEncryptionKey.shared(bytes: Uint8List(32)), + throwsArgumentError, + ); + }); + + test('rejects an index no frame could carry', () { + expect( + () => CallEncryptionKey.shared(bytes: Uint8List(16), keyIndex: -1), + throwsArgumentError, + ); + expect( + () => CallEncryptionKey.shared(bytes: Uint8List(16), keyIndex: 256), + throwsArgumentError, + ); + }); + + test('keeps the key out of toString', () { + final key = CallEncryptionKey.shared( + bytes: Uint8List.fromList(List.filled(16, 0xAB)), + ); + + expect(key.toString(), isNot(contains('171'))); + expect(key.toString(), isNot(contains('ab'))); + }); + }); +} diff --git a/packages/stream_video/test/src/call/call_e2ee_test.dart b/packages/stream_video/test/src/call/call_e2ee_test.dart new file mode 100644 index 000000000..71a726001 --- /dev/null +++ b/packages/stream_video/test/src/call/call_e2ee_test.dart @@ -0,0 +1,415 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_video/src/call/state/call_state_notifier.dart'; +import 'package:stream_video/src/webrtc/rtc_manager.dart'; +import 'package:stream_video/stream_video.dart'; + +import 'fixtures/call_test_helpers.dart'; +import 'fixtures/data.dart'; + +class MockEncryptionManager extends Mock implements EncryptionManager {} + +/// A state manager for a second, distinct call. +CallStateNotifier _otherCallStateManager() { + return CallStateNotifier( + createTestCallState( + callCid: StreamCallCid.from( + id: 'other-call', + type: StreamCallType.defaultType(), + ), + ), + ); +} + +class MockRtcManager extends Mock implements RtcManager {} + +void main() { + setUpAll(() { + registerMockFallbackValues(); + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('Call E2EE', () { + late MockEncryptionManager e2ee; + + setUp(() { + e2ee = MockEncryptionManager(); + when(() => e2ee.userId).thenReturn('test-user'); + when(() => e2ee.algorithm).thenReturn(EncryptionAlgorithm.aes128Gcm); + when( + () => e2ee.events, + ).thenAnswer((_) => const Stream.empty()); + when(() => e2ee.isDisposed).thenReturn(false); + when(() => e2ee.dispose()).thenAnswer((_) async {}); + }); + + // The claim registry is static and every case here shares one cid. + tearDown(Call.resetE2EEClaims); + + test('is off until a manager is attached', () { + final call = createTestCall(); + + expect(call.e2eeManager, isNull); + }); + + test('exposes the manager once attached', () async { + final call = createTestCall(); + + await call.setE2EEManager(e2ee); + + expect(call.e2eeManager, same(e2ee)); + }); + + test( + 'join reports an encrypted session when a manager is attached', + () async { + final coordinatorClient = setupMockCoordinatorClient(); + final call = createTestCall(coordinatorClient: coordinatorClient); + + await call.setE2EEManager(e2ee); + final result = await call.join(); + + expect(result.isSuccess, isTrue); + verify( + () => coordinatorClient.joinCall( + callCid: SampleCallData.defaultCid, + ringing: any(named: 'ringing'), + create: any(named: 'create'), + migratingFrom: any(named: 'migratingFrom'), + migratingFromList: any(named: 'migratingFromList'), + video: any(named: 'video'), + membersLimit: any(named: 'membersLimit'), + hintHighScaleLivestreamPublisher: any( + named: 'hintHighScaleLivestreamPublisher', + ), + e2ee: true, + ), + ).called(1); + }, + ); + + test( + 'join reports an unencrypted session when no manager is attached', + () async { + final coordinatorClient = setupMockCoordinatorClient(); + final call = createTestCall(coordinatorClient: coordinatorClient); + + await call.join(); + + verify( + () => coordinatorClient.joinCall( + callCid: SampleCallData.defaultCid, + ringing: any(named: 'ringing'), + create: any(named: 'create'), + migratingFrom: any(named: 'migratingFrom'), + migratingFromList: any(named: 'migratingFromList'), + video: any(named: 'video'), + membersLimit: any(named: 'membersLimit'), + hintHighScaleLivestreamPublisher: any( + named: 'hintHighScaleLivestreamPublisher', + ), + e2ee: false, + ), + ).called(1); + }, + ); + + test( + 'the manager reaches the session that builds the peer connections', + () async { + final callSession = setupMockCallSession(); + final sessionFactory = setupMockSessionFactory( + callSession: callSession, + ); + final call = createTestCall(sessionFactory: sessionFactory); + + await call.setE2EEManager(e2ee); + await call.join(); + + final captured = verify( + () => sessionFactory.makeCallSession( + onSuspendedAudioTrackRecorded: any( + named: 'onSuspendedAudioTrackRecorded', + ), + sessionId: any(named: 'sessionId'), + sessionSeq: any(named: 'sessionSeq'), + credentials: any(named: 'credentials'), + stateManager: any(named: 'stateManager'), + dynascaleManager: any(named: 'dynascaleManager'), + networkMonitor: any(named: 'networkMonitor'), + statsOptions: any(named: 'statsOptions'), + onReconnectionNeeded: any(named: 'onReconnectionNeeded'), + clientPublishOptions: any(named: 'clientPublishOptions'), + streamVideo: any(named: 'streamVideo'), + leftoverTraceRecords: any(named: 'leftoverTraceRecords'), + pcFactory: any(named: 'pcFactory'), + e2eeManager: captureAny(named: 'e2eeManager'), + ), + ).captured; + + expect(captured, isNotEmpty); + expect(captured.last, same(e2ee)); + }, + ); + + test('rejects a manager once peer connections exist', () async { + final callSession = setupMockCallSession(); + when(() => callSession.rtcManager).thenReturn(MockRtcManager()); + + final call = createTestCall( + sessionFactory: setupMockSessionFactory(callSession: callSession), + ); + + await call.join(); + + // Those peer connections were negotiated without an encryptor, so + // adopting one now would publish cleartext while claiming encryption. + expect( + () => call.setE2EEManager(e2ee), + throwsA(isA()), + ); + expect(call.e2eeManager, isNull); + }); + + test('refuses a manager already attached to another call', () async { + final callA = createTestCall(); + final callB = createTestCall(stateManager: _otherCallStateManager()); + + await callA.setE2EEManager(e2ee); + + // One key store between two calls means each overwrites the other's + // key at the same index, and decryption starts failing for no visible + // reason. Refuse instead. + expect(() => callB.setE2EEManager(e2ee), throwsA(isA())); + expect(callB.e2eeManager, isNull); + expect(callA.e2eeManager, same(e2ee)); + }); + + test('a released manager can be attached to another call', () async { + final callA = createTestCall(); + final callB = createTestCall(stateManager: _otherCallStateManager()); + + await callA.setE2EEManager(e2ee); + // Keeping the native manager alive is the point: the keys move to the + // next call with it. + await callA.clearE2EEManager(dispose: false); + + await callB.setE2EEManager(e2ee); + + expect(callB.e2eeManager, same(e2ee)); + verifyNever(() => e2ee.dispose()); + }); + + test('re-attaching the same manager to the same call is allowed', () async { + final call = createTestCall(); + + await call.setE2EEManager(e2ee); + await call.setE2EEManager(e2ee); + + expect(call.e2eeManager, same(e2ee)); + }); + + test('refuses a second, different manager on the same call', () async { + final call = createTestCall(); + final other = MockEncryptionManager(); + when(() => other.userId).thenReturn('test-user'); + when(() => other.isDisposed).thenReturn(false); + when( + () => other.events, + ).thenAnswer((_) => const Stream.empty()); + + await call.setE2EEManager(e2ee); + + // Overwriting would drop the first manager while it still holds a + // native key store: nothing else references it, so the handle leaks. + expect(() => call.setE2EEManager(other), throwsA(isA())); + expect(call.e2eeManager, same(e2ee)); + verifyNever(() => e2ee.dispose()); + }); + + test('refuses a disposed manager', () async { + final call = createTestCall(); + when(() => e2ee.isDisposed).thenReturn(true); + + expect(() => call.setE2EEManager(e2ee), throwsA(isA())); + expect(call.e2eeManager, isNull); + }); + + test('clearE2EEManager releases the native manager', () async { + final call = createTestCall(); + await call.setE2EEManager(e2ee); + + await call.clearE2EEManager(); + + // Nothing else ever releases it: the manager outlives leave() by + // design, and each live one holds a key store and a crypto thread. + verify(() => e2ee.dispose()).called(1); + expect(call.e2eeManager, isNull); + }); + + test('clearE2EEManager can detach without disposing', () async { + final call = createTestCall(); + await call.setE2EEManager(e2ee); + + await call.clearE2EEManager(dispose: false); + + verifyNever(() => e2ee.dispose()); + expect(call.e2eeManager, isNull); + }); + + test('clearE2EEManager is a no-op when nothing is attached', () async { + final call = createTestCall(); + + await call.clearE2EEManager(); + + verifyNever(() => e2ee.dispose()); + expect(call.e2eeManager, isNull); + }); + + test('a cleared call joins unencrypted again', () async { + final coordinatorClient = setupMockCoordinatorClient(); + final call = createTestCall(coordinatorClient: coordinatorClient); + + await call.setE2EEManager(e2ee); + await call.clearE2EEManager(); + await call.join(); + + verify( + () => coordinatorClient.joinCall( + callCid: SampleCallData.defaultCid, + ringing: any(named: 'ringing'), + create: any(named: 'create'), + migratingFrom: any(named: 'migratingFrom'), + migratingFromList: any(named: 'migratingFromList'), + video: any(named: 'video'), + membersLimit: any(named: 'membersLimit'), + hintHighScaleLivestreamPublisher: any( + named: 'hintHighScaleLivestreamPublisher', + ), + e2ee: false, + ), + ).called(1); + }); + + test( + 'releases and disposes the manager when the call is left', + () async { + final coordinatorClient = setupMockCoordinatorClient(); + final call = createTestCall(coordinatorClient: coordinatorClient); + + await call.setE2EEManager(e2ee); + await call.join(); + await call.leave(); + + // A Call cannot be rejoined, so holding the manager would only pin a + // native key store and a crypto thread with nothing left to use them. + expect(call.e2eeManager, isNull); + verify(() => e2ee.dispose()).called(1); + }, + ); + + test( + 'a manager released on leave can be attached to the next call', + () async { + final callA = createTestCall(); + + await callA.setE2EEManager(e2ee); + await callA.join(); + await callA.leave(); + + // Ownership is released with it, so a fresh Call for the same cid can + // take a new manager without tripping the one-manager-per-call guard. + final callB = createTestCall(); + final other = MockEncryptionManager(); + when(() => other.userId).thenReturn('test-user'); + when(() => other.isDisposed).thenReturn(false); + when( + () => other.events, + ).thenAnswer((_) => const Stream.empty()); + + await callB.setE2EEManager(other); + + expect(callB.e2eeManager, same(other)); + }, + ); + + test( + 'refuses a manager when another instance of the call already has one', + () async { + final first = createTestCall(); + await first.setE2EEManager(e2ee); + + // Same cid, two key stores: whichever session a peer decrypts, the + // other is noise, and that is indistinguishable from a wrong key. + final second = createTestCall(); + final other = MockEncryptionManager(); + when(() => other.isDisposed).thenReturn(false); + + expect( + () => second.setE2EEManager(other), + throwsA(isA()), + ); + expect(second.e2eeManager, isNull); + expect(first.e2eeManager, same(e2ee)); + }, + ); + + test( + 'the same manager on another instance of the call is allowed', + () async { + // One key store, so there is nothing to disagree about. This is the + // hand-off in `clearE2EEManager(dispose: false)` seen from the far end. + final first = createTestCall(); + await first.setE2EEManager(e2ee); + + final second = createTestCall(); + await second.setE2EEManager(e2ee); + + expect(second.e2eeManager, same(e2ee)); + verifyNever(() => e2ee.dispose()); + }, + ); + + test('releasing the claim lets the next instance attach', () async { + final first = createTestCall(); + await first.setE2EEManager(e2ee); + await first.clearE2EEManager(); + + final second = createTestCall(); + final other = MockEncryptionManager(); + when(() => other.isDisposed).thenReturn(false); + when( + () => other.events, + ).thenAnswer((_) => const Stream.empty()); + + await second.setE2EEManager(other); + + expect(second.e2eeManager, same(other)); + }); + }); + + group('Call E2EE state', () { + test('the SFU decides whether the call reports as encrypted', () { + final notifier = CallStateNotifier( + createTestCallState(callCid: SampleCallData.defaultCid), + ); + + // Starts on the server's default rather than on local intent: an + // attached manager proves nothing until the join response agrees. + expect(notifier.callState.isE2eeEnabled, isFalse); + + notifier.sfuE2eeEnabledUpdated(true); + expect(notifier.callState.isE2eeEnabled, isTrue); + + notifier.sfuE2eeEnabledUpdated(false); + expect(notifier.callState.isE2eeEnabled, isFalse); + }); + }); + + group('EncryptionManager key validation', () { + test('AES-128 wants 16 bytes, AES-256 wants 32', () { + expect(EncryptionAlgorithm.aes128Gcm.keyLengthBytes, 16); + expect(EncryptionAlgorithm.aes256Gcm.keyLengthBytes, 32); + }); + }); +} diff --git a/packages/stream_video/test/src/call/call_join_sfu_error_test.dart b/packages/stream_video/test/src/call/call_join_sfu_error_test.dart index 3aa8a22e9..8bf5afade 100644 --- a/packages/stream_video/test/src/call/call_join_sfu_error_test.dart +++ b/packages/stream_video/test/src/call/call_join_sfu_error_test.dart @@ -1,6 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:stream_video/src/errors/video_error.dart'; import 'package:stream_video/stream_video.dart'; import '../../test_helpers.dart'; @@ -72,6 +71,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), ).called(1); @@ -154,6 +154,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), () => localSession.start( reconnectDetails: any(named: 'reconnectDetails'), @@ -172,6 +173,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), () => localSession.start( reconnectDetails: any(named: 'reconnectDetails'), @@ -190,6 +192,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), () => localSession.start( reconnectDetails: any(named: 'reconnectDetails'), @@ -254,6 +257,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), () => mockCallSession.start( reconnectDetails: any(named: 'reconnectDetails'), @@ -282,6 +286,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), () => mockCallSession.start( reconnectDetails: any(named: 'reconnectDetails'), @@ -354,6 +359,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), ).called(1); diff --git a/packages/stream_video/test/src/call/call_reconnect_stability_test.dart b/packages/stream_video/test/src/call/call_reconnect_stability_test.dart index 505800f65..66d88c80f 100644 --- a/packages/stream_video/test/src/call/call_reconnect_stability_test.dart +++ b/packages/stream_video/test/src/call/call_reconnect_stability_test.dart @@ -142,6 +142,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), ).called(1); @@ -161,6 +162,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), ); }, @@ -190,6 +192,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), ).called(2); }, @@ -216,6 +219,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), ).called(1); @@ -237,6 +241,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), ); @@ -254,6 +259,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), ).called(1); }, @@ -290,6 +296,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), ).called(1); @@ -316,6 +323,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), ); @@ -354,6 +362,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), ).called(1); @@ -371,6 +380,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), ); }, @@ -428,6 +438,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), ).called(1); @@ -458,6 +469,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), ).called(1); }, @@ -513,6 +525,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), ).called(1); @@ -541,6 +554,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), ).called(1); }, diff --git a/packages/stream_video/test/src/call/call_ring_test.dart b/packages/stream_video/test/src/call/call_ring_test.dart index 3d45803c3..34c0c4fd2 100644 --- a/packages/stream_video/test/src/call/call_ring_test.dart +++ b/packages/stream_video/test/src/call/call_ring_test.dart @@ -5,7 +5,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:stream_video/open_api/video/coordinator/api.dart' as open; import 'package:stream_video/src/call/state/call_state_notifier.dart'; -import 'package:stream_video/src/errors/video_error.dart'; import 'package:stream_video/src/shared_emitter.dart'; import 'package:stream_video/stream_video.dart'; diff --git a/packages/stream_video/test/src/call/call_test.dart b/packages/stream_video/test/src/call/call_test.dart index 3695d08a6..d7e30a887 100644 --- a/packages/stream_video/test/src/call/call_test.dart +++ b/packages/stream_video/test/src/call/call_test.dart @@ -5,7 +5,6 @@ import 'package:internet_connection_checker_plus/internet_connection_checker_plu import 'package:mocktail/mocktail.dart'; import 'package:rxdart/rxdart.dart'; import 'package:stream_video/src/call/state/call_state_notifier.dart'; -import 'package:stream_video/src/errors/video_error.dart'; import 'package:stream_video/src/webrtc/rtc_manager.dart'; import 'package:stream_video/stream_video.dart'; @@ -73,6 +72,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), () => mockCallSession.start( reconnectDetails: any(named: 'reconnectDetails'), @@ -102,6 +102,7 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), () => mockCallSession.start( reconnectDetails: any(named: 'reconnectDetails'), @@ -150,6 +151,9 @@ void main() { migratingFromList: any(named: 'migratingFromList'), video: false, membersLimit: null, + // No E2EE manager attached, so the join reports an unencrypted + // session. + e2ee: false, ), ).called(1); diff --git a/packages/stream_video/test/src/call/fixtures/call_test_helpers.dart b/packages/stream_video/test/src/call/fixtures/call_test_helpers.dart index 32918347f..8ced480de 100644 --- a/packages/stream_video/test/src/call/fixtures/call_test_helpers.dart +++ b/packages/stream_video/test/src/call/fixtures/call_test_helpers.dart @@ -224,6 +224,7 @@ MockCoordinatorClient setupMockCoordinatorClient({ migratingFromList: any(named: 'migratingFromList'), video: any(named: 'video'), membersLimit: any(named: 'membersLimit'), + e2ee: any(named: 'e2ee'), ), ).thenAnswer( (_) => Future.value( @@ -265,12 +266,13 @@ MockRetryPolicy setupMockRetryPolicy() { return retryPolicy; } -SfuCallState createTestSfuCallState() { +SfuCallState createTestSfuCallState({bool e2eeEnabled = false}) { return SfuCallState( participants: const [], participantCount: const SfuParticipantCount(total: 0, anonymous: 0), startedAt: DateTime.now(), pins: const [], + e2eeEnabled: e2eeEnabled, ); } @@ -373,6 +375,7 @@ MockSessionFactory setupMockSessionFactory({MockCallSession? callSession}) { streamVideo: any(named: 'streamVideo'), leftoverTraceRecords: any(named: 'leftoverTraceRecords'), pcFactory: any(named: 'pcFactory'), + e2eeManager: any(named: 'e2eeManager'), ), ).thenAnswer( (_) => Future.value(callSession ?? setupMockCallSession()), diff --git a/packages/stream_video/test/src/open_api/open_api_manual_edits_test.dart b/packages/stream_video/test/src/open_api/open_api_manual_edits_test.dart index 6bfaf5bb8..d19909b70 100644 --- a/packages/stream_video/test/src/open_api/open_api_manual_edits_test.dart +++ b/packages/stream_video/test/src/open_api/open_api_manual_edits_test.dart @@ -502,6 +502,7 @@ Map _minimalCallSettingsJson() => { }, 'rtmp': {'enabled': false, 'quality': '720p'}, }, + 'encryption': {'mode': 'disabled'}, 'frame_recording': { 'capture_interval_in_seconds': 0, 'mode': 'disabled', diff --git a/packages/stream_video/test/src/webrtc/e2ee/e2ee_mapping_test.dart b/packages/stream_video/test/src/webrtc/e2ee/e2ee_mapping_test.dart new file mode 100644 index 000000000..8bd6142ae --- /dev/null +++ b/packages/stream_video/test/src/webrtc/e2ee/e2ee_mapping_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_video/src/sfu/data/models/sfu_codec.dart'; +import 'package:stream_video/src/sfu/data/models/sfu_track_type.dart'; +import 'package:stream_video/src/webrtc/e2ee/e2ee_mapping.dart'; +import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart' as rtc; + +SfuCodec codec(String name) => SfuCodec( + payloadType: 96, + name: name, + fmtpLine: '', + clockRate: 90000, + encodingParameters: '', +); + +void main() { + group('e2eeCodecPin', () { + test('pins the four codecs the frame transform understands', () { + expect(codec('opus').e2eeCodecPin, 'opus'); + expect(codec('vp8').e2eeCodecPin, 'vp8'); + expect(codec('vp9').e2eeCodecPin, 'vp9'); + expect(codec('h264').e2eeCodecPin, 'h264'); + }); + + test('normalises case, since the pin must be exact lowercase', () { + expect(codec('VP8').e2eeCodecPin, 'vp8'); + expect(codec('H264').e2eeCodecPin, 'h264'); + expect(codec('Opus').e2eeCodecPin, 'opus'); + }); + + test('passes through a codec the transform cannot frame', () { + // AV1 has no framing scheme in this format, and the spec wants the client + // to fail closed on it. That only works if the transform is told the + // codec is AV1 β€” reporting no pin would have it read the codec from the + // frame and encrypt on a guessed clear-byte count instead. + expect(codec('av1').e2eeCodecPin, 'av1'); + expect(codec('AV1').e2eeCodecPin, 'av1'); + }); + + test('leaves a nameless codec for native to read from the frame', () { + expect(codec('').e2eeCodecPin, isNull); + expect(codec(' ').e2eeCodecPin, isNull); + }); + }); + + group('e2eeTrackType', () { + test('maps every published track type', () { + expect(SfuTrackType.audio.e2eeTrackType, rtc.E2eeTrackType.audio); + expect(SfuTrackType.video.e2eeTrackType, rtc.E2eeTrackType.video); + expect( + SfuTrackType.screenShare.e2eeTrackType, + rtc.E2eeTrackType.screenShare, + ); + expect( + SfuTrackType.screenShareAudio.e2eeTrackType, + rtc.E2eeTrackType.screenShareAudio, + ); + }); + + test('keeps screenshare distinct from camera video', () { + // Collapsing the two would make one replay window cover both tracks. + expect( + SfuTrackType.screenShare.e2eeTrackType, + isNot(SfuTrackType.video.e2eeTrackType), + ); + }); + + test('leaves an unspecified track for native to classify', () { + expect(SfuTrackType.unspecified.e2eeTrackType, isNull); + }); + }); +} diff --git a/packages/stream_video/test/src/webrtc/rtc_manager_e2ee_test.dart b/packages/stream_video/test/src/webrtc/rtc_manager_e2ee_test.dart new file mode 100644 index 000000000..e198d54ce --- /dev/null +++ b/packages/stream_video/test/src/webrtc/rtc_manager_e2ee_test.dart @@ -0,0 +1,190 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_video/src/sfu/data/models/sfu_codec.dart'; +import 'package:stream_video/src/sfu/data/models/sfu_publish_options.dart'; +import 'package:stream_video/src/sfu/data/models/sfu_track_type.dart'; +import 'package:stream_video/src/utils/result.dart'; +import 'package:stream_video/src/webrtc/media/media_constraints.dart'; +import 'package:stream_video/src/webrtc/peer_connection_factory.dart'; +import 'package:stream_video/src/webrtc/rtc_manager.dart'; +import 'package:stream_video/src/webrtc/rtc_track/rtc_local_track.dart'; +import 'package:stream_video/src/webrtc/traced_peer_connection.dart'; +import 'package:stream_webrtc_flutter/stream_webrtc_flutter.dart' as rtc; + +import '../call/fixtures/call_test_helpers.dart'; +import '../call/fixtures/data.dart'; + +class _MockTracedStreamPeerConnection extends Mock + implements TracedStreamPeerConnection {} + +class _MockPeerConnection extends Mock implements rtc.RTCPeerConnection {} + +class _MockTransceiver extends Mock implements rtc.RTCRtpTransceiver {} + +class _MockSender extends Mock implements rtc.RTCRtpSender {} + +class _MockMediaStreamTrack extends Mock implements rtc.MediaStreamTrack {} + +class _MockMediaStream extends Mock implements rtc.MediaStream {} + +class _MockEncryptionManager extends Mock implements rtc.EncryptionManager {} + +final _vp8 = SfuPublishOptions( + id: 1, + codec: const SfuCodec( + name: 'vp8', + payloadType: 96, + fmtpLine: '', + clockRate: 90000, + encodingParameters: '', + ), + trackType: SfuTrackType.video, +); + +rtc.MediaStreamTrack _mediaTrack(String id) { + var clones = 0; + final track = _MockMediaStreamTrack(); + when(() => track.id).thenReturn(id); + when(() => track.kind).thenReturn('video'); + when(() => track.enabled).thenReturn(true); + when(track.stop).thenAnswer((_) async {}); + when(track.clone).thenAnswer((_) async => _mediaTrack('$id-c${clones++}')); + return track; +} + +RtcLocalVideoTrack _videoTrack(String mediaId) { + final mediaStream = _MockMediaStream(); + when(mediaStream.dispose).thenAnswer((_) async {}); + + return RtcLocalTrack( + trackIdPrefix: 'pub', + trackType: SfuTrackType.video, + mediaStream: mediaStream, + mediaTrack: _mediaTrack(mediaId), + mediaConstraints: const CameraConstraints(), + ); +} + +void main() { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + registerMockFallbackValues(); + registerFallbackValue(_MockMediaStreamTrack()); + registerFallbackValue(_MockSender()); + registerFallbackValue([]); + }); + + ({ + RtcManager manager, + int Function() stopCalls, + }) + buildManager(rtc.EncryptionManager? e2ee) { + final pc = _MockPeerConnection(); + final publisher = _MockTracedStreamPeerConnection(); + when(() => publisher.pc).thenReturn(pc); + when(() => publisher.isReconnecting).thenReturn(false); + when(() => publisher.onRenegotiationNeeded).thenReturn(null); + + var stops = 0; + when( + () => publisher.addVideoTransceiver( + track: any(named: 'track'), + encodings: any(named: 'encodings'), + degradationPreference: any(named: 'degradationPreference'), + ), + ).thenAnswer((invocation) async { + final track = invocation.namedArguments[#track] as rtc.MediaStreamTrack?; + + final sender = _MockSender(); + when(() => sender.track).thenReturn(track); + when(() => sender.replaceTrack(any())).thenAnswer((_) async {}); + + final transceiver = _MockTransceiver(); + when(() => transceiver.sender).thenReturn(sender); + when(() => transceiver.mid).thenReturn(''); + when(transceiver.stop).thenAnswer((_) async { + stops++; + }); + return Result.success(transceiver); + }); + + final manager = RtcManager( + sessionId: 'test-session', + callCid: SampleCallData.defaultCid, + publisherId: 'test-publisher', + publisher: publisher, + subscriber: _MockTracedStreamPeerConnection(), + publishOptions: [_vp8], + stateManager: createTestCallStateManager(), + streamVideo: setupMockStreamVideo(), + pcFactory: StreamPeerConnectionFactory( + callCid: SampleCallData.defaultCid, + ), + e2eeManager: e2ee, + ); + + return (manager: manager, stopCalls: () => stops); + } + + group('encryptor attach', () { + test('a publish whose encryptor cannot attach fails', () async { + final e2ee = _MockEncryptionManager(); + when( + () => e2ee.encrypt( + any(), + codec: any(named: 'codec'), + trackType: any(named: 'trackType'), + ), + ).thenThrow(StateError('manager is disposed')); + + final wires = buildManager(e2ee); + + final published = await wires.manager.publishVideoTrack( + track: _videoTrack('cam'), + ); + + // Publishing cleartext on a call the user was told is encrypted is worse + // than not publishing at all, so the sender must not survive. + expect(published.isFailure, isTrue); + expect(wires.stopCalls(), 1); + }); + + test('a publish with no manager attached is unaffected', () async { + final wires = buildManager(null); + + final published = await wires.manager.publishVideoTrack( + track: _videoTrack('cam'), + ); + + expect(published.isSuccess, isTrue); + expect(wires.stopCalls(), 0); + }); + + test('a publish whose encryptor attaches succeeds', () async { + final e2ee = _MockEncryptionManager(); + when( + () => e2ee.encrypt( + any(), + codec: any(named: 'codec'), + trackType: any(named: 'trackType'), + ), + ).thenAnswer((_) async {}); + + final wires = buildManager(e2ee); + + final published = await wires.manager.publishVideoTrack( + track: _videoTrack('cam'), + ); + + expect(published.isSuccess, isTrue); + expect(wires.stopCalls(), 0); + verify( + () => e2ee.encrypt( + any(), + codec: 'vp8', + trackType: rtc.E2eeTrackType.video, + ), + ).called(1); + }); + }); +} diff --git a/pubspec.lock b/pubspec.lock index a857108a0..93c3238fa 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -369,6 +369,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + cryptography: + dependency: transitive + description: + name: cryptography + sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0" + url: "https://pub.dev" + source: hosted + version: "2.9.0" csslib: dependency: transitive description: @@ -1073,10 +1081,10 @@ packages: dependency: transitive description: name: intl - sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" url: "https://pub.dev" source: hosted - version: "0.20.3" + version: "0.20.2" io: dependency: transitive description: @@ -1241,10 +1249,10 @@ packages: dependency: transitive description: name: matcher - sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.20" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -1265,10 +1273,10 @@ packages: dependency: transitive description: name: meta - sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.18.0" mime: dependency: transitive description: @@ -1995,13 +2003,12 @@ packages: source: hosted version: "2.1.1" stream_webrtc_flutter: - dependency: transitive + dependency: "direct overridden" description: - name: stream_webrtc_flutter - sha256: "347b3da665bdb5d82dfec1d033b8ea2c8e163048e5d2cbe4779f7f1c85a657b3" - url: "https://pub.dev" - source: hosted - version: "3.0.1" + path: "../webrtc-flutter" + relative: true + source: path + version: "3.0.2" string_scanner: dependency: transitive description: @@ -2054,10 +2061,10 @@ packages: dependency: transitive description: name: test_api - sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.11" theme_extensions_builder_annotation: dependency: transitive description: @@ -2246,10 +2253,10 @@ packages: dependency: transitive description: name: vector_math - sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.2.0" video_player: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index bec91db98..b41b26fbe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,6 +25,10 @@ dev_dependencies: dependency_overrides: cli_util: ^0.5.1 file_picker: ^12.0.0-beta.5 + stream_webrtc_flutter: + git: + url: https://github.com/GetStream/webrtc-flutter.git + ref: c79df9cd3f5345f530bc1ea4a2c45d594de2af3c melos: ignore: