From 22d4b40268df3e400da686561e58546708e1a934 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 5 Aug 2026 17:45:07 -0700 Subject: [PATCH 1/8] 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 1444eea0053abbff9d03ff176cf8d1bb206f4783 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 5 Aug 2026 18:39:05 -0700 Subject: [PATCH 2/8] 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 { From c8a30cb8d049353624a70b2852b1f8a5d90a4875 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 5 Aug 2026 18:54:09 -0700 Subject: [PATCH 3/8] feat(mobile): lock protected identities on app entry Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- mobile/lib/app.dart | 156 +++++++++++++++++- mobile/lib/features/pairing/pairing_page.dart | 8 +- .../features/pairing/pairing_provider.dart | 4 +- .../mobile_security_section.dart | 10 +- .../security/sensitive_action_authorizer.dart | 36 ++++ .../features/pairing/pairing_page_test.dart | 12 +- .../shared/security/app_lock_gate_test.dart | 109 ++++++++++++ 7 files changed, 310 insertions(+), 25 deletions(-) create mode 100644 mobile/test/shared/security/app_lock_gate_test.dart diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index d5ae326afa..de6488c855 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -20,6 +20,7 @@ import 'shared/deeplink/pending_deep_link_provider.dart'; import 'shared/emoji/emoji_burst.dart'; import 'shared/relay/relay.dart'; import 'shared/read_state/read_state_provider.dart'; +import 'shared/security/sensitive_action_authorizer.dart'; import 'shared/theme/theme.dart'; import 'shared/widgets/buzz_loading_indicator.dart'; @@ -131,10 +132,15 @@ class App extends HookConsumerWidget { loading: () => const _SplashScreen(), error: (_, _) => const PairingPage(), data: (state) => switch (state.status) { - AuthStatus.authenticated => DeepLinkDispatcher( - child: HomePage( - settingsPageBuilder: _buildSettingsPage, - hasUnreadInbox: hasUnreadInbox, + AuthStatus.authenticated => AppLockGate( + enabled: + state.community?.sensitiveActionPolicy == + SensitiveActionPolicy.enabled, + child: DeepLinkDispatcher( + child: HomePage( + settingsPageBuilder: _buildSettingsPage, + hasUnreadInbox: hasUnreadInbox, + ), ), ), _ => const DeepLinkDispatcher( @@ -165,3 +171,145 @@ class _SplashScreen extends StatelessWidget { ); } } + +const appLockTimeout = Duration(minutes: 5); + +class AppLockGate extends ConsumerStatefulWidget { + const AppLockGate({super.key, required this.enabled, required this.child}); + + final bool enabled; + final Widget child; + + @override + ConsumerState createState() => _AppLockGateState(); +} + +class _AppLockGateState extends ConsumerState + with WidgetsBindingObserver { + bool _locked = true; + bool _authenticating = false; + String? _error; + DateTime? _backgroundedAt; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + if (!widget.enabled) _locked = false; + WidgetsBinding.instance.addPostFrameCallback((_) => _unlockIfNeeded()); + } + + @override + void didUpdateWidget(AppLockGate oldWidget) { + super.didUpdateWidget(oldWidget); + if (!widget.enabled) { + setState(() { + _locked = false; + _error = null; + }); + } else if (!oldWidget.enabled) { + setState(() => _locked = true); + WidgetsBinding.instance.addPostFrameCallback((_) => _unlockIfNeeded()); + } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (!widget.enabled || _authenticating) return; + switch (state) { + case AppLifecycleState.inactive: + case AppLifecycleState.paused: + case AppLifecycleState.hidden: + case AppLifecycleState.detached: + _backgroundedAt ??= ref.read(appLockClockProvider)(); + setState(() => _locked = true); + case AppLifecycleState.resumed: + final backgroundedAt = _backgroundedAt; + _backgroundedAt = null; + if (backgroundedAt != null) { + final timedOut = + ref.read(appLockClockProvider)().difference(backgroundedAt) >= + appLockTimeout; + _unlockIfNeeded(forceFresh: timedOut); + } + } + } + + Future _unlockIfNeeded({bool forceFresh = false}) async { + if (!mounted || !widget.enabled || !_locked || _authenticating) return; + final session = ref.read(sensitiveActionAuthorizationSessionProvider); + if (!forceFresh && session.wasAuthorizedWithin(appLockTimeout)) { + setState(() { + _locked = false; + _error = null; + }); + return; + } + + setState(() { + _authenticating = true; + _error = null; + }); + final result = await session.authorize(); + if (!mounted || !widget.enabled) return; + setState(() { + _authenticating = false; + _locked = result != DeviceAuthResult.success; + _error = result == DeviceAuthResult.success + ? null + : 'Buzz is locked. Authenticate to continue.'; + }); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!widget.enabled || !_locked) return widget.child; + final authenticationName = sensitiveActionAuthenticationName( + Theme.of(context).platform, + ); + return Scaffold( + key: const Key('app-lock-screen'), + body: SafeArea( + child: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.lock_outline, + size: 56, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + 'Buzz is locked', + style: Theme.of(context).textTheme.headlineSmall, + ), + if (_error != null) ...[ + const SizedBox(height: 8), + Text(_error!, textAlign: TextAlign.center), + ], + const SizedBox(height: 24), + FilledButton( + onPressed: _authenticating ? null : _unlockIfNeeded, + child: Text( + _authenticating + ? 'Authenticating…' + : 'Unlock with $authenticationName', + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index 263c18e51f..8fc2439a9a 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -293,11 +293,9 @@ class _SasVerificationView extends StatelessWidget { : (value) => onProtectionChanged(value ?? false), controlAffinity: ListTileControlAffinity.leading, contentPadding: EdgeInsets.zero, - 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.', + title: Text('Use $authenticationName'), + subtitle: Text( + 'Require $authenticationName to open Buzz and approve identity transfers.', ), ), diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index 5afb4a0e99..0714cf9c5d 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -157,8 +157,8 @@ class PairingNotifier extends Notifier { if (requiresAuthorization) { state = state.copyWith(authorizationInProgress: true); final result = await ref - .read(sensitiveActionAuthorizerProvider) - .authorizeIdentityAction(); + .read(sensitiveActionAuthorizationSessionProvider) + .authorize(); if (state.status != PairingStatus.confirmingSas) return; if (result != DeviceAuthResult.success) { _userConfirmedSas = false; 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 b6f1c55637..99d1858cc2 100644 --- a/mobile/lib/features/settings/settings_page/mobile_security_section.dart +++ b/mobile/lib/features/settings/settings_page/mobile_security_section.dart @@ -22,11 +22,11 @@ class _MobileSecuritySection extends ConsumerWidget { SwitchListTile( key: const Key('sensitive-action-confirmation-toggle'), secondary: const Icon(LucideIcons.shieldCheck), - title: const Text('Confirm sensitive identity actions'), + title: Text('Use $authenticationName'), subtitle: Text( enabled - ? 'Device authentication is required before sending your identity to a desktop.' - : 'Routine Buzz use never prompts. Enable protection for identity transfers.', + ? 'Required to open Buzz and approve identity transfers.' + : 'Require $authenticationName to open Buzz and approve identity transfers.', ), value: enabled, onChanged: (value) => _changePolicy(context, ref, value), @@ -58,8 +58,8 @@ class _MobileSecuritySection extends ConsumerWidget { ?.sensitiveActionPolicy; if (enabled || currentPolicy == SensitiveActionPolicy.enabled) { final result = await ref - .read(sensitiveActionAuthorizerProvider) - .authorizeIdentityAction(); + .read(sensitiveActionAuthorizationSessionProvider) + .authorize(); if (!context.mounted) return; if (result != DeviceAuthResult.success) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/mobile/lib/shared/security/sensitive_action_authorizer.dart b/mobile/lib/shared/security/sensitive_action_authorizer.dart index cf4926a608..c128572c14 100644 --- a/mobile/lib/shared/security/sensitive_action_authorizer.dart +++ b/mobile/lib/shared/security/sensitive_action_authorizer.dart @@ -74,3 +74,39 @@ final sensitiveActionAuthorizerProvider = Provider(( final sensitiveActionAuthSupportedProvider = FutureProvider((ref) { return ref.watch(sensitiveActionAuthorizerProvider).isSupported(); }); + +final appLockClockProvider = Provider((ref) { + return DateTime.now; +}); + +class SensitiveActionAuthorizationSession { + SensitiveActionAuthorizationSession({ + required SensitiveActionAuthorizer authorizer, + required DateTime Function() now, + }) : _authorizer = authorizer, + _now = now; + + final SensitiveActionAuthorizer _authorizer; + final DateTime Function() _now; + + DateTime? lastSuccessfulAt; + + Future authorize() async { + final result = await _authorizer.authorizeIdentityAction(); + if (result == DeviceAuthResult.success) lastSuccessfulAt = _now(); + return result; + } + + bool wasAuthorizedWithin(Duration duration) { + final authorizedAt = lastSuccessfulAt; + return authorizedAt != null && _now().difference(authorizedAt) < duration; + } +} + +final sensitiveActionAuthorizationSessionProvider = + Provider((ref) { + return SensitiveActionAuthorizationSession( + authorizer: ref.watch(sensitiveActionAuthorizerProvider), + now: ref.watch(appLockClockProvider), + ); + }); diff --git a/mobile/test/features/pairing/pairing_page_test.dart b/mobile/test/features/pairing/pairing_page_test.dart index f7e8ee71ff..39bb5daa43 100644 --- a/mobile/test/features/pairing/pairing_page_test.dart +++ b/mobile/test/features/pairing/pairing_page_test.dart @@ -237,10 +237,7 @@ void main() { ), ); - expect( - find.text('Use Face ID to confirm sensitive identity actions'), - findsOneWidget, - ); + expect(find.text('Use Face ID'), findsOneWidget); }); testWidgets('uses generic biometrics copy on Android builds', ( @@ -258,10 +255,7 @@ void main() { ), ); - expect( - find.text('Use biometrics to confirm sensitive identity actions'), - findsOneWidget, - ); + expect(find.text('Use biometrics'), findsOneWidget); }); testWidgets('new identity import offers protection checked by default', ( @@ -281,7 +275,7 @@ void main() { ); expect(checkbox.value, isTrue); expect( - find.textContaining('Routine Buzz use will not prompt'), + find.textContaining('open Buzz and approve identity transfers'), findsOneWidget, ); }); diff --git a/mobile/test/shared/security/app_lock_gate_test.dart b/mobile/test/shared/security/app_lock_gate_test.dart new file mode 100644 index 0000000000..c8724d0496 --- /dev/null +++ b/mobile/test/shared/security/app_lock_gate_test.dart @@ -0,0 +1,109 @@ +import 'package:buzz/app.dart'; +import 'package:buzz/shared/security/sensitive_action_authorizer.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +void main() { + testWidgets('protected cold launch authenticates before showing content', ( + tester, + ) async { + final authorizer = _FakeAuthorizer(); + await tester.pumpWidget(_testApp(authorizer: authorizer)); + await tester.pump(); + + expect(authorizer.calls, 1); + expect(find.text('Private content'), findsOneWidget); + expect(find.byKey(const Key('app-lock-screen')), findsNothing); + }); + + testWidgets('cancelled cold launch stays on privacy-safe lock screen', ( + tester, + ) async { + final authorizer = _FakeAuthorizer(result: DeviceAuthResult.cancelled); + await tester.pumpWidget(_testApp(authorizer: authorizer)); + await tester.pump(); + + expect(find.byKey(const Key('app-lock-screen')), findsOneWidget); + expect(find.text('Private content'), findsNothing); + expect(find.text('Unlock with Face ID'), findsOneWidget); + }); + + testWidgets('resume inside five minutes remains unlocked', (tester) async { + var now = DateTime.utc(2026, 8, 6); + final authorizer = _FakeAuthorizer(); + await tester.pumpWidget(_testApp(authorizer: authorizer, now: () => now)); + await tester.pump(); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + + now = now.add(const Duration(minutes: 4)); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + + expect(authorizer.calls, 1); + expect(find.text('Private content'), findsOneWidget); + }); + + testWidgets('resume after five minutes requires fresh authentication', ( + tester, + ) async { + var now = DateTime.utc(2026, 8, 6); + final authorizer = _FakeAuthorizer(); + await tester.pumpWidget(_testApp(authorizer: authorizer, now: () => now)); + await tester.pump(); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + now = now.add(const Duration(minutes: 5)); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + + expect(authorizer.calls, 2); + expect(find.text('Private content'), findsOneWidget); + }); + + testWidgets('disabled protection never authenticates', (tester) async { + final authorizer = _FakeAuthorizer(); + await tester.pumpWidget(_testApp(authorizer: authorizer, enabled: false)); + await tester.pump(); + + expect(authorizer.calls, 0); + expect(find.text('Private content'), findsOneWidget); + }); +} + +Widget _testApp({ + required _FakeAuthorizer authorizer, + DateTime Function()? now, + bool enabled = true, +}) { + return ProviderScope( + overrides: [ + sensitiveActionAuthorizerProvider.overrideWithValue(authorizer), + if (now != null) appLockClockProvider.overrideWithValue(now), + ], + child: MaterialApp( + theme: ThemeData(platform: TargetPlatform.iOS), + home: AppLockGate( + enabled: enabled, + child: const Scaffold(body: Text('Private content')), + ), + ), + ); +} + +class _FakeAuthorizer implements SensitiveActionAuthorizer { + _FakeAuthorizer({this.result = DeviceAuthResult.success}); + + DeviceAuthResult result; + int calls = 0; + + @override + Future authorizeIdentityAction() async { + calls++; + return result; + } + + @override + Future isSupported() async => true; +} From ef8dbab3258e634ab375cc054387b5b80db1d628 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 5 Aug 2026 22:09:50 -0700 Subject: [PATCH 4/8] feat(mobile): refine biometric protection surfaces Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- mobile/lib/app.dart | 47 +++++++------------ mobile/lib/features/pairing/pairing_page.dart | 2 +- .../mobile_security_section.dart | 16 ++----- .../features/pairing/pairing_page_test.dart | 2 +- .../shared/security/app_lock_gate_test.dart | 18 +++++++ 5 files changed, 40 insertions(+), 45 deletions(-) diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index de6488c855..4b3be414ac 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -188,7 +188,6 @@ class _AppLockGateState extends ConsumerState with WidgetsBindingObserver { bool _locked = true; bool _authenticating = false; - String? _error; DateTime? _backgroundedAt; @override @@ -203,10 +202,7 @@ class _AppLockGateState extends ConsumerState void didUpdateWidget(AppLockGate oldWidget) { super.didUpdateWidget(oldWidget); if (!widget.enabled) { - setState(() { - _locked = false; - _error = null; - }); + setState(() => _locked = false); } else if (!oldWidget.enabled) { setState(() => _locked = true); WidgetsBinding.instance.addPostFrameCallback((_) => _unlockIfNeeded()); @@ -239,25 +235,16 @@ class _AppLockGateState extends ConsumerState if (!mounted || !widget.enabled || !_locked || _authenticating) return; final session = ref.read(sensitiveActionAuthorizationSessionProvider); if (!forceFresh && session.wasAuthorizedWithin(appLockTimeout)) { - setState(() { - _locked = false; - _error = null; - }); + setState(() => _locked = false); return; } - setState(() { - _authenticating = true; - _error = null; - }); + setState(() => _authenticating = true); final result = await session.authorize(); if (!mounted || !widget.enabled) return; setState(() { _authenticating = false; _locked = result != DeviceAuthResult.success; - _error = result == DeviceAuthResult.success - ? null - : 'Buzz is locked. Authenticate to continue.'; }); } @@ -275,6 +262,7 @@ class _AppLockGateState extends ConsumerState ); return Scaffold( key: const Key('app-lock-screen'), + backgroundColor: Colors.black, body: SafeArea( child: Center( child: Padding( @@ -282,22 +270,21 @@ class _AppLockGateState extends ConsumerState child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.lock_outline, - size: 56, - color: Theme.of(context).colorScheme.primary, + Image.asset( + 'assets/images/buzz-icon.png', + key: const Key('app-lock-logo'), + width: 112, + semanticLabel: 'Buzz', ), - const SizedBox(height: 16), - Text( - 'Buzz is locked', - style: Theme.of(context).textTheme.headlineSmall, - ), - if (_error != null) ...[ - const SizedBox(height: 8), - Text(_error!, textAlign: TextAlign.center), - ], - const SizedBox(height: 24), + const SizedBox(height: 32), FilledButton( + key: const Key('app-lock-unlock-button'), + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + disabledBackgroundColor: Colors.white54, + disabledForegroundColor: Colors.black54, + ), onPressed: _authenticating ? null : _unlockIfNeeded, child: Text( _authenticating diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index 8fc2439a9a..3ecedaea61 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -295,7 +295,7 @@ class _SasVerificationView extends StatelessWidget { contentPadding: EdgeInsets.zero, title: Text('Use $authenticationName'), subtitle: Text( - 'Require $authenticationName to open Buzz and approve identity transfers.', + 'Require $authenticationName to open Buzz and approve protected 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 99d1858cc2..f953093823 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,7 @@ class _MobileSecuritySection extends ConsumerWidget { final enabled = community.sensitiveActionPolicy == SensitiveActionPolicy.enabled; final capability = ref.watch(sensitiveActionAuthSupportedProvider); + if (!enabled && capability.value != true) return const SizedBox.shrink(); final authenticationName = sensitiveActionAuthenticationName( Theme.of(context).platform, ); @@ -25,23 +26,12 @@ class _MobileSecuritySection extends ConsumerWidget { title: Text('Use $authenticationName'), subtitle: Text( enabled - ? 'Required to open Buzz and approve identity transfers.' - : 'Require $authenticationName to open Buzz and approve identity transfers.', + ? 'Required to open Buzz and approve protected actions.' + : 'Require $authenticationName to open Buzz and approve protected actions.', ), value: enabled, onChanged: (value) => _changePolicy(context, ref, value), ), - AppListRow( - icon: LucideIcons.fingerprint, - title: 'Device authentication', - subtitle: capability.when( - data: (supported) => supported - ? '${authenticationName[0].toUpperCase()}${authenticationName.substring(1)} or device passcode available' - : 'Unavailable or not configured', - loading: () => 'Checking…', - error: (_, _) => 'Unavailable', - ), - ), ], ); } diff --git a/mobile/test/features/pairing/pairing_page_test.dart b/mobile/test/features/pairing/pairing_page_test.dart index 39bb5daa43..7849f94270 100644 --- a/mobile/test/features/pairing/pairing_page_test.dart +++ b/mobile/test/features/pairing/pairing_page_test.dart @@ -275,7 +275,7 @@ void main() { ); expect(checkbox.value, isTrue); expect( - find.textContaining('open Buzz and approve identity transfers'), + find.textContaining('open Buzz and approve protected actions'), findsOneWidget, ); }); diff --git a/mobile/test/shared/security/app_lock_gate_test.dart b/mobile/test/shared/security/app_lock_gate_test.dart index c8724d0496..defa2b4efa 100644 --- a/mobile/test/shared/security/app_lock_gate_test.dart +++ b/mobile/test/shared/security/app_lock_gate_test.dart @@ -25,8 +25,26 @@ void main() { await tester.pump(); expect(find.byKey(const Key('app-lock-screen')), findsOneWidget); + expect(find.byKey(const Key('app-lock-logo')), findsOneWidget); expect(find.text('Private content'), findsNothing); + expect(find.text('Buzz is locked'), findsNothing); expect(find.text('Unlock with Face ID'), findsOneWidget); + + final scaffold = tester.widget( + find.byKey(const Key('app-lock-screen')), + ); + expect(scaffold.backgroundColor, Colors.black); + final button = tester.widget( + find.byKey(const Key('app-lock-unlock-button')), + ); + expect( + button.style?.backgroundColor?.resolve({}), + Colors.white, + ); + expect( + button.style?.foregroundColor?.resolve({}), + Colors.black, + ); }); testWidgets('resume inside five minutes remains unlocked', (tester) async { From 43077e742c73b07d3343cfe1da2f403284262b20 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 5 Aug 2026 22:17:39 -0700 Subject: [PATCH 5/8] feat(mobile): authorize identity export before pairing Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../features/pairing/pairing_provider.dart | 44 +++++++++++++++-- .../lib/features/settings/settings_page.dart | 1 + .../settings_page/connection_section.dart | 22 +++++++-- .../features/pairing/pairing_page_test.dart | 12 +++++ .../pairing/pairing_provider_test.dart | 48 +++++++------------ 5 files changed, 89 insertions(+), 38 deletions(-) diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index 0714cf9c5d..51ce31f734 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -59,9 +59,10 @@ class PairingState { bool? sendsIdentityToDesktop, bool? protectImportedIdentity, bool? authorizationInProgress, + bool clearErrorMessage = false, }) => PairingState( status: status ?? this.status, - errorMessage: errorMessage ?? this.errorMessage, + errorMessage: clearErrorMessage ? null : errorMessage ?? this.errorMessage, sasCode: sasCode ?? this.sasCode, userConfirmedSas: userConfirmedSas ?? this.userConfirmedSas, sendsIdentityToDesktop: @@ -119,6 +120,40 @@ class PairingNotifier extends Notifier { return _pairLegacy(trimmed); } + Future authorizeIdentityExport() async { + if (state.authorizationInProgress) return false; + + final activePolicy = (await ref.read( + authProvider.future, + )).community?.sensitiveActionPolicy; + if (activePolicy != SensitiveActionPolicy.enabled) { + state = state.copyWith( + errorMessage: + 'Turn on biometric protection before sending your identity.', + ); + return false; + } + + state = state.copyWith( + authorizationInProgress: true, + clearErrorMessage: true, + ); + final result = await ref + .read(sensitiveActionAuthorizationSessionProvider) + .authorize(); + if (result != DeviceAuthResult.success) { + state = state.copyWith( + authorizationInProgress: false, + errorMessage: _authorizationError(result), + ); + return false; + } + + _identityExportAuthorized = true; + state = state.copyWith(authorizationInProgress: false); + return true; + } + /// Confirm that the SAS code matches. Called by the UI after user approval. void confirmSas() { if (state.status != PairingStatus.confirmingSas || @@ -147,11 +182,8 @@ class PairingNotifier extends Notifier { return; } - final activePolicy = (await ref.read( - authProvider.future, - )).community?.sensitiveActionPolicy; final requiresAuthorization = _sendIdentityToSource - ? activePolicy == SensitiveActionPolicy.enabled + ? !_identityExportAuthorized : state.protectImportedIdentity; if (requiresAuthorization) { @@ -226,6 +258,7 @@ class PairingNotifier extends Notifier { _userConfirmedSas = false; _pendingPayload = null; _sendIdentityToSource = false; + _identityExportAuthorized = false; } // ── NIP-AB pairing flow ───────────────────────────────────────────────── @@ -241,6 +274,7 @@ class PairingNotifier extends Notifier { bool _sasConfirmReceived = false; bool _userConfirmedSas = false; bool _sendIdentityToSource = false; + bool _identityExportAuthorized = false; Map? _pendingPayload; // buffered until user confirms SAS final Set _processedEventIds = {}; // NIP-AB §Duplicate Event Handling diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 1dee049f93..1aa11939ce 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -12,6 +12,7 @@ import '../../shared/auth/auth.dart'; import '../../shared/clipboard_utils.dart'; import '../../shared/relay/relay.dart'; import '../../shared/security/sensitive_action_authorizer.dart'; +import '../pairing/pairing_provider.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/app_list.dart'; import '../../shared/widgets/app_list_card.dart'; diff --git a/mobile/lib/features/settings/settings_page/connection_section.dart b/mobile/lib/features/settings/settings_page/connection_section.dart index 631f870abc..9352d1d163 100644 --- a/mobile/lib/features/settings/settings_page/connection_section.dart +++ b/mobile/lib/features/settings/settings_page/connection_section.dart @@ -25,9 +25,25 @@ class _ConnectionSection extends ConsumerWidget { title: 'Send identity to desktop', subtitle: 'Scan a recovery code shown by Buzz Desktop', trailing: const _RowChevron(), - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: identityRecoveryPageBuilder), - ), + onTap: () async { + final authorized = await ref + .read(pairingProvider.notifier) + .authorizeIdentityExport(); + if (!context.mounted) return; + if (!authorized) { + final message = ref.read(pairingProvider).errorMessage; + if (message != null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + return; + } + await Navigator.of(context).push( + MaterialPageRoute(builder: identityRecoveryPageBuilder), + ); + ref.read(pairingProvider.notifier).reset(); + }, ), ], ], diff --git a/mobile/test/features/pairing/pairing_page_test.dart b/mobile/test/features/pairing/pairing_page_test.dart index 7849f94270..7d1e3cf28d 100644 --- a/mobile/test/features/pairing/pairing_page_test.dart +++ b/mobile/test/features/pairing/pairing_page_test.dart @@ -335,6 +335,9 @@ class _ErrorPairingNotifier extends Notifier PairingState build() => PairingState(status: PairingStatus.error, errorMessage: error); + @override + Future authorizeIdentityExport() async => true; + @override Future pair(String rawInput) async {} @@ -356,6 +359,9 @@ class _ConnectingPairingNotifier extends Notifier @override PairingState build() => const PairingState(status: PairingStatus.connecting); + @override + Future authorizeIdentityExport() async => true; + @override Future pair(String rawInput) async {} @@ -379,6 +385,9 @@ class _RecordingPairingNotifier extends Notifier @override PairingState build() => const PairingState(); + @override + Future authorizeIdentityExport() async => true; + @override Future pair(String rawInput) async => pairedCodes.add(rawInput); @@ -408,6 +417,9 @@ class _ConfirmingSasPairingNotifier extends Notifier sendsIdentityToDesktop: sendsIdentityToDesktop, ); + @override + Future authorizeIdentityExport() async => true; + @override Future pair(String rawInput) async {} diff --git a/mobile/test/features/pairing/pairing_provider_test.dart b/mobile/test/features/pairing/pairing_provider_test.dart index d11c46af3e..a6c89ead06 100644 --- a/mobile/test/features/pairing/pairing_provider_test.dart +++ b/mobile/test/features/pairing/pairing_provider_test.dart @@ -233,7 +233,10 @@ void main() { await container.read(authProvider.future); }); - test('recovery URI enables phone-to-desktop transfer', () async { + test('recovery authorization happens before pairing starts', () async { + expect(await notifier.authorizeIdentityExport(), isTrue); + expect(authorizer.calls, 1); + await notifier.pair(recoveryCode); final state = container.read(pairingProvider); @@ -245,6 +248,7 @@ void main() { test( 'matching SAS sends nsec and successful completion finishes', () async { + expect(await notifier.authorizeIdentityExport(), isTrue); await notifier.pair(recoveryCode); notifier.confirmSas(); expect(container.read(pairingProvider).userConfirmedSas, isTrue); @@ -262,6 +266,7 @@ void main() { container.read(pairingProvider).status, PairingStatus.transferring, ); + expect(authorizer.calls, 1); final sentMessages = socket.decryptedPublishedMessages(sourceSecret); expect( sentMessages.any( @@ -282,38 +287,21 @@ 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); + test('cancelled authorization prevents pairing from starting', () async { + authorizer.result = DeviceAuthResult.cancelled; - 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'), - ); - }, - ); + expect(await notifier.authorizeIdentityExport(), isFalse); + + expect(authorizer.calls, 1); + expect(container.read(pairingProvider).status, PairingStatus.idle); + expect( + container.read(pairingProvider).errorMessage, + contains('cancelled'), + ); + }); test('desktop storage failure surfaces an error', () async { + expect(await notifier.authorizeIdentityExport(), isTrue); await notifier.pair(recoveryCode); notifier.confirmSas(); socket.sendSourceMessage( From 19eb730fa47edaaa333e55e1446936a46a01dbdd Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 5 Aug 2026 23:11:13 -0700 Subject: [PATCH 6/8] feat(mobile): simplify community removal Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../settings_page/connection_section.dart | 11 ++- .../settings/theme_picker_page_test.dart | 83 +++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/mobile/lib/features/settings/settings_page/connection_section.dart b/mobile/lib/features/settings/settings_page/connection_section.dart index 9352d1d163..2fcc355ded 100644 --- a/mobile/lib/features/settings/settings_page/connection_section.dart +++ b/mobile/lib/features/settings/settings_page/connection_section.dart @@ -102,13 +102,18 @@ class _IdentityRow extends StatelessWidget { } void _confirmRemoveCommunity(BuildContext context, WidgetRef ref) { + final communityName = ref.read(authProvider).value?.community?.name; + final title = communityName == null || communityName.trim().isEmpty + ? 'Remove community from this phone?' + : 'Remove “$communityName” from this phone?'; + showBuzzDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('Remove Community'), + title: Text(title), content: const Text( - 'This will disconnect this community. You will need ' - 'to scan a new pairing code to reconnect.', + 'You’ll be signed out of this community on this phone. ' + 'To come back, you’ll need to add it again from another signed-in device.', ), actions: [ TextButton( diff --git a/mobile/test/features/settings/theme_picker_page_test.dart b/mobile/test/features/settings/theme_picker_page_test.dart index 6b166c8efa..6ac1c1f84a 100644 --- a/mobile/test/features/settings/theme_picker_page_test.dart +++ b/mobile/test/features/settings/theme_picker_page_test.dart @@ -4,6 +4,8 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:buzz/features/settings/accent_picker_page.dart'; import 'package:buzz/features/settings/theme_picker_page.dart'; import 'package:buzz/features/settings/settings_page.dart'; +import 'package:buzz/shared/auth/auth.dart'; +import 'package:buzz/shared/security/sensitive_action_authorizer.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -168,6 +170,53 @@ void main() { }); }); + group('SettingsPage', () { + testWidgets('removes a protected community without device authentication', ( + tester, + ) async { + final auth = _ProtectedAuthNotifier(); + final authorizer = _RecordingAuthorizer(); + final instance = await _prefs(const {}); + await tester.pumpWidget( + WidgetHelpers.testable( + child: SettingsPage( + profileHeader: const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), + overrides: [ + savedPrefsProvider.overrideWithValue(instance), + authProvider.overrideWith(() => auth), + sensitiveActionAuthorizerProvider.overrideWithValue(authorizer), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.scrollUntilVisible( + find.text('Remove community'), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(find.text('Remove community')); + await tester.pumpAndSettle(); + + expect(find.text('Remove “Dungeon” from this phone?'), findsOneWidget); + expect( + find.text( + 'You’ll be signed out of this community on this phone. ' + 'To come back, you’ll need to add it again from another signed-in device.', + ), + findsOneWidget, + ); + + await tester.tap(find.widgetWithText(FilledButton, 'Remove')); + await tester.pumpAndSettle(); + + expect(auth.signOutCalls, 1); + expect(authorizer.authorizationCalls, 0); + }); + }); + group('Buzz accent behavior', () { testWidgets('settings hides accent navigation for Buzz', (tester) async { await _pumpPicker( @@ -236,3 +285,37 @@ void main() { }); }); } + +class _ProtectedAuthNotifier extends AuthNotifier { + int signOutCalls = 0; + + @override + Future build() async => AuthState( + status: AuthStatus.authenticated, + community: Community( + id: 'dungeon', + name: 'Dungeon', + relayUrl: 'https://dungeon.example', + sensitiveActionPolicy: SensitiveActionPolicy.enabled, + addedAt: DateTime(2026), + ), + ); + + @override + Future signOut() async { + signOutCalls++; + } +} + +class _RecordingAuthorizer implements SensitiveActionAuthorizer { + int authorizationCalls = 0; + + @override + Future authorizeIdentityAction() async { + authorizationCalls++; + return DeviceAuthResult.success; + } + + @override + Future isSupported() async => true; +} From 8fb29f55a2081570ada16cf77f3177dcaca6d9c7 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 6 Aug 2026 15:45:01 -0700 Subject: [PATCH 7/8] fix(mobile): harden protected identity access Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../xyz/block/buzz/mobile/MainActivity.kt | 22 +++ mobile/lib/app.dart | 161 ++++++++++++++---- .../invites/invite_join_provider.dart | 23 +++ .../features/pairing/pairing_provider.dart | 40 +++-- .../security/sensitive_action_authorizer.dart | 20 ++- .../invites/invite_join_provider_test.dart | 29 ++++ .../pairing/pairing_provider_test.dart | 26 +++ .../shared/security/app_lock_gate_test.dart | 127 +++++++++++++- 8 files changed, 391 insertions(+), 57 deletions(-) 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 f199f79823..afc9375310 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 @@ -9,6 +9,7 @@ import android.media.MediaExtractor import android.media.MediaMetadataRetriever import android.media.MediaMuxer import android.os.Build +import android.view.WindowManager import androidx.annotation.RequiresApi import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.engine.FlutterEngine @@ -79,10 +80,29 @@ internal object AndroidImageProcessor { class MainActivity : FlutterFragmentActivity() { private var mediaUploadChannel: MethodChannel? = null + private var appPrivacyChannel: MethodChannel? = null override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) + appPrivacyChannel = MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + APP_PRIVACY_CHANNEL, + ).also { channel -> + channel.setMethodCallHandler { call, result -> + if (call.method != SET_SECURE_METHOD) { + result.notImplemented() + return@setMethodCallHandler + } + if (call.arguments == true) { + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + result.success(null) + } + } + mediaUploadChannel = MethodChannel( flutterEngine.dartExecutor.binaryMessenger, MEDIA_UPLOAD_CHANNEL, @@ -336,6 +356,8 @@ class MainActivity : FlutterFragmentActivity() { } companion object { + private const val APP_PRIVACY_CHANNEL = "xyz.block.buzz/app_privacy" + private const val SET_SECURE_METHOD = "setSecure" private const val MEDIA_UPLOAD_CHANNEL = "buzz/media_upload" private const val SANITIZE_IMAGE_FOR_UPLOAD_METHOD = "sanitizeImageForUpload" private const val TRANSCODE_IMAGE_TO_JPEG_METHOD = "transcodeImageToJpeg" diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 4b3be414ac..f329855080 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -1,5 +1,7 @@ import 'package:app_badge_plus/app_badge_plus.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -133,13 +135,17 @@ class App extends HookConsumerWidget { error: (_, _) => const PairingPage(), data: (state) => switch (state.status) { AuthStatus.authenticated => AppLockGate( + communityId: state.community?.id, enabled: state.community?.sensitiveActionPolicy == SensitiveActionPolicy.enabled, - child: DeepLinkDispatcher( - child: HomePage( - settingsPageBuilder: _buildSettingsPage, - hasUnreadInbox: hasUnreadInbox, + child: KeyedSubtree( + key: ValueKey(state.community?.id), + child: DeepLinkDispatcher( + child: HomePage( + settingsPageBuilder: _buildSettingsPage, + hasUnreadInbox: hasUnreadInbox, + ), ), ), ), @@ -173,12 +179,19 @@ class _SplashScreen extends StatelessWidget { } const appLockTimeout = Duration(minutes: 5); +const _appPrivacyChannel = MethodChannel('xyz.block.buzz/app_privacy'); class AppLockGate extends ConsumerStatefulWidget { - const AppLockGate({super.key, required this.enabled, required this.child}); + const AppLockGate({ + super.key, + required this.enabled, + required this.child, + this.communityId, + }); final bool enabled; final Widget child; + final String? communityId; @override ConsumerState createState() => _AppLockGateState(); @@ -194,6 +207,7 @@ class _AppLockGateState extends ConsumerState void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + _setAndroidRecentsProtection(widget.enabled); if (!widget.enabled) _locked = false; WidgetsBinding.instance.addPostFrameCallback((_) => _unlockIfNeeded()); } @@ -201,17 +215,27 @@ class _AppLockGateState extends ConsumerState @override void didUpdateWidget(AppLockGate oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.enabled != widget.enabled) { + _setAndroidRecentsProtection(widget.enabled); + } if (!widget.enabled) { setState(() => _locked = false); - } else if (!oldWidget.enabled) { + } else if (!oldWidget.enabled || + oldWidget.communityId != widget.communityId) { setState(() => _locked = true); - WidgetsBinding.instance.addPostFrameCallback((_) => _unlockIfNeeded()); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _unlockIfNeeded(forceFresh: true), + ); } } @override void didChangeAppLifecycleState(AppLifecycleState state) { - if (!widget.enabled || _authenticating) return; + if (!widget.enabled || + _authenticating || + ref.read(sensitiveActionAuthorizationSessionProvider).isAuthorizing) { + return; + } switch (state) { case AppLifecycleState.inactive: case AppLifecycleState.paused: @@ -248,15 +272,60 @@ class _AppLockGateState extends ConsumerState }); } + Future _setAndroidRecentsProtection(bool enabled) async { + if (defaultTargetPlatform != TargetPlatform.android) return; + try { + await _appPrivacyChannel.invokeMethod('setSecure', enabled); + } on MissingPluginException { + // Widget tests and unsupported embedders do not register this channel. + } + } + @override void dispose() { + _setAndroidRecentsProtection(false); WidgetsBinding.instance.removeObserver(this); super.dispose(); } + Future _leaveCommunity() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Leave community?'), + content: const Text( + 'This removes the community and its identity from this phone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Leave community'), + ), + ], + ), + ); + if (confirmed == true && mounted) { + await ref.read(authProvider.notifier).signOut(); + } + } + @override Widget build(BuildContext context) { - if (!widget.enabled || !_locked) return widget.child; + final locked = widget.enabled && _locked; + return Stack( + fit: StackFit.expand, + children: [ + Offstage(offstage: locked, child: widget.child), + if (locked) _buildLockScreen(context), + ], + ); + } + + Widget _buildLockScreen(BuildContext context) { final authenticationName = sensitiveActionAuthenticationName( Theme.of(context).platform, ); @@ -264,37 +333,57 @@ class _AppLockGateState extends ConsumerState key: const Key('app-lock-screen'), backgroundColor: Colors.black, body: SafeArea( - child: Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Image.asset( - 'assets/images/buzz-icon.png', - key: const Key('app-lock-logo'), - width: 112, - semanticLabel: 'Buzz', + child: Stack( + fit: StackFit.expand, + children: [ + Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + 'assets/images/buzz-icon.png', + key: const Key('app-lock-logo'), + width: 112, + semanticLabel: 'Buzz', + ), + const SizedBox(height: 32), + FilledButton( + key: const Key('app-lock-unlock-button'), + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + disabledBackgroundColor: Colors.white54, + disabledForegroundColor: Colors.black54, + ), + onPressed: _authenticating ? null : _unlockIfNeeded, + child: Text( + _authenticating + ? 'Authenticating…' + : 'Unlock with $authenticationName', + ), + ), + ], ), - const SizedBox(height: 32), - FilledButton( - key: const Key('app-lock-unlock-button'), - style: FilledButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, - disabledBackgroundColor: Colors.white54, - disabledForegroundColor: Colors.black54, - ), - onPressed: _authenticating ? null : _unlockIfNeeded, - child: Text( - _authenticating - ? 'Authenticating…' - : 'Unlock with $authenticationName', + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.only(bottom: 12), + child: TextButton( + key: const Key('app-lock-leave-community'), + style: TextButton.styleFrom( + foregroundColor: Colors.white54, + textStyle: Theme.of(context).textTheme.bodySmall, ), + onPressed: _leaveCommunity, + child: const Text('Leave community'), ), - ], + ), ), - ), + ], ), ), ); diff --git a/mobile/lib/features/invites/invite_join_provider.dart b/mobile/lib/features/invites/invite_join_provider.dart index b1d2680c93..52f088c3b5 100644 --- a/mobile/lib/features/invites/invite_join_provider.dart +++ b/mobile/lib/features/invites/invite_join_provider.dart @@ -8,6 +8,7 @@ import '../../shared/auth/auth.dart'; import '../../shared/deeplink/deep_link.dart'; import '../../shared/relay/relay_session.dart'; import '../../shared/relay/relay_validation.dart'; +import '../../shared/security/sensitive_action_authorizer.dart'; final inviteJoinHttpClientProvider = Provider((ref) { final client = http.Client(); @@ -117,6 +118,17 @@ class InviteJoinNotifier extends Notifier { return; } + final authorization = await ref + .read(sensitiveActionAuthorizationSessionProvider) + .authorize(); + if (authorization != DeviceAuthResult.success) { + state = state.copyWith( + status: InviteJoinStatus.error, + errorMessage: _authorizationError(authorization), + ); + return; + } + final keys = ref.read(inviteKeyGeneratorProvider)(); final body = jsonEncode({ 'code': invite.code, @@ -159,6 +171,7 @@ class InviteJoinNotifier extends Notifier { relayUrl: invite.relayUrl, pubkey: keys.public, nsec: keys.nsec, + sensitiveActionPolicy: SensitiveActionPolicy.enabled, ); await ref .read(authProvider.notifier) @@ -266,6 +279,16 @@ bool _requiresFreshInvite(Object error) { message.contains('invite_exhausted'); } +String _authorizationError(DeviceAuthResult result) => switch (result) { + DeviceAuthResult.cancelled => 'Device authentication was cancelled.', + DeviceAuthResult.unavailable => + 'Set up a device passcode or biometrics before joining.', + DeviceAuthResult.lockedOut => + 'Device authentication is locked. Unlock it in system settings and try again.', + DeviceAuthResult.failed => 'Device authentication failed. Try again.', + DeviceAuthResult.success => '', +}; + String _friendlyInviteError(Object error) { final message = error.toString(); if (message.contains('invite_expired')) return 'This invite has expired.'; diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index 51ce31f734..2fe9f0264e 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -82,6 +82,8 @@ typedef PairingSocketFactory = required void Function(Object? error) onDisconnected, }); +const identityExportAuthorizationTtl = Duration(minutes: 2); + class PairingNotifier extends Notifier { final PairingSocketFactory _socketFactory; PairingSocket? _socket; @@ -123,17 +125,6 @@ class PairingNotifier extends Notifier { Future authorizeIdentityExport() async { if (state.authorizationInProgress) return false; - final activePolicy = (await ref.read( - authProvider.future, - )).community?.sensitiveActionPolicy; - if (activePolicy != SensitiveActionPolicy.enabled) { - state = state.copyWith( - errorMessage: - 'Turn on biometric protection before sending your identity.', - ); - return false; - } - state = state.copyWith( authorizationInProgress: true, clearErrorMessage: true, @@ -149,7 +140,7 @@ class PairingNotifier extends Notifier { return false; } - _identityExportAuthorized = true; + _identityExportAuthorizedAt = ref.read(appLockClockProvider)(); state = state.copyWith(authorizationInProgress: false); return true; } @@ -182,8 +173,13 @@ class PairingNotifier extends Notifier { return; } + final authorizedAt = _identityExportAuthorizedAt; + final hasFreshExportAuthorization = + authorizedAt != null && + ref.read(appLockClockProvider)().difference(authorizedAt) < + identityExportAuthorizationTtl; final requiresAuthorization = _sendIdentityToSource - ? !_identityExportAuthorized + ? !hasFreshExportAuthorization : state.protectImportedIdentity; if (requiresAuthorization) { @@ -258,7 +254,7 @@ class PairingNotifier extends Notifier { _userConfirmedSas = false; _pendingPayload = null; _sendIdentityToSource = false; - _identityExportAuthorized = false; + _identityExportAuthorizedAt = null; } // ── NIP-AB pairing flow ───────────────────────────────────────────────── @@ -274,7 +270,7 @@ class PairingNotifier extends Notifier { bool _sasConfirmReceived = false; bool _userConfirmedSas = false; bool _sendIdentityToSource = false; - bool _identityExportAuthorized = false; + DateTime? _identityExportAuthorizedAt; Map? _pendingPayload; // buffered until user confirms SAS final Set _processedEventIds = {}; // NIP-AB §Duplicate Event Handling @@ -721,6 +717,19 @@ class PairingNotifier extends Notifier { try { final community = _parseLegacyInput(rawInput); + if (community.nsec == null || community.nsec!.isEmpty) { + throw const FormatException('Pairing payload missing nsec'); + } + final authorization = await ref + .read(sensitiveActionAuthorizationSessionProvider) + .authorize(); + if (authorization != DeviceAuthResult.success) { + state = PairingState( + status: PairingStatus.error, + errorMessage: _authorizationError(authorization), + ); + return; + } await _validateCredentials( relayUrl: community.relayUrl, @@ -804,6 +813,7 @@ class PairingNotifier extends Notifier { relayUrl: relayUrl, pubkey: decoded['pubkey'] as String?, nsec: decoded['nsec'] as String?, + sensitiveActionPolicy: SensitiveActionPolicy.enabled, ); } diff --git a/mobile/lib/shared/security/sensitive_action_authorizer.dart b/mobile/lib/shared/security/sensitive_action_authorizer.dart index c128572c14..2eed26ef05 100644 --- a/mobile/lib/shared/security/sensitive_action_authorizer.dart +++ b/mobile/lib/shared/security/sensitive_action_authorizer.dart @@ -90,11 +90,25 @@ class SensitiveActionAuthorizationSession { final DateTime Function() _now; DateTime? lastSuccessfulAt; + Future? _authorizationInFlight; + + bool get isAuthorizing => _authorizationInFlight != null; Future authorize() async { - final result = await _authorizer.authorizeIdentityAction(); - if (result == DeviceAuthResult.success) lastSuccessfulAt = _now(); - return result; + final inFlight = _authorizationInFlight; + if (inFlight != null) return inFlight; + + final authorization = _authorizer.authorizeIdentityAction(); + _authorizationInFlight = authorization; + try { + final result = await authorization; + if (result == DeviceAuthResult.success) lastSuccessfulAt = _now(); + return result; + } finally { + if (identical(_authorizationInFlight, authorization)) { + _authorizationInFlight = null; + } + } } bool wasAuthorizedWithin(Duration duration) { diff --git a/mobile/test/features/invites/invite_join_provider_test.dart b/mobile/test/features/invites/invite_join_provider_test.dart index d3a2532388..4b8dacd27e 100644 --- a/mobile/test/features/invites/invite_join_provider_test.dart +++ b/mobile/test/features/invites/invite_join_provider_test.dart @@ -10,6 +10,7 @@ import 'package:pointycastle/digests/sha256.dart'; import 'package:buzz/features/invites/invite_join_provider.dart'; import 'package:buzz/shared/auth/auth.dart'; import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/shared/security/sensitive_action_authorizer.dart'; import '../../shared/community/community_storage_test.dart'; @@ -37,6 +38,9 @@ void main() { final container = ProviderContainer( overrides: [ communityStorageProvider.overrideWithValue(storage), + sensitiveActionAuthorizerProvider.overrideWithValue( + _SuccessfulAuthorizer(), + ), authProvider.overrideWith(() => auth), inviteKeyGeneratorProvider.overrideWithValue(() { generatedKeys++; @@ -86,6 +90,9 @@ void main() { final container = ProviderContainer( overrides: [ communityStorageProvider.overrideWithValue(storage), + sensitiveActionAuthorizerProvider.overrideWithValue( + _SuccessfulAuthorizer(), + ), authProvider.overrideWith(() => auth), inviteKeyGeneratorProvider.overrideWithValue(() => keys), inviteJoinHttpClientProvider.overrideWithValue( @@ -156,6 +163,10 @@ void main() { ); expect(auth.authenticatedCommunities.single.pubkey, keys.public); expect(auth.authenticatedCommunities.single.nsec, keys.nsec); + expect( + auth.authenticatedCommunities.single.sensitiveActionPolicy, + SensitiveActionPolicy.enabled, + ); }, ); @@ -181,6 +192,9 @@ void main() { final container = ProviderContainer( overrides: [ communityStorageProvider.overrideWithValue(storage), + sensitiveActionAuthorizerProvider.overrideWithValue( + _SuccessfulAuthorizer(), + ), inviteKeyGeneratorProvider.overrideWithValue(() => keys), inviteJoinHttpClientProvider.overrideWithValue( http_testing.MockClient((request) async { @@ -225,6 +239,9 @@ void main() { final container = ProviderContainer( overrides: [ communityStorageProvider.overrideWithValue(storage), + sensitiveActionAuthorizerProvider.overrideWithValue( + _SuccessfulAuthorizer(), + ), inviteKeyGeneratorProvider.overrideWithValue(() => keys), inviteJoinHttpClientProvider.overrideWithValue( http_testing.MockClient((request) async { @@ -270,6 +287,9 @@ void main() { final container = ProviderContainer( overrides: [ communityStorageProvider.overrideWithValue(storage), + sensitiveActionAuthorizerProvider.overrideWithValue( + _SuccessfulAuthorizer(), + ), authProvider.overrideWith(() => auth), inviteKeyGeneratorProvider.overrideWithValue(() => keys), inviteJoinHttpClientProvider.overrideWithValue( @@ -319,6 +339,15 @@ void main() { }); } +class _SuccessfulAuthorizer implements SensitiveActionAuthorizer { + @override + Future authorizeIdentityAction() async => + DeviceAuthResult.success; + + @override + Future isSupported() async => true; +} + class _RecordingAuthNotifier extends AuthNotifier { final List authenticatedCommunities = []; diff --git a/mobile/test/features/pairing/pairing_provider_test.dart b/mobile/test/features/pairing/pairing_provider_test.dart index a6c89ead06..7854f9f1e7 100644 --- a/mobile/test/features/pairing/pairing_provider_test.dart +++ b/mobile/test/features/pairing/pairing_provider_test.dart @@ -196,6 +196,7 @@ void main() { late PairingNotifier notifier; late String recoveryCode; late _FakeSensitiveActionAuthorizer authorizer; + late DateTime now; setUp(() async { final source = nostr.Keys(sourceSecret); @@ -204,6 +205,7 @@ void main() { '?secret=$sessionSecretHex' '&relay=wss%3A%2F%2Fpairing.buzz.xyz&v=1&mode=recover'; authorizer = _FakeSensitiveActionAuthorizer(); + now = DateTime.utc(2026, 8, 6); notifier = PairingNotifier( socketFactory: ({ @@ -226,6 +228,7 @@ void main() { relayConfigProvider.overrideWith(_RecoveryRelayConfig.new), authProvider.overrideWith(_ProtectedRecoveryAuthNotifier.new), sensitiveActionAuthorizerProvider.overrideWithValue(authorizer), + appLockClockProvider.overrideWithValue(() => now), ], ); container.read(pairingProvider); @@ -287,6 +290,29 @@ void main() { }, ); + test( + 'expired recovery authorization reauthenticates before transfer', + () async { + expect(await notifier.authorizeIdentityExport(), isTrue); + await notifier.pair(recoveryCode); + now = now.add(identityExportAuthorizationTtl); + notifier.confirmSas(); + socket.sendSourceMessage( + sourceSecret: sourceSecret, + sessionSecretHex: sessionSecretHex, + message: {'type': 'sas-confirm'}, + includeTranscriptHash: true, + ); + await Future.delayed(Duration.zero); + + expect(authorizer.calls, 2); + expect( + container.read(pairingProvider).status, + PairingStatus.transferring, + ); + }, + ); + test('cancelled authorization prevents pairing from starting', () async { authorizer.result = DeviceAuthResult.cancelled; diff --git a/mobile/test/shared/security/app_lock_gate_test.dart b/mobile/test/shared/security/app_lock_gate_test.dart index defa2b4efa..b47b65c115 100644 --- a/mobile/test/shared/security/app_lock_gate_test.dart +++ b/mobile/test/shared/security/app_lock_gate_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:buzz/app.dart'; import 'package:buzz/shared/security/sensitive_action_authorizer.dart'; import 'package:flutter/material.dart'; @@ -5,6 +7,25 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; void main() { + test('authorization session shares an in-flight device prompt', () async { + final pending = Completer(); + final authorizer = _FakeAuthorizer(pending: pending); + final session = SensitiveActionAuthorizationSession( + authorizer: authorizer, + now: () => DateTime.utc(2026, 8, 6), + ); + + final first = session.authorize(); + final second = session.authorize(); + expect(authorizer.calls, 1); + expect(session.isAuthorizing, isTrue); + + pending.complete(DeviceAuthResult.success); + expect(await first, DeviceAuthResult.success); + expect(await second, DeviceAuthResult.success); + expect(session.isAuthorizing, isFalse); + }); + testWidgets('protected cold launch authenticates before showing content', ( tester, ) async { @@ -80,6 +101,78 @@ void main() { expect(find.text('Private content'), findsOneWidget); }); + testWidgets('locking preserves authenticated child state', (tester) async { + final authorizer = _FakeAuthorizer(); + await tester.pumpWidget( + _testApp(authorizer: authorizer, child: const _StatefulChild()), + ); + await tester.pump(); + await tester.tap(find.text('Increment')); + await tester.pump(); + expect(find.text('Count: 1'), findsOneWidget); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + await tester.pump(); + expect(find.byKey(const Key('app-lock-screen')), findsOneWidget); + expect(find.text('Count: 1', skipOffstage: false), findsOneWidget); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + expect(find.text('Count: 1'), findsOneWidget); + }); + + testWidgets('protected community change requires fresh authentication', ( + tester, + ) async { + final authorizer = _FakeAuthorizer(); + await tester.pumpWidget( + _testApp(authorizer: authorizer, communityId: 'community-a'), + ); + await tester.pump(); + + await tester.pumpWidget( + _testApp(authorizer: authorizer, communityId: 'community-b'), + ); + await tester.pump(); + + expect(authorizer.calls, 2); + }); + + testWidgets('protected-action authorization suppresses lifecycle prompt', ( + tester, + ) async { + final pending = Completer(); + final authorizer = _FakeAuthorizer(pending: pending); + await tester.pumpWidget(_testApp(authorizer: authorizer)); + await tester.pump(); + expect(authorizer.calls, 1); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + expect(authorizer.calls, 1); + + pending.complete(DeviceAuthResult.success); + await tester.pump(); + }); + + testWidgets('lock screen offers subtle leave community action', ( + tester, + ) async { + final authorizer = _FakeAuthorizer(result: DeviceAuthResult.unavailable); + await tester.pumpWidget(_testApp(authorizer: authorizer)); + await tester.pump(); + + expect(find.text('Leave community'), findsOneWidget); + final button = tester.widget( + find.byKey(const Key('app-lock-leave-community')), + ); + expect( + button.style?.foregroundColor?.resolve({}), + Colors.white54, + ); + }); + testWidgets('disabled protection never authenticates', (tester) async { final authorizer = _FakeAuthorizer(); await tester.pumpWidget(_testApp(authorizer: authorizer, enabled: false)); @@ -94,6 +187,8 @@ Widget _testApp({ required _FakeAuthorizer authorizer, DateTime Function()? now, bool enabled = true, + String? communityId, + Widget child = const Scaffold(body: Text('Private content')), }) { return ProviderScope( overrides: [ @@ -104,22 +199,48 @@ Widget _testApp({ theme: ThemeData(platform: TargetPlatform.iOS), home: AppLockGate( enabled: enabled, - child: const Scaffold(body: Text('Private content')), + communityId: communityId, + child: child, ), ), ); } +class _StatefulChild extends StatefulWidget { + const _StatefulChild(); + + @override + State<_StatefulChild> createState() => _StatefulChildState(); +} + +class _StatefulChildState extends State<_StatefulChild> { + var count = 0; + + @override + Widget build(BuildContext context) => Scaffold( + body: Column( + children: [ + Text('Count: $count'), + TextButton( + onPressed: () => setState(() => count++), + child: const Text('Increment'), + ), + ], + ), + ); +} + class _FakeAuthorizer implements SensitiveActionAuthorizer { - _FakeAuthorizer({this.result = DeviceAuthResult.success}); + _FakeAuthorizer({this.result = DeviceAuthResult.success, this.pending}); DeviceAuthResult result; + final Completer? pending; int calls = 0; @override Future authorizeIdentityAction() async { calls++; - return result; + return pending?.future ?? result; } @override From 7129cfe48c3064005c7985c3b283f864b3a00063 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 6 Aug 2026 16:23:56 -0700 Subject: [PATCH 8/8] fix(mobile): preserve screenshots with protected recents Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../kotlin/xyz/block/buzz/mobile/MainActivity.kt | 13 ++++++------- mobile/lib/app.dart | 5 ++++- 2 files changed, 10 insertions(+), 8 deletions(-) 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 afc9375310..5905100a7c 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 @@ -9,7 +9,6 @@ import android.media.MediaExtractor import android.media.MediaMetadataRetriever import android.media.MediaMuxer import android.os.Build -import android.view.WindowManager import androidx.annotation.RequiresApi import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.engine.FlutterEngine @@ -90,14 +89,14 @@ class MainActivity : FlutterFragmentActivity() { APP_PRIVACY_CHANNEL, ).also { channel -> channel.setMethodCallHandler { call, result -> - if (call.method != SET_SECURE_METHOD) { + if (call.method != SET_RECENTS_PROTECTION_METHOD) { result.notImplemented() return@setMethodCallHandler } - if (call.arguments == true) { - window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // Keep normal screenshots and screen recording available; + // only prevent Android from snapshotting Buzz for Recents. + setRecentsScreenshotEnabled(call.arguments != true) } result.success(null) } @@ -357,7 +356,7 @@ class MainActivity : FlutterFragmentActivity() { companion object { private const val APP_PRIVACY_CHANNEL = "xyz.block.buzz/app_privacy" - private const val SET_SECURE_METHOD = "setSecure" + private const val SET_RECENTS_PROTECTION_METHOD = "setRecentsProtection" private const val MEDIA_UPLOAD_CHANNEL = "buzz/media_upload" private const val SANITIZE_IMAGE_FOR_UPLOAD_METHOD = "sanitizeImageForUpload" private const val TRANSCODE_IMAGE_TO_JPEG_METHOD = "transcodeImageToJpeg" diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index f329855080..13ac503917 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -275,7 +275,10 @@ class _AppLockGateState extends ConsumerState Future _setAndroidRecentsProtection(bool enabled) async { if (defaultTargetPlatform != TargetPlatform.android) return; try { - await _appPrivacyChannel.invokeMethod('setSecure', enabled); + await _appPrivacyChannel.invokeMethod( + 'setRecentsProtection', + enabled, + ); } on MissingPluginException { // Widget tests and unsupported embedders do not register this channel. }