From 952e6fc609e80395cb433661ba66b86a2bd06543 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 15 Oct 2026 15:42:24 -0700 Subject: [PATCH] feat(mobile): reconcile saved identities without replacing keys Inventory current and legacy device keys without migration. Reuse unambiguous identities, require explicit selection for conflicts, and authorize fresh generation only after local and cloud absence checks. Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Signed-off-by: Wes --- .../onboarding/hosted_connect_page.dart | 20 ++ mobile/lib/hosted_app_gate.dart | 10 +- .../shared/hosted/account_identity_store.dart | 3 + .../shared/hosted/device_identity_source.dart | 82 +++++++ mobile/lib/shared/hosted/hosted_flow.dart | 31 ++- .../hosted/hosted_identity_service.dart | 79 ++++++- .../onboarding/identity_choice_test.dart | 122 +++++++++++ .../device_identity_reconciliation_test.dart | 206 ++++++++++++++++++ .../test/shared/hosted/hosted_flow_test.dart | 5 + .../hosted/hosted_identity_service_test.dart | 2 +- 10 files changed, 547 insertions(+), 13 deletions(-) create mode 100644 mobile/lib/shared/hosted/device_identity_source.dart create mode 100644 mobile/test/features/onboarding/identity_choice_test.dart create mode 100644 mobile/test/shared/hosted/device_identity_reconciliation_test.dart diff --git a/mobile/lib/features/onboarding/hosted_connect_page.dart b/mobile/lib/features/onboarding/hosted_connect_page.dart index 40644e3463..37a7a7958a 100644 --- a/mobile/lib/features/onboarding/hosted_connect_page.dart +++ b/mobile/lib/features/onboarding/hosted_connect_page.dart @@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/hosted/hosted_flow.dart'; import '../../shared/hosted/hosted_flow_problem_message.dart'; +import '../../shared/utils/string_utils.dart'; import '../../shared/theme/grid.dart'; import '../../shared/theme/theme_extensions.dart'; @@ -24,6 +25,8 @@ class HostedConnectPage extends HookConsumerWidget { final message = switch (flow.stage) { HostedFlowStage.idle => 'Continue connecting your account.', HostedFlowStage.working => 'Connecting your account…', + HostedFlowStage.chooseIdentity => + 'Choose the identity to connect to Builderlab. Existing device and cloud keys will be kept.', HostedFlowStage.signIn => 'Complete sign-in in your browser.', HostedFlowStage.browserReturned => 'Continue setup in your browser. Open your community in Buzz when it is ready.', @@ -56,9 +59,26 @@ class HostedConnectPage extends HookConsumerWidget { children: [ Text(message, style: context.textTheme.titleLarge), const SizedBox(height: Grid.sm), + for (final choice in flow.identityChoices) + Padding( + padding: const EdgeInsets.only(bottom: Grid.sm), + child: OutlinedButton( + onPressed: () => flow.chooseIdentity(choice.pubkey), + child: Padding( + padding: const EdgeInsets.all(Grid.sm), + child: Column( + children: [ + Text(choice.label), + Text(fullNpub(choice.pubkey) ?? choice.pubkey), + ], + ), + ), + ), + ), if (working) const Center(child: CircularProgressIndicator()), if (!working && + flow.stage != HostedFlowStage.chooseIdentity && flow.stage != HostedFlowStage.complete && flow.stage != HostedFlowStage.browserReturned && flow.canRetry) diff --git a/mobile/lib/hosted_app_gate.dart b/mobile/lib/hosted_app_gate.dart index 410264acfc..56a5e615a5 100644 --- a/mobile/lib/hosted_app_gate.dart +++ b/mobile/lib/hosted_app_gate.dart @@ -14,6 +14,7 @@ import 'shared/hosted/builderlab_account.dart'; import 'shared/hosted/hosted_admission.dart'; import 'shared/hosted/hosted_flow.dart'; import 'shared/hosted/hosted_identity_service.dart'; +import 'shared/hosted/device_identity_source.dart'; import 'shared/hosted/hosted_environment.dart'; // Set by a community-entry surface while it owns setup presentation. @@ -37,7 +38,11 @@ final hostedFlowProvider = Provider((ref) { account: account, browser: browser, store: store, - identity: HostedIdentityService(account: account, store: store), + identity: HostedIdentityService( + account: account, + store: store, + deviceIdentities: DeviceIdentitySource(), + ), enterCommunity: (link, subject, pubkey, isCurrent) async { final relay = switch (link) { InviteDeepLink(:final relayUrl) => relayUrl, @@ -192,7 +197,8 @@ class HostedAppGate extends HookConsumerWidget { fit: StackFit.expand, children: [ child, - if (visible && !inlineEntry) + if (visible && + (!inlineEntry || flow.stage == HostedFlowStage.chooseIdentity)) HostedConnectPage( flow: flow, onClose: () => unawaited(flow.leave()), diff --git a/mobile/lib/shared/hosted/account_identity_store.dart b/mobile/lib/shared/hosted/account_identity_store.dart index 0f50ad0d3f..982b03423b 100644 --- a/mobile/lib/shared/hosted/account_identity_store.dart +++ b/mobile/lib/shared/hosted/account_identity_store.dart @@ -204,6 +204,9 @@ class AccountIdentityStore { } } + /// Validate a candidate without exposing signing material to the UI. + static String publicKeyForNsec(String nsec) => _keys(nsec).public; + static nostr.Keys _keys(String nsec) { try { final decoded = nostr.Nip19.decode(payload: nsec); diff --git a/mobile/lib/shared/hosted/device_identity_source.dart b/mobile/lib/shared/hosted/device_identity_source.dart new file mode 100644 index 0000000000..709eba840b --- /dev/null +++ b/mobile/lib/shared/hosted/device_identity_source.dart @@ -0,0 +1,82 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import 'account_identity_store.dart'; + +/// Public identity choices; never contains private signing material. +class DeviceIdentityChoice { + const DeviceIdentityChoice(this.pubkey, this.label); + final String pubkey; + final String label; +} + +/// Read-only inventory, including legacy records hidden by a newer community list. +/// Do not call CommunityStorage.loadAll here: it migrates and deletes records. +class DeviceIdentitySource { + DeviceIdentitySource({FlutterSecureStorage? storage}) + : _storage = storage ?? const FlutterSecureStorage(); + final FlutterSecureStorage _storage; + + Future> choices() async => [ + for (final entry in (await _read()).entries) + DeviceIdentityChoice(entry.key, entry.value.label), + ]; + + /// Reread and validate the chosen key immediately before create-only adoption. + Future adopt( + String pubkey, + AccountIdentityStore store, { + required void Function() checkCurrent, + }) async { + final candidate = (await _read())[pubkey]; + if (candidate == null) throw AccountIdentityFailure.absent; + checkCurrent(); + return store.create(candidate.nsec, checkCurrent: checkCurrent); + } + + Future> _read() async { + try { + final result = {}; + void add(dynamic nsec, dynamic advertised, String label) { + if (nsec == null) { + // An identity reference without its secret is not authoritative absence. + if (advertised != null) throw AccountIdentityFailure.unreadable; + return; + } + if (nsec is! String) throw AccountIdentityFailure.unreadable; + final pubkey = AccountIdentityStore.publicKeyForNsec(nsec); + if (advertised != null && advertised != pubkey) { + throw AccountIdentityFailure.unreadable; + } + final previous = result[pubkey]; + result[pubkey] = ( + nsec: nsec, + label: previous == null ? label : '${previous.label}, $label', + ); + } + + for (final key in ['buzz_communities', 'buzz_workspaces']) { + final raw = await _storage.read(key: key); + if (raw == null) continue; + final records = jsonDecode(raw) as List; + for (final record in records) { + final value = record as Map; + add( + value['nsec'], + value['pubkey'], + value['name'] as String? ?? 'Saved community', + ); + } + } + add( + await _storage.read(key: 'buzz_nsec'), + await _storage.read(key: 'buzz_pubkey'), + 'Previous device identity', + ); + return result; + } catch (_) { + throw AccountIdentityFailure.unreadable; + } + } +} diff --git a/mobile/lib/shared/hosted/hosted_flow.dart b/mobile/lib/shared/hosted/hosted_flow.dart index 89134edf82..f02b309f2e 100644 --- a/mobile/lib/shared/hosted/hosted_flow.dart +++ b/mobile/lib/shared/hosted/hosted_flow.dart @@ -5,6 +5,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../deeplink/deep_link.dart'; import 'account_identity_store.dart'; +import 'device_identity_source.dart'; import 'browser_auth.dart'; import 'builderlab_account.dart'; import 'hosted_identity_service.dart'; @@ -17,6 +18,7 @@ enum HostedFlowStage { idle, working, signIn, + chooseIdentity, blocked, browserReturned, complete, @@ -71,6 +73,7 @@ class HostedFlow extends ChangeNotifier { final Future Function(Uri) _openWeb; HostedFlowStage stage = HostedFlowStage.idle; HostedFlowProblem? problem; + List identityChoices = const []; BuzzDeepLink? _target; bool accountOnly = false; Future _tail = Future.value(); @@ -222,12 +225,26 @@ class HostedFlow extends ChangeNotifier { } } - Future _continue(int generation) async { + /// Resume only an offered public identity; reread local and cloud facts first. + Future chooseIdentity(String pubkey) { + if (stage != HostedFlowStage.chooseIdentity || + !identityChoices.any((item) => item.pubkey == pubkey)) { + return Future.value(); + } + final generation = ++_generation; + _set(HostedFlowStage.working); + return _enqueue(() => _continue(generation, selectedPubkey: pubkey)); + } + + Future _continue(int generation, {String? selectedPubkey}) async { final target = _target; final session = account.account; if (target == null || session == null) throw AccountFailure.signedOut; if (!_acceptTarget(target)) return; - var resolved = await identity.resolve(); + var resolved = await identity.resolve( + isCurrent: () => !_disposed && generation == _generation, + selectedPubkey: selectedPubkey, + ); if (target is! NostrBindDeepLink) { for (var step = 0; step < 2; step++) { if (_disposed || generation != _generation) return; @@ -278,10 +295,17 @@ class HostedFlow extends ChangeNotifier { break; } if (_disposed || generation != _generation) return; - resolved = await identity.resolve(); + resolved = await identity.resolve( + isCurrent: () => !_disposed && generation == _generation, + ); } } if (generation != _generation || _disposed) return; + if (resolved.state == HostedIdentityState.needsSelection) { + identityChoices = List.unmodifiable(resolved.choices); + _set(HostedFlowStage.chooseIdentity); + return; + } final pubkey = resolved.pubkey; if (target is NostrBindDeepLink && pubkey != null && @@ -402,6 +426,7 @@ class HostedFlow extends ChangeNotifier { void _set(HostedFlowStage next, [HostedFlowProblem? failure]) { if (_disposed) return; + if (next != HostedFlowStage.chooseIdentity) identityChoices = const []; stage = next; problem = failure; notifyListeners(); diff --git a/mobile/lib/shared/hosted/hosted_identity_service.dart b/mobile/lib/shared/hosted/hosted_identity_service.dart index 64c6b7e221..5153c38c38 100644 --- a/mobile/lib/shared/hosted/hosted_identity_service.dart +++ b/mobile/lib/shared/hosted/hosted_identity_service.dart @@ -4,9 +4,11 @@ import 'package:nostr/nostr.dart' as nostr; import 'account_identity_store.dart'; import 'builderlab_account.dart'; +import 'device_identity_source.dart'; enum HostedIdentityState { ready, + needsSelection, needsBinding, needsBackup, mismatch, @@ -15,9 +17,14 @@ enum HostedIdentityState { } class HostedIdentityResult { - const HostedIdentityResult(this.state, {this.pubkey}); + const HostedIdentityResult( + this.state, { + this.pubkey, + this.choices = const [], + }); final HostedIdentityState state; final String? pubkey; + final List choices; } /// Resolves identity safely. Generation requires a separately verified @@ -28,22 +35,30 @@ class HostedIdentityService { required this.account, required this.store, this.canGenerateAfterLegacyReconciliation, + this.deviceIdentities, }); /// Return true only when legacy community identities have been reconciled. /// Must not call AccountIdentityStore: invoked under its writer lock. final Future Function()? canGenerateAfterLegacyReconciliation; + final DeviceIdentitySource? deviceIdentities; final BuilderlabAccountClient account; final AccountIdentityStore store; Future _tail = Future.value(); - Future resolve({bool Function()? isCurrent}) { - final result = _tail.then((_) => _resolve(isCurrent)); + Future resolve({ + bool Function()? isCurrent, + String? selectedPubkey, + }) { + final result = _tail.then((_) => _resolve(isCurrent, selectedPubkey)); _tail = result.then((_) {}, onError: (_) {}); return result; } - Future _resolve(bool Function()? isCurrent) async { + Future _resolve( + bool Function()? isCurrent, + String? selectedPubkey, + ) async { void checkCurrent() { if (isCurrent != null && !isCurrent()) throw AccountFailure.unavailable; } @@ -64,7 +79,7 @@ class HostedIdentityService { } else { throw AccountFailure.invalidResponse; } - if (local != null) return _result(local, bound, subject); + if (local != null && bound != null) return _result(local, bound, subject); final status = await account.post('/v1/buzz/nsec-backups/status', {}); _sameAccount(subject); @@ -78,13 +93,50 @@ class HostedIdentityService { : _pubkey(status['current_nostr_pubkey']); final statusKeys = listed.map(_pubkey).toSet(); if (statusBound != bound) throw AccountFailure.invalidResponse; + if (local != null) { + if (statusKeys.any((key) => key != local.pubkey)) { + return const HostedIdentityResult(HostedIdentityState.mismatch); + } + return _result(local, bound, subject); + } + final device = + await deviceIdentities?.choices() ?? const []; + _sameAccount(subject); + checkCurrent(); + if (statusKeys.isEmpty && + (bound == null || device.any((item) => item.pubkey == bound))) { + final eligible = device + .where((item) => bound == null || item.pubkey == bound) + .toList(); + if (eligible.isNotEmpty) { + final chosen = + selectedPubkey ?? + (eligible.length == 1 ? eligible.single.pubkey : null); + if (!eligible.any((item) => item.pubkey == chosen)) { + return HostedIdentityResult( + HostedIdentityState.needsSelection, + choices: eligible, + ); + } + final saved = await deviceIdentities!.adopt( + chosen!, + store, + checkCurrent: () { + _sameAccount(subject); + checkCurrent(); + }, + ); + return _result(saved, bound, subject); + } + } if (statusBound == null && statusKeys.isEmpty) { final generated = await store.generateIfAbsent( authorize: () async { _sameAccount(subject); checkCurrent(); - final reconciled = - await canGenerateAfterLegacyReconciliation?.call() ?? false; + final reconciled = deviceIdentities != null + ? (await deviceIdentities!.choices()).isEmpty + : await canGenerateAfterLegacyReconciliation?.call() ?? false; _sameAccount(subject); checkCurrent(); return reconciled; @@ -165,6 +217,19 @@ class HostedIdentityService { if (bound != null && bound != candidate.keys.public) { return const HostedIdentityResult(HostedIdentityState.mismatch); } + if (device.isNotEmpty && + !device.any((item) => item.pubkey == candidate.keys.public) && + selectedPubkey != candidate.keys.public) { + return HostedIdentityResult( + HostedIdentityState.needsSelection, + choices: [ + DeviceIdentityChoice( + candidate.keys.public, + 'Builderlab backup (keep existing community identities)', + ), + ], + ); + } _sameAccount(subject); checkCurrent(); final saved = await store.create( diff --git a/mobile/test/features/onboarding/identity_choice_test.dart b/mobile/test/features/onboarding/identity_choice_test.dart new file mode 100644 index 0000000000..9ee5939c00 --- /dev/null +++ b/mobile/test/features/onboarding/identity_choice_test.dart @@ -0,0 +1,122 @@ +import 'dart:convert'; + +import 'package:buzz/features/onboarding/hosted_connect_page.dart'; +import 'package:buzz/hosted_app_gate.dart'; +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; +import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:buzz/shared/hosted/browser_auth.dart'; +import 'package:buzz/shared/hosted/device_identity_source.dart'; +import 'package:buzz/shared/hosted/hosted_flow.dart'; +import 'package:buzz/shared/hosted/hosted_identity_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../../shared/hosted/device_identity_reconciliation_test.dart' + show InventoryAccount; + +class _Browser extends BrowserAuth { + @override + Future initialize() async {} + @override + Future?> retainedIntent() async => null; + @override + Future clearIntent() async {} + @override + Future cancel() async {} +} + +void main() { + testWidgets( + 'inline link flow shows identity choice; leave and explicit selection preserve keys', + (tester) async { + final first = nostr.Keys('1'.padLeft(64, '0')); + final second = nostr.Keys('2'.padLeft(64, '0')); + final raw = jsonEncode([ + {'name': 'First community', 'nsec': first.nsec, 'pubkey': first.public}, + { + 'name': 'Second community', + 'nsec': second.nsec, + 'pubkey': second.public, + }, + ]); + FlutterSecureStorage.setMockInitialValues({'buzz_communities': raw}); + PendingDeepLinkNotifier.debugUriStreamOverride = + const Stream.empty(); + final account = InventoryAccount(); + final store = AccountIdentityStore(); + final browser = _Browser(); + var admitted = false; + final flow = HostedFlow( + account: account, + browser: browser, + store: store, + identity: HostedIdentityService( + account: account, + store: store, + deviceIdentities: DeviceIdentitySource(), + ), + enterCommunity: (_, _, _, _) async { + admitted = true; + }, + ); + final container = ProviderContainer( + overrides: [hostedFlowProvider.overrideWithValue(flow)], + ); + addTearDown(() async { + PendingDeepLinkNotifier.debugUriStreamOverride = null; + container.dispose(); + flow.dispose(); + await browser.dispose(); + account.dispose(); + }); + container.read(hostedInlineEntryProvider.notifier).setActive(true); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp( + home: HostedAppGate( + authenticated: false, + child: Scaffold(body: Text('Link form')), + ), + ), + ), + ); + await tester.pumpAndSettle(); + const target = AddCommunityDeepLink( + relayUrl: 'wss://team.communities.buzz.xyz', + name: 'Team', + ); + await flow.start(target); + await tester.pumpAndSettle(); + expect(find.byType(HostedConnectPage), findsOneWidget); + expect(find.text('First community'), findsOneWidget); + expect(find.text('Second community'), findsOneWidget); + expect(await store.read(), isNull); + await tester.tap(find.text('Leave setup')); + await tester.pumpAndSettle(); + expect(flow.stage, HostedFlowStage.idle); + expect(await store.read(), isNull); + await flow.start(target); + await tester.pumpAndSettle(); + await tester.tap(find.text('Second community')); + await tester.pumpAndSettle(); + expect((await store.read())!.pubkey, second.public); + expect( + await const FlutterSecureStorage().read(key: 'buzz_communities'), + raw, + ); + expect(admitted, isFalse); + // Fixture refuses binding: choosing a key never bypasses remaining gates. + expect(flow.problem, HostedFlowProblem.denied); + expect( + account.calls.where((path) => path.endsWith('/challenge')).length, + 1, + ); + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/mobile/test/shared/hosted/device_identity_reconciliation_test.dart b/mobile/test/shared/hosted/device_identity_reconciliation_test.dart new file mode 100644 index 0000000000..998fd4863b --- /dev/null +++ b/mobile/test/shared/hosted/device_identity_reconciliation_test.dart @@ -0,0 +1,206 @@ +import 'dart:convert'; + +import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/device_identity_source.dart'; +import 'package:buzz/shared/hosted/hosted_identity_service.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; + +class InventoryAccount extends BuilderlabAccountClient { + String? bound; + List backups = []; + Map recovery = {}; + final calls = []; + @override + BuilderlabAccount get account => + BuilderlabAccount(subject: 'fixture', expiresAt: DateTime.utc(2100)); + @override + Future restore() async => account; + @override + Future> currentIdentity() async => { + if (bound != null) 'identity': {'pubkey_hex': bound}, + }; + @override + Future> post( + String path, + Map body, + ) async { + calls.add(path); + if (path.endsWith('/status')) { + return {'current_nostr_pubkey': bound, 'backup_nostr_pubkeys': backups}; + } + if (path.endsWith('/recovery')) return recovery; + throw AccountFailure.denied; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final first = nostr.Keys('1'.padLeft(64, '0')); + final second = nostr.Keys('2'.padLeft(64, '0')); + const secure = FlutterSecureStorage(); + late InventoryAccount account; + late AccountIdentityStore store; + late HostedIdentityService service; + Map record(nostr.Keys key, String name) => { + 'nsec': key.nsec, + 'pubkey': key.public, + 'name': name, + }; + setUp(() { + FlutterSecureStorage.setMockInitialValues({}); + account = InventoryAccount(); + store = AccountIdentityStore(); + service = HostedIdentityService( + account: account, + store: store, + deviceIdentities: DeviceIdentitySource(), + ); + }); + tearDown(() => account.dispose()); + + test( + 'single legacy key is reused without migrating or deleting any device record', + () async { + final raw = jsonEncode([record(first, 'Old community')]); + await secure.write(key: 'buzz_workspaces', value: raw); + final result = await service.resolve(); + expect(result.pubkey, first.public); + expect(result.state, HostedIdentityState.needsBinding); + expect(await secure.read(key: 'buzz_workspaces'), raw); + expect(await secure.read(key: 'buzz_communities'), isNull); + expect(account.calls, ['/v1/buzz/nsec-backups/status']); + }, + ); + + test( + 'multiple current and hidden legacy keys require choice; no writes before choice', + () async { + await secure.write( + key: 'buzz_communities', + value: jsonEncode([record(first, 'Current')]), + ); + await secure.write(key: 'buzz_nsec', value: second.nsec); + final before = await secure.readAll(); + final pending = await service.resolve(); + expect(pending.state, HostedIdentityState.needsSelection); + expect(pending.choices.map((item) => item.pubkey), [ + first.public, + second.public, + ]); + expect(await secure.readAll(), before); + final selected = await service.resolve(selectedPubkey: second.public); + expect(selected.pubkey, second.public); + for (final entry in before.entries) { + expect(await secure.read(key: entry.key), entry.value); + } + expect(account.calls.every((path) => path.endsWith('/status')), isTrue); + expect( + (await service.resolve(selectedPubkey: first.public)).pubkey, + second.public, + ); + }, + ); + + test( + 'duplicate identities auto-resolve and truly empty inventory generates once', + () async { + final firstResult = await service.resolve(); + expect(firstResult.state, HostedIdentityState.needsBinding); + expect((await service.resolve()).pubkey, firstResult.pubkey); + FlutterSecureStorage.setMockInitialValues({ + 'buzz_nsec': first.nsec, + 'buzz_communities': jsonEncode([record(first, 'Same identity')]), + }); + expect((await service.resolve()).pubkey, first.public); + }, + ); + + test( + 'corrupt, incomplete and mismatched legacy records never authorize generation', + () async { + for (final data in [ + {'buzz_workspaces': '{broken'}, + {'buzz_nsec': ''}, + {'buzz_pubkey': first.public}, + { + 'buzz_communities': jsonEncode([ + {'nsec': first.nsec, 'pubkey': second.public}, + ]), + }, + ]) { + FlutterSecureStorage.setMockInitialValues(data); + await expectLater( + service.resolve(), + throwsA(AccountIdentityFailure.unreadable), + ); + expect(await secure.readAll(), data); + } + }, + ); + + test( + 'cloud backup differing from device requires consent and preserves old key', + () async { + await secure.write(key: 'buzz_nsec', value: first.nsec); + account.bound = second.public; + account.backups = [second.public]; + account.recovery = { + 'backups': [ + { + 'backup_id': 'fixture-backup', + 'nostr_pubkey': second.public, + 'secret_key': base64UrlEncode([ + ...List.filled(31, 0), + 2, + ]).replaceAll('=', ''), + }, + ], + 'failed_backup_ids': [], + }; + expect( + (await service.resolve()).state, + HostedIdentityState.needsSelection, + ); + expect(await store.read(), isNull); + expect( + (await service.resolve(selectedPubkey: first.public)).state, + HostedIdentityState.needsSelection, + ); + expect(await store.read(), isNull); + expect( + (await service.resolve(selectedPubkey: second.public)).state, + HostedIdentityState.ready, + ); + expect(await secure.read(key: 'buzz_nsec'), first.nsec); + expect( + account.calls.every( + (path) => path.endsWith('/status') || path.endsWith('/recovery'), + ), + isTrue, + ); + }, + ); + + test( + 'existing account slot cannot overwrite or bind over a different cloud backup', + () async { + await store.create(first.nsec); + final before = await secure.readAll(); + account.backups = [second.public]; + expect((await service.resolve()).state, HostedIdentityState.mismatch); + expect(await secure.readAll(), before); + }, + ); + + test('cancelled resolution cannot adopt or generate', () async { + await secure.write(key: 'buzz_nsec', value: first.nsec); + await expectLater( + service.resolve(isCurrent: () => false), + throwsA(AccountFailure.unavailable), + ); + expect(await store.read(), isNull); + }); +} diff --git a/mobile/test/shared/hosted/hosted_flow_test.dart b/mobile/test/shared/hosted/hosted_flow_test.dart index 768493c545..2dad32bfff 100644 --- a/mobile/test/shared/hosted/hosted_flow_test.dart +++ b/mobile/test/shared/hosted/hosted_flow_test.dart @@ -187,6 +187,11 @@ void main() { data = { 'identity': bound ? {'pubkey_hex': keys.public} : null, }; + } else if (path.endsWith('/status')) { + data = { + 'current_nostr_pubkey': bound ? keys.public : null, + 'backup_nostr_pubkeys': [], + }; } else if (path.endsWith('/challenge')) { effects.add('challenge'); data = { diff --git a/mobile/test/shared/hosted/hosted_identity_service_test.dart b/mobile/test/shared/hosted/hosted_identity_service_test.dart index 78b44eca99..bf17d33d2e 100644 --- a/mobile/test/shared/hosted/hosted_identity_service_test.dart +++ b/mobile/test/shared/hosted/hosted_identity_service_test.dart @@ -186,7 +186,7 @@ void main() { final first = await service.resolve(); expect(first.state, HostedIdentityState.needsBinding); expect((await service.resolve()).pubkey, first.pubkey); - expect(calls.where((p) => p.endsWith('/status')).length, 1); + expect(calls.where((p) => p.endsWith('/status')).length, 2); expect(calls.any((p) => p.endsWith('/recovery')), isFalse); }, );