Skip to content

Commit eabfc3e

Browse files
paberrjsdanielh
authored andcommitted
Fix out-of-bounds key nibbles in concatenation
1 parent 41d35ac commit eabfc3e

3 files changed

Lines changed: 104 additions & 2 deletions

File tree

primitives/src/key_nibbles.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,16 @@ impl KeyNibbles {
226226
(false, false) => self.cmp(other),
227227
}
228228
}
229+
230+
/// Concatenates two keys, returning `None` if the combined nibble length would exceed
231+
/// the storage capacity (`MAX_BYTES * 2`). Use this at trust boundaries where one of
232+
/// the operands is deserialized from an untrusted source.
233+
pub fn checked_add(&self, other: &KeyNibbles) -> Option<KeyNibbles> {
234+
if self.len() + other.len() > Self::MAX_BYTES * 2 {
235+
return None;
236+
}
237+
Some(self + other)
238+
}
229239
}
230240

231241
impl Default for KeyNibbles {
@@ -512,6 +522,25 @@ mod tests {
512522
assert_eq!((&key1 + &key2).to_string(), "cfb986");
513523
}
514524

525+
#[test]
526+
fn checked_add_rejects_overflow() {
527+
// Each operand is individually valid (length <= MAX_BYTES * 2), but their
528+
// combined nibble length exceeds the 63-byte storage capacity.
529+
let parent_even: KeyNibbles = "a".repeat(40).parse().unwrap();
530+
let suffix_even: KeyNibbles = "b".repeat(90).parse().unwrap();
531+
assert_eq!(parent_even.checked_add(&suffix_even), None);
532+
533+
let parent_odd: KeyNibbles = "a".repeat(41).parse().unwrap();
534+
let suffix_odd: KeyNibbles = "b".repeat(87).parse().unwrap();
535+
assert_eq!(parent_odd.checked_add(&suffix_odd), None);
536+
537+
// At the limit (sum == 126) the addition still succeeds.
538+
let lhs: KeyNibbles = "a".repeat(63).parse().unwrap();
539+
let rhs: KeyNibbles = "b".repeat(63).parse().unwrap();
540+
let combined = lhs.checked_add(&rhs).expect("sum at the limit should fit");
541+
assert_eq!(combined.len(), 126);
542+
}
543+
515544
#[test]
516545
fn nibbles_get_works() {
517546
let key: KeyNibbles = "cfb98637bcae43c13323eaa1731ced2b716962fd".parse().unwrap();

primitives/src/trie/trie_node.rs

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,12 @@ impl TrieNodeChild {
4848
parent_key: &KeyNibbles,
4949
missing_range: &Option<RangeFrom<KeyNibbles>>,
5050
) -> bool {
51+
let Some(combined) = parent_key.checked_add(&self.suffix) else {
52+
return false;
53+
};
5154
missing_range
5255
.as_ref()
53-
.map(|range| range.contains(&(parent_key + &self.suffix)))
56+
.map(|range| range.contains(&combined))
5457
.unwrap_or(false)
5558
}
5659

@@ -63,10 +66,13 @@ impl TrieNodeChild {
6366
parent_key: &KeyNibbles,
6467
missing_range: &Option<RangeFrom<KeyNibbles>>,
6568
) -> Result<KeyNibbles, MerkleRadixTrieError> {
69+
let combined = parent_key
70+
.checked_add(&self.suffix)
71+
.ok_or(MerkleRadixTrieError::WrongPrefix)?;
6672
if self.is_stump(parent_key, missing_range) {
6773
return Err(MerkleRadixTrieError::ChildIsStump);
6874
}
69-
Ok(parent_key + &self.suffix)
75+
Ok(combined)
7076
}
7177
}
7278

@@ -723,4 +729,32 @@ mod tests {
723729
let root_node = TrieNode::new_root();
724730
assert_eq!(root_node.value, None);
725731
}
732+
733+
#[test]
734+
fn key_rejects_oversized_suffix() {
735+
// Both operands deserialize as valid `KeyNibbles` individually (length <= 126),
736+
// but their concatenation would overflow the 63-byte buffer in `KeyNibbles::Add`.
737+
// `TrieNodeChild::key` must return an error instead of panicking.
738+
let cases = [
739+
("a".repeat(40), "b".repeat(90)), // even+even
740+
("a".repeat(41), "b".repeat(87)), // odd+odd
741+
];
742+
743+
for (parent_str, suffix_str) in &cases {
744+
let parent: KeyNibbles = parent_str.parse().unwrap();
745+
let suffix: KeyNibbles = suffix_str.parse().unwrap();
746+
let child = TrieNodeChild {
747+
suffix,
748+
hash: Blake2bHash::default(),
749+
};
750+
751+
assert_eq!(
752+
child.key(&parent, &None),
753+
Err(MerkleRadixTrieError::WrongPrefix),
754+
);
755+
// `is_stump` is panic-safe too: oversized combined keys cannot lie inside
756+
// any `missing_range`, so it must return `false` without invoking `Add`.
757+
assert!(!child.is_stump(&parent, &None));
758+
}
759+
}
726760
}

primitives/trie/src/trie.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2258,6 +2258,45 @@ mod tests {
22582258
}
22592259
}
22602260

2261+
#[test]
2262+
fn put_chunk_rejects_oversized_child_suffix() {
2263+
// A `TrieNodeChild.suffix` deserialized from the network used to cause an
2264+
// out-of-bounds slice panic inside `KeyNibbles::Add` when concatenated with the
2265+
// proof node's parent key. Both operands pass the `length <= 126` deserialize
2266+
// check individually, but together they exceed the 63-byte storage buffer.
2267+
// `put_chunk` must reject such a chunk gracefully instead of panicking.
2268+
use nimiq_primitives::trie::trie_node::TrieNodeChild;
2269+
2270+
let env = MdbxDatabase::new_volatile(Default::default()).unwrap();
2271+
let trie = MerkleRadixTrie::new_incomplete(&env, TestTrie);
2272+
let mut raw_txn = env.write_transaction();
2273+
let mut txn: WriteTransactionProxy = (&mut raw_txn).into();
2274+
assert!(!trie.is_complete(&txn));
2275+
2276+
let parent_key: KeyNibbles = "a".repeat(40).parse().unwrap();
2277+
let oversized_suffix: KeyNibbles = "b".repeat(90).parse().unwrap();
2278+
2279+
let mut proof_node: TrieProofNode = TrieNode::new_empty(parent_key.clone()).into();
2280+
// 40 + 90 = 130 nibbles > MAX_BYTES * 2 = 126 — would overflow `Add`.
2281+
proof_node.children[0] = Some(TrieNodeChild {
2282+
suffix: oversized_suffix,
2283+
hash: Blake2bHash::default(),
2284+
});
2285+
2286+
let proof_root = TrieNode::new_root();
2287+
let expected_hash = proof_root.hash_assert();
2288+
let proof = TrieProof::new(vec![proof_node, proof_root.into()], Default::default());
2289+
2290+
let malicious_chunk =
2291+
TrieChunk::new(None, vec![TrieItem::new(parent_key, vec![0x42])], proof);
2292+
2293+
// The exact error doesn't matter — what matters is that this returns instead
2294+
// of panicking inside `KeyNibbles::Add`.
2295+
assert!(trie
2296+
.put_chunk(&mut txn, KeyNibbles::ROOT, malicious_chunk, expected_hash)
2297+
.is_err());
2298+
}
2299+
22612300
#[test]
22622301
fn partial_tree_put_chunks_manual() {
22632302
let key_1 = "413f22".parse().unwrap();

0 commit comments

Comments
 (0)