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.
This commit is contained in:
Scott Reimers 2026-04-22 22:30:27 -04:00
parent eabdb7ba4f
commit 8b2881d84a
6 changed files with 339 additions and 51 deletions

View file

@ -361,6 +361,56 @@ pub fn verify_control_visibility(
vk.verify_strict(&control_visibility_bytes(post_id, &canon, timestamp_ms), &sig).is_ok()
}
/// Canonical bytes for a Profile-post signature: length-prefixed display_name
/// and bio, 32-byte avatar_cid (or zeros), then timestamp_ms. Length prefixes
/// prevent extension/reordering attacks.
fn profile_post_bytes(
display_name: &str,
bio: &str,
avatar_cid: &Option<[u8; 32]>,
timestamp_ms: u64,
) -> Vec<u8> {
let dn = display_name.as_bytes();
let bio_bytes = bio.as_bytes();
let mut buf = Vec::with_capacity(5 + 8 + dn.len() + 8 + bio_bytes.len() + 32 + 8);
buf.extend_from_slice(b"prof:");
buf.extend_from_slice(&(dn.len() as u64).to_le_bytes());
buf.extend_from_slice(dn);
buf.extend_from_slice(&(bio_bytes.len() as u64).to_le_bytes());
buf.extend_from_slice(bio_bytes);
let avatar = avatar_cid.unwrap_or([0u8; 32]);
buf.extend_from_slice(&avatar);
buf.extend_from_slice(&timestamp_ms.to_le_bytes());
buf
}
pub fn sign_profile(
seed: &[u8; 32],
display_name: &str,
bio: &str,
avatar_cid: &Option<[u8; 32]>,
timestamp_ms: u64,
) -> Vec<u8> {
let signing_key = SigningKey::from_bytes(seed);
let sig = signing_key.sign(&profile_post_bytes(display_name, bio, avatar_cid, timestamp_ms));
sig.to_bytes().to_vec()
}
pub fn verify_profile(
author: &NodeId,
display_name: &str,
bio: &str,
avatar_cid: &Option<[u8; 32]>,
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(&profile_post_bytes(display_name, bio, avatar_cid, 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 {