Skip to content

Commit 59ae028

Browse files
wesbillmanBrain
andauthored
fix(mobile): require Builderlab consent for existing identities (#8823)
Existing mobile identities entering the Builderlab hosted flow now require confirmation before the app reserves the account’s identity slot, binds the identity, or uploads its secret key. Cancelling leaves saved community keys usable and allows another identity to be selected, including after restart. Imported keys remain tentative until approval; paired keys remain safely retained outside the account slot. The confirmation shows the receiving account’s verified email and public key, explains that Builderlab can decrypt the backup and signed-in account users can recover it, and discloses that the app cannot change the linked identity later. **Cancel setup** stops future work without claiming to undo earlier binding or an upload whose acknowledgement was lost. The user can sign out; missing account identification blocks confirmation. Browser-initiated binding uses **Link and continue**, with explicit bind-only wording and no upload. Fresh keys generated in the current hosted attempt use ordinary onboarding without an extra backup prompt. Interrupted attempts without a confirmed backup ask again; approval is deliberately not persisted or carried across retries, cancellation, restart, account changes, or a replaced flow. Confirmed matching backup receipts skip the backup prompt. Cloud recovery and organization-managed backup are separate flows and are not changed here; this PR does not resolve server-side consent or recovery-authorization policy. Updated against main through `ce42853982` (#9418). Integration commit `dfb29fe5e7` includes the landed Hickory security fix and the shared workflow-test/runner repair. The consent implementation is unchanged from Carl’s reviewed `e11025ebed`; the CI merge preserves its configured public consent journeys. Validation at `e11025ebed`: 96 focused tests across eight files passed with public hosted domains configured; Flutter analysis passed. In an isolated source/Git/build copy, removing pre-adoption consent, bypassing the upload gate, and removing stale-confirmation checks each failed the consent journeys; restoring the code passed. Carl’s independent source review found no blockers. Mandatory hooks and hosted mobile CI passed, including both consent journeys in the configured public lane. Integration validation at `dfb29fe5e7`: 99 focused tests passed; one additional fullscreen-onboarding test failed its background community-admission assertion. An isolated clean-main control at `ce42853982` reproduces the same failure with the same expected/actual values. All consent journeys passed. Required commit and push hooks passed, including mobile analysis and the broader default mobile suite. Hosted CI is green at `dfb29fe5e7` ([run 43320753801](https://github.com/block/buzz/actions/runs/43320753801)). Security and the previously failing Rust/workflow gates now pass. The Mobile Checks log confirms both consent journeys executed successfully; that run has no pending checks. Review follow-up `ac9ac9be35`, integrated with main `279a31daac` at current head `1acfd2dff6`, is test-only: the browser journey now restores a saved account key without a receipt, cancels without binding, restarts and requires explicit approval before `/verify`. Both consent journeys passed again after integration using the updated CI settings. Disabling only the production bind gate in an isolated source/Git/build copy now fails the added step; restoring it passes. Mandatory commit/push hooks passed. Hosted CI is green at `1acfd2dff6` ([run 43343333843](https://github.com/block/buzz/actions/runs/43343333843)); no failing or pending checks remain. The Mobile Checks log confirms both consent journeys executed successfully, including the extended browser-binding journey. Carl’s latest review on `dfb29fe5e7` found no production blockers; its recommended test gap is addressed by this follow-up. Native-device and human acceptance have not been recorded here; the author’s ready-for-review setting is preserved. Try it: with two saved self-hosted keys, connect to Builderlab, select A, cancel, restart, then select B. Check the receiving account and key before approval. Repeat with an imported/paired key and a browser bind link; cancel must not reserve or bind the new selection. After an interrupted upload, cancellation must describe that earlier effects may remain. Confirm that ordinary brand-new onboarding has no extra backup step. --------- Signed-off-by: Wes <[email protected]> Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
1 parent a2d4ba0 commit 59ae028

14 files changed

Lines changed: 695 additions & 171 deletions

‎.github/workflows/_ci-clients.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ jobs:
163163
test/features/onboarding/hosted_app_gate_environment_test.dart
164164
test/features/settings/builderlab_account_row_test.dart
165165
test/features/onboarding/hosted_journeys_test.dart
166+
test/features/onboarding/hosted_backup_consent_test.dart
166167
test/features/onboarding/onboarding_sign_out_test.dart
167168
test/features/onboarding/hosted_communities_test.dart
168169
test/features/pairing/pairing_hosted_handoff_test.dart

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

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
44
import 'package:hooks_riverpod/hooks_riverpod.dart';
55

66
import '../../shared/hosted/hosted_flow.dart';
7+
import '../../shared/hosted/hosted_environment.dart';
78
import '../../shared/widgets/onboarding_step_page.dart';
89
import '../../shared/widgets/bee_loading_mark.dart';
910
import '../../shared/widgets/onboarding_loading_page.dart';
@@ -90,6 +91,20 @@ class HostedConnectSheet extends HookConsumerWidget {
9091
? 'Create a new Buzz identity, or import the identity you already use.'
9192
: 'Bring your existing Buzz identity from your other device.'
9293
: 'Your identity hasn’t been backed up yet. Bring it from your other device to keep using the same Buzz account.',
94+
HostedFlowStage.confirmBackup =>
95+
flow.bindingOnly
96+
? 'Link this identity to the receiving Builderlab account? '
97+
'This app cannot change the linked identity later. '
98+
'No backup is uploaded by this browser handoff. '
99+
'Cancelling stops setup; it does not undo any earlier linking.'
100+
: 'Allow Builderlab to link this identity to your account and upload '
101+
'an encrypted copy of your secret key (nsec) to '
102+
'${HostedEnvironment.origin.host}? Builderlab can decrypt this backup. '
103+
'Anyone signed in to this account can recover the key and use your '
104+
'identity, including on self-hosted relays. '
105+
'This app cannot change the linked identity later. '
106+
'Joining requires a backup. Cancelling stops setup; it does not undo '
107+
'any earlier linking or upload, including an upload whose confirmation was lost.',
93108
HostedFlowStage.chooseCommunities => 'Select a community',
94109
HostedFlowStage.communityConflict =>
95110
'${flow.addingCommunityName} is already saved with a different identity. '
@@ -145,6 +160,37 @@ class HostedConnectSheet extends HookConsumerWidget {
145160
],
146161
if (!fullScreen) Text(message, style: context.textTheme.bodyLarge),
147162
const SizedBox(height: Grid.sm),
163+
if (stage == HostedFlowStage.confirmBackup) ...[
164+
Text(
165+
flow.backupAccountEmail == null
166+
? 'We couldn’t identify the receiving account. Sign out and sign in again before continuing.'
167+
: 'Receiving account: ${flow.backupAccountEmail}',
168+
style: context.textTheme.bodyLarge,
169+
),
170+
const SizedBox(height: Grid.twelve),
171+
SelectableText(
172+
fullNpub(flow.backupPubkey ?? '') ?? '',
173+
style: context.textTheme.bodySmall,
174+
),
175+
const SizedBox(height: Grid.twelve),
176+
FilledButton(
177+
style: actionStyle,
178+
onPressed: flow.confirmBackup,
179+
child: Text(
180+
flow.bindingOnly ? 'Link and continue' : 'Back up and continue',
181+
),
182+
),
183+
TextButton(
184+
style: actionStyle,
185+
onPressed: () => flow.leave(),
186+
child: const Text('Cancel setup'),
187+
),
188+
TextButton(
189+
style: actionStyle,
190+
onPressed: flow.signOutAccount,
191+
child: const Text('Sign out'),
192+
),
193+
],
148194
for (final choice in flow.identityChoices)
149195
Padding(
150196
padding: const EdgeInsets.only(bottom: Grid.sm),
@@ -186,7 +232,9 @@ class HostedConnectSheet extends HookConsumerWidget {
186232
),
187233
const SizedBox(height: Grid.twelve),
188234
Text(
189-
'We’ll securely back it up so your next sign-in is easier.',
235+
flow.bindingOnly
236+
? 'This links the identity to your Builderlab account. This app cannot change it later. No backup is uploaded by this browser handoff.'
237+
: 'New identities are linked to your Builderlab account and backed up automatically. This app cannot change the linked identity later. Existing keys require confirmation before backup.',
190238
textAlign: TextAlign.center,
191239
style: context.textTheme.bodySmall,
192240
),
@@ -253,6 +301,7 @@ class HostedConnectSheet extends HookConsumerWidget {
253301
stage != HostedFlowStage.acceptPolicy &&
254302
stage != HostedFlowStage.chooseIdentity &&
255303
stage != HostedFlowStage.setupIdentity &&
304+
stage != HostedFlowStage.confirmBackup &&
256305
stage != HostedFlowStage.chooseCommunities &&
257306
stage != HostedFlowStage.communityConflict &&
258307
stage != HostedFlowStage.complete &&
@@ -282,6 +331,8 @@ class HostedConnectSheet extends HookConsumerWidget {
282331
? 'Create or import a Buzz identity'
283332
: 'Import your Buzz identity'
284333
: 'Bring your identity with you',
334+
HostedFlowStage.confirmBackup =>
335+
flow.bindingOnly ? 'Link your identity?' : 'Back up your secret key?',
285336
HostedFlowStage.chooseIdentity => 'Choose your identity',
286337
HostedFlowStage.working =>
287338
flow.addingCommunityName == null

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,15 +175,18 @@ class AccountIdentityStore implements BackupIdentity {
175175

176176
/// Authorization runs under the identity writer lock and must not reenter it.
177177
/// A completed but unacknowledged write is reused on retry, never regenerated.
178+
/// [onGenerated] runs only after this call confirmed a newly generated key.
178179
Future<AccountIdentitySnapshot?> generateIfAbsent({
179180
required Future<bool> Function() authorize,
181+
void Function()? onGenerated,
180182
}) => _serialized(() async {
181183
final existing = await _read();
182184
if (existing != null) return existing.snapshot;
183185
if (!await authorize()) return null;
184186
final keys = nostr.Keys.generate();
185187
final record = _Record(keys.nsec, keys.public, null);
186188
await _write(record);
189+
onGenerated?.call();
187190
return record.snapshot;
188191
});
189192

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

Lines changed: 48 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,15 @@ import 'web_connect_binding.dart';
2222
import 'hosted_join_policy.dart';
2323

2424
part 'hosted_flow_identity_recovery.dart';
25+
part 'hosted_flow_backup_consent.dart';
2526

2627
enum HostedFlowStage {
2728
idle,
2829
working,
2930
signIn,
3031
chooseIdentity,
3132
setupIdentity,
33+
confirmBackup,
3234
chooseCommunities,
3335
communityConflict,
3436
acceptPolicy,
@@ -139,6 +141,9 @@ class HostedFlow extends ChangeNotifier {
139141

140142
String? requiredIdentityPubkey;
141143
String? _pairedPubkey;
144+
({String subject, String pubkey})? _pendingBackup;
145+
String? _pendingImport;
146+
String? get backupPubkey => _pendingBackup?.pubkey;
142147
SensitiveActionPolicy? pairedSensitiveActionPolicy;
143148
List<AddCommunityDeepLink> communities = const [];
144149
Set<String> selectedRelays = const {};
@@ -180,6 +185,8 @@ class HostedFlow extends ChangeNotifier {
180185
int _generation = 0;
181186

182187
int _nextGeneration() {
188+
_pendingBackup = null;
189+
_pendingImport = null;
183190
_failedRelay = null;
184191
_cancelPolicy();
185192
_cancelProfile();
@@ -544,45 +551,13 @@ class HostedFlow extends ChangeNotifier {
544551
return _enqueue(() => _continue(generation, selectedPubkey: pubkey));
545552
}
546553

547-
/// Create explicitly from setup, or beside the saved identities offered.
548-
Future<void> createIdentity() {
549-
if ((_target == null && !accountOnly) ||
550-
!canCreateIdentity ||
551-
(stage != HostedFlowStage.setupIdentity &&
552-
stage != HostedFlowStage.chooseIdentity)) {
553-
return Future.value();
554-
}
555-
final offered = {for (final item in identityChoices) item.pubkey};
556-
final generation = _nextGeneration();
557-
_set(HostedFlowStage.working);
558-
return _enqueue(
559-
() => _continue(generation, createNew: true, offered: offered),
560-
);
561-
}
562-
563-
/// Import through the secure store, rechecking the account binding.
564-
/// Errors propagate to the import sheet without discarding the setup choice.
565-
Future<void> importIdentity(String nsec) {
566-
final generation = _generation;
567-
final subject = account.account?.subject;
568-
if (stage != HostedFlowStage.setupIdentity || subject == null) {
569-
throw AccountFailure.unavailable;
570-
}
571-
return _queue.run(() async {
572-
bool current() =>
573-
!_disposed &&
574-
generation == _generation &&
575-
account.account?.subject == subject;
576-
if (!current()) throw AccountFailure.unavailable;
577-
await identity.resolve(isCurrent: current, importNsec: nsec);
578-
});
579-
}
580-
581554
Future<void> _continue(
582555
int generation, {
583556
String? selectedPubkey,
584557
bool createNew = false,
558+
({String subject, String pubkey})? approvedBackup,
585559
Set<String> offered = const {},
560+
String? importNsec,
586561
}) async {
587562
final target = _target;
588563
final session = account.account;
@@ -607,8 +582,19 @@ class HostedFlow extends ChangeNotifier {
607582
var resolved = await identity.resolve(
608583
isCurrent: _isCurrent(generation),
609584
selectedPubkey: selectedPubkey ?? _pairedPubkey,
585+
importNsec: importNsec,
586+
canAdopt: (pubkey) =>
587+
approvedBackup == (subject: session.subject, pubkey: pubkey),
610588
);
611589
if (_disposed || generation != _generation) return;
590+
if (resolved.state == HostedIdentityState.needsConsent) {
591+
_presentBackupConsent(
592+
session.subject,
593+
resolved.pubkey!,
594+
importNsec: importNsec,
595+
);
596+
return;
597+
}
612598
if (_importRequested &&
613599
{
614600
HostedIdentityState.ready,
@@ -643,34 +629,43 @@ class HostedFlow extends ChangeNotifier {
643629
isCurrent: _isCurrent(generation),
644630
createNew: true,
645631
offered: offered,
632+
canAdopt: (pubkey) =>
633+
approvedBackup == (subject: session.subject, pubkey: pubkey),
646634
);
647635
}
636+
if (_disposed || generation != _generation) return;
637+
if (resolved.state == HostedIdentityState.needsConsent) {
638+
_presentBackupConsent(session.subject, resolved.pubkey!);
639+
return;
640+
}
641+
if (resolved.generated && resolved.pubkey != null) {
642+
approvedBackup = (subject: session.subject, pubkey: resolved.pubkey!);
643+
}
648644
if (_pairedPubkey != null && resolved.pubkey != _pairedPubkey) {
649645
_set(HostedFlowStage.blocked, HostedFlowProblem.identityMismatch);
650646
return;
651647
}
648+
if (target is NostrBindDeepLink &&
649+
!await _allowBackup(
650+
resolved,
651+
session.subject,
652+
generation,
653+
canSealBackup,
654+
approvedBackup: approvedBackup,
655+
)) {
656+
return;
657+
}
652658
if (target is! NostrBindDeepLink) {
653659
for (var step = 0; step < 2; step++) {
654660
if (_disposed || generation != _generation) return;
655-
if (!canSealBackup) {
656-
final saved = await store.read();
657-
if (_disposed || generation != _generation) return;
658-
final needsNewBackup =
659-
resolved.state == HostedIdentityState.needsSetup ||
660-
resolved.state == HostedIdentityState.needsBackup ||
661-
(resolved.state == HostedIdentityState.needsBinding &&
662-
!(saved?.hasBackupFor(
663-
backend: HostedEnvironment.origin.origin,
664-
subject: session.subject,
665-
) ??
666-
false));
667-
if (needsNewBackup) {
668-
_set(
669-
HostedFlowStage.blocked,
670-
HostedFlowProblem.unsupportedPlatform,
671-
);
672-
return;
673-
}
661+
if (!await _allowBackup(
662+
resolved,
663+
session.subject,
664+
generation,
665+
canSealBackup,
666+
approvedBackup: approvedBackup,
667+
)) {
668+
return;
674669
}
675670
if (resolved.state == HostedIdentityState.needsBinding &&
676671
resolved.pubkey != null) {

0 commit comments

Comments
 (0)