community: the soulseek link proof, one message to yourself links the account and one question links anyone who answers
This commit is contained in:
1 parent
cf9558cf26
commit
6ed6f5b8f8
10 files changed
+1156
-593
No files matched your search
@@ -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> {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user