Phase 2b: control-post flow (delete/visibility) + remove BlobDeleteNotice
Replaces two persona-signed direct pushes with CDN-propagated control posts:
a single `VisibilityIntent::Control` post type whose content is a signed
`ControlOp` the receiver verifies and applies. Deletes and visibility updates
now flow through the same neighbor-manifest CDN path as regular content — no
direct recipient push needed for persona-signed ops.
Core pieces:
- `VisibilityIntent::Control` + `VisibilityIntent::Profile` variants.
- `ControlOp::DeletePost` / `ControlOp::UpdateVisibility` (JSON, ed25519-signed
by the target post's author over op-specific byte strings).
- `crypto::{sign,verify}_control_{delete,visibility}` signing primitives.
- `control::build_delete_control_post` + `build_visibility_control_post`
for authors to construct control posts.
- `control::receive_post` — unified incoming-post path used by all 6 receive
sites. Verifies control signatures BEFORE storing, so bogus controls never
enter storage and can't be re-propagated via neighbor-manifest diffs.
- `control::apply_control_post_if_applicable` — executes the op under the
same storage guard as the insert.
Feed filter:
- Feeds (`get_feed`, `get_feed_page`, `list_posts_page`,
`list_posts_reverse_chron`) now exclude `Control` and `Profile` posts so
they propagate + tombstone without surfacing.
- Sync/export path (`list_posts_with_visibility`) keeps its own unfiltered
query so control posts still propagate via CDN.
Wire protocol:
- `SyncPost` carries `intent: Option<VisibilityIntent>` so control posts
arrive with their intent preserved.
- `BlobDeleteNotice` (0x95) removed — orphan blobs on remote holders evict
naturally via LRU rather than via a persona-signed push. Code path,
payload, sender, tests, and `delete_blob_with_cdn_notify` all gone.
Tests: control delete roundtrip (apply + tombstone) and wrong-author
rejection (not stored, not applied). 112/112 core tests pass.
This commit is contained in:
parent
4da6a8dc85
commit
36b6a466d2
10 changed files with 585 additions and 196 deletions
247
crates/core/src/control.rs
Normal file
247
crates/core/src/control.rs
Normal file
|
|
@ -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<bool> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue