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..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
@@ -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,12 +77,31 @@ internal object AndroidImageProcessor {
}
}
-class MainActivity : FlutterActivity() {
+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_RECENTS_PROTECTION_METHOD) {
+ result.notImplemented()
+ return@setMethodCallHandler
+ }
+ 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)
+ }
+ }
+
mediaUploadChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
MEDIA_UPLOAD_CHANNEL,
@@ -336,6 +355,8 @@ class MainActivity : FlutterActivity() {
}
companion object {
+ private const val APP_PRIVACY_CHANNEL = "xyz.block.buzz/app_privacy"
+ 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/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/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/app.dart b/mobile/lib/app.dart
index d5ae326afa..13ac503917 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';
@@ -20,6 +22,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 +134,19 @@ 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(
+ communityId: state.community?.id,
+ enabled:
+ state.community?.sensitiveActionPolicy ==
+ SensitiveActionPolicy.enabled,
+ child: KeyedSubtree(
+ key: ValueKey(state.community?.id),
+ child: DeepLinkDispatcher(
+ child: HomePage(
+ settingsPageBuilder: _buildSettingsPage,
+ hasUnreadInbox: hasUnreadInbox,
+ ),
+ ),
),
),
_ => const DeepLinkDispatcher(
@@ -165,3 +177,218 @@ 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,
+ this.communityId,
+ });
+
+ final bool enabled;
+ final Widget child;
+ final String? communityId;
+
+ @override
+ ConsumerState createState() => _AppLockGateState();
+}
+
+class _AppLockGateState extends ConsumerState
+ with WidgetsBindingObserver {
+ bool _locked = true;
+ bool _authenticating = false;
+ DateTime? _backgroundedAt;
+
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addObserver(this);
+ _setAndroidRecentsProtection(widget.enabled);
+ if (!widget.enabled) _locked = false;
+ WidgetsBinding.instance.addPostFrameCallback((_) => _unlockIfNeeded());
+ }
+
+ @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 ||
+ oldWidget.communityId != widget.communityId) {
+ setState(() => _locked = true);
+ WidgetsBinding.instance.addPostFrameCallback(
+ (_) => _unlockIfNeeded(forceFresh: true),
+ );
+ }
+ }
+
+ @override
+ void didChangeAppLifecycleState(AppLifecycleState state) {
+ if (!widget.enabled ||
+ _authenticating ||
+ ref.read(sensitiveActionAuthorizationSessionProvider).isAuthorizing) {
+ 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);
+ return;
+ }
+
+ setState(() => _authenticating = true);
+ final result = await session.authorize();
+ if (!mounted || !widget.enabled) return;
+ setState(() {
+ _authenticating = false;
+ _locked = result != DeviceAuthResult.success;
+ });
+ }
+
+ Future _setAndroidRecentsProtection(bool enabled) async {
+ if (defaultTargetPlatform != TargetPlatform.android) return;
+ try {
+ await _appPrivacyChannel.invokeMethod(
+ 'setRecentsProtection',
+ 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) {
+ 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,
+ );
+ return Scaffold(
+ key: const Key('app-lock-screen'),
+ backgroundColor: Colors.black,
+ body: SafeArea(
+ 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',
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ 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_page.dart b/mobile/lib/features/pairing/pairing_page.dart
index 7061180b12..3ecedaea61 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';
@@ -127,6 +128,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 +203,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,12 +213,18 @@ 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,
});
@override
Widget build(BuildContext context) {
+ final authenticationName = sensitiveActionAuthenticationName(
+ Theme.of(context).platform,
+ );
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@@ -266,6 +282,34 @@ 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: Text('Use $authenticationName'),
+ subtitle: Text(
+ 'Require $authenticationName to open Buzz and approve protected 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..2fe9f0264e 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,13 +57,20 @@ class PairingState {
String? sasCode,
bool? userConfirmedSas,
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:
sendsIdentityToDesktop ?? this.sendsIdentityToDesktop,
+ protectImportedIdentity:
+ protectImportedIdentity ?? this.protectImportedIdentity,
+ authorizationInProgress:
+ authorizationInProgress ?? this.authorizationInProgress,
);
}
@@ -70,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;
@@ -108,32 +122,113 @@ class PairingNotifier extends Notifier {
return _pairLegacy(trimmed);
}
+ Future authorizeIdentityExport() async {
+ if (state.authorizationInProgress) 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;
+ }
+
+ _identityExportAuthorizedAt = ref.read(appLockClockProvider)();
+ 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) 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 authorizedAt = _identityExportAuthorizedAt;
+ final hasFreshExportAuthorization =
+ authorizedAt != null &&
+ ref.read(appLockClockProvider)().difference(authorizedAt) <
+ identityExportAuthorizationTtl;
+ final requiresAuthorization = _sendIdentityToSource
+ ? !hasFreshExportAuthorization
+ : state.protectImportedIdentity;
+
+ if (requiresAuthorization) {
+ state = state.copyWith(authorizationInProgress: true);
+ final result = await ref
+ .read(sensitiveActionAuthorizationSessionProvider)
+ .authorize();
+ 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');
@@ -159,6 +254,7 @@ class PairingNotifier extends Notifier {
_userConfirmedSas = false;
_pendingPayload = null;
_sendIdentityToSource = false;
+ _identityExportAuthorizedAt = null;
}
// ── NIP-AB pairing flow ─────────────────────────────────────────────────
@@ -174,6 +270,7 @@ class PairingNotifier extends Notifier {
bool _sasConfirmReceived = false;
bool _userConfirmedSas = false;
bool _sendIdentityToSource = false;
+ DateTime? _identityExportAuthorizedAt;
Map? _pendingPayload; // buffered until user confirms SAS
final Set _processedEventIds = {}; // NIP-AB §Duplicate Event Handling
@@ -415,17 +512,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 +621,9 @@ class PairingNotifier extends Notifier {
relayUrl: relayUrl,
pubkey: pubkey,
nsec: nsec,
+ sensitiveActionPolicy: state.protectImportedIdentity
+ ? SensitiveActionPolicy.enabled
+ : SensitiveActionPolicy.disabledByUser,
);
await ref
.read(authProvider.notifier)
@@ -627,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,
@@ -710,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/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart
index 066dd1fd3d..1aa11939ce 100644
--- a/mobile/lib/features/settings/settings_page.dart
+++ b/mobile/lib/features/settings/settings_page.dart
@@ -11,6 +11,8 @@ 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 '../pairing/pairing_provider.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/app_list.dart';
import '../../shared/widgets/app_list_card.dart';
@@ -22,6 +24,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 +78,7 @@ class SettingsPage extends HookConsumerWidget {
_ConnectionSection(
identityRecoveryPageBuilder: identityRecoveryPageBuilder,
),
+ const _MobileSecuritySection(),
const _RemoveCommunitySection(),
],
),
diff --git a/mobile/lib/features/settings/settings_page/connection_section.dart b/mobile/lib/features/settings/settings_page/connection_section.dart
index 631f870abc..2fcc355ded 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();
+ },
),
],
],
@@ -86,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/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..f953093823
--- /dev/null
+++ b/mobile/lib/features/settings/settings_page/mobile_security_section.dart
@@ -0,0 +1,71 @@
+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);
+ if (!enabled && capability.value != true) return const SizedBox.shrink();
+ final authenticationName = sensitiveActionAuthenticationName(
+ Theme.of(context).platform,
+ );
+
+ return AppListCard(
+ label: 'Mobile security',
+ children: [
+ SwitchListTile(
+ key: const Key('sensitive-action-confirmation-toggle'),
+ secondary: const Icon(LucideIcons.shieldCheck),
+ title: Text('Use $authenticationName'),
+ subtitle: Text(
+ enabled
+ ? '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),
+ ),
+ ],
+ );
+ }
+
+ 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(sensitiveActionAuthorizationSessionProvider)
+ .authorize();
+ 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..2eed26ef05
--- /dev/null
+++ b/mobile/lib/shared/security/sensitive_action_authorizer.dart
@@ -0,0 +1,126 @@
+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 }
+
+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();
+});
+
+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? _authorizationInFlight;
+
+ bool get isAuthorizing => _authorizationInFlight != null;
+
+ Future authorize() async {
+ 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) {
+ 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/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/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_page_test.dart b/mobile/test/features/pairing/pairing_page_test.dart
index e8f34a6f71..7d1e3cf28d 100644
--- a/mobile/test/features/pairing/pairing_page_test.dart
+++ b/mobile/test/features/pairing/pairing_page_test.dart
@@ -224,6 +224,82 @@ 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'), 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'), findsOneWidget);
+ });
+
+ 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('open Buzz and approve protected actions'),
+ 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 {
@@ -259,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 {}
@@ -268,6 +347,9 @@ class _ErrorPairingNotifier extends Notifier
@override
void confirmSas() {}
+ @override
+ void setProtectImportedIdentity(bool value) {}
+
@override
void denySas() {}
}
@@ -277,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 {}
@@ -286,6 +371,9 @@ class _ConnectingPairingNotifier extends Notifier
@override
void confirmSas() {}
+ @override
+ void setProtectImportedIdentity(bool value) {}
+
@override
void denySas() {}
}
@@ -297,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);
@@ -306,6 +397,9 @@ class _RecordingPairingNotifier extends Notifier
@override
void confirmSas() {}
+ @override
+ void setProtectImportedIdentity(bool value) {}
+
@override
void denySas() {}
}
@@ -323,6 +417,9 @@ class _ConfirmingSasPairingNotifier extends Notifier
sendsIdentityToDesktop: sendsIdentityToDesktop,
);
+ @override
+ Future authorizeIdentityExport() async => true;
+
@override
Future pair(String rawInput) async {}
@@ -332,6 +429,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..7854f9f1e7 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,17 @@ void main() {
late _ControllableSocket socket;
late PairingNotifier notifier;
late String recoveryCode;
+ late _FakeSensitiveActionAuthorizer authorizer;
+ late DateTime now;
- 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();
+ now = DateTime.utc(2026, 8, 6);
notifier = PairingNotifier(
socketFactory:
({
@@ -221,13 +226,20 @@ void main() {
overrides: [
pairingProvider.overrideWith(() => notifier),
relayConfigProvider.overrideWith(_RecoveryRelayConfig.new),
+ authProvider.overrideWith(_ProtectedRecoveryAuthNotifier.new),
+ sensitiveActionAuthorizerProvider.overrideWithValue(authorizer),
+ appLockClockProvider.overrideWithValue(() => now),
],
);
container.read(pairingProvider);
notifier = container.read(pairingProvider.notifier);
+ 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);
@@ -239,6 +251,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);
@@ -250,10 +263,13 @@ void main() {
includeTranscriptHash: true,
);
+ await Future.delayed(Duration.zero);
+
expect(
container.read(pairingProvider).status,
PairingStatus.transferring,
);
+ expect(authorizer.calls, 1);
final sentMessages = socket.decryptedPublishedMessages(sourceSecret);
expect(
sentMessages.any(
@@ -274,7 +290,44 @@ 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;
+
+ 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(
@@ -283,6 +336,7 @@ void main() {
message: {'type': 'sas-confirm'},
includeTranscriptHash: true,
);
+ await Future.delayed(Duration.zero);
socket.sendSourceMessage(
sourceSecret: sourceSecret,
sessionSecretHex: sessionSecretHex,
@@ -323,6 +377,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 +424,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/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;
+}
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,
+ );
+ });
+}
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..b47b65c115
--- /dev/null
+++ b/mobile/test/shared/security/app_lock_gate_test.dart
@@ -0,0 +1,248 @@
+import 'dart:async';
+
+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() {
+ 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 {
+ 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.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 {
+ 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('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));
+ await tester.pump();
+
+ expect(authorizer.calls, 0);
+ expect(find.text('Private content'), findsOneWidget);
+ });
+}
+
+Widget _testApp({
+ required _FakeAuthorizer authorizer,
+ DateTime Function()? now,
+ bool enabled = true,
+ String? communityId,
+ Widget child = const Scaffold(body: Text('Private content')),
+}) {
+ return ProviderScope(
+ overrides: [
+ sensitiveActionAuthorizerProvider.overrideWithValue(authorizer),
+ if (now != null) appLockClockProvider.overrideWithValue(now),
+ ],
+ child: MaterialApp(
+ theme: ThemeData(platform: TargetPlatform.iOS),
+ home: AppLockGate(
+ enabled: enabled,
+ 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, this.pending});
+
+ DeviceAuthResult result;
+ final Completer? pending;
+ int calls = 0;
+
+ @override
+ Future authorizeIdentityAction() async {
+ calls++;
+ return pending?.future ?? result;
+ }
+
+ @override
+ Future isSupported() async => true;
+}