//! 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)?; // FoF Layer 1: scan any embedded vouch-grant batch BEFORE the // timestamp short-circuit below. A profile post that arrives older // than what we've stored (last-writer-wins on display_name/bio) can // still carry vouch grants we haven't seen — bio_epoch is the actual // freshness signal for the wrapper batch, distinct from the // post's display timestamp. scan_vouch_grants_for_all_personas(s, &post.author, &content)?; // 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![], public_visible: true, avatar_cid: content.avatar_cid, }; s.store_profile(&profile)?; Ok(()) } /// FoF Layer 1: trial-decrypt every wrapper in the post's /// `vouch_grants` batch against every persona on this device, recording /// successful unlocks into `vouch_keys_received`. Idempotent via the /// `(scanner_persona, bio_author, bio_epoch)` scan cache. /// /// Follow-gated per the spec: skipped if the bio author is not in /// `follows`. The manual "check this bio for a vouch for me" gesture /// (post-Layer-1) will call a separate force-scan entrypoint. /// /// Self-authored posts are skipped (we already have our own V_me). pub fn scan_vouch_grants_for_all_personas( s: &Storage, author: &NodeId, content: &ProfilePostContent, ) -> anyhow::Result<()> { let Some(batch) = &content.vouch_grants else { return Ok(()); }; // Skip if we authored this post. if s.get_posting_identity(author)?.is_some() { return Ok(()); } // Follow-gate: only auto-scan bios of accounts we follow. if !s.is_follow(author)? { return Ok(()); } let personas = s.list_posting_identities()?; let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); for persona in &personas { // Per-persona scan cache: skip if we already trialed this // (scanner_persona, bio_author, bio_epoch) tuple. if s.lookup_bio_scan_cache(&persona.node_id, author, content.bio_epoch)?.is_some() { continue; } // Derive persona's X25519 private scalar to trial-decrypt // wrappers under the batch ephemeral pubkey. let persona_x25519_priv = crypto::ed25519_seed_to_x25519_private(&persona.secret_seed); let mut unlocked: Option = None; for wrapper_bytes in &batch.wrappers { if let Some(v_me) = crypto::open_vouch_grant( &persona_x25519_priv, &batch.batch_eph_pub, &batch.bio_pub_nonce, wrapper_bytes, ) { // This wrapper was addressed to this persona. // Use the post's author as the source post-id field // (informational only — the cryptographic binder is // bio_pub_nonce inside the batch). s.insert_received_vouch_key( &persona.node_id, author, batch.v_x_epoch, &v_me, now_ms, None, )?; unlocked = Some(batch.v_x_epoch); // Continue iterating — a future multi-epoch batch may // address this persona twice (different epoch wrappers // for the same persona). Today only one epoch ships per // batch, but the loop is correct either way. } } // FoF Layer 5: if a new V_x just landed for this persona, // sweep the unreadable-posts queue for (persona, author) and // re-attempt unlock. Posts that were previously not-in-set // become readable as soon as this V_x lands. if unlocked.is_some() { let _ = crate::fof::sweep_unreadable_on_new_v_x(s, &persona.node_id, author); } s.record_bio_scan_result( &persona.node_id, author, content.bio_epoch, unlocked, now_ms, )?; } 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. /// v0.8 (A3): `fof_gating` carries the bio's comment gating — including /// the Greeting open slot when the persona's `greetings_open` consent is /// on. `None` publishes a bio that accepts no greetings. 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, fof_gating: Option, ) -> 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, fof_gating, supersedes_post_id: None, } } /// 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. pub(crate) 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, None); 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, None); 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, None); // 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, None); 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"); } /// End-to-end Layer 1: voucher's bio post carries a VouchGrantBatch /// addressed to the receiver's persona; receiver auto-scans on /// apply_profile_post_if_applicable and populates vouch_keys_received. #[test] fn vouch_grant_end_to_end_via_bio_post() { use crate::types::{PostingIdentity, VouchGrantBatch}; use rand::RngCore; let s = temp_storage(); // Two personas on this device (the "receiver" device). Alice is // the only one we're acting as; "bob" is the voucher whose bio // post arrives. let (alice_seed, alice_id) = make_keypair(50); let (bob_seed, bob_id) = make_keypair(60); s.upsert_posting_identity(&PostingIdentity { node_id: alice_id, secret_seed: alice_seed, display_name: "Alice".into(), created_at: 1000, }).unwrap(); // Receiver-device follows the voucher; otherwise auto-scan is // follow-gated off and would skip. s.add_follow(&bob_id).unwrap(); // Build bob's V_me + the wrapper batch addressed to alice's // persona X25519 pubkey. let mut v_me_bob = [0u8; 32]; rand::rng().fill_bytes(&mut v_me_bob); let alice_x25519_pub = crypto::ed25519_pubkey_to_x25519_public(&alice_id).unwrap(); 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(); let real_wrapper = crypto::seal_vouch_grant( &eph_priv, &alice_x25519_pub, &bio_pub_nonce, &v_me_bob, ).unwrap(); // Mix in some dummy wrappers to confirm the scan finds the real // one even when most positions fail AEAD. let mut wrappers = vec![real_wrapper]; for _ in 0..7 { let mut dummy = vec![0u8; 48]; rand::rng().fill_bytes(&mut dummy); wrappers.push(dummy); } let batch = VouchGrantBatch { batch_eph_pub, v_x_epoch: 1, bio_pub_nonce, wrappers, }; // Construct bob's bio post with the batch. let timestamp_ms = 2000; let display_name = "Bob"; let bio = "hi"; let signature = crypto::sign_profile(&bob_seed, display_name, bio, &None, timestamp_ms); let content = ProfilePostContent { display_name: display_name.to_string(), bio: bio.to_string(), avatar_cid: None, timestamp_ms, signature, vouch_grants: Some(batch), bio_epoch: 1, }; let post = Post { author: bob_id, content: serde_json::to_string(&content).unwrap(), attachments: vec![], timestamp_ms, fof_gating: None, supersedes_post_id: None, }; // Apply. Auto-scan should fire and store the unwrapped V_me. apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap(); // Alice's keyring should now hold V_bob at epoch 1. let received = s.list_received_vouch_keys(&alice_id).unwrap(); assert_eq!(received.len(), 1, "expected one received vouch"); let (owner, epoch, key) = &received[0]; assert_eq!(*owner, bob_id); assert_eq!(*epoch, 1); assert_eq!(*key, v_me_bob); // Scan cache should record the hit so a re-apply is a no-op // (idempotent + cheap). let cache = s.lookup_bio_scan_cache(&alice_id, &bob_id, 1).unwrap(); assert_eq!(cache, Some(Some(1))); } /// Same setup, but receiver-device does NOT follow the voucher. /// Auto-scan must skip; no vouch keys recorded. #[test] fn vouch_grant_skipped_for_non_followed_author() { use crate::types::{PostingIdentity, VouchGrantBatch}; use rand::RngCore; let s = temp_storage(); let (alice_seed, alice_id) = make_keypair(70); let (bob_seed, bob_id) = make_keypair(80); s.upsert_posting_identity(&PostingIdentity { node_id: alice_id, secret_seed: alice_seed, display_name: "Alice".into(), created_at: 1000, }).unwrap(); // NOT following bob — scan must skip. let mut v_me_bob = [0u8; 32]; rand::rng().fill_bytes(&mut v_me_bob); let alice_x25519_pub = crypto::ed25519_pubkey_to_x25519_public(&alice_id).unwrap(); 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(); let wrapper = crypto::seal_vouch_grant( &eph_priv, &alice_x25519_pub, &bio_pub_nonce, &v_me_bob, ).unwrap(); let batch = VouchGrantBatch { batch_eph_pub, v_x_epoch: 1, bio_pub_nonce, wrappers: vec![wrapper], }; let timestamp_ms = 2000; let signature = crypto::sign_profile(&bob_seed, "Bob", "", &None, timestamp_ms); let content = ProfilePostContent { display_name: "Bob".into(), bio: String::new(), avatar_cid: None, timestamp_ms, signature, vouch_grants: Some(batch), bio_epoch: 1, }; let post = Post { author: bob_id, content: serde_json::to_string(&content).unwrap(), attachments: vec![], timestamp_ms, fof_gating: None, supersedes_post_id: None, }; apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap(); let received = s.list_received_vouch_keys(&alice_id).unwrap(); assert!(received.is_empty(), "non-followed author must not auto-scan"); } }