holdings: a member of a Space publishes what they hold as room-salted album keys, sealed to the room, so "who has this" is answered while they are offline
This commit is contained in:
1 parent
08ca09586a
commit
a98c750908
12 files changed
+2955
-8
No files matched your search
Generated
+1449
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "soulstream-holdings-contract"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "The SoulStream holdings contract — one member's album list in one Space, a thin shell over persona_state::holdings. Built to wasm32-unknown-unknown and bundled into the app; its code hash is a commitment (see src-tauri/assets)."
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
freenet-stdlib = { version = "=0.8.5" }
|
||||
persona-state = { path = "../persona-state" }
|
||||
bincode = "1"
|
||||
blake3 = "1"
|
||||
|
||||
[features]
|
||||
default = ["freenet-main-contract", "contract"]
|
||||
freenet-main-contract = []
|
||||
contract = ["freenet-stdlib/contract"]
|
||||
trace = ["freenet-stdlib/trace"]
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = true
|
||||
|
||||
# Its own workspace: this crate targets wasm32 and must never join the
|
||||
# app's host build.
|
||||
[workspace]
|
||||
@@ -0,0 +1,132 @@
|
||||
//! The SoulStream holdings contract. Single writer: one member's latest
|
||||
//! signed album list in one Space, at the address (space id, member key).
|
||||
//! Merge is the pointer contract's total order, (sequence, blake3) —
|
||||
//! `persona_state::holdings::merge` decides, so every delivery order
|
||||
//! converges, and a stale replay cannot bring a withdrawn list back.
|
||||
//!
|
||||
//! The body is opaque here: sealed to the room's epoch key in a private
|
||||
//! Space, and the contract only ever checks the envelope and its signature.
|
||||
//!
|
||||
//! Build: `cargo build --release --target wasm32-unknown-unknown` in this
|
||||
//! directory; copy `target/wasm32-unknown-unknown/release/
|
||||
//! soulstream_holdings_contract.wasm` to `src-tauri/assets/holdings_contract.wasm`
|
||||
//! and re-pin the code hash in `src-tauri/src/holdings.rs`.
|
||||
|
||||
use freenet_stdlib::prelude::*;
|
||||
use persona_state::holdings as t;
|
||||
|
||||
pub struct Contract;
|
||||
|
||||
fn parts(parameters: &Parameters<'static>) -> Result<([u8; 16], [u8; 32]), ContractError> {
|
||||
t::params_parts(parameters.as_ref())
|
||||
.ok_or_else(|| ContractError::Other("params are not family+space+member".into()))
|
||||
}
|
||||
|
||||
fn summary_of(bytes: &[u8], s: &t::HoldingsState) -> Vec<u8> {
|
||||
let (seq, hash) = t::rank(bytes, s);
|
||||
let mut out = Vec::with_capacity(40);
|
||||
out.extend_from_slice(&seq.to_le_bytes());
|
||||
out.extend_from_slice(&hash);
|
||||
out
|
||||
}
|
||||
|
||||
#[contract]
|
||||
impl ContractInterface for Contract {
|
||||
fn validate_state(
|
||||
parameters: Parameters<'static>,
|
||||
state: State<'static>,
|
||||
_related: RelatedContracts<'static>,
|
||||
) -> Result<ValidateResult, ContractError> {
|
||||
let (space_id, member) = parts(¶meters)?;
|
||||
if state.as_ref().is_empty() {
|
||||
return Ok(ValidateResult::Valid);
|
||||
}
|
||||
match t::validate(&space_id, &member, state.as_ref()) {
|
||||
Ok(_) => Ok(ValidateResult::Valid),
|
||||
Err(e) => Err(ContractError::Other(format!("invalid holdings: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn update_state(
|
||||
parameters: Parameters<'static>,
|
||||
state: State<'static>,
|
||||
data: Vec<UpdateData<'static>>,
|
||||
) -> Result<UpdateModification<'static>, ContractError> {
|
||||
let (space_id, member) = parts(¶meters)?;
|
||||
let mut candidates: Vec<Vec<u8>> = Vec::new();
|
||||
if !state.as_ref().is_empty() {
|
||||
candidates.push(state.as_ref().to_vec());
|
||||
}
|
||||
let mut incoming_valid = 0usize;
|
||||
for ud in &data {
|
||||
let mut push = |bytes: &[u8]| {
|
||||
if bytes.is_empty() {
|
||||
return;
|
||||
}
|
||||
if t::validate(&space_id, &member, bytes).is_ok() {
|
||||
incoming_valid += 1;
|
||||
}
|
||||
candidates.push(bytes.to_vec());
|
||||
};
|
||||
match ud {
|
||||
UpdateData::State(s) => push(s.as_ref()),
|
||||
UpdateData::Delta(d) => push(d.as_ref()),
|
||||
UpdateData::StateAndDelta { state, delta } => {
|
||||
push(state.as_ref());
|
||||
push(delta.as_ref());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if incoming_valid == 0 && !data.is_empty() {
|
||||
return Err(ContractError::InvalidUpdate);
|
||||
}
|
||||
match t::merge(&space_id, &member, candidates.iter().map(|v| v.as_slice())) {
|
||||
Some(bytes) => Ok(UpdateModification::valid(State::from(bytes))),
|
||||
None => Ok(UpdateModification::valid(state)),
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_state(
|
||||
parameters: Parameters<'static>,
|
||||
state: State<'static>,
|
||||
) -> Result<StateSummary<'static>, ContractError> {
|
||||
let (space_id, member) = parts(¶meters)?;
|
||||
if state.as_ref().is_empty() {
|
||||
return Ok(StateSummary::from(Vec::new()));
|
||||
}
|
||||
let s = t::validate(&space_id, &member, state.as_ref())
|
||||
.map_err(|e| ContractError::Other(format!("invalid holdings: {e}")))?;
|
||||
Ok(StateSummary::from(summary_of(state.as_ref(), &s)))
|
||||
}
|
||||
|
||||
fn get_state_delta(
|
||||
parameters: Parameters<'static>,
|
||||
state: State<'static>,
|
||||
summary: StateSummary<'static>,
|
||||
) -> Result<StateDelta<'static>, ContractError> {
|
||||
let (space_id, member) = parts(¶meters)?;
|
||||
if state.as_ref().is_empty() {
|
||||
return Ok(StateDelta::from(Vec::new()));
|
||||
}
|
||||
let s = t::validate(&space_id, &member, state.as_ref())
|
||||
.map_err(|e| ContractError::Other(format!("invalid holdings: {e}")))?;
|
||||
let ours = t::rank(state.as_ref(), &s);
|
||||
let theirs = {
|
||||
let b = summary.as_ref();
|
||||
if b.len() == 40 {
|
||||
let seq = u64::from_le_bytes(b[..8].try_into().unwrap());
|
||||
let mut h = [0u8; 32];
|
||||
h.copy_from_slice(&b[8..40]);
|
||||
Some((seq, h))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
let newer = match theirs {
|
||||
Some(t) => ours > t,
|
||||
None => true,
|
||||
};
|
||||
Ok(StateDelta::from(if newer { state.as_ref().to_vec() } else { Vec::new() }))
|
||||
}
|
||||
}
|
||||
@@ -811,6 +811,14 @@ impl Library {
|
||||
Ok(rows.filter_map(|r| r.ok()).collect())
|
||||
}
|
||||
|
||||
/// Album id → MusicBrainz release id, for the albums that have one.
|
||||
pub fn album_mbids(&self) -> Result<std::collections::BTreeMap<i64, String>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT id, mbid FROM albums WHERE mbid IS NOT NULL AND mbid != ''")?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
|
||||
Ok(rows.filter_map(|r| r.ok()).collect())
|
||||
}
|
||||
|
||||
/// Per-album aggregates for the Library page: what the FILES add up to.
|
||||
/// One call for the whole library, because the page derives its stat row,
|
||||
/// its Format grouping and four of its sorts (length, size, bitrate,
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
//! 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<String>,
|
||||
/// Sorted by key, no duplicates — `encode_body` enforces it.
|
||||
pub entries: Vec<Holding>,
|
||||
}
|
||||
|
||||
/// 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<u8>,
|
||||
pub sig: Vec<u8>,
|
||||
}
|
||||
|
||||
#[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<u8> {
|
||||
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<Vec<u8>, 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<HoldingsBody> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<Vec<u8>> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<HoldingsState, Invalid> {
|
||||
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<Item = &'a [u8]>) -> Option<Vec<u8>> {
|
||||
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<u8>, 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<u8> {
|
||||
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::<Vec<_>>(), [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);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ pub mod playlist;
|
||||
pub mod inbox;
|
||||
pub mod space;
|
||||
pub mod proposals;
|
||||
pub mod holdings;
|
||||
|
||||
pub const SCHEMA_VERSION: u32 = 1;
|
||||
pub const SIGN_DOMAIN: &str = "soulstream.profile/v1";
|
||||
|
||||
Reference in new issue
Block a user