Files
buzz/mobile/lib/shared/hosted/device_identity_source.dart
Wes 0ba4ae8d03 fix(mobile): preserve unreadable identities and repair recovery choices
Use shared fail-closed Android secure-storage options across all namespace clients. Allow token-only legacy records through cloud recovery, clarify consent, and prevent stale chooser taps from interrupting cancellation.

Exercise real Android plugin decryption failures with an isolated emulator probe, and cover actual link-entry consent and lifecycle guards.

Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
2026-10-15 16:11:26 -07:00

85 lines
2.9 KiB
Dart

import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../storage/secure_storage.dart';
import 'account_identity_store.dart';
/// Public identity choices; never contains private signing material.
class DeviceIdentityChoice {
const DeviceIdentityChoice(this.pubkey, this.label, {this.fromCloud = false});
final String pubkey;
final String label;
final bool fromCloud;
}
/// 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 ?? communitySecureStorage;
final FlutterSecureStorage _storage;
Future<List<DeviceIdentityChoice>> 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<AccountIdentitySnapshot> 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<Map<String, ({String nsec, String label})>> _read() async {
try {
final result = <String, ({String nsec, String label})>{};
final labels = <String, Set<String>>{};
void add(dynamic nsec, dynamic advertised, String label) {
if (nsec == null) {
// Token-only legacy communities legitimately have no private key.
return;
}
if (nsec is! String) throw AccountIdentityFailure.unreadable;
final pubkey = AccountIdentityStore.publicKeyForNsec(nsec);
if (advertised != null && advertised != pubkey) {
throw AccountIdentityFailure.unreadable;
}
final names = labels.putIfAbsent(pubkey, () => <String>{})..add(label);
result[pubkey] = (nsec: nsec, label: names.join(', '));
}
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<String, dynamic>;
add(
value['nsec'],
value['pubkey'],
(value['name'] as String?)?.trim().isNotEmpty == true
? (value['name'] as String).trim()
: 'Saved community',
);
}
}
add(
await _storage.read(key: 'buzz_nsec'),
await _storage.read(key: 'buzz_pubkey'),
'Previous device identity',
);
return result;
} catch (_) {
throw AccountIdentityFailure.unreadable;
}
}
}