343 lines
12 KiB
Rust
343 lines
12 KiB
Rust
//! 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<String>,
|
|
pub isrcs: Vec<String>,
|
|
pub deezer_id: Option<String>,
|
|
pub apple_music_id: Option<String>,
|
|
pub title: String,
|
|
pub artists: Vec<String>,
|
|
pub album: String,
|
|
pub duration_ms: Option<u32>,
|
|
pub release_year: Option<u16>,
|
|
}
|
|
|
|
#[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<String>,
|
|
/// Free-text resolution hint ("artist title"), NEVER a remote path.
|
|
pub slsk_hint: Option<String>,
|
|
}
|
|
|
|
/// 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<ArtworkRef>,
|
|
pub entries: Vec<EntryV1>,
|
|
pub sig: Vec<u8>,
|
|
}
|
|
|
|
#[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<ArtworkRef>,
|
|
entries: &'a [EntryV1],
|
|
}
|
|
|
|
/// The 32 bytes the delegate signs: blake3 of the serialized sig body.
|
|
pub fn manifest_sign_payload(m: &ManifestV1) -> Vec<u8> {
|
|
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<u8> {
|
|
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<ManifestV1, Invalid> {
|
|
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<RevisionRef>,
|
|
pub withdrawn: bool,
|
|
pub sig: Vec<u8>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct PointerSigBody<'a> {
|
|
schema_version: u32,
|
|
owner: &'a [u8; 32],
|
|
playlist_uuid: &'a [u8; 16],
|
|
sequence: u64,
|
|
current: &'a Option<RevisionRef>,
|
|
withdrawn: bool,
|
|
}
|
|
|
|
pub fn pointer_sign_payload(p: &PointerState) -> Vec<u8> {
|
|
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<u8> {
|
|
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<PointerState, Invalid> {
|
|
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<u8> {
|
|
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));
|
|
}
|
|
}
|