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

+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)
}