feat(fof-layer4): FoFKeyBurn primitive — in-place wrap_slot replacement

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) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-05-14 16:20:26 -06:00
parent c0de21d37b
commit c2f2203331
5 changed files with 304 additions and 0 deletions

View file

@ -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<u8> {
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<u8> {
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<bool> {
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.