From fc67dac4c675d4625304f9c054b655aed10bc45f Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 16 Apr 2026 16:25:18 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20NIP-AB=20device=20pairing=20=E2=80=94?= =?UTF-8?q?=20Phase=202=20(desktop=20+=20mobile=20UI)=20(#343)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.6 (1M context) --- desktop/scripts/check-file-sizes.mjs | 3 + desktop/src-tauri/Cargo.lock | 15 + desktop/src-tauri/Cargo.toml | 4 +- desktop/src-tauri/src/commands/mod.rs | 4 +- desktop/src-tauri/src/commands/pairing.rs | 524 ++++++++++++++++++ desktop/src-tauri/src/commands/tokens.rs | 42 +- desktop/src-tauri/src/lib.rs | 4 + .../settings/ui/MobilePairingCard.tsx | 285 +++++++--- desktop/src/shared/api/tauri.ts | 14 + .../channels/send_message_provider.dart | 57 +- .../lib/features/pairing/pairing_crypto.dart | 164 ++++++ mobile/lib/features/pairing/pairing_page.dart | 346 ++++++++---- .../features/pairing/pairing_provider.dart | 485 +++++++++++++++- .../lib/features/pairing/pairing_socket.dart | 203 +++++++ .../lib/features/settings/settings_page.dart | 48 +- mobile/lib/shared/crypto/ecdh.dart | 65 +++ mobile/lib/shared/crypto/hkdf.dart | 44 ++ mobile/lib/shared/crypto/nip44.dart | 176 ++++++ mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 1 + .../features/pairing/pairing_page_test.dart | 12 + 21 files changed, 2248 insertions(+), 250 deletions(-) create mode 100644 desktop/src-tauri/src/commands/pairing.rs create mode 100644 mobile/lib/features/pairing/pairing_crypto.dart create mode 100644 mobile/lib/features/pairing/pairing_socket.dart create mode 100644 mobile/lib/shared/crypto/ecdh.dart create mode 100644 mobile/lib/shared/crypto/hkdf.dart create mode 100644 mobile/lib/shared/crypto/nip44.dart diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index ed87bce02..6fe53e0b3 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -65,6 +65,9 @@ const overrides = new Map([ ["src-tauri/src/huddle/preprocessing.rs", 670], // TTS text preprocessing pipeline + unified split_sentences + int_to_words 0-999999 + URL trailing punctuation preservation + 23 unit tests ["src-tauri/src/huddle/relay_api.rs", 510], // audio relay recv task + per-peer frame counting for remote human TTS interrupt ["src-tauri/src/huddle/tts.rs", 1030], // TTS pipeline + session warmup + cancel/shutdown handling + apply_fades + 18 unit tests for remote interrupt mechanism + ["src-tauri/src/commands/pairing.rs", 550], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + event parsing helpers + ["src-tauri/src/lib.rs", 715], // +4 lines for PairingHandle managed state + 3 pairing command registrations + ["src/shared/api/tauri.ts", 1110], // +14 lines for 3 NIP-AB pairing command wrappers ]); async function walkFiles(directory) { diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 6eef45b7a..f5478c3d2 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -5210,6 +5210,7 @@ dependencies = [ "infer", "libc", "ndarray", + "nostr 0.36.0", "nostr 0.37.0", "opus", "ort", @@ -5242,6 +5243,7 @@ dependencies = [ "url", "uuid", "windows-sys 0.61.2", + "zeroize", "zip 2.4.2", ] @@ -5258,6 +5260,7 @@ dependencies = [ "serde_json", "subtle", "thiserror 2.0.18", + "url", "uuid", "zeroize", ] @@ -6205,9 +6208,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tokio-rustls" version = "0.26.4" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 117dfeb22..8f19a87b0 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -35,7 +35,7 @@ tauri-plugin-updater = "2" tauri-plugin-process = "2" infer = "0.19" hex = "0.4" -tokio = { version = "1", features = ["fs", "sync", "rt"] } +tokio = { version = "1", features = ["fs", "sync", "rt", "macros", "time"] } tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] } tokio-util = { version = "0.7", features = ["rt"] } futures-util = "0.3" @@ -43,6 +43,8 @@ opus = "0.3" serde = { version = "1", features = ["derive"] } serde_json = "1" nostr = "0.37" +nostr-compat = { package = "nostr", version = "0.36" } +zeroize = "1" reqwest = { version = "0.13", features = ["json", "query"] } url = "2" sprout-core = { path = "../../crates/sprout-core" } diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 63c462647..fa1414708 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -9,11 +9,12 @@ mod export_util; mod identity; mod media; mod messages; +pub mod pairing; mod personas; mod profile; mod social; mod teams; -mod tokens; +pub mod tokens; mod workflows; pub use agent_discovery::*; @@ -26,6 +27,7 @@ pub use dms::*; pub use identity::*; pub use media::*; pub use messages::*; +pub use pairing::*; pub use personas::*; pub use profile::*; pub use social::*; diff --git a/desktop/src-tauri/src/commands/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs new file mode 100644 index 000000000..95325dd6b --- /dev/null +++ b/desktop/src-tauri/src/commands/pairing.rs @@ -0,0 +1,524 @@ +//! NIP-AB device pairing — Tauri commands for the source (desktop) side. +//! +//! Architecture: a background tokio task owns the WebSocket connection. +//! Tauri commands communicate with it via an mpsc channel (JSON strings). +//! State changes are pushed to the React frontend via Tauri events. +//! +//! sprout-core depends on nostr 0.36 while the desktop uses nostr 0.37. +//! We import `nostr_compat` (aliased to nostr 0.36) for types that cross +//! the sprout-core boundary, and serialize events to JSON strings for the +//! mpsc channel to avoid mixing the two versions. + +use std::sync::Arc; +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use nostr::ToBech32; +use serde::Serialize; +use sprout_core::kind::KIND_PAIRING; +use sprout_core::pairing::qr::encode_qr; +use sprout_core::pairing::session::PairingSession; +use sprout_core::pairing::types::{AbortReason, PayloadType}; +use tauri::{AppHandle, Emitter, State}; +use tokio::sync::mpsc; +use tokio_tungstenite::{connect_async, tungstenite::Message}; +use tokio_util::sync::CancellationToken; +use zeroize::Zeroizing; + +use crate::app_state::AppState; +use crate::relay::{relay_api_base_url, relay_ws_url}; + +use super::tokens::mint_token_internal; + +// ── Tauri event payloads ──────────────────────────────────────────────────── + +#[derive(Serialize, Clone)] +struct PairingSasPayload { + sas: String, +} + +#[derive(Serialize, Clone)] +struct PairingAbortedPayload { + reason: String, +} + +#[derive(Serialize, Clone)] +struct PairingErrorPayload { + message: String, +} + +// ── Managed state ─────────────────────────────────────────────────────────── + +/// Managed Tauri state for an active pairing session. +pub struct PairingHandle { + session: Arc>>, + 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>>, +} + +impl PairingHandle { + pub fn new() -> Self { + Self { + session: Arc::new(tokio::sync::Mutex::new(None)), + cancel: std::sync::Mutex::new(None), + outbound_tx: std::sync::Mutex::new(None), + payload: std::sync::Mutex::new(None), + } + } + + fn clear(&self) { + *self.cancel.lock().unwrap_or_else(|e| e.into_inner()) = None; + *self.outbound_tx.lock().unwrap_or_else(|e| e.into_inner()) = None; + *self.payload.lock().unwrap_or_else(|e| e.into_inner()) = None; + // Note: session is in an Arc and is cleared + // by the background task on exit. For immediate cleanup, callers + // should also lock and clear the session explicitly. + } +} + +// ── Mobile scopes ─────────────────────────────────────────────────────────── + +const MOBILE_SCOPES: &[&str] = &[ + "messages:read", + "messages:write", + "channels:read", + "channels:write", + "users:read", + "files:read", +]; +const EXPIRES_IN_DAYS: u32 = 90; + +// ── Commands ──────────────────────────────────────────────────────────────── + +/// Start a NIP-AB pairing session as the source device. +/// +/// Mints a token, creates a `PairingSession`, connects to the relay, +/// and returns the `nostrpair://` QR URI for the frontend to display. +#[tauri::command] +pub async fn start_pairing( + app: AppHandle, + state: State<'_, AppState>, + pairing: State<'_, PairingHandle>, +) -> Result { + // Cancel any existing session. + if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() { + token.cancel(); + } + pairing.clear(); + + // 1. Mint a mobile token. + let scopes: Vec = MOBILE_SCOPES.iter().map(|s| s.to_string()).collect(); + let token_name = format!("mobile-pairing-{}", chrono::Utc::now().timestamp()); + let mint_result = + mint_token_internal(&state, &token_name, &scopes, None, Some(EXPIRES_IN_DAYS)).await?; + + // 2. Read user identity. + let (nsec, pubkey_hex) = { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + let nsec = keys + .secret_key() + .to_bech32() + .map_err(|e| format!("encode nsec: {e}"))?; + let pubkey = keys.public_key().to_hex(); + (nsec, pubkey) + }; + + // 3. Get relay URLs. + let ws_url = relay_ws_url(); + let http_url = relay_api_base_url(); + + // 4. Create the pairing session. + let (session, qr_payload) = PairingSession::new_source(ws_url.clone()); + let qr_uri = encode_qr(&qr_payload); + + // 5. Build the custom payload to send after SAS confirmation. + let payload_json = serde_json::json!({ + "relayUrl": http_url, + "token": mint_result.token, + "pubkey": pubkey_hex, + "nsec": nsec, + }); + + // 6. Store session + payload. + { + let mut s = pairing.session.lock().await; + *s = Some(session); + } + *pairing.payload.lock().map_err(|e| e.to_string())? = + Some(Zeroizing::new(payload_json.to_string())); + + // 7. Create channel + cancellation token. + 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()); + + // 8. Spawn background WS task. + let session_arc = Arc::clone(&pairing.session); + tauri::async_runtime::spawn(pairing_ws_task( + ws_url, + session_arc, + cancel, + outbound_rx, + app, + )); + + Ok(qr_uri) +} + +/// User confirmed the SAS codes match. Sends sas-confirm + payload. +#[tauri::command] +pub async fn confirm_pairing_sas(pairing: State<'_, PairingHandle>) -> Result<(), String> { + let tx = pairing + .outbound_tx + .lock() + .map_err(|e| e.to_string())? + .clone() + .ok_or("no active pairing session")?; + + // 1. Confirm SAS → get sas-confirm event, serialize to JSON. + let sas_confirm_json = { + let mut guard = pairing.session.lock().await; + let session = guard.as_mut().ok_or("no active pairing session")?; + let event = session.confirm_sas().map_err(|e| e.to_string())?; + event_to_relay_json(&event) + }; + + tx.send(sas_confirm_json) + .await + .map_err(|_| "failed to send sas-confirm")?; + + // 2. Send payload (contains nsec — Zeroizing ensures cleanup). + 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(()) +} + +/// Cancel the active pairing session. +#[tauri::command] +pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), String> { + // Try to send an abort event if we have a session and a channel. + let abort_json = { + let mut guard = pairing.session.lock().await; + if let Some(session) = guard.as_mut() { + session + .abort(AbortReason::UserDenied) + .ok() + .flatten() + .map(|e| event_to_relay_json(&e)) + } else { + None + } + }; + + if let Some(json) = abort_json { + let tx = pairing + .outbound_tx + .lock() + .map_err(|e| e.to_string())? + .clone(); + if let Some(tx) = tx { + let _ = tx.send(json).await; + } + } + + // Cancel background task. + if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() { + token.cancel(); + } + pairing.clear(); + + // Clear session. + { + let mut s = pairing.session.lock().await; + *s = None; + } + + Ok(()) +} + +// ── Background WebSocket task ─────────────────────────────────────────────── + +async fn pairing_ws_task( + relay_url: String, + session: Arc>>, + cancel: CancellationToken, + mut outbound_rx: mpsc::Receiver, + app: AppHandle, +) { + if let Err(e) = + pairing_ws_task_inner(&relay_url, &session, &cancel, &mut outbound_rx, &app).await + { + let _ = app.emit("pairing-error", PairingErrorPayload { message: e }); + } + // Clean up session on exit. + let mut s = session.lock().await; + *s = None; +} + +async fn pairing_ws_task_inner( + relay_url: &str, + session: &Arc>>, + cancel: &CancellationToken, + outbound_rx: &mut mpsc::Receiver, + app: &AppHandle, +) -> Result<(), String> { + // Connect to relay. + let (ws, _) = connect_async(relay_url) + .await + .map_err(|e| format!("WebSocket connection failed: {e}"))?; + let (mut write, mut read) = ws.split(); + + // Handle NIP-42 auth if required. + handle_nip42_auth(&mut read, &mut write, session, relay_url).await?; + + // Subscribe for kind:24134 events tagged to our ephemeral pubkey. + let our_pk = { + let guard = session.lock().await; + guard.as_ref().ok_or("session gone")?.pubkey().to_hex() + }; + let sub_msg = serde_json::json!([ + "REQ", "pair", + { "kinds": [KIND_PAIRING], "#p": [our_pk] } + ]); + write + .send(Message::Text(sub_msg.to_string().into())) + .await + .map_err(|e| format!("subscribe failed: {e}"))?; + + // Wait for EOSE to confirm subscription is registered. + wait_for_eose(&mut read, "pair", Duration::from_secs(10)).await?; + + // Event loop. + let hard_timeout = tokio::time::sleep(Duration::from_secs(130)); + tokio::pin!(hard_timeout); + + loop { + tokio::select! { + _ = cancel.cancelled() => break, + _ = &mut hard_timeout => { + let _ = app.emit("pairing-error", PairingErrorPayload { + message: "Session timed out".into(), + }); + break; + } + Some(json_msg) = outbound_rx.recv() => { + // json_msg is already a ["EVENT", ...] JSON string. + if let Err(e) = write.send(Message::Text(json_msg.into())).await { + return Err(format!("publish failed: {e}")); + } + } + msg = read.next() => { + let Some(msg) = msg else { + return Err("relay connection closed".into()); + }; + let msg = msg.map_err(|e| format!("WS read error: {e}"))?; + let Message::Text(text) = msg else { continue }; + + // Parse relay EVENT message into sprout-core's nostr::Event. + if let Some(event) = parse_relay_event(text.as_str(), "pair") { + let mut guard = session.lock().await; + let Some(s) = guard.as_mut() else { break }; + + // Check for abort from peer. + match s.handle_abort(&event) { + Ok(reason) => { + let _ = app.emit("pairing-aborted", PairingAbortedPayload { + reason: format!("{reason:?}"), + }); + break; + } + Err(_) => {} // Not an abort — continue. + } + + // Try handle_offer (source waits for this first). + if let Ok(sas) = s.handle_offer(&event) { + let _ = app.emit("pairing-sas-received", PairingSasPayload { sas }); + continue; + } + + // Try handle_complete (source waits for this after sending payload). + match s.handle_complete(&event) { + Ok(()) => { + let _ = app.emit("pairing-complete", serde_json::json!({})); + break; + } + Err(ref e) if format!("{e}").contains("success=false") => { + let _ = app.emit("pairing-error", PairingErrorPayload { + message: "Mobile device reported failure importing credentials".into(), + }); + break; + } + Err(_) => {} // Wrong state or wrong message — discard per NIP-AB. + } + } + } + } + } + + Ok(()) +} + +// ── NIP-42 auth helper ────────────────────────────────────────────────────── + +async fn handle_nip42_auth( + read: &mut R, + write: &mut W, + session: &Arc>>, + relay_url: &str, +) -> Result<(), String> +where + R: StreamExt> + Unpin, + W: SinkExt + Unpin, +{ + // Wait up to 3 seconds for an AUTH challenge. Timeout is normal. + let auth_result = tokio::time::timeout(Duration::from_secs(3), async { + loop { + let msg = read + .next() + .await + .ok_or_else(|| "relay closed during auth".to_string())? + .map_err(|e| format!("WS error during auth: {e}"))?; + if let Message::Text(text) = msg { + if let Some(challenge) = parse_auth_challenge(text.as_str()) { + return Ok(challenge); + } + } + } + }) + .await; + + let challenge: String = match auth_result { + Ok(Ok(c)) => c, + Ok(Err(e)) => return Err(e), + Err(_) => return Ok(()), // No AUTH challenge. + }; + + // Sign auth event with the session's ephemeral keys. + // Use sprout-core's nostr EventBuilder (0.36) via PairingSession::sign_event. + let relay_url_parsed: url::Url = relay_url + .parse() + .map_err(|e| format!("invalid relay URL: {e}"))?; + let auth_json = { + let guard = session.lock().await; + let s = guard.as_ref().ok_or("session gone during auth")?; + let auth_event = s + .sign_event(nostr_compat::EventBuilder::auth( + challenge, + relay_url_parsed, + )) + .map_err(|e| format!("sign auth event: {e}"))?; + format!( + "[\"AUTH\",{}]", + nostr_compat::JsonUtil::as_json(&auth_event) + ) + }; + + write + .send(Message::Text(auth_json.into())) + .await + .map_err(|e| format!("send auth: {e}"))?; + + // Wait for OK response (up to 5 seconds). + let _ = tokio::time::timeout(Duration::from_secs(5), async { + loop { + let msg = read + .next() + .await + .ok_or_else(|| "relay closed during auth OK".to_string())? + .map_err(|e| format!("WS error during auth OK: {e}"))?; + if let Message::Text(text) = msg { + if text.contains("\"OK\"") || text.contains("[\"OK\"") { + return Ok::<(), String>(()); + } + } + } + }) + .await; + + Ok(()) +} + +// ── Helper functions ──────────────────────────────────────────────────────── + +/// Serialize a nostr 0.36 Event to `["EVENT", ]` JSON string. +fn event_to_relay_json(event: &nostr_compat::Event) -> String { + format!("[\"EVENT\",{}]", nostr_compat::JsonUtil::as_json(event)) +} + +/// Parse a relay EVENT message into a nostr 0.36 Event (sprout-core compatible). +fn parse_relay_event(text: &str, sub_id: &str) -> Option { + let arr: serde_json::Value = serde_json::from_str(text).ok()?; + let arr = arr.as_array()?; + if arr.len() < 3 { + return None; + } + if arr[0].as_str()? != "EVENT" { + return None; + } + if arr[1].as_str()? != sub_id { + return None; + } + serde_json::from_value(arr[2].clone()).ok() +} + +fn parse_auth_challenge(text: &str) -> Option { + let arr: serde_json::Value = serde_json::from_str(text).ok()?; + let arr = arr.as_array()?; + if arr.len() >= 2 && arr[0].as_str()? == "AUTH" { + return arr[1].as_str().map(|s| s.to_string()); + } + None +} + +async fn wait_for_eose(read: &mut S, sub_id: &str, dur: Duration) -> Result<(), String> +where + S: StreamExt> + Unpin, +{ + tokio::time::timeout(dur, async { + loop { + let msg = read + .next() + .await + .ok_or_else(|| "relay closed waiting for EOSE".to_string())? + .map_err(|e| format!("WS error waiting for EOSE: {e}"))?; + if let Message::Text(text) = msg { + if let Ok(arr) = serde_json::from_str::(text.as_str()) { + if let Some(arr) = arr.as_array() { + if arr.len() >= 2 + && arr[0].as_str() == Some("EOSE") + && arr[1].as_str() == Some(sub_id) + { + return Ok(()); + } + } + } + } + } + }) + .await + .map_err(|_| "timeout waiting for EOSE".to_string())? +} diff --git a/desktop/src-tauri/src/commands/tokens.rs b/desktop/src-tauri/src/commands/tokens.rs index 193d1d0c5..a45998e4d 100644 --- a/desktop/src-tauri/src/commands/tokens.rs +++ b/desktop/src-tauri/src/commands/tokens.rs @@ -17,28 +17,28 @@ pub async fn list_tokens(state: State<'_, AppState>) -> Result, - channel_ids: Option>, +/// Internal token minting logic, callable from other modules (e.g. pairing). +pub async fn mint_token_internal( + state: &AppState, + name: &str, + scopes: &[String], + channel_ids: Option<&[String]>, expires_in_days: Option, - state: State<'_, AppState>, ) -> Result { let body = MintTokenBody { - name: &name, - scopes: &scopes, - channel_ids: channel_ids.as_deref(), + name, + scopes, + channel_ids, expires_in_days, - owner_pubkey: None, // User-minted tokens don't set agent owner + owner_pubkey: None, }; let request = if state.configured_api_token.is_some() { - build_authed_request(&state.http_client, Method::POST, "/api/tokens", &state)?.json(&body) + build_authed_request(&state.http_client, Method::POST, "/api/tokens", state)?.json(&body) } else { let url = format!("{}{}", relay_api_base_url(), "/api/tokens"); let body_bytes = serde_json::to_vec(&body).map_err(|error| format!("serialize failed: {error}"))?; - let auth_header = build_nip98_auth_header(&Method::POST, &url, &body_bytes, &state)?; + let auth_header = build_nip98_auth_header(&Method::POST, &url, &body_bytes, state)?; state .http_client @@ -60,6 +60,24 @@ pub async fn mint_token( Ok(response) } +#[tauri::command] +pub async fn mint_token( + name: String, + scopes: Vec, + channel_ids: Option>, + expires_in_days: Option, + state: State<'_, AppState>, +) -> Result { + mint_token_internal( + &state, + &name, + &scopes, + channel_ids.as_deref(), + expires_in_days, + ) + .await +} + #[tauri::command] pub async fn revoke_token(token_id: String, state: State<'_, AppState>) -> Result<(), String> { let path = api_path(&["tokens", &token_id]); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 6c4f0ab33..34c2c3119 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -468,6 +468,7 @@ pub fn run() { }); }) .manage(build_app_state()) + .manage(commands::pairing::PairingHandle::new()) .setup(move |app| { let app_handle = app.handle().clone(); let shutdown_started = Arc::clone(&restore_shutdown_started); @@ -647,6 +648,9 @@ pub fn run() { list_audio_output_devices, set_audio_output_device, get_audio_output_device, + start_pairing, + confirm_pairing_sas, + cancel_pairing, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); diff --git a/desktop/src/features/settings/ui/MobilePairingCard.tsx b/desktop/src/features/settings/ui/MobilePairingCard.tsx index 318c3eb3f..02e19b8eb 100644 --- a/desktop/src/features/settings/ui/MobilePairingCard.tsx +++ b/desktop/src/features/settings/ui/MobilePairingCard.tsx @@ -1,10 +1,21 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { QRCodeSVG } from "qrcode.react"; -import { Check, Copy, Loader2, Smartphone, TriangleAlert } from "lucide-react"; +import { + Check, + Copy, + Loader2, + ShieldCheck, + Smartphone, + TriangleAlert, + X, +} from "lucide-react"; +import { listen } from "@tauri-apps/api/event"; -import { useMintTokenMutation } from "@/features/tokens/hooks"; -import { getNsec, getRelayHttpUrl } from "@/shared/api/tauri"; -import type { TokenScope } from "@/shared/api/types"; +import { + cancelPairing, + confirmPairingSas, + startPairing, +} from "@/shared/api/tauri"; import { Button } from "@/shared/ui/button"; import { Dialog, @@ -14,84 +25,56 @@ import { DialogTitle, } from "@/shared/ui/dialog"; -const MOBILE_SCOPES: TokenScope[] = [ - "messages:read", - "messages:write", - "channels:read", - "channels:write", - "users:read", - "files:read", -]; -const EXPIRES_IN_DAYS = 90; - -function toBase64Url(str: string): string { - return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} - -type PairingPayload = { - relayUrl: string; - token: string; - pubkey: string; - nsec: string; -}; +type PairingStep = + | "generating" + | "qr" + | "sas" + | "transferring" + | "done" + | "error"; function PairingDialog({ open, onOpenChange, - currentPubkey, }: { open: boolean; onOpenChange: (open: boolean) => void; - currentPubkey: string; }) { - const mintMutation = useMintTokenMutation(); - const mintRef = useRef(mintMutation.mutateAsync); - mintRef.current = mintMutation.mutateAsync; - - const [pairingUri, setPairingUri] = useState(null); + const [step, setStep] = useState("generating"); + const [qrUri, setQrUri] = useState(null); + const [sasCode, setSasCode] = useState(null); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); + const stepRef = useRef(step); + stepRef.current = step; - const generate = useCallback(async (pubkey: string) => { - const [tokenResult, relayUrl, nsec] = await Promise.all([ - mintRef.current({ - name: `mobile-${Date.now()}`, - scopes: [...MOBILE_SCOPES], - expiresInDays: EXPIRES_IN_DAYS, - }), - getRelayHttpUrl(), - getNsec(), - ]); - - const payload: PairingPayload = { - relayUrl, - token: tokenResult.token, - pubkey, - nsec, - }; - return `sprout://${toBase64Url(JSON.stringify(payload))}`; - }, []); - + // Start pairing when dialog opens. useEffect(() => { if (!open) return; - setPairingUri(null); + setStep("generating"); + setQrUri(null); + setSasCode(null); setError(null); setCopied(false); let cancelled = false; - generate(currentPubkey).then( + startPairing().then( (uri) => { - if (!cancelled) setPairingUri(uri); + if (!cancelled) { + setQrUri(uri); + setStep("qr"); + } }, (err) => { if (!cancelled) { setError( err instanceof Error ? err.message - : "Failed to generate pairing code", + : "Failed to start pairing session", ); + setStep("error"); } }, ); @@ -99,17 +82,98 @@ function PairingDialog({ return () => { cancelled = true; }; - }, [open, currentPubkey, generate]); + }, [open]); + + // Listen for Tauri events from the pairing backend. + useEffect(() => { + if (!open) return; + + let cancelled = false; + const unlisteners: (() => void)[] = []; + + listen<{ sas: string }>("pairing-sas-received", (event) => { + if (!cancelled) { + setSasCode(event.payload.sas); + setStep("sas"); + } + }).then((fn) => { + if (cancelled) fn(); + else unlisteners.push(fn); + }); + + listen("pairing-complete", () => { + if (!cancelled) { + setStep("done"); + } + }).then((fn) => { + if (cancelled) fn(); + else unlisteners.push(fn); + }); + + listen<{ reason: string }>("pairing-aborted", (event) => { + if (!cancelled) { + setError(`Pairing aborted: ${event.payload.reason}`); + setStep("error"); + } + }).then((fn) => { + if (cancelled) fn(); + else unlisteners.push(fn); + }); + + listen<{ message: string }>("pairing-error", (event) => { + if (!cancelled) { + setError(event.payload.message); + setStep("error"); + } + }).then((fn) => { + if (cancelled) fn(); + else unlisteners.push(fn); + }); + + return () => { + cancelled = true; + for (const fn of unlisteners) fn(); + }; + }, [open]); + + // Cancel pairing when dialog closes before completion. + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen && stepRef.current !== "done") { + cancelPairing().catch(() => {}); + } + onOpenChange(nextOpen); + }, + [onOpenChange], + ); + + async function handleConfirmSas() { + setStep("transferring"); + try { + await confirmPairingSas(); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to send credentials", + ); + setStep("error"); + } + } + + function handleDenySas() { + cancelPairing().catch(() => {}); + setError("SAS code mismatch — pairing cancelled for security."); + setStep("error"); + } async function handleCopy() { - if (!pairingUri) return; - await navigator.clipboard.writeText(pairingUri); + if (!qrUri) return; + await navigator.clipboard.writeText(qrUri); setCopied(true); setTimeout(() => setCopied(false), 2000); } return ( - + Pair Mobile Device - Scan this QR code with the Sprout mobile app, or copy the pairing - code for manual setup. + {step === "sas" + ? "Verify the security code matches your mobile device." + : step === "done" + ? "Your mobile device is now paired." + : "Scan this QR code with the Sprout mobile app to securely pair."}
- {error ? ( + {step === "error" && error ? (
{error}
- ) : !pairingUri ? ( + ) : step === "generating" ? (

- Generating pairing code… + Preparing secure pairing session...

- ) : ( + ) : step === "qr" && qrUri ? (
@@ -153,7 +220,7 @@ function PairingDialog({

- {pairingUri} + {qrUri}
-

- This token expires in {EXPIRES_IN_DAYS} days. You can revoke - it from the Tokens settings at any time. +

+ Waiting for mobile device to scan...

- )} + ) : step === "sas" && sasCode ? ( +
+
+ +

+ Verify this code matches your mobile device +

+
+

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

+
+

+ You are about to transfer your Sprout identity to another + device. Only confirm if you initiated this pairing. +

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

+ Sending identity to mobile device... +

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

+ Mobile device paired successfully +

+

+ Your mobile app is now connected to this relay. +

+
+ ) : null}
@@ -206,8 +332,9 @@ export function MobilePairingCard({

Mobile

- Connect the Sprout mobile app to this relay by scanning a QR code or - pasting a pairing code. + Connect the Sprout mobile app to this relay by scanning a QR code. The + connection is secured with end-to-end encryption and a verification + code.

@@ -216,7 +343,7 @@ export function MobilePairingCard({

Pair Mobile Device

- Generate a one-time pairing code for the mobile app + Securely transfer your identity via NIP-AB protocol