//! Control posts: signed protocol operations carried as public posts that //! receivers apply to local state (delete, update visibility) without //! rendering in feeds. //! //! Wire flow: //! 1. Author creates a `Post { author, content = ControlOp JSON, ... }` with //! `VisibilityIntent::Control`. //! 2. Post propagates via CDN like any other post (header-diffs on neighbor //! posts ship the reference; receivers pull the control post). //! 3. On receive, callers invoke `apply_control_post_if_applicable` to //! decode, verify the ControlOp's signature against the post's author, //! confirm the target post's author matches, and apply. //! //! Control posts themselves are stored with `VisibilityIntent::Control`; feed //! queries exclude them. They remain in storage as tombstones so we can //! re-propagate them to peers and so future arrivals of the target post are //! rejected via the delete tombstone. use crate::crypto; use crate::storage::Storage; use crate::types::{ControlOp, DeleteRecord, NodeId, Post, PostId, PostVisibility, VisibilityIntent}; /// Parse the post's content as a `ControlOp`, verify its signature against /// the post's author, verify target ownership, and apply to local storage. /// No-op (returns Ok) if the post is not a control post. Returns an error /// on a control post with an invalid signature or mismatched target author. /// Callers pass an existing storage guard so the apply happens under the /// same lock as the post-store that triggered the call. pub fn apply_control_post_if_applicable( s: &Storage, post: &Post, intent: Option<&VisibilityIntent>, ) -> anyhow::Result<()> { if !matches!(intent, Some(VisibilityIntent::Control)) { return Ok(()); } 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"); } if let Some(target) = s.get_post(&post_id)? { if target.author != post.author { anyhow::bail!("control-delete author does not match target post's author"); } } let record = DeleteRecord { post_id, author: post.author, timestamp_ms, signature: signature.clone(), }; let _ = s.store_delete(&record); let _ = s.apply_delete(&record); Ok(()) } 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"); } if let Some(target) = s.get_post(&post_id)? { if target.author != post.author { anyhow::bail!("control-visibility author does not match target post's author"); } let _ = s.update_post_visibility(&post_id, &new_visibility); } let _ = (timestamp_ms, new_visibility); Ok(()) } } } /// Unified receive path: for every incoming post, call this instead of /// `store_post_with_visibility` / `store_post_with_intent`. If the post is a /// control post, the op is verified and applied atomically under the same /// storage guard; if verification fails the post is NOT stored (so we don't /// propagate bogus controls to other peers via neighbor-manifest diffs). /// /// Returns Ok(true) if the post was newly stored, Ok(false) if already known, /// and an error for control posts with invalid signatures or mismatched /// target authors. pub fn receive_post( s: &Storage, id: &PostId, post: &Post, visibility: &PostVisibility, intent: Option<&VisibilityIntent>, ) -> anyhow::Result { // 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"); } } } } Some(VisibilityIntent::Profile) => { crate::profile::verify_profile_post(post)?; } _ => {} } let stored = if let Some(intent) = intent { s.store_post_with_intent(id, post, visibility, intent)? } else { s.store_post_with_visibility(id, post, visibility)? }; if stored { apply_control_post_if_applicable(s, post, intent)?; crate::profile::apply_profile_post_if_applicable(s, post, intent)?; } Ok(stored) } /// Build a Post representing a control-delete operation. Caller is /// responsible for storing and propagating it. pub fn build_delete_control_post( author: &NodeId, author_secret: &[u8; 32], target_post_id: &crate::types::PostId, ) -> 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_control_delete(author_secret, target_post_id, timestamp_ms); let op = ControlOp::DeletePost { post_id: *target_post_id, timestamp_ms, signature, }; Post { author: *author, content: serde_json::to_string(&op).unwrap_or_default(), attachments: vec![], timestamp_ms, } } /// Build a Post representing a control-update-visibility operation. Caller /// is responsible for storing and propagating it. pub fn build_visibility_control_post( author: &NodeId, author_secret: &[u8; 32], target_post_id: &crate::types::PostId, new_visibility: &PostVisibility, ) -> 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_control_visibility(author_secret, target_post_id, new_visibility, timestamp_ms); let op = ControlOp::UpdateVisibility { post_id: *target_post_id, new_visibility: new_visibility.clone(), timestamp_ms, signature, }; Post { author: *author, content: serde_json::to_string(&op).unwrap_or_default(), attachments: vec![], timestamp_ms, } } #[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 control_delete_roundtrip_verifies_and_applies() { let s = temp_storage(); let (author_sec, author_pub) = make_keypair(7); let post = Post { author: author_pub, content: "hello".to_string(), attachments: vec![], timestamp_ms: 1000, }; let post_id = crate::content::compute_post_id(&post); s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap(); let control = build_delete_control_post(&author_pub, &author_sec, &post_id); let control_id = crate::content::compute_post_id(&control); let stored = receive_post( &s, &control_id, &control, &PostVisibility::Public, Some(&VisibilityIntent::Control), ).unwrap(); assert!(stored); assert!(s.is_deleted(&post_id).unwrap()); } #[test] fn control_delete_rejects_wrong_author() { let s = temp_storage(); let (_author_sec, author_pub) = make_keypair(7); let (other_sec, _other_pub) = make_keypair(9); let post = Post { author: author_pub, content: "hello".to_string(), attachments: vec![], timestamp_ms: 1000, }; let post_id = crate::content::compute_post_id(&post); s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap(); // Sign with wrong secret → invalid signature for `author_pub`. let control = build_delete_control_post(&author_pub, &other_sec, &post_id); let control_id = crate::content::compute_post_id(&control); let res = receive_post( &s, &control_id, &control, &PostVisibility::Public, Some(&VisibilityIntent::Control), ); assert!(res.is_err()); assert!(s.get_post(&control_id).unwrap().is_none()); assert!(!s.is_deleted(&post_id).unwrap()); } }