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:
parent
12a305889e
commit
aa190db375
4 changed files with 249 additions and 2 deletions
|
|
@ -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 {
|
let stored = if let Some(intent) = intent {
|
||||||
s.store_post_with_intent(id, post, visibility, intent)?
|
s.store_post_with_intent(id, post, visibility, intent)?
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -469,6 +469,103 @@ pub fn decrypt_fof_comment_payload(
|
||||||
.map_err(|e| anyhow::anyhow!("deserialize fof comment payload: {}", e))
|
.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 (FoFClosed) body encryption ---
|
||||||
//
|
//
|
||||||
// Mode 1 reuses Layer 2's wrap_slots and CEK. The only addition is
|
// 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);
|
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]
|
#[test]
|
||||||
fn body_bucket_rule_boundaries() {
|
fn body_bucket_rule_boundaries() {
|
||||||
// Sub-1KB.
|
// Sub-1KB.
|
||||||
|
|
|
||||||
|
|
@ -5168,8 +5168,23 @@ impl Storage {
|
||||||
Ok(())
|
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
|
/// 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(
|
pub fn record_unreadable_post(
|
||||||
&self,
|
&self,
|
||||||
reader_persona_id: &NodeId,
|
reader_persona_id: &NodeId,
|
||||||
|
|
@ -5177,6 +5192,28 @@ impl Storage {
|
||||||
author_id: &NodeId,
|
author_id: &NodeId,
|
||||||
now_ms: u64,
|
now_ms: u64,
|
||||||
) -> anyhow::Result<()> {
|
) -> 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(
|
self.conn.execute(
|
||||||
"INSERT INTO vouch_unreadable_posts
|
"INSERT INTO vouch_unreadable_posts
|
||||||
(reader_persona_id, post_id, author_id, first_seen_ms, last_attempt_ms)
|
(reader_persona_id, post_id, author_id, first_seen_ms, last_attempt_ms)
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue