Skip to content

feat(identity): recover desktop identity from a signed-in phone - #4845

Merged
tellaho merged 20 commits into
mainfrom
tho/mobile-to-desktop-pairing
Aug 6, 2026
Merged

feat(identity): recover desktop identity from a signed-in phone#4845
tellaho merged 20 commits into
mainfrom
tho/mobile-to-desktop-pairing

Conversation

@tellaho

@tellaho tellaho commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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.

Screen.Recording.2026-08-05.at.10.58.31.AM.mov
File changes

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.

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 Desktop phone recovery QR Desktop security-code match Desktop receiving identity

iOS Simulator — complete handoff flow

Settings entry Recovery scanner Manual recovery code Code confirmation
iOS Settings entry for Send identity to desktop iOS recovery scanner entry iOS manual recovery code entry iOS security-code confirmation

Encrypted backup recovery — adjusted file flow

File picker Drag-and-drop target Password step
Desktop encrypted-backup file picker Desktop encrypted-backup drag-and-drop target Desktop backup password step

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

@tellaho
tellaho marked this pull request as ready for review August 5, 2026 18:38
@tellaho
tellaho requested a review from a team as a code owner August 5, 2026 18:38

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed on Wes Billman's behalf at 9e7a44603a16f68cbf575ee5bf1c0b078bb00c73. I am requesting changes for two recovery-state blockers:

  1. Superseding or cancelling a recovery does not fence the stale session's durable identity mutation. In pairing_ws_task_inner, the old task holds the shared session mutex while awaiting import_recovered_identity (desktop/src-tauri/src/commands/pairing.rs:357-398). A replacement start increments generation and cancels the old token before waiting for that mutex (:112-124), but the old task never rechecks current generation before persistence. It can therefore accept payload A, be superseded by session B, and still persist A's nsec and replace the live identity. cancel_pairing is weaker: it waits for this same mutex before incrementing generation (:227-252), so cancellation cannot invalidate an import already in flight. The generation checks currently fence emissions/session cleanup, not the security-sensitive commit. Please extract/validate the payload under the session lock, release it, and bind the identity commit to a still-current, unforgeable session generation/token under the identity-mutation boundary; cancellation must invalidate before waiting for the session lock. Add deterministic replacement and cancellation race tests proving a stale accepted payload cannot commit.

  2. A peer-completion write failure turns a successful durable import into a local recovery error. The code commits and swaps the identity first (pairing.rs:398, via identity.rs:416-438), then publishes complete with ? (pairing.rs:399-406), and emits local success only afterward. If the socket closes in that gap, Desktop reports pairing-error and offers retry even though the recovered identity is already authoritative on disk and in memory. Once local commit succeeds, local success must remain authoritative; make peer completion best-effort or surface it separately without regressing recovery state. Add a write-failure-after-commit regression.

The cryptographic binding and core one-shot state transitions otherwise look sound: signed expected-peer events, p-tag checks, transcript/SAS binding, fresh session material, payload-type validation, state gating, and event dedupe are all present. CI is green, but it does not cover these interleavings.

@tellaho

tellaho commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Addressed both recovery-path concerns in ef03df577:

  • Stale/cancelled session fencing: recovery now extracts the payload while holding the pairing-session lock, then releases that lock before persistence. The durable import path acquires identity_mutation and checks the pairing generation immediately before committing, so a superseded or cancelled session cannot replace the active identity. Cancellation now increments the generation and cancels the token before waiting on the session lock.
  • Best-effort peer completion: once the recovered identity has been durably imported, failure to publish the peer complete event no longer converts that local success into an error. The desktop emits local completion regardless of a subsequent socket-write failure.

Added deterministic coverage for supersession, cancellation while recovery is blocked at the mutation boundary, and a failed completion write after successful import. The full desktop Rust suite and Clippy passed, as did all repository pre-push checks after merging current main.

AI-generated comment posted by Carl on behalf of Taylor Ho.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewing on Wes Billman’s behalf at bd23abe9914c00cd1148842e104d58645aea48fd.

The peer-completion failure is fixed, and moving invalidation before the session lock plus rechecking after identity_mutation closes the races where cancellation/replacement happens before that check. One security-sensitive TOCTOU remains:

[P1] Cancellation/replacement can still invalidate the session after the generation check but before the identity commit completes. commit_recovery_if_current reads generation and then invokes the durable commit as two separate operations (desktop/src-tauri/src/commands/pairing.rs:501-507). The caller holds state.identity_mutation (:466-484), but both cancel_pairing and a replacement start_pairing_session increment generation without acquiring that lock (:112-116, :227-233). They can therefore interleave immediately after ensure_pairing_task_is_current returns and while commit_imported_identity persists/swaps the recovered identity. A cancelled or superseded session can still become authoritative.

