//! Holdings — what a member of a Space holds, as album keys and never as //! files. One contract per (space, member): the member's latest signed list, //! sealed to the room's epoch key in a private Space. Merge is the pointer //! contract's total order, (sequence, blake3): a withdrawal is just a newer //! sequence with an empty body, and a stale replay cannot resurrect a list. //! //! An album is named by a key, not a name: blake3 over the room's id and the //! same normalised (artist, album) the library-overlap comparison uses, so a //! holding matches exactly when overlap would have said "you own this". The //! room id salts it — the same album hashes differently in every room, so //! two rooms' lists cannot be joined. A member can still learn whether you //! hold any album they can NAME, by hashing it and looking; that is the //! feature, and the switch's disclosure says so. //! //! Nothing here reads a library or a network; the app does both. use serde::{Deserialize, Serialize}; pub const SCHEMA_VERSION: u32 = 1; pub const PARAMS_FAMILY: &[u8] = b"soulstream.holdings.v1"; /// The Space family's domain, not one of its own: the identity delegate /// signs only under an allowlist, and a new domain would be a delegate /// generation (a migration event) for the one case — an owner signing their /// own list — that needs it. The signed payload's shape keeps a holdings /// signature from ever reading as a Space message or a checkpoint. pub const SIGN_DOMAIN: &str = crate::space::SIGN_DOMAIN; /// The contract's gate: envelope plus body, sealed or not. pub const MAX_STATE_BYTES: usize = 1024 * 1024; /// Entries per list. A `Holding` is at most 66 bytes on the wire, so this /// keeps a full list — with its username and the envelope — under the gate /// with room to spare. A library above it publishes its newest entries. pub const MAX_ENTRIES: usize = 14_000; pub const MAX_USERNAME_BYTES: usize = 64; pub const TIER_UNKNOWN: u8 = 0; pub const TIER_LOSSY: u8 = 1; pub const TIER_LOSSLESS: u8 = 2; /// One album, as the room sees it. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub struct Holding { /// `album_key` of the normalised (artist, album), salted with the room. pub key: [u8; 32], /// `mbid_key` when the library knows the release's MusicBrainz id. pub mbid: Option<[u8; 32]>, /// One word about quality: lossless, lossy, or unknown. Never a format, /// a bitrate, or a size. pub tier: u8, } /// The plaintext a list carries. Sealed in a private Space, so the Soulseek /// name inside it is readable by members of that epoch only — it is what /// lets "who has this" become "get it from them". #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct HoldingsBody { pub schema_version: u32, pub slsk_username: Option, /// Sorted by key, no duplicates — `encode_body` enforces it. pub entries: Vec, } /// The contract state: the member's latest list, signed by the key they /// wear in the room. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct HoldingsState { pub schema_version: u32, pub space_id: [u8; 16], pub member: [u8; 32], pub sequence: u64, /// The epoch whose key sealed `body`; readers look their key up by it. pub epoch: u64, /// False in a public Space: `body` is the plain encoding. pub sealed: bool, pub published_at: u64, /// Withdrawn: the list is gone from current state (`body` empty). Copies /// members already read stay with them — never claim erasure. pub withdrawn: bool, pub body: Vec, pub sig: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum Invalid { Oversized(usize), Malformed, WrongSchema(u32), WrongAddress, BadSignature, TooManyEntries(usize), FieldTooLong(&'static str, usize), } impl core::fmt::Display for Invalid { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { Invalid::Oversized(n) => write!(f, "{n} bytes is over the cap"), Invalid::Malformed => write!(f, "does not decode"), Invalid::WrongSchema(v) => write!(f, "schema {v} is not {SCHEMA_VERSION}"), Invalid::WrongAddress => write!(f, "names another room or member"), Invalid::BadSignature => write!(f, "the signature does not verify"), Invalid::TooManyEntries(n) => write!(f, "{n} entries is over the cap of {MAX_ENTRIES}"), Invalid::FieldTooLong(what, n) => write!(f, "{what} is {n} bytes, over its cap"), } } } // ---------------------------------------------------------------- params pub fn make_params(space_id: &[u8; 16], member: &[u8; 32]) -> Vec { let mut p = Vec::with_capacity(PARAMS_FAMILY.len() + 48); p.extend_from_slice(PARAMS_FAMILY); p.extend_from_slice(space_id); p.extend_from_slice(member); p } pub fn params_parts(params: &[u8]) -> Option<([u8; 16], [u8; 32])> { let t = PARAMS_FAMILY.len(); if params.len() != t + 48 || ¶ms[..t] != PARAMS_FAMILY { return None; } Some((params[t..t + 16].try_into().unwrap(), params[t + 16..t + 48].try_into().unwrap())) } // ---------------------------------------------------------------- keys /// The key an album is held under in ONE room. `artist` and `album` are /// the overlap normalisation's output (lowercase alphanumerics, brackets /// gone, a leading "the" and a filing year dropped) — the caller's job, so /// a wasm build of this module never carries the normaliser. pub fn album_key(space_id: &[u8; 16], artist: &str, album: &str) -> [u8; 32] { let mut h = blake3::Hasher::new_derive_key("soulstream.holding.v1"); h.update(space_id); h.update(artist.as_bytes()); h.update(&[0]); h.update(album.as_bytes()); *h.finalize().as_bytes() } /// The same album by its MusicBrainz release id, for the day identity is /// anchored rather than named. pub fn mbid_key(space_id: &[u8; 16], mbid: &str) -> [u8; 32] { let mut h = blake3::Hasher::new_derive_key("soulstream.holding-mbid.v1"); h.update(space_id); h.update(mbid.trim().to_lowercase().as_bytes()); *h.finalize().as_bytes() } // ---------------------------------------------------------------- body /// Encode a list for the wire: sorted by key, duplicates folded (the better /// tier wins), caps enforced. pub fn encode_body(mut b: HoldingsBody) -> Result, Invalid> { if let Some(u) = &b.slsk_username { if u.len() > MAX_USERNAME_BYTES { return Err(Invalid::FieldTooLong("slsk_username", u.len())); } } b.schema_version = SCHEMA_VERSION; b.entries.sort_by(|x, y| x.key.cmp(&y.key).then(y.tier.cmp(&x.tier))); b.entries.dedup_by(|later, earlier| { if later.key != earlier.key { return false; } // The first of a run has the higher tier already; keep its mbid if // the later one had none. if earlier.mbid.is_none() { earlier.mbid = later.mbid; } true }); if b.entries.len() > MAX_ENTRIES { return Err(Invalid::TooManyEntries(b.entries.len())); } bincode::serialize(&b).map_err(|_| Invalid::Malformed) } pub fn decode_body(bytes: &[u8]) -> Option { let b: HoldingsBody = bincode::deserialize(bytes).ok()?; if b.schema_version != SCHEMA_VERSION || b.entries.len() > MAX_ENTRIES { return None; } Some(b) } // ---------------------------------------------------------------- sealing fn body_key(space_key: &[u8; 32]) -> [u8; 32] { blake3::derive_key("soulstream.holdings-body.v1", space_key) } /// The AAD binds a body to its envelope: another member's sealed bytes /// under this address, or this member's older sequence, do not open. fn aad(s: &HoldingsState) -> Vec { let mut v = Vec::with_capacity(64); v.extend_from_slice(&s.space_id); v.extend_from_slice(&s.member); v.extend_from_slice(&s.sequence.to_le_bytes()); v.extend_from_slice(&s.epoch.to_le_bytes()); v } /// Unique per (member, sequence) under one room — a sequence is never /// published twice by the same key, which is the nonce's whole contract. fn nonce(s: &HoldingsState) -> [u8; 12] { blake3::hash(&aad(s)).as_bytes()[..12].try_into().unwrap() } /// Seal `s.body` (the plain encoding going in) under the room's epoch key. /// Call BEFORE signing; the signature covers the sealed bytes. pub fn seal(space_key: &[u8; 32], s: &HoldingsState) -> Vec { use chacha20poly1305::aead::{Aead, KeyInit, Payload}; use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; let key = body_key(space_key); ChaCha20Poly1305::new(Key::from_slice(&key)) .encrypt(Nonce::from_slice(&nonce(s)), Payload { msg: &s.body, aad: &aad(s) }) .expect("seal") } /// Open a sealed body. None = wrong key for this epoch, or tampered. pub fn open(space_key: &[u8; 32], s: &HoldingsState) -> Option> { use chacha20poly1305::aead::{Aead, KeyInit, Payload}; use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; let key = body_key(space_key); ChaCha20Poly1305::new(Key::from_slice(&key)) .decrypt(Nonce::from_slice(&nonce(s)), Payload { msg: &s.body, aad: &aad(s) }) .ok() } // ---------------------------------------------------------------- signing #[derive(Serialize)] struct SigBody<'a> { schema_version: u32, space_id: &'a [u8; 16], member: &'a [u8; 32], sequence: u64, epoch: u64, sealed: bool, published_at: u64, withdrawn: bool, body: &'a [u8], } /// What the member signs: blake3 of the envelope, the sealed body included. pub fn sign_payload(s: &HoldingsState) -> Vec { let body = bincode::serialize(&SigBody { schema_version: s.schema_version, space_id: &s.space_id, member: &s.member, sequence: s.sequence, epoch: s.epoch, sealed: s.sealed, published_at: s.published_at, withdrawn: s.withdrawn, body: &s.body, }) .expect("sig body serializes"); blake3::hash(&body).as_bytes().to_vec() } /// Sign as the key worn in the room (a room-local member key, or an /// owner's persona through the delegate — the app decides which). pub fn sign_with(secret: &[u8; 32], s: &HoldingsState) -> Vec { crate::sign_local(SIGN_DOMAIN, secret, &sign_payload(s)) } // ---------------------------------------------------------------- validate / merge /// Strict, in the plan's order: size → parse → schema → address → caps → /// signature. A state that passes is exactly what its member published. pub fn validate(space_id: &[u8; 16], member: &[u8; 32], bytes: &[u8]) -> Result { if bytes.len() > MAX_STATE_BYTES { return Err(Invalid::Oversized(bytes.len())); } let s: HoldingsState = bincode::deserialize(bytes).map_err(|_| Invalid::Malformed)?; if s.schema_version != SCHEMA_VERSION { return Err(Invalid::WrongSchema(s.schema_version)); } if &s.space_id != space_id || &s.member != member { return Err(Invalid::WrongAddress); } if s.withdrawn && !s.body.is_empty() { return Err(Invalid::Malformed); } if !crate::verify_sig_in(SIGN_DOMAIN, member, &sign_payload(&s), &s.sig) { return Err(Invalid::BadSignature); } Ok(s) } /// The order every replica agrees on: highest sequence, then the byte hash. pub fn rank(bytes: &[u8], s: &HoldingsState) -> (u64, [u8; 32]) { (s.sequence, *blake3::hash(bytes).as_bytes()) } /// The winning candidate's bytes, or None when nothing valid was offered. pub fn merge<'a>(space_id: &[u8; 16], member: &[u8; 32], candidates: impl IntoIterator) -> Option> { let mut best: Option<((u64, [u8; 32]), &[u8])> = None; for bytes in candidates { let Ok(s) = validate(space_id, member, bytes) else { continue }; let r = rank(bytes, &s); if best.as_ref().is_none_or(|(b, _)| r > *b) { best = Some((r, bytes)); } } best.map(|(_, b)| b.to_vec()) } #[cfg(test)] mod tests { use super::*; fn secret(n: u8) -> [u8; 32] { [n; 32] } fn state(secret: &[u8; 32], seq: u64, body: Vec, withdrawn: bool) -> HoldingsState { let mut s = HoldingsState { schema_version: SCHEMA_VERSION, space_id: [3; 16], member: crate::public_of(secret), sequence: seq, epoch: 4, sealed: false, published_at: 1_700_000_000, withdrawn, body, sig: vec![], }; s.sig = sign_with(secret, &s); s } fn bytes(s: &HoldingsState) -> Vec { bincode::serialize(s).unwrap() } fn holding(n: u8, tier: u8) -> Holding { Holding { key: [n; 32], mbid: None, tier } } #[test] fn a_list_round_trips_and_refuses_tampering() { let k = secret(7); let member = crate::public_of(&k); let body = encode_body(HoldingsBody { schema_version: 0, slsk_username: Some("zoocone".into()), entries: vec![holding(2, TIER_LOSSY), holding(1, TIER_LOSSLESS)] }).unwrap(); let s = state(&k, 1, body.clone(), false); let b = bytes(&s); let back = validate(&[3; 16], &member, &b).unwrap(); assert_eq!(back, s); let d = decode_body(&back.body).unwrap(); assert_eq!(d.entries.iter().map(|h| h.key[0]).collect::>(), [1, 2], "sorted by key"); assert_eq!(d.slsk_username.as_deref(), Some("zoocone")); // another room, another member, a flipped byte, a foreign signature assert_eq!(validate(&[4; 16], &member, &b), Err(Invalid::WrongAddress)); assert_eq!(validate(&[3; 16], &crate::public_of(&secret(8)), &b), Err(Invalid::WrongAddress)); let mut t = s.clone(); t.sequence = 9; assert_eq!(validate(&[3; 16], &member, &bytes(&t)), Err(Invalid::BadSignature)); let mut f = s.clone(); f.sig = sign_with(&secret(8), &f); assert_eq!(validate(&[3; 16], &member, &bytes(&f)), Err(Invalid::BadSignature)); let mut w = state(&k, 2, body, true); w.sig = sign_with(&k, &w); assert_eq!(validate(&[3; 16], &member, &bytes(&w)), Err(Invalid::Malformed), "withdrawn keeps no body"); } #[test] fn merge_is_by_sequence_then_hash_and_a_replay_cannot_resurrect() { let k = secret(5); let member = crate::public_of(&k); let one = bytes(&state(&k, 1, vec![1], false)); let two = bytes(&state(&k, 2, vec![2], false)); let gone = bytes(&state(&k, 3, vec![], true)); let stranger = bytes(&state(&secret(6), 9, vec![9], false)); let sid = [3; 16]; assert_eq!(merge(&sid, &member, [one.as_slice(), two.as_slice()]), Some(two.clone())); assert_eq!(merge(&sid, &member, [two.as_slice(), one.as_slice()]), Some(two.clone())); assert_eq!(merge(&sid, &member, [gone.as_slice(), two.as_slice(), one.as_slice()]), Some(gone.clone())); assert_eq!(merge(&sid, &member, [stranger.as_slice()]), None); assert_eq!(merge(&sid, &member, [stranger.as_slice(), one.as_slice()]), Some(one.clone())); // same sequence twice: the hash breaks the tie the same way in any order let a = bytes(&state(&k, 4, vec![1, 1], false)); let b = bytes(&state(&k, 4, vec![2, 2], false)); let ab = merge(&sid, &member, [a.as_slice(), b.as_slice()]); let ba = merge(&sid, &member, [b.as_slice(), a.as_slice()]); assert_eq!(ab, ba); assert!(ab == Some(a.clone()) || ab == Some(b.clone())); } #[test] fn a_private_list_opens_only_with_the_epoch_key_and_only_in_its_envelope() { let k = secret(2); let room_key = [11u8; 32]; let plain = encode_body(HoldingsBody { schema_version: 0, slsk_username: None, entries: vec![holding(1, TIER_LOSSLESS)] }).unwrap(); let mut s = state(&k, 1, plain.clone(), false); s.sealed = true; s.body = seal(&room_key, &s); s.sig = sign_with(&k, &s); assert_ne!(s.body, plain); assert_eq!(open(&room_key, &s), Some(plain.clone())); assert_eq!(open(&[12u8; 32], &s), None); // the same ciphertext under another sequence is not this list let mut moved = s.clone(); moved.sequence = 2; assert_eq!(open(&room_key, &moved), None); // validate does not need the key: the envelope stands on its own assert!(validate(&[3; 16], &crate::public_of(&k), &bytes(&s)).is_ok()); } #[test] fn keys_are_salted_by_room_and_the_body_folds_duplicates_to_the_better_tier() { let a = album_key(&[1; 16], "broadcast", "tenderbuttons"); let b = album_key(&[2; 16], "broadcast", "tenderbuttons"); assert_ne!(a, b); assert_eq!(a, album_key(&[1; 16], "broadcast", "tenderbuttons")); assert_ne!(a, album_key(&[1; 16], "broadcasttender", "buttons"), "the separator keeps the two names apart"); assert_ne!(mbid_key(&[1; 16], "abc"), mbid_key(&[2; 16], "ABC")); assert_eq!(mbid_key(&[1; 16], " abc "), mbid_key(&[1; 16], "ABC")); let body = encode_body(HoldingsBody { schema_version: 0, slsk_username: None, entries: vec![ Holding { key: [1; 32], mbid: None, tier: TIER_LOSSY }, Holding { key: [1; 32], mbid: Some([9; 32]), tier: TIER_LOSSLESS }, Holding { key: [1; 32], mbid: None, tier: TIER_UNKNOWN }, ], }) .unwrap(); let d = decode_body(&body).unwrap(); assert_eq!(d.entries, vec![Holding { key: [1; 32], mbid: Some([9; 32]), tier: TIER_LOSSLESS }]); let too_many = HoldingsBody { schema_version: 0, slsk_username: None, entries: (0..MAX_ENTRIES + 1).map(|i| Holding { key: blake3::hash(&i.to_le_bytes()).into(), mbid: None, tier: 0 }).collect() }; assert!(matches!(encode_body(too_many), Err(Invalid::TooManyEntries(_)))); let long = HoldingsBody { schema_version: 0, slsk_username: Some("x".repeat(65)), entries: vec![] }; assert!(matches!(encode_body(long), Err(Invalid::FieldTooLong("slsk_username", 65)))); } #[test] fn params_round_trip() { let p = make_params(&[3; 16], &[4; 32]); assert_eq!(params_parts(&p), Some(([3; 16], [4; 32]))); assert_eq!(params_parts(&p[..p.len() - 1]), None); assert_eq!(params_parts(&crate::space::make_params(&[4; 32], &[3; 16])), None); } }