forked from Keelrleek/SoulStream
community: shared playlists, a signed revision and a pointer on the network, a reference over soulseek, an import through the normal door
This commit is contained in:
1 parent
984738135a
commit
13c44f731a
11 files changed
+1166
-3
No files matched your search
@@ -17,6 +17,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod slsk_link;
|
||||
pub mod friend;
|
||||
pub mod playlist;
|
||||
|
||||
pub const SCHEMA_VERSION: u32 = 1;
|
||||
pub const SIGN_DOMAIN: &str = "soulstream.profile/v1";
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user