itsgoin/crates/core/src/control.rs
Scott Reimers 8b2881d84a Phase 2d: profile posts signed by the posting identity
Display metadata (display_name, bio, avatar_cid) is no longer broadcast via
the ProfileUpdate direct push when the user edits their name. It travels as
a signed public post with VisibilityIntent::Profile, authored by the posting
identity, and propagates through the normal neighbor-manifest CDN path.

Core pieces:
- `types::ProfilePostContent` — JSON payload serialized into the post's
  content field. Ed25519 signature by the posting secret over length-prefixed
  display_name + bio + 32-byte avatar_cid (or zeros) + timestamp.
- `crypto::{sign,verify}_profile` with strict length prefixing to prevent
  extension attacks.
- New `profile` module: `build_profile_post`, `verify_profile_post`,
  `apply_profile_post_if_applicable`. Last-writer-wins by timestamp.
- `control::receive_post` now verifies Profile-intent posts upfront (same
  as Control) so bogus signatures never enter storage, and applies them
  after store so the `profiles` row updates atomically with the insert.

Node API:
- `Node::set_profile` rewritten: builds a signed Profile post, stores under
  intent=Profile, applies it locally (upserts the profiles row keyed by the
  posting identity), then propagates via `update_neighbor_manifests_as`.
  Stops calling `network.push_profile` — display changes no longer trigger
  a direct wire push.
- `Node::my_profile` / `has_profile` read by `default_posting_id` instead of
  `node_id`, matching where the row is written now.

ProfileUpdate (0x50) and push_profile stay for now — they still carry
routing-only data (anchors, recent_peers, preferred_peers) via
`sanitized_for_network_broadcast` and are used by `set_anchors` /
`set_public_visible`. Removing the routing fields would be a broader
cleanup; scoped out of this phase.

Tests: roundtrip verify+store, wrong-author rejection (not stored), and
older-timestamp ignored. 114 / 114 core tests pass.
2026-04-22 22:30:27 -04:00

254 lines
9.7 KiB
Rust

//! 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> {
// 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());
}
}