diff --git a/crates/core/src/control.rs b/crates/core/src/control.rs index ec68dcc..c6e15f7 100644 --- a/crates/core/src/control.rs +++ b/crates/core/src/control.rs @@ -88,24 +88,30 @@ pub fn receive_post( visibility: &PostVisibility, intent: Option<&VisibilityIntent>, ) -> anyhow::Result { - if matches!(intent, Some(VisibilityIntent::Control)) { - // Verify the ControlOp signature before storing. A bogus control post - // with an invalid signature should never enter storage. - let op: ControlOp = serde_json::from_str(&post.content).map_err(|e| { - anyhow::anyhow!("control post content is not a valid ControlOp: {}", e) - })?; - match &op { - ControlOp::DeletePost { post_id, timestamp_ms, signature } => { - if !crypto::verify_control_delete(&post.author, post_id, *timestamp_ms, signature) { - anyhow::bail!("invalid control-delete signature"); + // Verify signed intent posts BEFORE storing. Bogus signed posts must + // never enter storage and get re-propagated via neighbor-manifest diffs. + match intent { + Some(VisibilityIntent::Control) => { + let op: ControlOp = serde_json::from_str(&post.content).map_err(|e| { + anyhow::anyhow!("control post content is not a valid ControlOp: {}", e) + })?; + match &op { + ControlOp::DeletePost { post_id, timestamp_ms, signature } => { + if !crypto::verify_control_delete(&post.author, post_id, *timestamp_ms, signature) { + anyhow::bail!("invalid control-delete signature"); + } } - } - ControlOp::UpdateVisibility { post_id, new_visibility, timestamp_ms, signature } => { - if !crypto::verify_control_visibility(&post.author, post_id, new_visibility, *timestamp_ms, signature) { - anyhow::bail!("invalid control-visibility signature"); + ControlOp::UpdateVisibility { post_id, new_visibility, timestamp_ms, signature } => { + if !crypto::verify_control_visibility(&post.author, post_id, new_visibility, *timestamp_ms, signature) { + anyhow::bail!("invalid control-visibility signature"); + } } } } + Some(VisibilityIntent::Profile) => { + crate::profile::verify_profile_post(post)?; + } + _ => {} } let stored = if let Some(intent) = intent { @@ -115,6 +121,7 @@ pub fn receive_post( }; if stored { apply_control_post_if_applicable(s, post, intent)?; + crate::profile::apply_profile_post_if_applicable(s, post, intent)?; } Ok(stored) } diff --git a/crates/core/src/crypto.rs b/crates/core/src/crypto.rs index d94c945..301ed15 100644 --- a/crates/core/src/crypto.rs +++ b/crates/core/src/crypto.rs @@ -361,6 +361,56 @@ pub fn verify_control_visibility( vk.verify_strict(&control_visibility_bytes(post_id, &canon, timestamp_ms), &sig).is_ok() } +/// Canonical bytes for a Profile-post signature: length-prefixed display_name +/// and bio, 32-byte avatar_cid (or zeros), then timestamp_ms. Length prefixes +/// prevent extension/reordering attacks. +fn profile_post_bytes( + display_name: &str, + bio: &str, + avatar_cid: &Option<[u8; 32]>, + timestamp_ms: u64, +) -> Vec { + let dn = display_name.as_bytes(); + let bio_bytes = bio.as_bytes(); + let mut buf = Vec::with_capacity(5 + 8 + dn.len() + 8 + bio_bytes.len() + 32 + 8); + buf.extend_from_slice(b"prof:"); + buf.extend_from_slice(&(dn.len() as u64).to_le_bytes()); + buf.extend_from_slice(dn); + buf.extend_from_slice(&(bio_bytes.len() as u64).to_le_bytes()); + buf.extend_from_slice(bio_bytes); + let avatar = avatar_cid.unwrap_or([0u8; 32]); + buf.extend_from_slice(&avatar); + buf.extend_from_slice(×tamp_ms.to_le_bytes()); + buf +} + +pub fn sign_profile( + seed: &[u8; 32], + display_name: &str, + bio: &str, + avatar_cid: &Option<[u8; 32]>, + timestamp_ms: u64, +) -> Vec { + let signing_key = SigningKey::from_bytes(seed); + let sig = signing_key.sign(&profile_post_bytes(display_name, bio, avatar_cid, timestamp_ms)); + sig.to_bytes().to_vec() +} + +pub fn verify_profile( + author: &NodeId, + display_name: &str, + bio: &str, + avatar_cid: &Option<[u8; 32]>, + timestamp_ms: u64, + signature: &[u8], +) -> bool { + if signature.len() != 64 { return false; } + let sig_bytes: [u8; 64] = match signature.try_into() { Ok(b) => b, Err(_) => return false }; + let sig = ed25519_dalek::Signature::from_bytes(&sig_bytes); + let Ok(vk) = VerifyingKey::from_bytes(author) else { return false }; + vk.verify_strict(&profile_post_bytes(display_name, bio, avatar_cid, timestamp_ms), &sig).is_ok() +} + /// Verify an ed25519 delete signature: the author's public key signed the post_id. pub fn verify_delete_signature(author: &NodeId, post_id: &PostId, signature: &[u8]) -> bool { if signature.len() != 64 { diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 65d82ca..9ded878 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -10,6 +10,7 @@ pub mod identity; pub mod import; pub mod network; pub mod node; +pub mod profile; pub mod protocol; pub mod storage; pub mod stun; diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index f0b4937..9cbadf2 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -1221,48 +1221,77 @@ impl Node { // ---- Profiles ---- + /// Set the default posting identity's profile (display_name, bio, + /// preserving any existing avatar). Creates a signed + /// `VisibilityIntent::Profile` post authored by the posting identity and + /// propagates it via the normal neighbor-manifest CDN path. The locally + /// stored profile row is keyed by the posting identity — peers who pull + /// the profile post apply the same update on their side. pub async fn set_profile(&self, display_name: String, bio: String) -> anyhow::Result { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_millis() as u64; + let posting_id = self.default_posting_id; + let posting_secret = self.default_posting_secret; - let recent_peers = self.current_recent_peers().await; - // Profile is keyed by the network NodeId — that's how peers route to - // us. Broadcasts strip display_name / bio / avatar before going on - // the wire (see Network::push_profile). The locally stored profile - // retains the name for the user's own UI. - let profile = { + // Preserve existing avatar if present. + let avatar_cid = { let storage = self.storage.get().await; - let existing_anchors = storage.get_peer_anchors(&self.node_id).unwrap_or_default(); - let preferred_peers = storage.list_preferred_peers().unwrap_or_default(); - - let (existing_visible, existing_avatar) = storage.get_profile(&self.node_id) - .ok() - .flatten() - .map(|p| (p.public_visible, p.avatar_cid)) - .unwrap_or((true, None)); - - let profile = PublicProfile { - node_id: self.node_id, - display_name, - bio, - updated_at: now, - anchors: existing_anchors, - recent_peers, - preferred_peers, - public_visible: existing_visible, - avatar_cid: existing_avatar, - }; - - storage.store_profile(&profile)?; - profile + storage.get_profile(&posting_id).ok().flatten().and_then(|p| p.avatar_cid) }; - let pushed = self.network.push_profile(&profile).await; - if pushed > 0 { - info!(pushed, "Pushed profile update to peers"); + let profile_post = crate::profile::build_profile_post( + &posting_id, + &posting_secret, + &display_name, + &bio, + avatar_cid, + ); + let profile_post_id = crate::content::compute_post_id(&profile_post); + let timestamp_ms = profile_post.timestamp_ms; + + // Store post with VisibilityIntent::Profile + apply (upserts profile row). + { + let storage = self.storage.get().await; + storage.store_post_with_intent( + &profile_post_id, + &profile_post, + &PostVisibility::Public, + &VisibilityIntent::Profile, + )?; + crate::profile::apply_profile_post_if_applicable( + &*storage, + &profile_post, + Some(&VisibilityIntent::Profile), + )?; } + // Propagate via neighbor-manifest header diffs like any other post. + self.update_neighbor_manifests_as( + &posting_id, + &posting_secret, + &profile_post_id, + timestamp_ms, + ).await; + + let profile = { + let storage = self.storage.get().await; + storage.get_profile(&posting_id)? + .unwrap_or_else(|| PublicProfile { + node_id: posting_id, + display_name: display_name.clone(), + bio: bio.clone(), + updated_at: timestamp_ms, + anchors: vec![], + recent_peers: vec![], + preferred_peers: vec![], + public_visible: true, + avatar_cid, + }) + }; + + info!( + posting_id = hex::encode(posting_id), + profile_post_id = hex::encode(profile_post_id), + "Published profile post" + ); Ok(profile) } @@ -1315,14 +1344,17 @@ impl Node { storage.get_profile(node_id) } + /// v0.6.2: the user's own display profile lives under the default + /// posting identity (published as a signed Profile post), not the + /// network NodeId. pub async fn my_profile(&self) -> anyhow::Result> { let storage = self.storage.get().await; - storage.get_profile(&self.node_id) + storage.get_profile(&self.default_posting_id) } pub async fn has_profile(&self) -> anyhow::Result { let storage = self.storage.get().await; - Ok(storage.get_profile(&self.node_id)?.is_some()) + Ok(storage.get_profile(&self.default_posting_id)?.is_some()) } pub async fn get_display_name(&self, node_id: &NodeId) -> anyhow::Result> { diff --git a/crates/core/src/profile.rs b/crates/core/src/profile.rs new file mode 100644 index 0000000..7d1b66b --- /dev/null +++ b/crates/core/src/profile.rs @@ -0,0 +1,181 @@ +//! Profile posts: persona display metadata (display_name, bio, avatar_cid) +//! carried as a signed public post with `VisibilityIntent::Profile`. +//! +//! The post's `author` is the posting identity; the signature inside +//! `ProfilePostContent` is by that identity's secret. Profile posts propagate +//! via the normal CDN path (pull + header-diff). Receivers verify the +//! signature, then upsert a row in the `profiles` table keyed by the post's +//! author (= posting identity) with the new display fields. +//! +//! Profile posts are never rendered in feeds — the feed filter excludes +//! `VisibilityIntent::Profile` posts (see `Storage::get_feed*`). + +use crate::crypto; +use crate::storage::Storage; +use crate::types::{NodeId, Post, PostId, PostVisibility, ProfilePostContent, PublicProfile, VisibilityIntent}; + +/// Verify a profile-post signature without any other side effects. Used by +/// receive paths before storing, so bogus profile posts with invalid +/// signatures never enter storage and can't be re-propagated. +pub fn verify_profile_post(post: &Post) -> anyhow::Result { + let content: ProfilePostContent = serde_json::from_str(&post.content) + .map_err(|e| anyhow::anyhow!("profile post content is not a valid ProfilePostContent: {}", e))?; + if !crypto::verify_profile( + &post.author, + &content.display_name, + &content.bio, + &content.avatar_cid, + content.timestamp_ms, + &content.signature, + ) { + anyhow::bail!("invalid profile-post signature"); + } + Ok(content) +} + +/// If the post is a Profile post, verify + apply by upserting the +/// `profiles` row keyed by the post's author (= posting identity). Only +/// applied if newer than the existing row's `updated_at`. +pub fn apply_profile_post_if_applicable( + s: &Storage, + post: &Post, + intent: Option<&VisibilityIntent>, +) -> anyhow::Result<()> { + if !matches!(intent, Some(VisibilityIntent::Profile)) { + return Ok(()); + } + let content = verify_profile_post(post)?; + + // Only apply if newer than the stored row (last-writer-wins by timestamp). + if let Some(existing) = s.get_profile(&post.author)? { + if existing.updated_at >= content.timestamp_ms { + return Ok(()); + } + } + + let profile = PublicProfile { + node_id: post.author, + display_name: content.display_name, + bio: content.bio, + updated_at: content.timestamp_ms, + anchors: vec![], + recent_peers: vec![], + preferred_peers: vec![], + public_visible: true, + avatar_cid: content.avatar_cid, + }; + s.store_profile(&profile)?; + Ok(()) +} + +/// Build a Profile post signed by the posting identity. Caller is +/// responsible for storing and propagating it. +pub fn build_profile_post( + author: &NodeId, + author_secret: &[u8; 32], + display_name: &str, + bio: &str, + avatar_cid: Option<[u8; 32]>, +) -> Post { + let timestamp_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let signature = crypto::sign_profile(author_secret, display_name, bio, &avatar_cid, timestamp_ms); + let content = ProfilePostContent { + display_name: display_name.to_string(), + bio: bio.to_string(), + avatar_cid, + timestamp_ms, + signature, + }; + Post { + author: *author, + content: serde_json::to_string(&content).unwrap_or_default(), + attachments: vec![], + timestamp_ms, + } +} + +/// Profile-post visibility is always Public on the wire: the signature binds +/// the content to the posting identity and no recipient targeting is needed. +pub fn profile_post_visibility() -> PostVisibility { + PostVisibility::Public +} + +/// Compute the `PostId` for a freshly-built profile post. +pub fn profile_post_id(post: &Post) -> PostId { + crate::content::compute_post_id(post) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::Storage; + use ed25519_dalek::SigningKey; + + fn temp_storage() -> Storage { + Storage::open(":memory:").unwrap() + } + + fn make_keypair(seed_byte: u8) -> ([u8; 32], NodeId) { + let seed = [seed_byte; 32]; + let signing_key = SigningKey::from_bytes(&seed); + let public = signing_key.verifying_key(); + (seed, *public.as_bytes()) + } + + #[test] + fn profile_roundtrip_verifies_and_stores() { + let s = temp_storage(); + let (sec, pub_id) = make_keypair(11); + + let post = build_profile_post(&pub_id, &sec, "Alice", "hello world", None); + apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap(); + + let stored = s.get_profile(&pub_id).unwrap().expect("profile stored"); + assert_eq!(stored.display_name, "Alice"); + assert_eq!(stored.bio, "hello world"); + } + + #[test] + fn profile_rejects_wrong_author_signature() { + let s = temp_storage(); + let (_sec_a, pub_a) = make_keypair(1); + let (sec_b, _pub_b) = make_keypair(2); + + // Build a post claiming `pub_a` but signing with `sec_b`. + let post = build_profile_post(&pub_a, &sec_b, "Impostor", "", None); + let res = apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)); + assert!(res.is_err()); + assert!(s.get_profile(&pub_a).unwrap().is_none()); + } + + #[test] + fn profile_ignores_older_timestamp() { + let s = temp_storage(); + let (sec, pub_id) = make_keypair(3); + + // Seed with a newer profile. + let mut newer = build_profile_post(&pub_id, &sec, "NewName", "", None); + // Hack the timestamp to make it clearly newer. + let mut content: ProfilePostContent = serde_json::from_str(&newer.content).unwrap(); + content.timestamp_ms = 10_000; + content.signature = crypto::sign_profile(&sec, &content.display_name, &content.bio, &content.avatar_cid, content.timestamp_ms); + newer.content = serde_json::to_string(&content).unwrap(); + newer.timestamp_ms = 10_000; + apply_profile_post_if_applicable(&s, &newer, Some(&VisibilityIntent::Profile)).unwrap(); + + // Apply an older profile — should be ignored. + let mut older = build_profile_post(&pub_id, &sec, "OldName", "", None); + let mut content_o: ProfilePostContent = serde_json::from_str(&older.content).unwrap(); + content_o.timestamp_ms = 5_000; + content_o.signature = crypto::sign_profile(&sec, &content_o.display_name, &content_o.bio, &content_o.avatar_cid, content_o.timestamp_ms); + older.content = serde_json::to_string(&content_o).unwrap(); + older.timestamp_ms = 5_000; + apply_profile_post_if_applicable(&s, &older, Some(&VisibilityIntent::Profile)).unwrap(); + + let stored = s.get_profile(&pub_id).unwrap().unwrap(); + assert_eq!(stored.display_name, "NewName"); + } +} diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 5a9c680..a511478 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -239,6 +239,23 @@ pub enum VisibilityIntent { Profile, } +/// Content payload of a `VisibilityIntent::Profile` post — persona display +/// metadata (display_name, bio, avatar_cid) signed by the posting identity. +/// The post's `author` IS the posting identity; `signature` is an ed25519 +/// signature by that identity's secret over the fields (see `crypto::sign_profile`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProfilePostContent { + pub display_name: String, + #[serde(default)] + pub bio: String, + #[serde(default)] + pub avatar_cid: Option<[u8; 32]>, + pub timestamp_ms: u64, + /// 64-byte ed25519 signature. See `crypto::sign_profile` for the byte + /// layout signed by the posting identity. + pub signature: Vec, +} + /// Content payload of a `VisibilityIntent::Control` post, serialized as JSON /// into the post's content field. #[derive(Debug, Clone, Serialize, Deserialize)]