//! Playlist publication — F3's exact shapes: an immutable, content-addressed //! revision (the portable manifest: entry occurrences with recording ids and //! display fallbacks, never a path) and an owner-signed pointer to the //! current revision per (owner, playlist uuid). BYTE-COMPATIBILITY IS THE //! CONTRACT: both shapes are what the bundled revision/pointer wasm validate //! (built from research/freenet-f3, code hashes pinned by the app's tests). //! //! Signing: the delegate caps payloads, so what it signs is blake3(sig-body) //! under `soulstream.playlist/v1` — the F3 convention, mirrored here. use serde::{Deserialize, Serialize}; pub const SIGN_DOMAIN: &str = "soulstream.playlist/v1"; pub const SCHEMA_VERSION: u32 = 1; pub const REV_PARAMS_FAMILY: &[u8] = b"soulstream.playlist-rev.v1"; pub const PTR_PARAMS_FAMILY: &[u8] = b"soulstream.playlist-ptr.v1"; pub const MAX_MANIFEST_BYTES: usize = 2 * 1024 * 1024; pub const MAX_ENTRIES: usize = 5000; pub const MAX_NAME_BYTES: usize = 128; pub const MAX_DESC_BYTES: usize = 2048; pub const MAX_NOTE_BYTES: usize = 280; pub const MAX_FIELD_BYTES: usize = 512; pub const MAX_POINTER_BYTES: usize = 1024; // ------------------------------------------------------------ manifest #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RecordingRef { pub musicbrainz_recording_id: Option, pub isrcs: Vec, pub deezer_id: Option, pub apple_music_id: Option, pub title: String, pub artists: Vec, pub album: String, pub duration_ms: Option, pub release_year: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct EntryV1 { /// Occurrence id — duplicates of the same recording stay distinct. pub entry_id: [u8; 16], pub position: u32, pub recording: RecordingRef, pub note: Option, /// Free-text resolution hint ("artist title"), NEVER a remote path. pub slsk_hint: Option, } /// Bounded artwork REFERENCE — hash + type + size only, no bytes. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ArtworkRef { pub content_hash: [u8; 32], pub media_type: String, pub byte_length: u32, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ManifestV1 { pub schema_version: u32, pub owner: [u8; 32], pub playlist_uuid: [u8; 16], pub revision_seq: u64, /// Previous revision's content hash, None for the first. pub prev_revision_hash: Option<[u8; 32]>, pub name: String, pub description: String, pub artwork: Option, pub entries: Vec, pub sig: Vec, } #[derive(Serialize)] struct ManifestSigBody<'a> { schema_version: u32, owner: &'a [u8; 32], playlist_uuid: &'a [u8; 16], revision_seq: u64, prev_revision_hash: &'a Option<[u8; 32]>, name: &'a str, description: &'a str, artwork: &'a Option, entries: &'a [EntryV1], } /// The 32 bytes the delegate signs: blake3 of the serialized sig body. pub fn manifest_sign_payload(m: &ManifestV1) -> Vec { let body = bincode::serialize(&ManifestSigBody { schema_version: m.schema_version, owner: &m.owner, playlist_uuid: &m.playlist_uuid, revision_seq: m.revision_seq, prev_revision_hash: &m.prev_revision_hash, name: &m.name, description: &m.description, artwork: &m.artwork, entries: &m.entries, }) .expect("sig body serializes"); blake3::hash(&body).as_bytes().to_vec() } fn verify(owner: &[u8; 32], payload: &[u8], sig: &[u8]) -> bool { crate::verify_sig_in(SIGN_DOMAIN, owner, payload, sig) } #[derive(Debug, PartialEq, Eq)] pub enum Invalid { Oversized(usize), HashMismatch, Malformed, WrongSchema(u32), WrongOwner, TooManyEntries(usize), FieldTooLong(&'static str, usize), BadSignature, } 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::HashMismatch => write!(f, "bytes do not hash to their address"), Invalid::Malformed => write!(f, "bytes do not parse"), Invalid::WrongSchema(v) => write!(f, "unknown schema {v}"), Invalid::WrongOwner => write!(f, "owner differs from the address"), Invalid::TooManyEntries(n) => write!(f, "{n} entries is over the cap"), Invalid::FieldTooLong(w, n) => write!(f, "{w} is {n} bytes, over its cap"), Invalid::BadSignature => write!(f, "owner signature does not verify"), } } } pub fn make_rev_params(owner: &[u8; 32], content_hash: &[u8; 32]) -> Vec { let mut p = Vec::with_capacity(REV_PARAMS_FAMILY.len() + 64); p.extend_from_slice(REV_PARAMS_FAMILY); p.extend_from_slice(owner); p.extend_from_slice(content_hash); p } /// Strict verification in the plan's order: size → content hash → parse → /// schema → owner → caps → signature. All BEFORE any preview use. pub fn validate_revision( owner: &[u8; 32], content_hash: &[u8; 32], bytes: &[u8], ) -> Result { if bytes.len() > MAX_MANIFEST_BYTES { return Err(Invalid::Oversized(bytes.len())); } if blake3::hash(bytes).as_bytes() != content_hash { return Err(Invalid::HashMismatch); } let m: ManifestV1 = bincode::deserialize(bytes).map_err(|_| Invalid::Malformed)?; if m.schema_version != SCHEMA_VERSION { return Err(Invalid::WrongSchema(m.schema_version)); } if &m.owner != owner { return Err(Invalid::WrongOwner); } if m.entries.len() > MAX_ENTRIES { return Err(Invalid::TooManyEntries(m.entries.len())); } if m.name.len() > MAX_NAME_BYTES { return Err(Invalid::FieldTooLong("name", m.name.len())); } if m.description.len() > MAX_DESC_BYTES { return Err(Invalid::FieldTooLong("description", m.description.len())); } for e in &m.entries { if e.note.as_deref().map(str::len).unwrap_or(0) > MAX_NOTE_BYTES { return Err(Invalid::FieldTooLong("note", e.note.as_ref().unwrap().len())); } for s in [&e.recording.title, &e.recording.album] { if s.len() > MAX_FIELD_BYTES { return Err(Invalid::FieldTooLong("recording field", s.len())); } } if e.slsk_hint.as_deref().map(str::len).unwrap_or(0) > MAX_FIELD_BYTES { return Err(Invalid::FieldTooLong("slsk_hint", e.slsk_hint.as_ref().unwrap().len())); } } if !verify(owner, &manifest_sign_payload(&m), &m.sig) { return Err(Invalid::BadSignature); } Ok(m) } // ------------------------------------------------------------ pointer #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RevisionRef { pub content_hash: [u8; 32], pub revision_seq: u64, pub entry_count: u32, pub byte_length: u32, pub name: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PointerState { pub schema_version: u32, pub owner: [u8; 32], pub playlist_uuid: [u8; 16], pub sequence: u64, /// None + withdrawn = "removed from current state" — old revisions /// remain fetchable; never claim network erasure. pub current: Option, pub withdrawn: bool, pub sig: Vec, } #[derive(Serialize)] struct PointerSigBody<'a> { schema_version: u32, owner: &'a [u8; 32], playlist_uuid: &'a [u8; 16], sequence: u64, current: &'a Option, withdrawn: bool, } pub fn pointer_sign_payload(p: &PointerState) -> Vec { let body = bincode::serialize(&PointerSigBody { schema_version: p.schema_version, owner: &p.owner, playlist_uuid: &p.playlist_uuid, sequence: p.sequence, current: &p.current, withdrawn: p.withdrawn, }) .expect("sig body serializes"); blake3::hash(&body).as_bytes().to_vec() } pub fn make_ptr_params(owner: &[u8; 32], playlist_uuid: &[u8; 16]) -> Vec { let mut p = Vec::with_capacity(PTR_PARAMS_FAMILY.len() + 48); p.extend_from_slice(PTR_PARAMS_FAMILY); p.extend_from_slice(owner); p.extend_from_slice(playlist_uuid); p } pub fn validate_pointer( owner: &[u8; 32], uuid: &[u8; 16], bytes: &[u8], ) -> Result { if bytes.len() > MAX_POINTER_BYTES { return Err(Invalid::Oversized(bytes.len())); } let p: PointerState = bincode::deserialize(bytes).map_err(|_| Invalid::Malformed)?; if p.schema_version != SCHEMA_VERSION { return Err(Invalid::WrongSchema(p.schema_version)); } if &p.owner != owner || &p.playlist_uuid != uuid { return Err(Invalid::WrongOwner); } if let Some(r) = &p.current { if r.name.len() > MAX_NAME_BYTES { return Err(Invalid::FieldTooLong("pointer name", r.name.len())); } } if !verify(owner, &pointer_sign_payload(&p), &p.sig) { return Err(Invalid::BadSignature); } Ok(p) } #[cfg(test)] mod tests { use super::*; use ed25519_dalek::{Signer, SigningKey}; fn sign(key: &SigningKey, payload: &[u8]) -> Vec { key.sign(&crate::signable(SIGN_DOMAIN, payload)).to_bytes().to_vec() } #[test] fn a_revision_and_its_pointer_round_trip_and_refuse_tampering() { let key = SigningKey::from_bytes(&[5u8; 32]); let owner = key.verifying_key().to_bytes(); let mut m = ManifestV1 { schema_version: SCHEMA_VERSION, owner, playlist_uuid: [9; 16], revision_seq: 1, prev_revision_hash: None, name: "Rainy Window".into(), description: "long fades".into(), artwork: None, entries: vec![EntryV1 { entry_id: [1; 16], position: 0, recording: RecordingRef { musicbrainz_recording_id: None, isrcs: vec![], deezer_id: None, apple_music_id: None, title: "Airbag".into(), artists: vec!["Radiohead".into()], album: "OK Computer".into(), duration_ms: Some(284_000), release_year: Some(1997), }, note: None, slsk_hint: Some("radiohead airbag".into()), }], sig: vec![], }; m.sig = sign(&key, &manifest_sign_payload(&m)); let bytes = bincode::serialize(&m).unwrap(); let hash = *blake3::hash(&bytes).as_bytes(); assert_eq!(validate_revision(&owner, &hash, &bytes).unwrap(), m); // content addressing: a flipped byte trips the hash BEFORE parse let mut flipped = bytes.clone(); flipped[10] ^= 1; assert_eq!(validate_revision(&owner, &hash, &flipped), Err(Invalid::HashMismatch)); // a re-signed edit by the wrong key is a bad signature let mut edited = m.clone(); edited.name = "Sunny Window".into(); edited.sig = sign(&SigningKey::from_bytes(&[6u8; 32]), &manifest_sign_payload(&edited)); let eb = bincode::serialize(&edited).unwrap(); assert_eq!(validate_revision(&owner, blake3::hash(&eb).as_bytes(), &eb), Err(Invalid::BadSignature)); let mut p = PointerState { schema_version: SCHEMA_VERSION, owner, playlist_uuid: [9; 16], sequence: 1, current: Some(RevisionRef { content_hash: hash, revision_seq: 1, entry_count: 1, byte_length: bytes.len() as u32, name: "Rainy Window".into() }), withdrawn: false, sig: vec![], }; p.sig = sign(&key, &pointer_sign_payload(&p)); let pb = bincode::serialize(&p).unwrap(); assert_eq!(validate_pointer(&owner, &[9; 16], &pb).unwrap(), p); assert_eq!(validate_pointer(&owner, &[8; 16], &pb), Err(Invalid::WrongOwner)); } }