feat(fof-layer2): author publish-side build_fof_comment_gating
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) <noreply@anthropic.com>
This commit is contained in:
parent
0f5147a31c
commit
bdcd2142cd
3 changed files with 287 additions and 1 deletions
285
crates/core/src/fof.rs
Normal file
285
crates/core/src/fof.rs
Normal file
|
|
@ -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<Option<FoFCommentGatingBuilt>> {
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ pub mod crypto;
|
||||||
pub mod group_key_distribution;
|
pub mod group_key_distribution;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
|
pub mod fof;
|
||||||
pub mod identity;
|
pub mod identity;
|
||||||
pub mod import;
|
pub mod import;
|
||||||
pub mod announcement;
|
pub mod announcement;
|
||||||
|
|
|
||||||
|
|
@ -267,7 +267,7 @@ pub fn build_vouch_grant_batch(
|
||||||
/// Minimum bucket is 8 (so a single-target post still publishes 8
|
/// Minimum bucket is 8 (so a single-target post still publishes 8
|
||||||
/// wrappers, hiding "this persona has no vouchees" entirely).
|
/// wrappers, hiding "this persona has no vouchees" entirely).
|
||||||
/// Power-of-2 up to 256; linear +128 steps above 256.
|
/// 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 <= 8 { return 8; }
|
||||||
if real <= 256 {
|
if real <= 256 {
|
||||||
// smallest power of 2 >= real
|
// smallest power of 2 >= real
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue