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
|
|
@ -6287,6 +6287,23 @@ impl ConnectionManager {
|
||||||
&storage, post_id, new_pub_x, new_wrap_slot,
|
&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 } => {
|
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,
|
||||||
|
|
|
||||||
|
|
@ -604,6 +604,74 @@ pub fn verify_fof_access_grant(
|
||||||
verifying_key.verify(&bytes, &sig).is_ok()
|
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
|
/// 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.
|
/// (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
|
/// 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");
|
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]
|
#[test]
|
||||||
fn body_bucket_rule_boundaries() {
|
fn body_bucket_rule_boundaries() {
|
||||||
// Sub-1KB.
|
// Sub-1KB.
|
||||||
|
|
|
||||||
|
|
@ -5191,6 +5191,87 @@ impl Node {
|
||||||
Ok(())
|
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.
|
/// 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;
|
||||||
|
|
|
||||||
|
|
@ -5110,6 +5110,32 @@ impl Storage {
|
||||||
Ok(out)
|
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<bool> {
|
||||||
|
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
|
/// 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`
|
/// post's fof_gating. Local-only mutation; PostId (in the `id`
|
||||||
/// column) is unaffected. Idempotent on `(post_id, new_pub_x)`.
|
/// column) is unaffected. Idempotent on `(post_id, new_pub_x)`.
|
||||||
|
|
|
||||||
|
|
@ -1156,6 +1156,24 @@ pub enum BlobHeaderDiffOp {
|
||||||
/// (post_id || new_pub_x || canonical(new_wrap_slot) || granted_at_ms_le).
|
/// (post_id || new_pub_x || canonical(new_wrap_slot) || granted_at_ms_le).
|
||||||
author_sig: Vec<u8>,
|
author_sig: Vec<u8>,
|
||||||
},
|
},
|
||||||
|
/// 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<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