feat(fof-layer2): reader unlock + commenter authoring + sig verify

Adds the reader/commenter side of FoF Layer 2 in crates/core/src/fof.rs:

- find_unlock_for_post(storage, post): scans every persona × every
  held V_x (own V_me + received) against the post's wrap_slots using
  the 2B prefilter tag. Returns first successful unlock as a
  PostUnlock { persona_id, slot_index, cek, priv_x_seed }.

- FoFCommentPayload: serializable inner-plaintext shape (body +
  parent_comment_id + optional vouch_mac). Wrapped under CEK_comments
  inside InlineComment.encrypted_payload.

- build_fof_comment(parent_post_id, unlock, slot_binder_nonce,
  commenter_id, commenter_secret, body, parent_comment_id, now_ms)
  → InlineComment: encrypts the body under CEK_comments, signs
  (encrypted_payload || post_id || pub_x_index_le) with priv_x for
  group_sig, signs the conventional comment tuple with commenter's
  identity key, fills pub_x_index from the unlock.

- verify_fof_group_sig(comment, gating): CDN-level Ed25519 verify of
  group_sig against pub_post_set[pub_x_index]. Returns false on any
  shape/index/key/signature failure. Used by the four-check accept
  rule (next slice).

- decrypt_fof_comment_payload(comment, cek, slot_binder_nonce): inverse
  of build_fof_comment's encryption step. Used by authors with cached
  CEKs and by readers who just unlocked.

End-to-end roundtrip test covers: Alice publishes Mode 2 FoF post with
Bob's V_x in the gating; Bob's device unlocks via his V_me; Bob
authors a comment; verify_fof_group_sig accepts it; tampered payload
and wrong pub_x_index both reject; Alice decrypts the payload.

138 → 139 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-05-14 14:04:22 -04:00
parent 673f9e2261
commit 00522f4c4b

View file

@ -2,14 +2,16 @@
//! comments. See `docs/fof-spec/layer-2-mode2-fof-comments.md` for the //! comments. See `docs/fof-spec/layer-2-mode2-fof-comments.md` for the
//! wire shape and threat model. //! wire shape and threat model.
//! //!
//! This module owns the author-side **publish path**: //! This module owns:
//! - Generate the post's CEK + per-V_x signing keypairs. //! - **Author publish path** ([`build_fof_comment_gating`]): seal wrap
//! - Seal one wrap slot per unique V_x in the author's keyring. //! slots, generate per-V_x signing keypairs, bucket-pad, shuffle.
//! - Pad with dummy slots + dummy pub_x entries (bucketed per Layer 3). //! - **Reader/commenter unlock path** ([`find_unlock_for_post`],
//! - Shuffle real + dummy together so positions don't leak ordering. //! [`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 //! The CDN four-check verification path and revocation handling live
//! sibling modules (added in subsequent slices). //! in sibling modules (added in subsequent slices).
use anyhow::Result; use anyhow::Result;
use ed25519_dalek::SigningKey; use ed25519_dalek::SigningKey;
@ -140,6 +142,205 @@ pub struct FoFCommentGatingBuilt {
pub slot_binder_nonce: [u8; 32], 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<Option<PostUnlock>> {
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<crate::types::InlineComment> {
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<FoFCommentPayload> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -282,4 +483,104 @@ mod tests {
rand::rng().fill_bytes(&mut seed); rand::rng().fill_bytes(&mut seed);
let _ = ed25519_seed_to_x25519_private(&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
}
} }