mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
**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>
468 lines
14 KiB
Dart
468 lines
14 KiB
Dart
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';
|
|
|
|
import '../../helpers/widget_helpers.dart';
|
|
|
|
void main() {
|
|
group('PairingPage', () {
|
|
testWidgets('renders branding and progressive pairing actions', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
WidgetHelpers.testable(child: const PairingPage()),
|
|
);
|
|
|
|
expect(find.byType(TappableFlappingBee), findsOneWidget);
|
|
expect(find.text('Welcome to Buzz'), findsOneWidget);
|
|
expect(find.text('Scan a QR code'), findsOneWidget);
|
|
expect(find.text('Use pairing code'), findsOneWidget);
|
|
expect(find.text('Connect'), findsNothing);
|
|
expect(find.byType(TextField), findsNothing);
|
|
});
|
|
|
|
testWidgets('uses compact desktop-style onboarding actions', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
WidgetHelpers.testable(child: const PairingPage()),
|
|
);
|
|
|
|
final scanButton = tester.getSize(
|
|
find.widgetWithText(FilledButton, 'Scan a QR code'),
|
|
);
|
|
final pairingCodeButton = tester.getSize(
|
|
find.widgetWithText(TextButton, 'Use pairing code'),
|
|
);
|
|
|
|
expect(scanButton.width, lessThan(440));
|
|
expect(pairingCodeButton.width, lessThan(440));
|
|
expect(find.byType(OutlinedButton), findsNothing);
|
|
});
|
|
|
|
testWidgets('uses dark status-bar icons on the onboarding surface', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
WidgetHelpers.testable(child: const PairingPage()),
|
|
);
|
|
|
|
final overlay = tester.widget<AnnotatedRegion<SystemUiOverlayStyle>>(
|
|
find.byKey(const Key('pairing-onboarding-system-overlay')),
|
|
);
|
|
|
|
expect(overlay.value.statusBarIconBrightness, Brightness.dark);
|
|
expect(overlay.value.statusBarColor, Colors.transparent);
|
|
});
|
|
|
|
testWidgets('uses light status-bar icons for dark-theme SAS verification', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
pairingProvider.overrideWith(() => _ConfirmingSasPairingNotifier()),
|
|
],
|
|
child: MaterialApp(theme: AppTheme.dark(), home: const PairingPage()),
|
|
),
|
|
);
|
|
|
|
final overlay = tester.widget<AnnotatedRegion<SystemUiOverlayStyle>>(
|
|
find.byKey(const Key('pairing-sas-system-overlay')),
|
|
);
|
|
|
|
expect(overlay.value.statusBarIconBrightness, Brightness.light);
|
|
expect(overlay.value.statusBarColor, Colors.transparent);
|
|
expect(find.text('Verify Security Code'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('reveals pairing code field and connect action', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
WidgetHelpers.testable(child: const PairingPage()),
|
|
);
|
|
|
|
await _expandPairingCode(tester);
|
|
|
|
expect(find.text('Hide pairing code'), findsOneWidget);
|
|
expect(find.text('Connect'), findsOneWidget);
|
|
expect(find.byType(TextField), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('connect button is below text field, not beside it', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
WidgetHelpers.testable(child: const PairingPage()),
|
|
);
|
|
await _expandPairingCode(tester);
|
|
|
|
final textField = tester.getBottomLeft(find.byType(TextField));
|
|
final connectButton = tester.getTopLeft(
|
|
find.widgetWithText(FilledButton, 'Connect'),
|
|
);
|
|
|
|
// The connect button should be below the text field.
|
|
expect(connectButton.dy, greaterThan(textField.dy));
|
|
});
|
|
|
|
testWidgets('connect button is full width', (tester) async {
|
|
await tester.pumpWidget(
|
|
WidgetHelpers.testable(child: const PairingPage()),
|
|
);
|
|
await _expandPairingCode(tester);
|
|
|
|
final connectButton = tester.getSize(
|
|
find.widgetWithText(FilledButton, 'Connect'),
|
|
);
|
|
final textField = tester.getSize(find.byType(TextField));
|
|
|
|
// Button width should be close to the text field width (both full-width).
|
|
expect(connectButton.width, closeTo(textField.width, 2.0));
|
|
});
|
|
|
|
testWidgets('shows error container when pairing fails', (tester) async {
|
|
await tester.pumpWidget(
|
|
WidgetHelpers.testable(
|
|
overrides: [
|
|
pairingProvider.overrideWith(
|
|
() => _ErrorPairingNotifier('Invalid pairing code: bad input'),
|
|
),
|
|
],
|
|
child: const PairingPage(),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
expect(find.text('Invalid pairing code: bad input'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('shows spinner when connecting', (tester) async {
|
|
await tester.pumpWidget(
|
|
WidgetHelpers.testable(
|
|
overrides: [
|
|
pairingProvider.overrideWith(() => _ConnectingPairingNotifier()),
|
|
],
|
|
child: const PairingPage(),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
expect(find.byType(BuzzLoadingIndicator), findsOneWidget);
|
|
// Connect text should be replaced by spinner.
|
|
expect(find.text('Connect'), findsNothing);
|
|
});
|
|
|
|
testWidgets('pairing actions are disabled when connecting', (tester) async {
|
|
await tester.pumpWidget(
|
|
WidgetHelpers.testable(
|
|
overrides: [
|
|
pairingProvider.overrideWith(() => _ConnectingPairingNotifier()),
|
|
],
|
|
child: const PairingPage(),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
final scanButton = tester.widget<FilledButton>(find.byType(FilledButton));
|
|
final pairingCodeButton = tester.widget<TextButton>(
|
|
find.widgetWithText(TextButton, 'Use pairing code'),
|
|
);
|
|
|
|
expect(scanButton.onPressed, isNull);
|
|
expect(pairingCodeButton.onPressed, isNull);
|
|
});
|
|
|
|
testWidgets('recovery entry rejects ordinary nostrpair codes', (
|
|
tester,
|
|
) async {
|
|
final notifier = _RecordingPairingNotifier();
|
|
await tester.pumpWidget(
|
|
WidgetHelpers.testable(
|
|
overrides: [pairingProvider.overrideWith(() => notifier)],
|
|
child: const PairingPage(
|
|
addingCommunity: true,
|
|
identityRecoveryOnly: true,
|
|
),
|
|
),
|
|
);
|
|
|
|
await _expandPairingCode(tester);
|
|
await tester.enterText(find.byType(TextField), 'nostrpair://ordinary');
|
|
await tester.tap(find.text('Connect'));
|
|
await tester.pump();
|
|
|
|
expect(find.text('Scan a desktop recovery code.'), findsOneWidget);
|
|
expect(notifier.pairedCodes, isEmpty);
|
|
});
|
|
|
|
testWidgets('recovery entry accepts mode=recover codes', (tester) async {
|
|
final notifier = _RecordingPairingNotifier();
|
|
await tester.pumpWidget(
|
|
WidgetHelpers.testable(
|
|
overrides: [pairingProvider.overrideWith(() => notifier)],
|
|
child: const PairingPage(
|
|
addingCommunity: true,
|
|
identityRecoveryOnly: true,
|
|
),
|
|
),
|
|
);
|
|
|
|
await _expandPairingCode(tester);
|
|
const code = 'nostrpair://desktop?mode=recover';
|
|
await tester.enterText(find.byType(TextField), code);
|
|
await tester.tap(find.text('Connect'));
|
|
await tester.pump();
|
|
|
|
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 {
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
pairingProvider.overrideWith(
|
|
() => _ConfirmingSasPairingNotifier(sendsIdentityToDesktop: true),
|
|
),
|
|
],
|
|
child: MaterialApp(theme: AppTheme.dark(), home: const PairingPage()),
|
|
),
|
|
);
|
|
|
|
expect(find.textContaining('full Buzz identity'), findsOneWidget);
|
|
expect(find.textContaining('permanent access'), findsOneWidget);
|
|
expect(find.text('Codes Match'), findsOneWidget);
|
|
});
|
|
});
|
|
}
|
|
|
|
Future<void> _expandPairingCode(WidgetTester tester) async {
|
|
await tester.tap(find.text('Use pairing code'));
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
class _ErrorPairingNotifier extends Notifier<PairingState>
|
|
implements PairingNotifier {
|
|
final String error;
|
|
_ErrorPairingNotifier(this.error);
|
|
|
|
@override
|
|
PairingState build() =>
|
|
PairingState(status: PairingStatus.error, errorMessage: error);
|
|
|
|
@override
|
|
Future<bool> authorizeIdentityExport({required Community community}) async =>
|
|
true;
|
|
|
|
@override
|
|
Future<void> pair(String rawInput) async {}
|
|
|
|
@override
|
|
void reset() {}
|
|
|
|
@override
|
|
void confirmSas() {}
|
|
|
|
@override
|
|
void setProtectSensitiveActions(bool value) {}
|
|
|
|
@override
|
|
void denySas() {}
|
|
}
|
|
|
|
class _ConnectingPairingNotifier extends Notifier<PairingState>
|
|
implements PairingNotifier {
|
|
@override
|
|
PairingState build() => const PairingState(status: PairingStatus.connecting);
|
|
|
|
@override
|
|
Future<bool> authorizeIdentityExport({required Community community}) async =>
|
|
true;
|
|
|
|
@override
|
|
Future<void> pair(String rawInput) async {}
|
|
|
|
@override
|
|
void reset() {}
|
|
|
|
@override
|
|
void confirmSas() {}
|
|
|
|
@override
|
|
void setProtectSensitiveActions(bool value) {}
|
|
|
|
@override
|
|
void denySas() {}
|
|
}
|
|
|
|
class _RecordingPairingNotifier extends Notifier<PairingState>
|
|
implements PairingNotifier {
|
|
final pairedCodes = <String>[];
|
|
|
|
@override
|
|
PairingState build() => const PairingState();
|
|
|
|
@override
|
|
Future<bool> authorizeIdentityExport({required Community community}) async =>
|
|
true;
|
|
|
|
@override
|
|
Future<void> pair(String rawInput) async => pairedCodes.add(rawInput);
|
|
|
|
@override
|
|
void reset() {}
|
|
|
|
@override
|
|
void confirmSas() {}
|
|
|
|
@override
|
|
void setProtectSensitiveActions(bool value) {}
|
|
|
|
@override
|
|
void denySas() {}
|
|
}
|
|
|
|
class _ConfirmingSasPairingNotifier extends Notifier<PairingState>
|
|
implements PairingNotifier {
|
|
_ConfirmingSasPairingNotifier({this.sendsIdentityToDesktop = false});
|
|
|
|
final bool sendsIdentityToDesktop;
|
|
|
|
@override
|
|
PairingState build() => PairingState(
|
|
status: PairingStatus.confirmingSas,
|
|
sasCode: '123456',
|
|
sendsIdentityToDesktop: sendsIdentityToDesktop,
|
|
);
|
|
|
|
@override
|
|
Future<bool> authorizeIdentityExport({required Community community}) async =>
|
|
true;
|
|
|
|
@override
|
|
Future<void> pair(String rawInput) async {}
|
|
|
|
@override
|
|
void reset() {}
|
|
|
|
@override
|
|
void confirmSas() {}
|
|
|
|
@override
|
|
void setProtectSensitiveActions(bool value) {}
|
|
|
|
@override
|
|
void denySas() {}
|
|
}
|