community: the soulseek link proof, one message to yourself links the account and one question links anyone who answers

This commit is contained in:
KeelrLeek committed 2026-09-02 14:27:54 -05:00
1 parent cf9558cf26
commit 6ed6f5b8f8
10 files changed
+1156 -593

No files matched your search

Generated
+470 -587
View File
File diff suppressed because it is too large. Load diff
+9 -2
View File
@@ -15,6 +15,8 @@
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
pub mod slsk_link;
pub const SCHEMA_VERSION: u32 = 1;
pub const SIGN_DOMAIN: &str = "soulstream.profile/v1";
@@ -76,7 +78,7 @@ pub fn profile_sign_payload(p: &ProfileState) -> Vec<u8> {
.expect("serializes")
}
fn signable(domain: &str, payload: &[u8]) -> Vec<u8> {
pub(crate) fn signable(domain: &str, payload: &[u8]) -> Vec<u8> {
let mut msg = Vec::new();
msg.extend_from_slice(b"soulstream.sig.v1");
msg.push(0);
@@ -87,9 +89,14 @@ fn signable(domain: &str, payload: &[u8]) -> Vec<u8> {
}
pub fn verify_sig(public: &[u8; 32], payload: &[u8], sig: &[u8]) -> bool {
verify_sig_in(SIGN_DOMAIN, public, payload, sig)
}
/// The same check under an explicit domain — the link and friend families.
pub fn verify_sig_in(domain: &str, public: &[u8; 32], payload: &[u8], sig: &[u8]) -> bool {
let Ok(vk) = VerifyingKey::from_bytes(public) else { return false };
let Ok(sig) = Signature::from_slice(sig) else { return false };
vk.verify(&signable(SIGN_DOMAIN, payload), &sig).is_ok()
vk.verify(&signable(domain, payload), &sig).is_ok()
}
pub fn make_profile_params(owner: &[u8; 32]) -> Vec<u8> {
+194
View File
@@ -0,0 +1,194 @@
//! The Soulseek link proof — F4 §7.4's exact claim shape and verifier,
//! transport-agnostic. Byte-compatibility matters here as everywhere in
//! this crate: the claim is what the delegate signs and what another
//! SoulStream client parses out of a Soulseek private message, so the field
//! order is the wire.
//!
//! Sign convention: the delegate is handed blake3(bincode(sig-body)) — the
//! F3/F4 convention — and `verify_claim` mirrors it. Verifiers answer from
//! signatures, nonces and clocks, never from names.
use serde::{Deserialize, Serialize};
pub const SLSK_LINK_DOMAIN: &str = "soulstream.identity-link.soulseek/v1";
/// Exact-match normalization: trim + casefold. Nothing fuzzier — a claim is
/// for ONE account spelling class, never a lookalike.
pub fn normalize_username(name: &str) -> String {
name.trim().to_lowercase()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SlskLinkClaim {
pub claim_type: String,
pub soulstream_public_key: [u8; 32],
pub soulseek_server_scope: String,
pub soulseek_username_normalized: String,
/// The verifier's nonce: an answer binds to the question that was asked.
pub nonce: [u8; 16],
pub issued_at: u64,
pub expires_at: u64,
pub sig: Vec<u8>,
}
#[derive(Serialize)]
struct SigBody<'a> {
claim_type: &'a str,
soulstream_public_key: &'a [u8; 32],
soulseek_server_scope: &'a str,
soulseek_username_normalized: &'a str,
nonce: &'a [u8; 16],
issued_at: u64,
expires_at: u64,
}
pub fn claim_sig_body(c: &SlskLinkClaim) -> Vec<u8> {
bincode::serialize(&SigBody {
claim_type: &c.claim_type,
soulstream_public_key: &c.soulstream_public_key,
soulseek_server_scope: &c.soulseek_server_scope,
soulseek_username_normalized: &c.soulseek_username_normalized,
nonce: &c.nonce,
issued_at: c.issued_at,
expires_at: c.expires_at,
})
.expect("serializes")
}
/// What the delegate is handed to sign: blake3(sig-body), 32 bytes.
pub fn claim_sign_payload(c: &SlskLinkClaim) -> Vec<u8> {
blake3::hash(&claim_sig_body(c)).as_bytes().to_vec()
}
pub fn verify_claim(c: &SlskLinkClaim) -> bool {
c.claim_type == SLSK_LINK_DOMAIN
&& crate::verify_sig_in(
SLSK_LINK_DOMAIN,
&c.soulstream_public_key,
&claim_sign_payload(c),
&c.sig,
)
}
#[derive(Debug, PartialEq, Eq)]
pub enum LinkRefused {
WrongType,
BadSignature,
WrongScope,
NonceMismatch,
WrongSender { got: String },
WrongAccount,
Expired,
NotYetValid,
}
impl core::fmt::Display for LinkRefused {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
LinkRefused::WrongType => write!(f, "not a Soulseek link claim"),
LinkRefused::BadSignature => write!(f, "claim signature does not verify"),
LinkRefused::WrongScope => write!(f, "claim is for a different Soulseek server"),
LinkRefused::NonceMismatch => write!(f, "answer does not carry the nonce that was asked"),
LinkRefused::WrongSender { got } => {
write!(f, "answer came from {got:?}, not the claimed account")
}
LinkRefused::WrongAccount => write!(f, "claim names an account that was not asked"),
LinkRefused::Expired => write!(f, "claim expired before it arrived"),
LinkRefused::NotYetValid => write!(f, "claim is dated in the future"),
}
}
}
/// Allowed clock skew between two clients, in seconds.
const SKEW_SECS: u64 = 300;
/// Verify an answer to OUR question. `expected_username` is who we asked
/// (normalized), `nonce` what we sent, `from_username` who the server says
/// answered — the one fact a Soulseek server vouches for.
pub fn check_response(
c: &SlskLinkClaim,
expected_username: &str,
nonce: &[u8; 16],
from_username: &str,
now: u64,
scope: &str,
) -> Result<(), LinkRefused> {
if c.claim_type != SLSK_LINK_DOMAIN {
return Err(LinkRefused::WrongType);
}
if !verify_claim(c) {
return Err(LinkRefused::BadSignature);
}
if c.soulseek_server_scope != scope {
return Err(LinkRefused::WrongScope);
}
if &c.nonce != nonce {
return Err(LinkRefused::NonceMismatch);
}
let from = normalize_username(from_username);
if from != c.soulseek_username_normalized {
return Err(LinkRefused::WrongSender { got: from });
}
if c.soulseek_username_normalized != expected_username {
return Err(LinkRefused::WrongAccount);
}
if now > c.expires_at {
return Err(LinkRefused::Expired);
}
if c.issued_at > now + SKEW_SECS {
return Err(LinkRefused::NotYetValid);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{Signer, SigningKey};
const SCOPE: &str = "server.slsknet.org:2242";
fn signed(key: &SigningKey, user: &str, nonce: [u8; 16], issued: u64, expires: u64) -> SlskLinkClaim {
let mut c = SlskLinkClaim {
claim_type: SLSK_LINK_DOMAIN.into(),
soulstream_public_key: key.verifying_key().to_bytes(),
soulseek_server_scope: SCOPE.into(),
soulseek_username_normalized: normalize_username(user),
nonce,
issued_at: issued,
expires_at: expires,
sig: vec![],
};
c.sig = key
.sign(&crate::signable(SLSK_LINK_DOMAIN, &claim_sign_payload(&c)))
.to_bytes()
.to_vec();
c
}
#[test]
fn the_happy_path_and_every_refusal() {
let key = SigningKey::from_bytes(&[9u8; 32]);
let nonce = [7u8; 16];
let c = signed(&key, "ZooCone", nonce, 1000, 1300);
assert!(verify_claim(&c));
assert_eq!(check_response(&c, "zoocone", &nonce, "ZooCone", 1100, SCOPE), Ok(()));
// the server said someone else answered
assert!(matches!(
check_response(&c, "zoocone", &nonce, "mallory", 1100, SCOPE),
Err(LinkRefused::WrongSender { .. })
));
// we asked a different account than the claim names
assert_eq!(check_response(&c, "someone", &nonce, "zoocone", 1100, SCOPE), Err(LinkRefused::WrongAccount));
// a replayed answer to an older question
assert_eq!(check_response(&c, "zoocone", &[1u8; 16], "zoocone", 1100, SCOPE), Err(LinkRefused::NonceMismatch));
// late
assert_eq!(check_response(&c, "zoocone", &nonce, "zoocone", 1301, SCOPE), Err(LinkRefused::Expired));
// another server's account
assert_eq!(check_response(&c, "zoocone", &nonce, "zoocone", 1100, "other:1"), Err(LinkRefused::WrongScope));
// tampered after signing
let mut t = c.clone();
t.soulseek_username_normalized = "mallory".into();
assert_eq!(check_response(&t, "mallory", &nonce, "mallory", 1100, SCOPE), Err(LinkRefused::BadSignature));
}
}
+1
View File
@@ -31,6 +31,7 @@ tokio.workspace = true
base64 = "0.22"
bincode = "1"
bs58 = "0.5"
rand = "0.8"
# Shazam signature packets (shazam.rs) carry a CRC-32 of their body.
crc32fast = "1"
# Inflate for the deno release zip (ytdlp.rs) — already in the lock via the
+5
View File
@@ -926,6 +926,11 @@ pub async fn connect_and_login(
username,
message,
} => {
// 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 {
continue;
}
let _ = event_app.emit(
"soulseek:private-message",
serde_json::json!({
+6
View File
@@ -21,6 +21,7 @@ mod provider_playlist;
mod preview;
mod setlist_scan;
mod shazam;
mod slsk_link;
mod smtc;
mod state;
mod thumbbar;
@@ -123,6 +124,11 @@ pub fn run() {
commands::slsk_user_info,
commands::slsk_user_interests,
commands::slsk_similar_users,
slsk_link::slsk_link_status,
slsk_link::slsk_link_self,
slsk_link::slsk_link_unlink,
slsk_link::slsk_link_check,
slsk_link::slsk_link_forget,
commands::buddy_list,
commands::buddy_add,
commands::buddy_remove,
+337
View File
@@ -0,0 +1,337 @@
//! 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(app
.path()
.app_data_dir()
.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.
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
}
// ---------------------------------------------------------------- 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))
}
+5
View File
@@ -151,6 +151,11 @@ export const slskBrowseUser = (username) => invoke("slsk_browse_user", { usernam
export const slskUserInfo = (username) => invoke("slsk_user_info", { username });
export const slskUserInterests = (username) => invoke("slsk_user_interests", { username });
export const slskSimilarUsers = () => invoke("slsk_similar_users");
export const slskLinkStatus = () => invoke("slsk_link_status");
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 buddyList = () => invoke("buddy_list");
export const buddyAdd = (username) => invoke("buddy_add", { username });
export const buddyRemove = (username) => invoke("buddy_remove", { username });
+55 -1
View File
@@ -59,6 +59,13 @@ export const community = $state({
interests: {},
/** The server's similar-users answer, or null before it was asked. */
similar: null,
/** slsk_link_status: our own verified link and everyone we verified. */
links: null,
/** username → the last failed ask's reason. */
linkErrors: {},
/** persona key → ProfileView | {none} | {error} | {pending}: other
* people's signed profiles, read once per session when surfaced. */
remoteProfiles: {},
/** key → true while an action runs (buttons disable on it). */
busy: {},
/** The last action's error, shown where it happened. */
@@ -303,7 +310,7 @@ export async function initCommunity() {
if (p.status === 1 || p.status === 2) noteSeen(p.username);
});
}
await Promise.all([refreshFreenet(), loadContacts()]);
await Promise.all([refreshFreenet(), loadContacts(), refreshLinks()]);
await refreshPersona();
}
@@ -337,6 +344,53 @@ export async function loadSimilar(force = false) {
}
}
// ---- the Soulseek link proof ----------------------------------------------------
export async function refreshLinks() {
try {
community.links = await api.slskLinkStatus();
} catch {
// stays null: the UI treats it as "not linked"
}
}
export const linkSelf = () =>
run("linkself", async () => {
community.links = await api.slskLinkSelf();
});
export const unlinkSelf = () =>
run("linkself", async () => {
community.links = await api.slskLinkUnlink();
});
/** Ask one person over Soulseek; success is remembered on the Rust side. */
export async function checkLink(username) {
community.busy["link:" + username] = true;
delete community.linkErrors[username];
try {
const k = await api.slskLinkCheck(username);
await refreshLinks();
return k;
} catch (e) {
community.linkErrors[username] = String(e?.message ?? e);
return null;
} finally {
delete community.busy["link:" + username];
}
}
export const linkFor = (username) => {
const n = (username ?? "").trim().toLowerCase();
return community.links?.others?.find((l) => l.username_normalized === n) ?? null;
};
export async function loadRemoteProfile(key, force = false) {
if (!key) return;
if (community.remoteProfiles[key] && !force) return;
community.remoteProfiles[key] = { pending: true };
try {
community.remoteProfiles[key] = (await api.profileGet(key)) ?? { none: true };
} catch (e) {
community.remoteProfiles[key] = { error: String(e?.message ?? e) };
}
}
// 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;
+74 -3
View File
@@ -58,6 +58,12 @@
personaKey,
loadUserInfo,
loadSimilar,
refreshLinks,
linkSelf,
unlinkSelf,
checkLink,
linkFor,
loadRemoteProfile,
} from "../lib/community.svelte.js";
onMount(() => {
@@ -100,6 +106,12 @@
const nodeUp = $derived(adapterConnected(community.freenet?.status));
const online = $derived(app.connection === "online");
const me = $derived(app.username ?? "");
const ownLink = $derived(community.links?.own ?? null);
/** The stored link is only "current" for the signed-in account and the
* persona this device holds now — a different login or key is unlinked. */
const ownLinkCurrent = $derived(
!!ownLink && !!key && !!me && ownLink.username_normalized === me.trim().toLowerCase() && ownLink.persona_key === key,
);
const display = (u) => community.contacts[u]?.petname || u;
const isContact = (u) => slsk.buddies.includes(u);
@@ -398,6 +410,12 @@
const drawerPhoto = $derived(
drawerInfo?.picture_png_base64 ? `data:image/png;base64,${drawerInfo.picture_png_base64}` : null,
);
const drawerLink = $derived(drawerUser ? linkFor(drawerUser) : null);
const drawerRemote = $derived(drawerLink ? community.remoteProfiles[drawerLink.persona_key] : null);
$effect(() => {
const lk = drawerLink;
if (lk) untrack(() => loadRemoteProfile(lk.persona_key));
});
// Discover's similar-users shelf asks the server once per session.
$effect(() => {
if (community.section === "discover" && online) untrack(() => loadSimilar());
@@ -460,6 +478,7 @@
<Icon name="globe" size={12} />
{#if me}<b>{me}</b> on Soulseek{:else}not signed in to Soulseek{/if}
<span class="chip {online ? 'ok' : 'off'}">{online ? "online" : "offline"}</span>
{#if ownLinkCurrent}<span class="chip acc" title="Verified through a Soulseek message on {new Date(ownLink.verified_at * 1000).toLocaleString()}"><Icon name="link" size={10} />linked</span>{/if}
</span>
</div>
</div>
@@ -771,6 +790,7 @@
</div>
<div class="sub">
<span class="src slsk"><Icon name="globe" size={10} />Soulseek</span>
{#if linkFor(p.u)}<span class="src ss" title="Verified SoulStream ID {fpShort(linkFor(p.u).persona_key)}"><Icon name="disc" size={10} />SoulStream</span>{/if}
{#if p.meta.group}<span>·</span><span>{p.meta.group}</span>{/if}
{#if p.st.file_count != null}<span>·</span><span>{fmtCount(p.st.file_count)} files</span>{/if}
{#if p.meta.note}<span>·</span><span class="note">{p.meta.note}</span>{/if}
@@ -1054,8 +1074,27 @@
<div class="t">Soulseek <span class="chip {online ? 'ok' : 'off'}">{online ? "online" : "offline"}</span></div>
<div class="s">
{#if me}Signed in as <b>{me}</b> · {fmtCount(network.shareFiles)} files shared.{:else}Not signed in — see Settings.{/if}
<br />Linking this username to your SoulStream identity by a signed proof is the next identity slice; until then the two are simply side by side.
<br />
{#if !key}
Create a SoulStream identity to link this account to it.
{:else if !me}
Sign in to Soulseek to link this account to your identity.
{:else if ownLinkCurrent}
<b>Linked to this identity</b> — proof verified {new Date(ownLink.verified_at * 1000).toLocaleString()} through a Soulseek message to yourself. Other SoulStream users who ask over Soulseek now get your signed ID; nothing is published.
{:else}
Not linked to your identity. Linking sends one message to yourself over Soulseek and verifies the signed answer — nothing is published, and from then on other SoulStream users who ask get your signed ID.
{/if}
</div>
{#if key && me}
<div class="noderow">
{#if ownLinkCurrent}
<button class="ax-ghost sm" onclick={() => unlinkSelf()} disabled={!!community.busy.linkself}>Unlink</button>
{:else}
<button class="ax-fill sm" onclick={() => linkSelf()} disabled={!online || !nodeUp || !!community.busy.linkself}><Icon name="link" size={12} /> {community.busy.linkself ? "Linking…" : "Link this account"}</button>
{#if !nodeUp}<span class="hint">needs the node connected</span>{/if}
{/if}
</div>
{/if}
</div>
</div>
<div class="netcard">
@@ -1201,8 +1240,37 @@
{/if}
</div>
<div class="dsec">
<h4>Signed SoulStream profile <span class="chip off">none presented</span></h4>
<p class="hint">No SoulStream profile has been presented for this person. That is normal — most Soulseek users have none, and the link between a Soulseek name and a SoulStream ID is a signed proof that lands with the next identity slice.</p>
{#if drawerLink}
<h4>Signed SoulStream profile <span class="chip ok"><Icon name="check" size={10} />verified {fmtWhen(drawerLink.verified_at * 1000)}</span></h4>
<div class="rp">
<Identicon seed={drawerLink.persona_key} size={44} photo={drawerRemote?.avatar_png_base64 ? `data:image/png;base64,${drawerRemote.avatar_png_base64}` : null} />
<div class="rptx">
{#if !drawerRemote || drawerRemote.pending}
<div class="nm">SoulStream ID {fpShort(drawerLink.persona_key)}</div>
<p class="hint"><span class="bl"></span> reading their profile from the network</p>
{:else if drawerRemote.error}
<div class="nm">SoulStream ID {fpShort(drawerLink.persona_key)}</div>
<p class="hint warn">Could not read their profile: {drawerRemote.error}</p>
{:else if drawerRemote.none}
<div class="nm">SoulStream ID {fpShort(drawerLink.persona_key)}</div>
<p class="hint">They proved their ID but have not published a profile yet.</p>
{:else}
<div class="nm">{drawerRemote.display_name || "(no name)"} <span class="src ss"><Icon name="disc" size={10} />SoulStream</span></div>
<div class="mono muted" style="font-size:11px">{fpShort(drawerLink.persona_key)} · revision {drawerRemote.sequence}</div>
{#if drawerRemote.bio}<p class="desc">{drawerRemote.bio}</p>{/if}
{#if drawerRemote.avatar_note && drawerRemote.avatar_note !== "none published"}<p class="hint">{drawerRemote.avatar_note}</p>{/if}
{/if}
</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>
{: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>
<div class="noderow">
<button class="ax-ghost sm" onclick={() => checkLink(drawerUser)} disabled={!online || !!community.busy["link:" + drawerUser]}><Icon name="link" size={12} /> {community.busy["link:" + drawerUser] ? "Asking… up to 30 s" : "Ask for their SoulStream ID"}</button>
</div>
{#if community.linkErrors[drawerUser]}<p class="hint" style="margin-top:6px">{community.linkErrors[drawerUser]}</p>{/if}
{/if}
</div>
<div class="dfoot">
A SoulStream badge means a compatible, signed profile was presented and verified against its key. It is not proof of who
@@ -1557,6 +1625,9 @@
.tag { font-size: 11px; font-weight: 700; padding: 3px 9px; border-radius: 99px; background: var(--surf-hi); color: var(--text-secondary); }
.tag.hate { text-decoration: line-through; opacity: 0.7; }
.desc { margin: 8px 0 0; font-size: 12.5px; line-height: 1.5; color: var(--text-secondary); white-space: pre-wrap; overflow-wrap: anywhere; max-height: 180px; overflow-y: auto; }
.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; }
.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; }