From 6a76adef8f8d36d902d158326434243f1652c01c Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Thu, 14 May 2026 14:59:23 -0400 Subject: [PATCH] =?UTF-8?q?feat(fof-layer2):=20revocation=20diff=20?= =?UTF-8?q?=E2=80=94=20sign=20+=20verify=20+=20propagate=20+=20cascade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the full revocation primitive end-to-end: Wire format: - BlobHeaderDiffOp::FoFRevocation { post_id, revoked_pub_x, revoked_at_ms, reason_code, author_sig }. 64-byte Ed25519 sig by post author over (post_id || revoked_pub_x || ms_le || reason). fof.rs additions: - sign_fof_revocation(author_secret, ...): builds the canonical signing tuple and signs. - verify_fof_revocation(post_author, ...): Ed25519 verify; false on any shape/key/sig failure. CDN-verified before any side effect. - apply_fof_revocation_locally(storage, ...): records in fof_revocations + cascades retroactive delete of locally-stored comments matching the revoked pub_x via pub_post_set lookup. Receive path (connection.rs): new arm for FoFRevocation diffs. Looks up post.author from storage, verifies author_sig (rejects diffs where payload.author != post author or sig invalid), then applies locally. Propagation continues via existing mechanism. Author API (node.rs): Node::revoke_fof_commenter(post_id, pub_x_index, reason_code) resolves pub_x from gating.pub_post_set, signs with the persona's identity secret, applies locally for immediate UI update, then propagates via propagate_engagement_diff. Two new fof tests bring the suite to 141 passing: - fof_revocation_cascades: full author → publish → commenter → revoke → cascade-delete + recorded-in-storage roundtrip. - fof_revocation_wrong_author_rejected: Mallory signs claiming Alice's authorship → verify rejects. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/connection.rs | 22 ++++ crates/core/src/fof.rs | 195 ++++++++++++++++++++++++++++++++++ crates/core/src/node.rs | 64 +++++++++++ crates/core/src/types.rs | 13 +++ 4 files changed, 294 insertions(+) diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index 926adcd..11f262e 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -6248,6 +6248,28 @@ impl ConnectionManager { let _ = storage.set_comment_policy(&payload.post_id, new_policy); } } + BlobHeaderDiffOp::FoFRevocation { + post_id, revoked_pub_x, revoked_at_ms, reason_code, author_sig, + } => { + // Verify author identity signature before applying. + // payload.author is the engagement-diff sender; the + // post's real author lives in storage. + let post_author = match storage.get_post(post_id) { + Ok(Some(p)) => p.author, + _ => continue, + }; + if !crate::fof::verify_fof_revocation( + &post_author, post_id, revoked_pub_x, + *revoked_at_ms, *reason_code, author_sig, + ) { + continue; + } + // Apply: record + cascade-delete stored comments. + let _ = crate::fof::apply_fof_revocation_locally( + &storage, post_id, revoked_pub_x, + *revoked_at_ms, *reason_code, author_sig, + ); + } BlobHeaderDiffOp::ThreadSplit { new_post_id } => { let _ = storage.store_thread_meta(&crate::types::ThreadMeta { post_id: *new_post_id, diff --git a/crates/core/src/fof.rs b/crates/core/src/fof.rs index 1aa6151..5cf167c 100644 --- a/crates/core/src/fof.rs +++ b/crates/core/src/fof.rs @@ -341,6 +341,90 @@ pub fn decrypt_fof_comment_payload( .map_err(|e| anyhow::anyhow!("deserialize fof comment payload: {}", e)) } +// --- 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() +} + +/// 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::*; @@ -583,4 +667,115 @@ mod tests { 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()), + }; + 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); + } + + #[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; + } } diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 02940d6..c8f77eb 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -4753,6 +4753,70 @@ impl Node { Ok(()) } + /// FoF Layer 2: revoke a specific pub_x from a FoF-gated post the + /// caller authored. Builds a signed FoFRevocation diff, applies it + /// locally (record + cascade delete), and propagates via the + /// standard engagement-diff path. Idempotent. + /// + /// Caller passes the `pub_x_index` (from a stored comment they want + /// to revoke). The pub_x bytes are resolved via the post's + /// pub_post_set; if the post or index is missing, returns Err. + pub async fn revoke_fof_commenter( + &self, + post_id: PostId, + pub_x_index: u32, + reason_code: u8, + ) -> anyhow::Result<()> { + // Resolve pub_x bytes + confirm we authored the post. + let (post_author, posting_secret, revoked_pub_x) = { + let storage = self.storage.get().await; + let post = storage.get_post(&post_id)? + .ok_or_else(|| anyhow::anyhow!("post not found"))?; + let gating = post.fof_gating.as_ref() + .ok_or_else(|| anyhow::anyhow!("post is not FoF-gated"))?; + let pub_x = gating.pub_post_set.get(pub_x_index as usize).copied() + .ok_or_else(|| anyhow::anyhow!("pub_x_index out of bounds"))?; + let identity = storage.get_posting_identity(&post.author)? + .ok_or_else(|| anyhow::anyhow!("post author not on this device"))?; + (post.author, identity.secret_seed, pub_x) + }; + + let revoked_at_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + let author_sig = crate::fof::sign_fof_revocation( + &posting_secret, &post_id, &revoked_pub_x, revoked_at_ms, reason_code, + ); + + // Apply locally first so the author's UI updates immediately. + { + let storage = self.storage.get().await; + let _ = crate::fof::apply_fof_revocation_locally( + &*storage, &post_id, &revoked_pub_x, revoked_at_ms, reason_code, &author_sig, + ); + } + + // Propagate the diff. + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + let diff = crate::protocol::BlobHeaderDiffPayload { + post_id, + author: post_author, + ops: vec![crate::types::BlobHeaderDiffOp::FoFRevocation { + post_id, + revoked_pub_x, + revoked_at_ms, + reason_code, + author_sig, + }], + timestamp_ms: now, + }; + self.network.propagate_engagement_diff(&post_id, &diff, &post_author).await; + + Ok(()) + } + /// Get the comment policy for a post. pub async fn get_comment_policy(&self, post_id: PostId) -> anyhow::Result> { let storage = self.storage.get().await; diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index ec73145..0771a6e 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -1120,6 +1120,19 @@ pub enum BlobHeaderDiffOp { WriteCommentSlot { post_id: PostId, slot_index: u32, data: Vec }, /// Add new encrypted comment slots (each 256 bytes) AddCommentSlots { post_id: PostId, count: u32, slots: Vec> }, + /// FoF Layer 2: author revokes a pub_x from a FoF-gated post. + /// Propagation nodes verify author_sig, drop locally-stored + /// comments by the revoked pub_x, record the revocation, and + /// forward. Retroactive + idempotent. + FoFRevocation { + post_id: PostId, + revoked_pub_x: [u8; 32], + revoked_at_ms: u64, + reason_code: u8, + /// 64-byte ed25519 sig by post author over + /// (post_id || revoked_pub_x || revoked_at_ms_le || reason_code). + author_sig: Vec, + }, /// Unknown ops from newer protocol versions — silently ignored #[serde(other)] Unknown,