The new race test covers invalidation while recovery is waiting to acquire the mutation lock (:826-849), not invalidation after the check. Please make invalidation and check-plus-commit mutually exclusive under one shared fence (so whichever wins linearizes cancellation/replacement against durable persistence), and add a deterministic test that pauses after the current-generation check, invalidates, then proves the stale commit cannot proceed. Atomic ordering alone cannot make this compound operation atomic. The dungeon has found the gap between “checked” and “used,” because of course it has.

@tellaho

tellaho commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the requested TOCTOU follow-up in a1af8ac. Cancellation/replacement and recovery commit now share a mutex-backed generation fence: invalidation locks before advancing the generation, while recovery holds the same fence across the generation check and durable identity commit. This makes the ordering explicit—either invalidation wins and stale recovery is rejected, or commit wins and invalidation waits until the identity is authoritative. Added deterministic concurrency coverage for the check-to-commit boundary and moved pairing tests into dedicated modules to stay under the file-size ratchet.\n\nValidated with the full pre-push suite (desktop checks/tests, desktop Tauri clippy/tests, Rust tests, and mobile tests).\n\n— Carl, acting as an AI agent on Taylor Ho’s behalf.

npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w and others added 20 commits August 5, 2026 19:09
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
@tellaho
tellaho force-pushed the tho/mobile-to-desktop-pairing branch from bd74b7d to 9fea883 Compare August 6, 2026 02:11
@brow

brow commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

🤖 Non-blocking notes from a three-seat read of the recovery fence. No blocker. Nothing below needs a change before this merges.

Head these notes apply to: 9fea883bb63b26a3d45d7faa286a53f96f39b975.

The reads were made at the previous head bd74b7d467d42e3dde4b2a26c9103e2797c44488. They carry forward without re-derivation because the reviewed Rust is byte-identical across the rebase: desktop/src-tauri/src/commands/pairing.rs (blob aedd67854c13477b01746be6303baeadb1b5bb29), pairing_generation_tests.rs, pairing_relay_tests.rs, and commands/identity.rs all have the same blob at both heads. Against the new merge base 16cc3de6d, every file the PR touches is either blob-identical to the old head or contributes the identical added and removed lines.

The post-check commit window raised earlier in this PR is closed. Three independent derivations agree. Every production mutation of the pairing generation counter happens inside invalidate_pairing_generation, which takes generation_fence first. The only durable recovery commit reachable from pairing_ws_task runs inside the closure that commit_recovery_if_current invokes while it holds that same fence, so a cancel or a replacement cannot land between the generation check and the durable write. commit_imported_identity and persist_imported_identity have no other production caller on a pairing recovery path. The lock graph only ever runs identity_mutation then generation_fence, never the reverse, and neither guard crosses an await. A removal of the fence from commit_recovery_if_current makes invalidation_after_check_waits_for_identity_commit fail every run, so the guard is load bearing and the test measures it. This note is a report on our reading only. Only the reviewer who requested changes can clear his own review.

1. The fence call sites have no test. The mutants prove the helper bodies. They do not bind the callers to the helpers. When a caller was edited to bump the generation counter directly, and separately to commit recovery outside commit_recovery_if_current, the desktop suite stayed green at 2237 of 2237. So a future edit can quietly reintroduce exactly the race this PR fixes, and CI will not object. One test that asserts the wiring, or a short comment at each call site that names the helper as the only permitted path, would hold the invariant.

2. Lock poison handling is not uniform inside the file. generation_fence and most of the command entry points propagate a poisoned lock as an error, while PairingHandle::clear recovers with unwrap_or_else(|e| e.into_inner()). Failing closed on the fence is defensible and we are not asking for a change. The consequence worth knowing is that one panic while the fence is held disables both start_pairing_session and cancel_pairing for the rest of the process, while clear continues to work. If that asymmetry is deliberate, a one line comment on the fence would say so.

3. An unsupported recovery payload can report the wrong reason. In the branch that validate_recovery_payload_type rejects, the code publishes the failure event to the peer and propagates a publish error with ? before it emits the specific reason. When the publish fails, the user sees publish complete failed: ... and never sees "Mobile device sent an unsupported recovery payload". Emitting the reason before the publish, or carrying it through, keeps the message accurate. The durable path deliberately does the opposite and ignores a completion publish failure once the identity is on disk, which recovery_result_after_completion documents and which we agree with.

4. The one red check is not from this diff. Desktop Smoke E2E (4) fails on video-attachment.spec.ts at the right-click menu selector test. That test passes on main at this PR's own merge base 16cc3de6d in the same shard, the spec file is byte-identical between that merge base and this head, and the diff touches no video, context menu, or download code. The other three specs in that shard are recorded as flaky and passed on retry.

@tellaho

tellaho commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

@wesbillman moving this forward after tom's extra review. double-checked and your feedback should now be covered.

@tellaho
tellaho dismissed wesbillman’s stale review August 6, 2026 18:46

addressed feedback and agent confirmed; also got an extra lookover from tom

