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:
Scott Reimers 2026-04-22 21:17:34 -04:00
parent 4da6a8dc85
commit 36b6a466d2
10 changed files with 585 additions and 196 deletions

View file

@ -289,6 +289,78 @@ pub fn sign_delete(seed: &[u8; 32], post_id: &PostId) -> Vec<u8> {
sig.to_bytes().to_vec()
}
/// Canonical bytes for a ControlOp::DeletePost signature.
fn control_delete_bytes(post_id: &PostId, timestamp_ms: u64) -> Vec<u8> {
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(&timestamp_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<u8> {
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<u8> {
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(&timestamp_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<u8> {
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 {