feat(mobile): require device authentication for identity export (#5116)

**Category:** new-feature
**User Impact:** Mobile users must confirm with Face ID, biometrics, or
their device passcode before sending their Buzz identity to Desktop.

**Problem:** A signed-in phone could send its full identity, including
the `nsec`, to a desktop without fresh local verification.

**Solution:** Require OS device authentication before opening the
identity-recovery scanner, retain that authorization only for the active
pairing session and short pairing window, and require fresh
authentication again if it expires before the identity payload is sent.
Normal app opening, identity import, and community removal remain
unchanged.

## Screencasts

| Enable Face ID | Use Face ID |
| --- | --- |
| ![Enabling Face ID during identity
import](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5116/enable-face-id.gif)
| ![Using Face ID for identity
export](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5116/use-face-id.gif)
|

<details>
<summary>File changes</summary>

**Android and iOS integration**
- `mobile/android/app/build.gradle.kts` declares the AppCompat
dependency required by the biometric activity theme.
-
`mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt`
uses the activity type required by the system authentication prompt.
- `mobile/android/app/src/main/res/values/styles.xml` and
`mobile/android/app/src/main/res/values-night/styles.xml` use the
compatible launch theme.
- `mobile/ios/Podfile.lock` records the native local-authentication
dependency.
- `mobile/ios/Runner/Info.plist` explains why Buzz requests Face ID
access.

**Identity policy and pairing flow**
- `mobile/lib/shared/security/sensitive_action_authorizer.dart` wraps OS
authentication and maps platform errors to stable app-level outcomes.
- `mobile/lib/shared/community/community.dart` and
`mobile/lib/shared/community/community_storage.dart` persist the
sensitive-action policy.
- `mobile/lib/features/invites/invite_join_provider.dart` assigns the
explicit policy for invite-created communities.
- `mobile/lib/features/pairing/pairing_provider.dart` gates export,
binds grants to the active community/session, reauthenticates expired
grants, and clears grants on every terminal path.
- `mobile/lib/features/pairing/pairing_page.dart` lets users choose
biometric protection while importing an identity.
- `mobile/lib/features/settings/settings_page.dart` wires pairing into
settings.
- `mobile/lib/features/settings/settings_page/connection_section.dart`
authenticates before opening export recovery and bounds the
foreground-resume wait.
- `mobile/pubspec.yaml` and `mobile/pubspec.lock` add and lock
`local_auth`.

**Coverage**
- `mobile/test/shared/security/sensitive_action_authorizer_test.dart`
covers native result mapping, unsupported devices, and single-flight
behavior.
- `mobile/test/shared/community/community_test.dart` and
`mobile/test/shared/community/community_storage_test.dart` cover policy
defaults and persistence.
- `mobile/test/features/invites/invite_join_provider_test.dart` covers
the invite policy.
- `mobile/test/features/pairing/pairing_page_test.dart` covers import
protection controls.
- `mobile/test/features/pairing/pairing_provider_test.dart` covers
export/import authorization, stale/reset/concurrent guards, malformed
payload cleanup, and no-export failure paths.
- `mobile/test/features/settings/connection_section_test.dart` covers
the tap gate, lifecycle resume, and timeout behavior.

</details>

## Reproduction steps

1. Pair an identity into the mobile app.
2. Open Settings and choose “Send identity to desktop.”
3. Verify Face ID, biometrics, or the device passcode is required before
the recovery scanner opens.
4. Cancel device authentication and verify the scanner does not open and
no identity transfer begins.
5. Authenticate, scan a Desktop recovery code, confirm the SAS, and
verify the identity transfer completes.

## Validation

At `be5620f5f10aa6cc16e86a4f01f102f3d9aeef9b`:
- `cd mobile && ../bin/flutter analyze` — no issues
- `cd mobile && ../bin/flutter test` — 1,368 tests passed
- `cd mobile/android && JAVA_HOME=$(/usr/libexec/java_home -v 21)
./gradlew app:assembleDebug` — debug APK assembled successfully

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-15 18:34:02 -07:00
committed by GitHub
parent 78cbffeb64
commit d8281b9c93
23 changed files with 1881 additions and 51 deletions

View File

@@ -125,6 +125,8 @@ android {
}
dependencies {
implementation("androidx.appcompat:appcompat:1.6.1")
testImplementation(kotlin("test"))
androidTestImplementation(kotlin("test"))

View File

@@ -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) {

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<style name="LaunchTheme" parent="Theme.AppCompat.DayNight">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<style name="LaunchTheme" parent="Theme.AppCompat.DayNight">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>

View File

@@ -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

View File

@@ -43,6 +43,8 @@
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSFaceIDUsageDescription</key>
<string>Buzz uses Face ID to confirm sensitive identity transfers.</string>
<key>NSCameraUsageDescription</key>
<string>Buzz needs camera access so you can take photos to attach to messages and scan QR codes for device pairing.</string>
<key>NSPhotoLibraryUsageDescription</key>

View File

@@ -159,6 +159,7 @@ class InviteJoinNotifier extends Notifier<InviteJoinState> {
relayUrl: invite.relayUrl,
pubkey: keys.public,
nsec: keys.nsec,
sensitiveActionPolicy: SensitiveActionPolicy.disabledByUser,
);
await ref
.read(authProvider.notifier)

View File

@@ -1,11 +1,13 @@
import 'dart:async';
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';
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';
@@ -36,6 +38,7 @@ class PairingPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final pairingState = ref.watch(pairingProvider);
final enrolledBiometrics = ref.watch(enrolledBiometricsProvider);
final codeController = useTextEditingController();
final fallbackScannerVisible = useState(false);
final pairingCodeExpanded = useState(false);
@@ -127,6 +130,16 @@ class PairingPage extends HookConsumerWidget {
sasCode: pairingState.sasCode ?? '------',
confirmed: pairingState.userConfirmedSas,
sendsIdentityToDesktop: pairingState.sendsIdentityToDesktop,
protectSensitiveActions:
pairingState.protectSensitiveActions,
biometricLabel: biometricProtectionLabel(
defaultTargetPlatform,
enrolledBiometrics.value ?? const [],
),
errorMessage: pairingState.errorMessage,
onProtectionChanged: (value) => ref
.read(pairingProvider.notifier)
.setProtectSensitiveActions(value),
onConfirm: () =>
ref.read(pairingProvider.notifier).confirmSas(),
onDeny: () => ref.read(pairingProvider.notifier).denySas(),
@@ -196,6 +209,10 @@ class _SasVerificationView extends StatelessWidget {
final String sasCode;
final bool confirmed;
final bool sendsIdentityToDesktop;
final bool protectSensitiveActions;
final String biometricLabel;
final String? errorMessage;
final ValueChanged<bool> onProtectionChanged;
final VoidCallback onConfirm;
final VoidCallback onDeny;
@@ -203,6 +220,10 @@ class _SasVerificationView extends StatelessWidget {
required this.sasCode,
required this.confirmed,
required this.sendsIdentityToDesktop,
required this.protectSensitiveActions,
required this.biometricLabel,
required this.errorMessage,
required this.onProtectionChanged,
required this.onConfirm,
required this.onDeny,
});
@@ -266,6 +287,32 @@ class _SasVerificationView extends StatelessWidget {
),
),
const SizedBox(height: Grid.sm),
if (!sendsIdentityToDesktop)
CheckboxListTile(
key: const Key('protect-sensitive-actions-checkbox'),
value: protectSensitiveActions,
onChanged: confirmed
? null
: (value) => onProtectionChanged(value ?? false),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
title: Text(biometricLabel),
subtitle: const Text('For secure 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

View File

@@ -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 protectSensitiveActions;
final bool authorizationInProgress;
const PairingState({
this.status = PairingStatus.idle,
@@ -44,6 +47,8 @@ class PairingState {
this.sasCode,
this.userConfirmedSas = false,
this.sendsIdentityToDesktop = false,
this.protectSensitiveActions = true,
this.authorizationInProgress = false,
});
PairingState copyWith({
@@ -52,13 +57,20 @@ class PairingState {
String? sasCode,
bool? userConfirmedSas,
bool? sendsIdentityToDesktop,
bool? protectSensitiveActions,
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,
protectSensitiveActions:
protectSensitiveActions ?? this.protectSensitiveActions,
authorizationInProgress:
authorizationInProgress ?? this.authorizationInProgress,
);
}
@@ -70,13 +82,28 @@ typedef PairingSocketFactory =
required void Function(Object? error) onDisconnected,
});
typedef PairingCredentialValidator =
Future<void> Function({required String relayUrl, required String? nsec});
const identityExportAuthorizationTtl = Duration(minutes: 2);
final identityExportClockProvider = Provider<DateTime Function()>((ref) {
return DateTime.now;
});
class PairingNotifier extends Notifier<PairingState> {
final PairingSocketFactory _socketFactory;
final PairingCredentialValidator? _credentialValidator;
PairingSocket? _socket;
Timer? _sessionTimeout;
Community? _identityExportCommunity;
bool _identityExportBiometricOnly = false;
PairingNotifier({PairingSocketFactory? socketFactory})
: _socketFactory = socketFactory ?? _createPairingSocket;
PairingNotifier({
PairingSocketFactory? socketFactory,
PairingCredentialValidator? credentialValidator,
}) : _socketFactory = socketFactory ?? _createPairingSocket,
_credentialValidator = credentialValidator;
static PairingSocket _createPairingSocket({
required String wsUrl,
@@ -108,32 +135,171 @@ class PairingNotifier extends Notifier<PairingState> {
return _pairLegacy(trimmed);
}
Future<bool> authorizeIdentityExport({required Community community}) async {
if (state.authorizationInProgress) return false;
final biometricOnly =
community.sensitiveActionPolicy == SensitiveActionPolicy.enabled;
final pairingGeneration = _pairingGeneration;
state = state.copyWith(
authorizationInProgress: true,
clearErrorMessage: true,
);
final result = await ref
.read(sensitiveActionAuthorizationSessionProvider)
.authorize(biometricOnly: biometricOnly);
if (pairingGeneration != _pairingGeneration) return false;
if (result != DeviceAuthResult.success) {
state = state.copyWith(
authorizationInProgress: false,
errorMessage: _authorizationError(result),
);
return false;
}
_identityExportCommunity = community;
_identityExportBiometricOnly = biometricOnly;
_identityExportAuthorizedAt = ref.read(identityExportClockProvider)();
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 (state.status != PairingStatus.confirmingSas ||
state.authorizationInProgress) {
return;
}
_userConfirmedSas = true;
state = state.copyWith(userConfirmedSas: true);
if (_sasConfirmReceived) unawaited(_continueAfterSas());
}
// 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);
}
}
void setProtectSensitiveActions(bool value) {
if (state.status != PairingStatus.confirmingSas ||
state.sendsIdentityToDesktop ||
state.authorizationInProgress) {
return;
}
state = state.copyWith(protectSensitiveActions: value);
}
Future<void> _continueAfterSas() async {
if (!_userConfirmedSas ||
!_sasConfirmReceived ||
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 (_sendIdentityToSource && !_exportIdentityIsCurrent()) {
_userConfirmedSas = false;
state = state.copyWith(
userConfirmedSas: false,
errorMessage:
'The active community changed. Start identity export again.',
);
return;
}
final authorizedAt = _identityExportAuthorizedAt;
final elapsed = authorizedAt == null
? null
: ref.read(identityExportClockProvider)().difference(authorizedAt);
final hasFreshExportAuthorization =
elapsed != null &&
!elapsed.isNegative &&
elapsed < identityExportAuthorizationTtl;
if (_sendIdentityToSource && !hasFreshExportAuthorization) {
final pairingGeneration = _pairingGeneration;
state = state.copyWith(authorizationInProgress: true);
final result = await ref
.read(sensitiveActionAuthorizationSessionProvider)
.authorize(biometricOnly: _identityExportBiometricOnly);
if (pairingGeneration != _pairingGeneration ||
!_userConfirmedSas ||
!_sasConfirmReceived ||
state.status != PairingStatus.confirmingSas ||
!state.authorizationInProgress) {
return;
}
if (result != DeviceAuthResult.success) {
_userConfirmedSas = false;
state = state.copyWith(
userConfirmedSas: false,
authorizationInProgress: false,
errorMessage: _authorizationError(result),
);
return;
}
} else if (!_sendIdentityToSource && state.protectSensitiveActions) {
final pairingGeneration = _pairingGeneration;
state = state.copyWith(authorizationInProgress: true);
final result = await ref
.read(sensitiveActionAuthorizerProvider)
.authorizeBiometricProtection();
if (pairingGeneration != _pairingGeneration ||
!_userConfirmedSas ||
!_sasConfirmReceived ||
state.status != PairingStatus.confirmingSas ||
!state.authorizationInProgress) {
return;
}
if (result != DeviceAuthResult.success) {
_userConfirmedSas = false;
state = state.copyWith(
userConfirmedSas: false,
authorizationInProgress: false,
errorMessage: _biometricProtectionError(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 _biometricProtectionError(
DeviceAuthResult result,
) => switch (result) {
DeviceAuthResult.cancelled =>
'Biometric setup was cancelled. Nothing was transferred.',
DeviceAuthResult.unavailable =>
'Biometrics are unavailable. Enroll Face ID or biometrics and try again, or turn this option off.',
DeviceAuthResult.lockedOut =>
'Biometrics are locked. Unlock them in system settings and try again.',
DeviceAuthResult.failed =>
'Biometric confirmation failed. Nothing was transferred.',
DeviceAuthResult.success => '',
};
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 and try again.',
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');
@@ -150,6 +316,7 @@ class PairingNotifier extends Notifier<PairingState> {
}
void _cleanup() {
_pairingGeneration++;
_sessionTimeout?.cancel();
_sessionTimeout = null;
_socket?.dispose();
@@ -159,6 +326,9 @@ class PairingNotifier extends Notifier<PairingState> {
_userConfirmedSas = false;
_pendingPayload = null;
_sendIdentityToSource = false;
_identityExportCommunity = null;
_identityExportBiometricOnly = false;
_identityExportAuthorizedAt = null;
}
// ── NIP-AB pairing flow ─────────────────────────────────────────────────
@@ -174,6 +344,8 @@ class PairingNotifier extends Notifier<PairingState> {
bool _sasConfirmReceived = false;
bool _userConfirmedSas = false;
bool _sendIdentityToSource = false;
int _pairingGeneration = 0;
DateTime? _identityExportAuthorizedAt;
Map<String, dynamic>? _pendingPayload; // buffered until user confirms SAS
final Set<String> _processedEventIds = {}; // NIP-AB §Duplicate Event Handling
@@ -248,6 +420,7 @@ class PairingNotifier extends Notifier<PairingState> {
status: PairingStatus.confirmingSas,
sasCode: formatSas(sasCode),
sendsIdentityToDesktop: _sendIdentityToSource,
protectSensitiveActions: ref.read(relayConfigProvider).nsec == null,
);
// 9. Start 120s session timeout.
@@ -415,32 +588,33 @@ class PairingNotifier extends Notifier<PairingState> {
// 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().
}
bool _exportIdentityIsCurrent() {
final authorizedCommunity = _identityExportCommunity;
final currentConfig = ref.read(relayConfigProvider);
return authorizedCommunity != null &&
authorizedCommunity.nsec != null &&
authorizedCommunity.nsec!.isNotEmpty &&
authorizedCommunity.nsec == currentConfig.nsec &&
authorizedCommunity.relayUrl == currentConfig.storedOrigin;
}
void _sendIdentityPayload() {
final nsec = ref.read(relayConfigProvider).nsec;
if (nsec == null || nsec.isEmpty) {
_sendAbort('protocol_error');
if (!_exportIdentityIsCurrent()) {
_sendAbort('identity_changed');
_cleanup();
state = const PairingState(
status: PairingStatus.error,
errorMessage: 'No identity is available on this phone.',
errorMessage:
'The active community changed. Start identity export again.',
);
return;
}
final nsec = _identityExportCommunity!.nsec!;
final content = _encryptMessage({
'type': 'payload',
'payload_type': 'nsec',
@@ -472,6 +646,7 @@ class PairingNotifier extends Notifier<PairingState> {
final payloadType = msg['payload_type'] as String?;
final payload = msg['payload'] as String?;
if (payload == null) {
_cleanup();
state = const PairingState(
status: PairingStatus.error,
errorMessage: 'Received empty payload from source.',
@@ -479,7 +654,16 @@ class PairingNotifier extends Notifier<PairingState> {
return;
}
_processPayload(payloadType, payload);
final pairingGeneration = _pairingGeneration;
final protectSensitiveActions = state.protectSensitiveActions;
unawaited(
_processPayload(
payloadType,
payload,
pairingGeneration: pairingGeneration,
protectSensitiveActions: protectSensitiveActions,
),
);
}
void _handleComplete(Map<String, dynamic> msg) {
@@ -507,7 +691,12 @@ class PairingNotifier extends Notifier<PairingState> {
);
}
Future<void> _processPayload(String? payloadType, String payload) async {
Future<void> _processPayload(
String? payloadType,
String payload, {
required int pairingGeneration,
required bool protectSensitiveActions,
}) async {
try {
// Parse the custom payload.
final data = jsonDecode(payload) as Map<String, dynamic>;
@@ -523,7 +712,13 @@ class PairingNotifier extends Notifier<PairingState> {
_validateRelayUrl(relayUrl);
// Validate credentials against the relay via NIP-42 WS handshake.
await _validateCredentials(relayUrl: relayUrl, nsec: nsec);
final credentialValidator = _credentialValidator ?? _validateCredentials;
await credentialValidator(relayUrl: relayUrl, nsec: nsec);
if (pairingGeneration != _pairingGeneration ||
state.status != PairingStatus.storing ||
_sendIdentityToSource) {
return;
}
// Send complete only after credentials are validated.
_sendComplete(true);
@@ -534,14 +729,27 @@ class PairingNotifier extends Notifier<PairingState> {
relayUrl: relayUrl,
pubkey: pubkey,
nsec: nsec,
sensitiveActionPolicy: protectSensitiveActions
? SensitiveActionPolicy.enabled
: SensitiveActionPolicy.disabledByUser,
);
await ref
.read(authProvider.notifier)
.authenticateWithCommunity(community);
if (pairingGeneration != _pairingGeneration ||
state.status != PairingStatus.storing ||
_sendIdentityToSource) {
return;
}
_cleanup();
state = const PairingState(status: PairingStatus.success);
} catch (e) {
if (pairingGeneration != _pairingGeneration ||
state.status != PairingStatus.storing ||
_sendIdentityToSource) {
return;
}
_sendComplete(false);
_cleanup();
state = PairingState(
@@ -627,7 +835,6 @@ class PairingNotifier extends Notifier<PairingState> {
try {
final community = _parseLegacyInput(rawInput);
await _validateCredentials(
relayUrl: community.relayUrl,
nsec: community.nsec,
@@ -710,6 +917,7 @@ class PairingNotifier extends Notifier<PairingState> {
relayUrl: relayUrl,
pubkey: decoded['pubkey'] as String?,
nsec: decoded['nsec'] as String?,
sensitiveActionPolicy: SensitiveActionPolicy.disabledByUser,
);
}

View File

@@ -12,6 +12,7 @@ import '../../shared/auth/auth.dart';
import '../../shared/clipboard_utils.dart';
import '../../shared/community/community_membership_provider.dart';
import '../../shared/relay/relay.dart';
import '../pairing/pairing_provider.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/app_list.dart';
import '../../shared/widgets/app_list_card.dart';

View File

@@ -8,7 +8,9 @@ class _ConnectionSection extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final config = ref.watch(relayConfigProvider);
final authState = ref.watch(authProvider).value;
final nsec = config.nsec;
final community = authState?.community;
return AppListCard(
label: 'Connection',
@@ -18,16 +20,52 @@ class _ConnectionSection extends ConsumerWidget {
title: 'Connected to',
subtitle: config.baseUrl,
),
if (nsec != null && nsec.isNotEmpty) ...[
if (nsec != null && nsec.isNotEmpty && community != null) ...[
_IdentityRow(nsec: nsec),
AppListRow(
icon: LucideIcons.scanQrCode,
title: 'Send identity to desktop',
subtitle: 'Scan a recovery code shown by Buzz Desktop',
trailing: const _RowChevron(),
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: identityRecoveryPageBuilder),
),
onTap: () async {
final pairing = ref.read(pairingProvider.notifier);
final authorized = await pairing.authorizeIdentityExport(
community: community,
);
if (!authorized) {
if (!context.mounted) return;
final message = ref.read(pairingProvider).errorMessage;
if (message != null) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
return;
}
try {
if (!context.mounted) return;
final resumed = await _waitForResumedFrame();
if (!resumed) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Buzz did not return to the foreground. Try again.',
),
),
);
}
return;
}
if (!context.mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(builder: identityRecoveryPageBuilder),
);
} finally {
pairing.reset();
}
},
),
],
],
@@ -35,6 +73,29 @@ class _ConnectionSection extends ConsumerWidget {
}
}
const _resumeWaitTimeout = Duration(seconds: 5);
Future<bool> _waitForResumedFrame() async {
final binding = WidgetsBinding.instance;
if (binding.lifecycleState != AppLifecycleState.resumed) {
final resumed = Completer<void>();
final listener = AppLifecycleListener(
onResume: () {
if (!resumed.isCompleted) resumed.complete();
},
);
try {
await resumed.future.timeout(_resumeWaitTimeout);
} on TimeoutException {
return false;
} finally {
listener.dispose();
}
}
await binding.endOfFrame;
return true;
}
/// Destructive, so it gets a container of its own rather than sitting at the
/// bottom of the connection group.
class _RemoveCommunitySection extends ConsumerWidget {

View File

@@ -3,12 +3,15 @@ import 'package:uuid/uuid.dart';
const _uuid = Uuid();
const _sentinel = Object();
enum SensitiveActionPolicy { 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.disabledByUser,
required this.addedAt,
});
@@ -25,6 +29,8 @@ class Community {
required String relayUrl,
String? pubkey,
String? nsec,
SensitiveActionPolicy sensitiveActionPolicy =
SensitiveActionPolicy.disabledByUser,
}) {
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.disabledByUser,
),
addedAt: DateTime.parse(json['addedAt'] as String),
);

View File

@@ -51,6 +51,7 @@ class CommunityStorage {
relayUrl: legacyUrl,
pubkey: legacyPubkey,
nsec: legacyNsec,
sensitiveActionPolicy: SensitiveActionPolicy.disabledByUser,
);
await _saveList([community]);

View File

@@ -0,0 +1,158 @@
import 'package:flutter/foundation.dart';
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<DeviceAuthResult> authorizeIdentityAction({
required bool biometricOnly,
});
Future<DeviceAuthResult> authorizeBiometricProtection();
Future<List<BiometricType>> enrolledBiometrics();
}
class LocalSensitiveActionAuthorizer implements SensitiveActionAuthorizer {
LocalSensitiveActionAuthorizer([LocalAuthentication? authentication])
: _authentication = authentication ?? LocalAuthentication();
final LocalAuthentication _authentication;
@override
Future<DeviceAuthResult> authorizeIdentityAction({
required bool biometricOnly,
}) => _authorize(
localizedReason: 'Confirm sending your Buzz identity to desktop',
biometricOnly: biometricOnly,
);
@override
Future<List<BiometricType>> enrolledBiometrics() async {
try {
return await _authentication.getAvailableBiometrics();
} catch (_) {
return const [];
}
}
@override
Future<DeviceAuthResult> authorizeBiometricProtection() async {
try {
final availableBiometrics = await _authentication
.getAvailableBiometrics();
if (availableBiometrics.isEmpty) return DeviceAuthResult.unavailable;
return _authorize(
localizedReason: 'Enable biometrics for secure actions',
biometricOnly: true,
);
} on LocalAuthException catch (error) {
return _resultFor(error);
} catch (_) {
return DeviceAuthResult.failed;
}
}
Future<DeviceAuthResult> _authorize({
required String localizedReason,
required bool biometricOnly,
}) async {
try {
final supported = await _authentication.isDeviceSupported();
if (!supported) return DeviceAuthResult.unavailable;
final authenticated = await _authentication.authenticate(
localizedReason: localizedReason,
biometricOnly: biometricOnly,
sensitiveTransaction: true,
// iOS may briefly background the app while presenting Face ID. Keep
// this authorization alive across that system transition instead of
// returning a cancellation that forces the user to tap and retry.
persistAcrossBackgrounding: true,
);
return authenticated ? DeviceAuthResult.success : DeviceAuthResult.failed;
} on LocalAuthException catch (error) {
return _resultFor(error);
} catch (_) {
return DeviceAuthResult.failed;
}
}
static DeviceAuthResult _resultFor(LocalAuthException error) =>
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,
};
}
final sensitiveActionAuthorizerProvider = Provider<SensitiveActionAuthorizer>((
ref,
) {
return LocalSensitiveActionAuthorizer();
});
final enrolledBiometricsProvider = FutureProvider<List<BiometricType>>((ref) {
return ref.watch(sensitiveActionAuthorizerProvider).enrolledBiometrics();
});
String biometricProtectionLabel(
TargetPlatform platform,
Iterable<BiometricType> enrolledBiometrics,
) {
if (platform == TargetPlatform.iOS) {
if (enrolledBiometrics.contains(BiometricType.face)) return 'Use Face ID';
if (enrolledBiometrics.contains(BiometricType.fingerprint)) {
return 'Use Touch ID';
}
}
return 'Use biometrics';
}
class SensitiveActionAuthorizationSession {
SensitiveActionAuthorizationSession(this._authorizer);
final SensitiveActionAuthorizer _authorizer;
Future<DeviceAuthResult>? _authorizationInFlight;
bool? _inFlightBiometricOnly;
Future<DeviceAuthResult> authorize({required bool biometricOnly}) async {
final inFlight = _authorizationInFlight;
if (inFlight != null) {
if (_inFlightBiometricOnly == biometricOnly) return inFlight;
await inFlight;
return authorize(biometricOnly: biometricOnly);
}
final authorization = _authorizer.authorizeIdentityAction(
biometricOnly: biometricOnly,
);
_authorizationInFlight = authorization;
_inFlightBiometricOnly = biometricOnly;
try {
final result = await authorization;
return result;
} finally {
if (identical(_authorizationInFlight, authorization)) {
_authorizationInFlight = null;
_inFlightBiometricOnly = null;
}
}
}
}
final sensitiveActionAuthorizationSessionProvider =
Provider<SensitiveActionAuthorizationSession>((ref) {
return SensitiveActionAuthorizationSession(
ref.watch(sensitiveActionAuthorizerProvider),
);
});

View File

@@ -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:

View File

@@ -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:

View File

@@ -156,6 +156,10 @@ void main() {
);
expect(auth.authenticatedCommunities.single.pubkey, keys.public);
expect(auth.authenticatedCommunities.single.nsec, keys.nsec);
expect(
auth.authenticatedCommunities.single.sensitiveActionPolicy,
SensitiveActionPolicy.disabledByUser,
);
},
);

View File

@@ -1,9 +1,13 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:local_auth/local_auth.dart';
import 'package:buzz/features/pairing/pairing_page.dart';
import 'package:buzz/features/pairing/pairing_provider.dart';
import 'package:buzz/shared/community/community.dart';
import 'package:buzz/shared/security/sensitive_action_authorizer.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/buzz_loading_indicator.dart';
import 'package:buzz/shared/widgets/tappable_flapping_bee.dart';
@@ -224,6 +228,104 @@ 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<CheckboxListTile>(
find.byKey(const Key('protect-sensitive-actions-checkbox')),
);
expect(checkbox.value, isTrue);
expect(find.text('Use biometrics'), findsOneWidget);
expect(find.text('For secure actions'), findsOneWidget);
});
testWidgets('uses the native Face ID label on iOS', (tester) async {
final previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
try {
await tester.pumpWidget(
ProviderScope(
overrides: [
pairingProvider.overrideWith(
() => _ConfirmingSasPairingNotifier(),
),
enrolledBiometricsProvider.overrideWith(
(_) async => const [BiometricType.face],
),
],
child: MaterialApp(
theme: AppTheme.dark(),
home: const PairingPage(),
),
),
);
await tester.pump();
expect(find.text('Use Face ID'), findsOneWidget);
expect(find.text('Use biometrics'), findsNothing);
} finally {
debugDefaultTargetPlatformOverride = previousPlatform;
}
});
testWidgets('uses the native Touch ID label on iOS', (tester) async {
final previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
try {
await tester.pumpWidget(
ProviderScope(
overrides: [
pairingProvider.overrideWith(
() => _ConfirmingSasPairingNotifier(),
),
enrolledBiometricsProvider.overrideWith(
(_) async => const [BiometricType.fingerprint],
),
],
child: MaterialApp(
theme: AppTheme.dark(),
home: const PairingPage(),
),
),
);
await tester.pump();
expect(find.text('Use Touch ID'), findsOneWidget);
expect(find.text('Use Face ID'), findsNothing);
} finally {
debugDefaultTargetPlatformOverride = previousPlatform;
}
});
testWidgets('desktop recovery does not show 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-sensitive-actions-checkbox')),
findsNothing,
);
});
testWidgets('recovery SAS warns about permanent desktop access', (
tester,
) async {
@@ -259,6 +361,10 @@ class _ErrorPairingNotifier extends Notifier<PairingState>
PairingState build() =>
PairingState(status: PairingStatus.error, errorMessage: error);
@override
Future<bool> authorizeIdentityExport({required Community community}) async =>
true;
@override
Future<void> pair(String rawInput) async {}
@@ -268,6 +374,9 @@ class _ErrorPairingNotifier extends Notifier<PairingState>
@override
void confirmSas() {}
@override
void setProtectSensitiveActions(bool value) {}
@override
void denySas() {}
}
@@ -277,6 +386,10 @@ class _ConnectingPairingNotifier extends Notifier<PairingState>
@override
PairingState build() => const PairingState(status: PairingStatus.connecting);
@override
Future<bool> authorizeIdentityExport({required Community community}) async =>
true;
@override
Future<void> pair(String rawInput) async {}
@@ -286,6 +399,9 @@ class _ConnectingPairingNotifier extends Notifier<PairingState>
@override
void confirmSas() {}
@override
void setProtectSensitiveActions(bool value) {}
@override
void denySas() {}
}
@@ -297,6 +413,10 @@ class _RecordingPairingNotifier extends Notifier<PairingState>
@override
PairingState build() => const PairingState();
@override
Future<bool> authorizeIdentityExport({required Community community}) async =>
true;
@override
Future<void> pair(String rawInput) async => pairedCodes.add(rawInput);
@@ -306,6 +426,9 @@ class _RecordingPairingNotifier extends Notifier<PairingState>
@override
void confirmSas() {}
@override
void setProtectSensitiveActions(bool value) {}
@override
void denySas() {}
}
@@ -323,6 +446,10 @@ class _ConfirmingSasPairingNotifier extends Notifier<PairingState>
sendsIdentityToDesktop: sendsIdentityToDesktop,
);
@override
Future<bool> authorizeIdentityExport({required Community community}) async =>
true;
@override
Future<void> pair(String rawInput) async {}
@@ -332,6 +459,9 @@ class _ConfirmingSasPairingNotifier extends Notifier<PairingState>
@override
void confirmSas() {}
@override
void setProtectSensitiveActions(bool value) {}
@override
void denySas() {}
}

View File

@@ -1,7 +1,9 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:local_auth/local_auth.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:buzz/features/pairing/pairing_crypto.dart';
import 'package:buzz/features/pairing/pairing_provider.dart';
@@ -10,6 +12,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.
@@ -186,6 +189,194 @@ void main() {
expect(container.read(pairingProvider).status, PairingStatus.idle);
});
group('identity import protection', () {
const sourceSecret =
'09b3065e3570a3a4054660dccd66e12774a99a904fdb0ca02dbc6c3136249506';
const sessionSecretHex =
'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789';
late _ControllableSocket socket;
late PairingNotifier notifier;
late FakeAuthNotifier importAuth;
late _FakeSensitiveActionAuthorizer authorizer;
late Completer<void> validation;
late String pairingCode;
setUp(() {
final source = nostr.Keys(sourceSecret);
pairingCode =
'nostrpair://${source.public}'
'?secret=$sessionSecretHex'
'&relay=wss%3A%2F%2Fpairing.buzz.xyz&v=1';
validation = Completer<void>();
importAuth = FakeAuthNotifier();
authorizer = _FakeSensitiveActionAuthorizer();
notifier = PairingNotifier(
credentialValidator:
({required String relayUrl, required String? nsec}) =>
validation.future,
socketFactory:
({
required wsUrl,
required ephemeralPrivkey,
required onMessage,
required onDisconnected,
}) {
socket = _ControllableSocket(
ephemeralPrivkey: ephemeralPrivkey,
onMessage: onMessage,
onDisconnected: onDisconnected,
);
return socket;
},
);
container = ProviderContainer(
overrides: [
pairingProvider.overrideWith(() => notifier),
authProvider.overrideWith(() => importAuth),
sensitiveActionAuthorizerProvider.overrideWithValue(authorizer),
],
);
container.read(pairingProvider);
notifier = container.read(pairingProvider.notifier);
});
Future<void> beginImport({required bool protected}) async {
await notifier.pair(pairingCode);
notifier.setProtectSensitiveActions(protected);
notifier.confirmSas();
socket.sendSourceMessage(
sourceSecret: sourceSecret,
sessionSecretHex: sessionSecretHex,
message: {'type': 'sas-confirm'},
includeTranscriptHash: true,
);
await Future<void>.delayed(Duration.zero);
if (protected && authorizer.result != DeviceAuthResult.success) return;
socket.sendSourceMessage(
sourceSecret: sourceSecret,
sessionSecretHex: sessionSecretHex,
message: {
'type': 'payload',
'payload_type': 'credentials',
'payload': jsonEncode({
'relayUrl': 'https://relay.test',
'pubkey': nostr.Keys(sourceSecret).public,
'nsec': nostr.Keys(sourceSecret).nsec,
}),
},
);
await Future<void>.delayed(Duration.zero);
expect(container.read(pairingProvider).status, PairingStatus.storing);
}
test('unchecked protection persists on a successful import', () async {
await beginImport(protected: false);
validation.complete();
await Future<void>.delayed(Duration.zero);
expect(
importAuth.lastCommunity?.sensitiveActionPolicy,
SensitiveActionPolicy.disabledByUser,
);
expect(container.read(pairingProvider).status, PairingStatus.success);
});
test('checked protection persists on a successful import', () async {
await beginImport(protected: true);
expect(authorizer.calls, 1);
validation.complete();
await Future<void>.delayed(Duration.zero);
expect(
importAuth.lastCommunity?.sensitiveActionPolicy,
SensitiveActionPolicy.enabled,
);
});
test('unenrolled biometrics prevent a protected import', () async {
authorizer.result = DeviceAuthResult.unavailable;
await beginImport(protected: true);
final state = container.read(pairingProvider);
expect(authorizer.calls, 1);
expect(state.status, PairingStatus.confirmingSas);
expect(state.userConfirmedSas, isFalse);
expect(state.errorMessage, contains('Enroll Face ID or biometrics'));
expect(importAuth.lastCommunity, isNull);
expect(
socket
.decryptedPublishedMessages(sourceSecret)
.any((message) => message['type'] == 'complete'),
isFalse,
);
});
test('stale biometric approval cannot advance a reset import', () async {
final pendingAuthorization = Completer<DeviceAuthResult>();
authorizer.pending = pendingAuthorization;
await notifier.pair(pairingCode);
notifier.setProtectSensitiveActions(true);
notifier.confirmSas();
socket.sendSourceMessage(
sourceSecret: sourceSecret,
sessionSecretHex: sessionSecretHex,
message: {'type': 'sas-confirm'},
includeTranscriptHash: true,
);
await Future<void>.delayed(Duration.zero);
expect(authorizer.calls, 1);
notifier.reset();
pendingAuthorization.complete(DeviceAuthResult.success);
await Future<void>.delayed(Duration.zero);
expect(container.read(pairingProvider).status, PairingStatus.idle);
expect(importAuth.lastCommunity, isNull);
expect(
socket
.decryptedPublishedMessages(sourceSecret)
.any((message) => message['type'] == 'complete'),
isFalse,
);
});
test('reset invalidates pending authentication persistence', () async {
importAuth.authentication = Completer<void>();
await beginImport(protected: false);
validation.complete();
await Future<void>.delayed(Duration.zero);
expect(importAuth.lastCommunity, isNotNull);
notifier.reset();
importAuth.authentication!.complete();
await Future<void>.delayed(Duration.zero);
expect(container.read(pairingProvider).status, PairingStatus.idle);
expect(socket.isConnected, isFalse);
});
test('reset invalidates pending credential validation', () async {
await beginImport(protected: false);
notifier.reset();
validation.complete();
await Future<void>.delayed(Duration.zero);
expect(importAuth.lastCommunity, isNull);
expect(container.read(pairingProvider).status, PairingStatus.idle);
expect(
socket
.decryptedPublishedMessages(sourceSecret)
.any((message) => message['type'] == 'complete'),
isFalse,
);
});
});
group('desktop identity recovery', () {
const sourceSecret =
'09b3065e3570a3a4054660dccd66e12774a99a904fdb0ca02dbc6c3136249506';
@@ -194,13 +385,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 +416,24 @@ void main() {
overrides: [
pairingProvider.overrideWith(() => notifier),
relayConfigProvider.overrideWith(_RecoveryRelayConfig.new),
sensitiveActionAuthorizerProvider.overrideWithValue(authorizer),
identityExportClockProvider.overrideWithValue(() => now),
],
);
container.read(pairingProvider);
notifier = container.read(pairingProvider.notifier);
});
test('recovery URI enables phone-to-desktop transfer', () async {
test('recovery authorization honors biometric protection', () async {
expect(
await notifier.authorizeIdentityExport(
community: _exportCommunity(SensitiveActionPolicy.enabled),
),
isTrue,
);
expect(authorizer.calls, 1);
expect(authorizer.lastIdentityBiometricOnly, isTrue);
await notifier.pair(recoveryCode);
final state = container.read(pairingProvider);
@@ -236,9 +442,73 @@ void main() {
expect(state.sasCode, hasLength(6));
});
test('reset invalidates pending preflight authorization', () async {
final pendingAuthorization = Completer<DeviceAuthResult>();
authorizer.pending = pendingAuthorization;
final authorization = notifier.authorizeIdentityExport(
community: _exportCommunity(SensitiveActionPolicy.disabledByUser),
);
expect(container.read(pairingProvider).authorizationInProgress, isTrue);
notifier.reset();
pendingAuthorization.complete(DeviceAuthResult.success);
expect(await authorization, isFalse);
expect(container.read(pairingProvider).status, PairingStatus.idle);
await notifier.pair(recoveryCode);
notifier.confirmSas();
socket.sendSourceMessage(
sourceSecret: sourceSecret,
sessionSecretHex: sessionSecretHex,
message: {'type': 'sas-confirm'},
includeTranscriptHash: true,
);
await Future<void>.delayed(Duration.zero);
expect(
socket
.decryptedPublishedMessages(sourceSecret)
.any((message) => message['type'] == 'payload'),
isFalse,
);
});
test('concurrent preflight requests start one authentication', () async {
final pendingAuthorization = Completer<DeviceAuthResult>();
authorizer.pending = pendingAuthorization;
final community = _exportCommunity(
SensitiveActionPolicy.disabledByUser,
);
final first = notifier.authorizeIdentityExport(community: community);
final second = notifier.authorizeIdentityExport(community: community);
expect(await second, isFalse);
expect(authorizer.calls, 1);
pendingAuthorization.complete(DeviceAuthResult.success);
expect(await first, isTrue);
});
test('unchecked protection allows device passcode fallback', () async {
expect(
await notifier.authorizeIdentityExport(
community: _exportCommunity(SensitiveActionPolicy.disabledByUser),
),
isTrue,
);
expect(authorizer.lastIdentityBiometricOnly, isFalse);
});
test(
'matching SAS sends nsec and successful completion finishes',
() async {
expect(
await notifier.authorizeIdentityExport(
community: _exportCommunity(SensitiveActionPolicy.disabledByUser),
),
isTrue,
);
await notifier.pair(recoveryCode);
notifier.confirmSas();
expect(container.read(pairingProvider).userConfirmedSas, isTrue);
@@ -250,10 +520,13 @@ void main() {
includeTranscriptHash: true,
);
await Future<void>.delayed(Duration.zero);
expect(
container.read(pairingProvider).status,
PairingStatus.transferring,
);
expect(authorizer.calls, 1);
final sentMessages = socket.decryptedPublishedMessages(sourceSecret);
expect(
sentMessages.any(
@@ -274,7 +547,58 @@ void main() {
},
);
test('desktop storage failure surfaces an error', () async {
test(
'malformed payload invalidates authorization before another session',
() async {
expect(
await notifier.authorizeIdentityExport(
community: _exportCommunity(SensitiveActionPolicy.disabledByUser),
),
isTrue,
);
await notifier.pair(recoveryCode);
notifier.confirmSas();
socket.sendSourceMessage(
sourceSecret: sourceSecret,
sessionSecretHex: sessionSecretHex,
message: {'type': 'sas-confirm'},
includeTranscriptHash: true,
);
await Future<void>.delayed(Duration.zero);
socket.sendSourceMessage(
sourceSecret: sourceSecret,
sessionSecretHex: sessionSecretHex,
message: {'type': 'payload', 'payload_type': 'nsec'},
);
expect(container.read(pairingProvider).status, PairingStatus.error);
await notifier.pair(recoveryCode);
final replacementSocket = socket;
notifier.confirmSas();
replacementSocket.sendSourceMessage(
sourceSecret: sourceSecret,
sessionSecretHex: sessionSecretHex,
message: {'type': 'sas-confirm'},
includeTranscriptHash: true,
);
await Future<void>.delayed(Duration.zero);
final state = container.read(pairingProvider);
expect(state.status, PairingStatus.confirmingSas);
expect(state.userConfirmedSas, isFalse);
expect(state.errorMessage, contains('active community changed'));
expect(authorizer.calls, 1);
expect(
replacementSocket
.decryptedPublishedMessages(sourceSecret)
.any((message) => message['type'] == 'payload'),
isFalse,
);
},
);
test('skipped preflight authorization sends no identity', () async {
await notifier.pair(recoveryCode);
notifier.confirmSas();
socket.sendSourceMessage(
@@ -283,6 +607,306 @@ void main() {
message: {'type': 'sas-confirm'},
includeTranscriptHash: true,
);
await Future<void>.delayed(Duration.zero);
final state = container.read(pairingProvider);
expect(authorizer.calls, 0);
expect(state.status, PairingStatus.confirmingSas);
expect(state.userConfirmedSas, isFalse);
expect(state.errorMessage, contains('active community changed'));
expect(
socket
.decryptedPublishedMessages(sourceSecret)
.any((message) => message['type'] == 'payload'),
isFalse,
);
});
test(
'failed transfer reauthorization publishes no identity payload',
() async {
expect(
await notifier.authorizeIdentityExport(
community: _exportCommunity(SensitiveActionPolicy.disabledByUser),
),
isTrue,
);
await notifier.pair(recoveryCode);
now = now.add(identityExportAuthorizationTtl);
authorizer.result = DeviceAuthResult.cancelled;
notifier.confirmSas();
socket.sendSourceMessage(
sourceSecret: sourceSecret,
sessionSecretHex: sessionSecretHex,
message: {'type': 'sas-confirm'},
includeTranscriptHash: true,
);
await Future<void>.delayed(Duration.zero);
expect(authorizer.calls, 2);
final state = container.read(pairingProvider);
expect(state.status, PairingStatus.confirmingSas);
expect(state.userConfirmedSas, isFalse);
expect(state.errorMessage, contains('cancelled'));
final sentMessages = socket.decryptedPublishedMessages(sourceSecret);
expect(
sentMessages.any((message) => message['type'] == 'payload'),
isFalse,
);
},
);
test(
'stale authorization cannot advance a replacement pairing session',
() async {
expect(
await notifier.authorizeIdentityExport(
community: _exportCommunity(SensitiveActionPolicy.disabledByUser),
),
isTrue,
);
await notifier.pair(recoveryCode);
now = now.add(identityExportAuthorizationTtl);
final firstSocket = socket;
final pendingAuthorization = Completer<DeviceAuthResult>();
authorizer.pending = pendingAuthorization;
notifier.confirmSas();
firstSocket.sendSourceMessage(
sourceSecret: sourceSecret,
sessionSecretHex: sessionSecretHex,
message: {'type': 'sas-confirm'},
includeTranscriptHash: true,
);
await Future<void>.delayed(Duration.zero);
expect(authorizer.calls, 2);
notifier.reset();
await notifier.pair(recoveryCode);
final replacementSocket = socket;
expect(
container.read(pairingProvider).status,
PairingStatus.confirmingSas,
);
expect(container.read(pairingProvider).userConfirmedSas, isFalse);
pendingAuthorization.complete(DeviceAuthResult.success);
await Future<void>.delayed(Duration.zero);
final state = container.read(pairingProvider);
expect(state.status, PairingStatus.confirmingSas);
expect(state.userConfirmedSas, isFalse);
for (final pairingSocket in [firstSocket, replacementSocket]) {
final sentMessages = pairingSocket.decryptedPublishedMessages(
sourceSecret,
);
expect(
sentMessages.any((message) => message['type'] == 'payload'),
isFalse,
);
}
},
);
test('clock rollback invalidates the export authorization', () async {
expect(
await notifier.authorizeIdentityExport(
community: _exportCommunity(SensitiveActionPolicy.disabledByUser),
),
isTrue,
);
await notifier.pair(recoveryCode);
now = now.subtract(const Duration(minutes: 1));
notifier.confirmSas();
socket.sendSourceMessage(
sourceSecret: sourceSecret,
sessionSecretHex: sessionSecretHex,
message: {'type': 'sas-confirm'},
includeTranscriptHash: true,
);
await Future<void>.delayed(Duration.zero);
expect(authorizer.calls, 2);
expect(
container.read(pairingProvider).status,
PairingStatus.transferring,
);
});
test(
'websocket relay origin remains bound after canonicalization',
() async {
final community = _exportCommunity(
SensitiveActionPolicy.disabledByUser,
).copyWith(relayUrl: 'wss://relay.test');
container
.read(relayConfigProvider.notifier)
.update(baseUrl: community.relayUrl, nsec: community.nsec);
expect(
await notifier.authorizeIdentityExport(community: community),
isTrue,
);
await notifier.pair(recoveryCode);
notifier.confirmSas();
socket.sendSourceMessage(
sourceSecret: sourceSecret,
sessionSecretHex: sessionSecretHex,
message: {'type': 'sas-confirm'},
includeTranscriptHash: true,
);
await Future<void>.delayed(Duration.zero);
expect(
container.read(pairingProvider).status,
PairingStatus.transferring,
);
expect(
socket
.decryptedPublishedMessages(sourceSecret)
.any(
(message) =>
message['type'] == 'payload' &&
message['payload'] == community.nsec,
),
isTrue,
);
},
);
for (final expired in [false, true]) {
test(
'community switch aborts ${expired ? 'expired' : 'fresh'} export grant',
() async {
expect(
await notifier.authorizeIdentityExport(
community: _exportCommunity(
SensitiveActionPolicy.disabledByUser,
),
),
isTrue,
);
await notifier.pair(recoveryCode);
if (expired) now = now.add(identityExportAuthorizationTtl);
container
.read(relayConfigProvider.notifier)
.update(
baseUrl: 'https://other-relay.test',
nsec: nostr.Keys(
'2222222222222222222222222222222222222222222222222222222222222222',
).nsec,
);
notifier.confirmSas();
socket.sendSourceMessage(
sourceSecret: sourceSecret,
sessionSecretHex: sessionSecretHex,
message: {'type': 'sas-confirm'},
includeTranscriptHash: true,
);
await Future<void>.delayed(Duration.zero);
final state = container.read(pairingProvider);
expect(state.status, PairingStatus.confirmingSas);
expect(state.userConfirmedSas, isFalse);
expect(state.errorMessage, contains('active community changed'));
expect(authorizer.calls, 1);
expect(
socket
.decryptedPublishedMessages(sourceSecret)
.any((message) => message['type'] == 'payload'),
isFalse,
);
},
);
}
test('expired biometric-only authorization preserves its mode', () async {
expect(
await notifier.authorizeIdentityExport(
community: _exportCommunity(SensitiveActionPolicy.enabled),
),
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<void>.delayed(Duration.zero);
expect(authorizer.calls, 2);
expect(authorizer.lastIdentityBiometricOnly, isTrue);
expect(
container.read(pairingProvider).status,
PairingStatus.transferring,
);
});
test(
'expired passcode-fallback authorization preserves its mode',
() async {
expect(
await notifier.authorizeIdentityExport(
community: _exportCommunity(SensitiveActionPolicy.disabledByUser),
),
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<void>.delayed(Duration.zero);
expect(authorizer.calls, 2);
expect(authorizer.lastIdentityBiometricOnly, isFalse);
expect(
container.read(pairingProvider).status,
PairingStatus.transferring,
);
},
);
test('cancelled authorization prevents pairing from starting', () async {
authorizer.result = DeviceAuthResult.cancelled;
expect(
await notifier.authorizeIdentityExport(
community: _exportCommunity(SensitiveActionPolicy.disabledByUser),
),
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(
community: _exportCommunity(SensitiveActionPolicy.disabledByUser),
),
isTrue,
);
await notifier.pair(recoveryCode);
notifier.confirmSas();
socket.sendSourceMessage(
sourceSecret: sourceSecret,
sessionSecretHex: sessionSecretHex,
message: {'type': 'sas-confirm'},
includeTranscriptHash: true,
);
await Future<void>.delayed(Duration.zero);
socket.sendSourceMessage(
sourceSecret: sourceSecret,
sessionSecretHex: sessionSecretHex,
@@ -317,6 +941,7 @@ String _encodePairingCode({
class FakeAuthNotifier extends AsyncNotifier<AuthState>
implements AuthNotifier {
Community? lastCommunity;
Completer<void>? authentication;
bool signedOut = false;
@override
@@ -332,6 +957,7 @@ class FakeAuthNotifier extends AsyncNotifier<AuthState>
@override
Future<void> authenticateWithCommunity(Community community) async {
lastCommunity = community;
await authentication?.future;
state = AsyncData(
AuthState(status: AuthStatus.authenticated, community: community),
);
@@ -356,6 +982,15 @@ class _DisconnectingSocket extends PairingSocket {
}
}
Community _exportCommunity(SensitiveActionPolicy policy) => Community(
id: 'export-community',
name: 'Export',
relayUrl: 'https://relay.test',
nsec: _RecoveryRelayConfig.nsec,
sensitiveActionPolicy: policy,
addedAt: DateTime.utc(2026),
);
class _RecoveryRelayConfig extends RelayConfigNotifier {
static final nsec = nostr.Keys(
'1111111111111111111111111111111111111111111111111111111111111111',
@@ -365,6 +1000,31 @@ class _RecoveryRelayConfig extends RelayConfigNotifier {
RelayConfig build() => RelayConfig(baseUrl: 'https://relay.test', nsec: nsec);
}
class _FakeSensitiveActionAuthorizer implements SensitiveActionAuthorizer {
DeviceAuthResult result = DeviceAuthResult.success;
Completer<DeviceAuthResult>? pending;
int calls = 0;
bool? lastIdentityBiometricOnly;
@override
Future<DeviceAuthResult> authorizeIdentityAction({
required bool biometricOnly,
}) async {
calls++;
lastIdentityBiometricOnly = biometricOnly;
return pending?.future ?? result;
}
@override
Future<DeviceAuthResult> authorizeBiometricProtection() async {
calls++;
return pending?.future ?? result;
}
@override
Future<List<BiometricType>> enrolledBiometrics() async => const [];
}
class _ControllableSocket extends PairingSocket {
final String ephemeralPrivkey;
final void Function(List<dynamic> message) relayMessageCallback;

View File

@@ -0,0 +1,258 @@
import 'dart:async';
import 'package:buzz/features/pairing/pairing_provider.dart';
import 'package:buzz/features/settings/settings_page.dart';
import 'package:buzz/shared/auth/auth.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
import '../../helpers/widget_helpers.dart';
void main() {
testWidgets('waits for a resumed frame before navigating after auth', (
tester,
) async {
final authorization = Completer<bool>();
final pairing = _PairingNotifier(authorization.future);
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
relayConfigProvider.overrideWith(_RelayConfigNotifier.new),
authProvider.overrideWith(_AuthNotifier.new),
pairingProvider.overrideWith(() => pairing),
savedPrefsProvider.overrideWithValue(prefs),
],
child: SettingsPage(
profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) =>
const Scaffold(body: Text('Identity recovery')),
),
),
);
await tester.pump();
await tester.tap(find.text('Send identity to desktop'));
await tester.pump();
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
authorization.complete(true);
await tester.pump();
expect(find.text('Identity recovery'), findsNothing);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
await tester.pump();
await tester.pumpAndSettle();
expect(find.text('Identity recovery'), findsOneWidget);
});
testWidgets('resume timeout keeps identity recovery closed', (tester) async {
final pairing = _PairingNotifier(Future<bool>.value(true));
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
relayConfigProvider.overrideWith(_RelayConfigNotifier.new),
authProvider.overrideWith(_AuthNotifier.new),
pairingProvider.overrideWith(() => pairing),
savedPrefsProvider.overrideWithValue(prefs),
],
child: SettingsPage(
profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) =>
const Scaffold(body: Text('Identity recovery')),
),
),
);
await tester.pump();
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
await tester.tap(find.text('Send identity to desktop'));
await tester.pump();
await tester.pump(const Duration(seconds: 5));
await tester.pump();
expect(pairing.resetCalls, 1);
expect(find.text('Identity recovery'), findsNothing);
expect(
find.text('Buzz did not return to the foreground. Try again.'),
findsOneWidget,
);
});
testWidgets('clears authorization when disposed during authentication', (
tester,
) async {
final authorization = Completer<bool>();
final pairing = _PairingNotifier(authorization.future);
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
relayConfigProvider.overrideWith(_RelayConfigNotifier.new),
authProvider.overrideWith(_AuthNotifier.new),
pairingProvider.overrideWith(() => pairing),
savedPrefsProvider.overrideWithValue(prefs),
],
child: SettingsPage(
profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) =>
const Scaffold(body: Text('Identity recovery')),
),
),
);
await tester.pump();
final settingsContext = tester.element(
find.text('Send identity to desktop'),
);
await tester.tap(find.text('Send identity to desktop'));
await tester.pump();
unawaited(
Navigator.of(settingsContext).pushReplacement(
MaterialPageRoute<void>(builder: (_) => const SizedBox.shrink()),
),
);
await tester.pumpAndSettle();
authorization.complete(true);
await tester.pump();
expect(pairing.resetCalls, 1);
expect(find.text('Identity recovery'), findsNothing);
});
testWidgets('clears authorization when disposed during resume wait', (
tester,
) async {
final pairing = _PairingNotifier(Future<bool>.value(true));
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
relayConfigProvider.overrideWith(_RelayConfigNotifier.new),
authProvider.overrideWith(_AuthNotifier.new),
pairingProvider.overrideWith(() => pairing),
savedPrefsProvider.overrideWithValue(prefs),
],
child: SettingsPage(
profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) =>
const Scaffold(body: Text('Identity recovery')),
),
),
);
await tester.pump();
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
final settingsContext = tester.element(
find.text('Send identity to desktop'),
);
await tester.tap(find.text('Send identity to desktop'));
await tester.pump();
unawaited(
Navigator.of(settingsContext).pushReplacement(
MaterialPageRoute<void>(builder: (_) => const SizedBox.shrink()),
),
);
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 5));
await tester.pump();
expect(pairing.resetCalls, 1);
expect(find.text('Identity recovery'), findsNothing);
});
testWidgets('denied authentication does not open identity recovery', (
tester,
) async {
final pairing = _PairingNotifier(Future<bool>.value(false));
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
relayConfigProvider.overrideWith(_RelayConfigNotifier.new),
authProvider.overrideWith(_AuthNotifier.new),
pairingProvider.overrideWith(() => pairing),
savedPrefsProvider.overrideWithValue(prefs),
],
child: SettingsPage(
profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) =>
const Scaffold(body: Text('Identity recovery')),
),
),
);
await tester.pump();
await tester.tap(find.text('Send identity to desktop'));
await tester.pumpAndSettle();
expect(pairing.authorizationCalls, 1);
expect(find.text('Identity recovery'), findsNothing);
});
}
class _AuthNotifier extends AuthNotifier {
@override
Future<AuthState> build() async => AuthState(
status: AuthStatus.authenticated,
community: Community(
id: 'community',
name: 'Test',
relayUrl: 'https://relay.test',
nsec: _RelayConfigNotifier.nsec,
addedAt: DateTime.utc(2026),
),
);
}
class _RelayConfigNotifier extends RelayConfigNotifier {
static final nsec = nostr.Keys(
'1111111111111111111111111111111111111111111111111111111111111111',
).nsec;
@override
RelayConfig build() => RelayConfig(baseUrl: 'https://relay.test', nsec: nsec);
}
class _PairingNotifier extends PairingNotifier {
_PairingNotifier(this.authorization);
final Future<bool> authorization;
int authorizationCalls = 0;
int resetCalls = 0;
@override
PairingState build() => const PairingState();
@override
Future<bool> authorizeIdentityExport({required Community community}) {
authorizationCalls++;
return authorization;
}
@override
void reset() {
resetCalls++;
}
}

View File

@@ -196,6 +196,10 @@ void main() {
expect(loaded.first.pubkey, 'legacy_pub');
expect(loaded.first.nsec, 'legacy_nsec');
expect(loaded.first.name, isNotEmpty);
expect(
loaded.first.sensitiveActionPolicy,
SensitiveActionPolicy.disabledByUser,
);
// Legacy keys should be deleted.
expect(fakeSecure['buzz_relay_url'], isNull);

View File

@@ -0,0 +1,33 @@
import 'package:buzz/shared/community/community.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('existing records default to device authentication', () {
final community = Community.fromJson({
'id': 'one',
'name': 'Buzz',
'relayUrl': 'https://relay.test',
'addedAt': '2026-08-05T00:00:00.000Z',
});
expect(
community.sensitiveActionPolicy,
SensitiveActionPolicy.disabledByUser,
);
});
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,
);
});
}

