Skip to content

Commit 1955ba4

Browse files
[storage/mmr] hash leaf count in last to avoid malleability concern (#3392)
1 parent 2c043e3 commit 1955ba4

4 files changed

Lines changed: 89 additions & 36 deletions

File tree

storage/conformance.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ hash = "506b5a02d1a7c3e249f514b889e6099c27bb0723ebc711514263f0c71f0676ef"
7272

7373
["commonware_storage::merkle::mmr::conformance::MmrRootStability"]
7474
n_cases = 200
75-
hash = "d9de2d71a41b1c656744b4ff9b1bd8e3a1b7d9da65de654b7551912431eb02d7"
75+
hash = "4078ce1f5e242f8edb1d41575d51e166af620404036124f8094fc74d4b401047"
7676

7777
["commonware_storage::merkle::proof::tests::conformance::CodecConformance<Proof<Sha256Digest>>"]
7878
n_cases = 65536

storage/src/merkle/mmr/hasher.rs

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,18 +43,23 @@ pub trait Hasher: Clone + Send + Sync {
4343
self.hash([acc.as_ref(), peak.as_ref()])
4444
}
4545

46-
/// Computes the root for an MMR given its size and an iterator over the digests of its peaks in
47-
/// decreasing order of height.
46+
/// Computes the root for an MMR given its leaf count and peak digests in canonical order.
47+
///
48+
/// The root digest is computed as `Hash(leaves || fold(peak_digests))`, where `fold` is
49+
/// defined as `fold(acc, peak) = Hash(acc || peak)`. The `peak_digests` are assumed to be
50+
/// in canonical order.
4851
fn root<'a>(
4952
&mut self,
5053
leaves: Location,
5154
peak_digests: impl IntoIterator<Item = &'a Self::Digest>,
5255
) -> Self::Digest {
53-
let mut acc = self.digest(&leaves.to_be_bytes());
54-
for digest in peak_digests {
55-
acc = self.fold(&acc, digest);
56-
}
57-
acc
56+
let mut iter = peak_digests.into_iter();
57+
let Some(first) = iter.next() else {
58+
return self.digest(&leaves.to_be_bytes());
59+
};
60+
let acc = iter.fold(*first, |acc, digest| self.fold(&acc, digest));
61+
62+
self.hash([leaves.to_be_bytes().as_slice(), acc.as_ref()])
5863
}
5964
}
6065

