From 07b0b06dcafe8133563d84fa5b3ce882a90be9c4 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Sat, 1 Aug 2026 18:32:48 -0700 Subject: [PATCH 01/20] feat(pairing): recover desktop identity from phone Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- crates/buzz-core/src/pairing/session.rs | 110 ++++++++ desktop/src-tauri/src/commands/identity.rs | 2 +- desktop/src-tauri/src/commands/pairing.rs | 239 ++++++++++++------ desktop/src-tauri/src/lib.rs | 2 +- .../onboarding/ui/IdentityRecoveryPairing.tsx | 159 ++++++++++++ .../onboarding/ui/MachineOnboardingFlow.tsx | 4 + desktop/src/shared/api/tauri.ts | 2 - desktop/src/shared/api/tauriPairing.ts | 5 + desktop/src/testing/e2eBridge.ts | 7 +- desktop/tests/e2e/identity-lost.spec.ts | 39 +++ mobile/lib/features/pairing/pairing_page.dart | 24 +- .../features/pairing/pairing_provider.dart | 77 +++++- .../lib/features/settings/settings_page.dart | 1 + .../settings_page/connection_section.dart | 18 +- 14 files changed, 591 insertions(+), 98 deletions(-) create mode 100644 desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx create mode 100644 desktop/src/shared/api/tauriPairing.ts diff --git a/crates/buzz-core/src/pairing/session.rs b/crates/buzz-core/src/pairing/session.rs index 431b87fcc0..0d43d4d827 100644 --- a/crates/buzz-core/src/pairing/session.rs +++ b/crates/buzz-core/src/pairing/session.rs @@ -223,6 +223,48 @@ impl PairingSession { Ok(event) } + /// (Source) Process a payload sent back by the target. + /// + /// This is used by recovery flows where the QR-displaying device requests + /// a secret from an already-authorized scanning device. + pub fn handle_return_payload( + &mut self, + event: &Event, + ) -> Result<(PayloadType, Zeroizing), PairingError> { + self.check_expired()?; + self.expect_state(SessionState::Transferring)?; + self.expect_role(Role::Source)?; + self.validate_event_from_peer(event)?; + + let msg = self.decrypt_message(event)?; + match msg { + PairingMessage::Payload { + payload_type, + payload, + } => { + self.state = SessionState::PayloadExchanged; + self.record_event(event); + Ok((payload_type, Zeroizing::new(payload))) + } + other => Err(unexpected("payload", &other)), + } + } + + /// (Source) Report whether a returned payload was imported successfully. + pub fn send_source_complete(&mut self, success: bool) -> Result { + self.check_expired()?; + self.expect_state(SessionState::PayloadExchanged)?; + self.expect_role(Role::Source)?; + + let event = self.build_event(&PairingMessage::Complete { success })?; + self.state = if success { + SessionState::Completed + } else { + SessionState::Aborted + }; + Ok(event) + } + /// (Source) Build the payload event carrying the secret. pub fn send_payload( &mut self, @@ -821,6 +863,74 @@ mod tests { assert_eq!(source.state(), SessionState::Completed); } + /// Reverse happy-path: the scanning target returns an nsec and the source + /// reports the import result. Duplicate payloads remain single-use. + #[test] + fn reverse_payload_flow_is_single_use() { + let (mut source, qr) = PairingSession::new_source("wss://relay.test".into()); + let (mut target, offer) = PairingSession::new_target(&qr).expect("target"); + let source_sas = source.handle_offer(&offer).expect("offer"); + let sas_confirm = source.confirm_sas().expect("source confirm"); + assert_eq!( + target + .handle_sas_confirm(&sas_confirm) + .expect("sas-confirm"), + source_sas + ); + target.confirm_target_sas().expect("target confirm"); + + let payload = target + .build_event(&PairingMessage::Payload { + payload_type: PayloadType::Nsec, + payload: "nsec1recovered".into(), + }) + .expect("return payload"); + let (payload_type, secret) = source + .handle_return_payload(&payload) + .expect("handle return payload"); + assert_eq!(payload_type, PayloadType::Nsec); + assert_eq!(*secret, "nsec1recovered"); + assert_eq!(source.state(), SessionState::PayloadExchanged); + assert!(source.handle_return_payload(&payload).is_err()); + + let complete = source.send_source_complete(true).expect("source complete"); + assert_eq!(source.state(), SessionState::Completed); + assert!(matches!( + target.decrypt_message(&complete).expect("decrypt complete"), + PairingMessage::Complete { success: true } + )); + } + + #[test] + fn reverse_payload_import_failure_aborts_both_peers() { + let (mut source, qr) = PairingSession::new_source("wss://relay.test".into()); + let (mut target, offer) = PairingSession::new_target(&qr).expect("target"); + source.handle_offer(&offer).expect("offer"); + let sas_confirm = source.confirm_sas().expect("source confirm"); + target + .handle_sas_confirm(&sas_confirm) + .expect("sas-confirm"); + target.confirm_target_sas().expect("target confirm"); + let payload = target + .build_event(&PairingMessage::Payload { + payload_type: PayloadType::Nsec, + payload: "invalid".into(), + }) + .expect("return payload"); + source + .handle_return_payload(&payload) + .expect("handle return payload"); + + let complete = source + .send_source_complete(false) + .expect("failure complete"); + assert_eq!(source.state(), SessionState::Aborted); + assert!(matches!( + target.decrypt_message(&complete).expect("decrypt complete"), + PairingMessage::Complete { success: false } + )); + } + /// State machine rejects out-of-order operations. #[test] fn reject_out_of_order_operations() { diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index bddf2e725a..ec2357b85e 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -404,7 +404,7 @@ pub async fn import_identity( /// as a command `Err` would claim a half-applied import that actually /// succeeded. The leftover blob is still passphrase-encrypted and is /// replaced by the next backup creation; we log and move on. -fn commit_imported_identity( +pub(crate) fn commit_imported_identity( state: &AppState, data_dir: &std::path::Path, keys: nostr::Keys, diff --git a/desktop/src-tauri/src/commands/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs index fc874a0150..8a24a00256 100644 --- a/desktop/src-tauri/src/commands/pairing.rs +++ b/desktop/src-tauri/src/commands/pairing.rs @@ -9,7 +9,7 @@ use buzz_core_pkg::pairing::types::{AbortReason, PayloadType}; use futures_util::{SinkExt, StreamExt}; use nostr::ToBech32; use serde::Serialize; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, Emitter, Manager, State}; use tokio::sync::mpsc; use tokio_tungstenite::{connect_async, tungstenite::Message}; use tokio_util::sync::CancellationToken; @@ -33,6 +33,19 @@ struct PairingErrorPayload { message: String, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum PairingMode { + SendIdentity, + RecoverIdentity, +} + +#[derive(Clone)] +struct PairingTaskContext { + mode: PairingMode, + generation: Arc, + task_generation: u64, +} + /// Managed Tauri state for an active pairing session. pub struct PairingHandle { session: Arc>>, @@ -43,6 +56,7 @@ pub struct PairingHandle { /// Pre-built payload string (contains nsec) to send after SAS confirmation. /// Wrapped in Zeroizing so the nsec is cleared from memory on drop. payload: std::sync::Mutex>>, + mode: Arc>, } impl PairingHandle { @@ -53,6 +67,7 @@ impl PairingHandle { cancel: std::sync::Mutex::new(None), outbound_tx: std::sync::Mutex::new(None), payload: std::sync::Mutex::new(None), + mode: Arc::new(std::sync::Mutex::new(PairingMode::SendIdentity)), } } @@ -63,16 +78,32 @@ impl PairingHandle { } } -/// Start a NIP-AB pairing session as the source device. -/// -/// Creates a `PairingSession`, connects to the relay, and returns the -/// `nostrpair://` QR URI for the frontend to display. The mobile peer will -/// receive the desktop's nsec (NIP-OA auth — no token minting needed). +/// Start a NIP-AB pairing session that sends this desktop identity to mobile. #[tauri::command] pub async fn start_pairing( app: AppHandle, state: State<'_, AppState>, pairing: State<'_, PairingHandle>, +) -> Result { + start_pairing_session(app, state, pairing, PairingMode::SendIdentity).await +} + +/// Start a recovery session. The fresh desktop shows the QR and receives the +/// full identity from an already-authorized phone after both users approve SAS. +#[tauri::command] +pub async fn start_identity_recovery_pairing( + app: AppHandle, + state: State<'_, AppState>, + pairing: State<'_, PairingHandle>, +) -> Result { + start_pairing_session(app, state, pairing, PairingMode::RecoverIdentity).await +} + +async fn start_pairing_session( + app: AppHandle, + state: State<'_, AppState>, + pairing: State<'_, PairingHandle>, + mode: PairingMode, ) -> Result { let task_generation = pairing .generation @@ -86,54 +117,51 @@ pub async fn start_pairing( let mut session = pairing.session.lock().await; *session = None; } - - let keys = state.signing_keys()?; - let nsec = keys - .secret_key() - .to_bech32() - .map_err(|e| format!("encode nsec: {e}"))?; - let pubkey_hex = keys.public_key().to_hex(); + *pairing.mode.lock().map_err(|e| e.to_string())? = mode; + *pairing.payload.lock().map_err(|e| e.to_string())? = None; let ws_url = relay_ws_url_with_override(&state); let http_url = relay_api_base_url_with_override(&state); - - // NIP-43 relays gate connections on membership, so an unpaired peer can't - // reach the main relay yet — it must go through the /pair sidecar. Open - // relays (no NIP-43) accept the peer directly. We key off the relay's - // own NIP-11 declaration of NIP-43 support rather than `auth_required`, - // which is also true for plain NIP-42 / NIP-OA relays where the main - // relay is reachable. let pairing_relay_url = resolve_pairing_relay_url(&ws_url, probe_pairing_relay(&ws_url).await)?; - let (session, qr_payload) = PairingSession::new_source(pairing_relay_url.clone()); - let qr_uri = encode_qr(&qr_payload); + let mut qr_uri = encode_qr(&qr_payload); + if mode == PairingMode::RecoverIdentity { + qr_uri.push_str("&mode=recover"); + } - let payload_json = serde_json::json!({ - "relayUrl": http_url, - "pubkey": pubkey_hex, - "nsec": nsec, - }); + if mode == PairingMode::SendIdentity { + let keys = state.signing_keys()?; + let nsec = keys + .secret_key() + .to_bech32() + .map_err(|e| format!("encode nsec: {e}"))?; + let payload_json = serde_json::json!({ + "relayUrl": http_url, + "pubkey": keys.public_key().to_hex(), + "nsec": nsec, + }); + *pairing.payload.lock().map_err(|e| e.to_string())? = + Some(Zeroizing::new(payload_json.to_string())); + } { - let mut s = pairing.session.lock().await; - *s = Some(session); + let mut active = pairing.session.lock().await; + *active = Some(session); } - *pairing.payload.lock().map_err(|e| e.to_string())? = - Some(Zeroizing::new(payload_json.to_string())); let (outbound_tx, outbound_rx) = mpsc::channel::(16); let cancel = CancellationToken::new(); - *pairing.outbound_tx.lock().map_err(|e| e.to_string())? = Some(outbound_tx); *pairing.cancel.lock().map_err(|e| e.to_string())? = Some(cancel.clone()); - let session_arc = Arc::clone(&pairing.session); - let generation = Arc::clone(&pairing.generation); tauri::async_runtime::spawn(pairing_ws_task( pairing_relay_url, - session_arc, - generation, - task_generation, + Arc::clone(&pairing.session), + PairingTaskContext { + mode, + generation: Arc::clone(&pairing.generation), + task_generation, + }, cancel, outbound_rx, app, @@ -163,25 +191,28 @@ pub async fn confirm_pairing_sas(pairing: State<'_, PairingHandle>) -> Result<() .await .map_err(|_| "failed to send sas-confirm")?; - let payload = pairing - .payload - .lock() - .map_err(|e| e.to_string())? - .take() - .ok_or("no payload prepared")?; - - let payload_json = { - let mut guard = pairing.session.lock().await; - let session = guard.as_mut().ok_or("no active pairing session")?; - let event = session - .send_payload(PayloadType::Custom, payload) - .map_err(|e| e.to_string())?; - event_to_relay_json(&event) - }; - - tx.send(payload_json) - .await - .map_err(|_| "failed to send payload")?; + let mode = *pairing.mode.lock().map_err(|e| e.to_string())?; + if mode == PairingMode::SendIdentity { + let payload = pairing + .payload + .lock() + .map_err(|e| e.to_string())? + .take() + .ok_or("no payload prepared")?; + + let payload_json = { + let mut guard = pairing.session.lock().await; + let session = guard.as_mut().ok_or("no active pairing session")?; + let event = session + .send_payload(PayloadType::Custom, payload) + .map_err(|e| e.to_string())?; + event_to_relay_json(&event) + }; + + tx.send(payload_json) + .await + .map_err(|_| "failed to send payload")?; + } Ok(()) } @@ -231,8 +262,7 @@ pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), Str async fn pairing_ws_task( relay_url: String, session: Arc>>, - generation: Arc, - task_generation: u64, + context: PairingTaskContext, cancel: CancellationToken, mut outbound_rx: mpsc::Receiver, app: AppHandle, @@ -240,26 +270,24 @@ async fn pairing_ws_task( if let Err(e) = pairing_ws_task_inner( &relay_url, &session, - &generation, - task_generation, + &context, &cancel, &mut outbound_rx, &app, ) .await { - if pairing_task_is_current(&generation, task_generation) { + if pairing_task_is_current(&context.generation, context.task_generation) { let _ = app.emit("pairing-error", PairingErrorPayload { message: e }); } } - clear_pairing_session_if_current(&session, &generation, task_generation).await; + clear_pairing_session_if_current(&session, &context.generation, context.task_generation).await; } async fn pairing_ws_task_inner( relay_url: &str, session: &Arc>>, - generation: &AtomicU64, - task_generation: u64, + context: &PairingTaskContext, cancel: &CancellationToken, outbound_rx: &mut mpsc::Receiver, app: &AppHandle, @@ -290,14 +318,14 @@ async fn pairing_ws_task_inner( tokio::pin!(hard_timeout); loop { - if !pairing_task_is_current(generation, task_generation) { + if !pairing_task_is_current(&context.generation, context.task_generation) { break; } tokio::select! { _ = cancel.cancelled() => break, _ = &mut hard_timeout => { - if pairing_task_is_current(generation, task_generation) { + if pairing_task_is_current(&context.generation, context.task_generation) { let _ = app.emit("pairing-error", PairingErrorPayload { message: "Session timed out".into(), }); @@ -317,7 +345,7 @@ async fn pairing_ws_task_inner( let Message::Text(text) = msg else { continue }; if let Some(event) = parse_relay_event(text.as_str(), "pair") { - if !pairing_task_is_current(generation, task_generation) { + if !pairing_task_is_current(&context.generation, context.task_generation) { break; } @@ -325,7 +353,7 @@ async fn pairing_ws_task_inner( let Some(s) = guard.as_mut() else { break }; if let Ok(reason) = s.handle_abort(&event) { - if pairing_task_is_current(generation, task_generation) { + if pairing_task_is_current(&context.generation, context.task_generation) { let _ = app.emit("pairing-aborted", PairingAbortedPayload { reason: format!("{reason:?}"), }); @@ -334,28 +362,55 @@ async fn pairing_ws_task_inner( } if let Ok(sas) = s.handle_offer(&event) { - if pairing_task_is_current(generation, task_generation) { + if pairing_task_is_current(&context.generation, context.task_generation) { let _ = app.emit("pairing-sas-received", PairingSasPayload { sas }); } continue; } - match s.handle_complete(&event) { - Ok(()) => { - if pairing_task_is_current(generation, task_generation) { - let _ = app.emit("pairing-complete", serde_json::json!({})); + if context.mode == PairingMode::RecoverIdentity { + if let Ok((PayloadType::Nsec, payload)) = s.handle_return_payload(&event) { + let imported = import_recovered_identity(app, payload).await; + let success = imported.is_ok(); + let complete = s + .send_source_complete(success) + .map_err(|e| e.to_string())?; + write + .send(Message::Text(event_to_relay_json(&complete).into())) + .await + .map_err(|e| format!("publish complete failed: {e}"))?; + match imported { + Ok(()) => { + if pairing_task_is_current(&context.generation, context.task_generation) { + let _ = app.emit("pairing-complete", serde_json::json!({})); + } + } + Err(message) => { + if pairing_task_is_current(&context.generation, context.task_generation) { + let _ = app.emit("pairing-error", PairingErrorPayload { message }); + } + } } break; } - Err(ref e) if format!("{e}").contains("success=false") => { - if pairing_task_is_current(generation, task_generation) { - let _ = app.emit("pairing-error", PairingErrorPayload { - message: "Mobile device reported failure importing credentials".into(), - }); + } else { + match s.handle_complete(&event) { + Ok(()) => { + if pairing_task_is_current(&context.generation, context.task_generation) { + let _ = app.emit("pairing-complete", serde_json::json!({})); + } + break; } - break; + Err(ref e) if format!("{e}").contains("success=false") => { + if pairing_task_is_current(&context.generation, context.task_generation) { + let _ = app.emit("pairing-error", PairingErrorPayload { + message: "Mobile device reported failure importing credentials".into(), + }); + } + break; + } + Err(_) => {} } - Err(_) => {} } } } @@ -365,6 +420,30 @@ async fn pairing_ws_task_inner( Ok(()) } +async fn import_recovered_identity(app: &AppHandle, nsec: Zeroizing) -> Result<(), String> { + let app = app.clone(); + tokio::task::spawn_blocking(move || { + let keys = nostr::Keys::parse(nsec.trim()) + .map_err(|e| format!("Phone sent an invalid identity: {e}"))?; + let state = app.state::(); + let _mutation_guard = state.identity_mutation.lock().map_err(|e| e.to_string())?; + let data_dir = app + .path() + .app_data_dir() + .map_err(|e| format!("app data dir: {e}"))?; + std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?; + let key_path = data_dir.join("identity.key"); + crate::commands::identity::commit_imported_identity(&state, &data_dir, keys, |keys| { + let store = + crate::secret_store::SecretStore::shared(crate::app_state::keyring_service()); + crate::app_state::persist_imported_identity(store, keys, &key_path, &data_dir) + })?; + Ok(()) + }) + .await + .map_err(|e| format!("identity recovery task failed: {e}"))? +} + fn pairing_task_is_current(generation: &AtomicU64, task_generation: u64) -> bool { generation.load(Ordering::SeqCst) == task_generation } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 2847b87877..2468c858cd 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -879,7 +879,7 @@ pub fn run() { list_audio_output_devices, set_audio_output_device, get_audio_output_device, - start_pairing, + start_pairing, start_identity_recovery_pairing, confirm_pairing_sas, cancel_pairing, apply_workspace, diff --git a/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx b/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx new file mode 100644 index 0000000000..708f5649ca --- /dev/null +++ b/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx @@ -0,0 +1,159 @@ +import * as React from "react"; +import { listen } from "@tauri-apps/api/event"; +import { Check, LoaderCircle, RefreshCw, ShieldCheck } from "lucide-react"; + +import { cancelPairing, confirmPairingSas } from "@/shared/api/tauri"; +import { startIdentityRecoveryPairing } from "@/shared/api/tauriPairing"; +import { Button } from "@/shared/ui/button"; +import { StyledQrCode } from "@/shared/ui/styled-qr-code"; + +type Step = "loading" | "qr" | "sas" | "receiving" | "done" | "error"; + +export function IdentityRecoveryPairing({ + onRecovered, +}: { + onRecovered: () => Promise; +}) { + const [step, setStep] = React.useState("loading"); + const [qrUri, setQrUri] = React.useState(null); + const [sas, setSas] = React.useState(null); + const [error, setError] = React.useState(null); + const active = React.useRef(true); + + const start = React.useCallback(async () => { + active.current = true; + setStep("loading"); + setError(null); + setSas(null); + setQrUri(null); + try { + setQrUri(await startIdentityRecoveryPairing()); + setStep("qr"); + } catch (cause) { + setError( + cause instanceof Error ? cause.message : "Could not start recovery.", + ); + setStep("error"); + } + }, []); + + React.useEffect(() => { + void start(); + const unlisteners: Array<() => void> = []; + let disposed = false; + listen<{ sas: string }>("pairing-sas-received", ({ payload }) => { + if (!disposed && active.current) { + setSas(payload.sas); + setStep("sas"); + } + }).then((unlisten) => (disposed ? unlisten() : unlisteners.push(unlisten))); + listen("pairing-complete", () => { + if (!disposed && active.current) { + active.current = false; + setStep("done"); + void onRecovered(); + } + }).then((unlisten) => (disposed ? unlisten() : unlisteners.push(unlisten))); + listen<{ message: string }>("pairing-error", ({ payload }) => { + if (!disposed && active.current) { + active.current = false; + setError(payload.message); + setStep("error"); + } + }).then((unlisten) => (disposed ? unlisten() : unlisteners.push(unlisten))); + listen<{ reason: string }>("pairing-aborted", ({ payload }) => { + if (!disposed && active.current) { + active.current = false; + setError(`Recovery stopped: ${payload.reason}`); + setStep("error"); + } + }).then((unlisten) => (disposed ? unlisten() : unlisteners.push(unlisten))); + return () => { + disposed = true; + active.current = false; + for (const unlisten of unlisteners) unlisten(); + void cancelPairing(); + }; + }, [onRecovered, start]); + + async function confirm() { + setStep("receiving"); + try { + await confirmPairingSas(); + } catch (cause) { + setError( + cause instanceof Error ? cause.message : "Could not confirm recovery.", + ); + setStep("error"); + } + } + + return ( +
+

