Skip to content

Commit fe0970a

Browse files
johnmatthewtennantXanderclaude
authored andcommitted
fix(mobile): offer a new identity beside saved keys on unclaimed accounts (#9107)
## Summary This fixes a dead end in mobile Builderlab sign-in. A new account (unbound, with no backups) on a device that already had saved identities could only pick one of those identities. Any saved key forced the picker, and creating a key required the device to have none. If the service then definitively rejected binding the chosen key, the app showed "Let's try that again" with no way forward: the chosen key stayed in the account's slot, so every retry and every launch re-sent the same rejected bind. For an unclaimed account, the picker now also offers **Create a new identity**: - Creation writes only this account's empty identity slot, so every saved and device key is kept. - Creation is offered only when every current device key was among the choices shown, so a new identity never silently passes over a key the user didn't see. - Bound accounts, accounts with backups, pairing and pending import never offer it. When binding a chosen key is definitively rejected, the blocked screen offers **Choose a different identity**: - It appears only for a 400, 404, 409 or 422 from `/verify` in the current attempt, for the current account and pubkey, and never during pairing, whose transferred key is the only choice. Evidence from an earlier attempt is cleared before each bind. A challenge rejection, a 403, a 503 or any other ambiguous failure offers nothing. - Before releasing the slot, it rechecks the account: still unbound (`/current` and status), no backups, the same subject, and a slot that still holds that pubkey without a receipt. - It releases the slot only when another durable copy of the same key exists (the device inventory or another account slot), then verifies the slot is empty. No key is deleted: only the account's pointer to the rejected choice is cleared. A failed or refused release keeps the slot and the choice, so it can be retried in place or after a restart. - After release, the picker runs again and offers the saved keys plus creation. The recovery code lives in `hosted_flow_identity_recovery.dart`, a part of `hosted_flow.dart`, to stay within the mobile file-size limit. ### Related issue None found. ### Testing **Physical iPhone, a signed release build of `1fb633d55` installed over the existing app (keychain and saved identities kept):** a saved identity was rejected, then Choose a different identity, the picker, Create a new identity and Join your community. Joining then completed. This head differs from `1fb633d55` only in the guards for leaving, pairing and a failed or refused release listed below, which this journey does not reach, and in no UI code. The status of that phone's original rejection was not captured, so it is not confirmed to be one of the rejections above. The 409 case is covered by tests. **Tests (red on `main`, green here):** - Identity service and store: creation beside offered keys; refusal for unseen or partly offered device keys; release when another copy exists; refusal for a sole copy, a binding, a backup, a receipt, another account or an unusable recheck; failed delete and retry; a cancelled attempt. - Onboarding journeys: create beside saved keys; select, rejected bind, restart, choose a different identity, create and bind; ambiguous 503 and 403 failures; challenge rejection and stale evidence. - Release boundaries: an account switch at `/current`, at status, or between the recheck and the delete; cancelling between the recheck and the delete; leaving the picker before a late creation; an unreadable device inventory; leaving while a release is in flight; a failed delete retried in place and after a restart; a release refused because the inventory is unreadable, then retried in place; a rejected paired key. Each keeps every key, leaves the new account's slot untouched and leaves a cancelled flow idle, and a retry converges. - Mutating the creation guard, the generation check, the binding recheck, the store's receipt check, the account and cancellation checks before the delete, or the device-inventory error handling fails a test. #### Screenshots Physical iPhone at `1fb633d55` (frames from a screen recording; saved public keys redacted): | Rejected bind | After Choose a different identity | After Create a new identity | | --- | --- | --- | | ![Phone: rejected bind offers Choose a different identity](https://github.com/user-attachments/assets/cfd81998-d860-4b2e-9963-e68ec322ef9b) | ![Phone: picker with saved identities and Create a new identity](https://github.com/user-attachments/assets/85235963-746c-489c-8134-a1c20f6c7cd1) | ![Phone: Join your community after creating a new identity](https://github.com/user-attachments/assets/bc7505c4-147d-4b03-8073-db4be0bd88f8) | Widget-test renders of the same screens (the real `HostedFlow` against a mocked account service, with a fixture key; the square at the top left is the test renderer's placeholder for an asset): | Picker on an unclaimed account | Rejected bind | | --- | --- | | ![Widget render: picker with Create a new identity](https://github.com/user-attachments/assets/3616eba2-f9dc-4e36-b6de-21cbad5fc8b6) | ![Widget render: rejected bind with Choose a different identity](https://github.com/user-attachments/assets/30bc6ef2-d63e-4f4b-8193-153ce5a3cafc) | 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: John Tennant <[email protected]> Co-authored-by: Xander <ff30c859b96bd5233ffaf5d071200458f1274bd0dab03589214cf1055198491d@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <[email protected]> (cherry picked from commit c848b6d) Signed-off-by: Tom Brow <[email protected]>
1 parent bc4da98 commit fe0970a

10 files changed

Lines changed: 917 additions & 20 deletions

‎mobile/lib/features/onboarding/hosted_connect_sheet.dart‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ class HostedConnectSheet extends HookConsumerWidget {
9999
? 'Choose a backed-up identity to connect to Builderlab. Your other backups and saved community keys will be kept. This app cannot change the selected Builderlab identity later.'
100100
: flow.identityChoices.any((choice) => choice.fromCloud)
101101
? 'Use your Builderlab identity on this device? It differs from your saved community identities. Those keys and your cloud backups will be kept. This app cannot change the selected Builderlab identity later.'
102-
: 'Choose the identity to connect to Builderlab. It will be bound to your account and backed up. Existing keys will be kept. This app cannot change the selected Builderlab identity later.',
102+
: 'Choose the identity to connect to Builderlab${flow.canCreateIdentity ? ', or create a new one' : ''}. It will be bound to your account and backed up. Existing keys will be kept. This app cannot change the selected Builderlab identity later.',
103103
HostedFlowStage.profile =>
104104
'What should people in this community call you? You can add a photo in Settings.',
105105
HostedFlowStage.acceptPolicy =>
@@ -161,6 +161,12 @@ class HostedConnectSheet extends HookConsumerWidget {
161161
),
162162
),
163163
),
164+
if (stage == HostedFlowStage.chooseIdentity && flow.canCreateIdentity)
165+
FilledButton(
166+
style: actionStyle,
167+
onPressed: flow.createIdentity,
168+
child: const Text('Create a new identity'),
169+
),
164170
if (stage == HostedFlowStage.setupIdentity) ...[
165171
if (flow.canCreateIdentity) ...[
166172
FilledButton(
@@ -218,6 +224,11 @@ class HostedConnectSheet extends HookConsumerWidget {
218224
onPressed: flow.skipConflictingCommunity,
219225
child: const Text('Keep saved identity and skip'),
220226
),
227+
if (flow.canChooseDifferentIdentity)
228+
FilledButton(
229+
onPressed: flow.chooseDifferentIdentity,
230+
child: const Text('Choose a different identity'),
231+
),
221232
if (flow.canSkipFailedCommunity)
222233
TextButton(
223234
onPressed: flow.skipFailedCommunity,

‎mobile/lib/shared/hosted/account_identity_store.dart‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,41 @@ class AccountIdentityStore {
232232
return identities.values.toList();
233233
});
234234

235+
/// Release this slot's unconfirmed copy of [pubkey] so a definitively
236+
/// rejected choice can be made again. Refuses (false) unless another saved
237+
/// slot or [heldElsewhere] holds the same key; never releases a receipt.
238+
/// An already-empty slot counts as released, so a retry after a partial
239+
/// failure or restart converges.
240+
Future<bool> releaseUnconfirmed(
241+
String pubkey, {
242+
required Future<bool> Function(String nsec) heldElsewhere,
243+
required void Function() checkCurrent,
244+
}) => _serialized(() async {
245+
final record = await _read();
246+
if (record == null) return true;
247+
if (record.pubkey != pubkey || record.backup != null) return false;
248+
var kept = false;
249+
for (final key in await _identityKeys(pubkey: pubkey)) {
250+
if (key == _key) continue;
251+
try {
252+
if ((await _atKey(key)._read())?.nsec == record.nsec) kept = true;
253+
} on AccountIdentityFailure {
254+
// An unreadable slot is never evidence of a copy.
255+
}
256+
}
257+
if (!kept && !await heldElsewhere(record.nsec)) return false;
258+
checkCurrent();
259+
try {
260+
await _storage.delete(key: _key);
261+
} catch (_) {
262+
throw AccountIdentityFailure.unreadable;
263+
}
264+
if (await _storage.read(key: _key) != null) {
265+
throw AccountIdentityFailure.unreadable;
266+
}
267+
return true;
268+
});
269+
235270
/// Copy a chosen saved account key without deleting its original owner.
236271
Future<AccountIdentitySnapshot> adoptSaved(
237272
String pubkey, {

‎mobile/lib/shared/hosted/device_identity_source.dart‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ class DeviceIdentitySource {
6262
return null;
6363
}
6464

65+
/// Whether a saved community or previous device record holds exactly [nsec].
66+
Future<bool> holds(String nsec) async =>
67+
(await _read())[AccountIdentityStore.publicKeyForNsec(nsec)]?.nsec ==
68+
nsec;
69+
6570
/// Reread and validate the chosen key immediately before create-only adoption.
6671
Future<AccountIdentitySnapshot> adopt(
6772
String pubkey,

‎mobile/lib/shared/hosted/hosted_flow.dart‎

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import 'hosted_community_connection.dart';
2121
import 'web_connect_binding.dart';
2222
import 'hosted_join_policy.dart';
2323

24+
part 'hosted_flow_identity_recovery.dart';
25+
2426
enum HostedFlowStage {
2527
idle,
2628
working,
@@ -133,6 +135,8 @@ class HostedFlow extends ChangeNotifier {
133135
bool accountOnly = false;
134136
bool _importRequested = false;
135137
bool canCreateIdentity = false;
138+
_RejectedBinding? _rejectedBinding;
139+
136140
String? requiredIdentityPubkey;
137141
String? _pairedPubkey;
138142
SensitiveActionPolicy? pairedSensitiveActionPolicy;
@@ -540,14 +544,20 @@ class HostedFlow extends ChangeNotifier {
540544
return _enqueue(() => _continue(generation, selectedPubkey: pubkey));
541545
}
542546

543-
/// Web-binding links retain an explicit choice before creating an identity.
547+
/// Create explicitly from setup, or beside the saved identities offered.
544548
Future<void> createIdentity() {
545-
if (stage != HostedFlowStage.setupIdentity || !canCreateIdentity) {
549+
if ((_target == null && !accountOnly) ||
550+
!canCreateIdentity ||
551+
(stage != HostedFlowStage.setupIdentity &&
552+
stage != HostedFlowStage.chooseIdentity)) {
546553
return Future.value();
547554
}
555+
final offered = {for (final item in identityChoices) item.pubkey};
548556
final generation = _nextGeneration();
549557
_set(HostedFlowStage.working);
550-
return _enqueue(() => _continue(generation, createNew: true));
558+
return _enqueue(
559+
() => _continue(generation, createNew: true, offered: offered),
560+
);
551561
}
552562

553563
/// Import through the secure store, rechecking the account binding.
@@ -572,6 +582,7 @@ class HostedFlow extends ChangeNotifier {
572582
int generation, {
573583
String? selectedPubkey,
574584
bool createNew = false,
585+
Set<String> offered = const {},
575586
}) async {
576587
final target = _target;
577588
final session = account.account;
@@ -612,15 +623,17 @@ class HostedFlow extends ChangeNotifier {
612623
);
613624
if (_disposed || generation != _generation) return;
614625
}
615-
if (resolved.state == HostedIdentityState.needsSetup &&
626+
// Selection offers creation beside saved identities only on request.
627+
final creatable =
616628
resolved.canCreate &&
617-
createNew &&
618-
!canSealBackup) {
629+
(resolved.state == HostedIdentityState.needsSetup ||
630+
(createNew &&
631+
resolved.state == HostedIdentityState.needsSelection));
632+
if (creatable && createNew && !canSealBackup) {
619633
_set(HostedFlowStage.blocked, HostedFlowProblem.unsupportedPlatform);
620634
return;
621635
}
622-
if (resolved.state == HostedIdentityState.needsSetup &&
623-
resolved.canCreate &&
636+
if (creatable &&
624637
canSealBackup &&
625638
(target is! NostrBindDeepLink || createNew) &&
626639
!_importRequested) {
@@ -629,6 +642,7 @@ class HostedFlow extends ChangeNotifier {
629642
resolved = await identity.resolve(
630643
isCurrent: _isCurrent(generation),
631644
createNew: true,
645+
offered: offered,
632646
);
633647
}
634648
if (_pairedPubkey != null && resolved.pubkey != _pairedPubkey) {
@@ -692,13 +706,19 @@ class HostedFlow extends ChangeNotifier {
692706
if (response['error'] != null || request == null) {
693707
throw AccountFailure.invalidResponse;
694708
}
695-
await WebConnectBindingService().complete(
696-
request: request,
697-
subject: session.subject,
698-
expectedPubkey: resolved.pubkey!,
699-
withKeys: store.withKeys,
700-
verify: _verifyBinding,
701-
isCurrent: _isCurrent(generation),
709+
final pubkey = resolved.pubkey!;
710+
await _observeBinding(
711+
session.subject,
712+
pubkey,
713+
generation,
714+
() => WebConnectBindingService().complete(
715+
request: request,
716+
subject: session.subject,
717+
expectedPubkey: pubkey,
718+
withKeys: store.withKeys,
719+
verify: _verifyBinding,
720+
isCurrent: _isCurrent(generation),
721+
),
702722
);
703723
} else if (resolved.state == HostedIdentityState.needsBackup) {
704724
await HostedBackupService(
@@ -721,6 +741,8 @@ class HostedFlow extends ChangeNotifier {
721741
return;
722742
}
723743
if (resolved.state == HostedIdentityState.needsSelection) {
744+
canCreateIdentity =
745+
resolved.canCreate && _pairedPubkey == null && !_importRequested;
724746
identityChoices = List.unmodifiable(resolved.choices);
725747
_set(HostedFlowStage.chooseIdentity);
726748
return;
@@ -988,7 +1010,7 @@ class HostedFlow extends ChangeNotifier {
9881010
if (account.account?.subject != subject) {
9891011
throw AccountFailure.differentAccount;
9901012
}
991-
final response = await account.post('/v1/buzz/nostr-identities/verify', {
1013+
final response = await account.post(_verifyPath, {
9921014
'challenge_id': challengeId,
9931015
'nonce': nonce,
9941016
'signed_payload': signedPayload,
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
part of 'hosted_flow.dart';
2+
3+
const _verifyPath = '/v1/buzz/nostr-identities/verify';
4+
5+
/// Definitive rejection of binding an account's chosen key, valid only for
6+
/// the attempt that observed it.
7+
typedef _RejectedBinding = ({String subject, String pubkey, int generation});
8+
9+
/// Recovery when binding the chosen key was definitively rejected.
10+
extension HostedFlowIdentityRecovery on HostedFlow {
11+
bool get canChooseDifferentIdentity {
12+
final rejected = _rejectedBinding;
13+
// A paired transfer pins its key, so there is no other identity to choose.
14+
return _pairedPubkey == null &&
15+
stage == HostedFlowStage.blocked &&
16+
problem == HostedFlowProblem.requestRejected &&
17+
rejected != null &&
18+
rejected.generation == _generation &&
19+
rejected.subject == account.account?.subject;
20+
}
21+
22+
/// Release this account's copy of the rejected key (kept elsewhere) and
23+
/// return to identity choice.
24+
Future<void> chooseDifferentIdentity() {
25+
final rejected = _rejectedBinding;
26+
if (!canChooseDifferentIdentity || rejected == null) return Future.value();
27+
final generation = _nextGeneration();
28+
_set(HostedFlowStage.working);
29+
return _enqueue(() async {
30+
var released = false;
31+
try {
32+
released = await identity.releaseRejected(
33+
rejected.pubkey,
34+
subject: rejected.subject,
35+
isCurrent: _isCurrent(generation),
36+
);
37+
} catch (_) {
38+
if (_disposed ||
39+
generation != _generation ||
40+
account.account?.subject != rejected.subject) {
41+
rethrow;
42+
}
43+
}
44+
if (_disposed || generation != _generation) return;
45+
if (released) return _continue(generation);
46+
// The rejection still stands, so the choice can be made again.
47+
_rejectedBinding = (
48+
subject: rejected.subject,
49+
pubkey: rejected.pubkey,
50+
generation: generation,
51+
);
52+
_set(HostedFlowStage.blocked, HostedFlowProblem.requestRejected);
53+
});
54+
}
55+
56+
/// Run [bind], recording only a definitive 4xx on verify for this attempt,
57+
/// account and key as evidence against it.
58+
Future<void> _observeBinding(
59+
String subject,
60+
String pubkey,
61+
int generation,
62+
Future<void> Function() bind,
63+
) async {
64+
account.lastRejectedRequest = null;
65+
try {
66+
await bind();
67+
} on AccountFailure catch (failure) {
68+
if (failure == AccountFailure.rejected &&
69+
account.lastRejectedRequest?.path == _verifyPath &&
70+
account.account?.subject == subject) {
71+
_rejectedBinding = (
72+
subject: subject,
73+
pubkey: pubkey,
74+
generation: generation,
75+
);
76+
}
77+
rethrow;
78+
}
79+
}
80+
}

‎mobile/lib/shared/hosted/hosted_identity_service.dart‎

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,15 @@ class HostedIdentityService {
4848
bool Function()? isCurrent,
4949
String? selectedPubkey,
5050
bool createNew = false,
51+
Set<String> offered = const {},
5152
String? importNsec,
52-
}) => _resolve(isCurrent, selectedPubkey, createNew, importNsec);
53+
}) => _resolve(isCurrent, selectedPubkey, createNew, offered, importNsec);
5354

5455
Future<HostedIdentityResult> _resolve(
5556
bool Function()? isCurrent,
5657
String? selectedPubkey,
5758
bool createNew,
59+
Set<String> offered,
5860
String? importNsec,
5961
) async {
6062
void checkCurrent() {
@@ -142,7 +144,9 @@ class HostedIdentityService {
142144
// decryption, even when the account retains backups of older identities.
143145
final matchingDevice =
144146
bound != null && choices.any((item) => item.pubkey == bound);
145-
if ((statusKeys.isEmpty && bound == null) || matchingDevice) {
147+
// An unclaimed account may keep its saved keys and start a new identity.
148+
final unclaimed = statusKeys.isEmpty && bound == null;
149+
if ((unclaimed && !createNew) || matchingDevice) {
146150
final eligible = choices
147151
.where((item) => bound == null || item.pubkey == bound)
148152
.toList();
@@ -156,6 +160,7 @@ class HostedIdentityService {
156160
return HostedIdentityResult(
157161
HostedIdentityState.needsSelection,
158162
choices: eligible,
163+
canCreate: unclaimed,
159164
);
160165
}
161166
void checkSelection() {
@@ -190,7 +195,11 @@ class HostedIdentityService {
190195
authorize: () async {
191196
_sameAccount(subject);
192197
checkCurrent();
193-
final reconciled = (await deviceIdentities.choices()).isEmpty;
198+
// Every device key must have been offered beside creation, so a
199+
// new identity never silently passes over an unseen prior one.
200+
final reconciled = (await deviceIdentities.choices()).every(
201+
(item) => offered.contains(item.pubkey),
202+
);
194203
_sameAccount(subject);
195204
checkCurrent();
196205
return reconciled;
@@ -319,6 +328,49 @@ class HostedIdentityService {
319328
return _result(saved, bound, subject);
320329
}
321330

331+
/// After a definitive binding rejection of [pubkey], release this account's
332+
/// unconfirmed copy so the user can choose again. Rechecks that the account
333+
/// is still unbound with no backups and that the key survives elsewhere;
334+
/// returns false and keeps the slot otherwise.
335+
Future<bool> releaseRejected(
336+
String pubkey, {
337+
required String subject,
338+
bool Function()? isCurrent,
339+
}) async {
340+
void checkCurrent() {
341+
if (isCurrent != null && !isCurrent()) throw AccountFailure.unavailable;
342+
}
343+
344+
checkCurrent();
345+
_sameAccount(subject);
346+
final store = this.store.forSubject(subject);
347+
final local = await store.read();
348+
if (local == null) return true;
349+
if (local.pubkey != pubkey || local.backup != null) return false;
350+
final current = await account.currentIdentity();
351+
_sameAccount(subject);
352+
if (current['error'] != null) throw AccountFailure.invalidResponse;
353+
if (current['identity'] != null) return false;
354+
final status = await account.post('/v1/buzz/nsec-backups/status', {});
355+
_sameAccount(subject);
356+
final listed = status['backup_nostr_pubkeys'];
357+
if (status['error'] != null || listed is! List) {
358+
throw AccountFailure.invalidResponse;
359+
}
360+
if (status['current_nostr_pubkey'] != null || listed.isNotEmpty) {
361+
return false;
362+
}
363+
checkCurrent();
364+
return store.releaseUnconfirmed(
365+
pubkey,
366+
heldElsewhere: deviceIdentities.holds,
367+
checkCurrent: () {
368+
_sameAccount(subject);
369+
checkCurrent();
370+
},
371+
);
372+
}
373+
322374
HostedIdentityResult _result(
323375
AccountIdentitySnapshot local,
324376
String? bound,

0 commit comments

Comments
 (0)