diff --git a/crates/core/src/fof.rs b/crates/core/src/fof.rs index f806b75..1aa6151 100644 --- a/crates/core/src/fof.rs +++ b/crates/core/src/fof.rs @@ -2,14 +2,16 @@ //! 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. +//! 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 reader/commenter unwrap path and CDN verification path live in -//! sibling modules (added in subsequent slices). +//! The CDN four-check verification path and revocation handling live +//! in sibling modules (added in subsequent slices). use anyhow::Result; use ed25519_dalek::SigningKey; @@ -140,6 +142,205 @@ pub struct FoFCommentGatingBuilt { pub slot_binder_nonce: [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. +/// +/// Iteration order: personas as listed by storage; within each persona, +/// own current `V_me` first, then received V_x's. Slots are scanned in +/// order; the 2B prefilter lets us skip non-matching slots in O(1) per. +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 personas = storage.list_posting_identities()?; + for persona in &personas { + // Build this persona's V_x ring: own current + every received. + let mut keys: Vec<[u8; 32]> = Vec::new(); + if let Some((_, own_key)) = storage.current_own_vouch_key(&persona.node_id)? { + keys.push(own_key); + } + for (_owner, _epoch, key) in storage.list_received_vouch_keys(&persona.node_id)? { + keys.push(key); + } + for v_x in &keys { + 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 Ok(Some(PostUnlock { + persona_id: persona.node_id, + slot_index: idx as u32, + cek: opened.cek, + priv_x_seed: opened.priv_x_seed, + })); + } + } + } + } + Ok(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)) +} + #[cfg(test)] mod tests { use super::*; @@ -282,4 +483,104 @@ mod tests { 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()), + }; + + // 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 + } }