diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index cc0db25..12f646b 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -735,12 +735,22 @@ impl Node { bio: &str, avatar_cid: Option<[u8; 32]>, ) -> anyhow::Result<()> { + // FoF Layer 1: build the vouch-grant batch (if this persona has + // any current vouch targets) + bump the bio_epoch. + let (vouch_grants, bio_epoch) = { + let storage = self.storage.get().await; + let batch = crate::profile::build_vouch_grant_batch(&*storage, posting_id)?; + let epoch = storage.next_bio_epoch_for(posting_id)?; + (batch, epoch) + }; let profile_post = crate::profile::build_profile_post( posting_id, posting_secret, display_name, bio, avatar_cid, + vouch_grants, + bio_epoch, ); let profile_post_id = crate::content::compute_post_id(&profile_post); let timestamp_ms = profile_post.timestamp_ms; @@ -1542,12 +1552,22 @@ impl Node { storage.get_profile(&posting_id).ok().flatten().and_then(|p| p.avatar_cid) }; + // FoF Layer 1: build the vouch-grant batch (if this persona has + // any current vouch targets) + bump bio_epoch. + let (vouch_grants, bio_epoch) = { + let storage = self.storage.get().await; + let batch = crate::profile::build_vouch_grant_batch(&*storage, &posting_id)?; + let epoch = storage.next_bio_epoch_for(&posting_id)?; + (batch, epoch) + }; let profile_post = crate::profile::build_profile_post( &posting_id, &posting_secret, &display_name, &bio, avatar_cid, + vouch_grants, + bio_epoch, ); let profile_post_id = crate::content::compute_post_id(&profile_post); let timestamp_ms = profile_post.timestamp_ms; diff --git a/crates/core/src/profile.rs b/crates/core/src/profile.rs index 3d2a297..29bcd12 100644 --- a/crates/core/src/profile.rs +++ b/crates/core/src/profile.rs @@ -70,12 +70,19 @@ pub fn apply_profile_post_if_applicable( /// 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) @@ -88,8 +95,8 @@ pub fn build_profile_post( avatar_cid, timestamp_ms, signature, - vouch_grants: None, - bio_epoch: 0, + vouch_grants, + bio_epoch, }; Post { author: *author, @@ -105,6 +112,110 @@ 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) @@ -132,7 +243,7 @@ mod tests { let s = temp_storage(); let (sec, pub_id) = make_keypair(11); - let post = build_profile_post(&pub_id, &sec, "Alice", "hello world", None); + 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"); @@ -147,7 +258,7 @@ mod tests { 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); + 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()); @@ -159,7 +270,7 @@ mod tests { let (sec, pub_id) = make_keypair(3); // Seed with a newer profile. - let mut newer = build_profile_post(&pub_id, &sec, "NewName", "", None); + 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; @@ -169,7 +280,7 @@ mod tests { 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); + 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); diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index a8f8b6b..7958a40 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -4945,6 +4945,19 @@ impl Storage { Ok(()) } + /// Increment and return the next bio-publish epoch for a persona. + /// Counter is monotonic; used by receivers' scan cache to short-circuit + /// re-scanning unchanged bios. Stored in `settings` keyed by persona. + pub fn next_bio_epoch_for(&self, persona_id: &NodeId) -> anyhow::Result { + let key = format!("bio_epoch_{}", hex::encode(persona_id)); + let current: u32 = self.get_setting(&key)? + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + let next = current + 1; + self.set_setting(&key, &next.to_string())?; + Ok(next) + } + // --- File holders (flat, per-file, LRU-capped at 5) --- // // A single table for PostId-keyed engagement propagation and CID-keyed diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index d5c591d..7e3a54a 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -308,6 +308,12 @@ pub struct VouchGrantBatch { /// Epoch of the voucher's `V_me` being distributed in this batch. /// All wrappers in the batch carry the same epoch's key. pub v_x_epoch: u32, + /// Random 32B nonce binding this batch's wrappers to this specific + /// bio publish. Plays the role the spec calls "bio_post_id" in the + /// HKDF info string — distinct from the actual `PostId` (which is + /// `BLAKE3(post)` and would be circular here). Recipient-free per + /// HPKE key-privacy requirements. + pub bio_pub_nonce: [u8; 32], /// Real + dummy wrappers, shuffled. Each entry is exactly 48 bytes /// (32B sealed `V_me` + 16B AEAD tag); receivers identify "their" /// wrapper by successful AEAD decryption, not by position.