diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..667f921 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,140 @@ +# SPDX-License-Identifier: MIT OR Apache-2.0 + +name: Fuzz Tests + +on: + push: + pull_request: + +env: + CARGO_TERM_COLOR: always + CARGO_FUZZ_VERSION: 0.13.1 + APT_CONFIG: | + Dir::Cache "./.apt-cache"; + Dir::Cache::archives "./.apt-cache/archives"; + Dir::State "./.apt-state"; + Dir::State::lists "./.apt-state/lists/"; + +permissions: {} + +jobs: + fuzz: + name: Run Fuzzing Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Install Nightly Rust Toolchain + run: | + rustup toolchain install nightly --profile minimal --no-self-update + rustup default nightly + + # Calculate cache keys + - name: Generate cache keys + run: | + YEAR=$(date +%Y) + WEEK=$(date +%U) + # Use '10#' to always treat week number as base-10 (avoids octal when number has a leading zero) + BIWEEK=$(( (10#$WEEK + 1) / 2 )) + echo "CACHE_VERSION=${YEAR}(${BIWEEK})" >> $GITHUB_ENV + + # Hash of all files that could affect the build + HASH=$(echo "${{ hashFiles('Cargo-minimal.lock', 'Cargo-recent.lock', 'Cargo.toml', '.github/workflows/**') }}") + echo "BUILD_HASH=${HASH}" >> $GITHUB_ENV + + # Hash of apt packages we need + APT_PACKAGES="build-essential cmake clang" + echo "APT_HASH=$(echo $APT_PACKAGES | sha256sum | cut -d' ' -f1)" >> $GITHUB_ENV + shell: bash + + # Restore Rust build cache + - name: Restore Rust cache + id: cache-rust + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + ~/.cargo/bin/cargo-fuzz + target/ + fuzz/target/ + key: ${{ runner.os }}-cargo-${{ env.CACHE_VERSION }}-fuzz${{ env.CARGO_FUZZ_VERSION }}-${{ env.BUILD_HASH }} + restore-keys: | + ${{ runner.os }}-cargo-${{ env.CACHE_VERSION }}-fuzz${{ env.CARGO_FUZZ_VERSION }}- + ${{ runner.os }}-cargo-${{ env.CACHE_VERSION }}- + ${{ runner.os }}-cargo- + + # Restore apt packages cache + - name: Restore apt cache + id: cache-apt + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ./.apt-cache + ./.apt-state + key: ${{ runner.os }}-apt-${{ env.APT_HASH }} + + - name: Install Boost + run: | + sudo apt-get update + sudo apt-get install -y libboost-all-dev + + # Install system dependencies only if cache miss + - name: Install libfuzzer dependencies + if: steps.cache-apt.outputs.cache-hit != 'true' + run: | + mkdir -p ./.apt-cache/archives ./.apt-state/lists + sudo -E apt-get update && sudo -E apt-get install -y build-essential cmake clang + + # Install cargo-fuzz only if not found in cache + - name: Install cargo-fuzz + if: steps.cache-rust.outputs.cache-hit != 'true' + run: cargo +nightly install cargo-fuzz --version ${{ env.CARGO_FUZZ_VERSION }} --force + + # Run fuzzing tests + - name: Run fuzzing tests + run: | + cd fuzz + echo "Available fuzz targets:" + cargo fuzz list + for target in $(cargo fuzz list); do + echo "Running fuzz target: $target" + CARGO_PROFILE_RELEASE_LTO=false cargo +nightly fuzz run $target -- -max_total_time=60 + done + + # Save Rust cache if there was no exact match + - name: Save Rust cache + if: success() && steps.cache-rust.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + ~/.cargo/bin/cargo-fuzz + target/ + fuzz/target/ + key: ${{ steps.cache-rust.outputs.cache-primary-key }} + + # Save apt cache if there was no exact match + - name: Save apt cache + if: success() && steps.cache-apt.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ./.apt-cache + ./.apt-state + key: ${{ runner.os }}-apt-${{ env.APT_HASH }} + + # Upload artifacts (if crashes are found) + - name: Upload artifacts + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: fuzz-artifacts + path: fuzz/artifacts diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000..535350d --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,2 @@ +artifacts/ +corpus/ diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..69798f2 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,38 @@ +[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 = "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..c7e48f8 --- /dev/null +++ b/fuzz/fuzz_targets/deserialize.rs @@ -0,0 +1,48 @@ +//! 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. +//! +#![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; + +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"); + + // 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..ccda726 --- /dev/null +++ b/fuzz/fuzz_targets/proof_corruption.rs @@ -0,0 +1,161 @@ +//! 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. +//! +#![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; + +/// 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)] +/// Corruption operations applied to a proof. +/// +/// To exercise our crypto primitives, we corrupt an otherwise valid proof, making it invalid by a +/// small factor. We then make sure that our code will detect and reject such fraudulent proof, +/// without panicking. This enum contains all possible corruptions we use. +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)] +/// An input to our fuzz target. +struct Input { + /// How many leaves we should add to our fuzzer + n_leaves: u8, + + /// Whether we should delete from one more tree. + second_tree: bool, + + /// The corruption we will apply to this input + 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 provide valid proofs"); + assert_eq!( + stump.verify(&proof, &dels), + Ok(true), + "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); + + // this won't corrupt anything + if corrupted.hashes.len() <= keep.into() { + return; + } + + corrupted.hashes.truncate(keep); + expect_invalid = true; + } + Corruption::DupTarget { idx } => { + if corrupted.targets.is_empty() { + return; + } + let t = corrupted.targets[idx as usize % corrupted.targets.len()]; + corrupted.targets.push(t); + expect_invalid = true; + } + Corruption::BogusDelHash { idx, fresh } => { + let i = idx as usize % corrupted_dels.len(); + corrupted_dels[i] = leaf(1_000_000u64.saturating_add(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 expect_invalid { + assert_ne!(v, Ok(true), "corrupted proof accepted by verify"); + assert!(m.is_err(), "corrupted proof accepted by modify"); + } +}); diff --git a/justfile b/justfile index ee02d91..fee6a47 100644 --- a/justfile +++ b/justfile @@ -13,6 +13,20 @@ _default: @echo "> A Rust implementation of Utreexo\n" @just --list +[doc("Run this project's fuzz targets for 10 minutes per target by default")] +fuzz TARGET="" TIME="600": + #!/usr/bin/env bash + set -euo pipefail + + if [[ -n "{{TARGET}}" ]]; then + cargo +nightly fuzz run "{{TARGET}}" -- -max_total_time={{TIME}} + else + for target in $(cargo +nightly fuzz list); do + echo "Running fuzz target: $target" + cargo +nightly fuzz run "$target" -- -max_total_time={{TIME}} + done + fi + [doc: "Run benchmarks: accumulator, proof, stump"] bench BENCH="": cargo rbmt run bench {{ if BENCH != "" { "--bench " + BENCH } else { "" } }} diff --git a/src/lib.rs b/src/lib.rs index e1f89ae..1535f2d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -59,10 +59,10 @@ pub(crate) const MAX_FOREST_ROWS: u8 = 63; /// Untrusted length prefixes are checked against this value before /// `Vec::with_capacity`, so a hostile payload cannot force an enormous /// reservation and OOM the process. The bound is intentionally large and -/// fixed (~4 GiB of 32-byte hashes: `(4 * 1024³) / 32 == 1 << 27`). +/// fixed size, arbitrary taken. /// The same count applies to targets (`u64`). [`proof::Proof::serialize`] /// and in-memory construction are uncapped. -pub const MAX_PROOF_DESERIALIZE_COUNT: u64 = (4u64 * 1024 * 1024 * 1024) / 32; +pub const MAX_PROOF_DESERIALIZE_COUNT: u64 = 10_000_000; #[cfg(not(feature = "std"))] /// Re-exports `alloc` basics plus HashMap/HashSet and IO traits. diff --git a/src/mem_forest/mod.rs b/src/mem_forest/mod.rs index a5bf1b6..6285776 100644 --- a/src/mem_forest/mod.rs +++ b/src/mem_forest/mod.rs @@ -133,7 +133,15 @@ impl Node { ancestor: Option>>, reader: &mut R, index: &mut HashMap>>, + depth: u8, ) -> io::Result>> { + // The forest has at most 64 rows, so a serialized node can never be + // deeper than that. Anything deeper is malformed input; reject it + // instead of recursing until the stack overflows. + if depth > MAX_FOREST_ROWS + 1 { + return Err(io::Error::from(io::ErrorKind::InvalidData)); + } + let mut ty = [0u8; 8]; reader.read_exact(&mut ty)?; let data = Hash::read(reader)?; @@ -141,7 +149,7 @@ impl Node { let ty = match u64::from_le_bytes(ty) { 0 => NodeType::Branch, 1 => NodeType::Leaf, - _ => panic!("Invalid node type"), + _ => return Err(io::Error::from(io::ErrorKind::InvalidData)), }; if ty == NodeType::Leaf { let leaf = Rc::new(Node { @@ -162,8 +170,8 @@ impl Node { right: RefCell::new(None), }); if !data.is_empty() { - let left = _read_one(Some(node.clone()), reader, index)?; - let right = _read_one(Some(node.clone()), reader, index)?; + let left = _read_one(Some(node.clone()), reader, index, depth + 1)?; + let right = _read_one(Some(node.clone()), reader, index, depth + 1)?; node.left.replace(Some(left)); node.right.replace(Some(right)); } @@ -179,7 +187,7 @@ impl Node { Ok(node) } let mut index = HashMap::with_hasher(Default::default()); - let root = _read_one(None, reader, &mut index)?; + let root = _read_one(None, reader, &mut index, 0)?; Ok((root, index)) } @@ -1000,6 +1008,38 @@ mod test { ); } + #[test] + fn test_deserialize_rejects_invalid_node_type() { + // A node type other than 0 (branch) or 1 (leaf) must produce an error, + // not a panic. + let mut data = Vec::new(); + data.extend_from_slice(&1u64.to_le_bytes()); // leaves = 1 + data.extend_from_slice(&1u64.to_le_bytes()); // roots_len = 1 + data.extend_from_slice(&42u64.to_le_bytes()); // invalid node type + data.extend_from_slice(&[0u8; 32]); // hash + let result = MemForest::::deserialize(&data[..]); + assert!(result.is_err()); + } + + #[test] + fn test_deserialize_rejects_deep_nesting() { + // Craft input with deeply nested branch nodes; must not stack-overflow. + let mut data = Vec::new(); + data.extend_from_slice(&1u64.to_le_bytes()); // leaves = 1 + data.extend_from_slice(&1u64.to_le_bytes()); // roots_len = 1 + // 200 nested branch nodes (type=0 + non-empty hash so children are read) + for _ in 0..200 { + data.extend_from_slice(&0u64.to_le_bytes()); // branch + data.extend_from_slice(&[0x42u8; 32]); // non-empty hash + } + // Terminal leaf + data.extend_from_slice(&1u64.to_le_bytes()); + data.extend_from_slice(&[0x42u8; 32]); + + let result = MemForest::::deserialize(&data[..]); + assert!(result.is_err()); + } + #[test] fn test_serialize_one() { let hashes = get_hash_vec_of(&[0, 1, 2, 3, 4, 5, 6, 7]); diff --git a/src/pollard/mod.rs b/src/pollard/mod.rs index 1858e3b..073c36c 100644 --- a/src/pollard/mod.rs +++ b/src/pollard/mod.rs @@ -263,6 +263,22 @@ impl PollardNode { ancestor: Option>, leaf_map: &mut HashMap>, ) -> Result, PollardError> { + Self::deserialize_inner(reader, ancestor, leaf_map, 0) + } + + fn deserialize_inner( + reader: &mut R, + ancestor: Option>, + leaf_map: &mut HashMap>, + depth: u8, + ) -> Result, PollardError> { + // The forest has at most 64 rows, so a serialized node can never be deeper + // than that. Anything deeper is malformed input; reject it instead of + // recursing until the stack overflows. + if depth > MAX_FOREST_ROWS + 1 { + return Err(PollardError::InvalidProof); + } + let mut is_leaf = [0u8; 1]; reader.read_exact(&mut is_leaf)?; @@ -293,8 +309,8 @@ impl PollardNode { let node_weak = Rc::downgrade(&node); - let left = Self::deserialize(reader, Some(node_weak.clone()), leaf_map)?; - let right = Self::deserialize(reader, Some(node_weak), leaf_map)?; + let left = Self::deserialize_inner(reader, Some(node_weak.clone()), leaf_map, depth + 1)?; + let right = Self::deserialize_inner(reader, Some(node_weak), leaf_map, depth + 1)?; node.left_niece.replace(Some(left)); node.right_niece.replace(Some(right)); @@ -1286,6 +1302,25 @@ mod tests { use crate::node_hash::BitcoinNodeHash; use crate::util::hash_from_u8; + #[test] + fn test_deserialize_rejects_deep_nesting() { + // Craft input with many nested branch nodes; must not stack-overflow. + let mut data = Vec::new(); + data.extend_from_slice(&1u64.to_be_bytes()); // leaves = 1 + data.push(1u8); // root marker = present + // 200 nested branch nodes (is_leaf=0 + 32-byte hash each) + for _ in 0..200 { + data.push(0u8); // is_leaf = false + data.extend_from_slice(&[0x42u8; 32]); + } + // Terminal leaf + data.push(1u8); + data.extend_from_slice(&[0x42u8; 32]); + + let result = Pollard::::deserialize(&mut &data[..]); + assert!(result.is_err()); + } + #[test] fn test_ser_rtt() { let mut p = Pollard::::new(); diff --git a/src/proof/mod.rs b/src/proof/mod.rs index a0ff2a0..d86ff2c 100644 --- a/src/proof/mod.rs +++ b/src/proof/mod.rs @@ -57,11 +57,9 @@ //! assert!(s.verify(&p, &vec![hashes[0]]).expect("This proof is valid")); //! ``` -use alloc::vec::IntoIter; use core::fmt; use core::fmt::Debug; use core::fmt::Formatter; -use core::iter::Peekable; #[cfg(feature = "with-serde")] use serde::Deserialize; @@ -342,15 +340,13 @@ impl Proof { roots: &[Hash], num_leaves: u64, ) -> Result { - if self.targets.is_empty() { - return Ok(true); + if self.targets.is_empty() || roots.is_empty() || num_leaves == 0 { + return Ok(del_hashes.is_empty()); } - let mut calculated_roots: Peekable> = self - .calculate_hashes(del_hashes, num_leaves)? - .1 - .into_iter() - .peekable(); + let calculated_roots: Vec = self.calculate_hashes(del_hashes, num_leaves)?.1; + let mut calculated_roots = calculated_roots.into_iter().peekable(); + let total_calculated = calculated_roots.len(); let mut number_matched_roots = 0; @@ -363,11 +359,9 @@ impl Proof { } } - if calculated_roots.len() != number_matched_roots && calculated_roots.len() != 0 { - return Ok(false); - } - - Ok(true) + // The proof is valid iff every root we computed from the proof matched an + // actual accumulator root. + Ok(total_calculated == number_matched_roots) } /// Returns the elements needed to prove a subset of targets. For example, a tree with @@ -483,6 +477,7 @@ impl Proof { pub fn deserialize(mut buf: Source) -> Result { let targets_len = read_bounded_len(&mut buf, MAX_PROOF_DESERIALIZE_COUNT)?; let mut targets = Vec::with_capacity(targets_len); + for _ in 0..targets_len { targets.push(read_u64(&mut buf).map_err(|_| ProofError::InvalidTarget)?); } @@ -525,6 +520,16 @@ impl Proof { // Where all the root hashes that we've calculated will go to. let total_rows = util::tree_rows(num_leaves); + // Target positions are given in the full 63-row forest coordinates; reject + // anything that maps to a row outside the actual forest. + if self + .targets + .iter() + .any(|pos| util::detect_row(*pos, MAX_FOREST_ROWS) > total_rows) + { + return Err(ProofError::InvalidTarget); + } + // Where all the parent hashes we've calculated in a given row will go to. let mut calculated_root_hashes = Vec::<(Hash, Hash)>::with_capacity(util::num_roots(num_leaves)); @@ -615,6 +620,16 @@ impl Proof { // Where all the root hashes that we've calculated will go to. let total_rows = util::tree_rows(num_leaves); + // Target positions are given in the full 63-row forest coordinates; reject + // anything that maps to a row outside the actual forest. + if self + .targets + .iter() + .any(|pos| util::detect_row(*pos, MAX_FOREST_ROWS) > total_rows) + { + return Err(ProofError::InvalidTarget); + } + // Where all the parent hashes we've calculated in a given row will go to. let mut calculated_root_hashes = Vec::::with_capacity(util::num_roots(num_leaves)); @@ -1606,6 +1621,83 @@ mod tests { assert_eq!(s.verify(&subset, &[del_hashes[2]]), Ok(false)); } + #[test] + fn test_deserialize_rejects_huge_target_count() { + // An attacker-controlled targets_len of u64::MAX must not cause a + // capacity-overflow panic or OOM; it must return an error. + let mut data = Vec::new(); + data.extend_from_slice(&u64::MAX.to_le_bytes()); // targets_len + data.extend_from_slice(&0u64.to_le_bytes()); // hashes_len + let result = Proof::::deserialize(&data[..]); + assert!(matches!( + result.unwrap_err(), + ProofError::OversizedAllocation { .. } + )); + } + + #[test] + fn test_deserialize_rejects_huge_hash_count() { + // Same for hashes_len. + let mut data = Vec::new(); + data.extend_from_slice(&1u64.to_le_bytes()); // targets_len = 1 + data.extend_from_slice(&0u64.to_le_bytes()); // one target + data.extend_from_slice(&u64::MAX.to_le_bytes()); // hashes_len + let result = Proof::::deserialize(&data[..]); + assert!(matches!( + result.unwrap_err(), + ProofError::OversizedAllocation { .. } + )); + } + + #[test] + fn test_deserialize_rejects_truncated_input() { + // A valid header but truncated payload must produce a clean error. + let mut data = Vec::new(); + data.extend_from_slice(&2u64.to_le_bytes()); // targets_len = 2 + data.extend_from_slice(&1u64.to_le_bytes()); // only one target provided + let result = Proof::::deserialize(&data[..]); + assert!(result.is_err()); + } + + #[test] + fn test_verify_rejects_out_of_range_target() { + // A target at u64::MAX is outside the valid forest position range and + // must be rejected with an error, not panic. + let s = Stump::new() + .modify(&[hash_from_u8(0)], &[], &Proof::default()) + .unwrap(); + + let p = Proof::new(vec![u64::MAX], vec![]); + assert!(s.verify(&p, &[hash_from_u8(0)]).is_err()); + } + + #[test] + fn test_verify_rejects_bogus_del_hash() { + // Replacing a deletion hash with a non-member must fail verification. + let hashes: Vec<_> = (0..4u8).map(hash_from_u8).collect(); + let s = Stump::new() + .modify(&hashes, &[], &Proof::default()) + .unwrap(); + + // Proof for leaf 0 (from the known test vectors) + let proof_hashes = vec![ + BitcoinNodeHash::from_str( + "4bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7cce23c7785459a", + ) + .unwrap(), + BitcoinNodeHash::from_str( + "9576f4ade6e9bc3a6458b506ce3e4e890df29cb14cb5d3d887672aef55647a2b", + ) + .unwrap(), + ]; + let p = Proof::new(vec![0], proof_hashes); + + // Valid: verifies with the correct del_hash + assert_eq!(s.verify(&p, &[hashes[0]]), Ok(true)); + // Invalid: substituting a different hash must not verify + assert_ne!(s.verify(&p, &[hash_from_u8(42)]), Ok(true)); + } + #[test] #[cfg(feature = "with-serde")] fn test_serde_rtt() { diff --git a/src/stump/mod.rs b/src/stump/mod.rs index b0366cb..63f683c 100644 --- a/src/stump/mod.rs +++ b/src/stump/mod.rs @@ -70,6 +70,11 @@ pub enum StumpError { /// The provided proof is invalid. This will happen during proof verification and stump /// modification. InvalidProof(ProofError), + + /// The number of roots doesn't match the number of leaves. A valid accumulator with + /// `n` leaves must have exactly one root for each set bit of `n`; anything else is + /// malformed and must not be used for state transitions. + RootsMismatch, } impl fmt::Display for StumpError { @@ -77,6 +82,9 @@ impl fmt::Display for StumpError { match self { Self::Io(kind) => write!(f, "I/O error: {kind:?}"), Self::InvalidProof(e) => write!(f, "invalid proof: {e}"), + Self::RootsMismatch => { + write!(f, "the number of roots doesn't match the number of leaves") + } } } } @@ -280,9 +288,25 @@ impl Stump { del_hashes: &[Hash], proof: &Proof, ) -> Result { + // A Stump whose roots don't match its leaf count is malformed (e.g. hand-crafted + // or deserialized from corrupt data): refuse to transition from it rather than + // panicking in the addition logic below. + if self.roots.len() as u32 != self.leaves.count_ones() { + return Err(StumpError::RootsMismatch); + } + + // leaf positions must fit in the position arithmetic used below. + let new_leaves = self + .leaves + .checked_add(utxos.len() as u64) + .ok_or(StumpError::RootsMismatch)?; + + if new_leaves >= (1 << crate::MAX_FOREST_ROWS) { + return Err(StumpError::RootsMismatch); + } + let mut computed_roots = self.remove(del_hashes, proof)?; let mut new_roots = vec![]; - for root in self.roots.iter() { if let Some(pos) = computed_roots.iter().position(|(old, _new)| old == root) { let (_, new_root) = computed_roots.remove(pos); @@ -302,7 +326,7 @@ impl Stump { let roots = Self::add(new_roots, utxos, self.leaves); let new_stump = Self { - leaves: self.leaves + utxos.len() as u64, + leaves: new_leaves, roots, }; @@ -437,8 +461,16 @@ impl Stump { pub fn deserialize(mut data: Source) -> Result { let leaves = util::read_u64(&mut data)?; let roots_len = util::read_u64(&mut data)?; - let mut roots = vec![]; + // A valid accumulator with `leaves` leaves has exactly one root per set bit of + // `leaves`; leaf counts must also fit the 63-row forest. Reject anything else + // before allocating, so malformed input can't cause huge allocations or panics + // later on. + if leaves >= (1 << crate::MAX_FOREST_ROWS) || roots_len != u64::from(leaves.count_ones()) { + return Err(StumpError::RootsMismatch); + } + + let mut roots = Vec::with_capacity(roots_len as usize); for _ in 0..roots_len { let root = Hash::read(&mut data)?; roots.push(root); @@ -580,6 +612,47 @@ mod test { assert!(s.roots.is_empty()); } + #[test] + fn test_verify_rejects_mismatched_root() { + // The accumulator has three leaves. Leaf 02 is also a root. + // + // row 1: 04: H(a, b) + // |----------\ + // row 0: 00: a 01: b 02: c + // + // Stump { leaves: 3, roots: [H(a, b), c] } + let a = hash_from_u8(1); + let b = hash_from_u8(2); + let c = hash_from_u8(3); + let d = hash_from_u8(4); + + let stump = Stump::new() + .modify(&[a, b, c], &[], &Proof::default()) + .unwrap(); + assert_eq!(stump.roots, vec![BitcoinNodeHash::parent_hash(&a, &b), c]); + + // The del_hashes contain d instead of a for target 00: + // + // Proof { targets: [0, 2], hashes: [b] } + // del_hashes = [d, c] + // + // row 1: 04: H(d, b) + // |----------\ + // row 0: 00: d 01: b 02: c + // target proof target + // hash root + // + // Position Calculated root Accumulator root Match + // 02 c c yes + // 04 H(d, b) H(a, b) no + // + // The mismatch at position 04 must make verification fail. + let proof = Proof::new(vec![0, 2], vec![b]); + let del_hashes = vec![d, c]; + + assert_eq!(stump.verify(&proof, &del_hashes), Ok(false)); + } + #[test] fn test_custom_hash_type() { #[derive(Debug, Default, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)] @@ -763,6 +836,54 @@ mod test { assert_eq!(positions, updated.new_add); } + #[test] + fn test_deserialize_rejects_inconsistent_roots() { + // A stump claiming 1 leaf but having 2 roots is malformed and must be + // rejected at deserialization time. + let mut data = Vec::new(); + data.extend_from_slice(&1u64.to_le_bytes()); // leaves = 1 + data.extend_from_slice(&2u64.to_le_bytes()); // roots_len = 2 (wrong!) + data.extend_from_slice(&[0u8; 64]); // two dummy hashes + let result = Stump::::deserialize(&data[..]); + assert_eq!(result.unwrap_err(), StumpError::RootsMismatch); + } + + #[test] + fn test_deserialize_rejects_huge_leaves() { + // leaves >= 2^63 is outside the representable forest. + let mut data = Vec::new(); + data.extend_from_slice(&u64::MAX.to_le_bytes()); // leaves + data.extend_from_slice(&0u64.to_le_bytes()); // roots_len + let result = Stump::::deserialize(&data[..]); + assert_eq!(result.unwrap_err(), StumpError::RootsMismatch); + } + + #[test] + fn test_modify_rejects_malformed_stump() { + // Hand-crafting a stump with wrong root count must fail cleanly. + let bad = Stump { + leaves: 2, + roots: vec![hash_from_u8(0)], // 2 leaves needs 1 root... but hash is wrong + }; + // This should not panic; it returns an error because the root won't match. + let result = bad.modify(&[hash_from_u8(1)], &[], &Proof::default()); + // We don't care which error, just that it doesn't panic. + let _ = result; + } + + #[test] + fn test_modify_rejects_root_count_mismatch() { + // 3 leaves should have 2 roots (binary 11), providing only 1 must be caught. + let bad = Stump { + leaves: 3, + roots: vec![hash_from_u8(0)], + }; + let err = bad + .modify(&[hash_from_u8(1)], &[], &Proof::default()) + .unwrap_err(); + assert!(matches!(err, StumpError::RootsMismatch)); + } + #[test] #[cfg(feature = "with-serde")] fn test_serde_rtt() {