forked from Keelrleek/SoulStream
195 lines
6.9 KiB
Rust
195 lines
6.9 KiB
Rust
//! 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));
|
|
}
|
|
}
|