mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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 | | --- | --- | |  |  | <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:
@@ -125,6 +125,8 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
|
||||
testImplementation(kotlin("test"))
|
||||
|
||||
androidTestImplementation(kotlin("test"))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ class CommunityStorage {
|
||||
relayUrl: legacyUrl,
|
||||
pubkey: legacyPubkey,
|
||||
nsec: legacyNsec,
|
||||
sensitiveActionPolicy: SensitiveActionPolicy.disabledByUser,
|
||||
);
|
||||
|
||||
await _saveList([community]);
|
||||
|
||||
158
mobile/lib/shared/security/sensitive_action_authorizer.dart
Normal file
158
mobile/lib/shared/security/sensitive_action_authorizer.dart
Normal 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),
|
||||
);
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
258
mobile/test/features/settings/connection_section_test.dart
Normal file
258
mobile/test/features/settings/connection_section_test.dart
Normal 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++;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
33
mobile/test/shared/community/community_test.dart
Normal file
33
mobile/test/shared/community/community_test.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user