itsgoin/crates/core/src/profile.rs
Scott Reimers 3ee5c30ad2 feat(fof-layer1): publish path embeds VouchGrantBatch
Wires the publish side of FoF Layer 1 vouch distribution:

- VouchGrantBatch gains bio_pub_nonce (32B random per batch). Replaces
  the spec's circular "bio_post_id in HKDF info" — BLAKE3(post)
  depends on vouch_grants, so we need a content-independent binder.
  Recipient-free per HPKE key-privacy; serves the same anti-replay
  purpose as bio_post_id would have.

- profile::build_vouch_grant_batch reads current_own_vouch_key +
  list_current_vouch_targets, generates eph keypair + bio_pub_nonce,
  seals V_me for each target, bucket-pads with random 48B dummies,
  and shuffles. Returns None when there are no targets.

- next_vouch_batch_bucket implements the FoF Layer 3 padding rule:
  minimum bucket 8, power-of-2 up to 256, then linear +128 steps.
  Bucket-padding-tests verifies all boundaries.

- Storage gains next_bio_epoch_for(persona_id): monotonic counter
  per persona, used by receivers' scan cache. Stored in settings.

- build_profile_post signature extended to take Option<VouchGrantBatch>
  + bio_epoch: u32. Both publish_profile_post_as (initial post) and
  set_profile (subsequent edits) build the batch and bump the epoch
  on every publish.

- Test sites updated to pass None/0 for the new args.

Receive-side scan (next commit) reads VouchGrantBatch + bio_pub_nonce
to trial-decrypt wrappers and populate vouch_keys_received.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:39:09 -04:00

294 lines
11 KiB
Rust