@tellaho
tellaho merged commit 6eb6591 into main Aug 6, 2026
54 of 56 checks passed
@tellaho
tellaho deleted the tho/mobile-to-desktop-pairing branch August 6, 2026 18:47
sandro-sq added a commit that referenced this pull request Aug 6, 2026
* origin/main:
  feat(identity): recover desktop identity from a signed-in phone (#4845)

Signed-off-by: Alessandro Joabar <sandro@squareup.com>
tlongwell-block pushed a commit that referenced this pull request Aug 6, 2026
Fold main forward (through 6eb6591, #4845) so CI runs at a fresh SHA
after two runner-starved attempts at 2b5efb9. Requested by Tyler in
community-members-visibility; no rebase, no force. Merged tree verified
green locally (tsc, 4437/4437 desktop suite, pnpm check) before push.

Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
wpfleger96 pushed a commit that referenced this pull request Aug 6, 2026
* origin/main:
  Alert community owners and admins when a new key joins (#4900)
  fix(desktop): prevent sidebar prefs from reverting on stale-localStorage boot (#5086)
  chore(hooks): run desktop typecheck in pre-push (#5110)
  feat(identity): recover desktop identity from a signed-in phone (#4845)

Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
wpfleger96 added a commit that referenced this pull request Aug 6, 2026
* origin/main:
  Alert community owners and admins when a new key joins (#4900)
  fix(desktop): prevent sidebar prefs from reverting on stale-localStorage boot (#5086)
  chore(hooks): run desktop typecheck in pre-push (#5110)
  feat(identity): recover desktop identity from a signed-in phone (#4845)

Signed-off-by: Hayt <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
tlongwell-block pushed a commit that referenced this pull request Aug 7, 2026
Absorbs the relay half that shipped separately in #5133 (squash commit
ad92335): the kind:30179 ingest acceptance hunk in
crates/buzz-relay/src/handlers/ingest.rs was byte-identical on both
sides, so this merge removes all relay-side changes from this PR's
diff. #4999 now carries only the desktop + buzz-core codec half.

No rebase, no force-push — history preserved per operator instruction.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>

* origin/main:
  fix(bench): mention the orchestrator by pubkey when posting the task (#5136)
  feat(relay): accept kind:30179 private managed-agent events at ingest (#5133)
  fix(media): require authenticated reads (#4610)
  fix(desktop): preserve authoritative agent avatars (#4984)
  fix(desktop): next/back navigation during key creation onboarding (#4978)
  Alert community owners and admins when a new key joins (#4900)
  fix(desktop): prevent sidebar prefs from reverting on stale-localStorage boot (#5086)
  chore(hooks): run desktop typecheck in pre-push (#5110)
  feat(identity): recover desktop identity from a signed-in phone (#4845)
  fix(buzz-agent): classify read timeouts distinctly in LLM error messages (#4959)
  Refine agent runtime controls (#5026)
  test(desktop): await thread scroll anchor (#3174)
  Improve desktop mobile pairing flow (#5024)
  feat(desktop): show selected community in rail (#5000)
  fix(desktop): stop rate-limited reconnect backfill from tearing down the authenticated socket (#4990)
  fix(desktop): skip native notifications outside app bundles (#5004)
  ci: prove the relay-driven mesh lifecycle — discover, join, infer, deny — with real nodes (#3862)
  fix(desktop): virtualize channel member lists (#4991)

Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
bradseiler added a commit that referenced this pull request Aug 7, 2026
…igration

* origin/main:
  fix(bench): mention the orchestrator by pubkey when posting the task (#5136)
  feat(relay): accept kind:30179 private managed-agent events at ingest (#5133)
  fix(media): require authenticated reads (#4610)
  fix(desktop): preserve authoritative agent avatars (#4984)
  fix(desktop): next/back navigation during key creation onboarding (#4978)
  Alert community owners and admins when a new key joins (#4900)
  fix(desktop): prevent sidebar prefs from reverting on stale-localStorage boot (#5086)
  chore(hooks): run desktop typecheck in pre-push (#5110)
  feat(identity): recover desktop identity from a signed-in phone (#4845)
  fix(buzz-agent): classify read timeouts distinctly in LLM error messages (#4959)
  Refine agent runtime controls (#5026)
  test(desktop): await thread scroll anchor (#3174)
  Improve desktop mobile pairing flow (#5024)
  feat(desktop): show selected community in rail (#5000)
  fix(desktop): stop rate-limited reconnect backfill from tearing down the authenticated socket (#4990)
  fix(desktop): skip native notifications outside app bundles (#5004)
  ci: prove the relay-driven mesh lifecycle — discover, join, infer, deny — with real nodes (#3862)

Signed-off-by: Brad Seiler <seiler@squareup.com>

# Conflicts:
#	deploy/charts/buzz/templates/deployment.yaml
#	deploy/charts/buzz/tests/render_test.yaml
#	deploy/charts/buzz/values.schema.json
#	deploy/charts/buzz/values.yaml
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants