diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..8a18337 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "rustreexo-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +# Keep the fuzz crate out of the main package's build; it is its own +# workspace so the profile settings below are honored. +[workspace] + +[dependencies] +libfuzzer-sys = "0.4" +arbitrary = { version = "1", features = ["derive"] } +rustreexo = { path = ".." } + +[[bin]] +name = "stump_model" +path = "fuzz_targets/stump_model.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "deserialize" +path = "fuzz_targets/deserialize.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "proof_corruption" +path = "fuzz_targets/proof_corruption.rs" +test = false +doc = false +bench = false + +# Fuzz builds must panic on arithmetic overflow and debug assertions: +# silent wrapping in position math is itself a bug class we want to catch. +[profile.release] +debug = true +debug-assertions = true +overflow-checks = true diff --git a/fuzz/fuzz_targets/deserialize.rs b/fuzz/fuzz_targets/deserialize.rs new file mode 100644 index 0000000..f44704b --- /dev/null +++ b/fuzz/fuzz_targets/deserialize.rs @@ -0,0 +1,62 @@ +//! Deserialization robustness fuzz target. +//! +//! Feeds arbitrary bytes to every public deserializer. None of them may +//! panic, abort on allocation, or overflow the stack; malformed input must +//! produce a clean error. Successful parses must round-trip. +//! +//! Expected early crashes (known Tier 0 findings; triage before long runs): +//! * `Proof::deserialize`: `Vec::with_capacity` on an attacker-controlled +//! u64 length => capacity-overflow panic / OOM (src/proof/mod.rs:462-474). +//! * `Pollard` / `MemForest`: unbounded recursion on nested input => +//! stack overflow (src/pollard/mod.rs:261-302, src/mem_forest/mod.rs:132-180). +//! * `Stump::modify` on a deserialized stump whose roots don't match +//! `popcount(leaves)` => panic; gated by EXERCISE_STATE below. +#![no_main] + +use libfuzzer_sys::fuzz_target; +use rustreexo::mem_forest::MemForest; +use rustreexo::node_hash::BitcoinNodeHash; +use rustreexo::pollard::Pollard; +use rustreexo::proof::Proof; +use rustreexo::stump::Stump; + +/// Also exercise the state machine on successfully deserialized stumps. +/// Known to trip on malformed stumps; set to false after triage to keep +/// fuzzing for other bugs. +const EXERCISE_STATE: bool = true; + +fn one_leaf() -> BitcoinNodeHash { + BitcoinNodeHash::from([0x42; 32]) +} + +fuzz_target!(|data: &[u8]| { + if let Ok(p) = Proof::::deserialize(data) { + let mut buf = Vec::new(); + p.serialize(&mut buf) + .expect("serialize of parsed proof must succeed"); + let p2 = Proof::::deserialize(&buf[..]) + .expect("re-parse of own serialization must succeed"); + assert_eq!(p, p2, "proof round-trip mismatch"); + } + + if let Ok(s) = Stump::::deserialize(data) { + let mut buf = Vec::new(); + s.serialize(&mut buf) + .expect("serialize of parsed stump must succeed"); + let s2 = Stump::::deserialize(&buf[..]) + .expect("re-parse of own serialization must succeed"); + assert_eq!(s, s2, "stump round-trip mismatch"); + + if EXERCISE_STATE { + // Malformed stumps must produce errors, never panics. + let _ = s.modify(&[one_leaf()], &[], &Proof::default()); + let _ = s.modify(&[], &[], &Proof::default()); + let _ = s.verify(&Proof::default(), &[]); + } + } + + // Deeply nested / malformed input must be rejected without stack + // overflow or panics (both parsers are recursive). + let _ = Pollard::::deserialize(&mut &data[..]); + let _ = MemForest::::deserialize(&data[..]); +}); diff --git a/fuzz/fuzz_targets/proof_corruption.rs b/fuzz/fuzz_targets/proof_corruption.rs new file mode 100644 index 0000000..79a0f12 --- /dev/null +++ b/fuzz/fuzz_targets/proof_corruption.rs @@ -0,0 +1,139 @@ +//! Proof-corruption / soundness fuzz target. +//! +//! Builds a valid accumulator state, obtains a VALID deletion proof from the +//! `MemForest` oracle, applies one fuzzed corruption, then feeds the result +//! to `Stump::verify` and `Stump::modify` — the exact entry points Floresta +//! uses for peer-supplied proofs. +//! +//! Properties asserted: +//! * never panic (overflow-checks enabled; attacker-controlled positions +//! such as u64::MAX must be rejected, not crash), +//! * SOUNDNESS: a proof whose deletion hash was replaced by a non-member, +//! or with a bit-flipped proof hash, must not verify and must not modify +//! state. +//! +//! Note: the soundness assert currently reproduces the Tier 0 fail-open +//! finding in `Proof::verify` (src/proof/mod.rs:336-361). Set +//! `ENFORCE_SOUNDNESS = false` (or fix the bug) for long runs. +#![no_main] + +use libfuzzer_sys::arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; +use rustreexo::mem_forest::MemForest; +use rustreexo::node_hash::BitcoinNodeHash; +use rustreexo::proof::Proof; +use rustreexo::stump::Stump; + +const ENFORCE_SOUNDNESS: bool = true; + +/// Deterministic, unique, non-sentinel leaf hash for a counter value. +fn leaf(counter: u64) -> BitcoinNodeHash { + let mut bytes = [0u8; 32]; + bytes[..8].copy_from_slice(&counter.to_le_bytes()); + bytes[8..16].copy_from_slice(&(!counter).to_be_bytes()); + bytes[16] = 0xa5; + BitcoinNodeHash::from(bytes) +} + +#[derive(Debug, Arbitrary)] +enum Corruption { + /// Bit-flip one proof hash. + FlipHash { idx: u8, xor: u8 }, + /// Replace one target with an arbitrary position (biased to edge values). + ReplaceTarget { idx: u8, pos: u64 }, + /// Drop some proof hashes. + Truncate { keep: u8 }, + /// Duplicate a target. + DupTarget { idx: u8 }, + /// Replace one deletion hash with a non-member hash. + BogusDelHash { idx: u8, fresh: u64 }, +} + +#[derive(Debug, Arbitrary)] +struct Input { + n_leaves: u8, + second_tree: bool, + corruption: Corruption, +} + +fuzz_target!(|input: Input| { + let n = 2 + (input.n_leaves % 31) as usize; // 2..=32 leaves + let leaves: Vec<_> = (0..n as u64).map(leaf).collect(); + + let (stump, _) = Stump::new() + .modify(&leaves, &[], &Proof::default()) + .expect("setup add"); + let mut forest = MemForest::new(); + forest.modify(&leaves, &[]).expect("oracle setup"); + + // One deletion from the first leaf; optionally a second one from the + // last leaf (usually a different Merkle tree => multi-root proof). + let mut dels = vec![leaves[0]]; + if input.second_tree && n > 2 { + dels.push(leaves[n - 1]); + } + + let proof = forest.prove(&dels).expect("oracle must prove live leaves"); + assert_eq!( + stump.verify(&proof, &dels), + Ok(true), + "LIVENESS: valid proof rejected by Stump" + ); + + let mut corrupted = proof.clone(); + let mut corrupted_dels = dels.clone(); + // Set when the corruption is guaranteed to invalidate the proof. + let mut expect_invalid = false; + + match input.corruption { + Corruption::FlipHash { idx, xor } => { + if corrupted.hashes.is_empty() || xor == 0 { + return; + } + let i = idx as usize % corrupted.hashes.len(); + if let BitcoinNodeHash::Some(mut inner) = corrupted.hashes[i] { + inner[0] ^= xor; + corrupted.hashes[i] = BitcoinNodeHash::from(inner); + expect_invalid = true; + } + } + Corruption::ReplaceTarget { idx, pos } => { + if corrupted.targets.is_empty() { + return; + } + let biased = match pos % 4 { + 0 => pos, + 1 => u64::MAX, + 2 => stump.leaves.saturating_add(pos % 64), // just past the end + _ => pos % stump.leaves.max(1), // in-range, wrong pairing + }; + let i = idx as usize % corrupted.targets.len(); + corrupted.targets[i] = biased; + } + Corruption::Truncate { keep } => { + let keep = keep as usize % (corrupted.hashes.len() + 1); + corrupted.hashes.truncate(keep); + } + Corruption::DupTarget { idx } => { + if corrupted.targets.is_empty() { + return; + } + let t = corrupted.targets[idx as usize % corrupted.targets.len()]; + corrupted.targets.push(t); + } + Corruption::BogusDelHash { idx, fresh } => { + let i = idx as usize % corrupted_dels.len(); + corrupted_dels[i] = leaf(1_000_000 + fresh); // guaranteed non-member + expect_invalid = true; + } + } + + // These calls must never panic, whatever the corruption was. + let v = stump.verify(&corrupted, &corrupted_dels); + let m = stump.modify(&[], &corrupted_dels, &corrupted); + + if ENFORCE_SOUNDNESS && expect_invalid { + assert_ne!(v, Ok(true), "SOUNDNESS: corrupted proof accepted by verify"); + assert!(m.is_err(), "SOUNDNESS: corrupted proof accepted by modify"); + } +}); diff --git a/fuzz/fuzz_targets/stump_model.rs b/fuzz/fuzz_targets/stump_model.rs new file mode 100644 index 0000000..c69b73a --- /dev/null +++ b/fuzz/fuzz_targets/stump_model.rs @@ -0,0 +1,156 @@ +//! Differential state-machine fuzz target. +//! +//! Drives a `Stump` (the compact, verify-only accumulator used by Floresta) +//! and a `MemForest` (the full in-memory forest, used here as the proof +//! oracle) through the same random sequence of additions and deletions, and +//! asserts: +//! +//! 1. No public API ever panics (overflow-checks are enabled in fuzz +//! builds, so wrapping arithmetic in position math is caught too). +//! 2. After every operation both accumulators commit to the same set of +//! non-empty roots (ordering is an internal convention, so the +//! comparison is done sorted). +//! 3. Every proof the oracle generates for live leaves is accepted by the +//! `Stump`, both via `verify` and via `modify`. +//! 4. `Stump` survives a serialize/deserialize round-trip unchanged. +//! +//! Any assert failure is security-relevant: (2) means state divergence +//! between implementations, (3) means honest proofs are rejected +//! (liveness/DoS for Floresta, which bans the proof peer on failure). +#![no_main] + +use libfuzzer_sys::arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; +use rustreexo::mem_forest::MemForest; +use rustreexo::node_hash::AccumulatorHash; +use rustreexo::node_hash::BitcoinNodeHash; +use rustreexo::proof::Proof; +use rustreexo::stump::Stump; + +/// Deterministic, unique, non-sentinel leaf hash for a counter value. +fn leaf(counter: u64) -> BitcoinNodeHash { + let mut bytes = [0u8; 32]; + bytes[..8].copy_from_slice(&counter.to_le_bytes()); + bytes[8..16].copy_from_slice(&(!counter).to_be_bytes()); + bytes[16] = 0xa5; + BitcoinNodeHash::from(bytes) +} + +#[derive(Debug, Arbitrary)] +enum Op { + /// Append 1..=8 fresh leaves in one `modify`. + Add { n: u8 }, + /// Delete 1..=4 distinct live leaves using a valid oracle proof. + Del { idx: [u8; 4], n: u8 }, + /// Prove and verify one random live leaf. + VerifyOne { idx: u8 }, + /// Serialize/deserialize round-trip of the Stump. + SerDe, +} + +#[derive(Debug, Arbitrary)] +struct Input { + ops: Vec, +} + +/// The accumulator's commitment, normalized: non-empty roots, sorted. +fn commitment(stump: &Stump, forest: &MemForest) -> (Vec, Vec) { + let mut a: Vec<_> = stump + .roots + .iter() + .copied() + .filter(|r| !r.is_empty()) + .collect(); + let mut b: Vec<_> = forest + .get_roots() + .iter() + .map(|r| r.get_data()) + .filter(|r| !r.is_empty()) + .collect(); + a.sort(); + b.sort(); + (a, b) +} + +fuzz_target!(|input: Input| { + let mut stump = Stump::new(); + let mut forest = MemForest::new(); + let mut live: Vec = Vec::new(); + let mut counter: u64 = 0; + + for op in input.ops.iter().take(48) { + match *op { + Op::Add { n } => { + if live.len() > 96 { + continue; + } + let k = (n % 8) as usize + 1; + let adds: Vec<_> = (0..k) + .map(|_| { + let h = leaf(counter); + counter += 1; + h + }) + .collect(); + stump = stump + .modify(&adds, &[], &Proof::default()) + .expect("add-only modify must succeed") + .0; + forest.modify(&adds, &[]).expect("oracle add must succeed"); + live.extend_from_slice(&adds); + } + Op::Del { idx, n } => { + if live.is_empty() { + continue; + } + let want = (n % 4) as usize + 1; + let mut picked: Vec = Vec::new(); + for i in 0..want.min(live.len()) { + let h = live[idx[i] as usize % live.len()]; + if !picked.contains(&h) { + picked.push(h); + } + } + if picked.is_empty() { + continue; + } + let proof = forest + .prove(&picked) + .expect("oracle must prove live leaves"); + assert_eq!( + stump.verify(&proof, &picked), + Ok(true), + "LIVENESS: valid batch proof rejected by Stump" + ); + stump = stump + .modify(&[], &picked, &proof) + .expect("valid deletion must succeed") + .0; + forest.modify(&[], &picked).expect("oracle delete must succeed"); + live.retain(|h| !picked.contains(h)); + } + Op::VerifyOne { idx } => { + if live.is_empty() { + continue; + } + let h = live[idx as usize % live.len()]; + let proof = forest.prove(&[h]).expect("oracle must prove live leaf"); + assert_eq!( + stump.verify(&proof, &[h]), + Ok(true), + "LIVENESS: valid proof rejected by Stump" + ); + } + Op::SerDe => { + let mut buf = Vec::new(); + stump.serialize(&mut buf).expect("serialize must succeed"); + let back = Stump::deserialize(&buf[..]).expect("deserialize must succeed"); + assert_eq!(stump, back, "stump serialization round-trip mismatch"); + } + } + + let (a, b) = commitment(&stump, &forest); + assert_eq!(a, b, "DIVERGENCE: stump/oracle root mismatch after {op:?}"); + assert_eq!(stump.leaves, counter, "leaf count mismatch"); + } +});