diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index 61a3607..9d9cc79 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -1395,11 +1395,19 @@ impl ConnectionManager { for sp in &response.posts { if s.is_deleted(&sp.id)? { continue; } if verify_post_id(&sp.id, &sp.post) { - if s.store_post_with_visibility(&sp.id, &sp.post, &sp.visibility)? { - new_post_ids.push(sp.id); - posts_received += 1; + match crate::control::receive_post(&s, &sp.id, &sp.post, &sp.visibility, sp.intent.as_ref()) { + Ok(true) => { + new_post_ids.push(sp.id); + posts_received += 1; + synced_authors.insert(sp.post.author); + } + Ok(false) => { + synced_authors.insert(sp.post.author); + } + Err(e) => { + warn!(post_id = hex::encode(sp.id), error = %e, "rejecting post"); + } } - synced_authors.insert(sp.post.author); } } } @@ -1961,11 +1969,17 @@ impl ConnectionManager { let storage = self.storage.get().await; for sp in &response.posts { if verify_post_id(&sp.id, &sp.post) && !storage.is_deleted(&sp.id)? { - let _ = storage.store_post_with_visibility(&sp.id, &sp.post, &sp.visibility); - new_post_ids.push(sp.id); - synced_authors.insert(sp.post.author); - if sp.id == notification.post_id { - stored = true; + match crate::control::receive_post(&storage, &sp.id, &sp.post, &sp.visibility, sp.intent.as_ref()) { + Ok(_) => { + new_post_ids.push(sp.id); + synced_authors.insert(sp.post.author); + if sp.id == notification.post_id { + stored = true; + } + } + Err(e) => { + warn!(post_id = hex::encode(sp.id), error = %e, "rejecting post"); + } } } } @@ -2069,11 +2083,19 @@ impl ConnectionManager { continue; } if verify_post_id(&sp.id, &sp.post) { - if storage.store_post_with_visibility(&sp.id, &sp.post, &sp.visibility)? { - new_post_ids.push(sp.id); - posts_received += 1; + match crate::control::receive_post(&storage, &sp.id, &sp.post, &sp.visibility, sp.intent.as_ref()) { + Ok(true) => { + new_post_ids.push(sp.id); + posts_received += 1; + synced_authors.insert(sp.post.author); + } + Ok(false) => { + synced_authors.insert(sp.post.author); + } + Err(e) => { + warn!(post_id = hex::encode(sp.id), error = %e, "rejecting post"); + } } - synced_authors.insert(sp.post.author); } } } @@ -2294,12 +2316,15 @@ impl ConnectionManager { } } - // Phase 3: Brief re-lock for is_deleted checks on filtered posts + // Phase 3: Brief re-lock for is_deleted checks + intent fetch on filtered posts let (posts, vis_updates) = { let s = storage.get().await; let posts_to_send: Vec = candidates_to_send.into_iter() .filter(|(id, _, _)| !s.is_deleted(id).unwrap_or(false)) - .map(|(id, post, visibility)| SyncPost { id, post, visibility }) + .map(|(id, post, visibility)| { + let intent = s.get_post_intent(&id).ok().flatten(); + SyncPost { id, post, visibility, intent } + }) .collect(); (posts_to_send, vis_updates_to_send) }; @@ -4943,24 +4968,11 @@ impl ConnectionManager { } } - // Gather connections for CDN delete notices under lock, then send outside - let mut delete_notices: Vec<(iroh::endpoint::Connection, crate::protocol::BlobDeleteNoticePayload)> = Vec::new(); - for (cid, holders) in &blob_cleanup { - let payload = crate::protocol::BlobDeleteNoticePayload { cid: *cid, upstream_node: None }; - for (peer, _addrs) in holders { - if let Some(pc) = cm.connections_ref().get(peer) { - delete_notices.push((pc.connection.clone(), payload.clone())); - } - } - } drop(cm); - // Send outside lock - for (conn, payload) in &delete_notices { - if let Ok(mut send) = conn.open_uni().await { - let _ = write_typed_message(&mut send, MessageType::BlobDeleteNotice, payload).await; - let _ = send.finish(); - } - } + // BlobDeleteNotice removed in v0.6.2: orphaned blobs on remote + // holders are evicted naturally via LRU rather than by a + // persona-signed push. + let _ = blob_cleanup; } MessageType::VisibilityUpdate => { let payload: crate::protocol::VisibilityUpdatePayload = @@ -5014,22 +5026,30 @@ impl ConnectionManager { && storage.get_post(&push.post.id)?.is_none() && crate::content::verify_post_id(&push.post.id, &push.post.post) { - let _ = storage.store_post_with_visibility( + match crate::control::receive_post( + &storage, &push.post.id, &push.post.post, &push.post.visibility, - ); - let _ = storage.touch_file_holder( - &push.post.id, - &remote_node_id, - &[], - crate::storage::HolderDirection::Received, - ); - info!( - peer = hex::encode(remote_node_id), - post_id = hex::encode(push.post.id), - "Received direct post push" - ); + push.post.intent.as_ref(), + ) { + Ok(_) => { + let _ = storage.touch_file_holder( + &push.post.id, + &remote_node_id, + &[], + crate::storage::HolderDirection::Received, + ); + info!( + peer = hex::encode(remote_node_id), + post_id = hex::encode(push.post.id), + "Received direct post push" + ); + } + Err(e) => { + warn!(post_id = hex::encode(push.post.id), error = %e, "rejecting pushed post"); + } + } } } } @@ -5237,7 +5257,14 @@ impl ConnectionManager { let stored = { let cm = cm_arc.lock().await; let storage = cm.storage.get().await; - if storage.store_post_with_visibility(&sync_post.id, &sync_post.post, &sync_post.visibility).unwrap_or(false) { + let newly_stored = crate::control::receive_post( + &storage, + &sync_post.id, + &sync_post.post, + &sync_post.visibility, + sync_post.intent.as_ref(), + ).unwrap_or(false); + if newly_stored { let _ = storage.touch_file_holder( &sync_post.id, &sender_id, @@ -5327,24 +5354,6 @@ impl ConnectionManager { "Received social disconnect notice" ); } - MessageType::BlobDeleteNotice => { - let payload: crate::protocol::BlobDeleteNoticePayload = - read_payload(recv, MAX_PAYLOAD).await?; - let cm = conn_mgr.lock().await; - let storage = cm.storage.get().await; - let cid = payload.cid; - - // Flat-holder model: drop the sender as a holder of this file. - // The author's DeleteRecord (separate signed message) is what - // triggers the actual blob removal for followers. - let _ = storage.remove_file_holder(&cid, &remote_node_id); - - info!( - peer = hex::encode(remote_node_id), - cid = hex::encode(cid), - "Received blob delete notice" - ); - } MessageType::GroupKeyDistribute => { let payload: GroupKeyDistributePayload = read_payload(recv, MAX_PAYLOAD).await?; let cm = conn_mgr.lock().await; @@ -5675,11 +5684,13 @@ impl ConnectionManager { }; let result = { let store = storage.get().await; - store.get_post_with_visibility(&payload.post_id).ok().flatten() + let pv = store.get_post_with_visibility(&payload.post_id).ok().flatten(); + let intent = store.get_post_intent(&payload.post_id).ok().flatten(); + pv.map(|(p, v)| (p, v, intent)) }; - let resp = if let Some((post, visibility)) = result { + let resp = if let Some((post, visibility, intent)) = result { if matches!(visibility, PostVisibility::Public) { - crate::protocol::PostFetchResponsePayload { post_id: payload.post_id, found: true, post: Some(SyncPost { id: payload.post_id, post, visibility }) } + crate::protocol::PostFetchResponsePayload { post_id: payload.post_id, found: true, post: Some(SyncPost { id: payload.post_id, post, visibility, intent }) } } else { crate::protocol::PostFetchResponsePayload { post_id: payload.post_id, found: false, post: None } } @@ -6195,7 +6206,13 @@ impl ConnectionManager { let post_author = sp.post.author; let cm = cm_arc.lock().await; let storage = cm.storage.get().await; - let _ = storage.store_post_with_visibility(&sp.id, &sp.post, &sp.visibility); + let _ = crate::control::receive_post( + &storage, + &sp.id, + &sp.post, + &sp.visibility, + sp.intent.as_ref(), + ); let _ = storage.touch_file_holder( &sp.id, &sender, diff --git a/crates/core/src/control.rs b/crates/core/src/control.rs new file mode 100644 index 0000000..ec68dcc --- /dev/null +++ b/crates/core/src/control.rs @@ -0,0 +1,247 @@ +//! 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 { + 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"); + } + } + 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"); + } + } + } + } + + 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)?; + } + 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()); + } +} diff --git a/crates/core/src/crypto.rs b/crates/core/src/crypto.rs index ef48683..d94c945 100644 --- a/crates/core/src/crypto.rs +++ b/crates/core/src/crypto.rs @@ -289,6 +289,78 @@ pub fn sign_delete(seed: &[u8; 32], post_id: &PostId) -> Vec { sig.to_bytes().to_vec() } +/// Canonical bytes for a ControlOp::DeletePost signature. +fn control_delete_bytes(post_id: &PostId, timestamp_ms: u64) -> Vec { + let mut buf = Vec::with_capacity(12 + 32 + 8); + buf.extend_from_slice(b"ctrl:delete:"); + buf.extend_from_slice(post_id); + buf.extend_from_slice(×tamp_ms.to_le_bytes()); + buf +} + +/// Sign a control-post DeletePost operation. +pub fn sign_control_delete(seed: &[u8; 32], post_id: &PostId, timestamp_ms: u64) -> Vec { + let signing_key = SigningKey::from_bytes(seed); + let sig = signing_key.sign(&control_delete_bytes(post_id, timestamp_ms)); + sig.to_bytes().to_vec() +} + +pub fn verify_control_delete( + author: &NodeId, + post_id: &PostId, + 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(&control_delete_bytes(post_id, timestamp_ms), &sig).is_ok() +} + +/// Canonical bytes for a ControlOp::UpdateVisibility signature. Uses JSON +/// round-trip on the visibility payload because PostVisibility is an enum +/// with variable shape; callers must pass the exact same bytes when verifying. +fn control_visibility_bytes( + post_id: &PostId, + new_visibility_canonical: &[u8], + timestamp_ms: u64, +) -> Vec { + let mut buf = Vec::with_capacity(10 + 32 + new_visibility_canonical.len() + 8); + buf.extend_from_slice(b"ctrl:vis:"); + buf.extend_from_slice(post_id); + buf.extend_from_slice(new_visibility_canonical); + buf.extend_from_slice(×tamp_ms.to_le_bytes()); + buf +} + +pub fn sign_control_visibility( + seed: &[u8; 32], + post_id: &PostId, + new_visibility: &crate::types::PostVisibility, + timestamp_ms: u64, +) -> Vec { + let canon = serde_json::to_vec(new_visibility).unwrap_or_default(); + let signing_key = SigningKey::from_bytes(seed); + let sig = signing_key.sign(&control_visibility_bytes(post_id, &canon, timestamp_ms)); + sig.to_bytes().to_vec() +} + +pub fn verify_control_visibility( + author: &NodeId, + post_id: &PostId, + new_visibility: &crate::types::PostVisibility, + 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 }; + let canon = match serde_json::to_vec(new_visibility) { Ok(v) => v, Err(_) => return false }; + vk.verify_strict(&control_visibility_bytes(post_id, &canon, 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 0948e66..65d82ca 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -2,6 +2,7 @@ pub mod activity; pub mod blob; pub mod connection; pub mod content; +pub mod control; pub mod crypto; pub mod http; pub mod export; diff --git a/crates/core/src/network.rs b/crates/core/src/network.rs index 8bdd73f..5b0e9bd 100644 --- a/crates/core/src/network.rs +++ b/crates/core/src/network.rs @@ -1049,29 +1049,7 @@ impl Network { sent } - /// Send blob delete notices to all known holders of a file. - /// Second argument kept as Option for signature stability; flat-holder - /// model doesn't need separate upstream handling. - pub async fn send_blob_delete_notices( - &self, - cid: &[u8; 32], - holders: &[(NodeId, Vec)], - _legacy_upstream: Option<&(NodeId, Vec)>, - ) -> usize { - let payload = crate::protocol::BlobDeleteNoticePayload { - cid: *cid, - upstream_node: None, - }; - let mut sent = 0; - for (peer, _addrs) in holders { - if self.send_to_peer_uni(peer, MessageType::BlobDeleteNotice, &payload).await.is_ok() { - sent += 1; - } - } - sent - } - - /// Request a manifest refresh from the upstream peer for a blob CID. +/// Request a manifest refresh from the upstream peer for a blob CID. /// Returns the updated manifest if the upstream has a newer version. pub async fn request_manifest_refresh( &self, @@ -1136,6 +1114,7 @@ impl Network { id: *post_id, post: post.clone(), visibility: visibility.clone(), + intent: None, // PostPush is only for public posts; no intent carried }, }; diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index db40498..aa423df 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -1074,6 +1074,9 @@ impl Node { VisibilityIntent::Friends => storage.list_public_follows(), VisibilityIntent::Circle(name) => storage.get_circle_members(name), VisibilityIntent::Direct(ids) => Ok(ids.clone()), + // Control and Profile posts are always Public on the wire; they + // never go through encryption recipient resolution. + VisibilityIntent::Control | VisibilityIntent::Profile => Ok(vec![]), } } @@ -2155,39 +2158,32 @@ impl Node { // ---- Delete / Revocation ---- pub async fn delete_post(&self, post_id: &PostId) -> anyhow::Result<()> { - let post = { + // Load the target post and the posting identity of its author. Only + // the author can delete their own content, so the signing key must be + // one we hold in posting_identities. + let (target_author, author_secret) = { let storage = self.storage.get().await; - storage + let post = storage .get_post(post_id)? - .ok_or_else(|| anyhow::anyhow!("post not found"))? - }; - if post.author != self.node_id { - anyhow::bail!("cannot delete: you are not the author"); - } - - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_millis() as u64; - let signature = crypto::sign_delete(&self.default_posting_secret, post_id); - - let record = DeleteRecord { - post_id: *post_id, - author: self.default_posting_id, - timestamp_ms: now, - signature, + .ok_or_else(|| anyhow::anyhow!("post not found"))?; + let pi = storage + .get_posting_identity(&post.author)? + .ok_or_else(|| anyhow::anyhow!("cannot delete: not authored by a persona on this device"))?; + (pi.node_id, pi.secret_seed) }; - // Collect blob CIDs + known holders before cleanup (for delete notices) - let blob_cdn_info: Vec<([u8; 32], Vec<(NodeId, Vec)>, Option<(NodeId, Vec)>)> = { - let storage = self.storage.get().await; - let cids = storage.get_blobs_for_post(post_id).unwrap_or_default(); - cids.into_iter().map(|cid| { - let holders = storage.get_file_holders(&cid).unwrap_or_default(); - (cid, holders, None::<(NodeId, Vec)>) - }).collect() - }; + // Build the control-delete post signed by the target's author. + let control_post = crate::control::build_delete_control_post( + &target_author, + &author_secret, + post_id, + ); + let control_post_id = crate::content::compute_post_id(&control_post); + let now = control_post.timestamp_ms; - // Clean up blobs (DB metadata + CDN metadata + filesystem) + // Clean up blob storage local-side. Blobs in remote holders become + // orphans and get evicted naturally via LRU — BlobDeleteNotice is + // gone in v0.6.2. let blob_cids = { let storage = self.storage.get().await; let cids = storage.delete_blobs_for_post(post_id)?; @@ -2202,19 +2198,42 @@ impl Node { } } + // Store the control post locally with VisibilityIntent::Control so + // feeds filter it and propagation queries find it. Apply the op under + // the same guard so delete recording + target cleanup happen with the + // control-post insert atomically. { let storage = self.storage.get().await; - storage.store_delete(&record)?; - storage.apply_delete(&record)?; + storage.store_post_with_intent( + &control_post_id, + &control_post, + &PostVisibility::Public, + &VisibilityIntent::Control, + )?; + crate::control::apply_control_post_if_applicable( + &*storage, + &control_post, + Some(&VisibilityIntent::Control), + )?; } - // Send CDN delete notices for each blob - for (cid, downstream, upstream) in &blob_cdn_info { - self.network.send_blob_delete_notices(cid, downstream, upstream.as_ref()).await; - } + // Propagate via the normal neighbor-manifest CDN path: include the + // control post in the author's other posts' `following_posts` lists + // and push manifest diffs to their file_holders. Peers who follow + // any of the author's posts pick up the control post and apply it. + self.update_neighbor_manifests_as( + &target_author, + &author_secret, + &control_post_id, + now, + ).await; - let pushed = self.network.push_delete(&record).await; - info!(post_id = hex::encode(post_id), pushed, blobs_removed = blob_cids.len(), "Deleted post"); + info!( + post_id = hex::encode(post_id), + control_post_id = hex::encode(control_post_id), + blobs_removed = blob_cids.len(), + "Deleted post via control post", + ); Ok(()) } @@ -2269,13 +2288,38 @@ impl Node { storage.update_post_visibility(post_id, &new_vis)?; } - let update = VisibilityUpdate { - post_id: *post_id, - author: self.default_posting_id, - visibility: new_vis, + // Propagate via a signed control-visibility post rather than a + // direct push. Only the target's author can make such a post. + let author_secret = { + let s = self.storage.get().await; + s.get_posting_identity(&post.author)? + .map(|pi| pi.secret_seed) + .ok_or_else(|| anyhow::anyhow!("missing posting secret for post author"))? }; - let pushed = self.network.push_visibility(&update).await; - info!(post_id = hex::encode(post_id), pushed, "Revoked access (sync mode)"); + let control_post = crate::control::build_visibility_control_post( + &post.author, + &author_secret, + post_id, + &new_vis, + ); + let control_post_id = crate::content::compute_post_id(&control_post); + let now = control_post.timestamp_ms; + { + let storage = self.storage.get().await; + storage.store_post_with_intent( + &control_post_id, + &control_post, + &PostVisibility::Public, + &VisibilityIntent::Control, + )?; + } + self.update_neighbor_manifests_as( + &post.author, + &author_secret, + &control_post_id, + now, + ).await; + info!(post_id = hex::encode(post_id), control_post_id = hex::encode(control_post_id), "Revoked access (sync mode) via control post"); Ok(None) } RevocationMode::ReEncrypt => { @@ -3477,25 +3521,14 @@ impl Node { compute_blob_priority_standalone(candidate, &self.node_id, follows, audience_members, now_ms) } - /// Delete a blob with CDN notifications to known holders. - pub async fn delete_blob_with_cdn_notify(&self, cid: &[u8; 32]) -> anyhow::Result<()> { - // Gather known holders before cleanup - let holders = { - let storage = self.storage.get().await; - storage.get_file_holders(cid).unwrap_or_default() - }; - - // Send CDN delete notices to all holders - self.network.send_blob_delete_notices(cid, &holders, None).await; - - // Clean up local storage + /// Delete a blob locally. BlobDeleteNotice was removed in v0.6.2; remote + /// holders notice eviction via their own LRU / replica-miss handling. + pub async fn delete_blob_local(&self, cid: &[u8; 32]) -> anyhow::Result<()> { { let storage = self.storage.get().await; storage.cleanup_cdn_for_blob(cid)?; storage.remove_blob(cid)?; } - - // Delete from filesystem let _ = self.blob_store.delete(cid); Ok(()) @@ -3542,7 +3575,7 @@ impl Node { if bytes_freed >= target_free { break; } - if let Err(e) = self.delete_blob_with_cdn_notify(&candidate.cid).await { + if let Err(e) = self.delete_blob_local(&candidate.cid).await { warn!(cid = hex::encode(candidate.cid), error = %e, "Failed to evict blob"); continue; } diff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs index 245d9fc..8d84cd9 100644 --- a/crates/core/src/protocol.rs +++ b/crates/core/src/protocol.rs @@ -14,6 +14,11 @@ pub struct SyncPost { pub id: PostId, pub post: Post, pub visibility: PostVisibility, + /// Optional originator's intent, so receivers can filter control posts + /// out of the feed and process their ControlOp payload. Absent on + /// pre-v0.6.2 senders; receivers treat as "unknown"/regular post. + #[serde(default)] + pub intent: Option, } /// Message type byte for stream multiplexing @@ -45,7 +50,7 @@ pub enum MessageType { ManifestRefreshRequest = 0x92, ManifestRefreshResponse = 0x93, ManifestPush = 0x94, - BlobDeleteNotice = 0x95, + // 0x95 (BlobDeleteNotice) retired in v0.6.2 — remote holders evict via LRU. GroupKeyDistribute = 0xA0, GroupKeyRequest = 0xA1, GroupKeyResponse = 0xA2, @@ -102,7 +107,6 @@ impl MessageType { 0x92 => Some(Self::ManifestRefreshRequest), 0x93 => Some(Self::ManifestRefreshResponse), 0x94 => Some(Self::ManifestPush), - 0x95 => Some(Self::BlobDeleteNotice), 0xA0 => Some(Self::GroupKeyDistribute), 0xA1 => Some(Self::GroupKeyRequest), 0xA2 => Some(Self::GroupKeyResponse), @@ -411,15 +415,6 @@ pub struct ManifestPushEntry { pub manifest: CdnManifest, } -/// Notify upstream/downstream that a blob has been deleted (uni-stream) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BlobDeleteNoticePayload { - pub cid: [u8; 32], - /// If sender was upstream and is providing their own upstream for tree healing - #[serde(default)] - pub upstream_node: Option, -} - // --- Group key distribution payloads --- /// Admin pushes wrapped group key to a member (uni-stream) @@ -792,7 +787,6 @@ mod tests { MessageType::ManifestRefreshRequest, MessageType::ManifestRefreshResponse, MessageType::ManifestPush, - MessageType::BlobDeleteNotice, MessageType::GroupKeyDistribute, MessageType::GroupKeyRequest, MessageType::GroupKeyResponse, @@ -835,36 +829,6 @@ mod tests { assert!(MessageType::from_byte(0x06).is_none()); } - #[test] - fn blob_delete_notice_payload_roundtrip() { - use crate::types::PeerWithAddress; - - // Without upstream - let payload = BlobDeleteNoticePayload { - cid: [42u8; 32], - upstream_node: None, - }; - let json = serde_json::to_string(&payload).unwrap(); - let decoded: BlobDeleteNoticePayload = serde_json::from_str(&json).unwrap(); - assert_eq!(decoded.cid, [42u8; 32]); - assert!(decoded.upstream_node.is_none()); - - // With upstream - let payload_with_up = BlobDeleteNoticePayload { - cid: [99u8; 32], - upstream_node: Some(PeerWithAddress { - n: hex::encode([1u8; 32]), - a: vec!["10.0.0.1:4433".to_string()], - }), - }; - let json2 = serde_json::to_string(&payload_with_up).unwrap(); - let decoded2: BlobDeleteNoticePayload = serde_json::from_str(&json2).unwrap(); - assert_eq!(decoded2.cid, [99u8; 32]); - assert!(decoded2.upstream_node.is_some()); - let up = decoded2.upstream_node.unwrap(); - assert_eq!(up.a, vec!["10.0.0.1:4433".to_string()]); - } - #[test] fn relay_introduce_payload_roundtrip() { let payload = RelayIntroducePayload { diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index c68f094..68c3f02 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -833,7 +833,9 @@ impl Storage { /// All posts, newest first (with visibility) pub fn list_posts_reverse_chron(&self) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT id, author, content, attachments, timestamp_ms, visibility FROM posts ORDER BY timestamp_ms DESC", + "SELECT id, author, content, attachments, timestamp_ms, visibility FROM posts + WHERE (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"')) + ORDER BY timestamp_ms DESC", )?; let rows = stmt.query_map([], |row| { let id_bytes: Vec = row.get(0)?; @@ -869,6 +871,7 @@ impl Storage { "SELECT p.id, p.author, p.content, p.attachments, p.timestamp_ms, p.visibility FROM posts p INNER JOIN follows f ON p.author = f.node_id + WHERE (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"')) ORDER BY p.timestamp_ms DESC", )?; let rows = stmt.query_map([], |row| { @@ -905,10 +908,12 @@ impl Storage { "SELECT p.id, p.author, p.content, p.attachments, p.timestamp_ms, p.visibility FROM posts p INNER JOIN follows f ON p.author = f.node_id WHERE p.timestamp_ms < ?1 + AND (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"')) ORDER BY p.timestamp_ms DESC LIMIT ?2" } else { "SELECT p.id, p.author, p.content, p.attachments, p.timestamp_ms, p.visibility FROM posts p INNER JOIN follows f ON p.author = f.node_id + WHERE (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"')) ORDER BY p.timestamp_ms DESC LIMIT ?2" }; let mut stmt = self.conn.prepare(sql)?; @@ -924,11 +929,15 @@ impl Storage { pub fn list_posts_page(&self, before_ms: Option, limit: usize) -> anyhow::Result> { let sql = if before_ms.is_some() { "SELECT id, author, content, attachments, timestamp_ms, visibility - FROM posts WHERE timestamp_ms < ?1 + FROM posts + WHERE timestamp_ms < ?1 + AND (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"')) ORDER BY timestamp_ms DESC LIMIT ?2" } else { "SELECT id, author, content, attachments, timestamp_ms, visibility - FROM posts ORDER BY timestamp_ms DESC LIMIT ?2" + FROM posts + WHERE (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"')) + ORDER BY timestamp_ms DESC LIMIT ?2" }; let mut stmt = self.conn.prepare(sql)?; let rows = if let Some(bms) = before_ms { @@ -1051,9 +1060,39 @@ impl Storage { Ok(posts) } - /// All posts with visibility (for sync protocol) + /// All posts with visibility (for sync protocol and export). + /// Includes control/profile posts — they need to propagate through the + /// CDN like any other post. pub fn list_posts_with_visibility(&self) -> anyhow::Result> { - self.list_posts_reverse_chron() + let mut stmt = self.conn.prepare( + "SELECT id, author, content, attachments, timestamp_ms, visibility FROM posts ORDER BY timestamp_ms DESC", + )?; + let rows = stmt.query_map([], |row| { + let id_bytes: Vec = row.get(0)?; + let author_bytes: Vec = row.get(1)?; + let content: String = row.get(2)?; + let attachments_json: String = row.get(3)?; + let timestamp_ms: i64 = row.get(4)?; + let vis_json: String = row.get(5)?; + Ok((id_bytes, author_bytes, content, attachments_json, timestamp_ms, vis_json)) + })?; + let mut posts = Vec::new(); + for row in rows { + let (id_bytes, author_bytes, content, attachments_json, timestamp_ms, vis_json) = row?; + let attachments: Vec = serde_json::from_str(&attachments_json).unwrap_or_default(); + let visibility: PostVisibility = serde_json::from_str(&vis_json).unwrap_or_default(); + posts.push(( + blob_to_postid(id_bytes)?, + Post { + author: blob_to_nodeid(author_bytes)?, + content, + attachments, + timestamp_ms: timestamp_ms as u64, + }, + visibility, + )); + } + Ok(posts) } // ---- Follows ---- diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index a6598d3..49d8533 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -251,7 +251,15 @@ pub struct WrappedKey { pub wrapped_cek: Vec, } -/// User-facing intent for post visibility (resolved to recipients before encryption) +/// User-facing intent for post visibility (resolved to recipients before encryption). +/// +/// A few variants exist for structural distinctions rather than visibility: +/// - `Control` — the post carries a signed operation (delete / visibility +/// update) that receivers apply. Wire visibility is Public; the post is +/// filtered out of feeds and rendered nowhere. +/// - `Profile` — the post carries persona display metadata (display_name, +/// bio, avatar). Wire visibility is Public; the post is not shown in the +/// feed but consulted when rendering the author's name on other posts. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum VisibilityIntent { Public, @@ -261,6 +269,33 @@ pub enum VisibilityIntent { Circle(String), /// Specific recipients Direct(Vec), + /// Protocol-control post (delete / visibility change). + Control, + /// Persona profile post (display_name, bio, avatar). + Profile, +} + +/// Content payload of a `VisibilityIntent::Control` post, serialized as JSON +/// into the post's content field. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case")] +pub enum ControlOp { + /// Delete post `post_id`. Signature is over `b"ctrl:delete:" || post_id + /// || timestamp_ms (LE)`, by the target post's author. + DeletePost { + post_id: PostId, + timestamp_ms: u64, + /// 64-byte ed25519 signature + signature: Vec, + }, + /// Update post `post_id` visibility. Signature is over + /// `b"ctrl:vis:" || post_id || canonical(new_visibility) || timestamp_ms (LE)`. + UpdateVisibility { + post_id: PostId, + new_visibility: PostVisibility, + timestamp_ms: u64, + signature: Vec, + }, } /// A named group of recipients diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index 142907e..f9b8984 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -238,6 +238,8 @@ async fn post_to_dto( VisibilityIntent::Friends => "friends".to_string(), VisibilityIntent::Circle(_) => "circle".to_string(), VisibilityIntent::Direct(_) => "direct".to_string(), + VisibilityIntent::Control => "control".to_string(), + VisibilityIntent::Profile => "profile".to_string(), }, _ => "unknown".to_string(), }