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

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