community: friends, two signed answers on one request carried over soulseek, one answer is only ever pending

This commit is contained in:
KeelrLeek committed 2026-09-02 14:40:33 -05:00
1 parent 6ed6f5b8f8
commit 984738135a
9 files changed
+967 -4

No files matched your search

+281
View File
@@ -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<u8>,
}
#[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<u8> {
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<u8> {
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<u8>,
}
#[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<u8> {
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<u8> {
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<u8>,
}
fn transition_signable(old: &[u8; 32], new: &[u8; 32], v: u32) -> Vec<u8> {
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
&& &current_key(&a.signer, transitions) == signer
&& &current_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<u8> {
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));
}
}
+1
View File
@@ -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";
+3 -1
View File
@@ -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(
+546
View File
@@ -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?<b64 request>` asks, `SSFRIEND1!<b64 assertion>`
//! accepts or revokes, `SSFRIEND1-<b58 relationship id>` 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<fr::FriendAssertion>,
/// 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<String, Relationship>,
}
/// 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<PathBuf, String> {
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::<AppState>().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::<AppState>();
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<T: Serialize>(prefix: &str, v: &T) -> Result<String, String> {
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<T: for<'de> Deserialize<'de>>(b64: &str) -> Option<T> {
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<fr::FriendAssertion, String> {
let adapter = app.state::<AppState>().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<RelationshipView, String> {
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::<AppState>().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<RelationshipView, String> {
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<RelationshipView, String> {
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<RelationshipView, String> {
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<RelationshipView> {
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::<fr::FriendRequest>(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::<fr::FriendAssertion>(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<RelationshipView> {
list(&app)
}
#[tauri::command]
pub async fn friend_request(app: tauri::AppHandle, username: String) -> Result<RelationshipView, String> {
request(&app, &username).await
}
#[tauri::command]
pub async fn friend_accept(app: tauri::AppHandle, id: String) -> Result<RelationshipView, String> {
accept(&app, &id).await
}
#[tauri::command]
pub async fn friend_decline(app: tauri::AppHandle, id: String) -> Result<RelationshipView, String> {
decline(&app, &id).await
}
#[tauri::command]
pub async fn friend_remove(app: tauri::AppHandle, id: String) -> Result<RelationshipView, String> {
remove(&app, &id).await
}
#[tauri::command]
pub fn friend_forget(app: tauri::AppHandle, id: String) -> Result<(), String> {
forget(&app, &id)
}
+7
View File
@@ -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,
+16 -1
View File
@@ -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<KnownLink, String> {
pub(crate) async fn ask(app: &tauri::AppHandle, username: &str) -> Result<KnownLink, String> {
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<KnownLink> {
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]
+6
View File
@@ -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 });
+30 -1
View File
@@ -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 };
+77 -1
View File
@@ -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}
<span class="chip off">SoulStream identity off</span>
{/if}
<span class="hint">{slsk.buddies.length} contacts · {joinedNames.length} rooms · {convos.length} conversations</span>
<span class="hint">{friendCount()} friends · {slsk.buddies.length} contacts · {joinedNames.length} rooms · {convos.length} conversations</span>
</div>
<button class="ax-ghost" onclick={() => setSection("you")}>
<Icon name={key ? "pencil" : "sparkle"} size={13} />
@@ -536,6 +548,27 @@
<!-- ── INBOX ──────────────────────────────────────────────────────── -->
{:else if community.section === "inbox"}
{#if pendingIn().length}
<div class="freqs">
{#each pendingIn() as f (f.id)}
<div class="card inv">
<div class="top">
<button class="avbtn" onclick={() => (community.drawer = f.username)}><Identicon seed={f.persona_key} size={44} presence={presenceOf(f.username)} /></button>
<div>
<div class="t">{display(f.username)} wants to be friends</div>
<div class="s">{f.username} · SoulStream ID {fpShort(f.persona_key)} · asked {fmtWhen(f.updated_at * 1000)}</div>
<div class="prov"><span class="src ss"><Icon name="disc" size={10} />SoulStream</span><span>signed request · their ID proven over Soulseek</span><span>·</span><span>open until {fmtWhen(f.expires_at * 1000)}</span></div>
</div>
</div>
<div class="bot">
<button class="ax-fill sm" onclick={() => friendAccept(f.id)} disabled={!online || !nodeUp || !!community.busy["friend:" + f.id]}>Accept</button>
<button class="ax-ghost sm" onclick={() => friendDecline(f.id)} disabled={!!community.busy["friend:" + f.id]}>Decline</button>
<span class="hint">friends are two signed answers — yours goes back to them over Soulseek</span>
</div>
</div>
{/each}
</div>
{/if}
{#if !convos.length}
<div class="empty">
<Icon name="inbox" size={30} />
@@ -762,6 +795,7 @@
{#each groups as g (g)}
<button class="fchip" class:on={peopleGroup === g} onclick={() => (peopleGroup = g)}>{g}</button>
{/each}
<button class="fchip" class:on={peopleGroup === "friends"} onclick={() => (peopleGroup = "friends")}>Friends</button>
<button class="fchip" class:on={peopleGroup === "favorite"} onclick={() => (peopleGroup = "favorite")}>Favorites</button>
<button class="fchip" class:on={peopleGroup === "trusted"} onclick={() => (peopleGroup = "trusted")}>Trusted</button>
<span class="sp"></span>
@@ -787,6 +821,7 @@
{#if p.meta.petname}<span class="real">{p.u}</span>{/if}
{#if p.meta.favorite}<span class="mark fav" title="Favorite"><Icon name="star" size={11} /></span>{/if}
{#if p.meta.trusted}<span class="mark tr" title="Trusted — a local permission choice, not a verified identity"><Icon name="shield" size={11} /></span>{/if}
{#if friendshipFor(p.u)?.state === "friends"}<span class="mark fr" title="Friend — a signed, reciprocal SoulStream relationship"><Icon name="link" size={11} /></span>{/if}
</div>
<div class="sub">
<span class="src slsk"><Icon name="globe" size={10} />Soulseek</span>
@@ -1170,6 +1205,7 @@
<input class="pet" placeholder={drawerUser} bind:value={petDraft} onchange={() => saveMeta()} title="Your private petname for this person" />
{#if drawerMeta?.favorite}<span class="mark fav"><Icon name="star" size={12} /></span>{/if}
{#if drawerMeta?.trusted}<span class="mark tr"><Icon name="shield" size={12} /></span>{/if}
{#if drawerFriend?.state === "friends"}<span class="mark fr" title="Friend"><Icon name="link" size={12} /></span>{/if}
</div>
<div class="sub">
{#if petDraft.trim()}<span>{drawerUser}</span><span>·</span>{/if}
@@ -1263,6 +1299,35 @@
</div>
</div>
<p class="hint" style="margin-top:8px">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. <button class="lnk" onclick={() => checkLink(drawerUser)} disabled={!online || !!community.busy["link:" + drawerUser]}>ask again</button></p>
{#if !isMe(drawerUser)}
<div class="friendrow">
{#if !drawerFriend || drawerFriend.state === "expired" || drawerFriend.state === "revoked" && drawerFriend.direction === "in"}
<button class="ax-fill sm" onclick={() => friendRequest(drawerUser)} disabled={!online || !nodeUp || !key || !!community.busy["friend:" + drawerUser]}><Icon name="link" size={12} /> {community.busy["friend:" + drawerUser] ? "Sending…" : "Add as friend"}</button>
<span class="hint">{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"}</span>
{:else if drawerFriend.state === "friends"}
<span class="chip ok"><Icon name="link" size={10} />friends since {fmtWhen(drawerFriend.since * 1000)}</span>
<button class="ax-ghost sm" onclick={() => friendRemove(drawerFriend.id)} disabled={!!community.busy["friend:" + drawerFriend.id]}>Unfriend</button>
{:else if drawerFriend.state === "pending-in"}
<span class="chip acc">they asked to be friends</span>
<button class="ax-fill sm" onclick={() => friendAccept(drawerFriend.id)} disabled={!online || !nodeUp || !!community.busy["friend:" + drawerFriend.id]}>Accept</button>
<button class="ax-ghost sm" onclick={() => friendDecline(drawerFriend.id)} disabled={!!community.busy["friend:" + drawerFriend.id]}>Decline</button>
{:else if drawerFriend.state === "pending-out"}
<span class="chip acc"><span class="bl"></span>request sent · waiting for their answer</span>
<button class="lnk" onclick={() => friendForget(drawerFriend.id)}>withdraw</button>
{:else if drawerFriend.state === "accepted-waiting"}
<span class="chip acc"><span class="bl"></span>you accepted · waiting for their signed answer</span>
{:else if drawerFriend.state === "declined"}
<span class="chip off">{drawerFriend.direction === "out" ? "they declined" : "you declined"}</span>
<button class="lnk" onclick={() => friendForget(drawerFriend.id)}>forget</button>
{:else if drawerFriend.state === "revoked"}
<span class="chip warn">friendship ended</span>
<button class="ax-ghost sm" onclick={() => friendRequest(drawerUser)} disabled={!online || !nodeUp}>Ask again</button>
{:else if drawerFriend.state === "reacceptance-required"}
<span class="chip warn">their key changed — must re-accept</span>
<button class="ax-ghost sm" onclick={() => friendRequest(drawerUser)} disabled={!online || !nodeUp}>Ask again</button>
{/if}
</div>
{/if}
{:else}
<h4>Signed SoulStream profile <span class="chip off">not asked</span></h4>
<p class="hint">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.</p>
@@ -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; }