mirror of
https://github.com/block/buzz.git
synced 2026-11-08 18:31:56 +00:00
## Summary Cold-start iOS invite links could open the welcome screen without continuing sign-in, and accounts without a stored backup had no explicit identity setup choice. Forward scene launch URLs to `app_links`, offer creation for an unbound account with no backups, and offer matching-key import for an existing binding without a backup. Existing key storage, binding, backup, and admission checks remain in use. Discover saved hosted destinations using `known-communities/list` and remember successfully admitted destinations with `known-communities/add`. Saved destinations are not membership proof; normal admission and the existing failed-claim membership reconciliation remain separate. A failed save preserves the intent and local community for idempotent retry. ### Related issue Related to #8599 (QR-first mobile onboarding) and #8601 (invite policy consent; overlapping setup UI). No exact duplicate found in the open onboarding PR / identity-backup issue search. ### Validation before the latest main integration - 48 tests passed across five focused files: `hosted_journeys_test`, `hosted_communities_test`, `hosted_communities_flow_test`, `hosted_environment_flow_test`, and `hosted_multi_backup_recovery_test`. Test defines enable hosted domains and the push gateway. - Four additional pairing handoff tests passed with hosted domains enabled after updating their shared HTTP fixture for community saving. - Targeted Dart analysis: no issues. Required pre-commit and pre-push hooks passed, including the broader mobile analysis/test lane and file-size checks. - Native iOS build passed during iteration. On an isolated iOS simulator, a terminated app opened from an invite automatically entered browser login; human recording confirmed login continued into identity setup and new identity creation. - Human staging testing on October 27 passed invite entry with an existing email/password and new identity, community entry, messaging, cold restart, and recovery on two separate clean iOS simulators. These results apply to build `ff2138fc325790e0af4d9a64b2ed033fae3a8295`; the backend backup-upload blocker described previously is no longer blocking that tested flow. - Fresh-account signup through an invite is deferred. Physical-camera QR scanning, native regression/mutation coverage, independent review, and native retesting after the latest main integration remain outstanding. - Known limitation: failed-claim membership reconciliation still calls `communities/joined`; its endpoint/response contract needs alignment with the supported backend before that fallback is considered ready. The successful primary recovery path does not prove this fallback. - No account reset tooling, environment-specific credentials, generated dependency files, or recording artifacts are included. ### Current review fixes and validation Head `9902a98d42904a4a7d44a79541d81ceb94d8eac3` addresses the two review blockers and both small import races: - Hosted imports explain a linked-account key mismatch; the shared self-hosted/occupied-device message remains unchanged. The linked-account journey enters the wrong key through the actual sheet, checks the visible error and empty storage, then imports the correct key. - Closing the import sheet while its operation is pending now waits for that operation and resumes successful setup. The new-account journey covers creation and import, including dismissal while awaiting the status response, binding, backup receipt, and retry. - An unbound account with newly appeared backups refuses direct import, preserving recovery's receipt-before-adoption rule. The existing multiple-backup invariant covers matching and different imported keys. - The configured route journey uses host labels and its existing fake account now supports destination saving; the real-account journeys separately exercise persistence. 53 tests passed across six focused files with CI's `BUZZ_AUTH_DOMAINS=buzz.xyz,builderlab.xyz,builderlabstaging.xyz` and push-gateway define. In a separate clone with independent Git metadata/build output, restoring the old production import paths failed the three intended regressions; restoring the fixes passed all 43 tests in that control. Required pre-commit and pre-push hooks passed, including mobile analysis/tests and file-size checks. Hosted CI and native retesting of this head are still pending. The earlier independent review was at `10815e5d9`; these fixes still need reviewer confirmation. Optional accessibility and import-navigation refinements are deferred. Fresh signup, authenticated production add/list and the membership fallback remain open as described above. ### Staging testing and production configuration Staging selection is build configuration, not a changed production default. `HostedEnvironment` is unchanged by this PR: `BUZZ_BUILDERLAB_URL` defaults to `https://app.builderlab.xyz`; `BUZZ_AUTH_DOMAINS` is empty by default (hosted account features disabled). Builds that enable production hosted domains receive these onboarding behavior fixes after merge. This PR does not deploy anything or redirect production builds to staging. Check out this PR (`gh pr checkout 8617` in a dedicated `block/buzz` checkout/worktree), install dependencies per `mobile/README.md`, and boot a clean iOS simulator. With the repository-pinned Flutter toolchain: ```sh . ./bin/activate-hermit ./scripts/mobile-worktree-overrides.sh cd mobile flutter run -d <clean-simulator-udid> \ --dart-define=BUZZ_BUILDERLAB_URL=https://app.builderlabstaging.xyz \ --dart-define=BUZZ_AUTH_DOMAINS=communities.public.builderlabstaging.xyz \ --dart-define=BUZZ_DEBUG_PRIVATE_LOGIN=true ``` Private login requests an ephemeral browser session only in debug builds. Hosted relay assertions remain off by default; this test does not validate assertion-enabled admission. Generated local Xcode settings, dependency metadata, credentials, and simulator state are not included in the PR. - Invite test: open a valid public-staging invite in the simulator browser, choose **Accept invite in Buzz**, sign in or sign up, complete identity setup, enter the community, and send a message. - Recovery test: preserve that simulator, start another clean simulator with the same build, choose **or sign in with email**, and sign into the same account. Verify the recovered public key matches, the saved community and earlier message return, and a new message sends. - Fresh signup: repeat invite entry using a new account. This still needs human verification. Keep this PR draft while the listed validation gaps remain. --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
364 lines
12 KiB
Dart
364 lines
12 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,
|
|
String? importNsec,
|
|
}) => _resolve(isCurrent, selectedPubkey, createNew, importNsec);
|
|
|
|
Future<HostedIdentityResult> _resolve(
|
|
bool Function()? isCurrent,
|
|
String? selectedPubkey,
|
|
bool createNew,
|
|
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);
|
|
if ((statusKeys.isEmpty && bound == null) || 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,
|
|
);
|
|
}
|
|
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();
|
|
final reconciled = (await deviceIdentities.choices()).isEmpty;
|
|
_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);
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|