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..aedd67854c 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,16 +33,36 @@ struct PairingErrorPayload { message: String, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum PairingMode { + SendIdentity, + RecoverIdentity, +} + +#[derive(Clone)] +struct PairingTaskContext { + mode: PairingMode, + generation: Arc, + generation_fence: Arc>, + task_generation: u64, +} + /// Managed Tauri state for an active pairing session. pub struct PairingHandle { session: Arc>>, generation: Arc, + /// Linearizes cancellation/replacement against recovered identity commits. + generation_fence: Arc>, + /// Serializes session setup so an older start cannot resume after relay + /// discovery and overwrite a newer session's shared state. + start_lock: tokio::sync::Mutex<()>, cancel: std::sync::Mutex>, /// Send JSON-serialized events to the background WS task for relay publication. outbound_tx: std::sync::Mutex>>, /// 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 { @@ -50,9 +70,12 @@ impl PairingHandle { Self { session: Arc::new(tokio::sync::Mutex::new(None)), generation: Arc::new(AtomicU64::new(0)), + generation_fence: Arc::new(std::sync::Mutex::new(())), + start_lock: tokio::sync::Mutex::new(()), 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,21 +86,36 @@ 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 { - let task_generation = pairing - .generation - .fetch_add(1, Ordering::SeqCst) - .wrapping_add(1); + 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 _start_guard = pairing.start_lock.lock().await; + let task_generation = + invalidate_pairing_generation(&pairing.generation, &pairing.generation_fence)?; if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() { token.cancel(); } @@ -86,54 +124,52 @@ 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), + generation_fence: Arc::clone(&pairing.generation_fence), + task_generation, + }, cancel, outbound_rx, app, @@ -161,27 +197,30 @@ pub async fn confirm_pairing_sas(pairing: State<'_, PairingHandle>) -> Result<() tx.send(sas_confirm_json) .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")?; + .map_err(|_| "Pairing code expired. Create a new code and try again.")?; - 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(()) } @@ -189,6 +228,14 @@ pub async fn confirm_pairing_sas(pairing: State<'_, PairingHandle>) -> Result<() /// Cancel the active pairing session. #[tauri::command] pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), String> { + // Invalidate the task before waiting for its session lock. Recovery may be + // blocked on identity persistence after releasing this lock, and must see + // cancellation before crossing the durable commit boundary. + invalidate_pairing_generation(&pairing.generation, &pairing.generation_fence)?; + if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() { + token.cancel(); + } + let abort_json = { let mut guard = pairing.session.lock().await; if let Some(session) = guard.as_mut() { @@ -213,11 +260,6 @@ pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), Str } } - pairing.generation.fetch_add(1, Ordering::SeqCst); - - if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() { - token.cancel(); - } pairing.clear(); { @@ -231,8 +273,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 +281,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 +329,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 +356,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 +364,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 +373,83 @@ 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((payload_type, payload)) = s.handle_return_payload(&event) { + if let Err(message) = validate_recovery_payload_type(payload_type) { + let complete = s + .send_source_complete(false) + .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}"))?; + if pairing_task_is_current( + &context.generation, + context.task_generation, + ) { + let _ = app.emit( + "pairing-error", + PairingErrorPayload { message }, + ); + } + break; } + + let payload = payload; + drop(guard); + + let imported = import_recovered_identity( + app, + payload, + &context.generation, + &context.generation_fence, + context.task_generation, + ) + .await; + let success = imported.is_ok(); + let complete = { + let mut guard = session.lock().await; + if !pairing_task_is_current( + &context.generation, + context.task_generation, + ) { + break; + } + let Some(s) = guard.as_mut() else { break }; + s.send_source_complete(success) + .map_err(|e| e.to_string())? + }; + let completion_result = write + .send(Message::Text(event_to_relay_json(&complete).into())) + .await + .map_err(|e| format!("publish complete failed: {e}")); + finish_recovery(imported, completion_result, context, app)?; 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,10 +459,111 @@ async fn pairing_ws_task_inner( Ok(()) } +async fn import_recovered_identity( + app: &AppHandle, + nsec: Zeroizing, + generation: &Arc, + generation_fence: &Arc>, + task_generation: u64, +) -> Result<(), String> { + let app = app.clone(); + let generation = Arc::clone(generation); + let generation_fence = Arc::clone(generation_fence); + 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())?; + commit_recovery_if_current(&generation, &generation_fence, task_generation, || { + 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 ensure_pairing_task_is_current( + generation: &AtomicU64, + task_generation: u64, +) -> Result<(), String> { + if pairing_task_is_current(generation, task_generation) { + Ok(()) + } else { + Err("Pairing session was superseded or cancelled".into()) + } +} + +fn invalidate_pairing_generation( + generation: &AtomicU64, + generation_fence: &std::sync::Mutex<()>, +) -> Result { + let _fence = generation_fence.lock().map_err(|e| e.to_string())?; + Ok(generation.fetch_add(1, Ordering::SeqCst).wrapping_add(1)) +} + +fn commit_recovery_if_current( + generation: &AtomicU64, + generation_fence: &std::sync::Mutex<()>, + task_generation: u64, + commit: impl FnOnce() -> Result, +) -> Result { + let _fence = generation_fence.lock().map_err(|e| e.to_string())?; + ensure_pairing_task_is_current(generation, task_generation)?; + commit() +} + +fn recovery_result_after_completion( + imported: Result<(), String>, + _completion_result: Result<(), String>, +) -> Result<(), String> { + // Once the identity is durable, notifying the peer cannot roll it back. + imported +} + +fn finish_recovery( + imported: Result<(), String>, + completion_result: Result<(), String>, + context: &PairingTaskContext, + app: &AppHandle, +) -> Result<(), String> { + if !pairing_task_is_current(&context.generation, context.task_generation) { + return Ok(()); + } + + match recovery_result_after_completion(imported, completion_result) { + Ok(()) => { + let _ = app.emit("pairing-complete", serde_json::json!({})); + } + Err(message) => { + let _ = app.emit("pairing-error", PairingErrorPayload { message }); + } + } + Ok(()) +} + fn pairing_task_is_current(generation: &AtomicU64, task_generation: u64) -> bool { generation.load(Ordering::SeqCst) == task_generation } +fn validate_recovery_payload_type(payload_type: PayloadType) -> Result<(), String> { + if payload_type == PayloadType::Nsec { + Ok(()) + } else { + Err("Mobile device sent an unsupported recovery payload".into()) + } +} + async fn clear_pairing_session_if_current( session: &Arc>>, generation: &AtomicU64, @@ -590,143 +785,9 @@ where } #[cfg(test)] -mod pairing_generation_tests { - use std::sync::atomic::{AtomicU64, Ordering}; - use std::sync::Arc; - - use super::{clear_pairing_session_if_current, PairingSession}; - - #[tokio::test] - async fn stale_task_does_not_clear_replacement_session() { - let (initial, _) = PairingSession::new_source("ws://initial.example".to_string()); - let session = Arc::new(tokio::sync::Mutex::new(Some(initial))); - let generation = AtomicU64::new(1); - - generation.store(2, Ordering::SeqCst); - let (replacement, _) = PairingSession::new_source("ws://replacement.example".to_string()); - *session.lock().await = Some(replacement); - - clear_pairing_session_if_current(&session, &generation, 1).await; - - assert!(session.lock().await.is_some()); - } - - #[tokio::test] - async fn current_task_clears_its_session() { - let (active, _) = PairingSession::new_source("ws://active.example".to_string()); - let session = Arc::new(tokio::sync::Mutex::new(Some(active))); - let generation = AtomicU64::new(3); - - clear_pairing_session_if_current(&session, &generation, 3).await; - - assert!(session.lock().await.is_none()); - } -} +#[path = "pairing_generation_tests.rs"] +mod pairing_generation_tests; #[cfg(test)] -mod pairing_relay_tests { - use super::{ - pairing_relay_from_nip11, probe_pairing_relay, resolve_pairing_relay_url, PairingRelay, - }; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - #[tokio::test] - async fn live_nip11_probe_discovers_configured_pairing_relay() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind test NIP-11 server"); - let addr = listener.local_addr().expect("test server address"); - let server = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept NIP-11 request"); - let mut request = vec![0; 2048]; - let bytes_read = stream.read(&mut request).await.expect("read request"); - let request = String::from_utf8_lossy(&request[..bytes_read]); - assert!(request.starts_with("GET / HTTP/1.1")); - assert!(request - .to_ascii_lowercase() - .contains("accept: application/nostr+json")); - - let body = r#"{"pairing_relay_url":"ws://127.0.0.1:5000"}"#; - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/nostr+json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ); - stream - .write_all(response.as_bytes()) - .await - .expect("write response"); - }); - - assert_eq!( - probe_pairing_relay(&format!("ws://{addr}")).await, - PairingRelay::Configured("ws://127.0.0.1:5000".to_string()) - ); - server.await.expect("NIP-11 server task"); - } - - #[test] - fn configured_pairing_relay_takes_precedence_over_legacy_path() { - let document = serde_json::json!({ - "pairing_relay_url": "wss://pairing.buzz.xyz", - "supported_nips": [43] - }); - - assert_eq!( - pairing_relay_from_nip11(&document), - PairingRelay::Configured("wss://pairing.buzz.xyz".to_string()) - ); - } - - #[test] - fn invalid_pairing_relay_url_falls_back_to_legacy_path() { - let document = serde_json::json!({ - "pairing_relay_url": "https://pairing.buzz.xyz", - "supported_nips": [43] - }); - - assert_eq!( - pairing_relay_from_nip11(&document), - PairingRelay::LegacyPath - ); - } - - #[test] - fn document_without_pairing_configuration_uses_main_relay() { - let document = serde_json::json!({ "supported_nips": [1, 11] }); - - assert_eq!(pairing_relay_from_nip11(&document), PairingRelay::MainRelay); - } - - #[test] - fn configured_pairing_relay_resolves_to_configured_url() { - let resolved = resolve_pairing_relay_url( - "wss://flint.communities.buzz.xyz", - PairingRelay::Configured("wss://pairing.buzz.xyz".to_string()), - ) - .expect("resolve configured pairing relay"); - - assert_eq!(resolved, "wss://pairing.buzz.xyz"); - } - - #[test] - fn legacy_pairing_relay_appends_pair_path() { - let resolved = resolve_pairing_relay_url( - "wss://flint.communities.buzz.xyz/community", - PairingRelay::LegacyPath, - ) - .expect("resolve legacy pairing relay"); - - assert_eq!(resolved, "wss://flint.communities.buzz.xyz/community/pair"); - } - - #[test] - fn main_relay_pairing_uses_main_relay_url() { - let resolved = resolve_pairing_relay_url( - "wss://sprout-oss.stage.blox.sqprod.co", - PairingRelay::MainRelay, - ) - .expect("resolve main pairing relay"); - - assert_eq!(resolved, "wss://sprout-oss.stage.blox.sqprod.co"); - } -} +#[path = "pairing_relay_tests.rs"] +mod pairing_relay_tests; diff --git a/desktop/src-tauri/src/commands/pairing_generation_tests.rs b/desktop/src-tauri/src/commands/pairing_generation_tests.rs new file mode 100644 index 0000000000..8a2291ae86 --- /dev/null +++ b/desktop/src-tauri/src/commands/pairing_generation_tests.rs @@ -0,0 +1,129 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use super::{ + clear_pairing_session_if_current, commit_recovery_if_current, invalidate_pairing_generation, + recovery_result_after_completion, validate_recovery_payload_type, PairingHandle, + PairingSession, PayloadType, +}; + +#[tokio::test] +async fn overlapping_starts_are_serialized() { + let pairing = Arc::new(PairingHandle::new()); + let first_pairing = Arc::clone(&pairing); + let (locked_tx, locked_rx) = tokio::sync::oneshot::channel(); + let first = tokio::spawn(async move { + let _guard = first_pairing.start_lock.lock().await; + locked_tx.send(()).expect("signal acquired start lock"); + tokio::time::sleep(Duration::from_millis(50)).await; + }); + + locked_rx.await.expect("first start acquired lock"); + assert!(pairing.start_lock.try_lock().is_err()); + first.await.expect("first start task"); + assert!(pairing.start_lock.try_lock().is_ok()); +} + +#[test] +fn recovery_rejects_non_nsec_payloads() { + assert!(validate_recovery_payload_type(PayloadType::Nsec).is_ok()); + assert_eq!( + validate_recovery_payload_type(PayloadType::Custom).unwrap_err(), + "Mobile device sent an unsupported recovery payload" + ); +} + +#[test] +fn superseded_recovery_cannot_commit_identity() { + let generation = AtomicU64::new(2); + let committed = std::sync::atomic::AtomicBool::new(false); + + let generation_fence = std::sync::Mutex::new(()); + let result = commit_recovery_if_current(&generation, &generation_fence, 1, || { + committed.store(true, Ordering::SeqCst); + Ok(()) + }); + + assert_eq!( + result.unwrap_err(), + "Pairing session was superseded or cancelled" + ); + assert!(!committed.load(Ordering::SeqCst)); +} + +#[test] +fn invalidation_after_check_waits_for_identity_commit() { + let generation = Arc::new(AtomicU64::new(7)); + let generation_fence = Arc::new(std::sync::Mutex::new(())); + let (checked_tx, checked_rx) = std::sync::mpsc::channel(); + let (finish_tx, finish_rx) = std::sync::mpsc::channel(); + let committed = Arc::new(std::sync::atomic::AtomicBool::new(false)); + + let recovery_generation = Arc::clone(&generation); + let recovery_fence = Arc::clone(&generation_fence); + let recovery_committed = Arc::clone(&committed); + let recovery = std::thread::spawn(move || { + commit_recovery_if_current(&recovery_generation, &recovery_fence, 7, || { + checked_tx.send(()).expect("signal generation checked"); + finish_rx.recv().expect("release identity commit"); + recovery_committed.store(true, Ordering::SeqCst); + Ok(()) + }) + }); + + checked_rx.recv().expect("generation checked"); + let invalidation_generation = Arc::clone(&generation); + let invalidation_fence = Arc::clone(&generation_fence); + let (attempted_tx, attempted_rx) = std::sync::mpsc::channel(); + let (invalidated_tx, invalidated_rx) = std::sync::mpsc::channel(); + let invalidation = std::thread::spawn(move || { + attempted_tx.send(()).expect("signal invalidation attempt"); + let next = invalidate_pairing_generation(&invalidation_generation, &invalidation_fence) + .expect("invalidate generation"); + invalidated_tx.send(next).expect("signal invalidated"); + }); + + attempted_rx.recv().expect("invalidation attempted"); + assert!(invalidated_rx + .recv_timeout(Duration::from_millis(50)) + .is_err()); + assert!(!committed.load(Ordering::SeqCst)); + + finish_tx.send(()).expect("finish identity commit"); + recovery.join().expect("recovery task").unwrap(); + assert!(committed.load(Ordering::SeqCst)); + assert_eq!(invalidated_rx.recv().expect("invalidation completed"), 8); + invalidation.join().expect("invalidation task"); +} + +#[test] +fn completion_publish_failure_does_not_undo_successful_import() { + assert!(recovery_result_after_completion(Ok(()), Err("socket closed".into())).is_ok()); +} + +#[tokio::test] +async fn stale_task_does_not_clear_replacement_session() { + let (initial, _) = PairingSession::new_source("ws://initial.example".to_string()); + let session = Arc::new(tokio::sync::Mutex::new(Some(initial))); + let generation = AtomicU64::new(1); + + generation.store(2, Ordering::SeqCst); + let (replacement, _) = PairingSession::new_source("ws://replacement.example".to_string()); + *session.lock().await = Some(replacement); + + clear_pairing_session_if_current(&session, &generation, 1).await; + + assert!(session.lock().await.is_some()); +} + +#[tokio::test] +async fn current_task_clears_its_session() { + let (active, _) = PairingSession::new_source("ws://active.example".to_string()); + let session = Arc::new(tokio::sync::Mutex::new(Some(active))); + let generation = AtomicU64::new(3); + + clear_pairing_session_if_current(&session, &generation, 3).await; + + assert!(session.lock().await.is_none()); +} diff --git a/desktop/src-tauri/src/commands/pairing_relay_tests.rs b/desktop/src-tauri/src/commands/pairing_relay_tests.rs new file mode 100644 index 0000000000..f0e765eb9c --- /dev/null +++ b/desktop/src-tauri/src/commands/pairing_relay_tests.rs @@ -0,0 +1,104 @@ +use super::{ + pairing_relay_from_nip11, probe_pairing_relay, resolve_pairing_relay_url, PairingRelay, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +#[tokio::test] +async fn live_nip11_probe_discovers_configured_pairing_relay() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test NIP-11 server"); + let addr = listener.local_addr().expect("test server address"); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept NIP-11 request"); + let mut request = vec![0; 2048]; + let bytes_read = stream.read(&mut request).await.expect("read request"); + let request = String::from_utf8_lossy(&request[..bytes_read]); + assert!(request.starts_with("GET / HTTP/1.1")); + assert!(request + .to_ascii_lowercase() + .contains("accept: application/nostr+json")); + + let body = r#"{"pairing_relay_url":"ws://127.0.0.1:5000"}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/nostr+json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write response"); + }); + + assert_eq!( + probe_pairing_relay(&format!("ws://{addr}")).await, + PairingRelay::Configured("ws://127.0.0.1:5000".to_string()) + ); + server.await.expect("NIP-11 server task"); +} + +#[test] +fn configured_pairing_relay_takes_precedence_over_legacy_path() { + let document = serde_json::json!({ + "pairing_relay_url": "wss://pairing.buzz.xyz", + "supported_nips": [43] + }); + + assert_eq!( + pairing_relay_from_nip11(&document), + PairingRelay::Configured("wss://pairing.buzz.xyz".to_string()) + ); +} + +#[test] +fn invalid_pairing_relay_url_falls_back_to_legacy_path() { + let document = serde_json::json!({ + "pairing_relay_url": "https://pairing.buzz.xyz", + "supported_nips": [43] + }); + + assert_eq!( + pairing_relay_from_nip11(&document), + PairingRelay::LegacyPath + ); +} + +#[test] +fn document_without_pairing_configuration_uses_main_relay() { + let document = serde_json::json!({ "supported_nips": [1, 11] }); + + assert_eq!(pairing_relay_from_nip11(&document), PairingRelay::MainRelay); +} + +#[test] +fn configured_pairing_relay_resolves_to_configured_url() { + let resolved = resolve_pairing_relay_url( + "wss://flint.communities.buzz.xyz", + PairingRelay::Configured("wss://pairing.buzz.xyz".to_string()), + ) + .expect("resolve configured pairing relay"); + + assert_eq!(resolved, "wss://pairing.buzz.xyz"); +} + +#[test] +fn legacy_pairing_relay_appends_pair_path() { + let resolved = resolve_pairing_relay_url( + "wss://flint.communities.buzz.xyz/community", + PairingRelay::LegacyPath, + ) + .expect("resolve legacy pairing relay"); + + assert_eq!(resolved, "wss://flint.communities.buzz.xyz/community/pair"); +} + +#[test] +fn main_relay_pairing_uses_main_relay_url() { + let resolved = resolve_pairing_relay_url( + "wss://sprout-oss.stage.blox.sqprod.co", + PairingRelay::MainRelay, + ) + .expect("resolve main pairing relay"); + + assert_eq!(resolved, "wss://sprout-oss.stage.blox.sqprod.co"); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 2847b87877..4f935631b6 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -880,6 +880,7 @@ pub fn run() { set_audio_output_device, get_audio_output_device, start_pairing, + start_identity_recovery_pairing, confirm_pairing_sas, cancel_pairing, apply_workspace, 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/BackupPasswordTimeline.tsx b/desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx index 610c104d95..555d6e1365 100644 --- a/desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx +++ b/desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx @@ -46,6 +46,66 @@ const TIMELINE_BOTTOM_DOT_TRANSITIONS = TIMELINE_CONNECTOR_DOTS.map( }), ); +export function BackupFileUnlockPreview() { + const reduceMotion = useReducedMotion() ?? false; + + return ( +
+ +
+ {BACKUP_KEY_DOTS.map((dot) => ( + + ))} +
+ + +
+ ); +} + +function TimelineDots({ + reduceMotion, + transitions, +}: { + reduceMotion: boolean; + transitions: ReadonlyArray< + typeof TIMELINE_DOT_TRANSITION & { delay: number } + >; +}) { + return ( +
+ {TIMELINE_CONNECTOR_DOTS.map((dot, index) => ( + + ))} +
+ ); +} + /** * Decorative timeline shared by backup creation and encrypted-backup restore. * Backup creation reads key → password → lock; restore reads encrypted file → diff --git a/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx b/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx new file mode 100644 index 0000000000..5cca7c1bf5 --- /dev/null +++ b/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx @@ -0,0 +1,278 @@ +import * as React from "react"; +import { listen } from "@tauri-apps/api/event"; +import { + Check, + Copy, + LoaderCircle, + RefreshCw, + ShieldCheck, + TriangleAlert, + X, +} 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"; + +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, + onStepChange, +}: { + onRecovered: () => Promise; + onStepChange?: (step: Step) => void; +}) { + 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 [copied, setCopied] = React.useState(false); + const active = React.useRef(true); + const copyTimer = React.useRef(null); + + React.useEffect(() => { + onStepChange?.(step); + }, [onStepChange, step]); + + const start = React.useCallback(async () => { + active.current = true; + setStep("loading"); + setError(null); + setSas(null); + setQrUri(null); + setCopied(false); + 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(recoveryErrorMessage(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(); + if (copyTimer.current !== null) window.clearTimeout(copyTimer.current); + void cancelPairing(); + }; + }, [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 { + 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 deny() { + active.current = false; + await cancelPairing().catch(() => {}); + setError("The codes didn't match. Pairing was canceled."); + setStep("error"); + } + + async function confirm() { + setStep("receiving"); + try { + await confirmPairingSas(); + } catch (cause) { + if (!active.current) return; + setError( + recoveryErrorMessage( + cause instanceof Error + ? cause.message + : "Could not confirm recovery.", + ), + ); + setStep("error"); + } + } + + return ( +
+
+ {step === "qr" && qrUri ? ( + + ) : step === "sas" && sas ? ( +
+ +

+ Does this code match your phone? +

+
+

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

+
+

+ This gives this desktop permanent access to your Buzz identity. + Only continue if you trust it. +

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

Identity received securely

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

{error}

+ +
+ ) : ( +
+ +

+ {step === "receiving" + ? "Receiving identity from mobile device..." + : "Starting pairing..."} +

+
+ )} +
+ {step === "loading" || (step === "qr" && qrUri) ? ( + + ) : null} + {step === "qr" && error ? ( +

+ {error} +

+ ) : null} + {step === "qr" || step === "loading" ? ( +

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

+ ) : null} +
+ ); +} diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index 693d1af058..c0cc2d8f79 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -10,6 +10,12 @@ import { } from "@/shared/api/tauriIdentity"; import type { IdentityStorage } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@/shared/ui/dialog"; import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; import { BackupStep } from "./BackupStep"; import { DefaultConfigStep } from "./DefaultConfigStep"; @@ -20,12 +26,14 @@ import { useEncryptedBackupSession, } from "./EncryptedBackupCreator"; import { IdentityKeyHelpDialog } from "./IdentityKeyHelpDialog"; +import { IdentityRecoveryPairing } from "./IdentityRecoveryPairing"; import { LandingBees } from "./LandingBees"; import { NostrKeyImportForm, type NostrKeyImportStage, } from "./NostrKeyImportForm"; import { + ONBOARDING_INK_ICON_CLASS, ONBOARDING_LANDING_CTA_CLASS, ONBOARDING_SECONDARY_CTA_CLASS, OnboardingChrome, @@ -53,6 +61,7 @@ export type PostOnboardingNavigation = { export function MachineOnboardingFlow({ complete, continueWithIdentity, + continueWithRecoveredIdentity, identityLost, initialPage, queryClient, @@ -60,6 +69,7 @@ export function MachineOnboardingFlow({ }: { complete: (pubkey?: string) => void; continueWithIdentity: (pubkey: string) => void; + continueWithRecoveredIdentity: (pubkey: string) => void; identityLost: boolean; initialPage?: MachineOnboardingPage; queryClient: QueryClient; @@ -79,6 +89,10 @@ export function MachineOnboardingFlow({ const [identityWasImported, setIdentityWasImported] = React.useState(false); const [keyImportStage, setKeyImportStage] = React.useState("key-entry"); + const [keyImportDialog, setKeyImportDialog] = React.useState< + "backup" | "phone" | null + >(null); + const [phoneRecoveryStep, setPhoneRecoveryStep] = React.useState("loading"); const [selectedPubkey, setSelectedPubkey] = React.useState( null, ); @@ -128,6 +142,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?", @@ -243,6 +277,7 @@ export function MachineOnboardingFlow({ className={`${ONBOARDING_SECONDARY_CTA_CLASS} px-5`} disabled={isPending} onClick={() => { + setKeyImportDialog(null); setKeyImportStage("key-entry"); setPage("key-import"); }} @@ -265,7 +300,7 @@ export function MachineOnboardingFlow({ > {keyImportStage === "backup-password" ? "Unlock your account" - : 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." - : 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."} -

+
+ {keyImportStage === "backup-password" ? ( + "Enter your backup password to restore your identity." + ) : ( +

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

+ )} +
- void replaceLostIdentity() - : () => setPage("identity") - } - onImport={importExistingIdentity} - onStageChange={setKeyImportStage} - variant="spotlight" - /> +
+ { + setKeyImportStage("key-entry"); + if (identityLost) { + return; + } + setPage("identity"); + }} + onImport={importExistingIdentity} + onStageChange={setKeyImportStage} + showBack={!identityLost} + variant="spotlight" + /> + {identityLost && keyImportStage === "key-entry" ? ( + + ) : null} +
+ { + if (!open) setKeyImportDialog(null); + }} + open={keyImportDialog === "backup"} + > + +
+ + Restore from a backup file + + + Choose the encrypted backup file you saved from Buzz. + + setKeyImportDialog(null)} + onImport={importExistingIdentity} + showBack={false} + variant="spotlight" + /> +
+
+
+ { + if (!open) setKeyImportDialog(null); + }} + open={keyImportDialog === "phone"} + > + +
+ + {identityLost + ? "Recover from your phone" + : "Use your Buzz identity"} + + + {phoneRecoveryStep === "loading" || + phoneRecoveryStep === "qr" + ? "Scan this code with a signed-in Buzz phone." + : "Confirm the code before sharing your identity."} + +
+ +
+
+
+
) : page === "backup" ? ( backupSubview === "password" ? ( diff --git a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx index 59e5bfdb0b..a424236eb6 100644 --- a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx +++ b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Check, Eye, EyeOff, KeyRound } from "lucide-react"; +import { Check, Eye, EyeOff, FileKey2, KeyRound } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import { nsecToNpub } from "@/shared/lib/nostrUtils"; @@ -16,7 +16,10 @@ import { ONBOARDING_PRIMARY_CTA_CLASS, ONBOARDING_SECONDARY_CTA_CLASS, } from "./OnboardingChrome"; -import { BackupPasswordTimeline } from "./BackupPasswordTimeline"; +import { + BackupFileUnlockPreview, + BackupPasswordTimeline, +} from "./BackupPasswordTimeline"; import { OnboardingFooter } from "./OnboardingFooter"; const NOSTR_KEY_FILE_MAX_BYTES = 1024; @@ -30,6 +33,11 @@ type NostrKeyImportFormProps = { onBack: () => void; 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"; }; @@ -48,6 +56,9 @@ export function NostrKeyImportForm({ onBack, onImport, onStageChange, + showBack = true, + mode = "key", + footerMode = "onboarding", variant = "default", }: NostrKeyImportFormProps) { const [nsecInput, setNsecInput] = React.useState(""); @@ -55,6 +66,7 @@ export function NostrKeyImportForm({ const [isImporting, setIsImporting] = React.useState(false); const [importError, setImportError] = React.useState(null); const [isDragging, setIsDragging] = React.useState(false); + const dragDepthRef = React.useRef(0); const [isRevealed, setIsRevealed] = React.useState(false); const inputRef = React.useRef(null); const passphraseInputRef = React.useRef(null); @@ -89,6 +101,7 @@ export function NostrKeyImportForm({ previewNpub === null && trimmedInput.length >= 5; const errorMessage = importError ?? externalErrorMessage; + const Footer = footerMode === "inline" ? "div" : OnboardingFooter; React.useLayoutEffect(() => { if (isPasswordStage) { @@ -102,6 +115,39 @@ export function NostrKeyImportForm({ onStageChange?.(isPasswordStage ? "backup-password" : "key-entry"); }, [isPasswordStage, onStageChange]); + React.useEffect(() => { + if (mode !== "backup" || isPasswordStage || isInteractionDisabled) { + dragDepthRef.current = 0; + setIsDragging(false); + return; + } + + const handleDragEnter = (event: DragEvent) => { + if (!event.dataTransfer?.types.includes("Files")) return; + dragDepthRef.current += 1; + setIsDragging(true); + }; + const handleDragLeave = () => { + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) setIsDragging(false); + }; + const handleDragEnd = () => { + dragDepthRef.current = 0; + setIsDragging(false); + }; + + window.addEventListener("dragenter", handleDragEnter); + window.addEventListener("dragleave", handleDragLeave); + window.addEventListener("drop", handleDragEnd); + window.addEventListener("dragend", handleDragEnd); + return () => { + window.removeEventListener("dragenter", handleDragEnter); + window.removeEventListener("dragleave", handleDragLeave); + window.removeEventListener("drop", handleDragEnd); + window.removeEventListener("dragend", handleDragEnd); + }; + }, [isInteractionDisabled, isPasswordStage, mode]); + const openFilePicker = React.useCallback(() => { if (isInteractionDisabled) { return; @@ -194,12 +240,27 @@ export function NostrKeyImportForm({ return (
{ + if (mode !== "backup" || isPasswordStage) return; + event.preventDefault(); + if (!isInteractionDisabled) { + event.dataTransfer.dropEffect = "copy"; + } + }} + onDrop={(event) => { + if (mode !== "backup" || isPasswordStage) return; + event.preventDefault(); + setIsDragging(false); + if (!isInteractionDisabled) { + void handleFiles(event.dataTransfer.files); + } + }} onSubmit={(event) => { event.preventDefault(); void handleSubmit(); }} > - {!isPasswordStage ? ( + {!isPasswordStage && mode === "key" ? (
+ {isDragging ? ( +
+ + +
+ ) : null} + + ) : null} + + {!isPasswordStage && mode === "key" && variant !== "spotlight" ? ( +
+ {mode === "key" || isPasswordStage ? ( + + ) : null} - - + {showBack || isPasswordStage ? ( + + ) : null} +
); } diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 67ba582a1d..6e29f77c14 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -1126,8 +1126,6 @@ export async function nip44DecryptFromSelf( return invokeTauri("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..750a57ccaf 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -12749,9 +12749,20 @@ export function maybeInstallE2eTauriMocks() { } return "nostrpair://8f4b8db31967ce14fef970a1ff1e8eecf19a430aa1c83875e2f5be68dcac0f1a?relay=wss%3A%2F%2Frelay.example.com&secret=87d5a8cfd5807a0cb44f728b67d88d6dcb8daf99be137c158f21a50c1e913c0a&v=1"; } + 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&mode=recover`; + } case "cancel_pairing": case "confirm_pairing_sas": return null; + case "complete_identity_recovery_pairing": + mockIdentityLostCleared = true; + await emit("pairing-complete", {}); + return null; // ── NIP-IA identity archival ──────────────────────────────────────── // These mocks drive the archive-button gate matrix in // tests/e2e/identity-archive.spec.ts. Defaults keep the button hidden diff --git a/desktop/tests/e2e/identity-lost.spec.ts b/desktop/tests/e2e/identity-lost.spec.ts index 71c663ab4f..2ab41cb52d 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,13 +62,44 @@ 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: "Enter your private key" }), ).toBeVisible(); + await page.waitForTimeout(1_000); + await page.screenshot({ + path: testInfo.outputPath("desktop-private-key-recovery.png"), + }); }); -test("importing a key from lost mode shows the relaunch-required screen", async ({ +test("lost boot keeps the pairing-code action stable while generating", async ({ page, }) => { + await installMockBridge( + page, + { identityLost: true, pairingStartDelayMs: 2_500 }, + { skipOnboardingSeed: true }, + ); + await page.goto("/"); + + await page.getByTestId("nostr-import-phone-link").click(); + const copyButton = page.getByTestId("copy-identity-recovery-code"); + await expect(copyButton).toBeVisible(); + await expect(copyButton).toBeDisabled(); + await expect(copyButton).toHaveText("Generating pairing code..."); + const loadingButton = await copyButton.elementHandle(); + + await expect(copyButton).toBeEnabled(); + await expect(copyButton).toHaveText("Copy pairing code"); + expect( + await copyButton.evaluate( + (button, loading) => button === loading, + loadingButton, + ), + ).toBe(true); +}); + +test("lost boot offers phone recovery with a single-use QR", async ({ + page, +}, testInfo) => { await installMockBridge( page, { identityLost: true }, @@ -76,8 +107,254 @@ test("importing a key from lost mode shows the relaunch-required screen", 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( - page.getByRole("heading", { name: "Re-import your key" }), + page.getByText("Scan this code with a signed-in Buzz phone."), + ).toBeVisible(); + await expect( + page.getByText("On your phone, open Settings → Send identity to desktop."), + ).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 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( + () => + ( + 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("phone recovery uses the desktop pairing card semantics", async ({ + page, +}) => { + await installMockBridge( + page, + { identityLost: true }, + { skipOnboardingSeed: true }, + ); + 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"); + 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(1); + expect(Math.abs((copyBox?.width ?? 0) - (qrBox?.width ?? 0))).toBeLessThan(1); + + await page.evaluate(async () => { + await window.__TAURI_INTERNALS__?.invoke?.("plugin:event|emit", { + event: "pairing-sas-received", + payload: { sas: "123456" }, + }); + }); + + await expect( + card.getByText("Does this code match your phone?"), + ).toBeVisible(); + await expect( + page.getByText("Confirm the code before sharing your identity."), + ).toBeVisible(); + await expect( + card.getByText( + "This gives this desktop permanent access to your Buzz identity. Only continue if you trust it.", + ), + ).toBeVisible(); + await expect( + card.getByText(/On your phone, open Settings/), + ).not.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", + ); + const cancelBox = await card + .getByTestId("deny-identity-recovery-sas") + .boundingBox(); + const confirmBox = await card + .getByTestId("confirm-identity-recovery-sas") + .boundingBox(); + expect(cancelBox).not.toBeNull(); + expect(confirmBox).not.toBeNull(); + expect((cancelBox?.y ?? 0) - (confirmBox?.y ?? 0)).toBeGreaterThan( + confirmBox?.height ?? 0, + ); +}); + +test("canceling recovery uses the standard pairing cancellation state", async ({ + page, +}) => { + await installMockBridge( + page, + { identityLost: true }, + { 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 () => { + 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, +}) => { + await installMockBridge( + page, + { identityLost: true }, + { 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 () => { + await window.__TAURI_INTERNALS__?.invoke?.( + "complete_identity_recovery_pairing", + ); + }); + + await expect( + page.getByRole("heading", { name: "Set up your agent harnesses" }), + ).toBeVisible(); + await expect(page.getByTestId("relaunch-required")).toHaveCount(0); + await expect( + page.getByRole("heading", { + name: "Your unique identity key has been created", + }), + ).toHaveCount(0); +}); + +test("recovery turns relay failures into actionable copy", async ({ page }) => { + await installMockBridge( + page, + { identityLost: true }, + { 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 () => { + 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 page.getByTestId("nostr-import-phone-link").click(); + 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, +}) => { + await installMockBridge( + page, + { identityLost: true }, + { skipOnboardingSeed: true }, + ); + await page.goto("/"); + await expect( + page.getByRole("heading", { name: "Enter your private key" }), ).toBeVisible(); const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey)); @@ -97,9 +374,8 @@ test("start-new-identity from lost mode persists the ephemeral key after confirm { skipOnboardingSeed: true }, ); await page.goto("/"); - 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()); @@ -131,9 +407,8 @@ test("cancelling start-new-identity in lost mode stays on the import screen", as { skipOnboardingSeed: true }, ); await page.goto("/"); - 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()); @@ -141,7 +416,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); }); @@ -159,7 +434,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/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 57e812091c..0f2e8ce617 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -620,6 +620,99 @@ test("completed users skip the loading gate while profile is still settling", as await expectHomeView(page); }); +test("fresh existing-identity path leads with private-key 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: "Enter your private key" }), + ).toBeVisible(); + await expect( + 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-file-button").click(); + const backupDialog = page.getByTestId("backup-recovery-dialog"); + await expect(backupDialog).toBeVisible(); + await expect( + backupDialog.getByRole("heading", { name: "Restore from a backup file" }), + ).toBeVisible(); + await expect( + backupDialog.getByTestId("nostr-import-backup-picker"), + ).toBeVisible(); + const unlockPreview = backupDialog.getByTestId("backup-file-unlock-preview"); + await expect(unlockPreview).toBeVisible(); + await expect(unlockPreview.locator("span")).toHaveCount(17); + await expect( + unlockPreview.getByTestId("backup-file-key-dots").locator("span"), + ).toHaveCount(9); + await expect( + unlockPreview.getByTestId("backup-file-unlock-preview-icon"), + ).toBeVisible(); + await expect( + backupDialog.getByTestId("nostr-import-backup-drop"), + ).toHaveCount(0); + await backupDialog + .getByTestId("nostr-import-backup-picker") + .evaluate((element) => { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add( + new File(["backup"], "identity.ncryptsec", { type: "text/plain" }), + ); + element.dispatchEvent( + new DragEvent("dragenter", { + bubbles: true, + cancelable: true, + dataTransfer, + }), + ); + }); + const backupDrop = backupDialog.getByTestId("nostr-import-backup-drop"); + await expect(backupDrop).toHaveAttribute("data-dragging", "true"); + await expect(backupDrop).toContainText("Drop your backup file here"); + const [backupDropBox, backupFileSectionBox] = await Promise.all([ + backupDrop.boundingBox(), + unlockPreview.boundingBox(), + ]); + expect(backupDropBox?.width).toBeGreaterThan( + backupFileSectionBox?.width ?? 0, + ); + await expect( + backupDialog.getByTestId("nostr-import-backup-picker"), + ).toBeVisible(); + await backupDrop.evaluate((element) => { + element.dispatchEvent( + new DragEvent("dragleave", { bubbles: true, cancelable: true }), + ); + }); + await expect(backupDrop).toHaveCount(0); + await expect(page.getByTestId("nostr-import-card")).toBeVisible(); + await backupDialog.getByRole("button", { name: "Close" }).click(); + + await page.getByTestId("nostr-import-phone-link").click(); + const phoneDialog = page.getByTestId("phone-recovery-dialog"); + await expect(phoneDialog).toBeVisible(); + await expect( + phoneDialog.getByRole("heading", { name: "Use your Buzz identity" }), + ).toBeVisible(); + await expect(phoneDialog.getByTestId("identity-recovery-qr")).toBeVisible(); + await expect(page.getByTestId("nostr-import-card")).toBeVisible(); +}); + test("first-launch key import continues to machine setup", async ({ page }) => { await installMockBridge(page, undefined, { skipCommunitySeed: true, @@ -707,8 +800,10 @@ test("first-launch import accepts an .ncryptsec backup file", async ({ // exactly the identity.ncryptsec our own save dialog produced. The accept // attribute is asserted explicitly because setInputFiles bypasses it — the // OS picker is what filters on it in real use. - await expect(page.getByTestId("nostr-import-file-button")).toBeVisible(); - const fileInput = page.getByTestId("nostr-import-file-input"); + await page.getByTestId("nostr-import-file-button").click(); + const fileInput = page + .getByTestId("backup-recovery-dialog") + .getByTestId("nostr-import-file-input"); await expect(fileInput).toHaveAttribute( "accept", ".key,.ncryptsec,text/plain", @@ -719,44 +814,87 @@ test("first-launch import accepts an .ncryptsec backup file", async ({ mimeType: "text/plain", name: "not-a-backup.txt", }); - await expect(page.getByTestId("nostr-import-feedback")).toContainText( - /too large to be a key backup/i, - ); + await expect( + page + .getByTestId("backup-recovery-dialog") + .getByTestId("nostr-import-feedback"), + ).toContainText(/too large to be a key backup/i); // Spec-vector blob the mock bridge accepts with the mock passphrase. const mockNcryptsec = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; - await fileInput.setInputFiles({ - buffer: Buffer.from(`${mockNcryptsec}\n`), - mimeType: "text/plain", - name: "identity.ncryptsec", + // File contents advance to the password stage inside the same dialog. + const backupDialog = page.getByTestId("backup-recovery-dialog"); + const backupFileSection = backupDialog.getByTestId( + "nostr-import-backup-file-section", + ); + const backupFileSectionHeight = await backupFileSection.evaluate((element) => + Number.parseFloat(getComputedStyle(element).height), + ); + expect(backupFileSectionHeight).toBe(312); + const backupPicker = backupDialog.getByTestId("nostr-import-backup-picker"); + await backupPicker.evaluate((element) => { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add( + new File(["backup"], "identity.ncryptsec", { type: "text/plain" }), + ); + element.dispatchEvent( + new DragEvent("dragenter", { + bubbles: true, + cancelable: true, + dataTransfer, + }), + ); }); + const backupDrop = backupDialog.getByTestId("nostr-import-backup-drop"); + await expect(backupDrop).toBeVisible(); + await backupDrop.evaluate((element, contents) => { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add( + new File([contents], "identity.ncryptsec", { type: "text/plain" }), + ); + element.dispatchEvent( + new DragEvent("drop", { + bubbles: true, + cancelable: true, + dataTransfer, + }), + ); + }, `${mockNcryptsec}\n`); - // File contents advance to the same focused password stage as manual input. await expect( - page.getByRole("heading", { name: "Unlock your account" }), + backupDialog.getByTestId("backup-password-timeline"), ).toBeVisible(); - await expect(page.getByTestId("backup-password-timeline")).toBeVisible(); - await expect(page.getByTestId("nostr-import-passphrase")).toBeFocused(); + const passphraseSection = backupDialog.getByTestId( + "nostr-import-passphrase-section", + ); + await expect(passphraseSection).toBeVisible(); + const passphraseSectionHeight = await passphraseSection.evaluate((element) => + Number.parseFloat(getComputedStyle(element).height), + ); + expect(passphraseSectionHeight).toBe(backupFileSectionHeight); + await expect( + backupDialog.getByTestId("nostr-import-passphrase"), + ).toBeFocused(); - // Back first returns to key/file selection instead of leaving import. - await page.getByRole("button", { name: "Back", exact: true }).click(); + // Back first returns to backup-file selection instead of closing the dialog. + await backupDialog.getByRole("button", { name: "Back", exact: true }).click(); await expect( - page.getByRole("heading", { name: "Enter your private key" }), + backupDialog.getByRole("heading", { name: "Restore from a backup file" }), + ).toBeVisible(); + await expect( + backupDialog.getByTestId("nostr-import-backup-picker"), ).toBeVisible(); - await expect(page.getByTestId("nostr-import-card")).toBeVisible(); - await expect(page.getByTestId("nostr-import-file-button")).toBeVisible(); - await expect(page.getByTestId("nostr-import-nsec-input")).toHaveValue(""); await fileInput.setInputFiles({ buffer: Buffer.from(`${mockNcryptsec}\n`), mimeType: "text/plain", name: "identity.ncryptsec", }); - await page + await backupDialog .getByTestId("nostr-import-passphrase") .fill("mock horse battery staple lake orbit"); - await page.getByTestId("nostr-import-submit").click(); + await backupDialog.getByTestId("nostr-import-submit").click(); await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible(); diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 057594dfad..d5ae326afa 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -147,8 +147,11 @@ class App extends HookConsumerWidget { } } -Widget _buildSettingsPage(BuildContext context) => - const SettingsPage(profileHeader: SettingsProfileHeader()); +Widget _buildSettingsPage(BuildContext context) => SettingsPage( + profileHeader: const SettingsProfileHeader(), + identityRecoveryPageBuilder: (_) => + const PairingPage(addingCommunity: true, identityRecoveryOnly: true), +); class _SplashScreen extends StatelessWidget { const _SplashScreen(); diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index 85b781052d..7061180b12 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,7 @@ 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(), @@ -146,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)); } }, ), @@ -182,12 +195,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 +257,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..5adde987eb 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,8 @@ 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 +247,7 @@ class PairingNotifier extends Notifier { state = PairingState( status: PairingStatus.confirmingSas, sasCode: formatSas(sasCode), + sendsIdentityToDesktop: _sendIdentityToSource, ); // 9. Start 120s session timeout. @@ -359,6 +373,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 +417,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 +482,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..066dd1fd3d 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -24,9 +24,14 @@ part 'settings_page/appearance_section.dart'; part 'settings_page/connection_section.dart'; class SettingsPage extends HookConsumerWidget { - const SettingsPage({super.key, required this.profileHeader}); + const SettingsPage({ + super.key, + required this.profileHeader, + required this.identityRecoveryPageBuilder, + }); final Widget profileHeader; + final WidgetBuilder identityRecoveryPageBuilder; @override Widget build(BuildContext context, WidgetRef ref) { @@ -67,7 +72,9 @@ class SettingsPage extends HookConsumerWidget { children: [ profileHeader, const _AppearanceSection(), - const _ConnectionSection(), + _ConnectionSection( + identityRecoveryPageBuilder: identityRecoveryPageBuilder, + ), const _RemoveCommunitySection(), ], ), diff --git a/mobile/lib/features/settings/settings_page/connection_section.dart b/mobile/lib/features/settings/settings_page/connection_section.dart index 19da784a63..631f870abc 100644 --- a/mobile/lib/features/settings/settings_page/connection_section.dart +++ b/mobile/lib/features/settings/settings_page/connection_section.dart @@ -1,7 +1,9 @@ part of '../settings_page.dart'; class _ConnectionSection extends ConsumerWidget { - const _ConnectionSection(); + const _ConnectionSection({required this.identityRecoveryPageBuilder}); + + final WidgetBuilder identityRecoveryPageBuilder; @override Widget build(BuildContext context, WidgetRef ref) { @@ -16,7 +18,18 @@ 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: identityRecoveryPageBuilder), + ), + ), + ], ], ); } 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()]); + } +} diff --git a/mobile/test/features/settings/theme_picker_page_test.dart b/mobile/test/features/settings/theme_picker_page_test.dart index 010db98ba3..6b166c8efa 100644 --- a/mobile/test/features/settings/theme_picker_page_test.dart +++ b/mobile/test/features/settings/theme_picker_page_test.dart @@ -172,7 +172,10 @@ void main() { testWidgets('settings hides accent navigation for Buzz', (tester) async { await _pumpPicker( tester, - const SettingsPage(profileHeader: SizedBox.shrink()), + SettingsPage( + profileHeader: const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), prefs: {'buzz_color_scheme': 'buzz', 'buzz_accent_color': 4}, ); @@ -184,7 +187,10 @@ void main() { ) async { await _pumpPicker( tester, - const SettingsPage(profileHeader: SizedBox.shrink()), + SettingsPage( + profileHeader: const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), prefs: { 'buzz_theme_mode': 'light', 'buzz_color_scheme': 'github-light',