//! Profile posts: persona display metadata (display_name, bio, avatar_cid)
//! carried as a signed public post with `VisibilityIntent::Profile`.
//!
//! The post's `author` is the posting identity; the signature inside
//! `ProfilePostContent` is by that identity's secret. Profile posts propagate
//! via the normal CDN path (pull + header-diff). Receivers verify the
//! signature, then upsert a row in the `profiles` table keyed by the post's
//! author (= posting identity) with the new display fields.
//!
//! Profile posts are never rendered in feeds — the feed filter excludes
//! `VisibilityIntent::Profile` posts (see `Storage::get_feed*`).
use crate::crypto;
use crate::storage::Storage;
use crate::types::{NodeId, Post, PostId, PostVisibility, ProfilePostContent, PublicProfile, VisibilityIntent};
/// Verify a profile-post signature without any other side effects. Used by
/// receive paths before storing, so bogus profile posts with invalid
/// signatures never enter storage and can't be re-propagated.
pub fn verify_profile_post(post: &Post) -> anyhow::Result<ProfilePostContent> {
let content: ProfilePostContent = serde_json::from_str(&post.content)
.map_err(|e| anyhow::anyhow!("profile post content is not a valid ProfilePostContent: {}", e))?;
if !crypto::verify_profile(
&post.author,
&content.display_name,
&content.bio,
&content.avatar_cid,
content.timestamp_ms,
&content.signature,
) {
anyhow::bail!("invalid profile-post signature");
}
Ok(content)
}
/// If the post is a Profile post, verify + apply by upserting the
/// `profiles` row keyed by the post's author (= posting identity). Only
/// applied if newer than the existing row's `updated_at`.
pub fn apply_profile_post_if_applicable(
s: &Storage,
post: &Post,
intent: Option<&VisibilityIntent>,
) -> anyhow::Result<()> {
if !matches!(intent, Some(VisibilityIntent::Profile)) {
return Ok(());
}
let content = verify_profile_post(post)?;
// Only apply if newer than the stored row (last-writer-wins by timestamp).
if let Some(existing) = s.get_profile(&post.author)? {
if existing.updated_at >= content.timestamp_ms {
return Ok(());
}
}
let profile = PublicProfile {
node_id: post.author,
display_name: content.display_name,
bio: content.bio,
updated_at: content.timestamp_ms,
anchors: vec![],
recent_peers: vec![],
preferred_peers: vec![],
public_visible: true,
avatar_cid: content.avatar_cid,
};
s.store_profile(&profile)?;
Ok(())
}
/// Build a Profile post signed by the posting identity. Caller is
/// responsible for storing and propagating it.
///
/// Optional `vouch_grants` carries the FoF Layer 1 anonymous-wrapper
/// batch distributing the persona's current `V_me` to vouched personas.
/// `bio_epoch` is a monotonic per-persona counter that lets receivers
/// short-circuit re-scanning unchanged bios.
pub fn build_profile_post(
author: &NodeId,
author_secret: &[u8; 32],
display_name: &str,
bio: &str,
avatar_cid: Option<[u8; 32]>,
vouch_grants: Option<crate::types::VouchGrantBatch>,
bio_epoch: u32,
) -> 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_profile(author_secret, display_name, bio, &avatar_cid, timestamp_ms);
let content = ProfilePostContent {
display_name: display_name.to_string(),
bio: bio.to_string(),
avatar_cid,
timestamp_ms,
signature,
vouch_grants,
bio_epoch,
};
Post {
author: *author,
content: serde_json::to_string(&content).unwrap_or_default(),
attachments: vec![],
timestamp_ms,
}
}
/// Profile-post visibility is always Public on the wire: the signature binds
/// the content to the posting identity and no recipient targeting is needed.
pub fn profile_post_visibility() -> PostVisibility {
PostVisibility::Public
}
/// FoF Layer 1: build the `VouchGrantBatch` for a persona's next bio
/// publish, drawing the current `V_me` from `vouch_keys_own` and the
/// recipient list from `own_vouch_targets` (current=1 only).
///
/// Returns `None` when the persona has no current vouch targets — the
/// bio post can be published without a vouch-grant batch in that case.
///
/// Padding: per FoF Layer 3, the wrapper count is bucketed: power-of-2
/// up to 256 (minimum bucket 8), then linear +128 steps. Real wrappers
/// + random-bytes dummies are shuffled together. Dummies are 48B random
/// sequences — AEAD-indistinguishable from real wrappers to outsiders.
pub fn build_vouch_grant_batch(
storage: &crate::storage::Storage,
persona_id: &NodeId,
) -> anyhow::Result<Option<crate::types::VouchGrantBatch>> {
use rand::RngCore;
use rand::seq::SliceRandom;
let Some((v_x_epoch, v_me)) = storage.current_own_vouch_key(persona_id)? else {
return Ok(None);
};
let targets = storage.list_current_vouch_targets(persona_id)?;
if targets.is_empty() {
return Ok(None);
}
let mut bio_pub_nonce = [0u8; 32];
rand::rng().fill_bytes(&mut bio_pub_nonce);
let (eph_priv, batch_eph_pub) = crypto::generate_vouch_batch_ephemeral();
// Real wrappers.
let mut wrappers: Vec<Vec<u8>> = Vec::with_capacity(targets.len());
for (_tid, x25519_pub, _at) in &targets {
let w = crypto::seal_vouch_grant(&eph_priv, x25519_pub, &bio_pub_nonce, &v_me)?;
wrappers.push(w);
}
// Dummy padding to the next bucket. Min 8; power-of-2 to 256; then
// +128 linear steps. See FoF Layer 3 lead decisions.
let target_count = next_vouch_batch_bucket(wrappers.len());
let mut rng = rand::rng();
while wrappers.len() < target_count {
let mut dummy = vec![0u8; 48];
rng.fill_bytes(&mut dummy);
wrappers.push(dummy);
}
// Shuffle so real and dummy positions are indistinguishable.
wrappers.shuffle(&mut rng);
Ok(Some(crate::types::VouchGrantBatch {
batch_eph_pub,
v_x_epoch,
bio_pub_nonce,
wrappers,
}))
}
/// Bucket-pad a real wrapper count to the next allowed bucket.
/// Minimum bucket is 8 (so a single-target post still publishes 8
/// wrappers, hiding "this persona has no vouchees" entirely).
/// Power-of-2 up to 256; linear +128 steps above 256.
fn next_vouch_batch_bucket(real: usize) -> usize {
if real <= 8 { return 8; }
if real <= 256 {
// smallest power of 2 >= real
let mut b = 8usize;
while b < real { b *= 2; }
return b;
}
// 384, 512, 640, ...
let above = real - 256;
let steps = (above + 127) / 128;
256 + steps * 128
}
#[cfg(test)]
mod batch_padding_tests {
use super::next_vouch_batch_bucket;
#[test]
fn buckets_match_spec() {
// Minimum floor.
assert_eq!(next_vouch_batch_bucket(0), 8);
assert_eq!(next_vouch_batch_bucket(1), 8);
assert_eq!(next_vouch_batch_bucket(7), 8);
assert_eq!(next_vouch_batch_bucket(8), 8);
// Power-of-2 progression.
assert_eq!(next_vouch_batch_bucket(9), 16);
assert_eq!(next_vouch_batch_bucket(16), 16);
assert_eq!(next_vouch_batch_bucket(17), 32);
assert_eq!(next_vouch_batch_bucket(129), 256);
assert_eq!(next_vouch_batch_bucket(256), 256);
// Linear +128 above 256.
assert_eq!(next_vouch_batch_bucket(257), 384);
assert_eq!(next_vouch_batch_bucket(384), 384);
assert_eq!(next_vouch_batch_bucket(385), 512);
assert_eq!(next_vouch_batch_bucket(500), 512);
assert_eq!(next_vouch_batch_bucket(513), 640);
}
}
/// Compute the `PostId` for a freshly-built profile post.
pub fn profile_post_id(post: &Post) -> PostId {
crate::content::compute_post_id(post)
}
#[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 profile_roundtrip_verifies_and_stores() {
let s = temp_storage();
let (sec, pub_id) = make_keypair(11);
let post = build_profile_post(&pub_id, &sec, "Alice", "hello world", None, None, 0);
apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap();
let stored = s.get_profile(&pub_id).unwrap().expect("profile stored");
assert_eq!(stored.display_name, "Alice");
assert_eq!(stored.bio, "hello world");
}
#[test]
fn profile_rejects_wrong_author_signature() {
let s = temp_storage();
let (_sec_a, pub_a) = make_keypair(1);
let (sec_b, _pub_b) = make_keypair(2);
// Build a post claiming `pub_a` but signing with `sec_b`.
let post = build_profile_post(&pub_a, &sec_b, "Impostor", "", None, None, 0);
let res = apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile));
assert!(res.is_err());
assert!(s.get_profile(&pub_a).unwrap().is_none());
}
#[test]
fn profile_ignores_older_timestamp() {
let s = temp_storage();
let (sec, pub_id) = make_keypair(3);
// Seed with a newer profile.
let mut newer = build_profile_post(&pub_id, &sec, "NewName", "", None, None, 0);
// Hack the timestamp to make it clearly newer.
let mut content: ProfilePostContent = serde_json::from_str(&newer.content).unwrap();
content.timestamp_ms = 10_000;
content.signature = crypto::sign_profile(&sec, &content.display_name, &content.bio, &content.avatar_cid, content.timestamp_ms);
newer.content = serde_json::to_string(&content).unwrap();
newer.timestamp_ms = 10_000;
apply_profile_post_if_applicable(&s, &newer, Some(&VisibilityIntent::Profile)).unwrap();
// Apply an older profile — should be ignored.
let mut older = build_profile_post(&pub_id, &sec, "OldName", "", None, None, 0);
let mut content_o: ProfilePostContent = serde_json::from_str(&older.content).unwrap();
content_o.timestamp_ms = 5_000;
content_o.signature = crypto::sign_profile(&sec, &content_o.display_name, &content_o.bio, &content_o.avatar_cid, content_o.timestamp_ms);
older.content = serde_json::to_string(&content_o).unwrap();
older.timestamp_ms = 5_000;
apply_profile_post_if_applicable(&s, &older, Some(&VisibilityIntent::Profile)).unwrap();
let stored = s.get_profile(&pub_id).unwrap().unwrap();
assert_eq!(stored.display_name, "NewName");
}
}