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>
364 lines
12 KiB
Dart
364 lines
12 KiB
Dart
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';
|
|
import 'pairing_provider.dart';
|
|
import 'pairing_qr_scanner.dart';
|
|
|
|
part 'pairing_page/onboarding_background.dart';
|
|
part 'pairing_page/pairing_welcome_view.dart';
|
|
|
|
const _onboardingChartreuse = Color(0xFFD7D72E);
|
|
const _onboardingShellBottom = Color(0xFFD7E7F6);
|
|
const _onboardingCtaLabel = Color(0xFFD7E6F0);
|
|
const _onboardingInk = Color(0xFF111111);
|
|
const _onboardingMutedInk = Color(0xB3111111);
|
|
|
|
class PairingPage extends HookConsumerWidget {
|
|
/// When true, the pairing page is being used to add a new community
|
|
/// (user is already authenticated with at least one community).
|
|
final bool addingCommunity;
|
|
final bool identityRecoveryOnly;
|
|
|
|
const PairingPage({
|
|
super.key,
|
|
this.addingCommunity = false,
|
|
this.identityRecoveryOnly = false,
|
|
});
|
|
|
|
@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);
|
|
final isBusy =
|
|
pairingState.status == PairingStatus.connecting ||
|
|
pairingState.status == PairingStatus.transferring ||
|
|
pairingState.status == PairingStatus.storing;
|
|
|
|
// When adding a community and pairing succeeds, pop back.
|
|
if (addingCommunity && pairingState.status == PairingStatus.success) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (context.mounted) {
|
|
ref.read(pairingProvider.notifier).reset();
|
|
Navigator.of(context).pop();
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> handleScannerResult(String? code) async {
|
|
if (code != null && context.mounted) {
|
|
if (identityRecoveryOnly &&
|
|
Uri.tryParse(code)?.queryParameters['mode'] != 'recover') {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Scan a desktop recovery code.')),
|
|
);
|
|
return;
|
|
}
|
|
await ref.read(pairingProvider.notifier).pair(code);
|
|
}
|
|
}
|
|
|
|
Future<void> openScanner() async {
|
|
final usesDynamicIslandPortal = await usesDynamicIslandQrScannerPortal();
|
|
if (!context.mounted) {
|
|
return;
|
|
}
|
|
|
|
if (!usesDynamicIslandPortal) {
|
|
fallbackScannerVisible.value = true;
|
|
return;
|
|
}
|
|
|
|
final code = await showDynamicIslandPairingQrScanner(context);
|
|
await handleScannerResult(code);
|
|
}
|
|
|
|
final isVerifyingSas = pairingState.status == PairingStatus.confirmingSas;
|
|
final themedSystemOverlayStyle =
|
|
(context.theme.brightness == Brightness.dark
|
|
? SystemUiOverlayStyle.light
|
|
: SystemUiOverlayStyle.dark)
|
|
.copyWith(statusBarColor: Colors.transparent);
|
|
final pairingAppBar = addingCommunity
|
|
? AppBar(
|
|
foregroundColor: isVerifyingSas
|
|
? context.colors.onSurface
|
|
: _onboardingInk,
|
|
systemOverlayStyle: isVerifyingSas
|
|
? themedSystemOverlayStyle
|
|
: SystemUiOverlayStyle.dark.copyWith(
|
|
statusBarColor: Colors.transparent,
|
|
),
|
|
leading: IconButton(
|
|
icon: const Icon(LucideIcons.arrowLeft),
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
title: Text(
|
|
identityRecoveryOnly ? 'Send to Desktop' : 'Add Community',
|
|
style: isVerifyingSas
|
|
? null
|
|
: context.textTheme.titleMedium?.copyWith(
|
|
color: _onboardingInk,
|
|
),
|
|
),
|
|
)
|
|
: null;
|
|
|
|
final pairingScaffold = isVerifyingSas
|
|
? AnnotatedRegion<SystemUiOverlayStyle>(
|
|
key: const Key('pairing-sas-system-overlay'),
|
|
value: themedSystemOverlayStyle,
|
|
child: Scaffold(
|
|
backgroundColor: context.colors.surface,
|
|
appBar: pairingAppBar,
|
|
body: SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: Grid.sm),
|
|
child: _SasVerificationView(
|
|
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(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
: AnnotatedRegion<SystemUiOverlayStyle>(
|
|
key: const Key('pairing-onboarding-system-overlay'),
|
|
value: SystemUiOverlayStyle.dark.copyWith(
|
|
statusBarColor: Colors.transparent,
|
|
),
|
|
child: _OnboardingBackground(
|
|
child: Scaffold(
|
|
backgroundColor: Colors.transparent,
|
|
appBar: pairingAppBar,
|
|
body: SafeArea(
|
|
child: _PairingWelcomeView(
|
|
codeController: codeController,
|
|
isBusy: isBusy,
|
|
pairingCodeExpanded: pairingCodeExpanded.value,
|
|
errorMessage: pairingState.status == PairingStatus.error
|
|
? pairingState.errorMessage
|
|
: null,
|
|
onScan: openScanner,
|
|
onTogglePairingCode: () {
|
|
pairingCodeExpanded.value = !pairingCodeExpanded.value;
|
|
},
|
|
onConnect: () {
|
|
final code = codeController.text.trim();
|
|
if (code.isNotEmpty) {
|
|
unawaited(handleScannerResult(code));
|
|
}
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final appSurface = PopScope(
|
|
onPopInvokedWithResult: (didPop, _) {
|
|
if (didPop) {
|
|
ref.read(pairingProvider.notifier).reset();
|
|
}
|
|
},
|
|
child: pairingScaffold,
|
|
);
|
|
|
|
if (!fallbackScannerVisible.value) {
|
|
return appSurface;
|
|
}
|
|
|
|
return FallbackPairingQrScanner(
|
|
appSurface: appSurface,
|
|
onClosed: (code) {
|
|
fallbackScannerVisible.value = false;
|
|
unawaited(handleScannerResult(code));
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// SAS verification screen shown during NIP-AB pairing.
|
|
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;
|
|
|
|
const _SasVerificationView({
|
|
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,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Spacer(flex: 2),
|
|
|
|
Icon(LucideIcons.shieldCheck, size: 56, color: context.colors.primary),
|
|
const SizedBox(height: Grid.sm),
|
|
|
|
Text('Verify Security Code', style: context.textTheme.headlineSmall),
|
|
const SizedBox(height: Grid.xs),
|
|
|
|
Text(
|
|
confirmed
|
|
? 'Waiting for desktop to confirm...'
|
|
: 'Does your desktop app show this code?',
|
|
textAlign: TextAlign.center,
|
|
style: context.textTheme.bodyMedium?.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: Grid.lg),
|
|
|
|
// Large SAS code display
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 20),
|
|
decoration: BoxDecoration(
|
|
color: context.colors.primaryContainer.withValues(alpha: 0.3),
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(
|
|
color: context.colors.primary.withValues(alpha: 0.3),
|
|
width: 2,
|
|
),
|
|
),
|
|
child: Text(
|
|
'${sasCode.substring(0, 3)} ${sasCode.substring(3)}',
|
|
style: context.textTheme.displayMedium?.copyWith(
|
|
fontFamily: 'GeistMono',
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 8,
|
|
color: context.colors.primary,
|
|
),
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: Grid.lg),
|
|
|
|
Text(
|
|
sendsIdentityToDesktop
|
|
? 'This sends your full Buzz identity to the desktop\nand grants it permanent access. Only confirm a\ndesktop you trust and a recovery you started.'
|
|
: 'You are about to transfer your Buzz identity\nto this device. Only confirm if you initiated\nthis pairing from your desktop.',
|
|
textAlign: TextAlign.center,
|
|
style: context.textTheme.bodySmall?.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
|
|
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
|
|
if (confirmed)
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
BuzzLoadingIndicator(
|
|
size: 24,
|
|
color: context.colors.primary,
|
|
semanticLabel: 'Connecting',
|
|
),
|
|
const SizedBox(width: Grid.twelve),
|
|
Text(
|
|
'Confirmed — waiting for desktop',
|
|
style: context.textTheme.bodySmall?.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
)
|
|
else
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Expanded(
|
|
child: OutlinedButton.icon(
|
|
onPressed: onDeny,
|
|
icon: const Icon(LucideIcons.x),
|
|
label: const Text('Cancel'),
|
|
),
|
|
),
|
|
const SizedBox(width: Grid.sm),
|
|
Expanded(
|
|
child: FilledButton.icon(
|
|
onPressed: onConfirm,
|
|
icon: const Icon(LucideIcons.check),
|
|
label: const Text('Codes Match'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
const Spacer(flex: 3),
|
|
],
|
|
);
|
|
}
|
|
}
|