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:
parent
673f9e2261
commit
00522f4c4b
1 changed files with 308 additions and 7 deletions
|
|
@ -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<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)]
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue