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:
parent
c0de21d37b
commit
c2f2203331
5 changed files with 304 additions and 0 deletions
|
|
@ -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<Option<crate::types::CommentPolicy>> {
|
||||
let storage = self.storage.get().await;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue