//! Persona profile + avatar-asset state — the shapes the bundled Freenet //! contracts validate. //! //! BYTE-COMPATIBILITY IS THE CONTRACT. `ProfileState` and `MediaAssetV1` //! must stay bincode-identical (field order, types, sig-body layouts) to //! the wasm bundled in `src-tauri/assets/` (built from the F2/F2b spike //! sources, keys pinned by tests there). Change anything here and every //! published profile stops verifying — that is a migration event. //! //! Two signing conventions coexist under `soulstream.profile/v1` (an F2b //! record): PROFILES sign their RAW sig-body; ASSETS sign blake3(sig-body). //! `profile_sign_payload` / `asset_sign_payload` return exactly what the //! identity delegate must be handed for each. use ed25519_dalek::{Signature, Verifier, VerifyingKey}; use serde::{Deserialize, Serialize}; pub const SCHEMA_VERSION: u32 = 1; pub const SIGN_DOMAIN: &str = "soulstream.profile/v1"; pub const PROFILE_PARAMS_FAMILY: &[u8] = b"soulstream.persona.v1"; pub const MEDIA_PARAMS_FAMILY: &[u8] = b"soulstream.media.v1"; pub const MAX_PROFILE_BYTES: usize = 4 * 1024; pub const MAX_NAME_BYTES: usize = 64; pub const MAX_BIO_BYTES: usize = 280; /// v1 avatar rendition: one square PNG. pub const V1_MEDIA_TYPE: &str = "image/png"; pub const V1_DIMENSION: u32 = 256; pub const MAX_MEDIA_BYTES: usize = 96 * 1024; pub const MAX_MEDIA_STATE_BYTES: usize = 128 * 1024; // ------------------------------------------------------------------ profile #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MediaRefV1 { pub content_hash: [u8; 32], pub media_type: String, pub byte_length: u32, pub width: u16, pub height: u16, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ProfileState { pub schema_version: u32, pub owner: [u8; 32], pub sequence: u64, pub display_name: String, pub bio: String, pub avatar: Option, pub sig: Vec, } #[derive(Serialize)] struct ProfileSigBody<'a> { schema_version: u32, owner: &'a [u8; 32], sequence: u64, display_name: &'a str, bio: &'a str, avatar: &'a Option, } /// What the delegate signs for a profile: the RAW sig-body (F2 convention). pub fn profile_sign_payload(p: &ProfileState) -> Vec { bincode::serialize(&ProfileSigBody { schema_version: p.schema_version, owner: &p.owner, sequence: p.sequence, display_name: &p.display_name, bio: &p.bio, avatar: &p.avatar, }) .expect("serializes") } fn signable(domain: &str, payload: &[u8]) -> Vec { let mut msg = Vec::new(); msg.extend_from_slice(b"soulstream.sig.v1"); msg.push(0); msg.extend_from_slice(domain.as_bytes()); msg.push(0); msg.extend_from_slice(blake3::hash(payload).as_bytes()); msg } pub fn verify_sig(public: &[u8; 32], payload: &[u8], sig: &[u8]) -> bool { let Ok(vk) = VerifyingKey::from_bytes(public) else { return false }; let Ok(sig) = Signature::from_slice(sig) else { return false }; vk.verify(&signable(SIGN_DOMAIN, payload), &sig).is_ok() } pub fn make_profile_params(owner: &[u8; 32]) -> Vec { let mut p = Vec::with_capacity(PROFILE_PARAMS_FAMILY.len() + 32); p.extend_from_slice(PROFILE_PARAMS_FAMILY); p.extend_from_slice(owner); p } #[derive(Debug, PartialEq, Eq)] pub enum ProfileInvalid { Oversized(usize), Malformed, WrongSchema(u32), WrongOwner, NameTooLong(usize), BioTooLong(usize), AvatarUnbounded, BadSignature, } impl core::fmt::Display for ProfileInvalid { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { ProfileInvalid::Oversized(n) => write!(f, "profile {n} bytes over cap {MAX_PROFILE_BYTES}"), ProfileInvalid::Malformed => write!(f, "bytes do not parse as a profile"), ProfileInvalid::WrongSchema(v) => write!(f, "unknown profile schema {v}"), ProfileInvalid::WrongOwner => write!(f, "profile owner differs from the address owner"), ProfileInvalid::NameTooLong(n) => write!(f, "display name {n} bytes over cap {MAX_NAME_BYTES}"), ProfileInvalid::BioTooLong(n) => write!(f, "bio {n} bytes over cap {MAX_BIO_BYTES}"), ProfileInvalid::AvatarUnbounded => write!(f, "avatar reference outside the v1 bounds"), ProfileInvalid::BadSignature => write!(f, "owner signature does not verify"), } } } fn avatar_bounded(r: &MediaRefV1) -> bool { r.media_type == V1_MEDIA_TYPE && r.byte_length as usize <= MAX_MEDIA_BYTES && r.width as u32 == V1_DIMENSION && r.height as u32 == V1_DIMENSION } /// Gen-2 validation (avatar-accepting) — the generation the bundled /// contract enforces. Gen-1 states (avatar=None) validate identically. pub fn validate_profile(owner: &[u8; 32], bytes: &[u8]) -> Result { if bytes.len() > MAX_PROFILE_BYTES { return Err(ProfileInvalid::Oversized(bytes.len())); } let p: ProfileState = bincode::deserialize(bytes).map_err(|_| ProfileInvalid::Malformed)?; if p.schema_version != SCHEMA_VERSION { return Err(ProfileInvalid::WrongSchema(p.schema_version)); } if &p.owner != owner { return Err(ProfileInvalid::WrongOwner); } if p.display_name.len() > MAX_NAME_BYTES { return Err(ProfileInvalid::NameTooLong(p.display_name.len())); } if p.bio.len() > MAX_BIO_BYTES { return Err(ProfileInvalid::BioTooLong(p.bio.len())); } if let Some(r) = &p.avatar { if !avatar_bounded(r) { return Err(ProfileInvalid::AvatarUnbounded); } } if !verify_sig(&p.owner, &profile_sign_payload(&p), &p.sig) { return Err(ProfileInvalid::BadSignature); } Ok(p) } // ------------------------------------------------------------------ asset #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MediaAssetV1 { pub schema_version: u32, pub owner: [u8; 32], pub asset_id: [u8; 16], pub revision: u32, pub media_type: String, pub width: u32, pub height: u32, pub sanitized_static_bytes: Vec, pub sig: Vec, } #[derive(Serialize)] struct AssetSigBody<'a> { schema_version: u32, owner: &'a [u8; 32], asset_id: &'a [u8; 16], revision: u32, media_type: &'a str, width: u32, height: u32, content_hash: &'a [u8; 32], } fn asset_sig_body(a: &MediaAssetV1) -> Vec { let content_hash = *blake3::hash(&a.sanitized_static_bytes).as_bytes(); bincode::serialize(&AssetSigBody { schema_version: a.schema_version, owner: &a.owner, asset_id: &a.asset_id, revision: a.revision, media_type: &a.media_type, width: a.width, height: a.height, content_hash: &content_hash, }) .expect("serializes") } /// What the delegate signs for an asset: blake3(sig-body) — 32 bytes. pub fn asset_sign_payload(a: &MediaAssetV1) -> Vec { blake3::hash(&asset_sig_body(a)).as_bytes().to_vec() } pub fn make_media_params(owner: &[u8; 32], content_hash: &[u8; 32]) -> Vec { let mut p = Vec::with_capacity(MEDIA_PARAMS_FAMILY.len() + 64); p.extend_from_slice(MEDIA_PARAMS_FAMILY); p.extend_from_slice(owner); p.extend_from_slice(content_hash); p } #[derive(Debug, PartialEq, Eq)] pub enum AssetInvalid { Oversized(usize), HashMismatch, Malformed, WrongSchema(u32), WrongOwner, WrongType(String), WrongDimensions(u32, u32), BytesOverBudget(usize), BadSignature, } impl core::fmt::Display for AssetInvalid { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { AssetInvalid::Oversized(n) => write!(f, "asset state {n} bytes over gate {MAX_MEDIA_STATE_BYTES}"), AssetInvalid::HashMismatch => write!(f, "bytes do not hash to the referenced content hash"), AssetInvalid::Malformed => write!(f, "bytes do not parse as a media asset"), AssetInvalid::WrongSchema(v) => write!(f, "unknown media schema {v}"), AssetInvalid::WrongOwner => write!(f, "asset owner differs from the reference owner"), AssetInvalid::WrongType(t) => write!(f, "media type {t:?} not accepted (v1: image/png)"), AssetInvalid::WrongDimensions(w, h) => write!(f, "declared {w}x{h} is not the 256x256 rendition"), AssetInvalid::BytesOverBudget(n) => write!(f, "{n} media bytes over the {MAX_MEDIA_BYTES} budget"), AssetInvalid::BadSignature => write!(f, "owner signature does not verify"), } } } /// Structural verification of a fetched asset against a signed reference — /// everything that must pass BEFORE any image decoding happens. pub fn validate_asset( owner: &[u8; 32], content_hash: &[u8; 32], state_bytes: &[u8], ) -> Result { if state_bytes.len() > MAX_MEDIA_STATE_BYTES { return Err(AssetInvalid::Oversized(state_bytes.len())); } let a: MediaAssetV1 = bincode::deserialize(state_bytes).map_err(|_| AssetInvalid::Malformed)?; if a.schema_version != SCHEMA_VERSION { return Err(AssetInvalid::WrongSchema(a.schema_version)); } if &a.owner != owner { return Err(AssetInvalid::WrongOwner); } if a.sanitized_static_bytes.len() > MAX_MEDIA_BYTES { return Err(AssetInvalid::BytesOverBudget(a.sanitized_static_bytes.len())); } if blake3::hash(&a.sanitized_static_bytes).as_bytes() != content_hash { return Err(AssetInvalid::HashMismatch); } if a.media_type != V1_MEDIA_TYPE { return Err(AssetInvalid::WrongType(a.media_type.clone())); } if a.width != V1_DIMENSION || a.height != V1_DIMENSION { return Err(AssetInvalid::WrongDimensions(a.width, a.height)); } if !verify_sig(owner, &asset_sign_payload(&a), &a.sig) { return Err(AssetInvalid::BadSignature); } Ok(a) } // ------------------------------------------------------------------ tests #[cfg(test)] mod tests { use super::*; use ed25519_dalek::{Signer, SigningKey}; fn kp(seed: u8) -> SigningKey { SigningKey::from_bytes(&[seed; 32]) } fn sign_raw(key: &SigningKey, payload: &[u8]) -> Vec { key.sign(&signable(SIGN_DOMAIN, payload)).to_bytes().to_vec() } #[test] fn profile_roundtrip_both_generations() { let k = kp(1); let owner = k.verifying_key().to_bytes(); let mut p = ProfileState { schema_version: SCHEMA_VERSION, owner, sequence: 1, display_name: "Kyle".into(), bio: String::new(), avatar: None, sig: vec![], }; p.sig = sign_raw(&k, &profile_sign_payload(&p)); let b = bincode::serialize(&p).unwrap(); assert!(validate_profile(&owner, &b).is_ok()); // With a bounded avatar ref. let mut p2 = p.clone(); p2.sequence = 2; p2.avatar = Some(MediaRefV1 { content_hash: [9u8; 32], media_type: V1_MEDIA_TYPE.into(), byte_length: 50_000, width: 256, height: 256, }); p2.sig = sign_raw(&k, &profile_sign_payload(&p2)); assert!(validate_profile(&owner, &bincode::serialize(&p2).unwrap()).is_ok()); // Unbounded avatar refused; tamper refused. let mut p3 = p2.clone(); p3.avatar.as_mut().unwrap().width = 4096; p3.sig = sign_raw(&k, &profile_sign_payload(&p3)); assert_eq!( validate_profile(&owner, &bincode::serialize(&p3).unwrap()), Err(ProfileInvalid::AvatarUnbounded) ); let mut p4 = p2.clone(); p4.display_name = "Mallory".into(); assert_eq!( validate_profile(&owner, &bincode::serialize(&p4).unwrap()), Err(ProfileInvalid::BadSignature) ); } #[test] fn asset_roundtrip_and_hash_binding() { let k = kp(1); let owner = k.verifying_key().to_bytes(); let mut a = MediaAssetV1 { schema_version: SCHEMA_VERSION, owner, asset_id: [4u8; 16], revision: 1, media_type: V1_MEDIA_TYPE.into(), width: V1_DIMENSION, height: V1_DIMENSION, sanitized_static_bytes: vec![7u8; 1000], sig: vec![], }; a.sig = sign_raw(&k, &asset_sign_payload(&a)); let hash = *blake3::hash(&a.sanitized_static_bytes).as_bytes(); let b = bincode::serialize(&a).unwrap(); assert!(validate_asset(&owner, &hash, &b).is_ok()); assert_eq!(validate_asset(&owner, &[0u8; 32], &b), Err(AssetInvalid::HashMismatch)); } } /// blake3 of raw bytes — the content-addressing hash used across the /// persona/media contract families. pub fn content_hash(bytes: &[u8]) -> [u8; 32] { *blake3::hash(bytes).as_bytes() }