diff --git a/mobile/lib/shared/hosted/account_identity_store.dart b/mobile/lib/shared/hosted/account_identity_store.dart index 76e8f39b91..47e7b00014 100644 --- a/mobile/lib/shared/hosted/account_identity_store.dart +++ b/mobile/lib/shared/hosted/account_identity_store.dart @@ -118,6 +118,9 @@ class AccountIdentityStore { 'buzz_account_identity_${HostedEnvironment.origin.host.replaceAll('.', '_')}_v1'; static final _queue = SerialQueue(); final FlutterSecureStorage _storage; + // Public lookup hints from the choices shown by this store. Always reread + // and validate the record; never retain signing material across selection. + final _savedLocations = {}; Future _serialized(Future Function() operation) { final result = _queue.run(() => operation()); @@ -215,10 +218,17 @@ class AccountIdentityStore { Future> identities() => _serialized(() async { final keys = await _identityKeys(); final identities = {}; + final locations = {}; for (final key in keys) { final record = await _atKey(key)._read(); - if (record != null) identities[record.pubkey] = record.snapshot; + if (record != null) { + identities[record.pubkey] = record.snapshot; + locations[record.pubkey] = key; + } } + _savedLocations + ..clear() + ..addAll(locations); return identities.values.toList(); }); @@ -236,6 +246,11 @@ class AccountIdentityStore { Future _savedNsec(String pubkey) => _serialized(() async { try { + final location = _savedLocations[pubkey]; + if (location != null) { + final record = await _atKey(location)._read(); + if (record?.pubkey == pubkey) return record!.nsec; + } final keys = await _identityKeys(pubkey: pubkey); for (final key in keys) { final record = await _atKey(key)._read(); diff --git a/mobile/test/features/onboarding/hosted_journeys_test.dart b/mobile/test/features/onboarding/hosted_journeys_test.dart index c65c2c84dc..450219f7f6 100644 --- a/mobile/test/features/onboarding/hosted_journeys_test.dart +++ b/mobile/test/features/onboarding/hosted_journeys_test.dart @@ -553,6 +553,9 @@ void main() { 'my-community.example', ); await f.tap(tester, 'Continue'); + // Choices loaded successfully; enumeration breaks before selection. + // The account-scoped key remains directly readable throughout admission. + f.identityStorage.failEnumeration = true; await f.tap(tester, 'Saved identity'); final saved = (await f.storage.loadAll()).single; expect(saved.relayUrl, 'wss://my-community.example'); @@ -560,6 +563,7 @@ void main() { expect(await f.storage.loadActiveId(), saved.id); expect(f.probes, hasLength(1)); expect(f.sent('/claim'), isEmpty); + f.identityStorage.failEnumeration = false; expect( (await const FlutterSecureStorage().readAll()).keys.where( (key) => key.startsWith('buzz_pending_self_hosted_'), diff --git a/mobile/test/helpers/hosted_journey_fixture.dart b/mobile/test/helpers/hosted_journey_fixture.dart index b2ca016fb8..60ba65fe74 100644 --- a/mobile/test/helpers/hosted_journey_fixture.dart +++ b/mobile/test/helpers/hosted_journey_fixture.dart @@ -40,6 +40,7 @@ class HostedJourneyFixture { final secondKeys = nostr.Keys('2'.padLeft(64, '0')); AccountIdentityStore get store => AccountIdentityStore(subject: subject); final storage = CommunityStorage(); + final identityStorage = JourneyIdentityStorage(); final requests = []; Future Function()? beforeBrowserReturn; Future Function()? beforeEnrollmentReturn; @@ -348,6 +349,7 @@ class HostedJourneyFixture { ), selfHostedEntryProvider.overrideWithValue( SelfHostedEntry( + accountStore: AccountIdentityStore(storage: identityStorage), probe: (community) async { probes.add(community); if (community.pubkey == refusedSelfHostedPubkey) { @@ -452,6 +454,25 @@ class HostedJourneyFixture { } } +/// Keeps direct reads working when an unrelated secure-storage entry breaks +/// enumeration, as can happen on Android after the identity picker is shown. +class JourneyIdentityStorage extends FlutterSecureStorage { + bool failEnumeration = false; + + @override + Future> readAll({ + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async { + if (failEnumeration) throw StateError('unrelated entry cannot decrypt'); + return super.readAll(); + } +} + class _NoChannels extends ChannelsNotifier { @override Future> build() async => [];