From 4ec3a80b6cc80508864fb6cf3697225fa4472238 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Thu, 14 May 2026 20:31:15 -0600 Subject: [PATCH] fix(fof): key-burn replay rejection + bounded sweep lock-hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two security/operational findings from the deeper pre-deploy audit: 1. Key-burn replay attack (security) - Before: replace_fof_slot did a blind UPDATE on receive. An attacker replaying an older signed FoFKeyBurn diff (Monday's) after a newer one (Friday's) would revert the slot. - Fix: new fof_key_burns table tracks (post_id, slot_index) -> max(burned_at_ms) seen. replace_fof_slot now refuses to apply a burn whose timestamp is <= the stored max. Atomic transaction ensures the gating-update + monotonic-record stay consistent. - apply_fof_key_burn_locally signature gains burned_at_ms; the three callers (connection.rs receive, node.rs author API, the fof.rs roundtrip test) all updated. 2. Unbounded sweep lock-hold (operational) - Before: sweep_unreadable_on_new_v_x walked up to 4096 queued posts per call, all under one storage lock. ~2s lock-held worst case. Attack: spammy bio posts trigger repeated sweeps. - Fix: MAX_SWEEP_PER_CALL = 256. Remaining entries processed on subsequent V_x arrivals. Bounds lock-hold to ~250ms worst case. One new test (158 total): - fof_key_burn_replay_rejected: applies Monday burn, then Friday burn, then replays Monday — asserts stored state stays at Friday's pub_x. Bio-post replay was also evaluated: vouch grants are HPKE-sealed (unforgeable) and stored INSERT OR IGNORE on (holder, owner, epoch) so replay is a no-op. No fix needed. Revocation + access-grant were also evaluated as idempotent by their storage layers (INSERT OR IGNORE / dedup-on-pub_x). Safe. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/connection.rs | 1 + crates/core/src/fof.rs | 115 ++++++++++++++++++++++++++++++---- crates/core/src/node.rs | 1 + crates/core/src/storage.rs | 55 +++++++++++++++- 4 files changed, 157 insertions(+), 15 deletions(-) diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index bd5dd9b..9a022a5 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -6302,6 +6302,7 @@ impl ConnectionManager { } let _ = crate::fof::apply_fof_key_burn_locally( &storage, post_id, *slot_index, new_pub_x, new_wrap_slot, + *burned_at_ms, ); } BlobHeaderDiffOp::ThreadSplit { new_post_id } => { diff --git a/crates/core/src/fof.rs b/crates/core/src/fof.rs index 04496e6..f43cd90 100644 --- a/crates/core/src/fof.rs +++ b/crates/core/src/fof.rs @@ -278,18 +278,21 @@ pub fn find_unlock_for_post( Ok(None) } -/// FoF Layer 5: when a persona acquires a new V_x, walk ALL -/// unreadable posts for the persona and re-attempt unlock. The new -/// V_x can unlock posts authored by anyone — the author may hold the -/// V_x's owner as one of their vouches, so the post's wrap_slots -/// include a slot under the new V_x — even if the post's author is -/// someone else entirely. Successful unlocks populate +/// Maximum posts processed per `sweep_unreadable_on_new_v_x` call. +/// Bounds lock-hold time. Unprocessed entries remain queued and will +/// be tried on the next V_x arrival or via a periodic background +/// sweep (future Layer 5+ work). +const MAX_SWEEP_PER_CALL: usize = 256; + +/// FoF Layer 5: when a persona acquires a new V_x, walk the +/// unreadable-posts queue and re-attempt unlock. The new V_x can +/// unlock posts authored by anyone (the author may hold the V_x's +/// owner as one of their vouches). Successful unlocks populate /// `vouch_unlock_cache` + clear the queue entry as a side effect. /// -/// Called by the receive path immediately after a new V_x lands in -/// the persona's keyring. `_v_x_owner` is informational (no narrowing -/// happens here because the sweep can't predict which authors will -/// have included this V_x in their gating). +/// Bounded to `MAX_SWEEP_PER_CALL` posts per invocation to cap +/// lock-hold time and prevent spam-grant-triggered DoS. Remaining +/// entries are processed on subsequent V_x arrivals. pub fn sweep_unreadable_on_new_v_x( storage: &Storage, holder_persona_id: &NodeId, @@ -297,7 +300,7 @@ pub fn sweep_unreadable_on_new_v_x( ) -> Result { let post_ids = storage.list_all_unreadable_posts(holder_persona_id)?; let mut unlocked = 0usize; - for post_id in post_ids { + for post_id in post_ids.into_iter().take(MAX_SWEEP_PER_CALL) { let Some((post, _vis)) = storage.get_post_with_visibility(&post_id)? else { let _ = storage.clear_unreadable_post(holder_persona_id, &post_id); continue; @@ -850,14 +853,18 @@ pub fn verify_fof_key_burn( /// + 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). +/// +/// `burned_at_ms` enforces monotonic ordering: older signed key-burn +/// diffs cannot revert newer state. 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, + burned_at_ms: u64, ) -> Result { - storage.replace_fof_slot(post_id, slot_index, new_pub_x, new_wrap_slot) + storage.replace_fof_slot(post_id, slot_index, new_pub_x, new_wrap_slot, burned_at_ms) } /// Apply a verified access-grant to local storage. Appends the new @@ -1420,7 +1427,7 @@ mod tests { )); let applied = apply_fof_key_burn_locally( - &s, &post_id, alice_slot_idx as u32, &new_pub_x, &new_wrap_slot, + &s, &post_id, alice_slot_idx as u32, &new_pub_x, &new_wrap_slot, burned_at, ).unwrap(); assert!(applied); @@ -1550,6 +1557,88 @@ mod tests { assert!(!queued.contains(&overflow_pid)); } + /// Key-burn replay rejection: applying an older signed burn after + /// a newer one must not revert state. + #[test] + fn fof_key_burn_replay_rejected() { + use crate::types::PostingIdentity; + use ed25519_dalek::SigningKey; + + let s = temp_storage(); + let (alice_id, alice_seed) = make_persona(220); + 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 built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); + let post_id = [0xEE; 32]; + let post = crate::types::Post { + author: alice_id, content: String::new(), attachments: vec![], + timestamp_ms: 3000, fof_gating: Some(built.gating.clone()), + supersedes_post_id: None, + }; + s.store_post_with_intent( + &post_id, &post, + &crate::types::PostVisibility::Public, + &crate::types::VisibilityIntent::Public, + ).unwrap(); + + // Find Alice's slot. + let alice_slot_idx = (0..built.gating.wrap_slots.len()).find(|&i| { + crate::crypto::open_wrap_slot( + &v_me_alice, &built.slot_binder_nonce, + &built.gating.wrap_slots[i].read_ciphertext, + &built.gating.wrap_slots[i].sign_ciphertext, + ).is_some() + }).expect("alice slot exists") as u32; + + // First burn (Monday): switch to V_me_new1. + let mut v_me_new1 = [0u8; 32]; rand::rng().fill_bytes(&mut v_me_new1); + let mut seed1 = [0u8; 32]; rand::rng().fill_bytes(&mut seed1); + let pub_x1 = SigningKey::from_bytes(&seed1).verifying_key().to_bytes(); + let sealed1 = crate::crypto::seal_wrap_slot( + &v_me_new1, &built.slot_binder_nonce, &built.cek, &seed1, + ).unwrap(); + let wrap1 = crate::types::WrapSlot { + prefilter_tag: sealed1.prefilter_tag, + read_ciphertext: sealed1.read_ciphertext, + sign_ciphertext: sealed1.sign_ciphertext, + }; + let monday = 100_000; + apply_fof_key_burn_locally(&s, &post_id, alice_slot_idx, &pub_x1, &wrap1, monday).unwrap(); + + // Second burn (Friday, later timestamp): switch to V_me_new2. + let mut v_me_new2 = [0u8; 32]; rand::rng().fill_bytes(&mut v_me_new2); + let mut seed2 = [0u8; 32]; rand::rng().fill_bytes(&mut seed2); + let pub_x2 = SigningKey::from_bytes(&seed2).verifying_key().to_bytes(); + let sealed2 = crate::crypto::seal_wrap_slot( + &v_me_new2, &built.slot_binder_nonce, &built.cek, &seed2, + ).unwrap(); + let wrap2 = crate::types::WrapSlot { + prefilter_tag: sealed2.prefilter_tag, + read_ciphertext: sealed2.read_ciphertext, + sign_ciphertext: sealed2.sign_ciphertext, + }; + let friday = 200_000; + apply_fof_key_burn_locally(&s, &post_id, alice_slot_idx, &pub_x2, &wrap2, friday).unwrap(); + + // Replay of Monday's burn (older timestamp) — must be rejected. + let applied = apply_fof_key_burn_locally( + &s, &post_id, alice_slot_idx, &pub_x1, &wrap1, monday, + ).unwrap(); + assert!(!applied, "older burn must be rejected as replay"); + + // Stored state must still reflect Friday's burn. + let stored = s.get_post(&post_id).unwrap().unwrap(); + let g = stored.fof_gating.as_ref().unwrap(); + assert_eq!(g.pub_post_set[alice_slot_idx as usize], pub_x2, + "Friday's pub_x preserved despite replayed Monday burn"); + } + #[test] fn body_bucket_rule_boundaries() { // Sub-1KB. diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 40baf9b..7c90407 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -5274,6 +5274,7 @@ impl Node { 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, + burned_at_ms, ); } diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index 6aad389..cee97e8 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -542,6 +542,18 @@ impl Storage { cek BLOB NOT NULL, slot_binder_nonce BLOB NOT NULL, PRIMARY KEY (author_persona_id, post_id) + ); + -- FoF Layer 4 hardening: per-slot key-burn monotonic + -- timestamp. Receivers refuse to apply key-burns with an + -- older timestamp than the most recent already applied at + -- that slot — prevents replay of older signed diffs from + -- reverting newer state. + CREATE TABLE IF NOT EXISTS fof_key_burns ( + post_id BLOB NOT NULL, + slot_index INTEGER NOT NULL, + burned_at_ms INTEGER NOT NULL, + new_pub_x BLOB NOT NULL, + PRIMARY KEY (post_id, slot_index) );", )?; Ok(()) @@ -5426,13 +5438,19 @@ impl Storage { /// 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. + /// (in the `id` column) is unaffected. Monotonic guard: refuses + /// to apply if a more-recent key-burn at this slot has already + /// been recorded — prevents an attacker from replaying an older + /// signed key-burn to revert a more recent one. + /// Returns true on success, false on bounds error / not-found / + /// stale-timestamp rejection. pub fn replace_fof_slot( &self, post_id: &PostId, slot_index: u32, new_pub_x: &[u8; 32], new_wrap_slot: &crate::types::WrapSlot, + burned_at_ms: u64, ) -> 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); }; @@ -5440,13 +5458,46 @@ impl Storage { if idx >= gating.pub_post_set.len() || idx >= gating.wrap_slots.len() { return Ok(false); } + // Monotonic guard: reject burns older than the most recent we've + // applied at this slot. Without this, an attacker replaying an + // older signed key-burn diff could revert a newer one. + let last_burn: Option = self.conn.query_row( + "SELECT MAX(burned_at_ms) FROM fof_key_burns + WHERE post_id = ?1 AND slot_index = ?2", + params![post_id.as_slice(), slot_index as i64], + |row| row.get(0), + ).ok(); + if let Some(Some(last_ts)) = last_burn.map(|v| if v == 0 { None } else { Some(v) }) { + if (burned_at_ms as i64) <= last_ts { + 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( + let tx = self.conn.unchecked_transaction()?; + tx.execute( "UPDATE posts SET fof_gating_json = ?1 WHERE id = ?2", params![gating_json, post_id.as_slice()], )?; + // Record the burn for monotonicity on the next attempt. + tx.execute( + "INSERT INTO fof_key_burns + (post_id, slot_index, burned_at_ms, new_pub_x) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(post_id, slot_index) DO UPDATE SET + burned_at_ms = MAX(burned_at_ms, excluded.burned_at_ms), + new_pub_x = CASE + WHEN excluded.burned_at_ms > burned_at_ms + THEN excluded.new_pub_x ELSE new_pub_x END", + params![ + post_id.as_slice(), + slot_index as i64, + burned_at_ms as i64, + new_pub_x.as_slice(), + ], + )?; + tx.commit()?; Ok(true) }