From c2f2203331c8816b981a64c955880ba3d0498e29 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Thu, 14 May 2026 16:20:26 -0600 Subject: [PATCH] =?UTF-8?q?feat(fof-layer4):=20FoFKeyBurn=20primitive=20?= =?UTF-8?q?=E2=80=94=20in-place=20wrap=5Fslot=20replacement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For leaked-V_me scenarios. The author re-seals a single slot under a fresh V_me, invalidating the leaked key's access to this specific post on the wire. Comments signed under the old pub_x at that slot are NOT auto-deleted; pair with revoke_fof_commenter if comment cleanup is desired. Wire format (BlobHeaderDiffOp::FoFKeyBurn): post_id, slot_index, new_pub_x, new_wrap_slot, burned_at_ms, author_sig (64B ed25519 over canonical tuple). fof.rs: - sign_fof_key_burn / verify_fof_key_burn: canonical signing tuple includes post_id, slot_index_le, new_pub_x, prefilter+read+sign bytes from WrapSlot, burned_at_ms_le. Identical shape to access- grant but with slot_index instead of append. - apply_fof_key_burn_locally: delegates to storage.replace_fof_slot. storage.rs: - replace_fof_slot(post_id, slot_index, new_pub_x, new_wrap_slot): mutates the stored post's fof_gating_json. Bounds-checks slot_index. Local-only; PostId unaffected. connection.rs: receive arm. Verifies author_sig + applies. node.rs: - Node::key_burn_post_slot(post_id, slot_index, new_v_x): recovers CEK via find_unlock_for_post, generates fresh per-V_x keypair, seals new slot under new_v_x with the existing CEK + slot_binder_nonce. Signs + applies locally + propagates. CEK is NOT rotated by this op — body remains encrypted under the same CEK as before. Locally-cached plaintext on devices that already-decrypted is unrecoverable by any wire mechanism (out of scope per spec). Test brings the total to 147: - fof_key_burn_replaces_slot: Alice burns her slot from V_me_old to V_me_new; V_me_old no longer unlocks; V_me_new unlocks and yields the same CEK; pub_post_set updates to the new pub_x. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/connection.rs | 17 ++++ crates/core/src/fof.rs | 162 ++++++++++++++++++++++++++++++++++ crates/core/src/node.rs | 81 +++++++++++++++++ crates/core/src/storage.rs | 26 ++++++ crates/core/src/types.rs | 18 ++++ 5 files changed, 304 insertions(+) diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index f70c9d0..bd5dd9b 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -6287,6 +6287,23 @@ impl ConnectionManager { &storage, post_id, new_pub_x, new_wrap_slot, ); } + BlobHeaderDiffOp::FoFKeyBurn { + post_id, slot_index, new_pub_x, new_wrap_slot, burned_at_ms, author_sig, + } => { + let post_author = match storage.get_post(post_id) { + Ok(Some(p)) => p.author, + _ => continue, + }; + if !crate::fof::verify_fof_key_burn( + &post_author, post_id, *slot_index, new_pub_x, new_wrap_slot, + *burned_at_ms, author_sig, + ) { + continue; + } + let _ = crate::fof::apply_fof_key_burn_locally( + &storage, post_id, *slot_index, new_pub_x, new_wrap_slot, + ); + } 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 c66ea6d..f4315bc 100644 --- a/crates/core/src/fof.rs +++ b/crates/core/src/fof.rs @@ -604,6 +604,74 @@ pub fn verify_fof_access_grant( verifying_key.verify(&bytes, &sig).is_ok() } +// --- Key burn: sign + verify + apply (Layer 4) --- + +fn fof_key_burn_signing_bytes( + post_id: &[u8; 32], + slot_index: u32, + new_pub_x: &[u8; 32], + new_wrap_slot: &crate::types::WrapSlot, + burned_at_ms: u64, +) -> Vec { + let mut out = Vec::with_capacity(32 + 4 + 32 + 2 + 48 + 48 + 8); + out.extend_from_slice(post_id); + out.extend_from_slice(&slot_index.to_le_bytes()); + out.extend_from_slice(new_pub_x); + out.extend_from_slice(&new_wrap_slot.prefilter_tag); + out.extend_from_slice(&new_wrap_slot.read_ciphertext); + out.extend_from_slice(&new_wrap_slot.sign_ciphertext); + out.extend_from_slice(&burned_at_ms.to_le_bytes()); + out +} + +pub fn sign_fof_key_burn( + author_secret: &[u8; 32], + post_id: &[u8; 32], + slot_index: u32, + new_pub_x: &[u8; 32], + new_wrap_slot: &crate::types::WrapSlot, + burned_at_ms: u64, +) -> Vec { + use ed25519_dalek::{Signer, SigningKey}; + let signing_key = SigningKey::from_bytes(author_secret); + let bytes = fof_key_burn_signing_bytes(post_id, slot_index, new_pub_x, new_wrap_slot, burned_at_ms); + signing_key.sign(&bytes).to_bytes().to_vec() +} + +pub fn verify_fof_key_burn( + post_author: &NodeId, + post_id: &[u8; 32], + slot_index: u32, + new_pub_x: &[u8; 32], + new_wrap_slot: &crate::types::WrapSlot, + burned_at_ms: u64, + 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_key_burn_signing_bytes(post_id, slot_index, new_pub_x, new_wrap_slot, burned_at_ms); + verifying_key.verify(&bytes, &sig).is_ok() +} + +/// Apply a verified key-burn to local storage. Replaces the wrap_slot +/// + pub_x at the indicated slot in the stored post's fof_gating. The +/// old key's holders can no longer decrypt this post via this slot +/// (locally-cached plaintext on already-read devices is out of scope). +pub fn apply_fof_key_burn_locally( + storage: &Storage, + post_id: &[u8; 32], + slot_index: u32, + new_pub_x: &[u8; 32], + new_wrap_slot: &crate::types::WrapSlot, +) -> Result { + storage.replace_fof_slot(post_id, slot_index, new_pub_x, new_wrap_slot) +} + /// Apply a verified access-grant to local storage. Appends the new /// (pub_x, wrap_slot) at the tail of the stored post's fof_gating. /// Idempotent on `(post_id, new_pub_x)`. Must only be called after @@ -1092,6 +1160,100 @@ mod tests { assert!(!blocked, "revoked pub_x must not be re-granted access"); } + /// Key-burn roundtrip: Alice publishes a Mode 2 FoF post sealed + /// under V_me_old; signs + applies a key-burn that swaps the slot + /// to a new V_me. After burn, old V_me cannot unlock the burned + /// slot but new V_me can. + #[test] + fn fof_key_burn_replaces_slot() { + use crate::types::PostingIdentity; + use ed25519_dalek::SigningKey; + + let s = temp_storage(); + let (alice_id, alice_seed) = make_persona(90); + s.upsert_posting_identity(&PostingIdentity { + node_id: alice_id, secret_seed: alice_seed, + display_name: "Alice".into(), created_at: 1000, + }).unwrap(); + + // Alice has V_me_old. Build a gating block with just her slot. + let mut v_me_old = [0u8; 32]; + rand::rng().fill_bytes(&mut v_me_old); + s.insert_own_vouch_key(&alice_id, 1, &v_me_old, 1000).unwrap(); + + let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); + let post_id = [0xAB; 32]; + let post = crate::types::Post { + author: alice_id, content: "x".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(); + + // Find Alice's slot (the one V_me_old unlocks). + let alice_slot_idx = (0..built.gating.wrap_slots.len()).find(|&i| { + crate::crypto::open_wrap_slot( + &v_me_old, &built.slot_binder_nonce, + &built.gating.wrap_slots[i].read_ciphertext, + &built.gating.wrap_slots[i].sign_ciphertext, + ).is_some() + }).expect("alice slot exists"); + + // Simulate leak: Alice burns the slot under a new V_me. + let mut v_me_new = [0u8; 32]; + rand::rng().fill_bytes(&mut v_me_new); + let mut new_seed = [0u8; 32]; + rand::rng().fill_bytes(&mut new_seed); + let new_pub_x = SigningKey::from_bytes(&new_seed).verifying_key().to_bytes(); + let sealed = crate::crypto::seal_wrap_slot( + &v_me_new, &built.slot_binder_nonce, &built.cek, &new_seed, + ).unwrap(); + let new_wrap_slot = crate::types::WrapSlot { + prefilter_tag: sealed.prefilter_tag, + read_ciphertext: sealed.read_ciphertext, + sign_ciphertext: sealed.sign_ciphertext, + }; + + let burned_at = 5000; + let sig = sign_fof_key_burn( + &alice_seed, &post_id, alice_slot_idx as u32, + &new_pub_x, &new_wrap_slot, burned_at, + ); + assert!(verify_fof_key_burn( + &alice_id, &post_id, alice_slot_idx as u32, + &new_pub_x, &new_wrap_slot, burned_at, &sig, + )); + + let applied = apply_fof_key_burn_locally( + &s, &post_id, alice_slot_idx as u32, &new_pub_x, &new_wrap_slot, + ).unwrap(); + assert!(applied); + + // Post-burn: V_me_old can NO LONGER unlock the burned slot. + let stored = s.get_post(&post_id).unwrap().unwrap(); + let g = stored.fof_gating.as_ref().unwrap(); + let old_attempt = crate::crypto::open_wrap_slot( + &v_me_old, &g.slot_binder_nonce, + &g.wrap_slots[alice_slot_idx].read_ciphertext, + &g.wrap_slots[alice_slot_idx].sign_ciphertext, + ); + assert!(old_attempt.is_none(), "V_me_old can no longer unlock the burned slot"); + + // V_me_new CAN unlock and recovers the same CEK. + let new_attempt = crate::crypto::open_wrap_slot( + &v_me_new, &g.slot_binder_nonce, + &g.wrap_slots[alice_slot_idx].read_ciphertext, + &g.wrap_slots[alice_slot_idx].sign_ciphertext, + ).expect("V_me_new unlocks the new slot"); + assert_eq!(new_attempt.cek, built.cek, "CEK is unchanged across the burn"); + + // pub_post_set at that slot is now the new pub_x. + assert_eq!(g.pub_post_set[alice_slot_idx], new_pub_x); + } + #[test] fn body_bucket_rule_boundaries() { // Sub-1KB. diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 710dfa2..bd15796 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -5191,6 +5191,87 @@ impl Node { Ok(()) } + /// FoF Layer 4: in-place wrap-slot replacement for leaked-V_me + /// scenarios. Re-seals the slot at `slot_index` under `new_v_x` + /// (typically a freshly-rotated V_me), publishes a signed + /// FoFKeyBurn diff. Local stored copy of the post mutates to + /// replace the slot. Post body remains encrypted under the + /// existing CEK (CEK isn't rotated by this op). + pub async fn key_burn_post_slot( + &self, + post_id: PostId, + slot_index: u32, + new_v_x: &[u8; 32], + ) -> anyhow::Result<()> { + use ed25519_dalek::SigningKey; + use rand::RngCore; + + let (post_author, posting_secret, cek, slot_binder_nonce) = { + 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"))?; + if slot_index as usize >= gating.wrap_slots.len() { + anyhow::bail!("slot_index out of bounds"); + } + let identity = storage.get_posting_identity(&post.author)? + .ok_or_else(|| anyhow::anyhow!("post author not on this device"))?; + // Recover CEK by trial-unlocking the author's own slot. + let unlock = crate::fof::find_unlock_for_post(&*storage, &post)? + .ok_or_else(|| anyhow::anyhow!("could not recover CEK for own post"))?; + (post.author, identity.secret_seed, unlock.cek, gating.slot_binder_nonce) + }; + + // Generate fresh per-V_x keypair, seal a new slot under new_v_x. + let mut seed = [0u8; 32]; + rand::rng().fill_bytes(&mut seed); + let signing_key = SigningKey::from_bytes(&seed); + let new_pub_x = *signing_key.verifying_key().as_bytes(); + + let sealed = crate::crypto::seal_wrap_slot(new_v_x, &slot_binder_nonce, &cek, &seed)?; + let new_wrap_slot = crate::types::WrapSlot { + prefilter_tag: sealed.prefilter_tag, + read_ciphertext: sealed.read_ciphertext, + sign_ciphertext: sealed.sign_ciphertext, + }; + + let burned_at_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + let author_sig = crate::fof::sign_fof_key_burn( + &posting_secret, &post_id, slot_index, &new_pub_x, &new_wrap_slot, burned_at_ms, + ); + + // Apply locally for immediate UI update. + { + let storage = self.storage.get().await; + let _ = crate::fof::apply_fof_key_burn_locally( + &*storage, &post_id, slot_index, &new_pub_x, &new_wrap_slot, + ); + } + + // Propagate. + 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::FoFKeyBurn { + post_id, + slot_index, + new_pub_x, + new_wrap_slot, + burned_at_ms, + 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/storage.rs b/crates/core/src/storage.rs index 63ae566..f887324 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -5110,6 +5110,32 @@ impl Storage { Ok(out) } + /// FoF Layer 4: replace a wrap_slot + pub_x at `slot_index` in a + /// stored post's fof_gating. Local-only mutation; PostId + /// (in the `id` column) is unaffected. Returns true on success. + pub fn replace_fof_slot( + &self, + post_id: &PostId, + slot_index: u32, + new_pub_x: &[u8; 32], + new_wrap_slot: &crate::types::WrapSlot, + ) -> anyhow::Result { + let Some(mut post) = self.get_post(post_id)? else { return Ok(false); }; + let Some(mut gating) = post.fof_gating.take() else { return Ok(false); }; + let idx = slot_index as usize; + if idx >= gating.pub_post_set.len() || idx >= gating.wrap_slots.len() { + return Ok(false); + } + gating.pub_post_set[idx] = *new_pub_x; + gating.wrap_slots[idx] = new_wrap_slot.clone(); + let gating_json = serde_json::to_string(&gating)?; + self.conn.execute( + "UPDATE posts SET fof_gating_json = ?1 WHERE id = ?2", + params![gating_json, post_id.as_slice()], + )?; + Ok(true) + } + /// FoF Layer 2: append a new (pub_x, wrap_slot) entry to a stored /// post's fof_gating. Local-only mutation; PostId (in the `id` /// column) is unaffected. Idempotent on `(post_id, new_pub_x)`. diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index d5c806d..e4b4e01 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -1156,6 +1156,24 @@ pub enum BlobHeaderDiffOp { /// (post_id || new_pub_x || canonical(new_wrap_slot) || granted_at_ms_le). author_sig: Vec, }, + /// FoF Layer 4: in-place wrap_slot replacement for leaked-V_me + /// scenarios. The author re-seals the slot at `slot_index` under a + /// fresh V_x (or a different held V_x), invalidating the leaked + /// key's read access to this specific post. The corresponding + /// pub_x in pub_post_set is also replaced so future comments must + /// sign under the new keypair. Comments signed under the OLD + /// pub_x at this slot are NOT auto-deleted by this op — use a + /// revocation diff alongside if comment cleanup is desired. + FoFKeyBurn { + post_id: PostId, + slot_index: u32, + new_pub_x: [u8; 32], + new_wrap_slot: WrapSlot, + burned_at_ms: u64, + /// 64-byte ed25519 sig by post author over + /// (post_id || slot_index_le || new_pub_x || canonical(new_wrap_slot) || burned_at_ms_le). + author_sig: Vec, + }, /// Unknown ops from newer protocol versions — silently ignored #[serde(other)] Unknown,