Files
buzz/mobile/lib/shared/hosted/hosted_identity_service.dart
John Matthew Tennant c848b6dcad 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 <jtennant@squareup.com>
Co-authored-by: Xander <ff30c859b96bd5233ffaf5d071200458f1274bd0dab03589214cf1055198491d@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:23:59 -05:00

416 lines
14 KiB
Dart

import 'dart:convert';
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,
needsSetup,
needsBinding,
needsBackup,
mismatch,
recoveryBlocked,
needsAuthoritativeAbsence,
}
class HostedIdentityResult {
const HostedIdentityResult(
this.state, {
this.pubkey,
this.choices = const [],
this.canCreate = false,
});
final HostedIdentityState state;
final String? pubkey;
final bool canCreate;
final List<DeviceIdentityChoice> choices;
}
/// Resolves identity safely. Generation requires a separately verified
/// complete absence contract and explicit legacy-key reconciliation; neither a
/// failed recovery nor an unbound account proves there is no prior identity.
class HostedIdentityService {
HostedIdentityService({
required this.account,
required this.store,
DeviceIdentitySource? deviceIdentities,
}) : deviceIdentities = deviceIdentities ?? DeviceIdentitySource();
final DeviceIdentitySource deviceIdentities;
final BuilderlabAccountClient account;
final AccountIdentityStore store;
Future<HostedIdentityResult> resolve({
bool Function()? isCurrent,
String? selectedPubkey,
bool createNew = false,
Set<String> offered = const {},
String? importNsec,
}) => _resolve(isCurrent, selectedPubkey, createNew, offered, importNsec);
Future<HostedIdentityResult> _resolve(
bool Function()? isCurrent,
String? selectedPubkey,
bool createNew,
Set<String> offered,
String? importNsec,
) async {
void checkCurrent() {
if (isCurrent != null && !isCurrent()) throw AccountFailure.unavailable;
}
checkCurrent();
final subject = account.account?.subject;
if (subject == null) throw AccountFailure.signedOut;
final store = this.store.forSubject(subject);
final local = await store.read();
final response = await account.currentIdentity();
_sameAccount(subject);
if (response['error'] != null) throw AccountFailure.invalidResponse;
final identity = response['identity'];
final String? bound;
if (identity == null) {
bound = null; // The current API omits the optional unbound identity.
} else if (identity is Map<String, dynamic>) {
bound = _pubkey(identity['pubkey_hex']);
} else {
throw AccountFailure.invalidResponse;
}
if (local != null && bound != null && importNsec == null) {
return _result(local, bound, subject);
}
final status = await account.post('/v1/buzz/nsec-backups/status', {});
_sameAccount(subject);
checkCurrent();
final listed = status['backup_nostr_pubkeys'];
if (status['error'] != null || listed is! List) {
throw AccountFailure.invalidResponse;
}
final statusBound = status['current_nostr_pubkey'] == null
? null
: _pubkey(status['current_nostr_pubkey']);
final statusKeys = listed.map(_pubkey).toSet();
if (statusBound != bound) throw AccountFailure.invalidResponse;
if (importNsec != null) {
final pubkey = AccountIdentityStore.publicKeyForNsec(importNsec);
// A backup arriving while setup is open must use normal recovery, which
// records the selected backup receipt before adopting its identity.
if (bound == null && statusKeys.isNotEmpty) {
throw AccountFailure.unavailable;
}
if (bound != null && bound != pubkey) {
throw AccountIdentityFailure.mismatch;
}
final imported = await store.create(
importNsec,
checkCurrent: () {
_sameAccount(subject);
checkCurrent();
},
);
return _result(imported, bound, subject);
}
if (local != null) {
// A previously chosen recovery key may still await binding after a
// restart. Its account-scoped receipt and current backup listing preserve
// that choice even when the account also retains older identities.
final confirmedSelection =
statusKeys.contains(local.pubkey) &&
local.hasBackupFor(
backend: BuilderlabAccountClient.origin.origin,
subject: subject,
);
if (statusKeys.any((key) => key != local.pubkey) && !confirmedSelection) {
return const HostedIdentityResult(HostedIdentityState.mismatch);
}
return _result(local, bound, subject);
}
final device = await deviceIdentities.choices();
final savedAccounts = await store.identities();
final choices = [
...device,
for (final saved in savedAccounts)
if (!device.any((item) => item.pubkey == saved.pubkey))
DeviceIdentityChoice(saved.pubkey, 'Saved identity'),
];
_sameAccount(subject);
checkCurrent();
// The authoritative bound key already on this device needs no remote
// decryption, even when the account retains backups of older identities.
final matchingDevice =
bound != null && choices.any((item) => item.pubkey == bound);
// An unclaimed account may keep its saved keys and start a new identity.
final unclaimed = statusKeys.isEmpty && bound == null;
if ((unclaimed && !createNew) || matchingDevice) {
final eligible = choices
.where((item) => bound == null || item.pubkey == bound)
.toList();
if (eligible.isNotEmpty) {
final chosen =
selectedPubkey ??
(bound != null && eligible.length == 1
? eligible.single.pubkey
: null);
if (!eligible.any((item) => item.pubkey == chosen)) {
return HostedIdentityResult(
HostedIdentityState.needsSelection,
choices: eligible,
canCreate: unclaimed,
);
}
void checkSelection() {
_sameAccount(subject);
checkCurrent();
}
final saved = device.any((item) => item.pubkey == chosen)
? await deviceIdentities.adopt(
chosen!,
store,
checkCurrent: checkSelection,
)
: await store.adoptSaved(chosen!, checkCurrent: checkSelection);
return _result(saved, bound, subject);
}
}
if (statusKeys.isEmpty && bound != null && !matchingDevice) {
return HostedIdentityResult(
HostedIdentityState.needsSetup,
pubkey: bound,
);
}
if (statusBound == null && statusKeys.isEmpty) {
if (!createNew) {
return const HostedIdentityResult(
HostedIdentityState.needsSetup,
canCreate: true,
);
}
final generated = await store.generateIfAbsent(
authorize: () async {
_sameAccount(subject);
checkCurrent();
// Every device key must have been offered beside creation, so a
// new identity never silently passes over an unseen prior one.
final reconciled = (await deviceIdentities.choices()).every(
(item) => offered.contains(item.pubkey),
);
_sameAccount(subject);
checkCurrent();
return reconciled;
},
);
_sameAccount(subject);
checkCurrent();
return generated == null
? const HostedIdentityResult(
HostedIdentityState.needsAuthoritativeAbsence,
)
: _result(generated, bound, subject);
}
final recovered = await account.post('/v1/buzz/nsec-backups/recovery', {});
_sameAccount(subject);
final backups = recovered['backups'];
final failed = recovered['failed_backup_ids'];
if (recovered['error'] != null ||
backups is! List ||
failed is! List ||
failed.any((id) => id is! String)) {
throw AccountFailure.invalidResponse;
}
if (failed.isNotEmpty) {
return const HostedIdentityResult(HostedIdentityState.recoveryBlocked);
}
if (backups.isEmpty) {
return HostedIdentityResult(
bound == null
? HostedIdentityState.needsAuthoritativeAbsence
: HostedIdentityState.recoveryBlocked,
);
}
// Validate the entire result before storing anything. Never trust its
// advertised pubkey without deriving it from the actual secret.
final candidates = <String, ({nostr.Keys keys, String backupId})>{};
try {
for (final backup in backups) {
if (backup is! Map<String, dynamic>) throw const FormatException();
final secret = backup['secret_key'];
final backupId = backup['backup_id'];
if (secret is! String ||
!RegExp(r'^[A-Za-z0-9_-]{43}$').hasMatch(secret) ||
backupId is! String ||
backupId.isEmpty) {
throw const FormatException();
}
final bytes = base64Url.decode(base64Url.normalize(secret));
if (bytes.length != 32) throw const FormatException();
final hex = bytes
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final keys = AccountIdentityStore.keysForHex(hex);
if (keys.public != _pubkey(backup['nostr_pubkey'])) {
throw const FormatException();
}
candidates.putIfAbsent(
keys.public,
() => (keys: keys, backupId: backupId),
);
}
} catch (_) {
throw AccountFailure.invalidResponse;
}
if (bound == null &&
(statusKeys.length != candidates.length ||
!statusKeys.every(candidates.containsKey))) {
return const HostedIdentityResult(HostedIdentityState.recoveryBlocked);
}
// An account binding is authoritative even when older backups contain
// other keys. Without a binding, distinct keys require an explicit choice;
// never let response ordering choose the user's identity.
final chosen =
bound ??
selectedPubkey ??
(candidates.length == 1 ? candidates.keys.single : null);
final candidate = candidates[chosen];
if (candidate == null) {
if (bound != null) {
return const HostedIdentityResult(HostedIdentityState.mismatch);
}
return HostedIdentityResult(
HostedIdentityState.needsSelection,
choices: [
for (final pubkey in candidates.keys)
DeviceIdentityChoice(
pubkey,
'Use Builderlab identity',
fromCloud: true,
),
],
);
}
if (device.isNotEmpty &&
!device.any((item) => item.pubkey == candidate.keys.public) &&
selectedPubkey != candidate.keys.public) {
return HostedIdentityResult(
HostedIdentityState.needsSelection,
choices: [
DeviceIdentityChoice(
candidate.keys.public,
'Use Builderlab identity',
fromCloud: true,
),
],
);
}
_sameAccount(subject);
checkCurrent();
final saved = await store.create(
candidate.keys.nsec,
checkCurrent: () {
_sameAccount(subject);
checkCurrent();
},
recoveredBackup: BackupReceipt(
backend: BuilderlabAccountClient.origin.origin,
subject: subject,
pubkey: candidate.keys.public,
backupId: candidate.backupId,
),
);
_sameAccount(subject);
checkCurrent();
return _result(saved, bound, subject);
}
/// After a definitive binding rejection of [pubkey], release this account's
/// unconfirmed copy so the user can choose again. Rechecks that the account
/// is still unbound with no backups and that the key survives elsewhere;
/// returns false and keeps the slot otherwise.
Future<bool> releaseRejected(
String pubkey, {
required String subject,
bool Function()? isCurrent,
}) async {
void checkCurrent() {
if (isCurrent != null && !isCurrent()) throw AccountFailure.unavailable;
}
checkCurrent();
_sameAccount(subject);
final store = this.store.forSubject(subject);
final local = await store.read();
if (local == null) return true;
if (local.pubkey != pubkey || local.backup != null) return false;
final current = await account.currentIdentity();
_sameAccount(subject);
if (current['error'] != null) throw AccountFailure.invalidResponse;
if (current['identity'] != null) return false;
final status = await account.post('/v1/buzz/nsec-backups/status', {});
_sameAccount(subject);
final listed = status['backup_nostr_pubkeys'];
if (status['error'] != null || listed is! List) {
throw AccountFailure.invalidResponse;
}
if (status['current_nostr_pubkey'] != null || listed.isNotEmpty) {
return false;
}
checkCurrent();
return store.releaseUnconfirmed(
pubkey,
heldElsewhere: deviceIdentities.holds,
checkCurrent: () {
_sameAccount(subject);
checkCurrent();
},
);
}
HostedIdentityResult _result(
AccountIdentitySnapshot local,
String? bound,
String subject,
) {
_sameAccount(subject);
if (bound != null && bound != local.pubkey) {
return HostedIdentityResult(
HostedIdentityState.mismatch,
pubkey: local.pubkey,
);
}
if (bound == null) {
return HostedIdentityResult(
HostedIdentityState.needsBinding,
pubkey: local.pubkey,
);
}
return HostedIdentityResult(
local.hasBackupFor(
backend: BuilderlabAccountClient.origin.origin,
subject: subject,
)
? HostedIdentityState.ready
: HostedIdentityState.needsBackup,
pubkey: local.pubkey,
);
}
void _sameAccount(String subject) {
if (account.account?.subject != subject) {
throw AccountFailure.differentAccount;
}
}
static String _pubkey(dynamic value) {
if (value is! String || !RegExp(r'^[a-fA-F0-9]{64}$').hasMatch(value)) {
throw AccountFailure.invalidResponse;
}
return value.toLowerCase();
}
}