Files
buzz/mobile/lib/features/settings/settings_page/connection_section.dart
6eb65919f1 feat(identity): recover desktop identity from a signed-in phone (#4845)
**Category:** new-feature
**User Impact:** People who lose a desktop identity can securely restore
it from a signed-in Buzz phone without creating a replacement identity.

**Problem:** A fresh or identity-lost desktop could not recover its
existing full Buzz identity from an already-authorized phone.

**Solution:** Add a SAS-confirmed reverse NIP-AB transfer, durable
desktop import, a dedicated mobile recovery entry point, and clearer
desktop recovery dialogs with tested loading, drag-and-drop, and failure
states.


https://github.com/user-attachments/assets/e9215c9c-80d0-462f-9161-0fa184ca2f74

<details>
<summary>File changes</summary>

**crates/buzz-core/src/pairing/session.rs**
Adds the reverse encrypted payload and source-completion state
transitions used for phone-to-desktop recovery.

**desktop/src-tauri/src/commands/identity.rs**
Exposes the existing guarded identity commit path for recovery imports.

**desktop/src-tauri/src/commands/pairing.rs**
Adds recovery-mode pairing, durable nsec import, start serialization,
stale-task protection, and explicit rejection of unsupported recovery
payloads.

**desktop/src-tauri/src/lib.rs**
Registers the recovery pairing command.

**desktop/src/app/App.tsx**
Refreshes the recovered identity before continuing onboarding.

**desktop/src/features/onboarding/machineOnboarding.ts**
Adds recovery transitions to the onboarding state machine.

**desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx**
Adds the visual backup-to-password-to-unlock progression.

**desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx**
Implements QR generation, copy fallback, SAS confirmation, cancellation,
expiry, and completion UI.

**desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx**
Connects private-key, phone, and backup recovery paths to the onboarding
flow.

**desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx**
Polishes recovery dialogs, backup drag-and-drop, loading stability, and
security copy.

**desktop/src/shared/api/tauri.ts**
Keeps the existing pairing API surface focused on standard
desktop-to-mobile pairing.

**desktop/src/shared/api/tauriPairing.ts**
Adds the recovery pairing invoke without growing the ratcheted shared
API file.

**desktop/src/testing/e2eBridge.ts**
Mocks recovery pairing commands and lifecycle events for browser tests.

**desktop/tests/e2e/identity-lost.spec.ts**
Covers lost-identity entry, QR/copy recovery, SAS, cancellation, expiry,
success, errors, backup import, drag-and-drop, and screenshots.

**desktop/tests/e2e/onboarding.spec.ts**
Verifies recovered identities continue through harness setup without
replacement-key side effects.

**mobile/lib/features/pairing/pairing_page.dart**
Adds recovery-only scanning and explicit identity-handoff warnings.

**mobile/lib/features/pairing/pairing_provider.dart**
Recognizes recovery codes, returns the signed-in nsec after mutual SAS
approval, and waits for desktop completion.

**mobile/lib/features/settings/settings_page.dart**
Accepts the recovery route builder at the app composition boundary to
preserve feature isolation.

**mobile/lib/features/settings/settings_page/connection_section.dart**
Adds the signed-in “Send identity to desktop” settings action.

**mobile/test/features/pairing/pairing_page_test.dart**
Covers recovery-only validation and handoff messaging.

**mobile/test/features/pairing/pairing_provider_test.dart**
Covers reverse payload encryption, confirmation ordering, success,
failure, timeout, and cleanup.

</details>

## Reproduction steps

1. Launch Buzz Desktop with identity-lost state and choose **Recover
from your phone**.
2. Confirm the QR and persistent **Copy pairing code** fallback appear
without layout shift.
3. On a signed-in phone, open **Settings → Send identity to desktop**,
scan or paste the recovery code, and compare the six-digit SAS on both
devices.
4. Confirm on both sides and verify Desktop restores the identity and
continues to harness setup.
5. Repeat from identity-lost state with **Recover from a backup file**;
verify picker and drag-and-drop both advance to password entry and
restore the encrypted backup.
6. Exercise cancellation, mismatched/unsupported codes, expired
sessions, and an invalid backup; verify each returns actionable,
non-stuck UI.

## Screenshots

### Desktop phone recovery — complete flow

| Recovery entry | Pairing QR | Code match | Receiving identity |
|---|---|---|---|
| ![Desktop recovery
entry](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/desktop-phone-01-recovery-entry.png)
| ![Desktop phone recovery
QR](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/desktop-phone-02-qr.png)
| ![Desktop security-code
match](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/desktop-phone-03-sas.png)
| ![Desktop receiving
identity](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/desktop-phone-04-receiving.png)
|

### iOS Simulator — complete handoff flow

| Settings entry | Recovery scanner | Manual recovery code | Code
confirmation |
|---|---|---|---|
| ![iOS Settings entry for Send identity to
desktop](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/ios-01-settings-entry.png)
| ![iOS recovery scanner
entry](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/ios-02-recovery-entry.png)
| ![iOS manual recovery code
entry](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/ios-03-manual-code.png)
| ![iOS security-code
confirmation](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/ios-04-sas-verification.png)
|

### Encrypted backup recovery — adjusted file flow

| File picker | Drag-and-drop target | Password step |
|---|---|---|
| ![Desktop encrypted-backup file
picker](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/desktop-backup-01-file-picker-settled.png)
| ![Desktop encrypted-backup drag-and-drop
target](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/desktop-backup-02-drag-drop.png)
| ![Desktop backup password
step](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/desktop-backup-03-enter-password.png)
|

## Verification

- `cargo test -p buzz-core pairing` — 71 passed
- `just mobile-test` — 1,169 passed
- `pnpm build:e2e && pnpm exec playwright test identity-lost.spec.ts
--project=smoke` — 15 passed
- Full pre-push gates — desktop checks, desktop unit tests, Rust tests,
Tauri checks, and mobile tests passed

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
2026-08-06 11:47:18 -07:00

117 lines
3.4 KiB
Dart

part of '../settings_page.dart';
class _ConnectionSection extends ConsumerWidget {
const _ConnectionSection({required this.identityRecoveryPageBuilder});
final WidgetBuilder identityRecoveryPageBuilder;
@override
Widget build(BuildContext context, WidgetRef ref) {
final config = ref.watch(relayConfigProvider);
final nsec = config.nsec;
return AppListCard(
label: 'Connection',
children: [
AppListRow(
icon: LucideIcons.server,
title: 'Connected to',
subtitle: config.baseUrl,
),
if (nsec != null && nsec.isNotEmpty) ...[
_IdentityRow(nsec: nsec),
AppListRow(
icon: LucideIcons.scanQrCode,
title: 'Send identity to desktop',
subtitle: 'Scan a recovery code shown by Buzz Desktop',
trailing: const _RowChevron(),
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: identityRecoveryPageBuilder),
),
),
],
],
);
}
}
/// Destructive, so it gets a container of its own rather than sitting at the
/// bottom of the connection group.
class _RemoveCommunitySection extends ConsumerWidget {
const _RemoveCommunitySection();
@override
Widget build(BuildContext context, WidgetRef ref) {
return AppListCard(
children: [
AppListRow(
icon: LucideIcons.logOut,
title: 'Remove community',
titleColor: context.colors.error,
onTap: () => _confirmRemoveCommunity(context, ref),
),
],
);
}
}
class _IdentityRow extends StatelessWidget {
const _IdentityRow({required this.nsec});
final String nsec;
@override
Widget build(BuildContext context) {
final privHex = nostr.Nip19.decode(payload: nsec).data;
final pubkey = privHex.isNotEmpty ? nostr.Keys(privHex).public : 'unknown';
return AppListRow(
icon: LucideIcons.key,
title: 'Identity (pubkey)',
subtitle: pubkey,
subtitleStyle: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
fontFamily: 'GeistMono',
fontSize: 11,
),
subtitleMaxLines: 2,
trailing: IconButton(
icon: const Icon(LucideIcons.copy, size: 16),
onPressed: () async {
await copyToClipboard(context, pubkey, message: 'Pubkey copied');
},
),
);
}
}
void _confirmRemoveCommunity(BuildContext context, WidgetRef ref) {
showBuzzDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Remove Community'),
content: const Text(
'This will disconnect this community. You will need '
'to scan a new pairing code to reconnect.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
Navigator.of(ctx).pop(); // close dialog
// Pop all pushed routes back to root so MaterialApp.home rebuilds
// to PairingPage when auth state changes.
Navigator.of(context).popUntil((route) => route.isFirst);
ref.read(authProvider.notifier).signOut();
},
style: FilledButton.styleFrom(backgroundColor: ctx.colors.error),
child: const Text('Remove'),
),
],
),
);
}