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 <wesbillman@users.noreply.github.com>
This commit is contained in:
Wes
2026-10-15 15:42:24 -07:00
parent c20d217752
commit 952e6fc609
10 changed files with 547 additions and 13 deletions

View File

@@ -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)

View File

@@ -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<HostedFlow>((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()),

View File

@@ -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);

View File

@@ -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<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})>{};
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<String, dynamic>;
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;
}
}
}

View File

@@ -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<bool> Function(Uri) _openWeb;
HostedFlowStage stage = HostedFlowStage.idle;
HostedFlowProblem? problem;
List<DeviceIdentityChoice> identityChoices = const [];
BuzzDeepLink? _target;
bool accountOnly = false;
Future<void> _tail = Future.value();
@@ -222,12 +225,26 @@ class HostedFlow extends ChangeNotifier {
}
}
Future<void> _continue(int generation) async {
/// Resume only an offered public identity; reread local and cloud facts first.
Future<void> 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<void> _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();

View File

@@ -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<DeviceIdentityChoice> 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<bool> Function()? canGenerateAfterLegacyReconciliation;
final DeviceIdentitySource? deviceIdentities;
final BuilderlabAccountClient account;
final AccountIdentityStore store;
Future<void> _tail = Future<void>.value();
Future<HostedIdentityResult> resolve({bool Function()? isCurrent}) {
final result = _tail.then((_) => _resolve(isCurrent));
Future<HostedIdentityResult> resolve({
bool Function()? isCurrent,
String? selectedPubkey,
}) {
final result = _tail.then((_) => _resolve(isCurrent, selectedPubkey));
_tail = result.then<void>((_) {}, onError: (_) {});
return result;
}
Future<HostedIdentityResult> _resolve(bool Function()? isCurrent) async {
Future<HostedIdentityResult> _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 <DeviceIdentityChoice>[];
_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(

View File

@@ -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<void> initialize() async {}
@override
Future<Map<String, String>?> retainedIntent() async => null;
@override
Future<void> clearIntent() async {}
@override
Future<void> 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<Uri>.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);
},
);
}

View File

@@ -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<String> backups = [];
Map<String, dynamic> recovery = {};
final calls = <String>[];
@override
BuilderlabAccount get account =>
BuilderlabAccount(subject: 'fixture', expiresAt: DateTime.utc(2100));
@override
Future<BuilderlabAccount?> restore() async => account;
@override
Future<Map<String, dynamic>> currentIdentity() async => {
if (bound != null) 'identity': {'pubkey_hex': bound},
};
@override
Future<Map<String, dynamic>> post(
String path,
Map<String, dynamic> 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<String, dynamic> 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);
});
}

View File

@@ -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': <String>[],
};
} else if (path.endsWith('/challenge')) {
effects.add('challenge');
data = {

View File

@@ -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);
},
);