diff --git a/crates/persona-state/src/friend.rs b/crates/persona-state/src/friend.rs new file mode 100644 index 0000000..3014c8f --- /dev/null +++ b/crates/persona-state/src/friend.rs @@ -0,0 +1,281 @@ +//! Friendship proofs — F4 §7.5's exact shapes and verifier. A friendship +//! exists ONLY from two reciprocal, verified, currently-accepted assertions +//! on the same relationship id and request hash; one assertion is a pending +//! request and is never shown as friendship; a revocation is a NEWER +//! assertion with accepted=false — nothing is erased. Names and avatars +//! never enter any decision here. +//! +//! Sign convention: the delegate signs blake3(bincode(sig-body)) under +//! `soulstream.friend/v1`; the verifiers mirror it. Field order is the wire. + +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use serde::{Deserialize, Serialize}; + +pub const FRIEND_DOMAIN: &str = "soulstream.friend/v1"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FriendRequest { + pub relationship_id: [u8; 16], + pub sender: [u8; 32], + pub recipient: [u8; 32], + pub nonce: [u8; 16], + pub issued_at: u64, + pub expires_at: u64, + pub sig: Vec, +} + +#[derive(Serialize)] +struct RequestSigBody<'a> { + relationship_id: &'a [u8; 16], + sender: &'a [u8; 32], + recipient: &'a [u8; 32], + nonce: &'a [u8; 16], + issued_at: u64, + expires_at: u64, +} + +pub fn request_sig_body(r: &FriendRequest) -> Vec { + bincode::serialize(&RequestSigBody { + relationship_id: &r.relationship_id, + sender: &r.sender, + recipient: &r.recipient, + nonce: &r.nonce, + issued_at: r.issued_at, + expires_at: r.expires_at, + }) + .expect("serializes") +} + +/// What the delegate is handed to sign for a request. +pub fn request_sign_payload(r: &FriendRequest) -> Vec { + blake3::hash(&request_sig_body(r)).as_bytes().to_vec() +} + +/// The request's identity: what both assertions bind to. +pub fn request_hash(r: &FriendRequest) -> [u8; 32] { + *blake3::hash(&request_sig_body(r)).as_bytes() +} + +pub fn verify_request(r: &FriendRequest) -> bool { + crate::verify_sig_in(FRIEND_DOMAIN, &r.sender, &request_sign_payload(r), &r.sig) +} + +/// One party's signed stance on one relationship. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FriendAssertion { + pub relationship_id: [u8; 16], + pub signer: [u8; 32], + pub peer: [u8; 32], + pub request_hash: [u8; 32], + pub accepted: bool, + pub seq: u64, + pub sig: Vec, +} + +#[derive(Serialize)] +struct AssertionSigBody<'a> { + relationship_id: &'a [u8; 16], + signer: &'a [u8; 32], + peer: &'a [u8; 32], + request_hash: &'a [u8; 32], + accepted: bool, + seq: u64, +} + +pub fn assertion_sig_body(a: &FriendAssertion) -> Vec { + bincode::serialize(&AssertionSigBody { + relationship_id: &a.relationship_id, + signer: &a.signer, + peer: &a.peer, + request_hash: &a.request_hash, + accepted: a.accepted, + seq: a.seq, + }) + .expect("serializes") +} + +pub fn assertion_sign_payload(a: &FriendAssertion) -> Vec { + blake3::hash(&assertion_sig_body(a)).as_bytes().to_vec() +} + +pub fn verify_assertion(a: &FriendAssertion) -> bool { + crate::verify_sig_in(FRIEND_DOMAIN, &a.signer, &assertion_sign_payload(a), &a.sig) +} + +/// A key-rotation transition (the identity delegate's): old key vouches +/// for new. Carried so a rotated friend can continue instead of re-accept. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct KeyTransition { + pub old_public: [u8; 32], + pub new_public: [u8; 32], + pub key_version: u32, + /// By the OLD key, the delegate's own transition construction. + pub sig: Vec, +} + +fn transition_signable(old: &[u8; 32], new: &[u8; 32], v: u32) -> Vec { + let mut p = Vec::with_capacity(72); + p.extend_from_slice(b"rotate:"); + p.extend_from_slice(old); + p.extend_from_slice(new); + p.extend_from_slice(&v.to_le_bytes()); + crate::signable("soulstream.key-transition/v1", &p) +} + +pub fn verify_transition(t: &KeyTransition) -> bool { + let Ok(vk) = VerifyingKey::from_bytes(&t.old_public) else { return false }; + let Ok(sig) = Signature::from_slice(&t.sig) else { return false }; + vk.verify(&transition_signable(&t.old_public, &t.new_public, t.key_version), &sig) + .is_ok() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum FriendStatus { + /// Both reciprocal assertions verified and accepted. + Friends, + /// One valid assertion — pending, never displayed as friendship. + PendingOneSided, + /// A newer signed revocation stands from at least one side. + Revoked, + /// A key changed without a verifiable transition — must re-accept. + ReacceptanceRequired, + NotRelated, +} + +/// One persona's current key through transition records (one hop). +fn current_key(key: &[u8; 32], transitions: &[KeyTransition]) -> [u8; 32] { + for t in transitions { + if &t.old_public == key && verify_transition(t) { + return t.new_public; + } + } + *key +} + +/// The verifier. Duplicates collapse (the newest seq per side wins); a +/// rotated key without a transition forces re-acceptance. +pub fn friendship_status( + a_key: &[u8; 32], + b_key: &[u8; 32], + relationship_id: &[u8; 16], + req_hash: &[u8; 32], + assertions: &[FriendAssertion], + transitions: &[KeyTransition], +) -> FriendStatus { + let a_now = current_key(a_key, transitions); + let b_now = current_key(b_key, transitions); + let latest = |signer: &[u8; 32], peer: &[u8; 32]| -> Option<&FriendAssertion> { + assertions + .iter() + .filter(|a| { + &a.relationship_id == relationship_id + && &a.request_hash == req_hash + && ¤t_key(&a.signer, transitions) == signer + && ¤t_key(&a.peer, transitions) == peer + && verify_assertion(a) + }) + .max_by_key(|a| a.seq) + }; + let ab = latest(&a_now, &b_now); + let ba = latest(&b_now, &a_now); + match (ab, ba) { + (Some(x), Some(y)) if x.accepted && y.accepted => FriendStatus::Friends, + (Some(x), Some(y)) if !x.accepted || !y.accepted => FriendStatus::Revoked, + (Some(x), None) if x.accepted => { + let stale_exists = assertions.iter().any(|a| { + &a.relationship_id == relationship_id + && verify_assertion(a) + && current_key(&a.signer, transitions) != a_now + && current_key(&a.signer, transitions) != b_now + }); + if stale_exists { + FriendStatus::ReacceptanceRequired + } else { + FriendStatus::PendingOneSided + } + } + (None, Some(y)) if y.accepted => FriendStatus::PendingOneSided, + (None, None) => { + let stale_exists = assertions + .iter() + .any(|a| &a.relationship_id == relationship_id && verify_assertion(a)); + if stale_exists { + FriendStatus::ReacceptanceRequired + } else { + FriendStatus::NotRelated + } + } + _ => FriendStatus::Revoked, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey}; + + fn sign(key: &SigningKey, payload: &[u8]) -> Vec { + key.sign(&crate::signable(FRIEND_DOMAIN, payload)).to_bytes().to_vec() + } + fn request(from: &SigningKey, to: &SigningKey) -> FriendRequest { + let mut r = FriendRequest { + relationship_id: [3; 16], + sender: from.verifying_key().to_bytes(), + recipient: to.verifying_key().to_bytes(), + nonce: [4; 16], + issued_at: 100, + expires_at: 900, + sig: vec![], + }; + r.sig = sign(from, &request_sign_payload(&r)); + r + } + fn assertion(by: &SigningKey, peer: &SigningKey, r: &FriendRequest, accepted: bool, seq: u64) -> FriendAssertion { + let mut a = FriendAssertion { + relationship_id: r.relationship_id, + signer: by.verifying_key().to_bytes(), + peer: peer.verifying_key().to_bytes(), + request_hash: request_hash(r), + accepted, + seq, + sig: vec![], + }; + a.sig = sign(by, &assertion_sign_payload(&a)); + a + } + + #[test] + fn one_sided_is_never_friends_and_revocation_sticks() { + let a = SigningKey::from_bytes(&[1; 32]); + let b = SigningKey::from_bytes(&[2; 32]); + let mallory = SigningKey::from_bytes(&[3; 32]); + let r = request(&a, &b); + assert!(verify_request(&r)); + let (ak, bk) = (a.verifying_key().to_bytes(), b.verifying_key().to_bytes()); + let h = request_hash(&r); + let st = |list: &[FriendAssertion]| friendship_status(&ak, &bk, &r.relationship_id, &h, list, &[]); + + assert_eq!(st(&[]), FriendStatus::NotRelated); + let mine = assertion(&a, &b, &r, true, 10); + assert_eq!(st(&[mine.clone()]), FriendStatus::PendingOneSided); + let theirs = assertion(&b, &a, &r, true, 11); + assert_eq!(st(&[mine.clone(), theirs.clone()]), FriendStatus::Friends); + // duplicates collapse + assert_eq!(st(&[mine.clone(), theirs.clone(), theirs.clone()]), FriendStatus::Friends); + // a forged acceptance never counts as friendship. (To the verifier a + // validly signed third-key assertion looks like an old key without a + // transition, so it answers ReacceptanceRequired — still not friends; + // the app never stores a foreign assertion in the first place.) + let forged = assertion(&mallory, &a, &r, true, 12); + assert_ne!(st(&[mine.clone(), forged]), FriendStatus::Friends); + // a newer revocation stands, and an old acceptance cannot resurrect it + let revoke = assertion(&b, &a, &r, false, 20); + assert_eq!(st(&[mine.clone(), theirs.clone(), revoke.clone()]), FriendStatus::Revoked); + assert_eq!(st(&[revoke, theirs, mine]), FriendStatus::Revoked); + // tampering after signing + let mut t = request(&a, &b); + t.recipient = mallory.verifying_key().to_bytes(); + assert!(!verify_request(&t)); + } +} diff --git a/crates/persona-state/src/lib.rs b/crates/persona-state/src/lib.rs index b4f0efa..749f9db 100644 --- a/crates/persona-state/src/lib.rs +++ b/crates/persona-state/src/lib.rs @@ -16,6 +16,7 @@ use ed25519_dalek::{Signature, Verifier, VerifyingKey}; use serde::{Deserialize, Serialize}; pub mod slsk_link; +pub mod friend; pub const SCHEMA_VERSION: u32 = 1; pub const SIGN_DOMAIN: &str = "soulstream.profile/v1"; diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index dd5d4be..0382de3 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -928,7 +928,9 @@ pub async fn connect_and_login( } => { // Identity-link handshakes ride private messages and // never reach the Inbox (slsk_link.rs). - if crate::slsk_link::intercept_pm(&event_app, &username, &message).await { + if crate::slsk_link::intercept_pm(&event_app, &username, &message).await + || crate::friends::intercept_pm(&event_app, &username, &message) + { continue; } let _ = event_app.emit( diff --git a/src-tauri/src/friends.rs b/src-tauri/src/friends.rs new file mode 100644 index 0000000..3b10e67 --- /dev/null +++ b/src-tauri/src/friends.rs @@ -0,0 +1,546 @@ +//! 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. +//! +//! Carry today: the same Soulseek private-message channel the link proof +//! uses (the encrypted inbox of F5 takes over later). The server can see +//! that one account wrote to another; it cannot forge who wrote, which is +//! the one thing these messages need from it. +//! +//! Wire: `SSFRIEND1?` asks, `SSFRIEND1!` +//! accepts or revokes, `SSFRIEND1-` declines. An +//! incoming REQUEST is bound to its sender by the link handshake: the +//! request names a persona key, and the sender is asked (over PM, with a +//! fresh nonce) to prove that key is theirs before the request is shown. +//! That ask waits on a PM, so the pump hands friend messages to a task — +//! awaiting inline would block the very 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::persona; +use crate::slsk_link; +use crate::state::AppState; + +const REQ_PREFIX: &str = "SSFRIEND1?"; +const ASSERT_PREFIX: &str = "SSFRIEND1!"; +const DECLINE_PREFIX: &str = "SSFRIEND1-"; +/// A request stays open this long. +const REQUEST_TTL_SECS: u64 = 7 * 24 * 3600; +pub const EVENT: &str = "community:friends"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Relationship { + /// Their Soulseek account as the server spelled it when it last spoke. + username: String, + username_normalized: String, + their_key: String, + my_key: String, + request: fr::FriendRequest, + request_from_me: bool, + #[serde(default)] + assertions: Vec, + /// Local-only stances the verifier has no signature for. + #[serde(default)] + declined_by_me: bool, + #[serde(default)] + declined_by_them: bool, + created_at: u64, + updated_at: u64, +} + +#[derive(Default, Serialize, Deserialize)] +struct Store { + #[serde(default)] + relationships: BTreeMap, +} + +/// What the UI sees: one row per relationship, status from the verifier. +#[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 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> { + bs58::decode(b58s) + .into_vec() + .map_err(|e| format!("bad key: {e}"))? + .as_slice() + .try_into() + .map_err(|_| "key is not 32 bytes".to_string()) +} + +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(), + 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> { + 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() +} + +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) +} + +// ---------------------------------------------------------------- outgoing + +/// Ask `username` to be friends. Needs their verified link (the drawer's +/// "Ask for their SoulStream ID") — the request is addressed to a KEY. +pub async fn request(app: &tauri::AppHandle, username: &str) -> Result { + let me = my_key(app).await?; + let link = slsk_link::known_link(app, username) + .ok_or("verify their SoulStream ID first (the drawer's Ask button)")?; + let their = key_of(&link.persona_key)?; + 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 rel = Relationship { + username: link.username.clone(), + username_normalized: link.username_normalized.clone(), + their_key: link.persona_key.clone(), + my_key: b58(&me), + request: req.clone(), + request_from_me: true, + assertions: vec![], + declined_by_me: false, + declined_by_them: false, + created_at: now, + updated_at: now, + }; + send_pm(app, &link.username, &encode(REQ_PREFIX, &req)?).await?; + let mut store = load(app); + store.relationships.insert(id.clone(), rel); + save(app, &store)?; + log::info!("friends: request sent to {}", link.username); + Ok(view_of(&id, &store.relationships[&id], now)) +} + +/// Accept an incoming request: sign our assertion, send it, remember it. +/// Friendship is still one-sided until their reciprocal assertion arrives. +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, + fr::FriendAssertion { + relationship_id: rel.request.relationship_id, + signer: me, + peer: their, + request_hash: fr::request_hash(&rel.request), + accepted: true, + seq: now, + sig: vec![], + }, + ) + .await?; + send_pm(app, &rel.username, &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 _ = send_pm(app, &rel.username, &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, + fr::FriendAssertion { + relationship_id: rel.request.relationship_id, + signer: me, + peer: their, + request_hash: fr::request_hash(&rel.request), + accepted: false, + seq: now, + sig: vec![], + }, + ) + .await?; + let _ = send_pm(app, &rel.username, &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 + +async fn on_request(app: tauri::AppHandle, from: String, req: fr::FriendRequest) { + if !fr::verify_request(&req) { + log::warn!("friends: request from {from} does not verify"); + return; + } + let Ok(me) = my_key(&app).await else { + log::debug!("friends: request from {from}, but no persona here to receive it"); + return; + }; + if req.recipient != me { + log::debug!("friends: request from {from} is addressed to another persona"); + return; + } + let now = now_secs(); + if now > req.expires_at { + return; + } + // The request names a key; the sender must prove it is theirs. + 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 }; + let id = b58(&req.relationship_id); + let mut store = load(&app); + if store.relationships.contains_key(&id) { + return; + } + store.relationships.insert( + id, + Relationship { + username: link.username, + username_normalized: link.username_normalized, + their_key: link.persona_key, + my_key: b58(&me), + request: req, + request_from_me: false, + assertions: vec![], + declined_by_me: false, + declined_by_them: false, + created_at: now, + updated_at: now, + }, + ); + if let Err(e) = save(&app, &store) { + log::warn!("friends: could not store the request from {from}: {e}"); + } else { + log::info!("friends: request from {from} verified and waiting"); + } +} + +async fn on_assertion(app: tauri::AppHandle, from: String, a: fr::FriendAssertion) { + if !fr::verify_assertion(&a) { + log::warn!("friends: assertion from {from} 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 from {from} for an unknown relationship"); + return; + }; + if normalize_username(&from) != rel.username_normalized + || b58(&a.signer) != rel.their_key + || b58(&a.peer) != rel.my_key + || a.request_hash != fr::request_hash(&rel.request) + { + log::warn!("friends: assertion from {from} 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; + // 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, + fr::FriendAssertion { + relationship_id: rel.request.relationship_id, + signer: me, + peer: their, + request_hash: fr::request_hash(&rel.request), + accepted: true, + seq: now, + sig: vec![], + }, + ) + .await + { + Ok(mine) => { + if let Ok(text) = encode(ASSERT_PREFIX, &mine) { + let _ = send_pm(&app, &rel.username, &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; + if let Err(e) = save(&app, &store) { + log::warn!("friends: could not store the assertion from {from}: {e}"); + } else { + log::info!("friends: {from} → {state}"); + } +} + +fn on_decline(app: &tauri::AppHandle, from: &str, id: &str) { + let mut store = load(app); + let Some(rel) = store.relationships.get_mut(id) else { return }; + if normalize_username(from) != rel.username_normalized || !rel.request_from_me { + return; + } + rel.declined_by_them = true; + rel.updated_at = now_secs(); + let _ = save(app, &store); +} + +/// 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) { + tauri::async_runtime::spawn(on_request(app.clone(), from.to_string(), req)); + } + return true; + } + if let Some(rest) = msg.strip_prefix(ASSERT_PREFIX) { + if let Some(a) = decode::(rest) { + tauri::async_runtime::spawn(on_assertion(app.clone(), from.to_string(), a)); + } + return true; + } + if let Some(rest) = msg.strip_prefix(DECLINE_PREFIX) { + on_decline(app, from, 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_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) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8a1c27b..70a0124 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod config; mod cover_logo; mod diagnostics; mod freenet; +mod friends; mod persona; mod hero_crop; mod import; @@ -129,6 +130,12 @@ pub fn run() { slsk_link::slsk_link_unlink, slsk_link::slsk_link_check, slsk_link::slsk_link_forget, + friends::friends_list, + friends::friend_request, + friends::friend_accept, + friends::friend_decline, + friends::friend_remove, + friends::friend_forget, commands::buddy_list, commands::buddy_add, commands::buddy_remove, diff --git a/src-tauri/src/slsk_link.rs b/src-tauri/src/slsk_link.rs index 02d6d3a..1a85598 100644 --- a/src-tauri/src/slsk_link.rs +++ b/src-tauri/src/slsk_link.rs @@ -150,7 +150,7 @@ async fn send_pm(app: &tauri::AppHandle, to: &str, text: &str) -> Result<(), Str /// Ask `username` over Soulseek for their signed SoulStream ID and verify /// the answer. One PM out, one in, thirty seconds at most. -async fn ask(app: &tauri::AppHandle, username: &str) -> Result { +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()); @@ -278,6 +278,21 @@ pub async fn intercept_pm(app: &tauri::AppHandle, from: &str, message: &str) -> 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); +} + // ---------------------------------------------------------------- commands #[tauri::command] diff --git a/src/lib/api.js b/src/lib/api.js index d2b6331..f51a035 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -156,6 +156,12 @@ export const slskLinkSelf = () => invoke("slsk_link_self"); export const slskLinkUnlink = () => invoke("slsk_link_unlink"); export const slskLinkCheck = (username) => invoke("slsk_link_check", { username }); export const slskLinkForget = (username) => invoke("slsk_link_forget", { username }); +export const friendsList = () => invoke("friends_list"); +export const friendRequest = (username) => invoke("friend_request", { username }); +export const friendAccept = (id) => invoke("friend_accept", { id }); +export const friendDecline = (id) => invoke("friend_decline", { id }); +export const friendRemove = (id) => invoke("friend_remove", { id }); +export const friendForget = (id) => invoke("friend_forget", { id }); export const buddyList = () => invoke("buddy_list"); export const buddyAdd = (username) => invoke("buddy_add", { username }); export const buddyRemove = (username) => invoke("buddy_remove", { username }); diff --git a/src/lib/community.svelte.js b/src/lib/community.svelte.js index 179ebb7..aec708c 100644 --- a/src/lib/community.svelte.js +++ b/src/lib/community.svelte.js @@ -63,6 +63,8 @@ export const community = $state({ links: null, /** username → the last failed ask's reason. */ linkErrors: {}, + /** friends_list: every relationship this device holds, newest first. */ + friends: [], /** persona key → ProfileView | {none} | {error} | {pending}: other * people's signed profiles, read once per session when surfaced. */ remoteProfiles: {}, @@ -306,11 +308,12 @@ export async function initCommunity() { if (adapterConnected(status)) void refreshPersona(); else if (community.persona) community.persona.available = false; }); + api.onEvent("community:friends", () => void refreshFriends()); api.onEvent("soulseek:user-state", (p) => { if (p.status === 1 || p.status === 2) noteSeen(p.username); }); } - await Promise.all([refreshFreenet(), loadContacts(), refreshLinks()]); + await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends()]); await refreshPersona(); } @@ -391,6 +394,32 @@ export async function loadRemoteProfile(key, force = false) { } +// ---- friends ------------------------------------------------------------------------ +export async function refreshFriends() { + try { + community.friends = await api.friendsList(); + } catch { + // stays as it was + } +} +const friendAction = (key, fn) => run(key, async () => { await fn(); await refreshFriends(); }); +export const friendRequest = (username) => friendAction("friend:" + username, () => api.friendRequest(username)); +export const friendAccept = (id) => friendAction("friend:" + id, () => api.friendAccept(id)); +export const friendDecline = (id) => friendAction("friend:" + id, () => api.friendDecline(id)); +export const friendRemove = (id) => friendAction("friend:" + id, () => api.friendRemove(id)); +export const friendForget = (id) => friendAction("friend:" + id, () => api.friendForget(id)); +/** The live relationship with a username, if any: friends first, then the + * most recently touched one. */ +export const friendshipFor = (username) => { + const n = (username ?? "").trim().toLowerCase(); + const mine = community.friends.filter((f) => f.username_normalized === n); + return mine.find((f) => f.state === "friends") ?? mine[0] ?? null; +}; +export const friendCount = () => community.friends.filter((f) => f.state === "friends").length; +export const pendingIn = () => community.friends.filter((f) => f.state === "pending-in"); + + // Dev handle beside `__soulstream` (state.svelte.js): probes reach the store // through this, never through a bare import (which would be a second copy). if (import.meta.env.DEV) globalThis.__community = community; +if (import.meta.env.DEV) globalThis.__communityActions = { friendRequest, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends }; diff --git a/src/views/Community.svelte b/src/views/Community.svelte index 643277f..087244b 100644 --- a/src/views/Community.svelte +++ b/src/views/Community.svelte @@ -64,6 +64,14 @@ checkLink, linkFor, loadRemoteProfile, + friendRequest, + friendAccept, + friendDecline, + friendRemove, + friendForget, + friendshipFor, + friendCount, + pendingIn, } from "../lib/community.svelte.js"; onMount(() => { @@ -296,6 +304,8 @@ ? true : peopleGroup === "trusted" ? p.meta.trusted + : peopleGroup === "friends" + ? friendshipFor(p.u)?.state === "friends" : peopleGroup === "favorite" ? p.meta.favorite : p.meta.group === peopleGroup, @@ -412,6 +422,8 @@ ); const drawerLink = $derived(drawerUser ? linkFor(drawerUser) : null); const drawerRemote = $derived(drawerLink ? community.remoteProfiles[drawerLink.persona_key] : null); + const drawerFriend = $derived(drawerUser ? friendshipFor(drawerUser) : null); + const isMe = (u) => !!me && (u ?? "").trim().toLowerCase() === me.trim().toLowerCase(); $effect(() => { const lk = drawerLink; if (lk) untrack(() => loadRemoteProfile(lk.persona_key)); @@ -493,7 +505,7 @@ {:else} SoulStream identity off {/if} - {slsk.buddies.length} contacts · {joinedNames.length} rooms · {convos.length} conversations + {friendCount()} friends · {slsk.buddies.length} contacts · {joinedNames.length} rooms · {convos.length} conversations +
+
{display(f.username)} wants to be friends
+
{f.username} · SoulStream ID {fpShort(f.persona_key)} · asked {fmtWhen(f.updated_at * 1000)}
+
SoulStreamsigned request · their ID proven over Soulseek·open until {fmtWhen(f.expires_at * 1000)}
+
+ +
+ + + friends are two signed answers — yours goes back to them over Soulseek +
+ + {/each} + + {/if} {#if !convos.length}
@@ -762,6 +795,7 @@ {#each groups as g (g)} {/each} + @@ -787,6 +821,7 @@ {#if p.meta.petname}{p.u}{/if} {#if p.meta.favorite}{/if} {#if p.meta.trusted}{/if} + {#if friendshipFor(p.u)?.state === "friends"}{/if}
Soulseek @@ -1170,6 +1205,7 @@ saveMeta()} title="Your private petname for this person" /> {#if drawerMeta?.favorite}{/if} {#if drawerMeta?.trusted}{/if} + {#if drawerFriend?.state === "friends"}{/if}
{#if petDraft.trim()}{drawerUser}·{/if} @@ -1263,6 +1299,35 @@

Their client answered a one-time question over Soulseek with a claim signed by this key — the server vouched for the sender, the signature for the key.

+ {#if !isMe(drawerUser)} +
+ {#if !drawerFriend || drawerFriend.state === "expired" || drawerFriend.state === "revoked" && drawerFriend.direction === "in"} + + {drawerFriend?.state === "revoked" ? "the earlier friendship was ended — a new request starts over" : "a signed request goes to them over Soulseek; friends are two reciprocal signed answers"} + {:else if drawerFriend.state === "friends"} + friends since {fmtWhen(drawerFriend.since * 1000)} + + {:else if drawerFriend.state === "pending-in"} + they asked to be friends + + + {:else if drawerFriend.state === "pending-out"} + request sent · waiting for their answer + + {:else if drawerFriend.state === "accepted-waiting"} + you accepted · waiting for their signed answer + {:else if drawerFriend.state === "declined"} + {drawerFriend.direction === "out" ? "they declined" : "you declined"} + + {:else if drawerFriend.state === "revoked"} + friendship ended + + {:else if drawerFriend.state === "reacceptance-required"} + their key changed — must re-accept + + {/if} +
+ {/if} {:else}

Signed SoulStream profile not asked

Ask their client, over Soulseek, for a signed SoulStream ID. One message goes out; a client that is not SoulStream sees a line saying it is safe to ignore.

@@ -1443,6 +1508,15 @@ .sub { color: var(--text-secondary); font-size: 12.5px; max-width: 460px; line-height: 1.6; } .spico { width: 56px; height: 56px; border-radius: 16px; display: grid; place-items: center; background: color-mix(in srgb, var(--space) 16%, transparent); color: #d9a6ff; } + /* friend requests — the invitation card, parsed and signed, never pasted text */ + .freqs { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; } + .card { border-radius: 12px; background: var(--surf); border: 1px solid var(--hair); overflow: hidden; max-width: 560px; } + .card .top { display: flex; gap: 12px; padding: 12px; align-items: center; } + .card .t { font-size: 14px; font-weight: 800; } + .card .s { font-size: 11.5px; color: var(--text-secondary); margin-top: 2px; } + .card .prov { font-size: 10.5px; color: var(--text-muted); margin-top: 6px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } + .card .bot { display: flex; gap: 8px; padding: 0 12px 12px; align-items: center; flex-wrap: wrap; } + .card .bot .hint { margin-left: auto; font-size: 10.5px; } /* ── thread / room ── */ .thread { display: flex; flex-direction: column; height: calc(100vh - 330px); min-height: 480px; } .thead-bar, .roomhead { display: flex; align-items: center; gap: 12px; padding: 4px 4px 10px; border-bottom: 1px solid var(--hair); } @@ -1628,6 +1702,8 @@ .rp { display: flex; gap: 12px; align-items: flex-start; } .rptx { min-width: 0; flex: 1; } .rp .nm { font-size: 15px; font-weight: 800; display: flex; align-items: center; gap: 8px; } + .friendrow { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 12px; } + .mark.fr { color: var(--accent-hover); } .dsec { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--hair); } .dsec h4 { margin: 0 0 8px; font-size: 10.5px; font-weight: 900; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); display: flex; align-items: center; gap: 8px; } .dsec h4 .src, .dsec h4 .chip { margin-left: auto; }