diff --git a/.github/workflows/_ci-clients.yml b/.github/workflows/_ci-clients.yml index f067300e98..6fe925c827 100644 --- a/.github/workflows/_ci-clients.yml +++ b/.github/workflows/_ci-clients.yml @@ -163,6 +163,7 @@ jobs: test/features/onboarding/hosted_app_gate_environment_test.dart test/features/settings/builderlab_account_row_test.dart test/features/onboarding/hosted_journeys_test.dart + test/features/onboarding/hosted_backup_consent_test.dart test/features/onboarding/onboarding_sign_out_test.dart test/features/onboarding/hosted_communities_test.dart test/features/pairing/pairing_hosted_handoff_test.dart diff --git a/mobile/lib/features/onboarding/hosted_connect_sheet.dart b/mobile/lib/features/onboarding/hosted_connect_sheet.dart index f7e98fe264..bf9931a4c6 100644 --- a/mobile/lib/features/onboarding/hosted_connect_sheet.dart +++ b/mobile/lib/features/onboarding/hosted_connect_sheet.dart @@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/hosted/hosted_flow.dart'; +import '../../shared/hosted/hosted_environment.dart'; import '../../shared/widgets/onboarding_step_page.dart'; import '../../shared/widgets/bee_loading_mark.dart'; import '../../shared/widgets/onboarding_loading_page.dart'; @@ -90,6 +91,20 @@ class HostedConnectSheet extends HookConsumerWidget { ? 'Create a new Buzz identity, or import the identity you already use.' : 'Bring your existing Buzz identity from your other device.' : 'Your identity hasn’t been backed up yet. Bring it from your other device to keep using the same Buzz account.', + HostedFlowStage.confirmBackup => + flow.bindingOnly + ? 'Link this identity to the receiving Builderlab account? ' + 'This app cannot change the linked identity later. ' + 'No backup is uploaded by this browser handoff. ' + 'Cancelling stops setup; it does not undo any earlier linking.' + : 'Allow Builderlab to link this identity to your account and upload ' + 'an encrypted copy of your secret key (nsec) to ' + '${HostedEnvironment.origin.host}? Builderlab can decrypt this backup. ' + 'Anyone signed in to this account can recover the key and use your ' + 'identity, including on self-hosted relays. ' + 'This app cannot change the linked identity later. ' + 'Joining requires a backup. Cancelling stops setup; it does not undo ' + 'any earlier linking or upload, including an upload whose confirmation was lost.', HostedFlowStage.chooseCommunities => 'Select a community', HostedFlowStage.communityConflict => '${flow.addingCommunityName} is already saved with a different identity. ' @@ -145,6 +160,37 @@ class HostedConnectSheet extends HookConsumerWidget { ], if (!fullScreen) Text(message, style: context.textTheme.bodyLarge), const SizedBox(height: Grid.sm), + if (stage == HostedFlowStage.confirmBackup) ...[ + Text( + flow.backupAccountEmail == null + ? 'We couldn’t identify the receiving account. Sign out and sign in again before continuing.' + : 'Receiving account: ${flow.backupAccountEmail}', + style: context.textTheme.bodyLarge, + ), + const SizedBox(height: Grid.twelve), + SelectableText( + fullNpub(flow.backupPubkey ?? '') ?? '', + style: context.textTheme.bodySmall, + ), + const SizedBox(height: Grid.twelve), + FilledButton( + style: actionStyle, + onPressed: flow.confirmBackup, + child: Text( + flow.bindingOnly ? 'Link and continue' : 'Back up and continue', + ), + ), + TextButton( + style: actionStyle, + onPressed: () => flow.leave(), + child: const Text('Cancel setup'), + ), + TextButton( + style: actionStyle, + onPressed: flow.signOutAccount, + child: const Text('Sign out'), + ), + ], for (final choice in flow.identityChoices) Padding( padding: const EdgeInsets.only(bottom: Grid.sm), @@ -186,7 +232,9 @@ class HostedConnectSheet extends HookConsumerWidget { ), const SizedBox(height: Grid.twelve), Text( - 'We’ll securely back it up so your next sign-in is easier.', + flow.bindingOnly + ? 'This links the identity to your Builderlab account. This app cannot change it later. No backup is uploaded by this browser handoff.' + : 'New identities are linked to your Builderlab account and backed up automatically. This app cannot change the linked identity later. Existing keys require confirmation before backup.', textAlign: TextAlign.center, style: context.textTheme.bodySmall, ), @@ -253,6 +301,7 @@ class HostedConnectSheet extends HookConsumerWidget { stage != HostedFlowStage.acceptPolicy && stage != HostedFlowStage.chooseIdentity && stage != HostedFlowStage.setupIdentity && + stage != HostedFlowStage.confirmBackup && stage != HostedFlowStage.chooseCommunities && stage != HostedFlowStage.communityConflict && stage != HostedFlowStage.complete && @@ -282,6 +331,8 @@ class HostedConnectSheet extends HookConsumerWidget { ? 'Create or import a Buzz identity' : 'Import your Buzz identity' : 'Bring your identity with you', + HostedFlowStage.confirmBackup => + flow.bindingOnly ? 'Link your identity?' : 'Back up your secret key?', HostedFlowStage.chooseIdentity => 'Choose your identity', HostedFlowStage.working => flow.addingCommunityName == null diff --git a/mobile/lib/shared/hosted/account_identity_store.dart b/mobile/lib/shared/hosted/account_identity_store.dart index 5f7d93cbb0..5d33948760 100644 --- a/mobile/lib/shared/hosted/account_identity_store.dart +++ b/mobile/lib/shared/hosted/account_identity_store.dart @@ -175,8 +175,10 @@ class AccountIdentityStore implements BackupIdentity { /// Authorization runs under the identity writer lock and must not reenter it. /// A completed but unacknowledged write is reused on retry, never regenerated. + /// [onGenerated] runs only after this call confirmed a newly generated key. Future generateIfAbsent({ required Future Function() authorize, + void Function()? onGenerated, }) => _serialized(() async { final existing = await _read(); if (existing != null) return existing.snapshot; @@ -184,6 +186,7 @@ class AccountIdentityStore implements BackupIdentity { final keys = nostr.Keys.generate(); final record = _Record(keys.nsec, keys.public, null); await _write(record); + onGenerated?.call(); return record.snapshot; }); diff --git a/mobile/lib/shared/hosted/hosted_flow.dart b/mobile/lib/shared/hosted/hosted_flow.dart index 1ecd49ebed..ab44779677 100644 --- a/mobile/lib/shared/hosted/hosted_flow.dart +++ b/mobile/lib/shared/hosted/hosted_flow.dart @@ -22,6 +22,7 @@ import 'web_connect_binding.dart'; import 'hosted_join_policy.dart'; part 'hosted_flow_identity_recovery.dart'; +part 'hosted_flow_backup_consent.dart'; enum HostedFlowStage { idle, @@ -29,6 +30,7 @@ enum HostedFlowStage { signIn, chooseIdentity, setupIdentity, + confirmBackup, chooseCommunities, communityConflict, acceptPolicy, @@ -139,6 +141,9 @@ class HostedFlow extends ChangeNotifier { String? requiredIdentityPubkey; String? _pairedPubkey; + ({String subject, String pubkey})? _pendingBackup; + String? _pendingImport; + String? get backupPubkey => _pendingBackup?.pubkey; SensitiveActionPolicy? pairedSensitiveActionPolicy; List communities = const []; Set selectedRelays = const {}; @@ -180,6 +185,8 @@ class HostedFlow extends ChangeNotifier { int _generation = 0; int _nextGeneration() { + _pendingBackup = null; + _pendingImport = null; _failedRelay = null; _cancelPolicy(); _cancelProfile(); @@ -544,45 +551,13 @@ class HostedFlow extends ChangeNotifier { return _enqueue(() => _continue(generation, selectedPubkey: pubkey)); } - /// Create explicitly from setup, or beside the saved identities offered. - Future createIdentity() { - if ((_target == null && !accountOnly) || - !canCreateIdentity || - (stage != HostedFlowStage.setupIdentity && - stage != HostedFlowStage.chooseIdentity)) { - return Future.value(); - } - final offered = {for (final item in identityChoices) item.pubkey}; - final generation = _nextGeneration(); - _set(HostedFlowStage.working); - return _enqueue( - () => _continue(generation, createNew: true, offered: offered), - ); - } - - /// Import through the secure store, rechecking the account binding. - /// Errors propagate to the import sheet without discarding the setup choice. - Future importIdentity(String nsec) { - final generation = _generation; - final subject = account.account?.subject; - if (stage != HostedFlowStage.setupIdentity || subject == null) { - throw AccountFailure.unavailable; - } - return _queue.run(() async { - bool current() => - !_disposed && - generation == _generation && - account.account?.subject == subject; - if (!current()) throw AccountFailure.unavailable; - await identity.resolve(isCurrent: current, importNsec: nsec); - }); - } - Future _continue( int generation, { String? selectedPubkey, bool createNew = false, + ({String subject, String pubkey})? approvedBackup, Set offered = const {}, + String? importNsec, }) async { final target = _target; final session = account.account; @@ -607,8 +582,19 @@ class HostedFlow extends ChangeNotifier { var resolved = await identity.resolve( isCurrent: _isCurrent(generation), selectedPubkey: selectedPubkey ?? _pairedPubkey, + importNsec: importNsec, + canAdopt: (pubkey) => + approvedBackup == (subject: session.subject, pubkey: pubkey), ); if (_disposed || generation != _generation) return; + if (resolved.state == HostedIdentityState.needsConsent) { + _presentBackupConsent( + session.subject, + resolved.pubkey!, + importNsec: importNsec, + ); + return; + } if (_importRequested && { HostedIdentityState.ready, @@ -643,34 +629,43 @@ class HostedFlow extends ChangeNotifier { isCurrent: _isCurrent(generation), createNew: true, offered: offered, + canAdopt: (pubkey) => + approvedBackup == (subject: session.subject, pubkey: pubkey), ); } + if (_disposed || generation != _generation) return; + if (resolved.state == HostedIdentityState.needsConsent) { + _presentBackupConsent(session.subject, resolved.pubkey!); + return; + } + if (resolved.generated && resolved.pubkey != null) { + approvedBackup = (subject: session.subject, pubkey: resolved.pubkey!); + } if (_pairedPubkey != null && resolved.pubkey != _pairedPubkey) { _set(HostedFlowStage.blocked, HostedFlowProblem.identityMismatch); return; } + if (target is NostrBindDeepLink && + !await _allowBackup( + resolved, + session.subject, + generation, + canSealBackup, + approvedBackup: approvedBackup, + )) { + return; + } if (target is! NostrBindDeepLink) { for (var step = 0; step < 2; step++) { if (_disposed || generation != _generation) return; - if (!canSealBackup) { - final saved = await store.read(); - if (_disposed || generation != _generation) return; - final needsNewBackup = - resolved.state == HostedIdentityState.needsSetup || - resolved.state == HostedIdentityState.needsBackup || - (resolved.state == HostedIdentityState.needsBinding && - !(saved?.hasBackupFor( - backend: HostedEnvironment.origin.origin, - subject: session.subject, - ) ?? - false)); - if (needsNewBackup) { - _set( - HostedFlowStage.blocked, - HostedFlowProblem.unsupportedPlatform, - ); - return; - } + if (!await _allowBackup( + resolved, + session.subject, + generation, + canSealBackup, + approvedBackup: approvedBackup, + )) { + return; } if (resolved.state == HostedIdentityState.needsBinding && resolved.pubkey != null) { diff --git a/mobile/lib/shared/hosted/hosted_flow_backup_consent.dart b/mobile/lib/shared/hosted/hosted_flow_backup_consent.dart new file mode 100644 index 0000000000..8565181231 --- /dev/null +++ b/mobile/lib/shared/hosted/hosted_flow_backup_consent.dart @@ -0,0 +1,135 @@ +part of 'hosted_flow.dart'; + +/// Consent before reserving an existing identity or exporting its secret. +extension HostedFlowBackupConsent on HostedFlow { + bool get bindingOnly => _target is NostrBindDeepLink; + + String? get backupAccountEmail { + if (_pendingBackup?.subject != account.account?.subject) return null; + final email = account.account?.email?.trim(); + return email == null || email.isEmpty ? null : email; + } + + void _presentBackupConsent( + String subject, + String pubkey, { + String? importNsec, + }) { + _pendingBackup = (subject: subject, pubkey: pubkey); + _pendingImport = importNsec; + _set(HostedFlowStage.confirmBackup); + } + + /// Approval is bound to the displayed account, key and live presentation. + Future Function()? get confirmBackup { + final approved = _pendingBackup; + final presentedGeneration = _generation; + if (stage != HostedFlowStage.confirmBackup || + approved == null || + backupAccountEmail == null) { + return null; + } + return () { + if (_disposed || + presentedGeneration != _generation || + stage != HostedFlowStage.confirmBackup || + _pendingBackup != approved || + account.account?.subject != approved.subject) { + return Future.value(); + } + final importNsec = _pendingImport; + final generation = _nextGeneration(); + _set(HostedFlowStage.working); + return _enqueue( + () => _continue( + generation, + selectedPubkey: approved.pubkey, + approvedBackup: approved, + importNsec: importNsec, + ), + ); + }; + } + + /// Create explicitly from setup, or beside the saved identities offered. + Future createIdentity() { + if ((_target == null && !accountOnly) || + !canCreateIdentity || + (stage != HostedFlowStage.setupIdentity && + stage != HostedFlowStage.chooseIdentity)) { + return Future.value(); + } + final offered = {for (final item in identityChoices) item.pubkey}; + final generation = _nextGeneration(); + _set(HostedFlowStage.working); + return _enqueue( + () => _continue(generation, createNew: true, offered: offered), + ); + } + + /// Keep an imported key tentative until the confirmation is accepted. Only + /// its public key is exposed to widgets; cancellation drops the private input. + Future importIdentity(String nsec) { + final generation = _generation; + final subject = account.account?.subject; + if (stage != HostedFlowStage.setupIdentity || subject == null) { + throw AccountFailure.unavailable; + } + return _queue.run(() async { + bool current() => + !_disposed && + generation == _generation && + account.account?.subject == subject; + if (!current()) throw AccountFailure.unavailable; + final resolved = await identity.resolve( + isCurrent: current, + importNsec: nsec, + canAdopt: (_) => false, + ); + if (!current()) throw AccountFailure.unavailable; + if (resolved.state == HostedIdentityState.needsConsent) { + _presentBackupConsent(subject, resolved.pubkey!, importNsec: nsec); + } + }); + } + + Future _allowBackup( + HostedIdentityResult resolved, + String subject, + int generation, + bool canSealBackup, { + ({String subject, String pubkey})? approvedBackup, + }) async { + final saved = await store.forSubject(subject).read(); + if (_disposed || generation != _generation) return false; + if (account.account?.subject != subject) { + throw AccountFailure.differentAccount; + } + final confirmed = + saved?.pubkey == resolved.pubkey && + (saved?.hasBackupFor( + backend: HostedEnvironment.origin.origin, + subject: subject, + ) ?? + false); + final needsBackup = + resolved.state == HostedIdentityState.needsSetup || + resolved.state == HostedIdentityState.needsBackup || + (resolved.state == HostedIdentityState.needsBinding && !confirmed); + if (!bindingOnly && needsBackup && !canSealBackup) { + _set(HostedFlowStage.blocked, HostedFlowProblem.unsupportedPlatform); + return false; + } + final pubkey = resolved.pubkey; + if ((resolved.state == HostedIdentityState.needsBinding || + (!bindingOnly && + resolved.state == HostedIdentityState.needsBackup)) && + !confirmed && + pubkey != null && + approvedBackup != (subject: subject, pubkey: pubkey)) { + _presentBackupConsent(subject, pubkey); + return false; + } + return true; + } +} diff --git a/mobile/lib/shared/hosted/hosted_identity_service.dart b/mobile/lib/shared/hosted/hosted_identity_service.dart index e6fecb3293..af988a29e4 100644 --- a/mobile/lib/shared/hosted/hosted_identity_service.dart +++ b/mobile/lib/shared/hosted/hosted_identity_service.dart @@ -9,6 +9,7 @@ import 'device_identity_source.dart'; enum HostedIdentityState { ready, needsSelection, + needsConsent, needsSetup, needsBinding, needsBackup, @@ -23,10 +24,14 @@ class HostedIdentityResult { this.pubkey, this.choices = const [], this.canCreate = false, + this.generated = false, }); final HostedIdentityState state; final String? pubkey; final bool canCreate; + + /// True only when this resolution confirmed generating the key itself. + final bool generated; final List choices; } @@ -44,13 +49,23 @@ class HostedIdentityService { final BuilderlabAccountClient account; final AccountIdentityStore store; + /// [canAdopt] may require consent before a local key occupies the account slot. + /// Cloud recovery already has a receipt and does not export a local secret. Future resolve({ bool Function()? isCurrent, String? selectedPubkey, bool createNew = false, Set offered = const {}, String? importNsec, - }) => _resolve(isCurrent, selectedPubkey, createNew, offered, importNsec); + bool Function(String pubkey)? canAdopt, + }) => _resolve( + isCurrent, + selectedPubkey, + createNew, + offered, + importNsec, + canAdopt, + ); Future _resolve( bool Function()? isCurrent, @@ -58,6 +73,7 @@ class HostedIdentityService { bool createNew, Set offered, String? importNsec, + bool Function(String pubkey)? canAdopt, ) async { void checkCurrent() { if (isCurrent != null && !isCurrent()) throw AccountFailure.unavailable; @@ -106,6 +122,12 @@ class HostedIdentityService { if (bound != null && bound != pubkey) { throw AccountIdentityFailure.mismatch; } + if (canAdopt != null && !canAdopt(pubkey)) { + return HostedIdentityResult( + HostedIdentityState.needsConsent, + pubkey: pubkey, + ); + } final imported = await store.create( importNsec, checkCurrent: () { @@ -163,6 +185,12 @@ class HostedIdentityService { canCreate: unclaimed, ); } + if (canAdopt != null && !canAdopt(chosen!)) { + return HostedIdentityResult( + HostedIdentityState.needsConsent, + pubkey: chosen, + ); + } void checkSelection() { _sameAccount(subject); checkCurrent(); @@ -191,7 +219,9 @@ class HostedIdentityService { canCreate: true, ); } + var generatedNow = false; final generated = await store.generateIfAbsent( + onGenerated: () => generatedNow = true, authorize: () async { _sameAccount(subject); checkCurrent(); @@ -211,7 +241,7 @@ class HostedIdentityService { ? const HostedIdentityResult( HostedIdentityState.needsAuthoritativeAbsence, ) - : _result(generated, bound, subject); + : _result(generated, bound, subject, generated: generatedNow); } final recovered = await account.post('/v1/buzz/nsec-backups/recovery', {}); @@ -336,8 +366,9 @@ class HostedIdentityService { HostedIdentityResult _result( AccountIdentitySnapshot local, String? bound, - String subject, - ) { + String subject, { + bool generated = false, + }) { _sameAccount(subject); if (bound != null && bound != local.pubkey) { return HostedIdentityResult( @@ -349,6 +380,7 @@ class HostedIdentityService { return HostedIdentityResult( HostedIdentityState.needsBinding, pubkey: local.pubkey, + generated: generated, ); } return HostedIdentityResult( diff --git a/mobile/test/features/onboarding/hosted_backup_consent_test.dart b/mobile/test/features/onboarding/hosted_backup_consent_test.dart new file mode 100644 index 0000000000..94ae19f4d7 --- /dev/null +++ b/mobile/test/features/onboarding/hosted_backup_consent_test.dart @@ -0,0 +1,382 @@ +import 'package:buzz/shared/auth/auth.dart'; +import 'package:buzz/shared/hosted/hosted_environment.dart'; +import 'package:buzz/shared/hosted/hosted_flow.dart'; +import 'package:buzz/shared/utils/string_utils.dart'; +import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; + +import '../../helpers/hosted_journey_fixture.dart'; + +void main() { + void journey( + String name, + Future Function(WidgetTester, HostedJourneyFixture) run, + ) { + testWidgets(name, (tester) async { + final f = HostedJourneyFixture(); + await tester.runAsync( + () => http.runWithClient(() async { + try { + await run(tester, f); + } finally { + await f.dispose(tester); + } + }, f.client), + ); + }, skip: HostedEnvironment.authDomains.isEmpty); + } + + // This separate journey covers the first cloud export of an existing local + // key. The general hosted journey file is already at its size limit. + testWidgets('self-hosted key requires informed consent before cloud backup', ( + tester, + ) async { + final f = HostedJourneyFixture(); + await tester.runAsync( + () => http.runWithClient(() async { + try { + await f.initialize(localIdentity: false); + f.bound = null; + f.offerProfile = false; + final community = Community.create( + name: 'Self hosted', + relayUrl: 'wss://self-hosted.example', + pubkey: f.keys.public, + nsec: f.keys.nsec, + ); + await f.storage.save(community); + await f.storage.save( + Community.create( + name: 'Second self hosted', + relayUrl: 'wss://second.example', + pubkey: f.secondKeys.public, + nsec: f.secondKeys.nsec, + ), + ); + await f.storage.saveActiveId(community.id); + await f.mount(tester); + expect(f.bound, isNull); + expect(f.sent('/verify'), isEmpty); + expect(f.sent('/enrollment'), isEmpty); + expect(f.sent('/nsec-backups'), isEmpty); + + f.accountEmail = 'earlier-account@example.com'; + await f.flow.signInAccount(); + await f.settle(tester); + await f.tap(tester, fullNpub(f.keys.public)!); + expect(find.text('Back up your secret key?'), findsOneWidget); + expect( + find.textContaining('encrypted copy of your secret key'), + findsOneWidget, + ); + expect( + find.textContaining(HostedEnvironment.origin.host), + findsOneWidget, + ); + expect( + find.textContaining('Anyone signed in to this account'), + findsOneWidget, + ); + expect(find.text(fullNpub(f.keys.public)!), findsOneWidget); + expect( + find.text('Receiving account: earlier-account@example.com'), + findsOneWidget, + ); + expect( + find.textContaining('cannot change the linked identity later'), + findsOneWidget, + ); + expect(await f.store.read(), isNull); + expect(f.bound, isNull); + expect(f.sent('/verify'), isEmpty); + expect(f.sent('/enrollment'), isEmpty); + expect(f.sent('/nsec-backups'), isEmpty); + final staleConfirm = tester + .widget( + find.widgetWithText(FilledButton, 'Back up and continue'), + ) + .onPressed!; + await f.tap(tester, 'Cancel setup'); + expect((await f.storage.loadAll()).first.nsec, f.keys.nsec); + expect(await f.storage.loadActiveId(), community.id); + expect(await f.store.read(), isNull); + + // Re-enter on the same owner: disposal must not be the only fence. + await f.flow.signInAccount(); + await f.settle(tester); + expect(f.flow.stage, HostedFlowStage.chooseIdentity); + await f.tap(tester, fullNpub(f.secondKeys.public)!); + staleConfirm(); + await f.settle(tester); + expect(f.flow.stage, HostedFlowStage.confirmBackup); + expect(f.flow.backupPubkey, f.secondKeys.public); + expect(await f.store.read(), isNull); + expect(f.bound, isNull); + expect(f.sent('/verify'), isEmpty); + expect(f.sent('/enrollment'), isEmpty); + expect(f.sent('/nsec-backups'), isEmpty); + await f.restart(tester); + expect(f.bound, isNull); + expect(f.sent('/verify'), isEmpty); + expect(f.sent('/enrollment'), isEmpty); + await f.flow.signInAccount(); + await f.settle(tester); + expect(f.flow.stage, HostedFlowStage.chooseIdentity); + await f.tap(tester, fullNpub(f.keys.public)!); + expect(find.text('Back up your secret key?'), findsOneWidget); + staleConfirm(); + await f.settle(tester); + expect(f.bound, isNull); + expect(f.sent('/verify'), isEmpty); + expect(f.sent('/enrollment'), isEmpty); + expect(f.sent('/nsec-backups'), isEmpty); + // Android/system Back also discards the tentative selection. + await tester.binding.handlePopRoute(); + await f.settle(tester); + expect(f.flow.stage, HostedFlowStage.idle); + expect((await f.storage.loadAll()).first.nsec, f.keys.nsec); + await f.flow.signInAccount(); + await f.settle(tester); + await f.tap(tester, fullNpub(f.secondKeys.public)!); + // A restored unintended account is recognizable and can be signed out. + await f.tap(tester, 'Sign out'); + expect(f.flow.account.account, isNull); + expect(await f.store.read(), isNull); + f.accountEmail = null; + await f.flow.signInAccount(); + await f.settle(tester); + await f.tap(tester, fullNpub(f.secondKeys.public)!); + expect(f.flow.confirmBackup, isNull); + expect( + find.textContaining('couldn’t identify the receiving account'), + findsOneWidget, + ); + await f.tap(tester, 'Cancel setup'); + f.accountEmail = 'owner@example.com'; + await f.flow.signInAccount(); + await f.settle(tester); + await f.tap(tester, fullNpub(f.secondKeys.public)!); + expect( + find.text('Receiving account: owner@example.com'), + findsOneWidget, + ); + + // The server accepted the upload, but the acknowledgement was lost. + f.beforeUploadReturn = () async { + f.cloudKeys.add(f.secondKeys); + throw http.ClientException('Synthetic lost upload response'); + }; + await f.tap(tester, 'Back up and continue'); + expect(f.bound, f.secondKeys.public); + expect(f.sent('/enrollment'), hasLength(1)); + expect(f.sent('/nsec-backups'), hasLength(1)); + expect((await f.store.read())!.backup, isNull); + await f.tap(tester, 'Try again'); + expect(find.textContaining('does not undo'), findsOneWidget); + expect(find.textContaining('confirmation was lost'), findsOneWidget); + await f.tap(tester, 'Cancel setup'); + expect(f.bound, f.secondKeys.public); + expect(f.cloudKeys.single.public, f.secondKeys.public); + expect(f.sent('/nsec-backups'), hasLength(1)); + await f.restart(tester); + await f.flow.signInAccount(); + await f.settle(tester); + expect(find.text('Back up your secret key?'), findsOneWidget); + expect(find.textContaining('does not undo'), findsOneWidget); + f.beforeUploadReturn = null; + await f.tap(tester, 'Back up and continue'); + expect((await f.store.read())!.backup!.pubkey, f.secondKeys.public); + expect( + (await f.storage.loadAll()).map((c) => c.nsec), + containsAll([f.keys.nsec, f.secondKeys.nsec]), + ); + await f.restart(tester); + await f.flow.signInAccount(); + await f.settle(tester); + expect(find.text('Back up your secret key?'), findsNothing); + expect(f.sent('/nsec-backups'), hasLength(2)); + } finally { + await f.dispose(tester); + } + }, f.client), + ); + }, skip: HostedEnvironment.authDomains.isEmpty); + journey( + 'web binding keeps an explicit identity choice without backup support', + (tester, f) async { + await f.initialize(localIdentity: false); + f.bound = null; + await f.mount(tester); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('buzz/hosted_hpke'), + (_) async => false, + ); + final nativeChannels = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform_views, + (call) async { + if (call.method == 'create') { + final id = (call.arguments as Map)['id']; + final channel = MethodChannel('buzz/ios_navigation_bar/$id'); + nativeChannels.add(channel); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (_) async => null, + ); + } + return null; + }, + ); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/url_launcher'), + (_) async => true, + ); + void openBindLink() { + f.container + .read(pendingDeepLinkProvider.notifier) + .open( + Uri( + scheme: 'buzz', + host: 'nostr-bind', + queryParameters: { + 'challenge_id': '12345678-1234-1234-1234-123456789012', + 'nonce': 'a' * 43, + 'verification_code': '123456', + 'audience': 'buzz:nostr-identity', + 'action': 'bind_nostr_identity', + 'protocol': 'buzz-nostr-identity', + 'version': '1', + 'origin': HostedEnvironment.origin.toString(), + 'expires_at': '2099-01-01T00:00:00Z', + 'return': 'browser_fragment_v1', + 'callback_url': HostedEnvironment.origin + .resolve('/buzz') + .toString(), + }, + ), + ); + } + + try { + openBindLink(); + await f.settle(tester); + expect( + find.text('Create my Buzz identity').hitTestable(), + findsOneWidget, + ); + expect( + find.text('Import existing identity').hitTestable(), + findsOneWidget, + ); + expect(await f.store.read(), isNull); + expect(f.bound, isNull); + expect(f.sent('/verify'), isEmpty); + await f.tap(tester, 'Import existing identity'); + await tester.enterText( + find.byType(TextField).hitTestable(), + f.secondKeys.nsec, + ); + await f.tap(tester, 'Preview identity'); + await f.tap(tester, 'Import this identity'); + expect(find.text('Link your identity?'), findsOneWidget); + expect(find.textContaining('No backup is uploaded'), findsOneWidget); + expect( + find.text('Receiving account: owner@example.com'), + findsOneWidget, + ); + expect(await f.store.read(), isNull); + expect(f.sent('/verify'), isEmpty); + expect(f.sent('/enrollment'), isEmpty); + expect(f.sent('/nsec-backups'), isEmpty); + await f.tap(tester, 'Cancel setup'); + expect(await f.store.read(), isNull); + expect(f.sent('/verify'), isEmpty); + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + openBindLink(); + await f.settle(tester); + await f.tap(tester, 'Create my Buzz identity'); + expect(f.flow.problem, HostedFlowProblem.unsupportedPlatform); + expect(await f.store.read(), isNull); + expect(f.bound, isNull); + // Once the OS supports sealing, creation still requires an explicit tap. + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('buzz/hosted_hpke'), + (_) async => true, + ); + await f.flow.retry(); + await f.settle(tester); + expect(await f.store.read(), isNull); + await f.tap(tester, 'Create my Buzz identity'); + final saved = (await f.store.read())!; + expect(f.bound, saved.pubkey); + expect( + saved.backup, + isNull, + ); // Web binding has never promised mobile backup. + expect(f.flow.stage, HostedFlowStage.browserReturned); + + // A returning account may already have a saved key from an interrupted + // setup. No adoption occurs, so the bind-only gate must ask for consent. + await f.flow.signOutAccount(); + f.subject = 'returning-owner'; + f.bound = null; + await f.store.create(f.secondKeys.nsec); + await f.flow.account.exchange( + code: 'synthetic-code', + verifier: 'v' * 43, + ); + debugDefaultTargetPlatformOverride = null; + await f.restart(tester); + f.requests.clear(); + openBindLink(); + await f.settle(tester); + expect(find.text('Link your identity?'), findsOneWidget); + expect(find.textContaining('No backup is uploaded'), findsOneWidget); + expect(find.text(fullNpub(f.secondKeys.public)!), findsOneWidget); + expect((await f.store.read())!.backup, isNull); + expect(f.bound, isNull); + expect(f.sent('/verify'), isEmpty); + await f.tap(tester, 'Cancel setup'); + expect((await f.store.read())!.pubkey, f.secondKeys.public); + expect(f.bound, isNull); + expect(f.sent('/verify'), isEmpty); + + // Cancellation and restart retain the key, but never retain approval. + await f.restart(tester); + openBindLink(); + await f.settle(tester); + expect(find.text('Link your identity?'), findsOneWidget); + expect(f.sent('/verify'), isEmpty); + await f.tap(tester, 'Link and continue'); + expect(f.bound, f.secondKeys.public); + expect(f.sent('/verify'), hasLength(1)); + expect(f.sent('/enrollment'), isEmpty); + expect(f.sent('/nsec-backups'), isEmpty); + expect((await f.store.read())!.backup, isNull); + expect(f.flow.stage, HostedFlowStage.browserReturned); + } finally { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + debugDefaultTargetPlatformOverride = null; + for (final channel in nativeChannels) { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ); + } + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform_views, + null, + ); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/url_launcher'), + null, + ); + } + }, + ); +} diff --git a/mobile/test/features/onboarding/hosted_journeys_test.dart b/mobile/test/features/onboarding/hosted_journeys_test.dart index 2e8cf5096b..bb7c0b98a2 100644 --- a/mobile/test/features/onboarding/hosted_journeys_test.dart +++ b/mobile/test/features/onboarding/hosted_journeys_test.dart @@ -289,6 +289,9 @@ void main() { release.complete(); f.beforeStatusReturn = null; await f.settle(tester); + expect(await f.store.read(), isNull); + expect(f.bound, isNull); + await f.tap(tester, 'Back up and continue'); expect(f.bound, f.keys.public); } final saved = await f.store.read(); @@ -315,6 +318,7 @@ void main() { } else { await f.tap(tester, 'Try again'); } + await f.tap(tester, 'Back up and continue'); expect((await f.store.read())!.pubkey, saved.pubkey); expect((await f.store.read())!.backup, isNotNull); if (entry == 'import') { @@ -341,115 +345,6 @@ void main() { }); } - journey( - 'web binding keeps an explicit identity choice without backup support', - (tester, f) async { - await f.initialize(localIdentity: false); - f.bound = null; - await f.mount(tester); - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - const MethodChannel('buzz/hosted_hpke'), - (_) async => false, - ); - final nativeChannels = []; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - SystemChannels.platform_views, - (call) async { - if (call.method == 'create') { - final id = (call.arguments as Map)['id']; - final channel = MethodChannel('buzz/ios_navigation_bar/$id'); - nativeChannels.add(channel); - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - channel, - (_) async => null, - ); - } - return null; - }, - ); - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - const MethodChannel('plugins.flutter.io/url_launcher'), - (_) async => true, - ); - try { - f.container - .read(pendingDeepLinkProvider.notifier) - .open( - Uri( - scheme: 'buzz', - host: 'nostr-bind', - queryParameters: { - 'challenge_id': '12345678-1234-1234-1234-123456789012', - 'nonce': 'a' * 43, - 'verification_code': '123456', - 'audience': 'buzz:nostr-identity', - 'action': 'bind_nostr_identity', - 'protocol': 'buzz-nostr-identity', - 'version': '1', - 'origin': HostedEnvironment.origin.toString(), - 'expires_at': '2099-01-01T00:00:00Z', - 'return': 'browser_fragment_v1', - 'callback_url': HostedEnvironment.origin - .resolve('/buzz') - .toString(), - }, - ), - ); - await f.settle(tester); - expect( - find.text('Create my Buzz identity').hitTestable(), - findsOneWidget, - ); - expect( - find.text('Import existing identity').hitTestable(), - findsOneWidget, - ); - expect(await f.store.read(), isNull); - expect(f.bound, isNull); - expect(f.sent('/verify'), isEmpty); - await f.tap(tester, 'Create my Buzz identity'); - expect(f.flow.problem, HostedFlowProblem.unsupportedPlatform); - expect(await f.store.read(), isNull); - expect(f.bound, isNull); - // Once the OS supports sealing, creation still requires an explicit tap. - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - const MethodChannel('buzz/hosted_hpke'), - (_) async => true, - ); - await f.flow.retry(); - await f.settle(tester); - expect(await f.store.read(), isNull); - await f.tap(tester, 'Create my Buzz identity'); - final saved = (await f.store.read())!; - expect(f.bound, saved.pubkey); - expect( - saved.backup, - isNull, - ); // Web binding has never promised mobile backup. - expect(f.flow.stage, HostedFlowStage.browserReturned); - } finally { - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(); - debugDefaultTargetPlatformOverride = null; - for (final channel in nativeChannels) { - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - channel, - null, - ); - } - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - SystemChannels.platform_views, - null, - ); - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - const MethodChannel('plugins.flutter.io/url_launcher'), - null, - ); - } - }, - ); - journey('linked account without backup imports its key without replacing it', ( tester, f, @@ -482,6 +377,8 @@ void main() { await tester.enterText(find.byType(TextField).hitTestable(), f.keys.nsec); await f.tap(tester, 'Preview identity'); await f.tap(tester, 'Import this identity'); + expect(await f.store.read(), isNull); + await f.tap(tester, 'Back up and continue'); expect((await f.store.read())!.pubkey, f.keys.public); expect((await f.store.read())!.backup, isNotNull); expect(f.requests.where((r) => r.url.path.endsWith('/recovery')), isEmpty); @@ -1170,6 +1067,7 @@ void main() { // still be durable; resuming must not replay an acknowledged backup. f.beforeUploadReturn = () => f.flow.leave(); await f.link(tester, 'one'); + await f.tap(tester, 'Back up and continue'); expect((await f.store.read())!.backup, isNotNull); f.beforeUploadReturn = null; // Production legal documents exceed the ordinary 64 KiB account limit. @@ -1286,6 +1184,7 @@ void main() { expect(f.sent('/nsec-backups'), isEmpty); expect(await f.store.read(), isNull); await f.tap(tester, fullNpub(f.keys.public)!); + await f.tap(tester, 'Back up and continue'); } final saved = (await f.store.read())!; expect( @@ -1337,6 +1236,7 @@ void main() { await f.flow.signInAccount(); await f.settle(tester); await f.tap(tester, fullNpub(f.keys.public)!); + await f.tap(tester, 'Back up and continue'); return original; } @@ -1350,6 +1250,7 @@ void main() { await f.restart(tester); await f.flow.signInAccount(); await f.settle(tester); + await f.tap(tester, 'Back up and continue'); // The saved choice skips selection and retries the same rejection. expect(f.flow.problem, HostedFlowProblem.requestRejected); await f.tap(tester, 'Choose a different identity'); @@ -1438,6 +1339,7 @@ void main() { await f.restart(tester); await f.flow.signInAccount(); await f.settle(tester); + await f.tap(tester, 'Back up and continue'); expect(f.flow.problem, HostedFlowProblem.requestRejected); expect(f.flow.canChooseDifferentIdentity, isTrue); keychain.sticky = null; @@ -1500,6 +1402,7 @@ void main() { ); await f.flow.retry(); await f.settle(tester); + await f.tap(tester, 'Back up and continue'); expect(f.flow.problem, HostedFlowProblem.requestRejected); expect(f.flow.canChooseDifferentIdentity, isFalse); expect(find.text('Choose a different identity'), findsNothing); @@ -1532,10 +1435,12 @@ void main() { ..bindStatus = 409; await f.flow.retry(); await f.settle(tester); + await f.tap(tester, 'Back up and continue'); expect(f.flow.canChooseDifferentIdentity, isTrue); f.challengeStatus = 409; await f.flow.retry(); await f.settle(tester); + await f.tap(tester, 'Back up and continue'); // The earlier attempt's rejection is not reused. expect(f.flow.problem, HostedFlowProblem.requestRejected); expect(f.flow.canChooseDifferentIdentity, isFalse); @@ -1580,7 +1485,7 @@ void main() { await f.tap(tester, 'Try again'); expect(f.flow.stage, HostedFlowStage.idle); expect(f.probes, isEmpty); - expect(find.text('Signed in to BuilderLab'), findsOneWidget); + expect(find.text('Signed in as owner@example.com'), findsOneWidget); expect(find.text('Scan a QR code'), findsOneWidget); expect(find.text('Use pairing code'), findsNothing); f.rows.addAll([f.row('one'), f.row('two')]); diff --git a/mobile/test/features/pairing/pairing_hosted_handoff_test.dart b/mobile/test/features/pairing/pairing_hosted_handoff_test.dart index 048573da39..e01ae2f4d3 100644 --- a/mobile/test/features/pairing/pairing_hosted_handoff_test.dart +++ b/mobile/test/features/pairing/pairing_hosted_handoff_test.dart @@ -40,13 +40,18 @@ void main() { final settled = Completer(); f.flow.addListener(() { if ((f.flow.stage == HostedFlowStage.complete || - f.flow.stage == HostedFlowStage.blocked) && + f.flow.stage == HostedFlowStage.blocked || + f.flow.stage == HostedFlowStage.confirmBackup) && !settled.isCompleted) { settled.complete(); } }); await container.read(pairingProvider.notifier).pair(_payload(f.keys)); await settled.future.timeout(const Duration(seconds: 2)); + if (!backup) { + expect(f.flow.stage, HostedFlowStage.confirmBackup); + await f.flow.confirmBackup!(); + } expect(legacyProbes, 0); expect(f.admitted, backup ? ['paired.communities.buzz.xyz'] : isEmpty); expect((await f.store.read())?.pubkey, f.keys.public); diff --git a/mobile/test/helpers/hosted_journey_fixture.dart b/mobile/test/helpers/hosted_journey_fixture.dart index 9203b74465..310ea43d60 100644 --- a/mobile/test/helpers/hosted_journey_fixture.dart +++ b/mobile/test/helpers/hosted_journey_fixture.dart @@ -83,6 +83,7 @@ class HostedJourneyFixture { bool denyAssertion = false; bool refuseUpgrade = false; String subject = 'journey-owner'; + String? accountEmail = 'owner@example.com'; /// An organization realm, as an internal build's BUZZ_BUILDERLAB_URL and /// BUZZ_BUILDERLAB_RELAYS configure. Enable before [mount]. @@ -158,6 +159,7 @@ class HostedJourneyFixture { if (expiredSession) return http.Response('{}', 401); result = { 'subject': subject, + 'email': accountEmail, 'expires_at': expiry, 'account_status': needsAccountSetup ? 'ACCOUNT_STATUS_NEEDS_DETAILS' diff --git a/mobile/test/shared/hosted/account_identity_store_test.dart b/mobile/test/shared/hosted/account_identity_store_test.dart index 6875cc6341..6a5e45d8a7 100644 --- a/mobile/test/shared/hosted/account_identity_store_test.dart +++ b/mobile/test/shared/hosted/account_identity_store_test.dart @@ -204,6 +204,7 @@ void main() { final storage = UncertainStorage(); final store = AccountIdentityStore(storage: storage); var authorizations = 0; + var generatedHere = 0; final receipt = BackupReceipt( backend: 'https://app.builderlab.xyz', subject: 'a', @@ -212,6 +213,7 @@ void main() { ); Future write() => generate ? store.generateIfAbsent( + onGenerated: () => generatedHere++, authorize: () async { authorizations++; return true; @@ -223,6 +225,8 @@ void main() { final retried = (await write())!; expect(retried.pubkey, persisted.pubkey); expect(authorizations, generate ? 1 : 0); + // An uncertain write or an existing slot is never fresh creation consent. + expect(generatedHere, 0); if (!generate) { expect(retried.backup?.backupId, 'one'); final other = nostr.Keys('2'.padLeft(64, '0')); diff --git a/mobile/test/shared/hosted/device_identity_reconciliation_test.dart b/mobile/test/shared/hosted/device_identity_reconciliation_test.dart index 870abe8555..c4418298b9 100644 --- a/mobile/test/shared/hosted/device_identity_reconciliation_test.dart +++ b/mobile/test/shared/hosted/device_identity_reconciliation_test.dart @@ -14,8 +14,11 @@ class InventoryAccount extends BuilderlabAccountClient { Map recovery = {}; final calls = []; @override - BuilderlabAccount get account => - BuilderlabAccount(subject: 'fixture', expiresAt: DateTime.utc(2100)); + BuilderlabAccount get account => BuilderlabAccount( + subject: 'fixture', + email: 'owner@example.com', + expiresAt: DateTime.utc(2100), + ); @override Future restore() async => account; @override diff --git a/mobile/test/shared/hosted/hosted_communities_flow_test.dart b/mobile/test/shared/hosted/hosted_communities_flow_test.dart index 0b420e672b..3a4a6aaf78 100644 --- a/mobile/test/shared/hosted/hosted_communities_flow_test.dart +++ b/mobile/test/shared/hosted/hosted_communities_flow_test.dart @@ -52,6 +52,10 @@ void main() { if (failure == 'binding') f.bound = '2' * 64; final before = await const FlutterSecureStorage().readAll(); await f.flow.signInAccount(); + if (failure == 'backup') { + expect(f.flow.stage, HostedFlowStage.confirmBackup); + await f.flow.confirmBackup!(); + } expect(f.flow.stage, HostedFlowStage.blocked); if (failure == 'binding') { expect(f.flow.problem, HostedFlowProblem.identityMismatch); diff --git a/mobile/test/shared/hosted/identity_choice_lifecycle_test.dart b/mobile/test/shared/hosted/identity_choice_lifecycle_test.dart index 7934f80a72..b2b8470e82 100644 --- a/mobile/test/shared/hosted/identity_choice_lifecycle_test.dart +++ b/mobile/test/shared/hosted/identity_choice_lifecycle_test.dart @@ -97,6 +97,8 @@ void main() { final choosing = flow.chooseIdentity(second.public); expect(flow.identityChoices, isEmpty); await choosing; + expect(flow.stage, HostedFlowStage.confirmBackup); + await flow.confirmBackup!(); expect(flow.problem, HostedFlowProblem.denied); final completedCalls = List.of(account.calls); await flow.chooseIdentity(first.public);