Scan with Buzz on your phone

+

+ On your signed-in phone, open Settings → Send identity to desktop. This + code expires shortly and works once. +

+
+ {step === "qr" && qrUri ? ( + + ) : step === "sas" && sas ? ( +
+ +

+ {sas.slice(0, 3)} {sas.slice(3)} +

+ +
+ ) : step === "done" ? ( +
+ +

Identity received securely

+
+ ) : step === "error" ? ( +
+

{error}

+ +
+ ) : ( +
+ +

+ {step === "receiving" + ? "Waiting for your phone to send…" + : "Creating secure code…"} +

+
+ )} +
+

+ Your phone will grant this desktop permanent access to your full Buzz + identity. Only approve a desktop you trust and verify the six-digit code + on both screens. +

+
+ ); +} diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index 693d1af058..7e957cb7ab 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -20,6 +20,7 @@ import { useEncryptedBackupSession, } from "./EncryptedBackupCreator"; import { IdentityKeyHelpDialog } from "./IdentityKeyHelpDialog"; +import { IdentityRecoveryPairing } from "./IdentityRecoveryPairing"; import { LandingBees } from "./LandingBees"; import { NostrKeyImportForm, @@ -289,6 +290,9 @@ export function MachineOnboardingFlow({

+ {identityLost && keyImportStage === "key-entry" ? ( + + ) : null} ("nip44_decrypt_from_self", { ciphertext }); } -// ── NIP-AB device pairing ─────────────────────────────────────────────────── - export async function startPairing(): Promise { return invokeTauri("start_pairing"); } diff --git a/desktop/src/shared/api/tauriPairing.ts b/desktop/src/shared/api/tauriPairing.ts new file mode 100644 index 0000000000..6fdf779446 --- /dev/null +++ b/desktop/src/shared/api/tauriPairing.ts @@ -0,0 +1,5 @@ +import { invokeTauri } from "@/shared/api/tauri"; + +export async function startIdentityRecoveryPairing(): Promise { + return invokeTauri("start_identity_recovery_pairing"); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 54238323ae..b863313267 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -12742,12 +12742,15 @@ export function maybeInstallE2eTauriMocks() { case "plugin:webview|set_webview_zoom": window.__BUZZ_E2E_WEBVIEW_ZOOM__ = (payload as { value: number }).value; return; - case "start_pairing": { + case "start_pairing": + case "start_identity_recovery_pairing": { const delayMs = activeConfig?.mock?.pairingStartDelayMs ?? 0; if (delayMs > 0) { await new Promise((resolve) => window.setTimeout(resolve, delayMs)); } - return "nostrpair://8f4b8db31967ce14fef970a1ff1e8eecf19a430aa1c83875e2f5be68dcac0f1a?relay=wss%3A%2F%2Frelay.example.com&secret=87d5a8cfd5807a0cb44f728b67d88d6dcb8daf99be137c158f21a50c1e913c0a&v=1"; + const mode = + command === "start_identity_recovery_pairing" ? "&mode=recover" : ""; + return `nostrpair://8f4b8db31967ce14fef970a1ff1e8eecf19a430aa1c83875e2f5be68dcac0f1a?relay=wss%3A%2F%2Frelay.example.com&secret=87d5a8cfd5807a0cb44f728b67d88d6dcb8daf99be137c158f21a50c1e913c0a&v=1${mode}`; } case "cancel_pairing": case "confirm_pairing_sas": diff --git a/desktop/tests/e2e/identity-lost.spec.ts b/desktop/tests/e2e/identity-lost.spec.ts index 71c663ab4f..2c19864216 100644 --- a/desktop/tests/e2e/identity-lost.spec.ts +++ b/desktop/tests/e2e/identity-lost.spec.ts @@ -66,6 +66,45 @@ test("lost boot opens onboarding gate directly on the key-import page", async ({ ).toBeVisible(); }); +test("lost boot offers phone recovery with a single-use QR", async ({ + page, +}, testInfo) => { + await installMockBridge( + page, + { identityLost: true }, + { skipOnboardingSeed: true }, + ); + await page.goto("/"); + + await expect(page.getByTestId("identity-recovery-pairing")).toBeVisible(); + await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); + await expect( + page.getByText("This code expires shortly and works once."), + ).toBeVisible(); + await expect( + page.getByText(/grant this desktop permanent access/i), + ).toBeVisible(); + await page.waitForTimeout(1_000); // Let the onboarding entrance motion settle. + await page.screenshot({ + path: testInfo.outputPath("desktop-phone-recovery-qr.png"), + fullPage: true, + }); + + const commands = await page.evaluate( + () => + ( + window as Window & { + __BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{ command: string }>; + } + ).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [], + ); + expect( + commands.some( + (entry) => entry.command === "start_identity_recovery_pairing", + ), + ).toBe(true); +}); + test("importing a key from lost mode shows the relaunch-required screen", async ({ page, }) => { diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index 85b781052d..bed449e07a 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -25,8 +25,13 @@ class PairingPage extends HookConsumerWidget { /// When true, the pairing page is being used to add a new community /// (user is already authenticated with at least one community). final bool addingCommunity; + final bool identityRecoveryOnly; - const PairingPage({super.key, this.addingCommunity = false}); + const PairingPage({ + super.key, + this.addingCommunity = false, + this.identityRecoveryOnly = false, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -51,6 +56,13 @@ class PairingPage extends HookConsumerWidget { Future handleScannerResult(String? code) async { if (code != null && context.mounted) { + if (identityRecoveryOnly && + Uri.tryParse(code)?.queryParameters['mode'] != 'recover') { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Scan a desktop recovery code.')), + ); + return; + } await ref.read(pairingProvider.notifier).pair(code); } } @@ -91,7 +103,7 @@ class PairingPage extends HookConsumerWidget { onPressed: () => Navigator.of(context).pop(), ), title: Text( - 'Add Community', + identityRecoveryOnly ? 'Send to Desktop' : 'Add Community', style: isVerifyingSas ? null : context.textTheme.titleMedium?.copyWith( @@ -114,6 +126,8 @@ class PairingPage extends HookConsumerWidget { child: _SasVerificationView( sasCode: pairingState.sasCode ?? '------', confirmed: pairingState.userConfirmedSas, + sendsIdentityToDesktop: + pairingState.sendsIdentityToDesktop, onConfirm: () => ref.read(pairingProvider.notifier).confirmSas(), onDeny: () => ref.read(pairingProvider.notifier).denySas(), @@ -182,12 +196,14 @@ class PairingPage extends HookConsumerWidget { class _SasVerificationView extends StatelessWidget { final String sasCode; final bool confirmed; + final bool sendsIdentityToDesktop; final VoidCallback onConfirm; final VoidCallback onDeny; const _SasVerificationView({ required this.sasCode, required this.confirmed, + required this.sendsIdentityToDesktop, required this.onConfirm, required this.onDeny, }); @@ -242,7 +258,9 @@ class _SasVerificationView extends StatelessWidget { const SizedBox(height: Grid.lg), Text( - 'You are about to transfer your Buzz identity\nto this device. Only confirm if you initiated\nthis pairing from your desktop.', + sendsIdentityToDesktop + ? 'This sends your full Buzz identity to the desktop\nand grants it permanent access. Only confirm a\ndesktop you trust and a recovery you started.' + : 'You are about to transfer your Buzz identity\nto this device. Only confirm if you initiated\nthis pairing from your desktop.', textAlign: TextAlign.center, style: context.textTheme.bodySmall?.copyWith( color: context.colors.onSurfaceVariant, diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index 6a6c57a673..e8f27aa5bc 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -36,12 +36,14 @@ class PairingState { final String? errorMessage; final String? sasCode; final bool userConfirmedSas; + final bool sendsIdentityToDesktop; const PairingState({ this.status = PairingStatus.idle, this.errorMessage, this.sasCode, this.userConfirmedSas = false, + this.sendsIdentityToDesktop = false, }); PairingState copyWith({ @@ -49,11 +51,14 @@ class PairingState { String? errorMessage, String? sasCode, bool? userConfirmedSas, + bool? sendsIdentityToDesktop, }) => PairingState( status: status ?? this.status, errorMessage: errorMessage ?? this.errorMessage, sasCode: sasCode ?? this.sasCode, userConfirmedSas: userConfirmedSas ?? this.userConfirmedSas, + sendsIdentityToDesktop: + sendsIdentityToDesktop ?? this.sendsIdentityToDesktop, ); } @@ -111,10 +116,14 @@ class PairingNotifier extends Notifier { // transition immediately and process any buffered payload. if (_sasConfirmReceived) { state = state.copyWith(status: PairingStatus.transferring); - final pending = _pendingPayload; - if (pending != null) { - _pendingPayload = null; - _handlePayload(pending); + if (_sendIdentityToSource) { + _sendIdentityPayload(); + } else { + final pending = _pendingPayload; + if (pending != null) { + _pendingPayload = null; + _handlePayload(pending); + } } return; } @@ -149,6 +158,7 @@ class PairingNotifier extends Notifier { _sasConfirmReceived = false; _userConfirmedSas = false; _pendingPayload = null; + _sendIdentityToSource = false; } // ── NIP-AB pairing flow ───────────────────────────────────────────────── @@ -163,6 +173,7 @@ class PairingNotifier extends Notifier { Uint8List? _conversationKey; bool _sasConfirmReceived = false; bool _userConfirmedSas = false; + bool _sendIdentityToSource = false; Map? _pendingPayload; // buffered until user confirms SAS final Set _processedEventIds = {}; // NIP-AB §Duplicate Event Handling @@ -174,6 +185,7 @@ class PairingNotifier extends Notifier { final qr = parseNostrpairUri(uri); _sourcePubkey = qr.sourcePubkey; _sessionSecret = qr.sessionSecret; + _sendIdentityToSource = Uri.parse(uri).queryParameters['mode'] == 'recover'; final relayWsUrl = qr.relays.first; @@ -234,6 +246,7 @@ class PairingNotifier extends Notifier { state = PairingState( status: PairingStatus.confirmingSas, sasCode: formatSas(sasCode), + sendsIdentityToDesktop: _sendIdentityToSource, ); // 9. Start 120s session timeout. @@ -359,6 +372,9 @@ class PairingNotifier extends Notifier { case 'abort': _handleAbort(msg); _processedEventIds.add(eventId); + case 'complete': + _handleComplete(msg); + _processedEventIds.add(eventId); } } catch (e) { // Silently discard invalid events per NIP-AB §Event Validation. @@ -400,15 +416,44 @@ class PairingNotifier extends Notifier { if (_userConfirmedSas) { _userConfirmedSas = false; state = state.copyWith(status: PairingStatus.transferring); - final pending = _pendingPayload; - if (pending != null) { - _pendingPayload = null; - _handlePayload(pending); + if (_sendIdentityToSource) { + _sendIdentityPayload(); + } else { + final pending = _pendingPayload; + if (pending != null) { + _pendingPayload = null; + _handlePayload(pending); + } } } // Otherwise stay in confirmingSas — user must still confirm via confirmSas(). } + void _sendIdentityPayload() { + final nsec = ref.read(relayConfigProvider).nsec; + if (nsec == null || nsec.isEmpty) { + _sendAbort('protocol_error'); + _cleanup(); + state = const PairingState( + status: PairingStatus.error, + errorMessage: 'No identity is available on this phone.', + ); + return; + } + final content = _encryptMessage({ + 'type': 'payload', + 'payload_type': 'nsec', + 'payload': nsec, + }); + _publishEvent( + kind: 24134, + content: content, + tags: [ + ['p', _sourcePubkey!], + ], + ); + } + void _handlePayload(Map msg) { // Only accept payload after the transcript hash was verified. if (!_sasConfirmReceived) return; @@ -436,6 +481,22 @@ class PairingNotifier extends Notifier { _processPayload(payloadType, payload); } + void _handleComplete(Map msg) { + if (!_sendIdentityToSource || state.status != PairingStatus.transferring) { + return; + } + if (msg['success'] != true) { + _cleanup(); + state = const PairingState( + status: PairingStatus.error, + errorMessage: 'Desktop could not store the identity.', + ); + return; + } + _cleanup(); + state = const PairingState(status: PairingStatus.success); + } + void _handleAbort(Map msg) { final reason = msg['reason'] as String? ?? 'unknown'; _cleanup(); diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index d53f39a672..3c9797f15a 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -8,6 +8,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:package_info_plus/package_info_plus.dart'; +import '../../features/pairing/pairing_page.dart'; import '../../shared/auth/auth.dart'; import '../../shared/clipboard_utils.dart'; import '../../shared/relay/relay.dart'; diff --git a/mobile/lib/features/settings/settings_page/connection_section.dart b/mobile/lib/features/settings/settings_page/connection_section.dart index 19da784a63..d155b789a5 100644 --- a/mobile/lib/features/settings/settings_page/connection_section.dart +++ b/mobile/lib/features/settings/settings_page/connection_section.dart @@ -16,7 +16,23 @@ class _ConnectionSection extends ConsumerWidget { title: 'Connected to', subtitle: config.baseUrl, ), - if (nsec != null && nsec.isNotEmpty) _IdentityRow(nsec: nsec), + 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( + builder: (_) => const PairingPage( + addingCommunity: true, + identityRecoveryOnly: true, + ), + ), + ), + ), + ], ], ); } From cb4019a102e2d313c1f9a19436b7c13c711719e0 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Sat, 1 Aug 2026 18:50:13 -0700 Subject: [PATCH 02/20] fix(pairing): lead onboarding with phone recovery Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../onboarding/ui/IdentityRecoveryPairing.tsx | 17 +- .../onboarding/ui/MachineOnboardingFlow.tsx | 99 +++++++-- desktop/tests/e2e/identity-lost.spec.ts | 11 +- desktop/tests/e2e/key-import-reveal.spec.ts | 3 + .../onboarding-docked-cta-screenshots.spec.ts | 6 + desktop/tests/e2e/onboarding.spec.ts | 35 +++ mobile/lib/features/pairing/pairing_page.dart | 5 +- .../features/pairing/pairing_provider.dart | 3 +- .../features/pairing/pairing_page_test.dart | 90 +++++++- .../pairing/pairing_provider_test.dart | 203 ++++++++++++++++++ 10 files changed, 436 insertions(+), 36 deletions(-) diff --git a/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx b/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx index 708f5649ca..7f7e130b7c 100644 --- a/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx +++ b/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx @@ -90,20 +90,15 @@ export function IdentityRecoveryPairing({ return (
-

Scan with Buzz on your phone

-

- On your signed-in phone, open Settings → Send identity to desktop. This - code expires shortly and works once. -

-
+
{step === "qr" && qrUri ? ( @@ -149,7 +144,11 @@ export function IdentityRecoveryPairing({
)}
-

+

+ On your phone, open Settings → Send identity to desktop. This code + expires shortly and works once. +

+

Your phone will grant this desktop permanent access to your full Buzz identity. Only approve a desktop you trust and verify the six-digit code on both screens. diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index 7e957cb7ab..b30dfc2797 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -80,6 +80,9 @@ export function MachineOnboardingFlow({ const [identityWasImported, setIdentityWasImported] = React.useState(false); const [keyImportStage, setKeyImportStage] = React.useState("key-entry"); + const [keyImportMethod, setKeyImportMethod] = React.useState<"phone" | "key">( + "phone", + ); const [selectedPubkey, setSelectedPubkey] = React.useState( null, ); @@ -244,6 +247,7 @@ export function MachineOnboardingFlow({ className={`${ONBOARDING_SECONDARY_CTA_CLASS} px-5`} disabled={isPending} onClick={() => { + setKeyImportMethod("phone"); setKeyImportStage("key-entry"); setPage("key-import"); }} @@ -277,33 +281,86 @@ export function MachineOnboardingFlow({

{keyImportStage === "backup-password" ? "Unlock your account" - : identityLost - ? "Re-import your key" - : "Enter your private key"} + : keyImportMethod === "phone" + ? identityLost + ? "Recover from your phone" + : "Use your Buzz identity" + : identityLost + ? "Re-import your key" + : "Enter your private key"}

{keyImportStage === "backup-password" ? "Enter your backup password to unlock your key and restore your identity." - : identityLost - ? "Your identity is no longer in the system keyring. Re-import your nsec to restore it." - : "If you already have a Buzz account, enter your private key below to get started."} + : keyImportMethod === "phone" + ? "Scan with a signed-in Buzz phone to securely bring this identity to your desktop." + : identityLost + ? "Re-import your nsec or encrypted backup to restore this identity." + : "Enter your nsec or choose an encrypted backup file."}

-
- {identityLost && keyImportStage === "key-entry" ? ( - - ) : null} - void replaceLostIdentity() - : () => setPage("identity") - } - onImport={importExistingIdentity} - onStageChange={setKeyImportStage} - variant="spotlight" - /> +
+ {keyImportMethod === "phone" ? ( +
+ + + {identityLost ? ( + + ) : ( + + )} +
+ ) : ( +
+ { + setKeyImportStage("key-entry"); + setKeyImportMethod("phone"); + }} + onImport={importExistingIdentity} + onStageChange={setKeyImportStage} + variant="spotlight" + /> + {identityLost && keyImportStage === "key-entry" ? ( + + ) : null} +
+ )}
) : page === "backup" ? ( diff --git a/desktop/tests/e2e/identity-lost.spec.ts b/desktop/tests/e2e/identity-lost.spec.ts index 2c19864216..524b2021b8 100644 --- a/desktop/tests/e2e/identity-lost.spec.ts +++ b/desktop/tests/e2e/identity-lost.spec.ts @@ -62,7 +62,7 @@ test("lost boot opens onboarding gate directly on the key-import page", async ({ await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible(); await expect( - page.getByRole("heading", { name: "Re-import your key" }), + page.getByRole("heading", { name: "Recover from your phone" }), ).toBeVisible(); }); @@ -114,6 +114,9 @@ test("importing a key from lost mode shows the relaunch-required screen", async { skipOnboardingSeed: true }, ); await page.goto("/"); + await page + .getByRole("button", { name: "Use a private key or backup instead" }) + .click(); await expect( page.getByRole("heading", { name: "Re-import your key" }), @@ -136,6 +139,9 @@ test("start-new-identity from lost mode persists the ephemeral key after confirm { skipOnboardingSeed: true }, ); await page.goto("/"); + await page + .getByRole("button", { name: "Use a private key or backup instead" }) + .click(); await expect( page.getByRole("heading", { name: "Re-import your key" }), @@ -170,6 +176,9 @@ test("cancelling start-new-identity in lost mode stays on the import screen", as { skipOnboardingSeed: true }, ); await page.goto("/"); + await page + .getByRole("button", { name: "Use a private key or backup instead" }) + .click(); await expect( page.getByRole("heading", { name: "Re-import your key" }), diff --git a/desktop/tests/e2e/key-import-reveal.spec.ts b/desktop/tests/e2e/key-import-reveal.spec.ts index cd05f71e76..1c1f529a96 100644 --- a/desktop/tests/e2e/key-import-reveal.spec.ts +++ b/desktop/tests/e2e/key-import-reveal.spec.ts @@ -19,6 +19,9 @@ test("key import masks the key with a reveal toggle", async ({ page }) => { await page.goto("/"); await page.getByRole("button", { name: "Use an existing key" }).click(); + await page + .getByRole("button", { name: "Use a private key or backup instead" }) + .click(); const input = page.getByTestId("nostr-import-nsec-input"); await expect(input).toBeVisible(); await waitForAnimations(page); diff --git a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts index efae7c3784..0475672735 100644 --- a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts +++ b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts @@ -30,6 +30,9 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({ await page.screenshot({ path: `${SHOT_DIR}/01-landing.png` }); await page.getByRole("button", { name: "Use an existing key" }).click(); + await page + .getByRole("button", { name: "Use a private key or backup instead" }) + .click(); await expect( page.getByRole("heading", { name: "Enter your private key" }), ).toBeVisible(); @@ -143,6 +146,9 @@ test("machine key import remains usable in a short viewport", async ({ }); await page.goto("/"); await page.getByRole("button", { name: "Use an existing key" }).click(); + await page + .getByRole("button", { name: "Use a private key or backup instead" }) + .click(); const heading = page.getByRole("heading", { name: "Enter your private key" }); const input = page.getByLabel("Private key", { exact: true }); diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 57e812091c..89291c0e5f 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -620,6 +620,32 @@ test("completed users skip the loading gate while profile is still settling", as await expectHomeView(page); }); +test("fresh existing-identity path leads with phone recovery", async ({ + page, +}) => { + await installMockBridge(page, undefined, { + skipCommunitySeed: true, + skipOnboardingSeed: true, + }); + await page.goto("/"); + + await page.getByRole("button", { name: "Use an existing key" }).click(); + await expect( + page.getByRole("heading", { name: "Use your Buzz identity" }), + ).toBeVisible(); + await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); + await expect(page.getByTestId("nostr-import-card")).toHaveCount(0); + + await page + .getByRole("button", { name: "Use a private key or backup instead" }) + .click(); + await expect( + page.getByRole("heading", { name: "Enter your private key" }), + ).toBeVisible(); + await expect(page.getByTestId("nostr-import-card")).toBeVisible(); + await expect(page.getByTestId("identity-recovery-pairing")).toHaveCount(0); +}); + test("first-launch key import continues to machine setup", async ({ page }) => { await installMockBridge(page, undefined, { skipCommunitySeed: true, @@ -628,6 +654,9 @@ test("first-launch key import continues to machine setup", async ({ page }) => { await page.goto("/"); await page.getByRole("button", { name: "Use an existing key" }).click(); + await page + .getByRole("button", { name: "Use a private key or backup instead" }) + .click(); const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey)); await page.getByTestId("nostr-import-nsec-input").fill(importedNsec); await page.getByTestId("nostr-import-submit").click(); @@ -648,6 +677,9 @@ test("first-launch encrypted backup import asks for a passphrase and continues", await page.goto("/"); await page.getByRole("button", { name: "Use an existing key" }).click(); + await page + .getByRole("button", { name: "Use a private key or backup instead" }) + .click(); // Spec-vector blob the mock bridge accepts with the mock passphrase. const mockNcryptsec = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; @@ -702,6 +734,9 @@ test("first-launch import accepts an .ncryptsec backup file", async ({ await page.goto("/"); await page.getByRole("button", { name: "Use an existing key" }).click(); + await page + .getByRole("button", { name: "Use a private key or backup instead" }) + .click(); // The spotlight variant must expose a file path: a wiped user returns with // exactly the identity.ncryptsec our own save dialog produced. The accept diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index bed449e07a..7061180b12 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -126,8 +126,7 @@ class PairingPage extends HookConsumerWidget { child: _SasVerificationView( sasCode: pairingState.sasCode ?? '------', confirmed: pairingState.userConfirmedSas, - sendsIdentityToDesktop: - pairingState.sendsIdentityToDesktop, + sendsIdentityToDesktop: pairingState.sendsIdentityToDesktop, onConfirm: () => ref.read(pairingProvider.notifier).confirmSas(), onDeny: () => ref.read(pairingProvider.notifier).denySas(), @@ -160,7 +159,7 @@ class PairingPage extends HookConsumerWidget { onConnect: () { final code = codeController.text.trim(); if (code.isNotEmpty) { - ref.read(pairingProvider.notifier).pair(code); + unawaited(handleScannerResult(code)); } }, ), diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index e8f27aa5bc..5adde987eb 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -185,7 +185,8 @@ class PairingNotifier extends Notifier { final qr = parseNostrpairUri(uri); _sourcePubkey = qr.sourcePubkey; _sessionSecret = qr.sessionSecret; - _sendIdentityToSource = Uri.parse(uri).queryParameters['mode'] == 'recover'; + _sendIdentityToSource = + Uri.parse(uri).queryParameters['mode'] == 'recover'; final relayWsUrl = qr.relays.first; diff --git a/mobile/test/features/pairing/pairing_page_test.dart b/mobile/test/features/pairing/pairing_page_test.dart index 678be9dfe7..e8f34a6f71 100644 --- a/mobile/test/features/pairing/pairing_page_test.dart +++ b/mobile/test/features/pairing/pairing_page_test.dart @@ -179,6 +179,69 @@ void main() { expect(scanButton.onPressed, isNull); expect(pairingCodeButton.onPressed, isNull); }); + + testWidgets('recovery entry rejects ordinary nostrpair codes', ( + tester, + ) async { + final notifier = _RecordingPairingNotifier(); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [pairingProvider.overrideWith(() => notifier)], + child: const PairingPage( + addingCommunity: true, + identityRecoveryOnly: true, + ), + ), + ); + + await _expandPairingCode(tester); + await tester.enterText(find.byType(TextField), 'nostrpair://ordinary'); + await tester.tap(find.text('Connect')); + await tester.pump(); + + expect(find.text('Scan a desktop recovery code.'), findsOneWidget); + expect(notifier.pairedCodes, isEmpty); + }); + + testWidgets('recovery entry accepts mode=recover codes', (tester) async { + final notifier = _RecordingPairingNotifier(); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [pairingProvider.overrideWith(() => notifier)], + child: const PairingPage( + addingCommunity: true, + identityRecoveryOnly: true, + ), + ), + ); + + await _expandPairingCode(tester); + const code = 'nostrpair://desktop?mode=recover'; + await tester.enterText(find.byType(TextField), code); + await tester.tap(find.text('Connect')); + await tester.pump(); + + expect(notifier.pairedCodes, [code]); + }); + + testWidgets('recovery SAS warns about permanent desktop access', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + pairingProvider.overrideWith( + () => _ConfirmingSasPairingNotifier(sendsIdentityToDesktop: true), + ), + ], + child: MaterialApp(theme: AppTheme.dark(), home: const PairingPage()), + ), + ); + + expect(find.textContaining('full Buzz identity'), findsOneWidget); + expect(find.textContaining('permanent access'), findsOneWidget); + expect(find.text('Codes Match'), findsOneWidget); + }); }); } @@ -227,12 +290,37 @@ class _ConnectingPairingNotifier extends Notifier void denySas() {} } +class _RecordingPairingNotifier extends Notifier + implements PairingNotifier { + final pairedCodes = []; + + @override + PairingState build() => const PairingState(); + + @override + Future pair(String rawInput) async => pairedCodes.add(rawInput); + + @override + void reset() {} + + @override + void confirmSas() {} + + @override + void denySas() {} +} + class _ConfirmingSasPairingNotifier extends Notifier implements PairingNotifier { + _ConfirmingSasPairingNotifier({this.sendsIdentityToDesktop = false}); + + final bool sendsIdentityToDesktop; + @override - PairingState build() => const PairingState( + PairingState build() => PairingState( status: PairingStatus.confirmingSas, sasCode: '123456', + sendsIdentityToDesktop: sendsIdentityToDesktop, ); @override diff --git a/mobile/test/features/pairing/pairing_provider_test.dart b/mobile/test/features/pairing/pairing_provider_test.dart index 6f49f71921..c14599bbef 100644 --- a/mobile/test/features/pairing/pairing_provider_test.dart +++ b/mobile/test/features/pairing/pairing_provider_test.dart @@ -2,9 +2,14 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/features/pairing/pairing_crypto.dart'; import 'package:buzz/features/pairing/pairing_provider.dart'; import 'package:buzz/features/pairing/pairing_socket.dart'; import 'package:buzz/shared/auth/auth.dart'; +import 'package:buzz/shared/crypto/ecdh.dart'; +import 'package:buzz/shared/crypto/nip44.dart'; +import 'package:buzz/shared/relay/relay.dart'; /// Tests for [PairingNotifier]'s legacy `buzz://` payload parsing and /// SSRF-prevention validation. @@ -180,6 +185,115 @@ void main() { container.read(pairingProvider.notifier).reset(); expect(container.read(pairingProvider).status, PairingStatus.idle); }); + + group('desktop identity recovery', () { + const sourceSecret = + '09b3065e3570a3a4054660dccd66e12774a99a904fdb0ca02dbc6c3136249506'; + const sessionSecretHex = + 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789'; + late _ControllableSocket socket; + late PairingNotifier notifier; + late String recoveryCode; + + setUp(() { + final source = nostr.Keys(sourceSecret); + recoveryCode = + 'nostrpair://${source.public}' + '?secret=$sessionSecretHex' + '&relay=wss%3A%2F%2Fpairing.buzz.xyz&v=1&mode=recover'; + notifier = PairingNotifier( + socketFactory: + ({ + required wsUrl, + required ephemeralPrivkey, + required onMessage, + required onDisconnected, + }) { + socket = _ControllableSocket( + ephemeralPrivkey: ephemeralPrivkey, + onMessage: onMessage, + onDisconnected: onDisconnected, + ); + return socket; + }, + ); + container = ProviderContainer( + overrides: [ + pairingProvider.overrideWith(() => notifier), + relayConfigProvider.overrideWith(_RecoveryRelayConfig.new), + ], + ); + container.read(pairingProvider); + notifier = container.read(pairingProvider.notifier); + }); + + test('recovery URI enables phone-to-desktop transfer', () async { + await notifier.pair(recoveryCode); + + final state = container.read(pairingProvider); + expect(state.status, PairingStatus.confirmingSas); + expect(state.sendsIdentityToDesktop, isTrue); + expect(state.sasCode, hasLength(6)); + }); + + test( + 'matching SAS sends nsec and successful completion finishes', + () async { + await notifier.pair(recoveryCode); + notifier.confirmSas(); + expect(container.read(pairingProvider).userConfirmedSas, isTrue); + + socket.sendSourceMessage( + sourceSecret: sourceSecret, + sessionSecretHex: sessionSecretHex, + message: {'type': 'sas-confirm'}, + includeTranscriptHash: true, + ); + + expect( + container.read(pairingProvider).status, + PairingStatus.transferring, + ); + final sentMessages = socket.decryptedPublishedMessages(sourceSecret); + expect( + sentMessages.any( + (message) => + message['type'] == 'payload' && + message['payload_type'] == 'nsec' && + message['payload'] == _RecoveryRelayConfig.nsec, + ), + isTrue, + ); + + socket.sendSourceMessage( + sourceSecret: sourceSecret, + sessionSecretHex: sessionSecretHex, + message: {'type': 'complete', 'success': true}, + ); + expect(container.read(pairingProvider).status, PairingStatus.success); + }, + ); + + test('desktop storage failure surfaces an error', () async { + await notifier.pair(recoveryCode); + notifier.confirmSas(); + socket.sendSourceMessage( + sourceSecret: sourceSecret, + sessionSecretHex: sessionSecretHex, + message: {'type': 'sas-confirm'}, + includeTranscriptHash: true, + ); + socket.sendSourceMessage( + sourceSecret: sourceSecret, + sessionSecretHex: sessionSecretHex, + message: {'type': 'complete', 'success': false}, + ); + + final state = container.read(pairingProvider); + expect(state.status, PairingStatus.error); + expect(state.errorMessage, contains('could not store')); + }); + }); }); } @@ -241,3 +355,92 @@ class _DisconnectingSocket extends PairingSocket { disconnectCallback(Exception('Connection closed')); } } + +class _RecoveryRelayConfig extends RelayConfigNotifier { + static final nsec = nostr.Keys( + '1111111111111111111111111111111111111111111111111111111111111111', + ).nsec; + + @override + RelayConfig build() => RelayConfig(baseUrl: 'https://relay.test', nsec: nsec); +} + +class _ControllableSocket extends PairingSocket { + final String ephemeralPrivkey; + final void Function(List message) relayMessageCallback; + final List> published = []; + bool _connected = false; + int _eventSequence = 0; + + _ControllableSocket({ + required this.ephemeralPrivkey, + required super.onMessage, + required super.onDisconnected, + }) : relayMessageCallback = onMessage, + super(wsUrl: 'ws://unused', ephemeralPrivkey: ephemeralPrivkey); + + @override + bool get isConnected => _connected; + + @override + Future connect() async => _connected = true; + + @override + void subscribe(String subId, int kind, String pubkeyHex) {} + + @override + void publishEvent(Map event) => published.add(event); + + @override + void dispose() => _connected = false; + + List> decryptedPublishedMessages(String sourceSecret) { + final key = getConversationKey( + sourceSecret, + nostr.Keys(ephemeralPrivkey).public, + ); + return published + .map( + (event) => + jsonDecode(nip44Decrypt(key, event['content'] as String)) + as Map, + ) + .toList(); + } + + void sendSourceMessage({ + required String sourceSecret, + required String sessionSecretHex, + required Map message, + bool includeTranscriptHash = false, + }) { + final source = nostr.Keys(sourceSecret); + final targetPubkey = nostr.Keys(ephemeralPrivkey).public; + final sessionSecret = hexToBytes(sessionSecretHex); + final body = Map.from(message); + if (includeTranscriptHash) { + final shared = ecdhSharedSecret(sourceSecret, targetPubkey); + final (_, sasInput) = deriveSas(shared, sessionSecret); + body['transcript_hash'] = bytesToHex( + deriveTranscriptHash( + deriveSessionId(sessionSecret), + hexToBytes(source.public), + hexToBytes(targetPubkey), + sasInput, + sessionSecret, + ), + ); + } + final key = getConversationKey(sourceSecret, targetPubkey); + final event = nostr.Event.from( + kind: 24134, + content: nip44Encrypt(key, jsonEncode(body)), + tags: [ + ['p', targetPubkey], + ], + secretKey: sourceSecret, + createdAt: 1_700_000_000 + _eventSequence++, + ); + relayMessageCallback(['EVENT', 'pair', event.toMap()]); + } +} From 74ab122f98be2af45ab0d37ec3764c5a44927e5b Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Sat, 1 Aug 2026 20:15:23 -0700 Subject: [PATCH 03/20] feat(pairing): copy desktop recovery code Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../onboarding/ui/IdentityRecoveryPairing.tsx | 45 ++++++++++++++++++- desktop/tests/e2e/identity-lost.spec.ts | 20 +++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx b/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx index 7f7e130b7c..dcbeb920b0 100644 --- a/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx +++ b/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx @@ -1,9 +1,16 @@ import * as React from "react"; import { listen } from "@tauri-apps/api/event"; -import { Check, LoaderCircle, RefreshCw, ShieldCheck } from "lucide-react"; +import { + Check, + Copy, + LoaderCircle, + RefreshCw, + ShieldCheck, +} from "lucide-react"; import { cancelPairing, confirmPairingSas } from "@/shared/api/tauri"; import { startIdentityRecoveryPairing } from "@/shared/api/tauriPairing"; +import { writeTextToClipboard } from "@/shared/lib/clipboard"; import { Button } from "@/shared/ui/button"; import { StyledQrCode } from "@/shared/ui/styled-qr-code"; @@ -18,7 +25,9 @@ export function IdentityRecoveryPairing({ const [qrUri, setQrUri] = React.useState(null); const [sas, setSas] = React.useState(null); const [error, setError] = React.useState(null); + const [copied, setCopied] = React.useState(false); const active = React.useRef(true); + const copyTimer = React.useRef(null); const start = React.useCallback(async () => { active.current = true; @@ -26,6 +35,7 @@ export function IdentityRecoveryPairing({ setError(null); setSas(null); setQrUri(null); + setCopied(false); try { setQrUri(await startIdentityRecoveryPairing()); setStep("qr"); @@ -72,10 +82,23 @@ export function IdentityRecoveryPairing({ disposed = true; active.current = false; for (const unlisten of unlisteners) unlisten(); + if (copyTimer.current !== null) window.clearTimeout(copyTimer.current); void cancelPairing(); }; }, [onRecovered, start]); + async function copyPairingCode() { + if (!qrUri) return; + try { + await writeTextToClipboard(qrUri); + setCopied(true); + if (copyTimer.current !== null) window.clearTimeout(copyTimer.current); + copyTimer.current = window.setTimeout(() => setCopied(false), 2_000); + } catch { + setError("Could not copy the pairing code. Try again."); + } + } + async function confirm() { setStep("receiving"); try { @@ -144,6 +167,26 @@ export function IdentityRecoveryPairing({
)}
+ {step === "qr" && qrUri ? ( + + ) : null} + {step === "qr" && error ? ( +

{error}

+ ) : null}

On your phone, open Settings → Send identity to desktop. This code expires shortly and works once. diff --git a/desktop/tests/e2e/identity-lost.spec.ts b/desktop/tests/e2e/identity-lost.spec.ts index 524b2021b8..dbfcbaa92a 100644 --- a/desktop/tests/e2e/identity-lost.spec.ts +++ b/desktop/tests/e2e/identity-lost.spec.ts @@ -90,6 +90,26 @@ test("lost boot offers phone recovery with a single-use QR", async ({ fullPage: true, }); + const copyButton = page.getByTestId("copy-identity-recovery-code"); + await expect(copyButton).toHaveText("Copy pairing code"); + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await copyButton.click(); + await expect(copyButton).toHaveText("Copied"); + + const copiedPayload = await page.evaluate(() => { + const log = ( + window as Window & { + __BUZZ_E2E_COMMAND_LOG__?: Array<{ + command: string; + payload: Record | null; + }>; + } + ).__BUZZ_E2E_COMMAND_LOG__; + return log?.findLast(({ command }) => command === "copy_text_to_clipboard") + ?.payload; + }); + expect(copiedPayload?.text).toMatch(/^nostrpair:\/\/.+&mode=recover$/); + const commands = await page.evaluate( () => ( From bef33e390cd5719719298d5a5b73604ad3fb4236 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Sat, 1 Aug 2026 20:23:18 -0700 Subject: [PATCH 04/20] fix(pairing): refresh stale recovery sessions Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/src-tauri/src/commands/pairing.rs | 2 +- .../onboarding/ui/IdentityRecoveryPairing.tsx | 33 +++++++++++- desktop/tests/e2e/identity-lost.spec.ts | 50 +++++++++++++++++++ 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/commands/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs index 8a24a00256..777db384f6 100644 --- a/desktop/src-tauri/src/commands/pairing.rs +++ b/desktop/src-tauri/src/commands/pairing.rs @@ -189,7 +189,7 @@ pub async fn confirm_pairing_sas(pairing: State<'_, PairingHandle>) -> Result<() tx.send(sas_confirm_json) .await - .map_err(|_| "failed to send sas-confirm")?; + .map_err(|_| "Pairing code expired. Create a new code and try again.")?; let mode = *pairing.mode.lock().map_err(|e| e.to_string())?; if mode == PairingMode::SendIdentity { diff --git a/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx b/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx index dcbeb920b0..fcb6642f73 100644 --- a/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx +++ b/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx @@ -16,6 +16,24 @@ import { StyledQrCode } from "@/shared/ui/styled-qr-code"; type Step = "loading" | "qr" | "sas" | "receiving" | "done" | "error"; +// Refresh before the pairing relay's two-minute connection cap so Desktop never +// leaves a code on screen after its publishing channel has closed. +const QR_REFRESH_MS = 90_000; + +function recoveryErrorMessage(message: string): string { + const normalized = message.toLowerCase(); + if ( + normalized.includes("sas-confirm") || + normalized.includes("relay connection closed") || + normalized.includes("websocket") || + normalized.includes("expired") || + normalized.includes("timed out") + ) { + return "This pairing code expired or lost its connection. Create a new code and try again."; + } + return message; +} + export function IdentityRecoveryPairing({ onRecovered, }: { @@ -67,7 +85,7 @@ export function IdentityRecoveryPairing({ listen<{ message: string }>("pairing-error", ({ payload }) => { if (!disposed && active.current) { active.current = false; - setError(payload.message); + setError(recoveryErrorMessage(payload.message)); setStep("error"); } }).then((unlisten) => (disposed ? unlisten() : unlisteners.push(unlisten))); @@ -87,6 +105,12 @@ export function IdentityRecoveryPairing({ }; }, [onRecovered, start]); + React.useEffect(() => { + if (step !== "qr") return; + const timer = window.setTimeout(() => void start(), QR_REFRESH_MS); + return () => window.clearTimeout(timer); + }, [start, step]); + async function copyPairingCode() { if (!qrUri) return; try { @@ -104,8 +128,13 @@ export function IdentityRecoveryPairing({ try { await confirmPairingSas(); } catch (cause) { + if (!active.current) return; setError( - cause instanceof Error ? cause.message : "Could not confirm recovery.", + recoveryErrorMessage( + cause instanceof Error + ? cause.message + : "Could not confirm recovery.", + ), ); setStep("error"); } diff --git a/desktop/tests/e2e/identity-lost.spec.ts b/desktop/tests/e2e/identity-lost.spec.ts index dbfcbaa92a..9d01d9acbe 100644 --- a/desktop/tests/e2e/identity-lost.spec.ts +++ b/desktop/tests/e2e/identity-lost.spec.ts @@ -125,6 +125,56 @@ test("lost boot offers phone recovery with a single-use QR", async ({ ).toBe(true); }); +test("recovery turns relay failures into actionable copy", async ({ page }) => { + await installMockBridge( + page, + { identityLost: true }, + { skipOnboardingSeed: true }, + ); + await page.goto("/"); + await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); + + await page.evaluate(async () => { + await window.__TAURI_INTERNALS__?.invoke?.("plugin:event|emit", { + event: "pairing-error", + payload: { message: "failed to send sas-confirm" }, + }); + }); + + await expect( + page.getByText( + "This pairing code expired or lost its connection. Create a new code and try again.", + ), + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Try again" })).toBeVisible(); +}); + +test("desktop refreshes recovery codes before the relay expires them", async ({ + page, +}) => { + await page.clock.install(); + await installMockBridge( + page, + { identityLost: true }, + { skipOnboardingSeed: true }, + ); + await page.goto("/"); + await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); + + const recoveryStarts = () => + page.evaluate( + () => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter( + ({ command }) => command === "start_identity_recovery_pairing", + ).length, + ); + await expect.poll(recoveryStarts).toBe(1); + + await page.clock.fastForward(90_000); + await expect.poll(recoveryStarts).toBe(2); + await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); +}); + test("importing a key from lost mode shows the relaunch-required screen", async ({ page, }) => { From ef00c993e6c9ee6310304354220966c594508e5d Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Sat, 1 Aug 2026 20:32:23 -0700 Subject: [PATCH 05/20] fix(onboarding): continue after phone recovery Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/src/app/App.tsx | 1 + .../features/onboarding/machineOnboarding.ts | 13 ++++++++- .../onboarding/ui/MachineOnboardingFlow.tsx | 26 ++++++++++++++++- desktop/src/testing/e2eBridge.ts | 9 ++++-- desktop/tests/e2e/identity-lost.spec.ts | 28 +++++++++++++++++++ 5 files changed, 72 insertions(+), 5 deletions(-) diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 811da043f9..0f311f3a65 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -706,6 +706,7 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { { + continuingPubkeyRef.current = pubkey; + setBootedLost(false); + setBootedLocked(false); + }, []); + const reopen = React.useCallback(() => { clearMachineOnboardingCompletion(currentPubkey); setCompletedPubkey((pubkey) => (pubkey === currentPubkey ? null : pubkey)); @@ -224,7 +230,11 @@ export function useMachineOnboardingState({ continuingPubkeyRef.current !== currentPubkey) ) { stage = "blocking"; - } else if (identityLost || !hasCompletedCurrentPubkey) { + } else if ( + identityLost || + continuingPubkeyRef.current === currentPubkey || + !hasCompletedCurrentPubkey + ) { stage = "onboarding"; } else { stage = "ready"; @@ -233,6 +243,7 @@ export function useMachineOnboardingState({ return { complete, continueWithIdentity, + continueWithRecoveredIdentity, currentPubkey, identityLost, queryClient, diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index b30dfc2797..2fa2afb764 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -54,6 +54,7 @@ export type PostOnboardingNavigation = { export function MachineOnboardingFlow({ complete, continueWithIdentity, + continueWithRecoveredIdentity, identityLost, initialPage, queryClient, @@ -61,6 +62,7 @@ export function MachineOnboardingFlow({ }: { complete: (pubkey?: string) => void; continueWithIdentity: (pubkey: string) => void; + continueWithRecoveredIdentity: (pubkey: string) => void; identityLost: boolean; initialPage?: MachineOnboardingPage; queryClient: QueryClient; @@ -132,6 +134,26 @@ export function MachineOnboardingFlow({ } }, [queryClient]); + const loadRecoveredIdentity = React.useCallback(async () => { + setIsPending(true); + setError(null); + try { + const identity = await getIdentity(); + continueWithRecoveredIdentity(identity.pubkey); + queryClient.setQueryData(["identity"], identity); + setIdentityWasImported(true); + setSelectedPubkey(identity.pubkey); + setIdentityStorage(identity.storage); + setPage("setup"); + } catch (cause) { + setError( + cause instanceof Error ? cause.message : "Failed to load identity", + ); + } finally { + setIsPending(false); + } + }, [continueWithRecoveredIdentity, queryClient]); + const replaceLostIdentity = React.useCallback(async () => { const confirmed = window.confirm( "This will create a new identity and abandon your previous key. This cannot be undone. Continue?", @@ -308,7 +330,9 @@ export function MachineOnboardingFlow({ > {keyImportMethod === "phone" ? (

- + +
+

+ {sas.slice(0, 3)} {sas.slice(3)} +

+
+

+ Your phone is about to transfer your Buzz identity to this + desktop. Only confirm if you initiated this pairing. +

+
+ + +
) : step === "done" ? (
- -

Identity received securely

+
+ +
+

Identity received securely

) : step === "error" ? ( -
+
+

{error}

-
) : (
- -

+ +

{step === "receiving" - ? "Waiting for your phone to send…" - : "Creating secure code…"} + ? "Receiving identity from mobile device..." + : "Starting pairing..."}

)}
{step === "qr" && qrUri ? ( ) : null} {step === "qr" && error ? ( -

{error}

+

+ {error} +

) : null} -

+

On your phone, open Settings → Send identity to desktop. This code expires shortly and works once.

-

+

Your phone will grant this desktop permanent access to your full Buzz identity. Only approve a desktop you trust and verify the six-digit code on both screens. diff --git a/desktop/tests/e2e/identity-lost.spec.ts b/desktop/tests/e2e/identity-lost.spec.ts index fd5e5e2a75..d75c9b87c3 100644 --- a/desktop/tests/e2e/identity-lost.spec.ts +++ b/desktop/tests/e2e/identity-lost.spec.ts @@ -125,6 +125,90 @@ test("lost boot offers phone recovery with a single-use QR", async ({ ).toBe(true); }); +test("phone recovery uses the desktop pairing card semantics", async ({ + page, +}) => { + await installMockBridge( + page, + { identityLost: true }, + { skipOnboardingSeed: true }, + ); + await page.goto("/"); + + const card = page.getByTestId("identity-recovery-pairing"); + const qrContainer = card.getByTestId("identity-recovery-qr-container"); + const qrCode = card.getByTestId("identity-recovery-qr"); + const copyButton = card.getByTestId("copy-identity-recovery-code"); + await expect(qrCode).toBeVisible(); + await expect(qrCode).toHaveAttribute("data-qr-matrix-size", "57"); + await expect(qrCode.locator("[data-qr-finder-pattern]")).toHaveCount(3); + await expect(qrCode.locator(".buzz-qr-cell-reveal").first()).toHaveCSS( + "animation-name", + "buzz-qr-cell-reveal", + ); + const qrBox = await qrContainer.boundingBox(); + const copyBox = await copyButton.boundingBox(); + expect(qrBox).not.toBeNull(); + expect(copyBox).not.toBeNull(); + expect(Math.abs((copyBox?.x ?? 0) - (qrBox?.x ?? 0))).toBeLessThan(0.5); + expect(Math.abs((copyBox?.width ?? 0) - (qrBox?.width ?? 0))).toBeLessThan( + 0.5, + ); + + await page.evaluate(async () => { + await window.__TAURI_INTERNALS__?.invoke?.("plugin:event|emit", { + event: "pairing-sas-received", + payload: { sas: "123456" }, + }); + }); + + await expect( + card.getByText("Verify this code matches your mobile device"), + ).toBeVisible(); + await expect(card.getByTestId("identity-recovery-sas")).toHaveText("123 456"); + await expect(card.getByTestId("confirm-identity-recovery-sas")).toHaveText( + "Codes match", + ); + await expect(card.getByTestId("deny-identity-recovery-sas")).toHaveText( + "Cancel", + ); +}); + +test("canceling recovery uses the standard pairing cancellation state", async ({ + page, +}) => { + await installMockBridge( + page, + { identityLost: true }, + { skipOnboardingSeed: true }, + ); + await page.goto("/"); + await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); + + await page.evaluate(async () => { + await window.__TAURI_INTERNALS__?.invoke?.("plugin:event|emit", { + event: "pairing-sas-received", + payload: { sas: "123456" }, + }); + }); + await page.getByTestId("deny-identity-recovery-sas").click(); + + await expect( + page.getByText("The codes didn't match. Pairing was canceled."), + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Try again" })).toBeVisible(); + await expect + .poll(() => + page.evaluate( + () => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter( + ({ command }) => command === "cancel_pairing", + ).length, + ), + ) + .toBeGreaterThan(0); +}); + test("phone recovery continues to harness setup without creating or restarting", async ({ page, }) => { From 79a5aee0dd5c56dd8c195033d5a61e895bbf1c97 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 18:00:35 -0700 Subject: [PATCH 07/20] fix(desktop): lead identity recovery with private key Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../onboarding/ui/MachineOnboardingFlow.tsx | 25 +++---- .../onboarding/ui/NostrKeyImportForm.tsx | 65 ++++++++++++------- desktop/tests/e2e/identity-lost.spec.ts | 36 +++++----- desktop/tests/e2e/key-import-reveal.spec.ts | 3 - .../onboarding-docked-cta-screenshots.spec.ts | 6 -- desktop/tests/e2e/onboarding.spec.ts | 34 +++++----- 6 files changed, 88 insertions(+), 81 deletions(-) diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index 2fa2afb764..e52cef1fd5 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -83,7 +83,7 @@ export function MachineOnboardingFlow({ const [keyImportStage, setKeyImportStage] = React.useState("key-entry"); const [keyImportMethod, setKeyImportMethod] = React.useState<"phone" | "key">( - "phone", + "key", ); const [selectedPubkey, setSelectedPubkey] = React.useState( null, @@ -269,7 +269,7 @@ export function MachineOnboardingFlow({ className={`${ONBOARDING_SECONDARY_CTA_CLASS} px-5`} disabled={isPending} onClick={() => { - setKeyImportMethod("phone"); + setKeyImportMethod("key"); setKeyImportStage("key-entry"); setPage("key-import"); }} @@ -307,18 +307,14 @@ export function MachineOnboardingFlow({ ? identityLost ? "Recover from your phone" : "Use your Buzz identity" - : identityLost - ? "Re-import your key" - : "Enter your private key"} + : "Enter your private key"}

{keyImportStage === "backup-password" - ? "Enter your backup password to unlock your key and restore your identity." + ? "Enter your backup password to restore your identity." : keyImportMethod === "phone" - ? "Scan with a signed-in Buzz phone to securely bring this identity to your desktop." - : identityLost - ? "Re-import your nsec or encrypted backup to restore this identity." - : "Enter your nsec or choose an encrypted backup file."} + ? "Scan this code with a signed-in Buzz phone." + : "Paste your private key to sign in to Buzz."}

{ setKeyImportStage("key-entry"); - setKeyImportMethod("phone"); + if (identityLost) { + return; + } + setPage("identity"); }} onImport={importExistingIdentity} + onPhoneRecovery={() => setKeyImportMethod("phone")} onStageChange={setKeyImportStage} + showBack={!identityLost} variant="spotlight" /> {identityLost && keyImportStage === "key-entry" ? ( diff --git a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx index 59e5bfdb0b..95177b0b9c 100644 --- a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx +++ b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx @@ -29,7 +29,9 @@ type NostrKeyImportFormProps = { errorMessage?: string | null; onBack: () => void; onImport: (nsec: string, password?: string) => Promise; + onPhoneRecovery?: () => void; onStageChange?: (stage: NostrKeyImportStage) => void; + showBack?: boolean; /** "spotlight" is the first-launch treatment: glowy centered input, no drop zone, pill buttons. */ variant?: "default" | "spotlight"; }; @@ -47,7 +49,9 @@ export function NostrKeyImportForm({ errorMessage: externalErrorMessage = null, onBack, onImport, + onPhoneRecovery, onStageChange, + showBack = true, variant = "default", }: NostrKeyImportFormProps) { const [nsecInput, setNsecInput] = React.useState(""); @@ -301,21 +305,34 @@ export function NostrKeyImportForm({ /> {!isPasswordStage && variant === "spotlight" ? ( - // First-launch/wiped-identity treatment: no drop zone, but the file - // path must still exist — a backup saved through the OS dialog is - // exactly what a wiped user returns with. -
- -
+ backup file + + {onPhoneRecovery ? ( + <> + {" "} + or{" "} + + + ) : null} + . +

) : !isPasswordStage ? ( + {showBack || isPasswordStage ? ( + + ) : null} ); diff --git a/desktop/tests/e2e/identity-lost.spec.ts b/desktop/tests/e2e/identity-lost.spec.ts index d75c9b87c3..827a778e22 100644 --- a/desktop/tests/e2e/identity-lost.spec.ts +++ b/desktop/tests/e2e/identity-lost.spec.ts @@ -52,7 +52,7 @@ test("normal first launch uses the already-persisted identity", async ({ test("lost boot opens onboarding gate directly on the key-import page", async ({ page, -}) => { +}, testInfo) => { await installMockBridge( page, { identityLost: true }, @@ -62,8 +62,12 @@ test("lost boot opens onboarding gate directly on the key-import page", async ({ await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible(); await expect( - page.getByRole("heading", { name: "Recover from your phone" }), + page.getByRole("heading", { name: "Enter your private key" }), ).toBeVisible(); + await page.waitForTimeout(1_000); + await page.screenshot({ + path: testInfo.outputPath("desktop-private-key-recovery.png"), + }); }); test("lost boot offers phone recovery with a single-use QR", async ({ @@ -76,6 +80,7 @@ test("lost boot offers phone recovery with a single-use QR", async ({ ); await page.goto("/"); + await page.getByTestId("nostr-import-phone-link").click(); await expect(page.getByTestId("identity-recovery-pairing")).toBeVisible(); await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); await expect( @@ -135,6 +140,7 @@ test("phone recovery uses the desktop pairing card semantics", async ({ ); await page.goto("/"); + await page.getByTestId("nostr-import-phone-link").click(); const card = page.getByTestId("identity-recovery-pairing"); const qrContainer = card.getByTestId("identity-recovery-qr-container"); const qrCode = card.getByTestId("identity-recovery-qr"); @@ -183,6 +189,7 @@ test("canceling recovery uses the standard pairing cancellation state", async ({ { skipOnboardingSeed: true }, ); await page.goto("/"); + await page.getByTestId("nostr-import-phone-link").click(); await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); await page.evaluate(async () => { @@ -218,6 +225,7 @@ test("phone recovery continues to harness setup without creating or restarting", { skipOnboardingSeed: true }, ); await page.goto("/"); + await page.getByTestId("nostr-import-phone-link").click(); await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); await page.evaluate(async () => { @@ -244,6 +252,7 @@ test("recovery turns relay failures into actionable copy", async ({ page }) => { { skipOnboardingSeed: true }, ); await page.goto("/"); + await page.getByTestId("nostr-import-phone-link").click(); await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); await page.evaluate(async () => { @@ -271,6 +280,7 @@ test("desktop refreshes recovery codes before the relay expires them", async ({ { skipOnboardingSeed: true }, ); await page.goto("/"); + await page.getByTestId("nostr-import-phone-link").click(); await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); const recoveryStarts = () => @@ -296,12 +306,8 @@ test("importing a key from lost mode shows the relaunch-required screen", async { skipOnboardingSeed: true }, ); await page.goto("/"); - await page - .getByRole("button", { name: "Use a private key or backup instead" }) - .click(); - await expect( - page.getByRole("heading", { name: "Re-import your key" }), + page.getByRole("heading", { name: "Enter your private key" }), ).toBeVisible(); const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey)); @@ -321,12 +327,8 @@ test("start-new-identity from lost mode persists the ephemeral key after confirm { skipOnboardingSeed: true }, ); await page.goto("/"); - await page - .getByRole("button", { name: "Use a private key or backup instead" }) - .click(); - await expect( - page.getByRole("heading", { name: "Re-import your key" }), + page.getByRole("heading", { name: "Enter your private key" }), ).toBeVisible(); page.on("dialog", (dialog) => dialog.accept()); @@ -358,12 +360,8 @@ test("cancelling start-new-identity in lost mode stays on the import screen", as { skipOnboardingSeed: true }, ); await page.goto("/"); - await page - .getByRole("button", { name: "Use a private key or backup instead" }) - .click(); - await expect( - page.getByRole("heading", { name: "Re-import your key" }), + page.getByRole("heading", { name: "Enter your private key" }), ).toBeVisible(); page.on("dialog", (dialog) => dialog.dismiss()); @@ -371,7 +369,7 @@ test("cancelling start-new-identity in lost mode stays on the import screen", as // Still on the import screen — no navigation, no persist await expect( - page.getByRole("heading", { name: "Re-import your key" }), + page.getByRole("heading", { name: "Enter your private key" }), ).toBeVisible(); await expect(page.getByTestId("relaunch-required")).toHaveCount(0); }); @@ -389,7 +387,7 @@ test("locked boot shows the keyring-locked screen without the onboarding gate or await expect(page.getByTestId("keyring-locked")).toBeVisible(); await expect(page.getByTestId("onboarding-gate")).toHaveCount(0); await expect( - page.getByRole("heading", { name: "Re-import your key" }), + page.getByRole("heading", { name: "Enter your private key" }), ).toHaveCount(0); }); diff --git a/desktop/tests/e2e/key-import-reveal.spec.ts b/desktop/tests/e2e/key-import-reveal.spec.ts index 1c1f529a96..cd05f71e76 100644 --- a/desktop/tests/e2e/key-import-reveal.spec.ts +++ b/desktop/tests/e2e/key-import-reveal.spec.ts @@ -19,9 +19,6 @@ test("key import masks the key with a reveal toggle", async ({ page }) => { await page.goto("/"); await page.getByRole("button", { name: "Use an existing key" }).click(); - await page - .getByRole("button", { name: "Use a private key or backup instead" }) - .click(); const input = page.getByTestId("nostr-import-nsec-input"); await expect(input).toBeVisible(); await waitForAnimations(page); diff --git a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts index 0475672735..efae7c3784 100644 --- a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts +++ b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts @@ -30,9 +30,6 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({ await page.screenshot({ path: `${SHOT_DIR}/01-landing.png` }); await page.getByRole("button", { name: "Use an existing key" }).click(); - await page - .getByRole("button", { name: "Use a private key or backup instead" }) - .click(); await expect( page.getByRole("heading", { name: "Enter your private key" }), ).toBeVisible(); @@ -146,9 +143,6 @@ test("machine key import remains usable in a short viewport", async ({ }); await page.goto("/"); await page.getByRole("button", { name: "Use an existing key" }).click(); - await page - .getByRole("button", { name: "Use a private key or backup instead" }) - .click(); const heading = page.getByRole("heading", { name: "Enter your private key" }); const input = page.getByLabel("Private key", { exact: true }); diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 89291c0e5f..fbff2e3025 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -620,7 +620,7 @@ test("completed users skip the loading gate while profile is still settling", as await expectHomeView(page); }); -test("fresh existing-identity path leads with phone recovery", async ({ +test("fresh existing-identity path leads with private-key recovery", async ({ page, }) => { await installMockBridge(page, undefined, { @@ -631,19 +631,26 @@ test("fresh existing-identity path leads with phone recovery", async ({ await page.getByRole("button", { name: "Use an existing key" }).click(); await expect( - page.getByRole("heading", { name: "Use your Buzz identity" }), + page.getByRole("heading", { name: "Enter your private key" }), ).toBeVisible(); - await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); - await expect(page.getByTestId("nostr-import-card")).toHaveCount(0); - - await page - .getByRole("button", { name: "Use a private key or backup instead" }) - .click(); await expect( - page.getByRole("heading", { name: "Enter your private key" }), + page.getByText("Paste your private key to sign in to Buzz."), ).toBeVisible(); await expect(page.getByTestId("nostr-import-card")).toBeVisible(); + await expect(page.getByTestId("nostr-import-file-button")).toHaveText( + "backup file", + ); + await expect(page.getByTestId("nostr-import-phone-link")).toHaveText( + "recover from your phone", + ); await expect(page.getByTestId("identity-recovery-pairing")).toHaveCount(0); + + await page.getByTestId("nostr-import-phone-link").click(); + await expect( + page.getByRole("heading", { name: "Use your Buzz identity" }), + ).toBeVisible(); + await expect(page.getByTestId("identity-recovery-qr")).toBeVisible(); + await expect(page.getByTestId("nostr-import-card")).toHaveCount(0); }); test("first-launch key import continues to machine setup", async ({ page }) => { @@ -654,9 +661,6 @@ test("first-launch key import continues to machine setup", async ({ page }) => { await page.goto("/"); await page.getByRole("button", { name: "Use an existing key" }).click(); - await page - .getByRole("button", { name: "Use a private key or backup instead" }) - .click(); const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey)); await page.getByTestId("nostr-import-nsec-input").fill(importedNsec); await page.getByTestId("nostr-import-submit").click(); @@ -677,9 +681,6 @@ test("first-launch encrypted backup import asks for a passphrase and continues", await page.goto("/"); await page.getByRole("button", { name: "Use an existing key" }).click(); - await page - .getByRole("button", { name: "Use a private key or backup instead" }) - .click(); // Spec-vector blob the mock bridge accepts with the mock passphrase. const mockNcryptsec = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; @@ -734,9 +735,6 @@ test("first-launch import accepts an .ncryptsec backup file", async ({ await page.goto("/"); await page.getByRole("button", { name: "Use an existing key" }).click(); - await page - .getByRole("button", { name: "Use a private key or backup instead" }) - .click(); // The spotlight variant must expose a file path: a wiped user returns with // exactly the identity.ncryptsec our own save dialog produced. The accept From bda65905729b5a88371324f3172f88ff265e375d Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 18:05:15 -0700 Subject: [PATCH 08/20] fix(desktop): move recovery links into description Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../onboarding/ui/MachineOnboardingFlow.tsx | 38 +++++++++++++++---- .../onboarding/ui/NostrKeyImportForm.tsx | 34 +---------------- 2 files changed, 32 insertions(+), 40 deletions(-) diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index e52cef1fd5..279915a166 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -309,13 +309,36 @@ export function MachineOnboardingFlow({ : "Use your Buzz identity" : "Enter your private key"} -

- {keyImportStage === "backup-password" - ? "Enter your backup password to restore your identity." - : keyImportMethod === "phone" - ? "Scan this code with a signed-in Buzz phone." - : "Paste your private key to sign in to Buzz."} -

+
+ {keyImportStage === "backup-password" ? ( + "Enter your backup password to restore your identity." + ) : keyImportMethod === "phone" ? ( + "Scan this code with a signed-in Buzz phone." + ) : ( +

+ Paste your private key to sign in to Buzz. You can also + use a{" "} + + , or{" "} + + . +

+ )} +
setKeyImportMethod("phone")} onStageChange={setKeyImportStage} showBack={!identityLost} variant="spotlight" diff --git a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx index 95177b0b9c..74e0d99088 100644 --- a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx +++ b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx @@ -29,7 +29,6 @@ type NostrKeyImportFormProps = { errorMessage?: string | null; onBack: () => void; onImport: (nsec: string, password?: string) => Promise; - onPhoneRecovery?: () => void; onStageChange?: (stage: NostrKeyImportStage) => void; showBack?: boolean; /** "spotlight" is the first-launch treatment: glowy centered input, no drop zone, pill buttons. */ @@ -49,7 +48,6 @@ export function NostrKeyImportForm({ errorMessage: externalErrorMessage = null, onBack, onImport, - onPhoneRecovery, onStageChange, showBack = true, variant = "default", @@ -295,6 +293,7 @@ export function NostrKeyImportForm({ className="sr-only" data-testid="nostr-import-file-input" disabled={isInteractionDisabled} + id="nostr-import-file-input" onChange={(event) => { void handleFiles(event.currentTarget.files); event.currentTarget.value = ""; @@ -304,36 +303,7 @@ export function NostrKeyImportForm({ type="file" /> - {!isPasswordStage && variant === "spotlight" ? ( -

- You can also use a{" "} - - {onPhoneRecovery ? ( - <> - {" "} - or{" "} - - - ) : null} - . -

- ) : !isPasswordStage ? ( + {!isPasswordStage && variant !== "spotlight" ? ( , or{" "}
-
- {keyImportMethod === "phone" ? ( -
- +
+
+ { + setKeyImportStage("key-entry"); + if (identityLost) { + return; + } + setPage("identity"); + }} + onImport={importExistingIdentity} + onStageChange={setKeyImportStage} + showBack={!identityLost} + variant="spotlight" + /> + {identityLost && keyImportStage === "key-entry" ? ( - {identityLost ? ( - - ) : ( - - )} -
- ) : ( -
+ ) : null} +
+
+ { + if (!open) setKeyImportDialog(null); + }} + open={keyImportDialog === "backup"} + > + +
+ + Restore from a backup file + + + Choose the encrypted backup file you saved from Buzz. + { - setKeyImportStage("key-entry"); - if (identityLost) { - return; - } - setPage("identity"); - }} + footerMode="inline" + mode="backup" + onBack={() => setKeyImportDialog(null)} onImport={importExistingIdentity} - onStageChange={setKeyImportStage} - showBack={!identityLost} + showBack={false} variant="spotlight" /> - {identityLost && keyImportStage === "key-entry" ? ( - - ) : null}
- )} -
+ + + { + if (!open) setKeyImportDialog(null); + }} + open={keyImportDialog === "phone"} + > + +
+ + {identityLost + ? "Recover from your phone" + : "Use your Buzz identity"} + + + Scan this code with a signed-in Buzz phone. + +
+ +
+
+
+
) : page === "backup" ? ( backupSubview === "password" ? ( diff --git a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx index 74e0d99088..cb42333b15 100644 --- a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx +++ b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx @@ -31,6 +31,10 @@ type NostrKeyImportFormProps = { onImport: (nsec: string, password?: string) => Promise; onStageChange?: (stage: NostrKeyImportStage) => void; showBack?: boolean; + /** Restrict this instance to selecting a backup file instead of typing a key. */ + mode?: "key" | "backup"; + /** Dialogs keep their actions inside the surface instead of the onboarding dock. */ + footerMode?: "onboarding" | "inline"; /** "spotlight" is the first-launch treatment: glowy centered input, no drop zone, pill buttons. */ variant?: "default" | "spotlight"; }; @@ -50,6 +54,8 @@ export function NostrKeyImportForm({ onImport, onStageChange, showBack = true, + mode = "key", + footerMode = "onboarding", variant = "default", }: NostrKeyImportFormProps) { const [nsecInput, setNsecInput] = React.useState(""); @@ -91,6 +97,7 @@ export function NostrKeyImportForm({ previewNpub === null && trimmedInput.length >= 5; const errorMessage = importError ?? externalErrorMessage; + const Footer = footerMode === "inline" ? "div" : OnboardingFooter; React.useLayoutEffect(() => { if (isPasswordStage) { @@ -201,7 +208,7 @@ export function NostrKeyImportForm({ void handleSubmit(); }} > - {!isPasswordStage ? ( + {!isPasswordStage && mode === "key" ? (