fix(fof): pre-deploy hardening — wire validation + unreadable cap

DoS-resistance pass before shipping Layers 1-5. Found three concerns:

1. Receive-path lacked validation on FoF gating shape.
2. vouch_unreadable_posts queue had no upper bound.
3. Receive-path FoFClosed visibility could pair with no fof_gating.

Fix 1 (fof::validate_fof_gating_on_receive):
- Called from control::receive_post BEFORE any storage write.
- Rejects wrap_slots/pub_post_set length mismatch (preserves the
  pub_x_index lookup invariant).
- Caps wrap_slots at MAX_FOF_WRAP_SLOTS=8192. Above that we assume
  attacker-shaped; legitimate bucket rule maxes at ~real+128 above 256.
- Validates each WrapSlot.read_ciphertext / sign_ciphertext is
  exactly 48 bytes (matches seal_wrap_slot's output).
- Caps revocation_list at MAX_FOF_REVOCATION_LIST=4096.
- Bad posts never enter storage, never get re-propagated via
  neighbor-manifest diffs.

Fix 2 (fof::validate_fof_closed_has_gating):
- FoFClosed visibility + None gating is an invariant violation.
  Rejected at the same receive boundary.

Fix 3 (storage::record_unreadable_post):
- Per-persona cap of MAX_UNREADABLE_PER_PERSONA=4096. Above the cap,
  new posts get last_attempt_ms touched if already present but no
  new INSERT. Bounds sweep-on-V_x-arrival cost.

7 new tests bring the suite to 157:
- validate_rejects_length_mismatch / oversized_slots / wrong_ciphertext
- validate_accepts_well_formed_gating / post_without_gating
- validate_fof_closed_requires_gating
- unreadable_queue_is_capped

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-05-14 20:23:11 -06:00
parent 12a305889e
commit aa190db375
4 changed files with 249 additions and 2 deletions

View file

@ -120,6 +120,15 @@ pub fn receive_post(
_ => {}
}
// FoF Layer 2/3 hardening (pre-deploy audit): reject malformed
// FoF gating blocks BEFORE storage. Bounds wrap_slots count,
// enforces 1:1 wrap_slots/pub_post_set parity, validates
// ciphertext field sizes, caps revocation_list. Also enforces
// the FoFClosed-implies-gating invariant. Rejection prevents
// bad data from being re-propagated via neighbor-manifest diffs.
crate::fof::validate_fof_closed_has_gating(post, visibility)?;
crate::fof::validate_fof_gating_on_receive(post)?;
let stored = if let Some(intent) = intent {
s.store_post_with_intent(id, post, visibility, intent)?
} else {

View file

@ -469,6 +469,103 @@ pub fn decrypt_fof_comment_payload(
.map_err(|e| anyhow::anyhow!("deserialize fof comment payload: {}", e))
}
// --- Wire-shape validation for incoming posts (DoS hardening) ---
//
// Called from control::receive_post before any storage write. Rejects
// malformed FoF gating blocks so they never enter storage and get
// re-propagated via neighbor-manifest diffs.
/// Maximum allowed wrap_slots / pub_post_set entries on an incoming
/// FoF post. The bucket rule caps at `real + rand(0..=128)` above 256;
/// at a 4096-vouchee max realistic graph that's ~4224. Round up for
/// headroom; anything larger is presumed attacker-shaped.
const MAX_FOF_WRAP_SLOTS: usize = 8192;
/// Maximum allowed revocation_list entries in a t=0 published gating
/// block. Initial publish should have an EMPTY list; receivers
/// accumulate revocations via diffs into the live fof_revocations
/// table. A non-empty list on first publish is suspicious but not
/// strictly invalid — bound it generously.
const MAX_FOF_REVOCATION_LIST: usize = 4096;
/// Validate a FoF gating block on receive. Rejects:
/// - wrap_slots / pub_post_set length mismatch (indexing invariant)
/// - bucket-size violation (DoS bound)
/// - wrong WrapSlot ciphertext sizes (always 48 bytes today)
/// - oversized revocation_list (DoS bound)
///
/// Sound caller pattern (control::receive_post):
/// - Visibility == FoFClosed implies post.fof_gating MUST be Some.
/// - Any post with fof_gating Some passes this check.
pub fn validate_fof_gating_on_receive(post: &crate::types::Post) -> Result<()> {
// Invariant: FoFClosed visibility implies Some gating.
// (No way to recover the body without it.)
// The visibility is checked by the caller; this validates the
// gating shape when it's present.
let Some(gating) = post.fof_gating.as_ref() else {
return Ok(());
};
// 1:1 invariant: every wrap_slot has a matching pub_post_set entry
// so pub_x_index lookups always succeed within bounds.
if gating.wrap_slots.len() != gating.pub_post_set.len() {
anyhow::bail!(
"FoF wrap_slots/pub_post_set length mismatch: {} vs {}",
gating.wrap_slots.len(),
gating.pub_post_set.len(),
);
}
// Bucket-size cap — bounds memory + scan cost.
if gating.wrap_slots.len() > MAX_FOF_WRAP_SLOTS {
anyhow::bail!(
"FoF wrap_slots oversized: {} > {} (DoS cap)",
gating.wrap_slots.len(),
MAX_FOF_WRAP_SLOTS,
);
}
// Per-slot field-size invariants. seal_wrap_slot always produces
// 48-byte ciphertexts (32B sealed plaintext + 16B AEAD tag); any
// other size is malformed and shouldn't tie up AEAD attempts.
for (i, slot) in gating.wrap_slots.iter().enumerate() {
if slot.read_ciphertext.len() != 48 || slot.sign_ciphertext.len() != 48 {
anyhow::bail!(
"FoF wrap_slot {} has wrong ciphertext sizes: read={} sign={} (must both be 48)",
i,
slot.read_ciphertext.len(),
slot.sign_ciphertext.len(),
);
}
}
// Bound revocation_list size — revocations should arrive as diffs,
// not be stuffed into the t=0 publish. Cap generously.
if gating.revocation_list.len() > MAX_FOF_REVOCATION_LIST {
anyhow::bail!(
"FoF revocation_list oversized: {} > {} (DoS cap)",
gating.revocation_list.len(),
MAX_FOF_REVOCATION_LIST,
);
}
Ok(())
}
/// Companion check: enforce the visibility-implies-gating invariant.
/// Called from `control::receive_post` after the gating-shape check.
pub fn validate_fof_closed_has_gating(
post: &crate::types::Post,
visibility: &crate::types::PostVisibility,
) -> Result<()> {
if matches!(visibility, crate::types::PostVisibility::FoFClosed)
&& post.fof_gating.is_none()
{
anyhow::bail!("FoFClosed visibility requires a fof_gating block");
}
Ok(())
}
// --- Mode 1 (FoFClosed) body encryption ---
//
// Mode 1 reuses Layer 2's wrap_slots and CEK. The only addition is
@ -1349,6 +1446,110 @@ mod tests {
assert_eq!(g.pub_post_set[alice_slot_idx], new_pub_x);
}
// --- Pre-deploy hardening: wire-shape validation + DoS caps ---
fn dummy_wrap_slot() -> crate::types::WrapSlot {
crate::types::WrapSlot {
prefilter_tag: [0u8; 2],
read_ciphertext: vec![0u8; 48],
sign_ciphertext: vec![0u8; 48],
}
}
fn dummy_gating(slot_count: usize) -> crate::types::FoFCommentGating {
crate::types::FoFCommentGating {
slot_binder_nonce: [0u8; 32],
pub_post_set: (0..slot_count).map(|_| [0u8; 32]).collect(),
wrap_slots: (0..slot_count).map(|_| dummy_wrap_slot()).collect(),
revocation_list: vec![],
}
}
fn dummy_post(g: Option<crate::types::FoFCommentGating>) -> crate::types::Post {
crate::types::Post {
author: [0u8; 32], content: String::new(), attachments: vec![],
timestamp_ms: 0, fof_gating: g, supersedes_post_id: None,
}
}
#[test]
fn validate_rejects_length_mismatch() {
let mut g = dummy_gating(8);
g.pub_post_set.pop();
let p = dummy_post(Some(g));
let err = validate_fof_gating_on_receive(&p).unwrap_err().to_string();
assert!(err.contains("length mismatch"), "got: {}", err);
}
#[test]
fn validate_rejects_oversized_slots() {
let g = dummy_gating(MAX_FOF_WRAP_SLOTS + 1);
let p = dummy_post(Some(g));
let err = validate_fof_gating_on_receive(&p).unwrap_err().to_string();
assert!(err.contains("oversized"), "got: {}", err);
}
#[test]
fn validate_rejects_wrong_ciphertext_size() {
let mut g = dummy_gating(8);
g.wrap_slots[3].read_ciphertext = vec![0u8; 32]; // wrong size
let p = dummy_post(Some(g));
let err = validate_fof_gating_on_receive(&p).unwrap_err().to_string();
assert!(err.contains("ciphertext sizes"), "got: {}", err);
}
#[test]
fn validate_accepts_well_formed_gating() {
let g = dummy_gating(16);
let p = dummy_post(Some(g));
validate_fof_gating_on_receive(&p).unwrap();
}
#[test]
fn validate_accepts_post_without_gating() {
let p = dummy_post(None);
validate_fof_gating_on_receive(&p).unwrap();
}
#[test]
fn validate_fof_closed_requires_gating() {
let p = dummy_post(None);
let err = validate_fof_closed_has_gating(&p, &crate::types::PostVisibility::FoFClosed)
.unwrap_err().to_string();
assert!(err.contains("requires a fof_gating"), "got: {}", err);
// FoFClosed + gating Some → OK
let p2 = dummy_post(Some(dummy_gating(8)));
validate_fof_closed_has_gating(&p2, &crate::types::PostVisibility::FoFClosed).unwrap();
// Public + None → OK
validate_fof_closed_has_gating(&p, &crate::types::PostVisibility::Public).unwrap();
}
#[test]
fn unreadable_queue_is_capped() {
let s = temp_storage();
let (persona, _) = make_persona(200);
let (author, _) = make_persona(201);
// Fill to the cap (4096 entries) — use distinct post_ids.
for i in 0..crate::storage::Storage::max_unreadable_per_persona_for_test() as u32 {
let mut pid = [0u8; 32];
pid[..4].copy_from_slice(&i.to_le_bytes());
s.record_unreadable_post(&persona, &pid, &author, 1000 + i as u64).unwrap();
}
// Try to add one more. Should be silently dropped (no INSERT).
let mut overflow_pid = [0u8; 32];
overflow_pid[..4].copy_from_slice(&999_999u32.to_le_bytes());
s.record_unreadable_post(&persona, &overflow_pid, &author, 999_999).unwrap();
let queued = s.list_all_unreadable_posts(&persona).unwrap();
assert_eq!(
queued.len() as i64,
crate::storage::Storage::max_unreadable_per_persona_for_test(),
"queue stays at cap; overflow dropped",
);
// Overflow post was NOT added.
assert!(!queued.contains(&overflow_pid));
}
#[test]
fn body_bucket_rule_boundaries() {
// Sub-1KB.

View file

@ -5168,8 +5168,23 @@ impl Storage {
Ok(())
}
/// Per-persona cap on `vouch_unreadable_posts` queue size. Prevents
/// a spam attacker from filling the queue with O(N) FoF posts we
/// can't unlock — each subsequent V_x arrival would sweep the
/// entire queue. At 4096 entries × ~3.8 AEAD attempts per post per
/// V_x arrival, sweep cost is bounded to milliseconds.
const MAX_UNREADABLE_PER_PERSONA: i64 = 4096;
/// Test-only accessor for the cap constant.
#[cfg(test)]
#[doc(hidden)]
pub fn max_unreadable_per_persona_for_test() -> i64 {
Self::MAX_UNREADABLE_PER_PERSONA
}
/// Mark a post as unreadable by `reader_persona`. Swept later when
/// a new V_x arrives in the persona's keyring.
/// a new V_x arrives in the persona's keyring. Bounded per-persona
/// to prevent attacker-driven queue growth.
pub fn record_unreadable_post(
&self,
reader_persona_id: &NodeId,
@ -5177,6 +5192,28 @@ impl Storage {
author_id: &NodeId,
now_ms: u64,
) -> anyhow::Result<()> {
// Bound check. If the persona already has the cap, drop the
// new entry silently. The post is still propagated via CDN to
// peers; it just doesn't enter THIS persona's retry queue.
let existing: i64 = self.conn.prepare(
"SELECT COUNT(*) FROM vouch_unreadable_posts
WHERE reader_persona_id = ?1",
)?.query_row(params![reader_persona_id.as_slice()], |row| row.get(0))?;
if existing >= Self::MAX_UNREADABLE_PER_PERSONA {
// Allow re-touching an existing row's last_attempt_ms, but
// refuse to INSERT a new one.
self.conn.execute(
"UPDATE vouch_unreadable_posts
SET last_attempt_ms = ?3
WHERE reader_persona_id = ?1 AND post_id = ?2",
params![
reader_persona_id.as_slice(),
post_id.as_slice(),
now_ms as i64,
],
)?;
return Ok(());
}
self.conn.execute(
"INSERT INTO vouch_unreadable_posts
(reader_persona_id, post_id, author_id, first_seen_ms, last_attempt_ms)

File diff suppressed because one or more lines are too long