//! 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 { 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, 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> { 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::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"); } }