From bdcd2142cd7ee7e2635192f2485316f51d867f95 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Thu, 14 May 2026 13:50:56 -0400 Subject: [PATCH] feat(fof-layer2): author publish-side build_fof_comment_gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New crates/core/src/fof.rs module owns the author-side FoF Layer 2 publish path: - build_fof_comment_gating(storage, author_persona_id): gathers the author's keyring (own current V_me + every distinct received V_x), generates a fresh CEK + slot_binder_nonce, generates a fresh per-V_x (priv_x, pub_x) Ed25519 keypair per real slot, seals each slot, pads with random-bytes dummies to the next bucket (min 8, power-of-2 to 256, +128 above per Layer 3), shuffles real + dummy together, and returns the FoFCommentGating wire block + author-local CEK + the slot_binder_nonce. - Dedup at V_x byte level: a key held under multiple owners produces exactly one slot. - next_vouch_batch_bucket promoted to pub(crate) in profile.rs so the Layer 2 fof module can reuse the bucket rule from Layer 3. Three unit tests cover: - Real-count + padding + roundtrip (Alice's own V_me unlocks her slot; Bob's V_x unlocks his slot; both yield same CEK). - No V_me → returns None (graceful). - Duplicate V_x bytes across owners are deduped (single slot). 134 → 138 tests pass (no regressions). Subsequent slices wire this into the post-create path, add the reader/commenter side, the CDN four-check verification, and the revocation/access-grant diff handlers. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/fof.rs | 285 +++++++++++++++++++++++++++++++++++++ crates/core/src/lib.rs | 1 + crates/core/src/profile.rs | 2 +- 3 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 crates/core/src/fof.rs diff --git a/crates/core/src/fof.rs b/crates/core/src/fof.rs new file mode 100644 index 0000000..f806b75 --- /dev/null +++ b/crates/core/src/fof.rs @@ -0,0 +1,285 @@ +//! FoF Layer 2: post-side construction + verification for FoF-gated +//! comments. See `docs/fof-spec/layer-2-mode2-fof-comments.md` for the +//! wire shape and threat model. +//! +//! This module owns the author-side **publish path**: +//! - Generate the post's CEK + per-V_x signing keypairs. +//! - Seal one wrap slot per unique V_x in the author's keyring. +//! - Pad with dummy slots + dummy pub_x entries (bucketed per Layer 3). +//! - Shuffle real + dummy together so positions don't leak ordering. +//! +//! The reader/commenter unwrap path and CDN verification path live in +//! sibling modules (added in subsequent slices). + +use anyhow::Result; +use ed25519_dalek::SigningKey; +use rand::seq::SliceRandom; +use rand::RngCore; + +use crate::crypto::{seal_wrap_slot, SealedWrapSlot}; +use crate::storage::Storage; +use crate::types::{FoFCommentGating, NodeId, WrapSlot}; + +/// Build the `FoFCommentGating` block for a post about to be published +/// under `CommentPermission::FriendsOfFriends`. The author's keyring +/// (own current `V_me` + every distinct received `V_x`) drives the +/// real-slot set; dummies pad the count to the next bucket. +/// +/// Returns `None` if the author has no current `V_me` set — every +/// persona is supposed to have one (auto-generated on creation in +/// Layer 1), but this gracefully no-ops if not. +/// +/// Side effect: this function is pure; no storage writes. The caller +/// owns persisting the resulting Post. +pub fn build_fof_comment_gating( + storage: &Storage, + author_persona_id: &NodeId, +) -> Result> { + // Gather the author's keyring: own current V_me + all unique + // received V_x's (deduped at the byte level per Layer 3). + let Some((_own_epoch, own_v_me)) = storage.current_own_vouch_key(author_persona_id)? else { + return Ok(None); + }; + let received = storage.list_received_vouch_keys(author_persona_id)?; + + // Dedup at the V_x byte level. Keep the highest epoch per (owner, key). + let mut unique_keys: Vec<[u8; 32]> = Vec::with_capacity(1 + received.len()); + unique_keys.push(own_v_me); + for (_owner, _epoch, key) in &received { + if !unique_keys.iter().any(|existing| existing == key) { + unique_keys.push(*key); + } + } + + // Generate the per-post CEK + slot_binder_nonce. + let mut cek = [0u8; 32]; + rand::rng().fill_bytes(&mut cek); + let mut slot_binder_nonce = [0u8; 32]; + rand::rng().fill_bytes(&mut slot_binder_nonce); + + // Per real V_x: generate (priv_x, pub_x) freshly per spec (Layer 2 + // resolved decision — per-post keypair generation). Then seal the + // slot. Build pub_post_set in lockstep with wrap_slots so the + // .len() invariant holds and indices map cleanly. + let mut entries: Vec<([u8; 32], WrapSlot)> = Vec::with_capacity(unique_keys.len()); + for v_x in &unique_keys { + let mut seed = [0u8; 32]; + rand::rng().fill_bytes(&mut seed); + let signing_key = SigningKey::from_bytes(&seed); + let pub_x = *signing_key.verifying_key().as_bytes(); + + let sealed: SealedWrapSlot = seal_wrap_slot(v_x, &slot_binder_nonce, &cek, &seed)?; + let slot = WrapSlot { + prefilter_tag: sealed.prefilter_tag, + read_ciphertext: sealed.read_ciphertext, + sign_ciphertext: sealed.sign_ciphertext, + }; + entries.push((pub_x, slot)); + } + + // Pad to bucket with dummies. Dummy pub_x = 32B random bytes (no + // priv_x exists; group_sig verification against it will always + // fail — benign). Dummy slot = 98B random; AEAD-fails on every V_x. + let bucket = crate::profile::next_vouch_batch_bucket(entries.len()); + let mut rng = rand::rng(); + while entries.len() < bucket { + let mut dummy_pub_x = [0u8; 32]; + rng.fill_bytes(&mut dummy_pub_x); + let mut dummy_prefilter = [0u8; 2]; + rng.fill_bytes(&mut dummy_prefilter); + let mut dummy_read = vec![0u8; 48]; + rng.fill_bytes(&mut dummy_read); + let mut dummy_sign = vec![0u8; 48]; + rng.fill_bytes(&mut dummy_sign); + entries.push(( + dummy_pub_x, + WrapSlot { + prefilter_tag: dummy_prefilter, + read_ciphertext: dummy_read, + sign_ciphertext: dummy_sign, + }, + )); + } + + // Shuffle so real and dummy positions are indistinguishable. + entries.shuffle(&mut rng); + + let (pub_post_set, wrap_slots): (Vec<_>, Vec<_>) = entries.into_iter().unzip(); + + let gating = FoFCommentGating { + slot_binder_nonce, + pub_post_set, + wrap_slots, + revocation_list: Vec::new(), + }; + + Ok(Some(FoFCommentGatingBuilt { + gating, + // Returned to the caller because the author needs them locally: + // - cek: to decrypt their own comments later + // - own pub_x: to find their own slot in pub_post_set for + // authoring author-side comments + future access-grants + cek, + slot_binder_nonce, + })) +} + +/// Output of [`build_fof_comment_gating`]. The gating block goes into +/// `Post.fof_gating`; the side outputs are author-local state the +/// caller should cache (e.g., in `own_post_slot_provenance` introduced +/// in the cascade-revocation slice later). +#[derive(Debug, Clone)] +pub struct FoFCommentGatingBuilt { + pub gating: FoFCommentGating, + /// The post's body/comments encryption key. Authors keep this + /// locally keyed by `post_id` so they can read/decrypt without + /// needing to unwrap a slot themselves. + pub cek: [u8; 32], + /// Same nonce as `gating.slot_binder_nonce`; mirrored here for + /// callers who want it without reaching into the gating struct. + pub slot_binder_nonce: [u8; 32], +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::Storage; + use crate::crypto::{ed25519_seed_to_x25519_private, open_wrap_slot}; + use ed25519_dalek::SigningKey; + use rand::RngCore; + + fn temp_storage() -> Storage { + Storage::open(":memory:").unwrap() + } + + fn make_persona(seed_byte: u8) -> (NodeId, [u8; 32]) { + let mut seed = [0u8; 32]; + seed[0] = seed_byte; + let signing_key = SigningKey::from_bytes(&seed); + (*signing_key.verifying_key().as_bytes(), seed) + } + + /// Author has a V_me + one received V_x → build_fof_comment_gating + /// produces a real-slot-count of 2 (own + received), padded to + /// bucket 8. + #[test] + fn build_gating_realcount_and_padding() { + let s = temp_storage(); + let (alice_id, _alice_seed) = make_persona(1); + let (bob_id, _bob_seed) = make_persona(2); + + // Alice has her own V_me + let mut v_me_alice = [0u8; 32]; + rand::rng().fill_bytes(&mut v_me_alice); + s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap(); + + // Alice received a V_x from Bob + let mut v_x_bob = [0u8; 32]; + rand::rng().fill_bytes(&mut v_x_bob); + s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap(); + + let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("gating built"); + // Real count = 2 (own + Bob). Bucket = 8 (minimum floor). + assert_eq!(built.gating.pub_post_set.len(), 8); + assert_eq!(built.gating.wrap_slots.len(), 8); + assert_eq!(built.gating.revocation_list.len(), 0); + + // Every slot is exactly 48+48 bytes; prefilter is 2 bytes. + for slot in &built.gating.wrap_slots { + assert_eq!(slot.read_ciphertext.len(), 48); + assert_eq!(slot.sign_ciphertext.len(), 48); + } + + // Alice can find HER OWN slot by trial-unwrap with her V_me. + let mut own_hit = None; + for (idx, slot) in built.gating.wrap_slots.iter().enumerate() { + if let Some(opened) = open_wrap_slot( + &v_me_alice, &built.slot_binder_nonce, + &slot.read_ciphertext, &slot.sign_ciphertext, + ) { + assert_eq!(opened.cek, built.cek); + own_hit = Some((idx, opened)); + break; + } + } + let (own_idx, opened) = own_hit.expect("author's own V_me must unlock one slot"); + + // The pub_x in pub_post_set at the same index must match the + // ed25519 pubkey derived from the unwrapped priv_x seed. + let signing_key = SigningKey::from_bytes(&opened.priv_x_seed); + let derived_pub_x = signing_key.verifying_key().to_bytes(); + assert_eq!(built.gating.pub_post_set[own_idx], derived_pub_x); + + // A holder of V_x_bob can also unlock exactly one slot (Bob's + // chain to Alice's post). + let mut bob_hit = None; + for (idx, slot) in built.gating.wrap_slots.iter().enumerate() { + if let Some(opened) = open_wrap_slot( + &v_x_bob, &built.slot_binder_nonce, + &slot.read_ciphertext, &slot.sign_ciphertext, + ) { + bob_hit = Some((idx, opened)); + break; + } + } + let (bob_idx, bob_opened) = bob_hit.expect("Bob's V_x must unlock one slot"); + assert_ne!(bob_idx, own_idx, "different V_x's must hit different slots"); + assert_eq!(bob_opened.cek, built.cek, "same CEK across all real slots"); + } + + #[test] + fn build_gating_returns_none_without_vme() { + let s = temp_storage(); + let (alice_id, _) = make_persona(5); + // No V_me inserted. + let built = build_fof_comment_gating(&s, &alice_id).unwrap(); + assert!(built.is_none(), "no V_me → no gating block"); + } + + #[test] + fn build_gating_deduplicates_repeated_v_x() { + let s = temp_storage(); + let (alice_id, _) = make_persona(7); + let (bob_id, _) = make_persona(8); + let (carol_id, _) = make_persona(9); + + let mut v_me_alice = [0u8; 32]; + rand::rng().fill_bytes(&mut v_me_alice); + s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap(); + + // Two different vouchers happened to issue the SAME key bytes + // (contrived but tests dedup). Real probability is 2^-256; + // here we force it for the test. + let same_key = [0xCC; 32]; + s.insert_received_vouch_key(&alice_id, &bob_id, 1, &same_key, 2000, None).unwrap(); + s.insert_received_vouch_key(&alice_id, &carol_id, 1, &same_key, 3000, None).unwrap(); + + let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); + // Unique-key count = 2 (V_me_alice + same_key). Bucket = 8. + // We can't assert real count directly without exposing internals, + // but we can confirm exactly two distinct successful unwraps: + let mut alice_hits = 0; + let mut same_key_hits = 0; + for slot in &built.gating.wrap_slots { + if open_wrap_slot(&v_me_alice, &built.slot_binder_nonce, + &slot.read_ciphertext, &slot.sign_ciphertext).is_some() { + alice_hits += 1; + } + if open_wrap_slot(&same_key, &built.slot_binder_nonce, + &slot.read_ciphertext, &slot.sign_ciphertext).is_some() { + same_key_hits += 1; + } + } + assert_eq!(alice_hits, 1, "exactly one slot for V_me_alice"); + assert_eq!(same_key_hits, 1, "exactly one slot for the duplicated key (dedup'd)"); + } + + // Silences the unused-import warning when the X25519 derivation is + // only exercised in future slices. + #[test] + fn x25519_derivation_helper_compiles() { + let mut seed = [0u8; 32]; + rand::rng().fill_bytes(&mut seed); + let _ = ed25519_seed_to_x25519_private(&seed); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index a9cf280..a4e6d7f 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -7,6 +7,7 @@ pub mod crypto; pub mod group_key_distribution; pub mod http; pub mod export; +pub mod fof; pub mod identity; pub mod import; pub mod announcement; diff --git a/crates/core/src/profile.rs b/crates/core/src/profile.rs index 55970a6..2205c76 100644 --- a/crates/core/src/profile.rs +++ b/crates/core/src/profile.rs @@ -267,7 +267,7 @@ pub fn build_vouch_grant_batch( /// 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 { +pub(crate) fn next_vouch_batch_bucket(real: usize) -> usize { if real <= 8 { return 8; } if real <= 256 { // smallest power of 2 >= real