From 37645c203c40ce40e71aa6532f118918e8cb929b Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 5 Aug 2026 17:45:07 -0700 Subject: [PATCH 1/2] feat(mobile): protect sensitive identity transfers Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../xyz/block/buzz/mobile/MainActivity.kt | 4 +- mobile/ios/Runner/Info.plist | 2 + mobile/lib/features/pairing/pairing_page.dart | 42 +++++++ .../features/pairing/pairing_provider.dart | 118 +++++++++++++----- .../lib/features/settings/settings_page.dart | 3 + .../mobile_security_section.dart | 78 ++++++++++++ mobile/lib/shared/auth/auth_provider.dart | 14 +++ mobile/lib/shared/community/community.dart | 15 +++ .../security/sensitive_action_authorizer.dart | 68 ++++++++++ mobile/pubspec.lock | 40 ++++++ mobile/pubspec.yaml | 1 + .../features/pairing/pairing_page_test.dart | 54 ++++++++ .../pairing/pairing_provider_test.dart | 76 ++++++++++- .../test/shared/community/community_test.dart | 33 +++++ 14 files changed, 516 insertions(+), 32 deletions(-) create mode 100644 mobile/lib/features/settings/settings_page/mobile_security_section.dart create mode 100644 mobile/lib/shared/security/sensitive_action_authorizer.dart create mode 100644 mobile/test/shared/community/community_test.dart diff --git a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt index d9b456b270..f199f79823 100644 --- a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt @@ -10,7 +10,7 @@ import android.media.MediaMetadataRetriever import android.media.MediaMuxer import android.os.Build import androidx.annotation.RequiresApi -import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel import java.io.ByteArrayOutputStream @@ -77,7 +77,7 @@ internal object AndroidImageProcessor { } } -class MainActivity : FlutterActivity() { +class MainActivity : FlutterFragmentActivity() { private var mediaUploadChannel: MethodChannel? = null override fun configureFlutterEngine(flutterEngine: FlutterEngine) { diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 81227c202b..80292ff8bb 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -43,6 +43,8 @@ LSRequiresIPhoneOS + NSFaceIDUsageDescription + Buzz uses Face ID to confirm sensitive identity transfers. NSCameraUsageDescription Buzz needs camera access so you can take photos to attach to messages and scan QR codes for device pairing. NSPhotoLibraryUsageDescription diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index 7061180b12..63a5b6eca4 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -127,6 +127,12 @@ class PairingPage extends HookConsumerWidget { sasCode: pairingState.sasCode ?? '------', confirmed: pairingState.userConfirmedSas, sendsIdentityToDesktop: pairingState.sendsIdentityToDesktop, + protectImportedIdentity: + pairingState.protectImportedIdentity, + errorMessage: pairingState.errorMessage, + onProtectionChanged: (value) => ref + .read(pairingProvider.notifier) + .setProtectImportedIdentity(value), onConfirm: () => ref.read(pairingProvider.notifier).confirmSas(), onDeny: () => ref.read(pairingProvider.notifier).denySas(), @@ -196,6 +202,9 @@ class _SasVerificationView extends StatelessWidget { final String sasCode; final bool confirmed; final bool sendsIdentityToDesktop; + final bool protectImportedIdentity; + final String? errorMessage; + final ValueChanged onProtectionChanged; final VoidCallback onConfirm; final VoidCallback onDeny; @@ -203,6 +212,9 @@ class _SasVerificationView extends StatelessWidget { required this.sasCode, required this.confirmed, required this.sendsIdentityToDesktop, + required this.protectImportedIdentity, + required this.errorMessage, + required this.onProtectionChanged, required this.onConfirm, required this.onDeny, }); @@ -266,6 +278,36 @@ class _SasVerificationView extends StatelessWidget { ), ), + const SizedBox(height: Grid.sm), + + if (!sendsIdentityToDesktop) + CheckboxListTile( + key: const Key('protect-imported-identity-checkbox'), + value: protectImportedIdentity, + onChanged: confirmed + ? null + : (value) => onProtectionChanged(value ?? false), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + title: const Text( + 'Use biometrics to confirm sensitive identity actions', + ), + subtitle: const Text( + 'Routine Buzz use will not prompt. This protects identity transfer and reveal actions.', + ), + ), + + if (errorMessage != null) ...[ + const SizedBox(height: Grid.xs), + Text( + errorMessage!, + textAlign: TextAlign.center, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ], + const SizedBox(height: Grid.lg), // Confirm / Deny buttons diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index 5adde987eb..5afb4a0e99 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -11,6 +11,7 @@ import '../../shared/auth/auth.dart'; import '../../shared/crypto/ecdh.dart'; import '../../shared/crypto/nip44.dart'; import '../../shared/relay/relay.dart'; +import '../../shared/security/sensitive_action_authorizer.dart'; import 'pairing_crypto.dart'; import 'pairing_socket.dart'; @@ -37,6 +38,8 @@ class PairingState { final String? sasCode; final bool userConfirmedSas; final bool sendsIdentityToDesktop; + final bool protectImportedIdentity; + final bool authorizationInProgress; const PairingState({ this.status = PairingStatus.idle, @@ -44,6 +47,8 @@ class PairingState { this.sasCode, this.userConfirmedSas = false, this.sendsIdentityToDesktop = false, + this.protectImportedIdentity = true, + this.authorizationInProgress = false, }); PairingState copyWith({ @@ -52,6 +57,8 @@ class PairingState { String? sasCode, bool? userConfirmedSas, bool? sendsIdentityToDesktop, + bool? protectImportedIdentity, + bool? authorizationInProgress, }) => PairingState( status: status ?? this.status, errorMessage: errorMessage ?? this.errorMessage, @@ -59,6 +66,10 @@ class PairingState { userConfirmedSas: userConfirmedSas ?? this.userConfirmedSas, sendsIdentityToDesktop: sendsIdentityToDesktop ?? this.sendsIdentityToDesktop, + protectImportedIdentity: + protectImportedIdentity ?? this.protectImportedIdentity, + authorizationInProgress: + authorizationInProgress ?? this.authorizationInProgress, ); } @@ -110,30 +121,86 @@ class PairingNotifier extends Notifier { /// Confirm that the SAS code matches. Called by the UI after user approval. void confirmSas() { - if (state.status != PairingStatus.confirmingSas) return; - - // If the desktop's sas-confirm has already arrived and been verified, - // transition immediately and process any buffered payload. - if (_sasConfirmReceived) { - state = state.copyWith(status: PairingStatus.transferring); - if (_sendIdentityToSource) { - _sendIdentityPayload(); - } else { - final pending = _pendingPayload; - if (pending != null) { - _pendingPayload = null; - _handlePayload(pending); - } - } + if (state.status != PairingStatus.confirmingSas || + state.authorizationInProgress) { return; } - - // Desktop hasn't confirmed yet — record intent and wait. The transition - // will happen in _handleSasConfirm() once the transcript hash is verified. _userConfirmedSas = true; state = state.copyWith(userConfirmedSas: true); + if (_sasConfirmReceived) unawaited(_continueAfterSas()); + } + + void setProtectImportedIdentity(bool value) { + if (state.status != PairingStatus.confirmingSas || + state.sendsIdentityToDesktop || + state.authorizationInProgress) { + return; + } + state = state.copyWith(protectImportedIdentity: value); + } + + Future _continueAfterSas() async { + if (!_userConfirmedSas || + !_sasConfirmReceived || + state.status != PairingStatus.confirmingSas || + state.authorizationInProgress) { + return; + } + + final activePolicy = (await ref.read( + authProvider.future, + )).community?.sensitiveActionPolicy; + final requiresAuthorization = _sendIdentityToSource + ? activePolicy == SensitiveActionPolicy.enabled + : state.protectImportedIdentity; + + if (requiresAuthorization) { + state = state.copyWith(authorizationInProgress: true); + final result = await ref + .read(sensitiveActionAuthorizerProvider) + .authorizeIdentityAction(); + if (state.status != PairingStatus.confirmingSas) return; + if (result != DeviceAuthResult.success) { + _userConfirmedSas = false; + state = state.copyWith( + userConfirmedSas: false, + authorizationInProgress: false, + errorMessage: _authorizationError(result), + ); + return; + } + } + + _userConfirmedSas = false; + state = state.copyWith( + status: PairingStatus.transferring, + authorizationInProgress: false, + ); + if (_sendIdentityToSource) { + _sendIdentityPayload(); + } else { + final pending = _pendingPayload; + if (pending != null) { + _pendingPayload = null; + _handlePayload(pending); + } + } } + static String _authorizationError( + DeviceAuthResult result, + ) => switch (result) { + DeviceAuthResult.cancelled => + 'Identity confirmation was cancelled. Nothing was transferred.', + DeviceAuthResult.unavailable => + 'Device authentication is unavailable. Configure a device passcode or biometrics, or turn off protection for this import.', + DeviceAuthResult.lockedOut => + 'Device authentication is locked. Unlock it in system settings and try again.', + DeviceAuthResult.failed => + 'Identity confirmation failed. Nothing was transferred.', + DeviceAuthResult.success => '', + }; + /// Deny the SAS code. Send abort and terminate. void denySas() { _sendAbort('sas_mismatch'); @@ -415,17 +482,7 @@ class PairingNotifier extends Notifier { // If the user already tapped "Codes Match", complete the transition now // that the transcript hash is verified. if (_userConfirmedSas) { - _userConfirmedSas = false; - state = state.copyWith(status: PairingStatus.transferring); - if (_sendIdentityToSource) { - _sendIdentityPayload(); - } else { - final pending = _pendingPayload; - if (pending != null) { - _pendingPayload = null; - _handlePayload(pending); - } - } + unawaited(_continueAfterSas()); } // Otherwise stay in confirmingSas — user must still confirm via confirmSas(). } @@ -534,6 +591,9 @@ class PairingNotifier extends Notifier { relayUrl: relayUrl, pubkey: pubkey, nsec: nsec, + sensitiveActionPolicy: state.protectImportedIdentity + ? SensitiveActionPolicy.enabled + : SensitiveActionPolicy.disabledByUser, ); await ref .read(authProvider.notifier) diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 066dd1fd3d..1dee049f93 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -11,6 +11,7 @@ import 'package:package_info_plus/package_info_plus.dart'; import '../../shared/auth/auth.dart'; import '../../shared/clipboard_utils.dart'; import '../../shared/relay/relay.dart'; +import '../../shared/security/sensitive_action_authorizer.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/app_list.dart'; import '../../shared/widgets/app_list_card.dart'; @@ -22,6 +23,7 @@ import 'theme_picker_page.dart'; part 'settings_page/appearance_section.dart'; part 'settings_page/connection_section.dart'; +part 'settings_page/mobile_security_section.dart'; class SettingsPage extends HookConsumerWidget { const SettingsPage({ @@ -75,6 +77,7 @@ class SettingsPage extends HookConsumerWidget { _ConnectionSection( identityRecoveryPageBuilder: identityRecoveryPageBuilder, ), + const _MobileSecuritySection(), const _RemoveCommunitySection(), ], ), diff --git a/mobile/lib/features/settings/settings_page/mobile_security_section.dart b/mobile/lib/features/settings/settings_page/mobile_security_section.dart new file mode 100644 index 0000000000..c5f7ec5be6 --- /dev/null +++ b/mobile/lib/features/settings/settings_page/mobile_security_section.dart @@ -0,0 +1,78 @@ +part of '../settings_page.dart'; + +class _MobileSecuritySection extends ConsumerWidget { + const _MobileSecuritySection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final auth = ref.watch(authProvider).value; + final community = auth?.community; + if (community == null) return const SizedBox.shrink(); + + final enabled = + community.sensitiveActionPolicy == SensitiveActionPolicy.enabled; + final capability = ref.watch(sensitiveActionAuthSupportedProvider); + + return AppListCard( + label: 'Mobile security', + children: [ + SwitchListTile( + key: const Key('sensitive-action-confirmation-toggle'), + secondary: const Icon(LucideIcons.shieldCheck), + title: const Text('Confirm sensitive identity actions'), + subtitle: Text( + enabled + ? 'Device authentication is required before sending your identity to a desktop.' + : 'Routine Buzz use never prompts. Enable protection for identity transfers.', + ), + value: enabled, + onChanged: (value) => _changePolicy(context, ref, value), + ), + AppListRow( + icon: LucideIcons.fingerprint, + title: 'Device authentication', + subtitle: capability.when( + data: (supported) => supported + ? 'Biometrics or device passcode available' + : 'Unavailable or not configured', + loading: () => 'Checking…', + error: (_, _) => 'Unavailable', + ), + ), + ], + ); + } + + Future _changePolicy( + BuildContext context, + WidgetRef ref, + bool enabled, + ) async { + final currentPolicy = ref + .read(authProvider) + .value + ?.community + ?.sensitiveActionPolicy; + if (enabled || currentPolicy == SensitiveActionPolicy.enabled) { + final result = await ref + .read(sensitiveActionAuthorizerProvider) + .authorizeIdentityAction(); + if (!context.mounted) return; + if (result != DeviceAuthResult.success) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Device authentication did not complete.'), + ), + ); + return; + } + } + await ref + .read(authProvider.notifier) + .updateSensitiveActionPolicy( + enabled + ? SensitiveActionPolicy.enabled + : SensitiveActionPolicy.disabledByUser, + ); + } +} diff --git a/mobile/lib/shared/auth/auth_provider.dart b/mobile/lib/shared/auth/auth_provider.dart index ade2220264..e138f727bd 100644 --- a/mobile/lib/shared/auth/auth_provider.dart +++ b/mobile/lib/shared/auth/auth_provider.dart @@ -67,6 +67,20 @@ class AuthNotifier extends AsyncNotifier { ); } + Future updateSensitiveActionPolicy(SensitiveActionPolicy policy) async { + final current = state.value?.community; + if (current == null || current.sensitiveActionPolicy == policy) return; + + final updated = current.copyWith(sensitiveActionPolicy: policy); + final storage = ref.read(communityStorageProvider); + await storage.save(updated); + ref.invalidate(communityListProvider); + ref.invalidate(activeCommunityProvider); + state = AsyncData( + AuthState(status: AuthStatus.authenticated, community: updated), + ); + } + Future signOut() async { final storage = ref.read(communityStorageProvider); final activeId = await storage.loadActiveId(); diff --git a/mobile/lib/shared/community/community.dart b/mobile/lib/shared/community/community.dart index 1858609e05..f887cd260e 100644 --- a/mobile/lib/shared/community/community.dart +++ b/mobile/lib/shared/community/community.dart @@ -3,12 +3,15 @@ import 'package:uuid/uuid.dart'; const _uuid = Uuid(); const _sentinel = Object(); +enum SensitiveActionPolicy { notConfigured, enabled, disabledByUser } + class Community { final String id; final String name; final String relayUrl; final String? pubkey; final String? nsec; + final SensitiveActionPolicy sensitiveActionPolicy; final DateTime addedAt; const Community({ @@ -17,6 +20,7 @@ class Community { required this.relayUrl, this.pubkey, this.nsec, + this.sensitiveActionPolicy = SensitiveActionPolicy.notConfigured, required this.addedAt, }); @@ -25,6 +29,8 @@ class Community { required String relayUrl, String? pubkey, String? nsec, + SensitiveActionPolicy sensitiveActionPolicy = + SensitiveActionPolicy.notConfigured, }) { return Community( id: _uuid.v4(), @@ -32,6 +38,7 @@ class Community { relayUrl: relayUrl, pubkey: pubkey, nsec: nsec, + sensitiveActionPolicy: sensitiveActionPolicy, addedAt: DateTime.now(), ); } @@ -41,6 +48,7 @@ class Community { String? relayUrl, Object? pubkey = _sentinel, Object? nsec = _sentinel, + SensitiveActionPolicy? sensitiveActionPolicy, }) { return Community( id: id, @@ -48,6 +56,8 @@ class Community { relayUrl: relayUrl ?? this.relayUrl, pubkey: pubkey == _sentinel ? this.pubkey : pubkey as String?, nsec: nsec == _sentinel ? this.nsec : nsec as String?, + sensitiveActionPolicy: + sensitiveActionPolicy ?? this.sensitiveActionPolicy, addedAt: addedAt, ); } @@ -58,6 +68,7 @@ class Community { 'relayUrl': relayUrl, if (pubkey != null) 'pubkey': pubkey, if (nsec != null) 'nsec': nsec, + 'sensitiveActionPolicy': sensitiveActionPolicy.name, 'addedAt': addedAt.toIso8601String(), }; @@ -67,6 +78,10 @@ class Community { relayUrl: json['relayUrl'] as String, pubkey: json['pubkey'] as String?, nsec: json['nsec'] as String?, + sensitiveActionPolicy: SensitiveActionPolicy.values.firstWhere( + (value) => value.name == json['sensitiveActionPolicy'], + orElse: () => SensitiveActionPolicy.notConfigured, + ), addedAt: DateTime.parse(json['addedAt'] as String), ); diff --git a/mobile/lib/shared/security/sensitive_action_authorizer.dart b/mobile/lib/shared/security/sensitive_action_authorizer.dart new file mode 100644 index 0000000000..acf9ea2b77 --- /dev/null +++ b/mobile/lib/shared/security/sensitive_action_authorizer.dart @@ -0,0 +1,68 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:local_auth/local_auth.dart'; + +/// Coarse outcomes safe to use for control flow without retaining OS details. +enum DeviceAuthResult { success, cancelled, unavailable, lockedOut, failed } + +abstract interface class SensitiveActionAuthorizer { + Future authorizeIdentityAction(); + + Future isSupported(); +} + +class LocalSensitiveActionAuthorizer implements SensitiveActionAuthorizer { + LocalSensitiveActionAuthorizer([LocalAuthentication? authentication]) + : _authentication = authentication ?? LocalAuthentication(); + + final LocalAuthentication _authentication; + + @override + Future authorizeIdentityAction() async { + try { + final supported = await _authentication.isDeviceSupported(); + if (!supported) return DeviceAuthResult.unavailable; + final authenticated = await _authentication.authenticate( + localizedReason: 'Confirm this sensitive Buzz identity action', + biometricOnly: false, + sensitiveTransaction: true, + persistAcrossBackgrounding: false, + ); + return authenticated ? DeviceAuthResult.success : DeviceAuthResult.failed; + } on LocalAuthException catch (error) { + return switch (error.code) { + LocalAuthExceptionCode.userCanceled || + LocalAuthExceptionCode.systemCanceled || + LocalAuthExceptionCode.timeout => DeviceAuthResult.cancelled, + LocalAuthExceptionCode.temporaryLockout || + LocalAuthExceptionCode.biometricLockout => DeviceAuthResult.lockedOut, + LocalAuthExceptionCode.noCredentialsSet || + LocalAuthExceptionCode.noBiometricsEnrolled || + LocalAuthExceptionCode.noBiometricHardware || + LocalAuthExceptionCode.biometricHardwareTemporarilyUnavailable || + LocalAuthExceptionCode.uiUnavailable => DeviceAuthResult.unavailable, + _ => DeviceAuthResult.failed, + }; + } catch (_) { + return DeviceAuthResult.failed; + } + } + + @override + Future isSupported() async { + try { + return await _authentication.isDeviceSupported(); + } catch (_) { + return false; + } + } +} + +final sensitiveActionAuthorizerProvider = Provider(( + ref, +) { + return LocalSensitiveActionAuthorizer(); +}); + +final sensitiveActionAuthSupportedProvider = FutureProvider((ref) { + return ref.watch(sensitiveActionAuthorizerProvider).isSupported(); +}); diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 6287e4c86c..cb9bc0268c 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -784,6 +784,46 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + local_auth: + dependency: "direct main" + description: + name: local_auth + sha256: ecf24edf2283c509ecd217e3595f6f71034b68888d28ad1dae6bfa0857b816ac + url: "https://pub.dev" + source: hosted + version: "3.0.2" + local_auth_android: + dependency: transitive + description: + name: local_auth_android + sha256: b201c006fa769c23386f89aa6837ec0eb8179fcfb212eadcf87b422b3f9a6a78 + url: "https://pub.dev" + source: hosted + version: "2.0.8" + local_auth_darwin: + dependency: transitive + description: + name: local_auth_darwin + sha256: a8c3d4e17454111f7fd31ff72a31222359f6059f7fe956c2dcfe0f88f49826d4 + url: "https://pub.dev" + source: hosted + version: "2.0.3" + local_auth_platform_interface: + dependency: transitive + description: + name: local_auth_platform_interface + sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + local_auth_windows: + dependency: transitive + description: + name: local_auth_windows + sha256: be12c5b8ba5e64896983123655c5f67d2484ecfcc95e367952ad6e3bff94cb16 + url: "https://pub.dev" + source: hosted + version: "2.0.1" logging: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 41d2a0aeb8..2cc049c5fe 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -42,6 +42,7 @@ dependencies: open_filex: ^4.7.0 path_provider: ^2.1.6 share_plus: ^13.3.0 + local_auth: ^3.0.2 dev_dependencies: flutter_test: diff --git a/mobile/test/features/pairing/pairing_page_test.dart b/mobile/test/features/pairing/pairing_page_test.dart index e8f34a6f71..50ffe656c6 100644 --- a/mobile/test/features/pairing/pairing_page_test.dart +++ b/mobile/test/features/pairing/pairing_page_test.dart @@ -224,6 +224,48 @@ void main() { expect(notifier.pairedCodes, [code]); }); + testWidgets('new identity import offers protection checked by default', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + pairingProvider.overrideWith(() => _ConfirmingSasPairingNotifier()), + ], + child: MaterialApp(theme: AppTheme.dark(), home: const PairingPage()), + ), + ); + + final checkbox = tester.widget( + find.byKey(const Key('protect-imported-identity-checkbox')), + ); + expect(checkbox.value, isTrue); + expect( + find.textContaining('Routine Buzz use will not prompt'), + findsOneWidget, + ); + }); + + testWidgets('desktop recovery does not show import protection checkbox', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + pairingProvider.overrideWith( + () => _ConfirmingSasPairingNotifier(sendsIdentityToDesktop: true), + ), + ], + child: MaterialApp(theme: AppTheme.dark(), home: const PairingPage()), + ), + ); + + expect( + find.byKey(const Key('protect-imported-identity-checkbox')), + findsNothing, + ); + }); + testWidgets('recovery SAS warns about permanent desktop access', ( tester, ) async { @@ -268,6 +310,9 @@ class _ErrorPairingNotifier extends Notifier @override void confirmSas() {} + @override + void setProtectImportedIdentity(bool value) {} + @override void denySas() {} } @@ -286,6 +331,9 @@ class _ConnectingPairingNotifier extends Notifier @override void confirmSas() {} + @override + void setProtectImportedIdentity(bool value) {} + @override void denySas() {} } @@ -306,6 +354,9 @@ class _RecordingPairingNotifier extends Notifier @override void confirmSas() {} + @override + void setProtectImportedIdentity(bool value) {} + @override void denySas() {} } @@ -332,6 +383,9 @@ class _ConfirmingSasPairingNotifier extends Notifier @override void confirmSas() {} + @override + void setProtectImportedIdentity(bool value) {} + @override void denySas() {} } diff --git a/mobile/test/features/pairing/pairing_provider_test.dart b/mobile/test/features/pairing/pairing_provider_test.dart index c14599bbef..d11c46af3e 100644 --- a/mobile/test/features/pairing/pairing_provider_test.dart +++ b/mobile/test/features/pairing/pairing_provider_test.dart @@ -10,6 +10,7 @@ import 'package:buzz/shared/auth/auth.dart'; import 'package:buzz/shared/crypto/ecdh.dart'; import 'package:buzz/shared/crypto/nip44.dart'; import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/security/sensitive_action_authorizer.dart'; /// Tests for [PairingNotifier]'s legacy `buzz://` payload parsing and /// SSRF-prevention validation. @@ -194,13 +195,15 @@ void main() { late _ControllableSocket socket; late PairingNotifier notifier; late String recoveryCode; + late _FakeSensitiveActionAuthorizer authorizer; - setUp(() { + setUp(() async { final source = nostr.Keys(sourceSecret); recoveryCode = 'nostrpair://${source.public}' '?secret=$sessionSecretHex' '&relay=wss%3A%2F%2Fpairing.buzz.xyz&v=1&mode=recover'; + authorizer = _FakeSensitiveActionAuthorizer(); notifier = PairingNotifier( socketFactory: ({ @@ -221,10 +224,13 @@ void main() { overrides: [ pairingProvider.overrideWith(() => notifier), relayConfigProvider.overrideWith(_RecoveryRelayConfig.new), + authProvider.overrideWith(_ProtectedRecoveryAuthNotifier.new), + sensitiveActionAuthorizerProvider.overrideWithValue(authorizer), ], ); container.read(pairingProvider); notifier = container.read(pairingProvider.notifier); + await container.read(authProvider.future); }); test('recovery URI enables phone-to-desktop transfer', () async { @@ -250,6 +256,8 @@ void main() { includeTranscriptHash: true, ); + await Future.delayed(Duration.zero); + expect( container.read(pairingProvider).status, PairingStatus.transferring, @@ -274,6 +282,37 @@ void main() { }, ); + test( + 'protected recovery emits no payload when authentication is cancelled', + () async { + authorizer.result = DeviceAuthResult.cancelled; + await notifier.pair(recoveryCode); + notifier.confirmSas(); + socket.sendSourceMessage( + sourceSecret: sourceSecret, + sessionSecretHex: sessionSecretHex, + message: {'type': 'sas-confirm'}, + includeTranscriptHash: true, + ); + await Future.delayed(Duration.zero); + + final messages = socket.decryptedPublishedMessages(sourceSecret); + expect(authorizer.calls, 1); + expect( + messages.where((message) => message['type'] == 'payload'), + isEmpty, + ); + expect( + container.read(pairingProvider).status, + PairingStatus.confirmingSas, + ); + expect( + container.read(pairingProvider).errorMessage, + contains('cancelled'), + ); + }, + ); + test('desktop storage failure surfaces an error', () async { await notifier.pair(recoveryCode); notifier.confirmSas(); @@ -283,6 +322,7 @@ void main() { message: {'type': 'sas-confirm'}, includeTranscriptHash: true, ); + await Future.delayed(Duration.zero); socket.sendSourceMessage( sourceSecret: sourceSecret, sessionSecretHex: sessionSecretHex, @@ -323,6 +363,11 @@ class FakeAuthNotifier extends AsyncNotifier Future build() async => const AuthState(status: AuthStatus.unauthenticated); + @override + Future updateSensitiveActionPolicy( + SensitiveActionPolicy policy, + ) async {} + @override Future signOut() async { signedOut = true; @@ -365,6 +410,35 @@ class _RecoveryRelayConfig extends RelayConfigNotifier { RelayConfig build() => RelayConfig(baseUrl: 'https://relay.test', nsec: nsec); } +class _ProtectedRecoveryAuthNotifier extends AuthNotifier { + @override + Future build() async => AuthState( + status: AuthStatus.authenticated, + community: Community( + id: 'recovery', + name: 'Recovery', + relayUrl: 'https://relay.test', + nsec: _RecoveryRelayConfig.nsec, + sensitiveActionPolicy: SensitiveActionPolicy.enabled, + addedAt: DateTime.utc(2026, 8, 5), + ), + ); +} + +class _FakeSensitiveActionAuthorizer implements SensitiveActionAuthorizer { + DeviceAuthResult result = DeviceAuthResult.success; + int calls = 0; + + @override + Future authorizeIdentityAction() async { + calls++; + return result; + } + + @override + Future isSupported() async => true; +} + class _ControllableSocket extends PairingSocket { final String ephemeralPrivkey; final void Function(List message) relayMessageCallback; diff --git a/mobile/test/shared/community/community_test.dart b/mobile/test/shared/community/community_test.dart new file mode 100644 index 0000000000..2a81021417 --- /dev/null +++ b/mobile/test/shared/community/community_test.dart @@ -0,0 +1,33 @@ +import 'package:buzz/shared/community/community.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('existing records migrate to not configured protection', () { + final community = Community.fromJson({ + 'id': 'one', + 'name': 'Buzz', + 'relayUrl': 'https://relay.test', + 'addedAt': '2026-08-05T00:00:00.000Z', + }); + + expect( + community.sensitiveActionPolicy, + SensitiveActionPolicy.notConfigured, + ); + }); + + test('sensitive action policy round trips', () { + final community = Community( + id: 'one', + name: 'Buzz', + relayUrl: 'https://relay.test', + sensitiveActionPolicy: SensitiveActionPolicy.enabled, + addedAt: DateTime.utc(2026, 8, 5), + ); + + expect( + Community.fromJson(community.toJson()).sensitiveActionPolicy, + SensitiveActionPolicy.enabled, + ); + }); +} From c985ed237948a611a36660982d3160846bd04ab8 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 5 Aug 2026 18:39:05 -0700 Subject: [PATCH 2/2] fix(mobile): tailor biometric copy by platform Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- mobile/ios/Podfile.lock | 7 ++++ mobile/lib/features/pairing/pairing_page.dart | 8 +++- .../mobile_security_section.dart | 5 ++- .../security/sensitive_action_authorizer.dart | 8 ++++ .../features/pairing/pairing_page_test.dart | 40 +++++++++++++++++++ 5 files changed, 65 insertions(+), 3 deletions(-) diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index c1a2b9e13c..05267ed7c1 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -15,6 +15,9 @@ PODS: - FlutterMacOS - image_picker_ios (0.0.1): - Flutter + - local_auth_darwin (0.0.1): + - Flutter + - FlutterMacOS - mobile_scanner (7.0.0): - Flutter - FlutterMacOS @@ -45,6 +48,7 @@ DEPENDENCIES: - Flutter (from `Flutter`) - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) + - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`) - mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`) - open_filex (from `.symlinks/plugins/open_filex/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) @@ -71,6 +75,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" + local_auth_darwin: + :path: ".symlinks/plugins/local_auth_darwin/darwin" mobile_scanner: :path: ".symlinks/plugins/mobile_scanner/darwin" open_filex: @@ -97,6 +103,7 @@ SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 + local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index 63a5b6eca4..263c18e51f 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -6,6 +6,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/security/sensitive_action_authorizer.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/tappable_flapping_bee.dart'; @@ -221,6 +222,9 @@ class _SasVerificationView extends StatelessWidget { @override Widget build(BuildContext context) { + final authenticationName = sensitiveActionAuthenticationName( + Theme.of(context).platform, + ); return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -289,8 +293,8 @@ class _SasVerificationView extends StatelessWidget { : (value) => onProtectionChanged(value ?? false), controlAffinity: ListTileControlAffinity.leading, contentPadding: EdgeInsets.zero, - title: const Text( - 'Use biometrics to confirm sensitive identity actions', + title: Text( + 'Use $authenticationName to confirm sensitive identity actions', ), subtitle: const Text( 'Routine Buzz use will not prompt. This protects identity transfer and reveal actions.', diff --git a/mobile/lib/features/settings/settings_page/mobile_security_section.dart b/mobile/lib/features/settings/settings_page/mobile_security_section.dart index c5f7ec5be6..b6f1c55637 100644 --- a/mobile/lib/features/settings/settings_page/mobile_security_section.dart +++ b/mobile/lib/features/settings/settings_page/mobile_security_section.dart @@ -12,6 +12,9 @@ class _MobileSecuritySection extends ConsumerWidget { final enabled = community.sensitiveActionPolicy == SensitiveActionPolicy.enabled; final capability = ref.watch(sensitiveActionAuthSupportedProvider); + final authenticationName = sensitiveActionAuthenticationName( + Theme.of(context).platform, + ); return AppListCard( label: 'Mobile security', @@ -33,7 +36,7 @@ class _MobileSecuritySection extends ConsumerWidget { title: 'Device authentication', subtitle: capability.when( data: (supported) => supported - ? 'Biometrics or device passcode available' + ? '${authenticationName[0].toUpperCase()}${authenticationName.substring(1)} or device passcode available' : 'Unavailable or not configured', loading: () => 'Checking…', error: (_, _) => 'Unavailable', diff --git a/mobile/lib/shared/security/sensitive_action_authorizer.dart b/mobile/lib/shared/security/sensitive_action_authorizer.dart index acf9ea2b77..cf4926a608 100644 --- a/mobile/lib/shared/security/sensitive_action_authorizer.dart +++ b/mobile/lib/shared/security/sensitive_action_authorizer.dart @@ -1,6 +1,14 @@ +import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:local_auth/local_auth.dart'; +String sensitiveActionAuthenticationName(TargetPlatform platform) => + switch (platform) { + TargetPlatform.iOS => 'Face ID', + TargetPlatform.android => 'biometrics', + _ => 'device authentication', + }; + /// Coarse outcomes safe to use for control flow without retaining OS details. enum DeviceAuthResult { success, cancelled, unavailable, lockedOut, failed } diff --git a/mobile/test/features/pairing/pairing_page_test.dart b/mobile/test/features/pairing/pairing_page_test.dart index 50ffe656c6..f7e8ee71ff 100644 --- a/mobile/test/features/pairing/pairing_page_test.dart +++ b/mobile/test/features/pairing/pairing_page_test.dart @@ -224,6 +224,46 @@ void main() { expect(notifier.pairedCodes, [code]); }); + testWidgets('uses Face ID copy on iOS builds', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + pairingProvider.overrideWith(() => _ConfirmingSasPairingNotifier()), + ], + child: MaterialApp( + theme: AppTheme.dark().copyWith(platform: TargetPlatform.iOS), + home: const PairingPage(), + ), + ), + ); + + expect( + find.text('Use Face ID to confirm sensitive identity actions'), + findsOneWidget, + ); + }); + + testWidgets('uses generic biometrics copy on Android builds', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + pairingProvider.overrideWith(() => _ConfirmingSasPairingNotifier()), + ], + child: MaterialApp( + theme: AppTheme.dark().copyWith(platform: TargetPlatform.android), + home: const PairingPage(), + ), + ), + ); + + expect( + find.text('Use biometrics to confirm sensitive identity actions'), + findsOneWidget, + ); + }); + testWidgets('new identity import offers protection checked by default', ( tester, ) async {