fix(mobile): preserve saved identity lookup after enumeration fails (#8470)

An account-scoped saved identity could fail to open when secure-storage
enumeration succeeded while showing choices, then failed before
selection. Retain the discovered storage location and reread/validate
that exact record before falling back to inventory lookup. Only public
location hints are retained; signing material is read fresh.

Follow-up accepted at merge of #8375:
https://github.com/block/buzz/pull/8375#pullrequestreview-5577985447.

Validation: existing self-hosted widget journey now injects enumeration
failure after choices load and verifies the same key is admitted and
persisted. Three focused files passed: 37 tests with hosted domains
configured; 28 passed and 9 hosted-only skips with default
configuration. Changed-file analysis passed. In an isolated clone,
removing the location lookup failed that journey; control and restored
runs passed. Self-review found no blocking defect.

Draft acceptance gate: native/device behavior and human acceptance are
not established by widget fixtures. On a disposable saved identity,
enter a self-hosted community link, choose the identity, and confirm
admission preserves it; cancel/retry as well. Hosted live-backend
acceptance remains separate and pending backend availability. No full
local suite was manually run; mandatory hooks/CI provide broader
coverage.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@feedback.communities.buzz.xyz>
This commit is contained in:
Wes
2026-10-19 15:33:42 -07:00
committed by GitHub
parent 91b1cc4761
commit dcced2c4f7
3 changed files with 41 additions and 1 deletions

View File

@@ -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 = <String, String>{};
Future<T> _serialized<T>(Future<T> Function() operation) {
final result = _queue.run(() => operation());
@@ -215,10 +218,17 @@ class AccountIdentityStore {
Future<List<AccountIdentitySnapshot>> identities() => _serialized(() async {
final keys = await _identityKeys();
final identities = <String, AccountIdentitySnapshot>{};
final locations = <String, String>{};
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<String> _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();

View File

@@ -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_'),

View File

@@ -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 = <http.Request>[];
Future<void> Function()? beforeBrowserReturn;
Future<void> 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<Map<String, String>> 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<List<Channel>> build() async => [];