//! Friends, product side (F4 §7.5 → the app). A friendship is two //! reciprocal signed assertions on one relationship id and request hash; //! everything else — one assertion, a declined request, a revocation — is //! shown as exactly what it is. Proofs stay on this device; nothing is //! published. //! //! Two carries, chosen per relationship and remembered: the ENCRYPTED //! INBOX (inbox.rs — sealed to the recipient's box key, offered to their //! contract) whenever the other persona has published one, else the //! Soulseek private-message channel the link proof uses. The server can //! see that one account wrote to another on the PM carry; it cannot forge //! who wrote, which is the one thing that carry needs from it. //! //! PM wire: `SSFRIEND1?` asks, `SSFRIEND1!` //! accepts or revokes, `SSFRIEND1-` declines. An //! incoming PM REQUEST is bound to its sender by the link handshake (the //! request names a key; the sender proves it). An inbox request needs no //! such step: the envelope header is signed by the same key the request //! names. That link ask waits on a PM, so PM friend messages are handled //! on their own task — awaiting inline would block the loop that delivers //! the answer. use std::collections::BTreeMap; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use persona_state::friend as fr; use persona_state::slsk_link::normalize_username; use serde::{Deserialize, Serialize}; use tauri::{Emitter, Manager}; use crate::inbox::{self, Cargo}; use crate::persona; use crate::slsk_link; use crate::state::AppState; const REQ_PREFIX: &str = "SSFRIEND1?"; const ASSERT_PREFIX: &str = "SSFRIEND1!"; const DECLINE_PREFIX: &str = "SSFRIEND1-"; const REQUEST_TTL_SECS: u64 = 7 * 24 * 3600; pub const EVENT: &str = "community:friends"; fn carry_pm() -> String { "pm".into() } #[derive(Debug, Clone, Serialize, Deserialize)] struct Relationship { /// Their Soulseek account as the server spelled it — may be empty for a /// relationship that only ever travelled the inbox. username: String, username_normalized: String, their_key: String, my_key: String, request: fr::FriendRequest, request_from_me: bool, #[serde(default)] assertions: Vec, #[serde(default)] declined_by_me: bool, #[serde(default)] declined_by_them: bool, /// "inbox" | "pm" — how the last message travelled; replies follow it. #[serde(default = "carry_pm")] carry: String, created_at: u64, updated_at: u64, } #[derive(Default, Serialize, Deserialize)] struct Store { #[serde(default)] relationships: BTreeMap, } #[derive(Debug, Serialize)] pub struct RelationshipView { pub id: String, pub username: String, pub username_normalized: String, pub persona_key: String, pub direction: &'static str, /// friends | pending-out | pending-in | accepted-waiting | declined | /// revoked | expired | reacceptance-required pub state: String, pub carry: String, pub since: u64, pub updated_at: u64, pub expires_at: u64, } 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 b58(bytes: &[u8]) -> String { bs58::encode(bytes).into_string() } fn key_of(b58s: &str) -> Result<[u8; 32], String> { inbox::key_of(b58s) } fn path(app: &tauri::AppHandle) -> Result { Ok(app .path() .app_data_dir() .map_err(|e| e.to_string())? .join("freenet") .join("friends.json")) } fn load(app: &tauri::AppHandle) -> Store { 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, store: &Store) -> Result<(), String> { let p = 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(store).map_err(|e| e.to_string())?) .map_err(|e| e.to_string())?; let _ = app.emit(EVENT, ()); Ok(()) } fn view_of(id: &str, r: &Relationship, now: u64) -> RelationshipView { let (my, their) = (key_of(&r.my_key), key_of(&r.their_key)); let status = match (my, their) { (Ok(m), Ok(t)) => fr::friendship_status( &m, &t, &r.request.relationship_id, &fr::request_hash(&r.request), &r.assertions, &[], ), _ => fr::FriendStatus::NotRelated, }; let mine_accepted = r .assertions .iter() .any(|a| b58(&a.signer) == r.my_key && a.accepted); let state = match status { fr::FriendStatus::Friends => "friends", fr::FriendStatus::Revoked => "revoked", fr::FriendStatus::ReacceptanceRequired => "reacceptance-required", fr::FriendStatus::PendingOneSided | fr::FriendStatus::NotRelated => { if r.declined_by_me || r.declined_by_them { "declined" } else if now > r.request.expires_at && status == fr::FriendStatus::NotRelated { "expired" } else if mine_accepted { "accepted-waiting" } else if r.request_from_me { "pending-out" } else { "pending-in" } } }; let since = r .assertions .iter() .filter(|a| a.accepted) .map(|a| a.seq) .max() .unwrap_or(r.created_at); RelationshipView { id: id.to_string(), username: r.username.clone(), username_normalized: r.username_normalized.clone(), persona_key: r.their_key.clone(), direction: if r.request_from_me { "out" } else { "in" }, state: state.to_string(), carry: r.carry.clone(), since, updated_at: r.updated_at, expires_at: r.request.expires_at, } } async fn my_key(app: &tauri::AppHandle) -> Result<[u8; 32], String> { let adapter = app.state::().freenet.clone(); let info = persona::current(&adapter) .await? .ok_or("create a SoulStream identity first")?; key_of(&info.public_key) } async fn send_pm(app: &tauri::AppHandle, to: &str, text: &str) -> Result<(), String> { if to.is_empty() { return Err("no Soulseek account is known for them".into()); } 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()) } fn encode(prefix: &str, v: &T) -> Result { use base64::Engine; let bytes = bincode::serialize(v).map_err(|e| e.to_string())?; Ok(format!("{prefix}{}", base64::engine::general_purpose::STANDARD.encode(bytes))) } fn decode Deserialize<'de>>(b64: &str) -> Option { use base64::Engine; let bytes = base64::engine::general_purpose::STANDARD.decode(b64.trim()).ok()?; bincode::deserialize(&bytes).ok() } /// Deliver by the relationship's carry; an inbox that went unreachable /// falls back to the PM carry when an account is known. async fn deliver(app: &tauri::AppHandle, rel: &mut Relationship, cargo: Cargo, pm_text: String) -> Result<(), String> { if rel.carry == "inbox" { match key_of(&rel.their_key) { Ok(k) => match inbox::send(app, &k, &cargo).await { Ok(()) => return Ok(()), Err(e) => log::info!("friends: inbox carry failed ({e}); trying the Soulseek carry"), }, Err(e) => return Err(e), } } send_pm(app, &rel.username, &pm_text).await?; rel.carry = "pm".into(); Ok(()) } async fn sign_assertion(app: &tauri::AppHandle, mut a: fr::FriendAssertion) -> Result { let adapter = app.state::().freenet.clone(); a.sig = persona::sign(&adapter, fr::FRIEND_DOMAIN, fr::assertion_sign_payload(&a)).await?; Ok(a) } fn assertion_for(rel: &Relationship, me: [u8; 32], their: [u8; 32], accepted: bool, now: u64) -> fr::FriendAssertion { fr::FriendAssertion { relationship_id: rel.request.relationship_id, signer: me, peer: their, request_hash: fr::request_hash(&rel.request), accepted, seq: now, sig: vec![], } } // ---------------------------------------------------------------- outgoing async fn start_request( app: &tauri::AppHandle, their: [u8; 32], username: String, ) -> Result { let me = my_key(app).await?; if their == me { return Err("that is your own identity".into()); } let now = now_secs(); let mut req = fr::FriendRequest { relationship_id: nonce16(), sender: me, recipient: their, nonce: nonce16(), issued_at: now, expires_at: now + REQUEST_TTL_SECS, sig: vec![], }; let adapter = app.state::().freenet.clone(); req.sig = persona::sign(&adapter, fr::FRIEND_DOMAIN, fr::request_sign_payload(&req)).await?; let id = b58(&req.relationship_id); let mut rel = Relationship { username_normalized: normalize_username(&username), username, their_key: b58(&their), my_key: b58(&me), request: req.clone(), request_from_me: true, assertions: vec![], declined_by_me: false, declined_by_them: false, carry: if inbox::reachable(app, &their).await { "inbox".into() } else { "pm".into() }, created_at: now, updated_at: now, }; deliver(app, &mut rel, Cargo::FriendRequest(req.clone()), encode(REQ_PREFIX, &req)?).await?; let mut store = load(app); store.relationships.insert(id.clone(), rel); save(app, &store)?; let rel = &store.relationships[&id]; log::info!("friends: request sent to {} via {}", if rel.username.is_empty() { &rel.their_key } else { &rel.username }, rel.carry); Ok(view_of(&id, rel, now)) } /// Ask a Soulseek account to be friends. Needs their verified link — the /// request is addressed to a KEY. pub async fn request(app: &tauri::AppHandle, username: &str) -> Result { let link = slsk_link::known_link(app, username) .ok_or("verify their SoulStream ID first (the drawer's Ask button)")?; start_request(app, key_of(&link.persona_key)?, link.username.clone()).await } /// Ask a SoulStream ID directly — only through their inbox. pub async fn request_by_key(app: &tauri::AppHandle, persona_key: &str) -> Result { let their = key_of(persona_key)?; if !inbox::reachable(app, &their).await { return Err("they have not published an inbox — reachable only through a Soulseek account".into()); } let username = slsk_link::username_for_key(app, persona_key).unwrap_or_default(); start_request(app, their, username).await } pub async fn accept(app: &tauri::AppHandle, id: &str) -> Result { let me = my_key(app).await?; let mut store = load(app); let rel = store.relationships.get_mut(id).ok_or("no such request")?; if rel.request_from_me { return Err("that request is yours to wait on, not to accept".into()); } let their = key_of(&rel.their_key)?; let now = now_secs(); let a = sign_assertion(app, assertion_for(rel, me, their, true, now)).await?; deliver(app, rel, Cargo::FriendAssertion(a.clone()), encode(ASSERT_PREFIX, &a)?).await?; rel.assertions.push(a); rel.declined_by_me = false; rel.updated_at = now; let v = view_of(id, rel, now); save(app, &store)?; Ok(v) } pub async fn decline(app: &tauri::AppHandle, id: &str) -> Result { let mut store = load(app); let rel = store.relationships.get_mut(id).ok_or("no such request")?; let now = now_secs(); rel.declined_by_me = true; rel.updated_at = now; let _ = deliver(app, rel, Cargo::FriendDecline(rel.request.relationship_id), format!("{DECLINE_PREFIX}{id}")).await; let v = view_of(id, rel, now); save(app, &store)?; Ok(v) } /// Unfriend: a signed revocation goes to them; the record stays as /// "revoked" — the design's honesty about what a removal can and cannot do. pub async fn remove(app: &tauri::AppHandle, id: &str) -> Result { let me = my_key(app).await?; let mut store = load(app); let rel = store.relationships.get_mut(id).ok_or("no such relationship")?; let their = key_of(&rel.their_key)?; let now = now_secs(); let a = sign_assertion(app, assertion_for(rel, me, their, false, now)).await?; let _ = deliver(app, rel, Cargo::FriendAssertion(a.clone()), encode(ASSERT_PREFIX, &a)?).await; rel.assertions.push(a); rel.updated_at = now; let v = view_of(id, rel, now); save(app, &store)?; Ok(v) } pub fn forget(app: &tauri::AppHandle, id: &str) -> Result<(), String> { let mut store = load(app); store.relationships.remove(id); save(app, &store) } pub fn list(app: &tauri::AppHandle) -> Vec { let now = now_secs(); let store = load(app); let mut v: Vec<_> = store .relationships .iter() .map(|(id, r)| view_of(id, r, now)) .collect(); v.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); v } // ---------------------------------------------------------------- incoming /// Who a message came from, as far as its carry can vouch. enum From { /// A Soulseek account the server attributed. Pm(String), /// A persona key that signed the envelope header. Inbox([u8; 32]), } async fn store_request(app: &tauri::AppHandle, from: From, req: fr::FriendRequest) { if !fr::verify_request(&req) { log::warn!("friends: a request does not verify"); return; } let Ok(me) = my_key(app).await else { log::debug!("friends: a request arrived, but no persona is here to receive it"); return; }; if req.recipient != me { return; } let now = now_secs(); if now > req.expires_at { return; } // Bind the request's key to whoever sent it. let (username, carry) = match from { From::Inbox(sender) => { if sender != req.sender { log::warn!("friends: an inbox request names a key other than its envelope's signer"); return; } (slsk_link::username_for_key(app, &b58(&sender)).unwrap_or_default(), "inbox") } From::Pm(from) => { let bound = match slsk_link::known_link(app, &from) { Some(l) if l.persona_key == b58(&req.sender) => Some(l), _ => match slsk_link::ask(app, &from).await { Ok(l) if l.persona_key == b58(&req.sender) => { slsk_link::remember(app, l.clone()); Some(l) } Ok(_) => { log::warn!("friends: {from} proved a different key than the request names"); None } Err(e) => { log::warn!("friends: could not bind {from} to the request's key: {e}"); None } }, }; let Some(link) = bound else { return }; (link.username, "pm") } }; let id = b58(&req.relationship_id); let mut store = load(app); if store.relationships.contains_key(&id) { return; } let who = if username.is_empty() { b58(&req.sender) } else { username.clone() }; store.relationships.insert( id, Relationship { username_normalized: normalize_username(&username), username, their_key: b58(&req.sender), my_key: b58(&me), request: req, request_from_me: false, assertions: vec![], declined_by_me: false, declined_by_them: false, carry: carry.into(), created_at: now, updated_at: now, }, ); if let Err(e) = save(app, &store) { log::warn!("friends: could not store the request from {who}: {e}"); } else { log::info!("friends: request from {who} verified and waiting (via {carry})"); } } async fn apply_assertion(app: &tauri::AppHandle, from: From, a: fr::FriendAssertion) { if !fr::verify_assertion(&a) { log::warn!("friends: an assertion does not verify"); return; } let id = b58(&a.relationship_id); let mut store = load(app); let Some(rel) = store.relationships.get_mut(&id) else { log::debug!("friends: assertion for an unknown relationship"); return; }; let sender_ok = match &from { From::Pm(u) => normalize_username(u) == rel.username_normalized, From::Inbox(k) => b58(k) == rel.their_key, }; if !sender_ok || b58(&a.signer) != rel.their_key || b58(&a.peer) != rel.my_key || a.request_hash != fr::request_hash(&rel.request) { log::warn!("friends: an assertion does not belong to its relationship"); return; } if rel .assertions .iter() .any(|x| x.signer == a.signer && x.seq == a.seq && x.accepted == a.accepted) { return; } let now = now_secs(); rel.assertions.push(a.clone()); rel.updated_at = now; if let From::Inbox(_) = from { rel.carry = "inbox".into(); } // Their acceptance of OUR request completes with our own assertion. let mine_exists = rel.assertions.iter().any(|x| b58(&x.signer) == rel.my_key); if a.accepted && rel.request_from_me && !mine_exists { if let (Ok(me), Ok(their)) = (key_of(&rel.my_key), key_of(&rel.their_key)) { match sign_assertion(app, assertion_for(rel, me, their, true, now)).await { Ok(mine) => { if let Ok(text) = encode(ASSERT_PREFIX, &mine) { let _ = deliver(app, rel, Cargo::FriendAssertion(mine.clone()), text).await; } rel.assertions.push(mine); } Err(e) => log::warn!("friends: could not sign our assertion: {e}"), } } } let state = view_of(&id, rel, now).state; let who = if rel.username.is_empty() { rel.their_key.clone() } else { rel.username.clone() }; if let Err(e) = save(app, &store) { log::warn!("friends: could not store an assertion: {e}"); } else { log::info!("friends: {who} → {state}"); } } fn apply_decline(app: &tauri::AppHandle, from: From, id: &str) { let mut store = load(app); let Some(rel) = store.relationships.get_mut(id) else { return }; let sender_ok = match &from { From::Pm(u) => normalize_username(u) == rel.username_normalized, From::Inbox(k) => b58(k) == rel.their_key, }; if !sender_ok || !rel.request_from_me { return; } rel.declined_by_them = true; rel.updated_at = now_secs(); let _ = save(app, &store); } /// Sealed cargo from inbox.rs: the envelope's signer is the sender. pub(crate) async fn on_inbox_request(app: &tauri::AppHandle, sender: [u8; 32], req: fr::FriendRequest) { store_request(app, From::Inbox(sender), req).await; } pub(crate) async fn on_inbox_assertion(app: &tauri::AppHandle, sender: [u8; 32], a: fr::FriendAssertion) { apply_assertion(app, From::Inbox(sender), a).await; } pub(crate) fn on_inbox_decline(app: &tauri::AppHandle, sender: [u8; 32], id: [u8; 16]) { apply_decline(app, From::Inbox(sender), &b58(&id)); } /// The event pump hands every private message here after the link proof. /// True means it was ours and must not surface in the Inbox. Requests and /// assertions are handled on their own task (see the module note). pub fn intercept_pm(app: &tauri::AppHandle, from: &str, message: &str) -> bool { let msg = message.trim(); if let Some(rest) = msg.strip_prefix(REQ_PREFIX) { if let Some(req) = decode::(rest) { let (app, from) = (app.clone(), from.to_string()); tauri::async_runtime::spawn(async move { store_request(&app, From::Pm(from), req).await }); } return true; } if let Some(rest) = msg.strip_prefix(ASSERT_PREFIX) { if let Some(a) = decode::(rest) { let (app, from) = (app.clone(), from.to_string()); tauri::async_runtime::spawn(async move { apply_assertion(&app, From::Pm(from), a).await }); } return true; } if let Some(rest) = msg.strip_prefix(DECLINE_PREFIX) { apply_decline(app, From::Pm(from.to_string()), rest.trim()); return true; } false } // ---------------------------------------------------------------- commands #[tauri::command] pub fn friends_list(app: tauri::AppHandle) -> Vec { list(&app) } #[tauri::command] pub async fn friend_request(app: tauri::AppHandle, username: String) -> Result { request(&app, &username).await } #[tauri::command] pub async fn friend_request_by_key(app: tauri::AppHandle, persona_key: String) -> Result { request_by_key(&app, &persona_key).await } #[tauri::command] pub async fn friend_accept(app: tauri::AppHandle, id: String) -> Result { accept(&app, &id).await } #[tauri::command] pub async fn friend_decline(app: tauri::AppHandle, id: String) -> Result { decline(&app, &id).await } #[tauri::command] pub async fn friend_remove(app: tauri::AppHandle, id: String) -> Result { remove(&app, &id).await } #[tauri::command] pub fn friend_forget(app: tauri::AppHandle, id: String) -> Result<(), String> { forget(&app, &id) }