feat(fof-layer2): revocation diff — sign + verify + propagate + cascade
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) <noreply@anthropic.com>
This commit is contained in:
parent
583033e065
commit
6a76adef8f
4 changed files with 294 additions and 0 deletions
|
|
@ -6248,6 +6248,28 @@ impl ConnectionManager {
|
||||||
let _ = storage.set_comment_policy(&payload.post_id, new_policy);
|
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 } => {
|
BlobHeaderDiffOp::ThreadSplit { new_post_id } => {
|
||||||
let _ = storage.store_thread_meta(&crate::types::ThreadMeta {
|
let _ = storage.store_thread_meta(&crate::types::ThreadMeta {
|
||||||
post_id: *new_post_id,
|
post_id: *new_post_id,
|
||||||
|
|
|
||||||
|
|
@ -341,6 +341,90 @@ pub fn decrypt_fof_comment_payload(
|
||||||
.map_err(|e| anyhow::anyhow!("deserialize fof comment payload: {}", e))
|
.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<u8> {
|
||||||
|
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<usize> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -583,4 +667,115 @@ mod tests {
|
||||||
|
|
||||||
let _signing_key = SigningKey::from_bytes(&unlock.priv_x_seed); // exercise the import
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4753,6 +4753,70 @@ impl Node {
|
||||||
Ok(())
|
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.
|
/// Get the comment policy for a post.
|
||||||
pub async fn get_comment_policy(&self, post_id: PostId) -> anyhow::Result<Option<crate::types::CommentPolicy>> {
|
pub async fn get_comment_policy(&self, post_id: PostId) -> anyhow::Result<Option<crate::types::CommentPolicy>> {
|
||||||
let storage = self.storage.get().await;
|
let storage = self.storage.get().await;
|
||||||
|
|
|
||||||
|
|
@ -1120,6 +1120,19 @@ pub enum BlobHeaderDiffOp {
|
||||||
WriteCommentSlot { post_id: PostId, slot_index: u32, data: Vec<u8> },
|
WriteCommentSlot { post_id: PostId, slot_index: u32, data: Vec<u8> },
|
||||||
/// Add new encrypted comment slots (each 256 bytes)
|
/// Add new encrypted comment slots (each 256 bytes)
|
||||||
AddCommentSlots { post_id: PostId, count: u32, slots: Vec<Vec<u8>> },
|
AddCommentSlots { post_id: PostId, count: u32, slots: Vec<Vec<u8>> },
|
||||||
|
/// 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<u8>,
|
||||||
|
},
|
||||||
/// Unknown ops from newer protocol versions — silently ignored
|
/// Unknown ops from newer protocol versions — silently ignored
|
||||||
#[serde(other)]
|
#[serde(other)]
|
||||||
Unknown,
|
Unknown,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue