Files
SoulStream/src-tauri/src/slsk_link.rs
T

373 lines
13 KiB
Rust

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