mirror of
https://github.com/block/buzz.git
synced 2026-11-13 21:01:53 +00:00
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 <wesbillman@users.noreply.github.com> Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
This commit is contained in:
1
.github/workflows/_ci-clients.yml
vendored
1
.github/workflows/_ci-clients.yml
vendored
@@ -163,6 +163,7 @@ jobs:
|
||||
test/features/onboarding/hosted_app_gate_environment_test.dart
|
||||
test/features/settings/builderlab_account_row_test.dart
|
||||
test/features/onboarding/hosted_journeys_test.dart
|
||||
test/features/onboarding/hosted_backup_consent_test.dart
|
||||
test/features/onboarding/onboarding_sign_out_test.dart
|
||||
test/features/onboarding/hosted_communities_test.dart
|
||||
test/features/pairing/pairing_hosted_handoff_test.dart
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/hosted/hosted_flow.dart';
|
||||
import '../../shared/hosted/hosted_environment.dart';
|
||||
import '../../shared/widgets/onboarding_step_page.dart';
|
||||
import '../../shared/widgets/bee_loading_mark.dart';
|
||||
import '../../shared/widgets/onboarding_loading_page.dart';
|
||||
@@ -90,6 +91,20 @@ class HostedConnectSheet extends HookConsumerWidget {
|
||||
? 'Create a new Buzz identity, or import the identity you already use.'
|
||||
: 'Bring your existing Buzz identity from your other device.'
|
||||
: 'Your identity hasn’t been backed up yet. Bring it from your other device to keep using the same Buzz account.',
|
||||
HostedFlowStage.confirmBackup =>
|
||||
flow.bindingOnly
|
||||
? 'Link this identity to the receiving Builderlab account? '
|
||||
'This app cannot change the linked identity later. '
|
||||
'No backup is uploaded by this browser handoff. '
|
||||
'Cancelling stops setup; it does not undo any earlier linking.'
|
||||
: 'Allow Builderlab to link this identity to your account and upload '
|
||||
'an encrypted copy of your secret key (nsec) to '
|
||||
'${HostedEnvironment.origin.host}? Builderlab can decrypt this backup. '
|
||||
'Anyone signed in to this account can recover the key and use your '
|
||||
'identity, including on self-hosted relays. '
|
||||
'This app cannot change the linked identity later. '
|
||||
'Joining requires a backup. Cancelling stops setup; it does not undo '
|
||||
'any earlier linking or upload, including an upload whose confirmation was lost.',
|
||||
HostedFlowStage.chooseCommunities => 'Select a community',
|
||||
HostedFlowStage.communityConflict =>
|
||||
'${flow.addingCommunityName} is already saved with a different identity. '
|
||||
@@ -145,6 +160,37 @@ class HostedConnectSheet extends HookConsumerWidget {
|
||||
],
|
||||
if (!fullScreen) Text(message, style: context.textTheme.bodyLarge),
|
||||
const SizedBox(height: Grid.sm),
|
||||
if (stage == HostedFlowStage.confirmBackup) ...[
|
||||
Text(
|
||||
flow.backupAccountEmail == null
|
||||
? 'We couldn’t identify the receiving account. Sign out and sign in again before continuing.'
|
||||
: 'Receiving account: ${flow.backupAccountEmail}',
|
||||
style: context.textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: Grid.twelve),
|
||||
SelectableText(
|
||||
fullNpub(flow.backupPubkey ?? '') ?? '',
|
||||
style: context.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: Grid.twelve),
|
||||
FilledButton(
|
||||
style: actionStyle,
|
||||
onPressed: flow.confirmBackup,
|
||||
child: Text(
|
||||
flow.bindingOnly ? 'Link and continue' : 'Back up and continue',
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
style: actionStyle,
|
||||
onPressed: () => flow.leave(),
|
||||
child: const Text('Cancel setup'),
|
||||
),
|
||||
TextButton(
|
||||
style: actionStyle,
|
||||
onPressed: flow.signOutAccount,
|
||||
child: const Text('Sign out'),
|
||||
),
|
||||
],
|
||||
for (final choice in flow.identityChoices)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: Grid.sm),
|
||||
@@ -186,7 +232,9 @@ class HostedConnectSheet extends HookConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: Grid.twelve),
|
||||
Text(
|
||||
'We’ll securely back it up so your next sign-in is easier.',
|
||||
flow.bindingOnly
|
||||
? 'This links the identity to your Builderlab account. This app cannot change it later. No backup is uploaded by this browser handoff.'
|
||||
: '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.',
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodySmall,
|
||||
),
|
||||
@@ -253,6 +301,7 @@ class HostedConnectSheet extends HookConsumerWidget {
|
||||
stage != HostedFlowStage.acceptPolicy &&
|
||||
stage != HostedFlowStage.chooseIdentity &&
|
||||
stage != HostedFlowStage.setupIdentity &&
|
||||
stage != HostedFlowStage.confirmBackup &&
|
||||
stage != HostedFlowStage.chooseCommunities &&
|
||||
stage != HostedFlowStage.communityConflict &&
|
||||
stage != HostedFlowStage.complete &&
|
||||
@@ -282,6 +331,8 @@ class HostedConnectSheet extends HookConsumerWidget {
|
||||
? 'Create or import a Buzz identity'
|
||||
: 'Import your Buzz identity'
|
||||
: 'Bring your identity with you',
|
||||
HostedFlowStage.confirmBackup =>
|
||||
flow.bindingOnly ? 'Link your identity?' : 'Back up your secret key?',
|
||||
HostedFlowStage.chooseIdentity => 'Choose your identity',
|
||||
HostedFlowStage.working =>
|
||||
flow.addingCommunityName == null
|
||||
|
||||
@@ -175,8 +175,10 @@ class AccountIdentityStore implements BackupIdentity {
|
||||
|
||||
/// Authorization runs under the identity writer lock and must not reenter it.
|
||||
/// A completed but unacknowledged write is reused on retry, never regenerated.
|
||||
/// [onGenerated] runs only after this call confirmed a newly generated key.
|
||||
Future<AccountIdentitySnapshot?> generateIfAbsent({
|
||||
required Future<bool> Function() authorize,
|
||||
void Function()? onGenerated,
|
||||
}) => _serialized(() async {
|
||||
final existing = await _read();
|
||||
if (existing != null) return existing.snapshot;
|
||||
@@ -184,6 +186,7 @@ class AccountIdentityStore implements BackupIdentity {
|
||||
final keys = nostr.Keys.generate();
|
||||
final record = _Record(keys.nsec, keys.public, null);
|
||||
await _write(record);
|
||||
onGenerated?.call();
|
||||
return record.snapshot;
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import 'web_connect_binding.dart';
|
||||
import 'hosted_join_policy.dart';
|
||||
|
||||
part 'hosted_flow_identity_recovery.dart';
|
||||
part 'hosted_flow_backup_consent.dart';
|
||||
|
||||
enum HostedFlowStage {
|
||||
idle,
|
||||
@@ -29,6 +30,7 @@ enum HostedFlowStage {
|
||||
signIn,
|
||||
chooseIdentity,
|
||||
setupIdentity,
|
||||
confirmBackup,
|
||||
chooseCommunities,
|
||||
communityConflict,
|
||||
acceptPolicy,
|
||||
@@ -139,6 +141,9 @@ class HostedFlow extends ChangeNotifier {
|
||||
|
||||
String? requiredIdentityPubkey;
|
||||
String? _pairedPubkey;
|
||||
({String subject, String pubkey})? _pendingBackup;
|
||||
String? _pendingImport;
|
||||
String? get backupPubkey => _pendingBackup?.pubkey;
|
||||
SensitiveActionPolicy? pairedSensitiveActionPolicy;
|
||||
List<AddCommunityDeepLink> communities = const [];
|
||||
Set<String> selectedRelays = const {};
|
||||
@@ -180,6 +185,8 @@ class HostedFlow extends ChangeNotifier {
|
||||
int _generation = 0;
|
||||
|
||||
int _nextGeneration() {
|
||||
_pendingBackup = null;
|
||||
_pendingImport = null;
|
||||
_failedRelay = null;
|
||||
_cancelPolicy();
|
||||
_cancelProfile();
|
||||
@@ -544,45 +551,13 @@ class HostedFlow extends ChangeNotifier {
|
||||
return _enqueue(() => _continue(generation, selectedPubkey: pubkey));
|
||||
}
|
||||
|
||||
/// Create explicitly from setup, or beside the saved identities offered.
|
||||
Future<void> createIdentity() {
|
||||
if ((_target == null && !accountOnly) ||
|
||||
!canCreateIdentity ||
|
||||
(stage != HostedFlowStage.setupIdentity &&
|
||||
stage != HostedFlowStage.chooseIdentity)) {
|
||||
return Future.value();
|
||||
}
|
||||
final offered = {for (final item in identityChoices) item.pubkey};
|
||||
final generation = _nextGeneration();
|
||||
_set(HostedFlowStage.working);
|
||||
return _enqueue(
|
||||
() => _continue(generation, createNew: true, offered: offered),
|
||||
);
|
||||
}
|
||||
|
||||
/// Import through the secure store, rechecking the account binding.
|
||||
/// Errors propagate to the import sheet without discarding the setup choice.
|
||||
Future<void> importIdentity(String nsec) {
|
||||
final generation = _generation;
|
||||
final subject = account.account?.subject;
|
||||
if (stage != HostedFlowStage.setupIdentity || subject == null) {
|
||||
throw AccountFailure.unavailable;
|
||||
}
|
||||
return _queue.run(() async {
|
||||
bool current() =>
|
||||
!_disposed &&
|
||||
generation == _generation &&
|
||||
account.account?.subject == subject;
|
||||
if (!current()) throw AccountFailure.unavailable;
|
||||
await identity.resolve(isCurrent: current, importNsec: nsec);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _continue(
|
||||
int generation, {
|
||||
String? selectedPubkey,
|
||||
bool createNew = false,
|
||||
({String subject, String pubkey})? approvedBackup,
|
||||
Set<String> offered = const {},
|
||||
String? importNsec,
|
||||
}) async {
|
||||
final target = _target;
|
||||
final session = account.account;
|
||||
@@ -607,8 +582,19 @@ class HostedFlow extends ChangeNotifier {
|
||||
var resolved = await identity.resolve(
|
||||
isCurrent: _isCurrent(generation),
|
||||
selectedPubkey: selectedPubkey ?? _pairedPubkey,
|
||||
importNsec: importNsec,
|
||||
canAdopt: (pubkey) =>
|
||||
approvedBackup == (subject: session.subject, pubkey: pubkey),
|
||||
);
|
||||
if (_disposed || generation != _generation) return;
|
||||
if (resolved.state == HostedIdentityState.needsConsent) {
|
||||
_presentBackupConsent(
|
||||
session.subject,
|
||||
resolved.pubkey!,
|
||||
importNsec: importNsec,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (_importRequested &&
|
||||
{
|
||||
HostedIdentityState.ready,
|
||||
@@ -643,34 +629,43 @@ class HostedFlow extends ChangeNotifier {
|
||||
isCurrent: _isCurrent(generation),
|
||||
createNew: true,
|
||||
offered: offered,
|
||||
canAdopt: (pubkey) =>
|
||||
approvedBackup == (subject: session.subject, pubkey: pubkey),
|
||||
);
|
||||
}
|
||||
if (_disposed || generation != _generation) return;
|
||||
if (resolved.state == HostedIdentityState.needsConsent) {
|
||||
_presentBackupConsent(session.subject, resolved.pubkey!);
|
||||
return;
|
||||
}
|
||||
if (resolved.generated && resolved.pubkey != null) {
|
||||
approvedBackup = (subject: session.subject, pubkey: resolved.pubkey!);
|
||||
}
|
||||
if (_pairedPubkey != null && resolved.pubkey != _pairedPubkey) {
|
||||
_set(HostedFlowStage.blocked, HostedFlowProblem.identityMismatch);
|
||||
return;
|
||||
}
|
||||
if (target is NostrBindDeepLink &&
|
||||
!await _allowBackup(
|
||||
resolved,
|
||||
session.subject,
|
||||
generation,
|
||||
canSealBackup,
|
||||
approvedBackup: approvedBackup,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
if (target is! NostrBindDeepLink) {
|
||||
for (var step = 0; step < 2; step++) {
|
||||
if (_disposed || generation != _generation) return;
|
||||
if (!canSealBackup) {
|
||||
final saved = await store.read();
|
||||
if (_disposed || generation != _generation) return;
|
||||
final needsNewBackup =
|
||||
resolved.state == HostedIdentityState.needsSetup ||
|
||||
resolved.state == HostedIdentityState.needsBackup ||
|
||||
(resolved.state == HostedIdentityState.needsBinding &&
|
||||
!(saved?.hasBackupFor(
|
||||
backend: HostedEnvironment.origin.origin,
|
||||
subject: session.subject,
|
||||
) ??
|
||||
false));
|
||||
if (needsNewBackup) {
|
||||
_set(
|
||||
HostedFlowStage.blocked,
|
||||
HostedFlowProblem.unsupportedPlatform,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!await _allowBackup(
|
||||
resolved,
|
||||
session.subject,
|
||||
generation,
|
||||
canSealBackup,
|
||||
approvedBackup: approvedBackup,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
if (resolved.state == HostedIdentityState.needsBinding &&
|
||||
resolved.pubkey != null) {
|
||||
|
||||
135
mobile/lib/shared/hosted/hosted_flow_backup_consent.dart
Normal file
135
mobile/lib/shared/hosted/hosted_flow_backup_consent.dart
Normal file
@@ -0,0 +1,135 @@
|
||||
part of 'hosted_flow.dart';
|
||||
|
||||
/// Consent before reserving an existing identity or exporting its secret.
|
||||
extension HostedFlowBackupConsent on HostedFlow {
|
||||
bool get bindingOnly => _target is NostrBindDeepLink;
|
||||
|
||||
String? get backupAccountEmail {
|
||||
if (_pendingBackup?.subject != account.account?.subject) return null;
|
||||
final email = account.account?.email?.trim();
|
||||
return email == null || email.isEmpty ? null : email;
|
||||
}
|
||||
|
||||
void _presentBackupConsent(
|
||||
String subject,
|
||||
String pubkey, {
|
||||
String? importNsec,
|
||||
}) {
|
||||
_pendingBackup = (subject: subject, pubkey: pubkey);
|
||||
_pendingImport = importNsec;
|
||||
_set(HostedFlowStage.confirmBackup);
|
||||
}
|
||||
|
||||
/// Approval is bound to the displayed account, key and live presentation.
|
||||
Future<void> Function()? get confirmBackup {
|
||||
final approved = _pendingBackup;
|
||||
final presentedGeneration = _generation;
|
||||
if (stage != HostedFlowStage.confirmBackup ||
|
||||
approved == null ||
|
||||
backupAccountEmail == null) {
|
||||
return null;
|
||||
}
|
||||
return () {
|
||||
if (_disposed ||
|
||||
presentedGeneration != _generation ||
|
||||
stage != HostedFlowStage.confirmBackup ||
|
||||
_pendingBackup != approved ||
|
||||
account.account?.subject != approved.subject) {
|
||||
return Future.value();
|
||||
}
|
||||
final importNsec = _pendingImport;
|
||||
final generation = _nextGeneration();
|
||||
_set(HostedFlowStage.working);
|
||||
return _enqueue(
|
||||
() => _continue(
|
||||
generation,
|
||||
selectedPubkey: approved.pubkey,
|
||||
approvedBackup: approved,
|
||||
importNsec: importNsec,
|
||||
),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/// Create explicitly from setup, or beside the saved identities offered.
|
||||
Future<void> createIdentity() {
|
||||
if ((_target == null && !accountOnly) ||
|
||||
!canCreateIdentity ||
|
||||
(stage != HostedFlowStage.setupIdentity &&
|
||||
stage != HostedFlowStage.chooseIdentity)) {
|
||||
return Future.value();
|
||||
}
|
||||
final offered = {for (final item in identityChoices) item.pubkey};
|
||||
final generation = _nextGeneration();
|
||||
_set(HostedFlowStage.working);
|
||||
return _enqueue(
|
||||
() => _continue(generation, createNew: true, offered: offered),
|
||||
);
|
||||
}
|
||||
|
||||
/// Keep an imported key tentative until the confirmation is accepted. Only
|
||||
/// its public key is exposed to widgets; cancellation drops the private input.
|
||||
Future<void> importIdentity(String nsec) {
|
||||
final generation = _generation;
|
||||
final subject = account.account?.subject;
|
||||
if (stage != HostedFlowStage.setupIdentity || subject == null) {
|
||||
throw AccountFailure.unavailable;
|
||||
}
|
||||
return _queue.run(() async {
|
||||
bool current() =>
|
||||
!_disposed &&
|
||||
generation == _generation &&
|
||||
account.account?.subject == subject;
|
||||
if (!current()) throw AccountFailure.unavailable;
|
||||
final resolved = await identity.resolve(
|
||||
isCurrent: current,
|
||||
importNsec: nsec,
|
||||
canAdopt: (_) => false,
|
||||
);
|
||||
if (!current()) throw AccountFailure.unavailable;
|
||||
if (resolved.state == HostedIdentityState.needsConsent) {
|
||||
_presentBackupConsent(subject, resolved.pubkey!, importNsec: nsec);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> _allowBackup(
|
||||
HostedIdentityResult resolved,
|
||||
String subject,
|
||||
int generation,
|
||||
bool canSealBackup, {
|
||||
({String subject, String pubkey})? approvedBackup,
|
||||
}) async {
|
||||
final saved = await store.forSubject(subject).read();
|
||||
if (_disposed || generation != _generation) return false;
|
||||
if (account.account?.subject != subject) {
|
||||
throw AccountFailure.differentAccount;
|
||||
}
|
||||
final confirmed =
|
||||
saved?.pubkey == resolved.pubkey &&
|
||||
(saved?.hasBackupFor(
|
||||
backend: HostedEnvironment.origin.origin,
|
||||
subject: subject,
|
||||
) ??
|
||||
false);
|
||||
final needsBackup =
|
||||
resolved.state == HostedIdentityState.needsSetup ||
|
||||
resolved.state == HostedIdentityState.needsBackup ||
|
||||
(resolved.state == HostedIdentityState.needsBinding && !confirmed);
|
||||
if (!bindingOnly && needsBackup && !canSealBackup) {
|
||||
_set(HostedFlowStage.blocked, HostedFlowProblem.unsupportedPlatform);
|
||||
return false;
|
||||
}
|
||||
final pubkey = resolved.pubkey;
|
||||
if ((resolved.state == HostedIdentityState.needsBinding ||
|
||||
(!bindingOnly &&
|
||||
resolved.state == HostedIdentityState.needsBackup)) &&
|
||||
!confirmed &&
|
||||
pubkey != null &&
|
||||
approvedBackup != (subject: subject, pubkey: pubkey)) {
|
||||
_presentBackupConsent(subject, pubkey);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import 'device_identity_source.dart';
|
||||
enum HostedIdentityState {
|
||||
ready,
|
||||
needsSelection,
|
||||
needsConsent,
|
||||
needsSetup,
|
||||
needsBinding,
|
||||
needsBackup,
|
||||
@@ -23,10 +24,14 @@ class HostedIdentityResult {
|
||||
this.pubkey,
|
||||
this.choices = const [],
|
||||
this.canCreate = false,
|
||||
this.generated = false,
|
||||
});
|
||||
final HostedIdentityState state;
|
||||
final String? pubkey;
|
||||
final bool canCreate;
|
||||
|
||||
/// True only when this resolution confirmed generating the key itself.
|
||||
final bool generated;
|
||||
final List<DeviceIdentityChoice> choices;
|
||||
}
|
||||
|
||||
@@ -44,13 +49,23 @@ class HostedIdentityService {
|
||||
final BuilderlabAccountClient account;
|
||||
final AccountIdentityStore store;
|
||||
|
||||
/// [canAdopt] may require consent before a local key occupies the account slot.
|
||||
/// Cloud recovery already has a receipt and does not export a local secret.
|
||||
Future<HostedIdentityResult> resolve({
|
||||
bool Function()? isCurrent,
|
||||
String? selectedPubkey,
|
||||
bool createNew = false,
|
||||
Set<String> offered = const {},
|
||||
String? importNsec,
|
||||
}) => _resolve(isCurrent, selectedPubkey, createNew, offered, importNsec);
|
||||
bool Function(String pubkey)? canAdopt,
|
||||
}) => _resolve(
|
||||
isCurrent,
|
||||
selectedPubkey,
|
||||
createNew,
|
||||
offered,
|
||||
importNsec,
|
||||
canAdopt,
|
||||
);
|
||||
|
||||
Future<HostedIdentityResult> _resolve(
|
||||
bool Function()? isCurrent,
|
||||
@@ -58,6 +73,7 @@ class HostedIdentityService {
|
||||
bool createNew,
|
||||
Set<String> offered,
|
||||
String? importNsec,
|
||||
bool Function(String pubkey)? canAdopt,
|
||||
) async {
|
||||
void checkCurrent() {
|
||||
if (isCurrent != null && !isCurrent()) throw AccountFailure.unavailable;
|
||||
@@ -106,6 +122,12 @@ class HostedIdentityService {
|
||||
if (bound != null && bound != pubkey) {
|
||||
throw AccountIdentityFailure.mismatch;
|
||||
}
|
||||
if (canAdopt != null && !canAdopt(pubkey)) {
|
||||
return HostedIdentityResult(
|
||||
HostedIdentityState.needsConsent,
|
||||
pubkey: pubkey,
|
||||
);
|
||||
}
|
||||
final imported = await store.create(
|
||||
importNsec,
|
||||
checkCurrent: () {
|
||||
@@ -163,6 +185,12 @@ class HostedIdentityService {
|
||||
canCreate: unclaimed,
|
||||
);
|
||||
}
|
||||
if (canAdopt != null && !canAdopt(chosen!)) {
|
||||
return HostedIdentityResult(
|
||||
HostedIdentityState.needsConsent,
|
||||
pubkey: chosen,
|
||||
);
|
||||
}
|
||||
void checkSelection() {
|
||||
_sameAccount(subject);
|
||||
checkCurrent();
|
||||
@@ -191,7 +219,9 @@ class HostedIdentityService {
|
||||
canCreate: true,
|
||||
);
|
||||
}
|
||||
var generatedNow = false;
|
||||
final generated = await store.generateIfAbsent(
|
||||
onGenerated: () => generatedNow = true,
|
||||
authorize: () async {
|
||||
_sameAccount(subject);
|
||||
checkCurrent();
|
||||
@@ -211,7 +241,7 @@ class HostedIdentityService {
|
||||
? const HostedIdentityResult(
|
||||
HostedIdentityState.needsAuthoritativeAbsence,
|
||||
)
|
||||
: _result(generated, bound, subject);
|
||||
: _result(generated, bound, subject, generated: generatedNow);
|
||||
}
|
||||
|
||||
final recovered = await account.post('/v1/buzz/nsec-backups/recovery', {});
|
||||
@@ -336,8 +366,9 @@ class HostedIdentityService {
|
||||
HostedIdentityResult _result(
|
||||
AccountIdentitySnapshot local,
|
||||
String? bound,
|
||||
String subject,
|
||||
) {
|
||||
String subject, {
|
||||
bool generated = false,
|
||||
}) {
|
||||
_sameAccount(subject);
|
||||
if (bound != null && bound != local.pubkey) {
|
||||
return HostedIdentityResult(
|
||||
@@ -349,6 +380,7 @@ class HostedIdentityService {
|
||||
return HostedIdentityResult(
|
||||
HostedIdentityState.needsBinding,
|
||||
pubkey: local.pubkey,
|
||||
generated: generated,
|
||||
);
|
||||
}
|
||||
return HostedIdentityResult(
|
||||
|
||||
382
mobile/test/features/onboarding/hosted_backup_consent_test.dart
Normal file
382
mobile/test/features/onboarding/hosted_backup_consent_test.dart
Normal file
@@ -0,0 +1,382 @@
|
||||
import 'package:buzz/shared/auth/auth.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_environment.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_flow.dart';
|
||||
import 'package:buzz/shared/utils/string_utils.dart';
|
||||
import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../helpers/hosted_journey_fixture.dart';
|
||||
|
||||
void main() {
|
||||
void journey(
|
||||
String name,
|
||||
Future<void> Function(WidgetTester, HostedJourneyFixture) run,
|
||||
) {
|
||||
testWidgets(name, (tester) async {
|
||||
final f = HostedJourneyFixture();
|
||||
await tester.runAsync(
|
||||
() => http.runWithClient(() async {
|
||||
try {
|
||||
await run(tester, f);
|
||||
} finally {
|
||||
await f.dispose(tester);
|
||||
}
|
||||
}, f.client),
|
||||
);
|
||||
}, skip: HostedEnvironment.authDomains.isEmpty);
|
||||
}
|
||||
|
||||
// This separate journey covers the first cloud export of an existing local
|
||||
// key. The general hosted journey file is already at its size limit.
|
||||
testWidgets('self-hosted key requires informed consent before cloud backup', (
|
||||
tester,
|
||||
) async {
|
||||
final f = HostedJourneyFixture();
|
||||
await tester.runAsync(
|
||||
() => http.runWithClient(() async {
|
||||
try {
|
||||
await f.initialize(localIdentity: false);
|
||||
f.bound = null;
|
||||
f.offerProfile = false;
|
||||
final community = Community.create(
|
||||
name: 'Self hosted',
|
||||
relayUrl: 'wss://self-hosted.example',
|
||||
pubkey: f.keys.public,
|
||||
nsec: f.keys.nsec,
|
||||
);
|
||||
await f.storage.save(community);
|
||||
await f.storage.save(
|
||||
Community.create(
|
||||
name: 'Second self hosted',
|
||||
relayUrl: 'wss://second.example',
|
||||
pubkey: f.secondKeys.public,
|
||||
nsec: f.secondKeys.nsec,
|
||||
),
|
||||
);
|
||||
await f.storage.saveActiveId(community.id);
|
||||
await f.mount(tester);
|
||||
expect(f.bound, isNull);
|
||||
expect(f.sent('/verify'), isEmpty);
|
||||
expect(f.sent('/enrollment'), isEmpty);
|
||||
expect(f.sent('/nsec-backups'), isEmpty);
|
||||
|
||||
f.accountEmail = 'earlier-account@example.com';
|
||||
await f.flow.signInAccount();
|
||||
await f.settle(tester);
|
||||
await f.tap(tester, fullNpub(f.keys.public)!);
|
||||
expect(find.text('Back up your secret key?'), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining('encrypted copy of your secret key'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.textContaining(HostedEnvironment.origin.host),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.textContaining('Anyone signed in to this account'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text(fullNpub(f.keys.public)!), findsOneWidget);
|
||||
expect(
|
||||
find.text('Receiving account: earlier-account@example.com'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.textContaining('cannot change the linked identity later'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(await f.store.read(), isNull);
|
||||
expect(f.bound, isNull);
|
||||
expect(f.sent('/verify'), isEmpty);
|
||||
expect(f.sent('/enrollment'), isEmpty);
|
||||
expect(f.sent('/nsec-backups'), isEmpty);
|
||||
final staleConfirm = tester
|
||||
.widget<FilledButton>(
|
||||
find.widgetWithText(FilledButton, 'Back up and continue'),
|
||||
)
|
||||
.onPressed!;
|
||||
await f.tap(tester, 'Cancel setup');
|
||||
expect((await f.storage.loadAll()).first.nsec, f.keys.nsec);
|
||||
expect(await f.storage.loadActiveId(), community.id);
|
||||
expect(await f.store.read(), isNull);
|
||||
|
||||
// Re-enter on the same owner: disposal must not be the only fence.
|
||||
await f.flow.signInAccount();
|
||||
await f.settle(tester);
|
||||
expect(f.flow.stage, HostedFlowStage.chooseIdentity);
|
||||
await f.tap(tester, fullNpub(f.secondKeys.public)!);
|
||||
staleConfirm();
|
||||
await f.settle(tester);
|
||||
expect(f.flow.stage, HostedFlowStage.confirmBackup);
|
||||
expect(f.flow.backupPubkey, f.secondKeys.public);
|
||||
expect(await f.store.read(), isNull);
|
||||
expect(f.bound, isNull);
|
||||
expect(f.sent('/verify'), isEmpty);
|
||||
expect(f.sent('/enrollment'), isEmpty);
|
||||
expect(f.sent('/nsec-backups'), isEmpty);
|
||||
await f.restart(tester);
|
||||
expect(f.bound, isNull);
|
||||
expect(f.sent('/verify'), isEmpty);
|
||||
expect(f.sent('/enrollment'), isEmpty);
|
||||
await f.flow.signInAccount();
|
||||
await f.settle(tester);
|
||||
expect(f.flow.stage, HostedFlowStage.chooseIdentity);
|
||||
await f.tap(tester, fullNpub(f.keys.public)!);
|
||||
expect(find.text('Back up your secret key?'), findsOneWidget);
|
||||
staleConfirm();
|
||||
await f.settle(tester);
|
||||
expect(f.bound, isNull);
|
||||
expect(f.sent('/verify'), isEmpty);
|
||||
expect(f.sent('/enrollment'), isEmpty);
|
||||
expect(f.sent('/nsec-backups'), isEmpty);
|
||||
// Android/system Back also discards the tentative selection.
|
||||
await tester.binding.handlePopRoute();
|
||||
await f.settle(tester);
|
||||
expect(f.flow.stage, HostedFlowStage.idle);
|
||||
expect((await f.storage.loadAll()).first.nsec, f.keys.nsec);
|
||||
await f.flow.signInAccount();
|
||||
await f.settle(tester);
|
||||
await f.tap(tester, fullNpub(f.secondKeys.public)!);
|
||||
// A restored unintended account is recognizable and can be signed out.
|
||||
await f.tap(tester, 'Sign out');
|
||||
expect(f.flow.account.account, isNull);
|
||||
expect(await f.store.read(), isNull);
|
||||
f.accountEmail = null;
|
||||
await f.flow.signInAccount();
|
||||
await f.settle(tester);
|
||||
await f.tap(tester, fullNpub(f.secondKeys.public)!);
|
||||
expect(f.flow.confirmBackup, isNull);
|
||||
expect(
|
||||
find.textContaining('couldn’t identify the receiving account'),
|
||||
findsOneWidget,
|
||||
);
|
||||
await f.tap(tester, 'Cancel setup');
|
||||
f.accountEmail = 'owner@example.com';
|
||||
await f.flow.signInAccount();
|
||||
await f.settle(tester);
|
||||
await f.tap(tester, fullNpub(f.secondKeys.public)!);
|
||||
expect(
|
||||
find.text('Receiving account: owner@example.com'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
// The server accepted the upload, but the acknowledgement was lost.
|
||||
f.beforeUploadReturn = () async {
|
||||
f.cloudKeys.add(f.secondKeys);
|
||||
throw http.ClientException('Synthetic lost upload response');
|
||||
};
|
||||
await f.tap(tester, 'Back up and continue');
|
||||
expect(f.bound, f.secondKeys.public);
|
||||
expect(f.sent('/enrollment'), hasLength(1));
|
||||
expect(f.sent('/nsec-backups'), hasLength(1));
|
||||
expect((await f.store.read())!.backup, isNull);
|
||||
await f.tap(tester, 'Try again');
|
||||
expect(find.textContaining('does not undo'), findsOneWidget);
|
||||
expect(find.textContaining('confirmation was lost'), findsOneWidget);
|
||||
await f.tap(tester, 'Cancel setup');
|
||||
expect(f.bound, f.secondKeys.public);
|
||||
expect(f.cloudKeys.single.public, f.secondKeys.public);
|
||||
expect(f.sent('/nsec-backups'), hasLength(1));
|
||||
await f.restart(tester);
|
||||
await f.flow.signInAccount();
|
||||
await f.settle(tester);
|
||||
expect(find.text('Back up your secret key?'), findsOneWidget);
|
||||
expect(find.textContaining('does not undo'), findsOneWidget);
|
||||
f.beforeUploadReturn = null;
|
||||
await f.tap(tester, 'Back up and continue');
|
||||
expect((await f.store.read())!.backup!.pubkey, f.secondKeys.public);
|
||||
expect(
|
||||
(await f.storage.loadAll()).map((c) => c.nsec),
|
||||
containsAll([f.keys.nsec, f.secondKeys.nsec]),
|
||||
);
|
||||
await f.restart(tester);
|
||||
await f.flow.signInAccount();
|
||||
await f.settle(tester);
|
||||
expect(find.text('Back up your secret key?'), findsNothing);
|
||||
expect(f.sent('/nsec-backups'), hasLength(2));
|
||||
} finally {
|
||||
await f.dispose(tester);
|
||||
}
|
||||
}, f.client),
|
||||
);
|
||||
}, skip: HostedEnvironment.authDomains.isEmpty);
|
||||
journey(
|
||||
'web binding keeps an explicit identity choice without backup support',
|
||||
(tester, f) async {
|
||||
await f.initialize(localIdentity: false);
|
||||
f.bound = null;
|
||||
await f.mount(tester);
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('buzz/hosted_hpke'),
|
||||
(_) async => false,
|
||||
);
|
||||
final nativeChannels = <MethodChannel>[];
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform_views,
|
||||
(call) async {
|
||||
if (call.method == 'create') {
|
||||
final id = (call.arguments as Map)['id'];
|
||||
final channel = MethodChannel('buzz/ios_navigation_bar/$id');
|
||||
nativeChannels.add(channel);
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
channel,
|
||||
(_) async => null,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.flutter.io/url_launcher'),
|
||||
(_) async => true,
|
||||
);
|
||||
void openBindLink() {
|
||||
f.container
|
||||
.read(pendingDeepLinkProvider.notifier)
|
||||
.open(
|
||||
Uri(
|
||||
scheme: 'buzz',
|
||||
host: 'nostr-bind',
|
||||
queryParameters: {
|
||||
'challenge_id': '12345678-1234-1234-1234-123456789012',
|
||||
'nonce': 'a' * 43,
|
||||
'verification_code': '123456',
|
||||
'audience': 'buzz:nostr-identity',
|
||||
'action': 'bind_nostr_identity',
|
||||
'protocol': 'buzz-nostr-identity',
|
||||
'version': '1',
|
||||
'origin': HostedEnvironment.origin.toString(),
|
||||
'expires_at': '2099-01-01T00:00:00Z',
|
||||
'return': 'browser_fragment_v1',
|
||||
'callback_url': HostedEnvironment.origin
|
||||
.resolve('/buzz')
|
||||
.toString(),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
openBindLink();
|
||||
await f.settle(tester);
|
||||
expect(
|
||||
find.text('Create my Buzz identity').hitTestable(),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.text('Import existing identity').hitTestable(),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(await f.store.read(), isNull);
|
||||
expect(f.bound, isNull);
|
||||
expect(f.sent('/verify'), isEmpty);
|
||||
await f.tap(tester, 'Import existing identity');
|
||||
await tester.enterText(
|
||||
find.byType(TextField).hitTestable(),
|
||||
f.secondKeys.nsec,
|
||||
);
|
||||
await f.tap(tester, 'Preview identity');
|
||||
await f.tap(tester, 'Import this identity');
|
||||
expect(find.text('Link your identity?'), findsOneWidget);
|
||||
expect(find.textContaining('No backup is uploaded'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Receiving account: owner@example.com'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(await f.store.read(), isNull);
|
||||
expect(f.sent('/verify'), isEmpty);
|
||||
expect(f.sent('/enrollment'), isEmpty);
|
||||
expect(f.sent('/nsec-backups'), isEmpty);
|
||||
await f.tap(tester, 'Cancel setup');
|
||||
expect(await f.store.read(), isNull);
|
||||
expect(f.sent('/verify'), isEmpty);
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
openBindLink();
|
||||
await f.settle(tester);
|
||||
await f.tap(tester, 'Create my Buzz identity');
|
||||
expect(f.flow.problem, HostedFlowProblem.unsupportedPlatform);
|
||||
expect(await f.store.read(), isNull);
|
||||
expect(f.bound, isNull);
|
||||
// Once the OS supports sealing, creation still requires an explicit tap.
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('buzz/hosted_hpke'),
|
||||
(_) async => true,
|
||||
);
|
||||
await f.flow.retry();
|
||||
await f.settle(tester);
|
||||
expect(await f.store.read(), isNull);
|
||||
await f.tap(tester, 'Create my Buzz identity');
|
||||
final saved = (await f.store.read())!;
|
||||
expect(f.bound, saved.pubkey);
|
||||
expect(
|
||||
saved.backup,
|
||||
isNull,
|
||||
); // Web binding has never promised mobile backup.
|
||||
expect(f.flow.stage, HostedFlowStage.browserReturned);
|
||||
|
||||
// A returning account may already have a saved key from an interrupted
|
||||
// setup. No adoption occurs, so the bind-only gate must ask for consent.
|
||||
await f.flow.signOutAccount();
|
||||
f.subject = 'returning-owner';
|
||||
f.bound = null;
|
||||
await f.store.create(f.secondKeys.nsec);
|
||||
await f.flow.account.exchange(
|
||||
code: 'synthetic-code',
|
||||
verifier: 'v' * 43,
|
||||
);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
await f.restart(tester);
|
||||
f.requests.clear();
|
||||
openBindLink();
|
||||
await f.settle(tester);
|
||||
expect(find.text('Link your identity?'), findsOneWidget);
|
||||
expect(find.textContaining('No backup is uploaded'), findsOneWidget);
|
||||
expect(find.text(fullNpub(f.secondKeys.public)!), findsOneWidget);
|
||||
expect((await f.store.read())!.backup, isNull);
|
||||
expect(f.bound, isNull);
|
||||
expect(f.sent('/verify'), isEmpty);
|
||||
await f.tap(tester, 'Cancel setup');
|
||||
expect((await f.store.read())!.pubkey, f.secondKeys.public);
|
||||
expect(f.bound, isNull);
|
||||
expect(f.sent('/verify'), isEmpty);
|
||||
|
||||
// Cancellation and restart retain the key, but never retain approval.
|
||||
await f.restart(tester);
|
||||
openBindLink();
|
||||
await f.settle(tester);
|
||||
expect(find.text('Link your identity?'), findsOneWidget);
|
||||
expect(f.sent('/verify'), isEmpty);
|
||||
await f.tap(tester, 'Link and continue');
|
||||
expect(f.bound, f.secondKeys.public);
|
||||
expect(f.sent('/verify'), hasLength(1));
|
||||
expect(f.sent('/enrollment'), isEmpty);
|
||||
expect(f.sent('/nsec-backups'), isEmpty);
|
||||
expect((await f.store.read())!.backup, isNull);
|
||||
expect(f.flow.stage, HostedFlowStage.browserReturned);
|
||||
} finally {
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump();
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
for (final channel in nativeChannels) {
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
channel,
|
||||
null,
|
||||
);
|
||||
}
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform_views,
|
||||
null,
|
||||
);
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.flutter.io/url_launcher'),
|
||||
null,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -289,6 +289,9 @@ void main() {
|
||||
release.complete();
|
||||
f.beforeStatusReturn = null;
|
||||
await f.settle(tester);
|
||||
expect(await f.store.read(), isNull);
|
||||
expect(f.bound, isNull);
|
||||
await f.tap(tester, 'Back up and continue');
|
||||
expect(f.bound, f.keys.public);
|
||||
}
|
||||
final saved = await f.store.read();
|
||||
@@ -315,6 +318,7 @@ void main() {
|
||||
} else {
|
||||
await f.tap(tester, 'Try again');
|
||||
}
|
||||
await f.tap(tester, 'Back up and continue');
|
||||
expect((await f.store.read())!.pubkey, saved.pubkey);
|
||||
expect((await f.store.read())!.backup, isNotNull);
|
||||
if (entry == 'import') {
|
||||
@@ -341,115 +345,6 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
journey(
|
||||
'web binding keeps an explicit identity choice without backup support',
|
||||
(tester, f) async {
|
||||
await f.initialize(localIdentity: false);
|
||||
f.bound = null;
|
||||
await f.mount(tester);
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('buzz/hosted_hpke'),
|
||||
(_) async => false,
|
||||
);
|
||||
final nativeChannels = <MethodChannel>[];
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform_views,
|
||||
(call) async {
|
||||
if (call.method == 'create') {
|
||||
final id = (call.arguments as Map)['id'];
|
||||
final channel = MethodChannel('buzz/ios_navigation_bar/$id');
|
||||
nativeChannels.add(channel);
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
channel,
|
||||
(_) async => null,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.flutter.io/url_launcher'),
|
||||
(_) async => true,
|
||||
);
|
||||
try {
|
||||
f.container
|
||||
.read(pendingDeepLinkProvider.notifier)
|
||||
.open(
|
||||
Uri(
|
||||
scheme: 'buzz',
|
||||
host: 'nostr-bind',
|
||||
queryParameters: {
|
||||
'challenge_id': '12345678-1234-1234-1234-123456789012',
|
||||
'nonce': 'a' * 43,
|
||||
'verification_code': '123456',
|
||||
'audience': 'buzz:nostr-identity',
|
||||
'action': 'bind_nostr_identity',
|
||||
'protocol': 'buzz-nostr-identity',
|
||||
'version': '1',
|
||||
'origin': HostedEnvironment.origin.toString(),
|
||||
'expires_at': '2099-01-01T00:00:00Z',
|
||||
'return': 'browser_fragment_v1',
|
||||
'callback_url': HostedEnvironment.origin
|
||||
.resolve('/buzz')
|
||||
.toString(),
|
||||
},
|
||||
),
|
||||
);
|
||||
await f.settle(tester);
|
||||
expect(
|
||||
find.text('Create my Buzz identity').hitTestable(),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.text('Import existing identity').hitTestable(),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(await f.store.read(), isNull);
|
||||
expect(f.bound, isNull);
|
||||
expect(f.sent('/verify'), isEmpty);
|
||||
await f.tap(tester, 'Create my Buzz identity');
|
||||
expect(f.flow.problem, HostedFlowProblem.unsupportedPlatform);
|
||||
expect(await f.store.read(), isNull);
|
||||
expect(f.bound, isNull);
|
||||
// Once the OS supports sealing, creation still requires an explicit tap.
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('buzz/hosted_hpke'),
|
||||
(_) async => true,
|
||||
);
|
||||
await f.flow.retry();
|
||||
await f.settle(tester);
|
||||
expect(await f.store.read(), isNull);
|
||||
await f.tap(tester, 'Create my Buzz identity');
|
||||
final saved = (await f.store.read())!;
|
||||
expect(f.bound, saved.pubkey);
|
||||
expect(
|
||||
saved.backup,
|
||||
isNull,
|
||||
); // Web binding has never promised mobile backup.
|
||||
expect(f.flow.stage, HostedFlowStage.browserReturned);
|
||||
} finally {
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump();
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
for (final channel in nativeChannels) {
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
channel,
|
||||
null,
|
||||
);
|
||||
}
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform_views,
|
||||
null,
|
||||
);
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.flutter.io/url_launcher'),
|
||||
null,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
journey('linked account without backup imports its key without replacing it', (
|
||||
tester,
|
||||
f,
|
||||
@@ -482,6 +377,8 @@ void main() {
|
||||
await tester.enterText(find.byType(TextField).hitTestable(), f.keys.nsec);
|
||||
await f.tap(tester, 'Preview identity');
|
||||
await f.tap(tester, 'Import this identity');
|
||||
expect(await f.store.read(), isNull);
|
||||
await f.tap(tester, 'Back up and continue');
|
||||
expect((await f.store.read())!.pubkey, f.keys.public);
|
||||
expect((await f.store.read())!.backup, isNotNull);
|
||||
expect(f.requests.where((r) => r.url.path.endsWith('/recovery')), isEmpty);
|
||||
@@ -1170,6 +1067,7 @@ void main() {
|
||||
// still be durable; resuming must not replay an acknowledged backup.
|
||||
f.beforeUploadReturn = () => f.flow.leave();
|
||||
await f.link(tester, 'one');
|
||||
await f.tap(tester, 'Back up and continue');
|
||||
expect((await f.store.read())!.backup, isNotNull);
|
||||
f.beforeUploadReturn = null;
|
||||
// Production legal documents exceed the ordinary 64 KiB account limit.
|
||||
@@ -1286,6 +1184,7 @@ void main() {
|
||||
expect(f.sent('/nsec-backups'), isEmpty);
|
||||
expect(await f.store.read(), isNull);
|
||||
await f.tap(tester, fullNpub(f.keys.public)!);
|
||||
await f.tap(tester, 'Back up and continue');
|
||||
}
|
||||
final saved = (await f.store.read())!;
|
||||
expect(
|
||||
@@ -1337,6 +1236,7 @@ void main() {
|
||||
await f.flow.signInAccount();
|
||||
await f.settle(tester);
|
||||
await f.tap(tester, fullNpub(f.keys.public)!);
|
||||
await f.tap(tester, 'Back up and continue');
|
||||
return original;
|
||||
}
|
||||
|
||||
@@ -1350,6 +1250,7 @@ void main() {
|
||||
await f.restart(tester);
|
||||
await f.flow.signInAccount();
|
||||
await f.settle(tester);
|
||||
await f.tap(tester, 'Back up and continue');
|
||||
// The saved choice skips selection and retries the same rejection.
|
||||
expect(f.flow.problem, HostedFlowProblem.requestRejected);
|
||||
await f.tap(tester, 'Choose a different identity');
|
||||
@@ -1438,6 +1339,7 @@ void main() {
|
||||
await f.restart(tester);
|
||||
await f.flow.signInAccount();
|
||||
await f.settle(tester);
|
||||
await f.tap(tester, 'Back up and continue');
|
||||
expect(f.flow.problem, HostedFlowProblem.requestRejected);
|
||||
expect(f.flow.canChooseDifferentIdentity, isTrue);
|
||||
keychain.sticky = null;
|
||||
@@ -1500,6 +1402,7 @@ void main() {
|
||||
);
|
||||
await f.flow.retry();
|
||||
await f.settle(tester);
|
||||
await f.tap(tester, 'Back up and continue');
|
||||
expect(f.flow.problem, HostedFlowProblem.requestRejected);
|
||||
expect(f.flow.canChooseDifferentIdentity, isFalse);
|
||||
expect(find.text('Choose a different identity'), findsNothing);
|
||||
@@ -1532,10 +1435,12 @@ void main() {
|
||||
..bindStatus = 409;
|
||||
await f.flow.retry();
|
||||
await f.settle(tester);
|
||||
await f.tap(tester, 'Back up and continue');
|
||||
expect(f.flow.canChooseDifferentIdentity, isTrue);
|
||||
f.challengeStatus = 409;
|
||||
await f.flow.retry();
|
||||
await f.settle(tester);
|
||||
await f.tap(tester, 'Back up and continue');
|
||||
// The earlier attempt's rejection is not reused.
|
||||
expect(f.flow.problem, HostedFlowProblem.requestRejected);
|
||||
expect(f.flow.canChooseDifferentIdentity, isFalse);
|
||||
@@ -1580,7 +1485,7 @@ void main() {
|
||||
await f.tap(tester, 'Try again');
|
||||
expect(f.flow.stage, HostedFlowStage.idle);
|
||||
expect(f.probes, isEmpty);
|
||||
expect(find.text('Signed in to BuilderLab'), findsOneWidget);
|
||||
expect(find.text('Signed in as owner@example.com'), findsOneWidget);
|
||||
expect(find.text('Scan a QR code'), findsOneWidget);
|
||||
expect(find.text('Use pairing code'), findsNothing);
|
||||
f.rows.addAll([f.row('one'), f.row('two')]);
|
||||
|
||||
@@ -40,13 +40,18 @@ void main() {
|
||||
final settled = Completer<void>();
|
||||
f.flow.addListener(() {
|
||||
if ((f.flow.stage == HostedFlowStage.complete ||
|
||||
f.flow.stage == HostedFlowStage.blocked) &&
|
||||
f.flow.stage == HostedFlowStage.blocked ||
|
||||
f.flow.stage == HostedFlowStage.confirmBackup) &&
|
||||
!settled.isCompleted) {
|
||||
settled.complete();
|
||||
}
|
||||
});
|
||||
await container.read(pairingProvider.notifier).pair(_payload(f.keys));
|
||||
await settled.future.timeout(const Duration(seconds: 2));
|
||||
if (!backup) {
|
||||
expect(f.flow.stage, HostedFlowStage.confirmBackup);
|
||||
await f.flow.confirmBackup!();
|
||||
}
|
||||
expect(legacyProbes, 0);
|
||||
expect(f.admitted, backup ? ['paired.communities.buzz.xyz'] : isEmpty);
|
||||
expect((await f.store.read())?.pubkey, f.keys.public);
|
||||
|
||||
@@ -83,6 +83,7 @@ class HostedJourneyFixture {
|
||||
bool denyAssertion = false;
|
||||
bool refuseUpgrade = false;
|
||||
String subject = 'journey-owner';
|
||||
String? accountEmail = 'owner@example.com';
|
||||
|
||||
/// An organization realm, as an internal build's BUZZ_BUILDERLAB_URL and
|
||||
/// BUZZ_BUILDERLAB_RELAYS configure. Enable before [mount].
|
||||
@@ -158,6 +159,7 @@ class HostedJourneyFixture {
|
||||
if (expiredSession) return http.Response('{}', 401);
|
||||
result = {
|
||||
'subject': subject,
|
||||
'email': accountEmail,
|
||||
'expires_at': expiry,
|
||||
'account_status': needsAccountSetup
|
||||
? 'ACCOUNT_STATUS_NEEDS_DETAILS'
|
||||
|
||||
@@ -204,6 +204,7 @@ void main() {
|
||||
final storage = UncertainStorage();
|
||||
final store = AccountIdentityStore(storage: storage);
|
||||
var authorizations = 0;
|
||||
var generatedHere = 0;
|
||||
final receipt = BackupReceipt(
|
||||
backend: 'https://app.builderlab.xyz',
|
||||
subject: 'a',
|
||||
@@ -212,6 +213,7 @@ void main() {
|
||||
);
|
||||
Future<AccountIdentitySnapshot?> write() => generate
|
||||
? store.generateIfAbsent(
|
||||
onGenerated: () => generatedHere++,
|
||||
authorize: () async {
|
||||
authorizations++;
|
||||
return true;
|
||||
@@ -223,6 +225,8 @@ void main() {
|
||||
final retried = (await write())!;
|
||||
expect(retried.pubkey, persisted.pubkey);
|
||||
expect(authorizations, generate ? 1 : 0);
|
||||
// An uncertain write or an existing slot is never fresh creation consent.
|
||||
expect(generatedHere, 0);
|
||||
if (!generate) {
|
||||
expect(retried.backup?.backupId, 'one');
|
||||
final other = nostr.Keys('2'.padLeft(64, '0'));
|
||||
|
||||
@@ -14,8 +14,11 @@ class InventoryAccount extends BuilderlabAccountClient {
|
||||
Map<String, dynamic> recovery = {};
|
||||
final calls = <String>[];
|
||||
@override
|
||||
BuilderlabAccount get account =>
|
||||
BuilderlabAccount(subject: 'fixture', expiresAt: DateTime.utc(2100));
|
||||
BuilderlabAccount get account => BuilderlabAccount(
|
||||
subject: 'fixture',
|
||||
email: 'owner@example.com',
|
||||
expiresAt: DateTime.utc(2100),
|
||||
);
|
||||
@override
|
||||
Future<BuilderlabAccount?> restore() async => account;
|
||||
@override
|
||||
|
||||
@@ -52,6 +52,10 @@ void main() {
|
||||
if (failure == 'binding') f.bound = '2' * 64;
|
||||
final before = await const FlutterSecureStorage().readAll();
|
||||
await f.flow.signInAccount();
|
||||
if (failure == 'backup') {
|
||||
expect(f.flow.stage, HostedFlowStage.confirmBackup);
|
||||
await f.flow.confirmBackup!();
|
||||
}
|
||||
expect(f.flow.stage, HostedFlowStage.blocked);
|
||||
if (failure == 'binding') {
|
||||
expect(f.flow.problem, HostedFlowProblem.identityMismatch);
|
||||
|
||||
@@ -97,6 +97,8 @@ void main() {
|
||||
final choosing = flow.chooseIdentity(second.public);
|
||||
expect(flow.identityChoices, isEmpty);
|
||||
await choosing;
|
||||
expect(flow.stage, HostedFlowStage.confirmBackup);
|
||||
await flow.confirmBackup!();
|
||||
expect(flow.problem, HostedFlowProblem.denied);
|
||||
final completedCalls = List.of(account.calls);
|
||||
await flow.chooseIdentity(first.public);
|
||||
|
||||
Reference in New Issue
Block a user