//! 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, } #[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 { 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 { 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)); } }