//! 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: //! - **Author publish path** ([`build_fof_comment_gating`]): seal wrap //! slots, generate per-V_x signing keypairs, bucket-pad, shuffle. //! - **Reader/commenter unlock path** ([`find_unlock_for_post`], //! [`build_fof_comment`]): trial-decrypt slots with any held V_x; //! if a slot opens, derive priv_x + comments-CEK, encrypt the //! comment body, sign with priv_x, attach pub_x_index. //! //! The CDN four-check verification path and revocation handling 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 with provenance: (V_x, owner, epoch). // The author's own V_me appears with owner=author_persona_id. 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 first sighting (which is // own_v_me for the author's slot, then received keys in storage // order). Per Layer 3 spec: one slot per unique V_x. let mut tagged_keys: Vec<([u8; 32], NodeId, u32)> = Vec::with_capacity(1 + received.len()); tagged_keys.push((own_v_me, *author_persona_id, own_epoch)); for (owner, epoch, key) in &received { if !tagged_keys.iter().any(|(existing, _, _)| existing == key) { tagged_keys.push((*key, *owner, *epoch)); } } // 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. // // We carry a `kind` tag through the shuffle so we can recover // (slot_index, owner, epoch, pub_x) afterward for provenance. enum EntryKind { Real { v_x_owner: NodeId, v_x_epoch: u32 }, Dummy, } let mut entries: Vec<(EntryKind, [u8; 32], WrapSlot)> = Vec::with_capacity(tagged_keys.len()); for (v_x, owner, epoch) in &tagged_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((EntryKind::Real { v_x_owner: *owner, v_x_epoch: *epoch }, pub_x, slot)); } // Pad to bucket with dummies. 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(( EntryKind::Dummy, 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 mut pub_post_set: Vec<[u8; 32]> = Vec::with_capacity(entries.len()); let mut wrap_slots: Vec = Vec::with_capacity(entries.len()); let mut real_slot_provenance: Vec = Vec::new(); for (idx, (kind, pub_x, slot)) in entries.into_iter().enumerate() { if let EntryKind::Real { v_x_owner, v_x_epoch } = kind { real_slot_provenance.push(RealSlotProvenance { slot_index: idx as u32, v_x_owner, v_x_epoch, pub_x, }); } pub_post_set.push(pub_x); wrap_slots.push(slot); } let gating = FoFCommentGating { slot_binder_nonce, pub_post_set, wrap_slots, revocation_list: Vec::new(), }; Ok(Some(FoFCommentGatingBuilt { gating, cek, slot_binder_nonce, real_slot_provenance, })) } /// Output of [`build_fof_comment_gating`]. The gating block goes into /// `Post.fof_gating`; the side outputs are author-local state the /// caller persists (CEK cache, `own_post_slot_provenance` for cascade /// revocations). #[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], /// FoF Layer 4 provenance: which V_x sealed which slot. Real /// slots only (dummies are excluded). Caller persists into /// `own_post_slot_provenance` for later cascade revocations. /// Each entry: (slot_index, v_x_owner, v_x_epoch, pub_x). pub real_slot_provenance: Vec, } /// One entry per real (non-dummy) slot in a published FoF post. #[derive(Debug, Clone)] pub struct RealSlotProvenance { pub slot_index: u32, /// Persona who issued the V_x this slot was sealed under. For the /// author's own slot this is the author themselves. pub v_x_owner: NodeId, /// V_x epoch — important for cascade revocation when an old /// epoch is retired. pub v_x_epoch: u32, pub pub_x: [u8; 32], } // --- Reader / commenter side --- /// FoF Layer 2: a persona's successful unlock of a gated post. /// Carries everything the persona needs to read encrypted comments AND /// author new ones. #[derive(Debug, Clone)] pub struct PostUnlock { /// Which persona unlocked the post (the V_x that matched belongs to /// this persona's keyring — owned `V_me` or received). pub persona_id: NodeId, /// Index into `pub_post_set` / `wrap_slots` of the slot that /// unlocked. Comments author by this persona will set /// `InlineComment.pub_x_index` to this. pub slot_index: u32, /// Per-post shared CEK (unwrapped from the slot's read part). pub cek: [u8; 32], /// Ed25519 signing seed for the per-V_x keypair admitted to this /// post (unwrapped from the slot's sign part). Used to sign /// `group_sig` on FoF comments. pub priv_x_seed: [u8; 32], } /// Trial-decrypt the post's `fof_gating.wrap_slots` against every /// persona on this device. Returns the first successful unlock found, /// or `None` if no held V_x matches. /// /// FoF Layer 5: consults `vouch_unlock_cache` first to skip directly /// to the V_x that worked last time for this `(persona, author)` pair. /// Falls back to a full scan on miss. On success, updates the cache; /// on failure, queues the post in `vouch_unreadable_posts` for later /// retry when a new V_x arrives. pub fn find_unlock_for_post( storage: &Storage, post: &crate::types::Post, ) -> Result> { let Some(gating) = post.fof_gating.as_ref() else { return Ok(None); }; let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); let post_id_for_cache = crate::content::compute_post_id(post); let personas = storage.list_posting_identities()?; // Fast path: try the cached winning V_x per (persona, author). for persona in &personas { let Some((owner, epoch)) = storage.lookup_unlock_cache(&persona.node_id, &post.author)? else { continue; }; let v_x = if owner == persona.node_id { storage.list_own_vouch_keys(&persona.node_id)? .into_iter() .find(|(e, _)| *e == epoch) .map(|(_, k)| k) } else { storage.list_received_vouch_keys(&persona.node_id)? .into_iter() .find(|(o, e, _)| *o == owner && *e == epoch) .map(|(_, _, k)| k) }; if let Some(v_x) = v_x { if let Some(unlock) = try_unlock_with_v_x(gating, &v_x, &persona.node_id) { storage.record_unlock_hit( &persona.node_id, &post.author, &owner, epoch, now_ms, )?; let _ = storage.clear_unreadable_post(&persona.node_id, &post_id_for_cache); return Ok(Some(unlock)); } // Cache stale (e.g., post was key-burned). Fall through to // full scan; next success overwrites the cache. } } // Full scan path. for persona in &personas { let mut keyring: Vec<([u8; 32], NodeId, u32)> = Vec::new(); if let Some((epoch, own_key)) = storage.current_own_vouch_key(&persona.node_id)? { keyring.push((own_key, persona.node_id, epoch)); } for (owner, epoch, key) in storage.list_received_vouch_keys(&persona.node_id)? { keyring.push((key, owner, epoch)); } for (v_x, owner, epoch) in &keyring { if let Some(unlock) = try_unlock_with_v_x(gating, v_x, &persona.node_id) { storage.record_unlock_hit( &persona.node_id, &post.author, owner, *epoch, now_ms, )?; let _ = storage.clear_unreadable_post(&persona.node_id, &post_id_for_cache); return Ok(Some(unlock)); } } // No held V_x unlocks this post for this persona — queue for // retry when a new V_x arrives in the keyring. let _ = storage.record_unreadable_post( &persona.node_id, &post_id_for_cache, &post.author, now_ms, ); } Ok(None) } /// Maximum posts processed per `sweep_unreadable_on_new_v_x` call. /// Bounds lock-hold time. Unprocessed entries remain queued and will /// be tried on the next V_x arrival or via a periodic background /// sweep (future Layer 5+ work). const MAX_SWEEP_PER_CALL: usize = 256; /// FoF Layer 5: when a persona acquires a new V_x, walk the /// unreadable-posts queue and re-attempt unlock. The new V_x can /// unlock posts authored by anyone (the author may hold the V_x's /// owner as one of their vouches). Successful unlocks populate /// `vouch_unlock_cache` + clear the queue entry as a side effect. /// /// Bounded to `MAX_SWEEP_PER_CALL` posts per invocation to cap /// lock-hold time and prevent spam-grant-triggered DoS. Remaining /// entries are processed on subsequent V_x arrivals. pub fn sweep_unreadable_on_new_v_x( storage: &Storage, holder_persona_id: &NodeId, _v_x_owner: &NodeId, ) -> Result { let post_ids = storage.list_all_unreadable_posts(holder_persona_id)?; let mut unlocked = 0usize; for post_id in post_ids.into_iter().take(MAX_SWEEP_PER_CALL) { let Some((post, _vis)) = storage.get_post_with_visibility(&post_id)? else { let _ = storage.clear_unreadable_post(holder_persona_id, &post_id); continue; }; // find_unlock_for_post records the cache hit + clears the // unreadable entry as a side effect on success. if find_unlock_for_post(storage, &post)?.is_some() { unlocked += 1; } } Ok(unlocked) } /// Inner helper: prefilter + AEAD-open against a single V_x. fn try_unlock_with_v_x( gating: &crate::types::FoFCommentGating, v_x: &[u8; 32], persona_id: &NodeId, ) -> Option { let prefilter = crate::crypto::wrap_slot_prefilter_tag(v_x, &gating.slot_binder_nonce); for (idx, slot) in gating.wrap_slots.iter().enumerate() { if slot.prefilter_tag != prefilter { continue; } if let Some(opened) = crate::crypto::open_wrap_slot( v_x, &gating.slot_binder_nonce, &slot.read_ciphertext, &slot.sign_ciphertext, ) { return Some(PostUnlock { persona_id: *persona_id, slot_index: idx as u32, cek: opened.cek, priv_x_seed: opened.priv_x_seed, }); } } None } /// FoF Layer 2: inner plaintext encrypted under CEK_comments. Wrapped /// inside [`crate::types::InlineComment::encrypted_payload`]. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct FoFCommentPayload { /// User-visible comment body. pub body: String, /// Optional reply parent. #[serde(default)] pub parent_comment_id: Option<[u8; 32]>, /// HMAC(V_x, post_id || comment_hash)[:16B] — author-side /// attribution to a specific voucher-chain. Computed by the /// commenter; not yet enforced in this commit. #[serde(default)] pub vouch_mac: Option<[u8; 16]>, } /// Build a Mode 2 / Mode 1 FoF comment on a parent post. The post must /// have `fof_gating = Some`; the caller must already hold a successful /// `PostUnlock` for it (typically from [`find_unlock_for_post`]). /// /// Produces an `InlineComment` with empty `content` (the body lives /// encrypted in `encrypted_payload`) + `pub_x_index` + `group_sig` + /// `encrypted_payload`. The conventional `signature` field carries the /// commenter's identity-key signature over the existing payload (so /// non-FoF nodes can still verify identity without unwrapping the slot). pub fn build_fof_comment( parent_post_id: &[u8; 32], unlock: &PostUnlock, slot_binder_nonce: &[u8; 32], commenter_id: &NodeId, commenter_secret: &[u8; 32], body: &str, parent_comment_id: Option<[u8; 32]>, now_ms: u64, ) -> Result { use ed25519_dalek::{Signer, SigningKey}; // Encrypt the comment body under CEK_comments derived from the // post's CEK + slot_binder_nonce. let cek_comments = crate::crypto::derive_cek_comments(&unlock.cek, slot_binder_nonce); let payload = FoFCommentPayload { body: body.to_string(), parent_comment_id, vouch_mac: None, }; let plaintext = serde_json::to_vec(&payload) .map_err(|e| anyhow::anyhow!("serialize fof comment payload: {}", e))?; let encrypted = crate::crypto::encrypt_bytes_with_cek(&plaintext, &cek_comments)?; // Sign (encrypted || post_id || pub_x_index_le) with priv_x. let priv_x_signer = SigningKey::from_bytes(&unlock.priv_x_seed); let mut to_sign = Vec::with_capacity(encrypted.len() + 32 + 4); to_sign.extend_from_slice(&encrypted); to_sign.extend_from_slice(parent_post_id); to_sign.extend_from_slice(&unlock.slot_index.to_le_bytes()); let group_sig = priv_x_signer.sign(&to_sign).to_bytes().to_vec(); // Conventional InlineComment.signature: commenter's identity-key // signature over the existing comment_signature tuple. We use empty // content here (the body lives in encrypted_payload). Existing // non-FoF receivers can still verify the identity sig and tombstone // logic continues to work. let identity_signature = crate::crypto::sign_comment( commenter_secret, commenter_id, parent_post_id, "", now_ms, None, ); Ok(crate::types::InlineComment { author: *commenter_id, post_id: *parent_post_id, content: String::new(), timestamp_ms: now_ms, signature: identity_signature, deleted_at: None, ref_post_id: None, pub_x_index: Some(unlock.slot_index), group_sig: Some(group_sig), encrypted_payload: Some(encrypted), }) } /// Verify the `group_sig` on an incoming FoF comment against the post's /// `pub_post_set`. Used by the CDN four-check accept rule (next slice). /// Returns `true` iff the comment carries a valid Ed25519 signature /// under `pub_post_set[pub_x_index]` over /// (encrypted_payload || post_id || pub_x_index_le). pub fn verify_fof_group_sig( comment: &crate::types::InlineComment, gating: &FoFCommentGating, ) -> bool { use ed25519_dalek::{Signature, Verifier, VerifyingKey}; let Some(pub_x_index) = comment.pub_x_index else { return false; }; let Some(group_sig) = comment.group_sig.as_ref() else { return false; }; let Some(encrypted_payload) = comment.encrypted_payload.as_ref() else { return false; }; let idx = pub_x_index as usize; if idx >= gating.pub_post_set.len() { return false; } if group_sig.len() != 64 { return false; } let pub_x = &gating.pub_post_set[idx]; let Ok(verifying_key) = VerifyingKey::from_bytes(pub_x) else { return false; }; let sig_bytes: [u8; 64] = match group_sig.as_slice().try_into() { Ok(b) => b, Err(_) => return false, }; let sig = Signature::from_bytes(&sig_bytes); let mut to_verify = Vec::with_capacity(encrypted_payload.len() + 32 + 4); to_verify.extend_from_slice(encrypted_payload); to_verify.extend_from_slice(&comment.post_id); to_verify.extend_from_slice(&pub_x_index.to_le_bytes()); verifying_key.verify(&to_verify, &sig).is_ok() } /// Decrypt the `encrypted_payload` of an FoF comment back to its /// plaintext body / vouch_mac / parent_comment_id, using the CEK /// recovered via [`find_unlock_for_post`]. pub fn decrypt_fof_comment_payload( comment: &crate::types::InlineComment, cek: &[u8; 32], slot_binder_nonce: &[u8; 32], ) -> Result { let encrypted = comment.encrypted_payload.as_ref() .ok_or_else(|| anyhow::anyhow!("comment has no encrypted_payload"))?; let cek_comments = crate::crypto::derive_cek_comments(cek, slot_binder_nonce); let plaintext = crate::crypto::decrypt_bytes_with_cek(encrypted, &cek_comments)?; serde_json::from_slice(&plaintext) .map_err(|e| anyhow::anyhow!("deserialize fof comment payload: {}", e)) } // --- Wire-shape validation for incoming posts (DoS hardening) --- // // Called from control::receive_post before any storage write. Rejects // malformed FoF gating blocks so they never enter storage and get // re-propagated via neighbor-manifest diffs. /// Maximum allowed wrap_slots / pub_post_set entries on an incoming /// FoF post. The bucket rule caps at `real + rand(0..=128)` above 256; /// at a 4096-vouchee max realistic graph that's ~4224. Round up for /// headroom; anything larger is presumed attacker-shaped. const MAX_FOF_WRAP_SLOTS: usize = 8192; /// Maximum allowed revocation_list entries in a t=0 published gating /// block. Initial publish should have an EMPTY list; receivers /// accumulate revocations via diffs into the live fof_revocations /// table. A non-empty list on first publish is suspicious but not /// strictly invalid — bound it generously. const MAX_FOF_REVOCATION_LIST: usize = 4096; /// Validate a FoF gating block on receive. Rejects: /// - wrap_slots / pub_post_set length mismatch (indexing invariant) /// - bucket-size violation (DoS bound) /// - wrong WrapSlot ciphertext sizes (always 48 bytes today) /// - oversized revocation_list (DoS bound) /// /// Sound caller pattern (control::receive_post): /// - Visibility == FoFClosed implies post.fof_gating MUST be Some. /// - Any post with fof_gating Some passes this check. pub fn validate_fof_gating_on_receive(post: &crate::types::Post) -> Result<()> { // Invariant: FoFClosed visibility implies Some gating. // (No way to recover the body without it.) // The visibility is checked by the caller; this validates the // gating shape when it's present. let Some(gating) = post.fof_gating.as_ref() else { return Ok(()); }; // 1:1 invariant: every wrap_slot has a matching pub_post_set entry // so pub_x_index lookups always succeed within bounds. if gating.wrap_slots.len() != gating.pub_post_set.len() { anyhow::bail!( "FoF wrap_slots/pub_post_set length mismatch: {} vs {}", gating.wrap_slots.len(), gating.pub_post_set.len(), ); } // Bucket-size cap — bounds memory + scan cost. if gating.wrap_slots.len() > MAX_FOF_WRAP_SLOTS { anyhow::bail!( "FoF wrap_slots oversized: {} > {} (DoS cap)", gating.wrap_slots.len(), MAX_FOF_WRAP_SLOTS, ); } // Per-slot field-size invariants. seal_wrap_slot always produces // 48-byte ciphertexts (32B sealed plaintext + 16B AEAD tag); any // other size is malformed and shouldn't tie up AEAD attempts. for (i, slot) in gating.wrap_slots.iter().enumerate() { if slot.read_ciphertext.len() != 48 || slot.sign_ciphertext.len() != 48 { anyhow::bail!( "FoF wrap_slot {} has wrong ciphertext sizes: read={} sign={} (must both be 48)", i, slot.read_ciphertext.len(), slot.sign_ciphertext.len(), ); } } // Bound revocation_list size — revocations should arrive as diffs, // not be stuffed into the t=0 publish. Cap generously. if gating.revocation_list.len() > MAX_FOF_REVOCATION_LIST { anyhow::bail!( "FoF revocation_list oversized: {} > {} (DoS cap)", gating.revocation_list.len(), MAX_FOF_REVOCATION_LIST, ); } Ok(()) } /// Companion check: enforce the visibility-implies-gating invariant. /// Called from `control::receive_post` after the gating-shape check. pub fn validate_fof_closed_has_gating( post: &crate::types::Post, visibility: &crate::types::PostVisibility, ) -> Result<()> { if matches!(visibility, crate::types::PostVisibility::FoFClosed) && post.fof_gating.is_none() { anyhow::bail!("FoFClosed visibility requires a fof_gating block"); } Ok(()) } // --- Mode 1 (FoFClosed) body encryption --- // // Mode 1 reuses Layer 2's wrap_slots and CEK. The only addition is // that the post body is itself encrypted under that CEK before // publish; readers who can unlock a slot (recovering CEK) decrypt the // body too. Non-FoF readers see only the ciphertext; they can still // store + propagate the post as a CDN host without decrypting. /// FoF Layer 3: encrypt a post body under the gating CEK, padded to a /// bucket per the spec. Output is `body_bucket_padding_nonce(12) || /// real_len_u32_le || ciphertext_with_tag(body+pad+16)` so the reader /// can recover the original byte length after decryption. pub fn encrypt_fof_body( body: &str, cek: &[u8; 32], slot_binder_nonce: &[u8; 32], ) -> Result> { use chacha20poly1305::{aead::{Aead, KeyInit}, ChaCha20Poly1305, Nonce}; use rand::RngCore; let real = body.as_bytes(); // Pre-tag size we want to encrypt = 4-byte length prefix + body + padding. let prefixed_len = 4 + real.len(); let bucket = next_body_size_bucket(prefixed_len); let mut plaintext = Vec::with_capacity(bucket); plaintext.extend_from_slice(&(real.len() as u32).to_le_bytes()); plaintext.extend_from_slice(real); // Pad with random bytes (NOT zeros — zeros would be a small // distinguisher under known-plaintext but doesn't matter under // AEAD; random is defense-in-depth.) let mut pad = vec![0u8; bucket.saturating_sub(plaintext.len())]; rand::rng().fill_bytes(&mut pad); plaintext.extend_from_slice(&pad); // Derive a per-body nonce-context key so we don't reuse the slot // AEAD nonces. blake3-derive over (cek, slot_binder_nonce) gives // us a stable seed; we then prepend a fresh random 12B nonce. let cipher = ChaCha20Poly1305::new_from_slice(cek) .map_err(|e| anyhow::anyhow!("body cipher init: {}", e))?; let mut nonce_bytes = [0u8; 12]; rand::rng().fill_bytes(&mut nonce_bytes); // AAD = slot_binder_nonce. Binds body decrypt to the post's gating // (an attacker who steals the body ciphertext + CEK can't decrypt // against a different post's wrap slots). let ciphertext = cipher .encrypt( Nonce::from_slice(&nonce_bytes), chacha20poly1305::aead::Payload { msg: &plaintext, aad: slot_binder_nonce, }, ) .map_err(|e| anyhow::anyhow!("body encrypt: {}", e))?; let mut out = Vec::with_capacity(12 + ciphertext.len()); out.extend_from_slice(&nonce_bytes); out.extend_from_slice(&ciphertext); Ok(out) } /// FoF Layer 3: decrypt a body sealed by [`encrypt_fof_body`]. Returns /// the original `String` body (padding stripped via the 4-byte length /// prefix). pub fn decrypt_fof_body( encrypted: &[u8], cek: &[u8; 32], slot_binder_nonce: &[u8; 32], ) -> Result { use chacha20poly1305::{aead::{Aead, KeyInit}, ChaCha20Poly1305, Nonce}; if encrypted.len() < 12 + 16 { anyhow::bail!("encrypted body too short"); } let nonce = Nonce::from_slice(&encrypted[..12]); let cipher = ChaCha20Poly1305::new_from_slice(cek) .map_err(|e| anyhow::anyhow!("body cipher init: {}", e))?; let plaintext = cipher .decrypt( nonce, chacha20poly1305::aead::Payload { msg: &encrypted[12..], aad: slot_binder_nonce, }, ) .map_err(|e| anyhow::anyhow!("body decrypt: {}", e))?; if plaintext.len() < 4 { anyhow::bail!("body plaintext missing length prefix"); } let real_len = u32::from_le_bytes(plaintext[..4].try_into().unwrap()) as usize; if 4 + real_len > plaintext.len() { anyhow::bail!("body length prefix overruns plaintext"); } let body_bytes = &plaintext[4..4 + real_len]; String::from_utf8(body_bytes.to_vec()) .map_err(|e| anyhow::anyhow!("body not valid UTF-8: {}", e)) } /// FoF Layer 3 body-size bucket rule. /// Power-of-2 from a minimum of 1 KiB up to 256 KiB, then linear /// +256 KiB steps above. Hides body length within meaningful brackets. pub(crate) fn next_body_size_bucket(real: usize) -> usize { const MIN_BUCKET: usize = 1024; const POW2_CEIL: usize = 256 * 1024; const LINEAR_STEP: usize = 256 * 1024; if real <= MIN_BUCKET { return MIN_BUCKET; } if real <= POW2_CEIL { let mut b = MIN_BUCKET; while b < real { b *= 2; } return b; } // 256K, 512K, 768K, ... let above = real - POW2_CEIL; let steps = (above + LINEAR_STEP - 1) / LINEAR_STEP; POW2_CEIL + steps * LINEAR_STEP } // --- Revocation: sign + verify + apply --- /// Bytes covered by a `BlobHeaderDiffOp::FoFRevocation.author_sig`. /// Constructed identically on both ends so the verify is deterministic. fn fof_revocation_signing_bytes( post_id: &[u8; 32], revoked_pub_x: &[u8; 32], revoked_at_ms: u64, reason_code: u8, ) -> [u8; 32 + 32 + 8 + 1] { let mut out = [0u8; 32 + 32 + 8 + 1]; out[..32].copy_from_slice(post_id); out[32..64].copy_from_slice(revoked_pub_x); out[64..72].copy_from_slice(&revoked_at_ms.to_le_bytes()); out[72] = reason_code; out } /// Author-side: sign a revocation entry with the post author's /// identity secret. Returns the 64-byte Ed25519 signature. pub fn sign_fof_revocation( author_secret: &[u8; 32], post_id: &[u8; 32], revoked_pub_x: &[u8; 32], revoked_at_ms: u64, reason_code: u8, ) -> Vec { use ed25519_dalek::{Signer, SigningKey}; let signing_key = SigningKey::from_bytes(author_secret); let bytes = fof_revocation_signing_bytes(post_id, revoked_pub_x, revoked_at_ms, reason_code); signing_key.sign(&bytes).to_bytes().to_vec() } /// CDN-side: verify a revocation entry's author_sig against the post /// author's public key. Returns `false` on any shape/key/signature /// failure. pub fn verify_fof_revocation( post_author: &NodeId, post_id: &[u8; 32], revoked_pub_x: &[u8; 32], revoked_at_ms: u64, reason_code: u8, author_sig: &[u8], ) -> bool { use ed25519_dalek::{Signature, Verifier, VerifyingKey}; if author_sig.len() != 64 { return false; } let sig_bytes: [u8; 64] = match author_sig.try_into() { Ok(b) => b, Err(_) => return false, }; let sig = Signature::from_bytes(&sig_bytes); let Ok(verifying_key) = VerifyingKey::from_bytes(post_author) else { return false; }; let bytes = fof_revocation_signing_bytes(post_id, revoked_pub_x, revoked_at_ms, reason_code); verifying_key.verify(&bytes, &sig).is_ok() } // --- Access-grant: sign + verify + apply --- /// Bytes covered by a `BlobHeaderDiffOp::FoFAccessGrant.author_sig`. /// Wrap-slot is canonicalized by serializing its three fields in order: /// prefilter_tag || read_ciphertext || sign_ciphertext. fn fof_access_grant_signing_bytes( post_id: &[u8; 32], new_pub_x: &[u8; 32], new_wrap_slot: &crate::types::WrapSlot, granted_at_ms: u64, ) -> Vec { let mut out = Vec::with_capacity(32 + 32 + 2 + 48 + 48 + 8); out.extend_from_slice(post_id); out.extend_from_slice(new_pub_x); out.extend_from_slice(&new_wrap_slot.prefilter_tag); out.extend_from_slice(&new_wrap_slot.read_ciphertext); out.extend_from_slice(&new_wrap_slot.sign_ciphertext); out.extend_from_slice(&granted_at_ms.to_le_bytes()); out } /// Author-side: sign an access-grant entry. pub fn sign_fof_access_grant( author_secret: &[u8; 32], post_id: &[u8; 32], new_pub_x: &[u8; 32], new_wrap_slot: &crate::types::WrapSlot, granted_at_ms: u64, ) -> Vec { use ed25519_dalek::{Signer, SigningKey}; let signing_key = SigningKey::from_bytes(author_secret); let bytes = fof_access_grant_signing_bytes(post_id, new_pub_x, new_wrap_slot, granted_at_ms); signing_key.sign(&bytes).to_bytes().to_vec() } /// CDN-side: verify an access-grant entry's author_sig. pub fn verify_fof_access_grant( post_author: &NodeId, post_id: &[u8; 32], new_pub_x: &[u8; 32], new_wrap_slot: &crate::types::WrapSlot, granted_at_ms: u64, author_sig: &[u8], ) -> bool { use ed25519_dalek::{Signature, Verifier, VerifyingKey}; if author_sig.len() != 64 { return false; } let sig_bytes: [u8; 64] = match author_sig.try_into() { Ok(b) => b, Err(_) => return false, }; let sig = Signature::from_bytes(&sig_bytes); let Ok(verifying_key) = VerifyingKey::from_bytes(post_author) else { return false; }; let bytes = fof_access_grant_signing_bytes(post_id, new_pub_x, new_wrap_slot, granted_at_ms); verifying_key.verify(&bytes, &sig).is_ok() } // --- Key burn: sign + verify + apply (Layer 4) --- fn fof_key_burn_signing_bytes( post_id: &[u8; 32], slot_index: u32, new_pub_x: &[u8; 32], new_wrap_slot: &crate::types::WrapSlot, burned_at_ms: u64, ) -> Vec { let mut out = Vec::with_capacity(32 + 4 + 32 + 2 + 48 + 48 + 8); out.extend_from_slice(post_id); out.extend_from_slice(&slot_index.to_le_bytes()); out.extend_from_slice(new_pub_x); out.extend_from_slice(&new_wrap_slot.prefilter_tag); out.extend_from_slice(&new_wrap_slot.read_ciphertext); out.extend_from_slice(&new_wrap_slot.sign_ciphertext); out.extend_from_slice(&burned_at_ms.to_le_bytes()); out } pub fn sign_fof_key_burn( author_secret: &[u8; 32], post_id: &[u8; 32], slot_index: u32, new_pub_x: &[u8; 32], new_wrap_slot: &crate::types::WrapSlot, burned_at_ms: u64, ) -> Vec { use ed25519_dalek::{Signer, SigningKey}; let signing_key = SigningKey::from_bytes(author_secret); let bytes = fof_key_burn_signing_bytes(post_id, slot_index, new_pub_x, new_wrap_slot, burned_at_ms); signing_key.sign(&bytes).to_bytes().to_vec() } pub fn verify_fof_key_burn( post_author: &NodeId, post_id: &[u8; 32], slot_index: u32, new_pub_x: &[u8; 32], new_wrap_slot: &crate::types::WrapSlot, burned_at_ms: u64, author_sig: &[u8], ) -> bool { use ed25519_dalek::{Signature, Verifier, VerifyingKey}; if author_sig.len() != 64 { return false; } let sig_bytes: [u8; 64] = match author_sig.try_into() { Ok(b) => b, Err(_) => return false, }; let sig = Signature::from_bytes(&sig_bytes); let Ok(verifying_key) = VerifyingKey::from_bytes(post_author) else { return false; }; let bytes = fof_key_burn_signing_bytes(post_id, slot_index, new_pub_x, new_wrap_slot, burned_at_ms); verifying_key.verify(&bytes, &sig).is_ok() } /// Apply a verified key-burn to local storage. Replaces the wrap_slot /// + pub_x at the indicated slot in the stored post's fof_gating. The /// old key's holders can no longer decrypt this post via this slot /// (locally-cached plaintext on already-read devices is out of scope). /// /// `burned_at_ms` enforces monotonic ordering: older signed key-burn /// diffs cannot revert newer state. pub fn apply_fof_key_burn_locally( storage: &Storage, post_id: &[u8; 32], slot_index: u32, new_pub_x: &[u8; 32], new_wrap_slot: &crate::types::WrapSlot, burned_at_ms: u64, ) -> Result { storage.replace_fof_slot(post_id, slot_index, new_pub_x, new_wrap_slot, burned_at_ms) } /// Apply a verified access-grant to local storage. Appends the new /// (pub_x, wrap_slot) at the tail of the stored post's fof_gating. /// Idempotent on `(post_id, new_pub_x)`. Must only be called after /// `verify_fof_access_grant` returns true. /// /// Refuses to apply if `new_pub_x` is already in the post's /// revocation_list (prevents accidental re-admission of a previously- /// revoked signer; spec-resolved). pub fn apply_fof_access_grant_locally( storage: &Storage, post_id: &[u8; 32], new_pub_x: &[u8; 32], new_wrap_slot: &crate::types::WrapSlot, ) -> Result { if storage.is_fof_pub_x_revoked(post_id, new_pub_x)? { return Ok(false); } let appended = storage.append_fof_access_grant(post_id, new_pub_x, new_wrap_slot)?; Ok(appended) } /// Apply a verified revocation to local storage + cascade delete /// already-stored comments. Idempotent on `(post_id, revoked_pub_x)`. /// Returns the count of comments deleted. /// /// Must only be called after `verify_fof_revocation` returns true. /// The caller (CDN receive path) is responsible for that gate. pub fn apply_fof_revocation_locally( storage: &Storage, post_id: &[u8; 32], revoked_pub_x: &[u8; 32], revoked_at_ms: u64, reason_code: u8, author_sig: &[u8], ) -> Result { storage.add_fof_revocation(post_id, revoked_pub_x, revoked_at_ms, reason_code, author_sig)?; // Resolve pub_x -> pub_x_index via the post's pub_post_set, then // cascade-delete locally-stored comments matching that index. let Some(post) = storage.get_post(post_id)? else { return Ok(0); }; let Some(gating) = post.fof_gating.as_ref() else { return Ok(0); }; let mut deleted = 0; for (idx, pub_x) in gating.pub_post_set.iter().enumerate() { if pub_x == revoked_pub_x { deleted += storage.delete_fof_comments_by_pub_x_index(post_id, idx as u32)?; } } Ok(deleted) } #[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); } /// End-to-end FoF comment roundtrip: /// 1. Alice authors a Mode 2 FoF post (her keyring: own V_me + Bob's V_x). /// 2. A "receiver device" (Bob's) holds Bob's V_x. It finds the unlock. /// 3. Bob authors a comment on Alice's post. /// 4. The comment's group_sig verifies against the post's pub_post_set. /// 5. Alice (using her cached cek) decrypts Bob's comment plaintext. #[test] fn fof_comment_authoring_roundtrip() { use crate::types::PostingIdentity; use ed25519_dalek::SigningKey; // Alice's device: has her V_me and Bob's V_x (received from Bob). let alice_storage = temp_storage(); let (alice_id, alice_seed) = make_persona(1); alice_storage.upsert_posting_identity(&PostingIdentity { node_id: alice_id, secret_seed: alice_seed, display_name: "Alice".into(), created_at: 1000, }).unwrap(); let mut v_me_alice = [0u8; 32]; rand::rng().fill_bytes(&mut v_me_alice); alice_storage.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap(); let (bob_id, _) = make_persona(2); let mut v_x_bob = [0u8; 32]; rand::rng().fill_bytes(&mut v_x_bob); alice_storage.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap(); // Alice publishes the gating block. let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built"); let parent_post_id = [0xCC; 32]; // Bob's device: has his V_me (which is v_x_bob since he handed it out). let bob_storage = temp_storage(); let (bob_id_, bob_seed) = make_persona(2); assert_eq!(bob_id, bob_id_); bob_storage.upsert_posting_identity(&PostingIdentity { node_id: bob_id, secret_seed: bob_seed, display_name: "Bob".into(), created_at: 1500, }).unwrap(); bob_storage.insert_own_vouch_key(&bob_id, 1, &v_x_bob, 1500).unwrap(); // Wrap Alice's published gating into a Post struct as it would // appear on the wire. let post = crate::types::Post { author: alice_id, content: "alice's public post".into(), attachments: vec![], timestamp_ms: 3000, fof_gating: Some(built.gating.clone()), supersedes_post_id: None, }; // Bob's device unlocks the post via his V_me (= v_x_bob). let unlock = find_unlock_for_post(&bob_storage, &post).unwrap() .expect("Bob's persona must unlock the post"); assert_eq!(unlock.persona_id, bob_id); assert_eq!(unlock.cek, built.cek, "Bob recovers Alice's CEK"); // Bob authors a comment. let comment = build_fof_comment( &parent_post_id, &unlock, &built.slot_binder_nonce, &bob_id, &bob_seed, "great post alice", None, 4000, ).unwrap(); assert!(comment.content.is_empty(), "FoF comment body is encrypted, not in content"); assert!(comment.pub_x_index.is_some()); assert!(comment.group_sig.is_some()); assert!(comment.encrypted_payload.is_some()); // CDN-level verification: group_sig validates against pub_post_set. assert!(verify_fof_group_sig(&comment, &built.gating), "valid FoF comment must pass CDN four-check (group_sig leg)"); // Tamper detection: flip a byte in encrypted_payload and re-verify. let mut tampered = comment.clone(); let mut payload = tampered.encrypted_payload.clone().unwrap(); payload[0] ^= 0x01; tampered.encrypted_payload = Some(payload); assert!(!verify_fof_group_sig(&tampered, &built.gating), "tampered encrypted_payload must invalidate group_sig"); // Tamper detection: claim a different pub_x_index. let mut wrong_idx = comment.clone(); let bad_idx = (comment.pub_x_index.unwrap() + 1) % (built.gating.pub_post_set.len() as u32); wrong_idx.pub_x_index = Some(bad_idx); assert!(!verify_fof_group_sig(&wrong_idx, &built.gating), "wrong pub_x_index must invalidate group_sig"); // Alice (using her cached CEK) decrypts Bob's comment payload. let plaintext = decrypt_fof_comment_payload(&comment, &built.cek, &built.slot_binder_nonce) .expect("Alice decrypts FoF comment"); assert_eq!(plaintext.body, "great post alice"); let _signing_key = SigningKey::from_bytes(&unlock.priv_x_seed); // exercise the import } /// Revocation roundtrip: author publishes a Mode 2 FoF post, Bob /// comments, author signs a revocation, apply_fof_revocation_locally /// records it + cascade-deletes Bob's comment. #[test] fn fof_revocation_cascades() { use crate::types::PostingIdentity; let s = temp_storage(); let (alice_id, alice_seed) = make_persona(33); s.upsert_posting_identity(&PostingIdentity { node_id: alice_id, secret_seed: alice_seed, display_name: "Alice".into(), created_at: 1000, }).unwrap(); 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(); let (bob_id, bob_seed) = make_persona(44); 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("built"); let post_id = [0xDE; 32]; // Persist the post so apply_fof_revocation_locally can resolve // pub_x → pub_x_index via the post's pub_post_set. let post = crate::types::Post { author: alice_id, content: "alice".into(), attachments: vec![], timestamp_ms: 3000, fof_gating: Some(built.gating.clone()), supersedes_post_id: None, }; s.store_post_with_intent( &post_id, &post, &crate::types::PostVisibility::Public, &crate::types::VisibilityIntent::Public, ).unwrap(); // Bob unlocks via his V_x and authors a comment. Persist it // through the public store_comment path so the cascade-delete // has something to clean up. let bob_unlock = PostUnlock { persona_id: bob_id, slot_index: built.gating.pub_post_set.iter().position(|p| { // Find a slot Bob's V_x unlocks. let opened = crate::crypto::open_wrap_slot( &v_x_bob, &built.slot_binder_nonce, &built.gating.wrap_slots[built.gating.pub_post_set.iter().position(|x| x == p).unwrap()].read_ciphertext, &built.gating.wrap_slots[built.gating.pub_post_set.iter().position(|x| x == p).unwrap()].sign_ciphertext, ); opened.is_some() }).expect("Bob's slot exists") as u32, cek: built.cek, priv_x_seed: { // Re-derive by re-unwrapping. let mut seed = [0u8; 32]; for slot in &built.gating.wrap_slots { if let Some(o) = crate::crypto::open_wrap_slot( &v_x_bob, &built.slot_binder_nonce, &slot.read_ciphertext, &slot.sign_ciphertext, ) { seed = o.priv_x_seed; break; } } seed }, }; let comment = build_fof_comment( &post_id, &bob_unlock, &built.slot_binder_nonce, &bob_id, &bob_seed, "hello", None, 4000, ).unwrap(); s.store_comment(&comment).unwrap(); assert_eq!(s.get_comments(&post_id).unwrap().len(), 1, "Bob's comment stored"); // Resolve Bob's pub_x bytes from the gating's pub_post_set. let bob_pub_x = built.gating.pub_post_set[bob_unlock.slot_index as usize]; // Author signs + applies revocation. let revoked_at = 5000; let sig = sign_fof_revocation(&alice_seed, &post_id, &bob_pub_x, revoked_at, 0); assert!(verify_fof_revocation(&alice_id, &post_id, &bob_pub_x, revoked_at, 0, &sig)); let deleted = apply_fof_revocation_locally( &s, &post_id, &bob_pub_x, revoked_at, 0, &sig, ).unwrap(); assert_eq!(deleted, 1, "Bob's comment retroactively deleted"); assert!(s.get_comments(&post_id).unwrap().is_empty(), "no live comments remain on the post"); // Verify revocation is recorded for future CDN-verify lookups. assert!(s.is_fof_pub_x_revoked(&post_id, &bob_pub_x).unwrap()); // Idempotent: re-apply is a no-op (returns 0 because comment already gone). let re_deleted = apply_fof_revocation_locally( &s, &post_id, &bob_pub_x, revoked_at, 0, &sig, ).unwrap(); assert_eq!(re_deleted, 0); } /// Access-grant roundtrip: Alice publishes a Mode 2 FoF post, then /// later vouches for Carol. Alice signs an access-grant adding /// Carol's V_x to the post. apply_fof_access_grant_locally appends /// the new slot; Carol's device can now unlock the post. #[test] fn fof_access_grant_appends_and_unlocks() { use crate::types::PostingIdentity; let s = temp_storage(); let (alice_id, alice_seed) = make_persona(60); s.upsert_posting_identity(&PostingIdentity { node_id: alice_id, secret_seed: alice_seed, display_name: "Alice".into(), created_at: 1000, }).unwrap(); 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(); // Initial gating: Alice only. let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); let post_id = [0xBC; 32]; let post = crate::types::Post { author: alice_id, content: "alice".into(), attachments: vec![], timestamp_ms: 3000, fof_gating: Some(built.gating.clone()), supersedes_post_id: None, }; s.store_post_with_intent( &post_id, &post, &crate::types::PostVisibility::Public, &crate::types::VisibilityIntent::Public, ).unwrap(); // Carol's V_x (Carol vouches for herself or is granted access). let mut v_x_carol = [0u8; 32]; rand::rng().fill_bytes(&mut v_x_carol); // Pre-grant: Carol can NOT unlock the post via her V_x. let pre_post = s.get_post(&post_id).unwrap().unwrap(); let pre_unlock = pre_post.fof_gating.as_ref() .map(|g| g.wrap_slots.iter().any(|slot| { crate::crypto::open_wrap_slot( &v_x_carol, &g.slot_binder_nonce, &slot.read_ciphertext, &slot.sign_ciphertext, ).is_some() })) .unwrap_or(false); assert!(!pre_unlock, "Carol cannot unlock pre-grant"); // Alice authors an access-grant: seal a new slot under Carol's V_x. let mut new_priv_x_seed = [0u8; 32]; rand::rng().fill_bytes(&mut new_priv_x_seed); let new_pub_x = SigningKey::from_bytes(&new_priv_x_seed) .verifying_key().to_bytes(); let sealed = crate::crypto::seal_wrap_slot( &v_x_carol, &built.slot_binder_nonce, &built.cek, &new_priv_x_seed, ).unwrap(); let new_wrap_slot = crate::types::WrapSlot { prefilter_tag: sealed.prefilter_tag, read_ciphertext: sealed.read_ciphertext, sign_ciphertext: sealed.sign_ciphertext, }; let granted_at = 5000; let sig = sign_fof_access_grant( &alice_seed, &post_id, &new_pub_x, &new_wrap_slot, granted_at, ); assert!(verify_fof_access_grant( &alice_id, &post_id, &new_pub_x, &new_wrap_slot, granted_at, &sig, )); let applied = apply_fof_access_grant_locally( &s, &post_id, &new_pub_x, &new_wrap_slot, ).unwrap(); assert!(applied, "access-grant appended"); // Post-grant: stored post's gating now includes Carol's slot. let post = s.get_post(&post_id).unwrap().unwrap(); let g = post.fof_gating.as_ref().unwrap(); let unlocked = g.wrap_slots.iter().any(|slot| { crate::crypto::open_wrap_slot( &v_x_carol, &g.slot_binder_nonce, &slot.read_ciphertext, &slot.sign_ciphertext, ).map(|o| o.cek == built.cek).unwrap_or(false) }); assert!(unlocked, "Carol can now unlock the post and recover Alice's CEK"); // Idempotent: re-applying the same grant is a no-op. let again = apply_fof_access_grant_locally( &s, &post_id, &new_pub_x, &new_wrap_slot, ).unwrap(); assert!(!again, "duplicate grant skipped"); // Revocation blocks re-admission. s.add_fof_revocation(&post_id, &new_pub_x, 6000, 0, &[0u8; 64]).unwrap(); let blocked = apply_fof_access_grant_locally( &s, &post_id, &new_pub_x, &new_wrap_slot, ).unwrap(); assert!(!blocked, "revoked pub_x must not be re-granted access"); } /// Key-burn roundtrip: Alice publishes a Mode 2 FoF post sealed /// under V_me_old; signs + applies a key-burn that swaps the slot /// to a new V_me. After burn, old V_me cannot unlock the burned /// slot but new V_me can. #[test] fn fof_key_burn_replaces_slot() { use crate::types::PostingIdentity; use ed25519_dalek::SigningKey; let s = temp_storage(); let (alice_id, alice_seed) = make_persona(90); s.upsert_posting_identity(&PostingIdentity { node_id: alice_id, secret_seed: alice_seed, display_name: "Alice".into(), created_at: 1000, }).unwrap(); // Alice has V_me_old. Build a gating block with just her slot. let mut v_me_old = [0u8; 32]; rand::rng().fill_bytes(&mut v_me_old); s.insert_own_vouch_key(&alice_id, 1, &v_me_old, 1000).unwrap(); let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); let post_id = [0xAB; 32]; let post = crate::types::Post { author: alice_id, content: "x".into(), attachments: vec![], timestamp_ms: 3000, fof_gating: Some(built.gating.clone()), supersedes_post_id: None, }; s.store_post_with_intent( &post_id, &post, &crate::types::PostVisibility::Public, &crate::types::VisibilityIntent::Public, ).unwrap(); // Find Alice's slot (the one V_me_old unlocks). let alice_slot_idx = (0..built.gating.wrap_slots.len()).find(|&i| { crate::crypto::open_wrap_slot( &v_me_old, &built.slot_binder_nonce, &built.gating.wrap_slots[i].read_ciphertext, &built.gating.wrap_slots[i].sign_ciphertext, ).is_some() }).expect("alice slot exists"); // Simulate leak: Alice burns the slot under a new V_me. let mut v_me_new = [0u8; 32]; rand::rng().fill_bytes(&mut v_me_new); let mut new_seed = [0u8; 32]; rand::rng().fill_bytes(&mut new_seed); let new_pub_x = SigningKey::from_bytes(&new_seed).verifying_key().to_bytes(); let sealed = crate::crypto::seal_wrap_slot( &v_me_new, &built.slot_binder_nonce, &built.cek, &new_seed, ).unwrap(); let new_wrap_slot = crate::types::WrapSlot { prefilter_tag: sealed.prefilter_tag, read_ciphertext: sealed.read_ciphertext, sign_ciphertext: sealed.sign_ciphertext, }; let burned_at = 5000; let sig = sign_fof_key_burn( &alice_seed, &post_id, alice_slot_idx as u32, &new_pub_x, &new_wrap_slot, burned_at, ); assert!(verify_fof_key_burn( &alice_id, &post_id, alice_slot_idx as u32, &new_pub_x, &new_wrap_slot, burned_at, &sig, )); let applied = apply_fof_key_burn_locally( &s, &post_id, alice_slot_idx as u32, &new_pub_x, &new_wrap_slot, burned_at, ).unwrap(); assert!(applied); // Post-burn: V_me_old can NO LONGER unlock the burned slot. let stored = s.get_post(&post_id).unwrap().unwrap(); let g = stored.fof_gating.as_ref().unwrap(); let old_attempt = crate::crypto::open_wrap_slot( &v_me_old, &g.slot_binder_nonce, &g.wrap_slots[alice_slot_idx].read_ciphertext, &g.wrap_slots[alice_slot_idx].sign_ciphertext, ); assert!(old_attempt.is_none(), "V_me_old can no longer unlock the burned slot"); // V_me_new CAN unlock and recovers the same CEK. let new_attempt = crate::crypto::open_wrap_slot( &v_me_new, &g.slot_binder_nonce, &g.wrap_slots[alice_slot_idx].read_ciphertext, &g.wrap_slots[alice_slot_idx].sign_ciphertext, ).expect("V_me_new unlocks the new slot"); assert_eq!(new_attempt.cek, built.cek, "CEK is unchanged across the burn"); // pub_post_set at that slot is now the new pub_x. assert_eq!(g.pub_post_set[alice_slot_idx], new_pub_x); } // --- Pre-deploy hardening: wire-shape validation + DoS caps --- fn dummy_wrap_slot() -> crate::types::WrapSlot { crate::types::WrapSlot { prefilter_tag: [0u8; 2], read_ciphertext: vec![0u8; 48], sign_ciphertext: vec![0u8; 48], } } fn dummy_gating(slot_count: usize) -> crate::types::FoFCommentGating { crate::types::FoFCommentGating { slot_binder_nonce: [0u8; 32], pub_post_set: (0..slot_count).map(|_| [0u8; 32]).collect(), wrap_slots: (0..slot_count).map(|_| dummy_wrap_slot()).collect(), revocation_list: vec![], } } fn dummy_post(g: Option) -> crate::types::Post { crate::types::Post { author: [0u8; 32], content: String::new(), attachments: vec![], timestamp_ms: 0, fof_gating: g, supersedes_post_id: None, } } #[test] fn validate_rejects_length_mismatch() { let mut g = dummy_gating(8); g.pub_post_set.pop(); let p = dummy_post(Some(g)); let err = validate_fof_gating_on_receive(&p).unwrap_err().to_string(); assert!(err.contains("length mismatch"), "got: {}", err); } #[test] fn validate_rejects_oversized_slots() { let g = dummy_gating(MAX_FOF_WRAP_SLOTS + 1); let p = dummy_post(Some(g)); let err = validate_fof_gating_on_receive(&p).unwrap_err().to_string(); assert!(err.contains("oversized"), "got: {}", err); } #[test] fn validate_rejects_wrong_ciphertext_size() { let mut g = dummy_gating(8); g.wrap_slots[3].read_ciphertext = vec![0u8; 32]; // wrong size let p = dummy_post(Some(g)); let err = validate_fof_gating_on_receive(&p).unwrap_err().to_string(); assert!(err.contains("ciphertext sizes"), "got: {}", err); } #[test] fn validate_accepts_well_formed_gating() { let g = dummy_gating(16); let p = dummy_post(Some(g)); validate_fof_gating_on_receive(&p).unwrap(); } #[test] fn validate_accepts_post_without_gating() { let p = dummy_post(None); validate_fof_gating_on_receive(&p).unwrap(); } #[test] fn validate_fof_closed_requires_gating() { let p = dummy_post(None); let err = validate_fof_closed_has_gating(&p, &crate::types::PostVisibility::FoFClosed) .unwrap_err().to_string(); assert!(err.contains("requires a fof_gating"), "got: {}", err); // FoFClosed + gating Some → OK let p2 = dummy_post(Some(dummy_gating(8))); validate_fof_closed_has_gating(&p2, &crate::types::PostVisibility::FoFClosed).unwrap(); // Public + None → OK validate_fof_closed_has_gating(&p, &crate::types::PostVisibility::Public).unwrap(); } #[test] fn unreadable_queue_is_capped() { let s = temp_storage(); let (persona, _) = make_persona(200); let (author, _) = make_persona(201); // Fill to the cap (4096 entries) — use distinct post_ids. for i in 0..crate::storage::Storage::max_unreadable_per_persona_for_test() as u32 { let mut pid = [0u8; 32]; pid[..4].copy_from_slice(&i.to_le_bytes()); s.record_unreadable_post(&persona, &pid, &author, 1000 + i as u64).unwrap(); } // Try to add one more. Should be silently dropped (no INSERT). let mut overflow_pid = [0u8; 32]; overflow_pid[..4].copy_from_slice(&999_999u32.to_le_bytes()); s.record_unreadable_post(&persona, &overflow_pid, &author, 999_999).unwrap(); let queued = s.list_all_unreadable_posts(&persona).unwrap(); assert_eq!( queued.len() as i64, crate::storage::Storage::max_unreadable_per_persona_for_test(), "queue stays at cap; overflow dropped", ); // Overflow post was NOT added. assert!(!queued.contains(&overflow_pid)); } /// Key-burn replay rejection: applying an older signed burn after /// a newer one must not revert state. #[test] fn fof_key_burn_replay_rejected() { use crate::types::PostingIdentity; use ed25519_dalek::SigningKey; let s = temp_storage(); let (alice_id, alice_seed) = make_persona(220); s.upsert_posting_identity(&PostingIdentity { node_id: alice_id, secret_seed: alice_seed, display_name: "Alice".into(), created_at: 1000, }).unwrap(); 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(); let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); let post_id = [0xEE; 32]; let post = crate::types::Post { author: alice_id, content: String::new(), attachments: vec![], timestamp_ms: 3000, fof_gating: Some(built.gating.clone()), supersedes_post_id: None, }; s.store_post_with_intent( &post_id, &post, &crate::types::PostVisibility::Public, &crate::types::VisibilityIntent::Public, ).unwrap(); // Find Alice's slot. let alice_slot_idx = (0..built.gating.wrap_slots.len()).find(|&i| { crate::crypto::open_wrap_slot( &v_me_alice, &built.slot_binder_nonce, &built.gating.wrap_slots[i].read_ciphertext, &built.gating.wrap_slots[i].sign_ciphertext, ).is_some() }).expect("alice slot exists") as u32; // First burn (Monday): switch to V_me_new1. let mut v_me_new1 = [0u8; 32]; rand::rng().fill_bytes(&mut v_me_new1); let mut seed1 = [0u8; 32]; rand::rng().fill_bytes(&mut seed1); let pub_x1 = SigningKey::from_bytes(&seed1).verifying_key().to_bytes(); let sealed1 = crate::crypto::seal_wrap_slot( &v_me_new1, &built.slot_binder_nonce, &built.cek, &seed1, ).unwrap(); let wrap1 = crate::types::WrapSlot { prefilter_tag: sealed1.prefilter_tag, read_ciphertext: sealed1.read_ciphertext, sign_ciphertext: sealed1.sign_ciphertext, }; let monday = 100_000; apply_fof_key_burn_locally(&s, &post_id, alice_slot_idx, &pub_x1, &wrap1, monday).unwrap(); // Second burn (Friday, later timestamp): switch to V_me_new2. let mut v_me_new2 = [0u8; 32]; rand::rng().fill_bytes(&mut v_me_new2); let mut seed2 = [0u8; 32]; rand::rng().fill_bytes(&mut seed2); let pub_x2 = SigningKey::from_bytes(&seed2).verifying_key().to_bytes(); let sealed2 = crate::crypto::seal_wrap_slot( &v_me_new2, &built.slot_binder_nonce, &built.cek, &seed2, ).unwrap(); let wrap2 = crate::types::WrapSlot { prefilter_tag: sealed2.prefilter_tag, read_ciphertext: sealed2.read_ciphertext, sign_ciphertext: sealed2.sign_ciphertext, }; let friday = 200_000; apply_fof_key_burn_locally(&s, &post_id, alice_slot_idx, &pub_x2, &wrap2, friday).unwrap(); // Replay of Monday's burn (older timestamp) — must be rejected. let applied = apply_fof_key_burn_locally( &s, &post_id, alice_slot_idx, &pub_x1, &wrap1, monday, ).unwrap(); assert!(!applied, "older burn must be rejected as replay"); // Stored state must still reflect Friday's burn. let stored = s.get_post(&post_id).unwrap().unwrap(); let g = stored.fof_gating.as_ref().unwrap(); assert_eq!(g.pub_post_set[alice_slot_idx as usize], pub_x2, "Friday's pub_x preserved despite replayed Monday burn"); } #[test] fn body_bucket_rule_boundaries() { // Sub-1KB. assert_eq!(next_body_size_bucket(0), 1024); assert_eq!(next_body_size_bucket(1024), 1024); // Power-of-2 progression sub-256KB. assert_eq!(next_body_size_bucket(1025), 2048); assert_eq!(next_body_size_bucket(2048), 2048); assert_eq!(next_body_size_bucket(100_000), 131_072); // 128KB assert_eq!(next_body_size_bucket(200_000), 262_144); // 256KB // Linear +256KB above. assert_eq!(next_body_size_bucket(262_145), 524_288); // 512KB assert_eq!(next_body_size_bucket(500_000), 524_288); assert_eq!(next_body_size_bucket(524_289), 786_432); // 768KB } #[test] fn fof_body_roundtrip() { let cek = [0x55; 32]; let slot_binder_nonce = [0xCC; 32]; let body = "Hello, this is a Mode 1 FoFClosed body that nobody outside the FoF set should be able to read on the wire."; let encrypted = encrypt_fof_body(body, &cek, &slot_binder_nonce).unwrap(); // Encryption pads to bucket: small body → 1KB ciphertext-ish. assert!(encrypted.len() >= 1024, "body padded to >= 1KB bucket"); let decrypted = decrypt_fof_body(&encrypted, &cek, &slot_binder_nonce).unwrap(); assert_eq!(decrypted, body); // Wrong CEK fails AEAD. let wrong_cek = [0x11; 32]; assert!(decrypt_fof_body(&encrypted, &wrong_cek, &slot_binder_nonce).is_err()); // Wrong AAD (slot_binder_nonce) fails too. let wrong_nonce = [0xFF; 32]; assert!(decrypt_fof_body(&encrypted, &cek, &wrong_nonce).is_err()); } /// FoF Layer 5: first find_unlock_for_post call populates the /// cache; subsequent calls hit the fast path (cache lookup, single /// AEAD attempt). Tested by verifying the cache table after first /// call + cleared-unreadable invariant. #[test] fn fof_unlock_cache_populates_and_hits() { use crate::types::PostingIdentity; let s = temp_storage(); let (alice_id, alice_seed) = make_persona(100); let (bob_id, bob_seed) = make_persona(101); s.upsert_posting_identity(&PostingIdentity { node_id: bob_id, secret_seed: bob_seed, display_name: "Bob".into(), created_at: 1000, }).unwrap(); let mut v_x_bob = [0u8; 32]; rand::rng().fill_bytes(&mut v_x_bob); s.insert_own_vouch_key(&bob_id, 1, &v_x_bob, 1000).unwrap(); // Build a post from Alice that includes Bob's V_x. let alice_storage = temp_storage(); alice_storage.upsert_posting_identity(&PostingIdentity { node_id: alice_id, secret_seed: alice_seed, display_name: "Alice".into(), created_at: 500, }).unwrap(); let mut v_me_alice = [0u8; 32]; rand::rng().fill_bytes(&mut v_me_alice); alice_storage.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 500).unwrap(); alice_storage.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 600, None).unwrap(); let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built"); let post = crate::types::Post { author: alice_id, content: String::new(), attachments: vec![], timestamp_ms: 3000, fof_gating: Some(built.gating.clone()), supersedes_post_id: None, }; // Bob's first scan — full scan path, populates cache. let unlock1 = find_unlock_for_post(&s, &post).unwrap().expect("Bob unlocks"); assert_eq!(unlock1.cek, built.cek); let cached = s.lookup_unlock_cache(&bob_id, &alice_id).unwrap().expect("cache populated"); // Bob's V_x is owned by Bob himself (insert_own_vouch_key). assert_eq!(cached.0, bob_id); assert_eq!(cached.1, 1); // Second call should hit the cache. Sanity: still unlocks. let unlock2 = find_unlock_for_post(&s, &post).unwrap().expect("cache hit"); assert_eq!(unlock2.cek, built.cek); assert_eq!(unlock2.slot_index, unlock1.slot_index); } /// FoF Layer 5: non-member persona records the post as unreadable; /// later arrival of a matching V_x + sweep brings it back into /// readability and clears the queue. #[test] fn fof_unreadable_sweep_after_v_x_arrival() { use crate::types::PostingIdentity; // Build a post from Alice that requires Carol's V_x to unlock. let alice_storage = temp_storage(); let (alice_id, alice_seed) = make_persona(110); alice_storage.upsert_posting_identity(&PostingIdentity { node_id: alice_id, secret_seed: alice_seed, display_name: "Alice".into(), created_at: 100, }).unwrap(); let mut v_me_alice = [0u8; 32]; rand::rng().fill_bytes(&mut v_me_alice); alice_storage.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 100).unwrap(); let (carol_id, _) = make_persona(111); let mut v_x_carol = [0u8; 32]; rand::rng().fill_bytes(&mut v_x_carol); alice_storage.insert_received_vouch_key(&alice_id, &carol_id, 1, &v_x_carol, 200, None).unwrap(); let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built"); // Bob's storage: holds his own V_me only (no Carol-V_x). The post // shouldn't unlock for him yet. let s = temp_storage(); let (bob_id, bob_seed) = make_persona(112); s.upsert_posting_identity(&PostingIdentity { node_id: bob_id, secret_seed: bob_seed, display_name: "Bob".into(), created_at: 300, }).unwrap(); let mut v_me_bob = [0u8; 32]; rand::rng().fill_bytes(&mut v_me_bob); s.insert_own_vouch_key(&bob_id, 1, &v_me_bob, 300).unwrap(); let post = crate::types::Post { author: alice_id, content: String::new(), attachments: vec![], timestamp_ms: 3000, fof_gating: Some(built.gating.clone()), supersedes_post_id: None, }; // Persist the post so the sweep can re-fetch it. s.store_post_with_intent( &crate::content::compute_post_id(&post), &post, &crate::types::PostVisibility::Public, &crate::types::VisibilityIntent::Public, ).unwrap(); // First attempt: no V_x matches → unreadable queue grows. let pre = find_unlock_for_post(&s, &post).unwrap(); assert!(pre.is_none(), "Bob can't unlock pre-V_x"); let queued = s.list_unreadable_posts_for_author(&bob_id, &alice_id).unwrap(); assert_eq!(queued.len(), 1); // Carol vouches for Bob via... wait, Carol's V_x is sealed in // Alice's post for Carol. Bob is unrelated. To make this test // realistic, let's say Carol DOES vouch for Bob — Bob now // holds V_x_carol in his keyring. After the sweep, Bob can // unlock Alice's post via Carol's V_x (which IS sealed in the // gating because Alice held Carol's V_x). s.insert_received_vouch_key(&bob_id, &carol_id, 1, &v_x_carol, 400, None).unwrap(); let swept = sweep_unreadable_on_new_v_x(&s, &bob_id, &carol_id).unwrap(); assert_eq!(swept, 1, "sweep unlocks Alice's post via Carol's V_x"); // Post-sweep: unreadable queue cleared; unlock cache populated. let after = s.list_unreadable_posts_for_author(&bob_id, &alice_id).unwrap(); assert!(after.is_empty()); let cached = s.lookup_unlock_cache(&bob_id, &alice_id).unwrap().expect("cache populated"); assert_eq!(cached.0, carol_id, "winning V_x owner is Carol"); assert_eq!(cached.1, 1); } /// End-to-end FoFClosed roundtrip at the helper level: Alice /// encrypts a body; Bob (with Alice's V_me as a received V_x) /// trial-unlocks the gating + decrypts the body. Carol (no /// matching V_x) cannot unlock and the body stays opaque. #[test] fn fof_closed_body_end_to_end() { use crate::types::PostingIdentity; let s = temp_storage(); // Alice has V_me; she'll author a FoFClosed post. let (alice_id, alice_seed) = make_persona(70); s.upsert_posting_identity(&PostingIdentity { node_id: alice_id, secret_seed: alice_seed, display_name: "Alice".into(), created_at: 1000, }).unwrap(); 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 Bob's V_x — so the gating includes Bob's slot. let (bob_id, _bob_seed) = make_persona(71); 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("built"); let body_plaintext = "secret to the FoF set only"; let body_ct = encrypt_fof_body(body_plaintext, &built.cek, &built.slot_binder_nonce).unwrap(); // Bob's device (with his V_me == v_x_bob) sees the gating block // and trial-unlocks via his V_me. let bob_storage = temp_storage(); bob_storage.upsert_posting_identity(&PostingIdentity { node_id: bob_id, secret_seed: _bob_seed, display_name: "Bob".into(), created_at: 1500, }).unwrap(); bob_storage.insert_own_vouch_key(&bob_id, 1, &v_x_bob, 1500).unwrap(); let alice_post = crate::types::Post { author: alice_id, content: String::new(), attachments: vec![], timestamp_ms: 3000, fof_gating: Some(built.gating.clone()), supersedes_post_id: None, }; let bob_unlock = find_unlock_for_post(&bob_storage, &alice_post).unwrap() .expect("Bob can unlock"); let bob_decrypted = decrypt_fof_body(&body_ct, &bob_unlock.cek, &built.slot_binder_nonce).unwrap(); assert_eq!(bob_decrypted, body_plaintext); // Carol has no matching V_x — cannot unlock. let carol_storage = temp_storage(); let (carol_id, carol_seed) = make_persona(72); carol_storage.upsert_posting_identity(&PostingIdentity { node_id: carol_id, secret_seed: carol_seed, display_name: "Carol".into(), created_at: 1500, }).unwrap(); let mut v_me_carol = [0u8; 32]; rand::rng().fill_bytes(&mut v_me_carol); carol_storage.insert_own_vouch_key(&carol_id, 1, &v_me_carol, 1500).unwrap(); let carol_unlock = find_unlock_for_post(&carol_storage, &alice_post).unwrap(); assert!(carol_unlock.is_none(), "Carol has no matching V_x and cannot unlock the FoFClosed gating"); } #[test] fn fof_body_padding_hides_real_length() { let cek = [0x55; 32]; let nonce = [0xCC; 32]; // A 5-byte body and a 500-byte body should both produce the // same on-wire size (1KB bucket). let small = encrypt_fof_body("short", &cek, &nonce).unwrap(); let medium = encrypt_fof_body(&"x".repeat(500), &cek, &nonce).unwrap(); assert_eq!(small.len(), medium.len(), "different bodies in the same bucket produce same-sized ciphertexts"); } /// Provenance roundtrip: build_fof_comment_gating populates /// real_slot_provenance; entries match the actual real slots in /// the gating block. #[test] fn fof_gating_real_slot_provenance() { use crate::types::PostingIdentity; use ed25519_dalek::SigningKey; let s = temp_storage(); let (alice_id, alice_seed) = make_persona(80); s.upsert_posting_identity(&PostingIdentity { node_id: alice_id, secret_seed: alice_seed, display_name: "Alice".into(), created_at: 1000, }).unwrap(); let mut v_me_alice = [0u8; 32]; rand::rng().fill_bytes(&mut v_me_alice); s.insert_own_vouch_key(&alice_id, 7, &v_me_alice, 1000).unwrap(); // Two received V_x's at different epochs. let (bob_id, _) = make_persona(81); let (carol_id, _) = make_persona(82); let mut v_x_bob = [0u8; 32]; rand::rng().fill_bytes(&mut v_x_bob); let mut v_x_carol = [0u8; 32]; rand::rng().fill_bytes(&mut v_x_carol); s.insert_received_vouch_key(&alice_id, &bob_id, 3, &v_x_bob, 2000, None).unwrap(); s.insert_received_vouch_key(&alice_id, &carol_id, 5, &v_x_carol, 3000, None).unwrap(); let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); // 3 unique V_x's → 3 real slots, padded to bucket 8. assert_eq!(built.real_slot_provenance.len(), 3); assert_eq!(built.gating.pub_post_set.len(), 8); // Provenance entries must reference real positions whose pub_x // matches gating.pub_post_set[slot_index]. for prov in &built.real_slot_provenance { assert_eq!( built.gating.pub_post_set[prov.slot_index as usize], prov.pub_x, "provenance pub_x matches gating at indexed slot" ); } // Provenance covers exactly Alice's own V_me (epoch 7) + Bob (3) + Carol (5). let owners: Vec = built.real_slot_provenance.iter().map(|p| p.v_x_owner).collect(); assert!(owners.contains(&alice_id)); assert!(owners.contains(&bob_id)); assert!(owners.contains(&carol_id)); let epochs: Vec = built.real_slot_provenance.iter().map(|p| p.v_x_epoch).collect(); assert!(epochs.contains(&7)); assert!(epochs.contains(&3)); assert!(epochs.contains(&5)); // The pub_x derived from each real slot's signing seed must // match the published pub_post_set entry. for prov in &built.real_slot_provenance { for v_x in [v_me_alice, v_x_bob, v_x_carol].iter() { if let Some(opened) = crate::crypto::open_wrap_slot( v_x, &built.slot_binder_nonce, &built.gating.wrap_slots[prov.slot_index as usize].read_ciphertext, &built.gating.wrap_slots[prov.slot_index as usize].sign_ciphertext, ) { let derived = SigningKey::from_bytes(&opened.priv_x_seed) .verifying_key().to_bytes(); assert_eq!(derived, prov.pub_x); break; } } } } #[test] fn fof_revocation_wrong_author_rejected() { let post_id = [0x01; 32]; let revoked_pub_x = [0x02; 32]; let (alice_id, alice_seed) = make_persona(50); let (mallory_id, mallory_seed) = make_persona(51); let sig = sign_fof_revocation(&mallory_seed, &post_id, &revoked_pub_x, 1000, 0); // Mallory signed but claims Alice authored → reject. assert!(!verify_fof_revocation(&alice_id, &post_id, &revoked_pub_x, 1000, 0, &sig)); // Self-signed → accept. assert!(verify_fof_revocation(&mallory_id, &post_id, &revoked_pub_x, 1000, 0, &sig)); let _ = alice_seed; } }