forked from Keelrleek/SoulStream
373 lines
13 KiB
Rust
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))
|
|
}
|