424 lines
15 KiB
Rust
424 lines
15 KiB
Rust
//! 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, Signer, SigningKey, Verifier, VerifyingKey};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
pub mod slsk_link;
|
|
pub mod friend;
|
|
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";
|
|
|
|
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<MediaRefV1>,
|
|
pub sig: Vec<u8>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct ProfileSigBody<'a> {
|
|
schema_version: u32,
|
|
owner: &'a [u8; 32],
|
|
sequence: u64,
|
|
display_name: &'a str,
|
|
bio: &'a str,
|
|
avatar: &'a Option<MediaRefV1>,
|
|
}
|
|
|
|
/// What the delegate signs for a profile: the RAW sig-body (F2 convention).
|
|
pub fn profile_sign_payload(p: &ProfileState) -> Vec<u8> {
|
|
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")
|
|
}
|
|
|
|
pub(crate) fn signable(domain: &str, payload: &[u8]) -> Vec<u8> {
|
|
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
|
|
}
|
|
|
|
/// Sign with a raw ed25519 secret in an explicit domain. The delegate holds
|
|
/// the persona and signs for it; these are the room-local keys it does not
|
|
/// hold, so their signing lives beside the verification it has to match.
|
|
/// Deterministic — no entropy, which is what keeps getrandom out of wasm.
|
|
pub fn sign_local(domain: &str, secret: &[u8; 32], payload: &[u8]) -> Vec<u8> {
|
|
SigningKey::from_bytes(secret).sign(&signable(domain, payload)).to_bytes().to_vec()
|
|
}
|
|
|
|
/// The public half of a raw ed25519 secret.
|
|
pub fn public_of(secret: &[u8; 32]) -> [u8; 32] {
|
|
SigningKey::from_bytes(secret).verifying_key().to_bytes()
|
|
}
|
|
|
|
pub fn verify_sig(public: &[u8; 32], payload: &[u8], sig: &[u8]) -> bool {
|
|
verify_sig_in(SIGN_DOMAIN, public, payload, sig)
|
|
}
|
|
|
|
/// The same check under an explicit domain — the link and friend families.
|
|
pub fn verify_sig_in(domain: &str, 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(domain, payload), &sig).is_ok()
|
|
}
|
|
|
|
pub fn make_profile_params(owner: &[u8; 32]) -> Vec<u8> {
|
|
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<ProfileState, ProfileInvalid> {
|
|
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<u8>,
|
|
pub sig: Vec<u8>,
|
|
}
|
|
|
|
#[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<u8> {
|
|
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<u8> {
|
|
blake3::hash(&asset_sig_body(a)).as_bytes().to_vec()
|
|
}
|
|
|
|
pub fn make_media_params(owner: &[u8; 32], content_hash: &[u8; 32]) -> Vec<u8> {
|
|
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<MediaAssetV1, AssetInvalid> {
|
|
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::*;
|
|
|
|
/// `sign_local` has to be the exact inverse of `verify_sig_in`, and that
|
|
/// includes WHAT is signed: every family here signs blake3(body), and the
|
|
/// verifier hashes before it checks. Signing the raw body produces a
|
|
/// signature that validates nowhere — the board rejected a room-key
|
|
/// proposal for exactly this on 2026-09-03, and only a second instance
|
|
/// on the live network surfaced it.
|
|
#[test]
|
|
fn sign_local_is_the_inverse_of_verify_and_the_payload_is_hashed() {
|
|
const D: &str = "soulstream.playlist/v1";
|
|
let secret = [3u8; 32];
|
|
let body = b"a proposal body";
|
|
let payload = blake3::hash(body).as_bytes().to_vec();
|
|
let sig = sign_local(D, &secret, &payload);
|
|
assert!(verify_sig_in(D, &public_of(&secret), &payload, &sig));
|
|
assert!(
|
|
!verify_sig_in(D, &public_of(&secret), body, &sig),
|
|
"the raw body must not validate — callers hash first"
|
|
);
|
|
assert!(
|
|
!verify_sig_in("soulstream.friend/v1", &public_of(&secret), &payload, &sig),
|
|
"a signature must not cross domains"
|
|
);
|
|
}
|
|
|
|
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<u8> {
|
|
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()
|
|
}
|