mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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_'),
|
||||
|
||||
@@ -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 => [];
|
||||
|
||||
Reference in New Issue
Block a user