View File

@@ -0,0 +1,197 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:local_auth/local_auth.dart';
import 'package:mocktail/mocktail.dart';
import 'package:buzz/shared/security/sensitive_action_authorizer.dart';
class _MockLocalAuthentication extends Mock implements LocalAuthentication {}
void main() {
late _MockLocalAuthentication authentication;
setUp(() {
authentication = _MockLocalAuthentication();
when(authentication.isDeviceSupported).thenAnswer((_) async => true);
when(
authentication.getAvailableBiometrics,
).thenAnswer((_) async => <BiometricType>[BiometricType.face]);
when(
() => authentication.authenticate(
localizedReason: any(named: 'localizedReason'),
authMessages: any(named: 'authMessages'),
biometricOnly: any(named: 'biometricOnly'),
sensitiveTransaction: any(named: 'sensitiveTransaction'),
persistAcrossBackgrounding: any(named: 'persistAcrossBackgrounding'),
),
).thenAnswer((_) async => true);
});
test('identity authorization retries after system backgrounding', () async {
final result = await LocalSensitiveActionAuthorizer(
authentication,
).authorizeIdentityAction(biometricOnly: false);
expect(result, DeviceAuthResult.success);
verify(
() => authentication.authenticate(
localizedReason: 'Confirm sending your Buzz identity to desktop',
authMessages: any(named: 'authMessages'),
biometricOnly: false,
sensitiveTransaction: true,
persistAcrossBackgrounding: true,
),
).called(1);
});
test('unsupported devices fail closed before authentication', () async {
when(authentication.isDeviceSupported).thenAnswer((_) async => false);
final result = await LocalSensitiveActionAuthorizer(
authentication,
).authorizeIdentityAction(biometricOnly: false);
expect(result, DeviceAuthResult.unavailable);
verifyNever(
() => authentication.authenticate(
localizedReason: any(named: 'localizedReason'),
authMessages: any(named: 'authMessages'),
biometricOnly: any(named: 'biometricOnly'),
sensitiveTransaction: any(named: 'sensitiveTransaction'),
persistAcrossBackgrounding: any(named: 'persistAcrossBackgrounding'),
),
);
});
const mappedExceptions = <LocalAuthExceptionCode, DeviceAuthResult>{
LocalAuthExceptionCode.userCanceled: DeviceAuthResult.cancelled,
LocalAuthExceptionCode.systemCanceled: DeviceAuthResult.cancelled,
LocalAuthExceptionCode.timeout: DeviceAuthResult.cancelled,
LocalAuthExceptionCode.temporaryLockout: DeviceAuthResult.lockedOut,
LocalAuthExceptionCode.biometricLockout: DeviceAuthResult.lockedOut,
LocalAuthExceptionCode.noCredentialsSet: DeviceAuthResult.unavailable,
LocalAuthExceptionCode.noBiometricsEnrolled: DeviceAuthResult.unavailable,
LocalAuthExceptionCode.noBiometricHardware: DeviceAuthResult.unavailable,
LocalAuthExceptionCode.biometricHardwareTemporarilyUnavailable:
DeviceAuthResult.unavailable,
LocalAuthExceptionCode.uiUnavailable: DeviceAuthResult.unavailable,
LocalAuthExceptionCode.authInProgress: DeviceAuthResult.failed,
};
for (final MapEntry(key: code, value: expected) in mappedExceptions.entries) {
test('maps ${code.name} to ${expected.name}', () async {
when(
() => authentication.authenticate(
localizedReason: any(named: 'localizedReason'),
authMessages: any(named: 'authMessages'),
biometricOnly: any(named: 'biometricOnly'),
sensitiveTransaction: any(named: 'sensitiveTransaction'),
persistAcrossBackgrounding: any(named: 'persistAcrossBackgrounding'),
),
).thenThrow(LocalAuthException(code: code));
final result = await LocalSensitiveActionAuthorizer(
authentication,
).authorizeIdentityAction(biometricOnly: false);
expect(result, expected);
});
}
test('biometric protection requires an enrolled biometric', () async {
when(
authentication.getAvailableBiometrics,
).thenAnswer((_) async => <BiometricType>[]);
final result = await LocalSensitiveActionAuthorizer(
authentication,
).authorizeBiometricProtection();
expect(result, DeviceAuthResult.unavailable);
verifyNever(
() => authentication.authenticate(
localizedReason: any(named: 'localizedReason'),
authMessages: any(named: 'authMessages'),
biometricOnly: any(named: 'biometricOnly'),
sensitiveTransaction: any(named: 'sensitiveTransaction'),
persistAcrossBackgrounding: any(named: 'persistAcrossBackgrounding'),
),
);
});
test(
'biometric protection does not fall back to a device passcode',
() async {
final result = await LocalSensitiveActionAuthorizer(
authentication,
).authorizeBiometricProtection();
expect(result, DeviceAuthResult.success);
verify(
() => authentication.authenticate(
localizedReason: 'Enable biometrics for secure actions',
authMessages: any(named: 'authMessages'),
biometricOnly: true,
sensitiveTransaction: true,
persistAcrossBackgrounding: true,
),
).called(1);
},
);
test('single-flight shares authorization with the same mode', () async {
final authorizer = _ControllableAuthorizer();
final session = SensitiveActionAuthorizationSession(authorizer);
final first = session.authorize(biometricOnly: false);
final second = session.authorize(biometricOnly: false);
expect(authorizer.biometricOnlyCalls, [false]);
authorizer.completeNext(DeviceAuthResult.success);
expect(await first, DeviceAuthResult.success);
expect(await second, DeviceAuthResult.success);
});
test('single-flight does not reuse a weaker in-flight mode', () async {
final authorizer = _ControllableAuthorizer();
final session = SensitiveActionAuthorizationSession(authorizer);
final passcodeAllowed = session.authorize(biometricOnly: false);
final biometricOnly = session.authorize(biometricOnly: true);
expect(authorizer.biometricOnlyCalls, [false]);
authorizer.completeNext(DeviceAuthResult.success);
expect(await passcodeAllowed, DeviceAuthResult.success);
await Future<void>.delayed(Duration.zero);
expect(authorizer.biometricOnlyCalls, [false, true]);
authorizer.completeNext(DeviceAuthResult.success);
expect(await biometricOnly, DeviceAuthResult.success);
});
}
class _ControllableAuthorizer implements SensitiveActionAuthorizer {
final biometricOnlyCalls = <bool>[];
final _pending = <Completer<DeviceAuthResult>>[];
void completeNext(DeviceAuthResult result) {
_pending.removeAt(0).complete(result);
}
@override
Future<DeviceAuthResult> authorizeIdentityAction({
required bool biometricOnly,
}) {
biometricOnlyCalls.add(biometricOnly);
final completer = Completer<DeviceAuthResult>();
_pending.add(completer);
return completer.future;
}
@override
Future<DeviceAuthResult> authorizeBiometricProtection() =>
throw UnimplementedError();
@override
Future<List<BiometricType>> enrolledBiometrics() =>
throw UnimplementedError();
}