storage/src/merkle/mmr/verification.rs

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -92,17 +92,18 @@ impl<D: Digest> ProofStore<D> {
9292

9393
let mut digests: Vec<D> = Vec::new();
9494
if !bp.fold_prefix.is_empty() {
95-
let mut acc = self
96-
.fold_acc
97-
.unwrap_or_else(|| hasher.digest(&leaves.to_be_bytes()));
95+
// Start from the stored fold accumulator (which does not include the leaf count).
96+
let mut acc = self.fold_acc;
9897
// Fold in peaks beyond those already covered by the stored accumulator.
9998
for &pos in bp.fold_prefix.iter().skip(self.num_fold_peaks) {
10099
match self.digests.get(&pos) {
101-
Some(d) => acc = hasher.fold(&acc, d),
100+
Some(d) => {
101+
acc = Some(acc.map_or(*d, |a| hasher.fold(&a, d)));
102+
}
102103
None => return Err(Error::ElementPruned(pos)),
103104
}
104105
}
105-
digests.push(acc);
106+
digests.push(acc.expect("fold_prefix is non-empty so acc must be set"));
106107
}
107108

108109
for &pos in &bp.fetch_nodes {
@@ -186,14 +187,12 @@ pub async fn historical_range_proof<D: Digest, H: Hasher<Digest = D>, S: Storage
186187

187188
let mut digests: Vec<D> = Vec::new();
188189
if !bp.fold_prefix.is_empty() {
189-
let mut acc = hasher.digest(&leaves.to_be_bytes());
190190
let node_futures = bp.fold_prefix.iter().map(|&pos| mmr.get_node(pos));
191191
let results = try_join_all(node_futures).await?;
192-
for (i, result) in results.into_iter().enumerate() {
193-
match result {
194-
Some(d) => acc = hasher.fold(&acc, &d),
195-
None => return Err(Error::ElementPruned(bp.fold_prefix[i])),
196-
}
192+
let mut acc = results[0].ok_or(Error::ElementPruned(bp.fold_prefix[0]))?;
193+
for (i, &result) in results.iter().enumerate().skip(1) {
194+
let d = result.ok_or(Error::ElementPruned(bp.fold_prefix[i]))?;
195+
acc = hasher.fold(&acc, &d);
197196
}
198197
digests.push(acc);
199198
}

storage/src/merkle/proof.rs

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -236,9 +236,11 @@ impl<D: Digest> Proof<D> {
236236
if bp.fold_prefix.is_empty() { 0 } else { 1 } + bp.fetch_nodes.len(),
237237
);
238238
if !bp.fold_prefix.is_empty() {
239-
// Fold prefix peaks into accumulator
240-
let mut acc = hasher.digest(&self.leaves.to_be_bytes());
241-
for &pos in &bp.fold_prefix {
239+
// Fold prefix peaks into accumulator (without the leaf count).
240+
let mut acc = *node_digests
241+
.get(&bp.fold_prefix[0])
242+
.expect("must exist by construction");
243+
for &pos in &bp.fold_prefix[1..] {
242244
let d = node_digests.get(&pos).expect("must exist by construction");
243245
acc = hasher.fold(&acc, d);
244246
}
@@ -350,13 +352,17 @@ impl<D: Digest> Proof<D> {
350352
.zip(pinned_nodes.iter().copied())
351353
.collect();
352354

353-
// Verify fold-prefix pinned nodes by recomputing the accumulator.
355+
// Verify fold-prefix pinned nodes by recomputing the accumulator (without the leaf
356+
// count, which is hashed into the final root independently).
354357
if !bp.fold_prefix.is_empty() {
355358
if self.digests.is_empty() {
356359
return false;
357360
}
358-
let mut acc = hasher.digest(&self.leaves.to_be_bytes());
359-
for pos in &bp.fold_prefix {
361+
let Some(first) = pinned_map.remove(&bp.fold_prefix[0]) else {
362+
return false;
363+
};
364+
let mut acc = first;
365+
for pos in &bp.fold_prefix[1..] {
360366
let Some(digest) = pinned_map.remove(pos) else {
361367
return false;
362368
};
@@ -462,12 +468,12 @@ impl<D: Digest> Proof<D> {
462468
let after_end = after_start + after_peaks.len();
463469
let siblings = &self.digests[after_end..];
464470

465-
// Start fold accumulator.
466-
let mut acc = if has_prefix {
467-
// The first digest is the pre-folded prefix accumulator.
468-
self.digests[0]
471+
// Fold all peaks into an accumulator (without the leaf count, which is hashed in at the
472+
// end to prevent malleability via the `leaves` field).
473+
let mut acc: Option<D> = if has_prefix {
474+
Some(self.digests[0])
469475
} else {
470-
hasher.digest(&self.leaves.to_be_bytes())
476+
None
471477
};
472478

473479
// Reconstruct each range peak and fold into acc.
@@ -490,7 +496,7 @@ impl<D: Digest> Proof<D> {
490496
if let Some(ref mut cd) = collected_digests {
491497
cd.push((peak_pos, peak_digest));
492498
}
493-
acc = hasher.fold(&acc, &peak_digest);
499+
acc = Some(acc.map_or(peak_digest, |a| hasher.fold(&a, &peak_digest)));
494500
}
495501

496502
// Fold after-peak digests.
@@ -499,7 +505,7 @@ impl<D: Digest> Proof<D> {
499505
if let Some(ref mut cd) = collected_digests {
500506
cd.push((after_peak_pos, digest));
501507
}
502-
acc = hasher.fold(&acc, &digest);
508+
acc = Some(acc.map_or(digest, |a| hasher.fold(&a, &digest)));
503509
}
504510

505511
// Verify all elements were consumed.
@@ -512,7 +518,12 @@ impl<D: Digest> Proof<D> {
512518
return Err(ReconstructionError::ExtraDigests);
513519
}
514520

515-
Ok(acc)
521+
// Hash the leaf count into the final result.
522+
Ok(if let Some(peaks_acc) = acc {
523+
hasher.hash([self.leaves.to_be_bytes().as_slice(), peaks_acc.as_ref()])
524+
} else {
525+
hasher.digest(&self.leaves.to_be_bytes())
526+
})
516527
}
517528
}
518529

@@ -640,10 +651,11 @@ where
640651
let mut digests =
641652
Vec::with_capacity(if bp.fold_prefix.is_empty() { 0 } else { 1 } + bp.fetch_nodes.len());
642653

643-
// Fold prefix peaks into a single accumulator.
654+
// Fold prefix peaks into a single accumulator (without the leaf count, which is always
655+
// hashed into the final root independently).
644656
if !bp.fold_prefix.is_empty() {
645-
let mut acc = hasher.digest(&leaves.to_be_bytes());
646-
for &pos in &bp.fold_prefix {
657+
let mut acc = get_node(bp.fold_prefix[0]).ok_or(Error::ElementPruned(bp.fold_prefix[0]))?;
658+
for &pos in &bp.fold_prefix[1..] {
647659
let d = get_node(pos).ok_or(Error::ElementPruned(pos))?;
648660
acc = hasher.fold(&acc, &d);
649661
}
@@ -1822,6 +1834,43 @@ mod tests {
18221834
);
18231835
}
18241836

1837+
/// Regression test: mutating only the `leaves` field in a proof must invalidate it.
1838+
/// Before the fix, when a fold prefix existed, the leaf count was baked into the
1839+
/// pre-folded accumulator but not independently checked during verification, so a
1840+
/// different `leaves` value with a compatible peak structure would still verify.
1841+
#[test]
1842+
fn test_proof_leaves_malleability() {
1843+
let mut hasher: Standard<Sha256> = Standard::new();
1844+
let mut mmr = Mmr::new(&mut hasher);
1845+
1846+
// 252 leaves. Leaf 240 sits in a peak preceded by 4 prefix peaks.
1847+
let elements: Vec<Digest> = (0..252u16)
1848+
.map(|i| Sha256::hash(&i.to_be_bytes()))
1849+
.collect();
1850+
let changeset = {
1851+
let mut batch = mmr.new_batch();
1852+
for e in &elements {
1853+
batch.add(&mut hasher, e);
1854+
}
1855+
batch.merkleize(&mut hasher).finalize()
1856+
};
1857+
mmr.apply(changeset).unwrap();
1858+
let root = mmr.root();
1859+
1860+
let loc = Location::new(240);
1861+
let proof = mmr.proof(&mut hasher, loc).unwrap();
1862+
assert!(proof.verify_element_inclusion(&mut hasher, &elements[240], loc, root));
1863+
1864+
// Tamper with the leaves field (249 has the same peak layout for leaf 240).
1865+
let mut tampered = proof.clone();
1866+
tampered.leaves = Location::new(249);
1867+
assert_ne!(tampered, proof);
1868+
assert!(
1869+
!tampered.verify_element_inclusion(&mut hasher, &elements[240], loc, root),
1870+
"proof with tampered leaves field must not verify"
1871+
);
1872+
}
1873+
18251874
#[cfg(feature = "arbitrary")]
18261875
mod conformance {
18271876
use super::*;

0 commit comments

Comments
 (0)