//! The Soulseek link proof, product side (F4 §7.4 → the app). //! //! What a Soulseek server vouches for is exactly one thing: WHO sent a //! private message. So a SoulStream client proves "this Soulseek account //! is this SoulStream ID" by answering a question over a PM: the asker //! sends a nonce, the answerer's persona signs a claim binding its key, its //! username and that nonce, and sends it back. The asker checks the //! signature, the nonce, and that the server-attributed sender is the //! account the claim names. Names never decide anything. //! //! Two uses of the same handshake: //! - LINK (You → Soulseek card): ask yourself. A self-PM is server-routed //! and speaks to no one; the answer proves the wire both ways and the //! result is stored locally — method and time, as the design asks. //! Linking is also the consent act: from then on this client answers //! other SoulStream users who ask. Nothing is ever published. //! - CHECK (a person's drawer): ask them. A verified answer is remembered //! locally as username → SoulStream ID and unlocks their signed profile. //! //! Wire: `SSLINK1? · SoulStream identity check — safe to ignore` //! asks; `SSLINK1!` answers. Both are consumed by //! the event pump before the Inbox ever sees them. A client that is not //! SoulStream sees one plain line it can ignore. use std::collections::{BTreeMap, HashMap}; use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use persona_state::slsk_link as sl; use serde::{Deserialize, Serialize}; use tauri::Manager; use tokio::sync::oneshot; use crate::persona; use crate::state::AppState; /// The one Soulseek server SoulStream speaks to; a claim is scoped to it. pub const SCOPE: &str = "server.slsknet.org:2242"; const REQ_PREFIX: &str = "SSLINK1?"; const RESP_PREFIX: &str = "SSLINK1!"; const REQ_SUFFIX: &str = " · SoulStream identity check — safe to ignore"; /// How long an asker waits for an answer. const ASK_TIMEOUT: Duration = Duration::from_secs(30); /// How long an answer stays valid (clock skew and slow delivery included). const CLAIM_TTL_SECS: u64 = 300; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct KnownLink { /// As the server spelled the sender. pub username: String, pub username_normalized: String, pub persona_key: String, pub verified_at: u64, pub method: String, } #[derive(Default, Serialize, Deserialize)] struct Links { /// Our own account ↔ our own persona, once verified through ourselves. #[serde(default)] own: Option, /// Everyone we asked and who answered correctly, by normalized username. #[serde(default)] others: BTreeMap, } #[derive(Debug, Serialize)] pub struct LinkStatus { pub own: Option, pub others: Vec, /// Whether this client answers other people's questions right now. pub answering: bool, } fn links_path(app: &tauri::AppHandle) -> Result { Ok(crate::paths::app_data(app) .map_err(|e| e.to_string())? .join("freenet") .join("soulseek-links.json")) } fn load(app: &tauri::AppHandle) -> Links { links_path(app) .ok() .and_then(|p| std::fs::read(p).ok()) .and_then(|b| serde_json::from_slice(&b).ok()) .unwrap_or_default() } fn save(app: &tauri::AppHandle, links: &Links) -> Result<(), String> { let p = links_path(app)?; if let Some(dir) = p.parent() { std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; } std::fs::write(&p, serde_json::to_vec_pretty(links).map_err(|e| e.to_string())?) .map_err(|e| e.to_string()) } fn status_of(links: Links) -> LinkStatus { LinkStatus { answering: links.own.is_some(), others: links.others.into_values().collect(), own: links.own, } } struct Pending { username_normalized: String, expires_at: u64, reply: oneshot::Sender>, } static PENDING: OnceLock>> = OnceLock::new(); fn pending() -> &'static Mutex> { PENDING.get_or_init(Default::default) } fn now_secs() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) } fn nonce16() -> [u8; 16] { use rand::RngCore; let mut n = [0u8; 16]; rand::thread_rng().fill_bytes(&mut n); n } fn decode_key(b58: &str) -> Result<[u8; 32], String> { bs58::decode(b58) .into_vec() .map_err(|e| format!("bad persona key: {e}"))? .as_slice() .try_into() .map_err(|_| "persona key is not 32 bytes".to_string()) } async fn own_username(app: &tauri::AppHandle) -> Option { app.state::().connected_as.lock().await.clone() } async fn send_pm(app: &tauri::AppHandle, to: &str, text: &str) -> Result<(), String> { let st = app.state::(); let guard = st.client.lock().await; guard .as_ref() .ok_or("not connected to Soulseek")? .message_user(to, text) .map_err(|e| e.to_string()) } /// Ask `username` over Soulseek for their signed SoulStream ID and verify /// the answer. One PM out, one in, thirty seconds at most. pub(crate) async fn ask(app: &tauri::AppHandle, username: &str) -> Result { let target = sl::normalize_username(username); if target.is_empty() { return Err("no username".into()); } own_username(app).await.ok_or("not connected to Soulseek")?; let nonce = nonce16(); let (tx, rx) = oneshot::channel(); pending().lock().unwrap().insert( nonce, Pending { username_normalized: target, expires_at: now_secs() + ASK_TIMEOUT.as_secs(), reply: tx, }, ); let text = format!("{REQ_PREFIX}{}{REQ_SUFFIX}", bs58::encode(nonce).into_string()); if let Err(e) = send_pm(app, username, &text).await { pending().lock().unwrap().remove(&nonce); return Err(e); } match tokio::time::timeout(ASK_TIMEOUT, rx).await { Ok(Ok(result)) => result, _ => { pending().lock().unwrap().remove(&nonce); Err("no answer — not a SoulStream user, or they have not linked their account".into()) } } } /// Our half: answer ONLY when asked, and only with consent — our own link /// exists (linking is the consent act) — or when the asker is ourselves /// (the self-link's bootstrap). async fn answer(app: &tauri::AppHandle, from: &str, nonce: [u8; 16]) { let Some(me) = own_username(app).await else { return }; let me_norm = sl::normalize_username(&me); let from_norm = sl::normalize_username(from); if from_norm != me_norm && load(app).own.is_none() { log::debug!("soulseek link: {from} asked, but this account is not linked — not answering"); return; } let adapter = app.state::().freenet.clone(); let key = match persona::current(&adapter).await { Ok(Some(info)) => match decode_key(&info.public_key) { Ok(k) => k, Err(e) => { log::warn!("soulseek link: {e}"); return; } }, _ => { log::debug!("soulseek link: {from} asked, but there is no persona to answer with"); return; } }; let now = now_secs(); let mut claim = sl::SlskLinkClaim { claim_type: sl::SLSK_LINK_DOMAIN.into(), soulstream_public_key: key, soulseek_server_scope: SCOPE.into(), soulseek_username_normalized: me_norm, nonce, issued_at: now, expires_at: now + CLAIM_TTL_SECS, sig: vec![], }; claim.sig = match persona::sign(&adapter, sl::SLSK_LINK_DOMAIN, sl::claim_sign_payload(&claim)).await { Ok(sig) => sig, Err(e) => { log::warn!("soulseek link: could not sign the claim: {e}"); return; } }; let Ok(bytes) = bincode::serialize(&claim) else { return }; use base64::Engine; let text = format!("{RESP_PREFIX}{}", base64::engine::general_purpose::STANDARD.encode(bytes)); if let Err(e) = send_pm(app, from, &text).await { log::warn!("soulseek link: could not answer {from}: {e}"); } else { log::info!("soulseek link: answered {from}"); } } /// Their half arrives: match it to the question we asked and judge it. fn accept(from: &str, payload_b64: &str) { use base64::Engine; let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(payload_b64.trim()) else { return; }; let Ok(claim) = bincode::deserialize::(&bytes) else { return }; let Some(p) = pending().lock().unwrap().remove(&claim.nonce) else { log::debug!("soulseek link: answer from {from} matches no open question"); return; }; let now = now_secs(); let result = match sl::check_response(&claim, &p.username_normalized, &claim.nonce, from, now, SCOPE) { Ok(()) if now <= p.expires_at => Ok(KnownLink { username: from.to_string(), username_normalized: p.username_normalized.clone(), persona_key: bs58::encode(claim.soulstream_public_key).into_string(), verified_at: now, method: "native soulseek pm".into(), }), Ok(()) => Err("the answer arrived after the question expired".into()), Err(e) => Err(format!("refused: {e}")), }; let _ = p.reply.send(result); } /// The event pump hands every private message here first. True means it /// was ours and must not surface in the Inbox. pub async fn intercept_pm(app: &tauri::AppHandle, from: &str, message: &str) -> bool { let msg = message.trim(); if let Some(rest) = msg.strip_prefix(REQ_PREFIX) { let token = rest.split_whitespace().next().unwrap_or(""); match bs58::decode(token).into_vec().ok().and_then(|v| <[u8; 16]>::try_from(v).ok()) { Some(nonce) => answer(app, from, nonce).await, None => log::debug!("soulseek link: malformed question from {from}"), } return true; } if let Some(rest) = msg.strip_prefix(RESP_PREFIX) { accept(from, rest); return true; } false } /// A verified link for `username`, if this device holds one. pub(crate) fn known_link(app: &tauri::AppHandle, username: &str) -> Option { load(app) .others .get(&sl::normalize_username(username)) .cloned() } /// Keep a link another service verified (the friends handshake binds a /// request's key to its sender the same way the drawer's Ask does). pub(crate) fn remember(app: &tauri::AppHandle, link: KnownLink) { let mut links = load(app); links.others.insert(link.username_normalized.clone(), link); let _ = save(app, &links); } /// The Soulseek account a verified persona key belongs to, if this device /// verified one (our own included). /// The Soulseek name this device has VERIFIED as its own through the link /// handshake, if any — not merely the account signed in right now. pub(crate) fn own_linked_username(app: &tauri::AppHandle) -> Option { load(app).own.map(|o| o.username) } pub(crate) fn username_for_key(app: &tauri::AppHandle, persona_key: &str) -> Option { let links = load(app); if let Some(o) = &links.own { if o.persona_key == persona_key { return Some(o.username.clone()); } } links .others .values() .find(|l| l.persona_key == persona_key) .map(|l| l.username.clone()) } // ---------------------------------------------------------------- commands #[tauri::command] pub fn slsk_link_status(app: tauri::AppHandle) -> Result { Ok(status_of(load(&app))) } /// Link the signed-in Soulseek account to this device's persona by asking /// ourselves. Stores method and time; publishes nothing. #[tauri::command] pub async fn slsk_link_self( app: tauri::AppHandle, state: tauri::State<'_, AppState>, ) -> Result { let adapter = state.freenet.clone(); let info = persona::current(&adapter) .await? .ok_or("create a SoulStream identity first")?; let me = own_username(&app).await.ok_or("not connected to Soulseek")?; let mut link = ask(&app, &me).await?; if link.persona_key != info.public_key { return Err("the answer was signed by a different persona than this device holds".into()); } link.method = "native soulseek pm (self)".into(); let mut links = load(&app); links.own = Some(link); save(&app, &links)?; log::info!("soulseek link: {me} linked to this persona"); Ok(status_of(links)) } /// Forget our own link; this client stops answering questions. #[tauri::command] pub fn slsk_link_unlink(app: tauri::AppHandle) -> Result { let mut links = load(&app); links.own = None; save(&app, &links)?; Ok(status_of(links)) } /// Ask someone for their SoulStream ID; a verified answer is remembered. #[tauri::command] pub async fn slsk_link_check(app: tauri::AppHandle, username: String) -> Result { let link = ask(&app, &username).await?; let mut links = load(&app); links.others.insert(link.username_normalized.clone(), link.clone()); save(&app, &links)?; Ok(link) } #[tauri::command] pub fn slsk_link_forget(app: tauri::AppHandle, username: String) -> Result { let mut links = load(&app); links.others.remove(&sl::normalize_username(&username)); save(&app, &links)?; Ok(status_of(links)) }