ethrex_common/seeded_hash.rs
1//! A seeded hasher for maps whose keys a block can choose.
2//!
3//! rustc-hash 2.x is unseeded and hashes a key of 17 to 32 bytes as
4//! `multiply_mix(SEED2 ^ word(bytes[len - 16..len - 8]), ..) ^ len`, where `SEED2` is a
5//! public constant and `multiply_mix(0, _) == 0`. Keys carrying `SEED2` in that word all hash
6//! to `len` and share one hashbrown bucket, so storage slots, touched addresses or calldata
7//! crafted that way turn every lookup into a scan, at flat opcode gas. [`SeededState`] hashes
8//! with foldhash instead, seeded per process and per map, with one twist: a 32- or 20-byte
9//! key (`H256`, `U256`, `Address`) is fed to foldhash as words rather than as a byte string.
10//! foldhash's byte path spends four folded multiplies on 32 bytes where its integer path
11//! spends two, which shows on storage-heavy code; the word path resists the same attacks,
12//! since every multiply still mixes in a seed the key does not know.
13//!
14//! A keccak output is no safer as a key than the preimage behind it. Whoever picks the
15//! preimage (a CREATE2 salt, the trailing bytes of deployed code) can grind it until the hash
16//! lands in a chosen bucket, at about one keccak per bucket of the table for each key. What
17//! keeps such a map on FxHash safe is the gas a block pays for each key it adds or looks up.
18//! Code hashes are seeded: a contract deployed once can be called again for 100 gas, and every
19//! call looks its code up by hash. Created and self-destructed accounts stay on FxHash, since
20//! each entry costs the block a CREATE or a SELFDESTRUCT.
21//!
22//! A seeded map iterates in a different order in every process and every map. Whatever carries
23//! that order out of the process (a list another program replays, a report, the offender an
24//! error names) has to be sorted, as the prover's host and the guest sort a block's account
25//! updates before applying them to the state trie.
26//!
27//! Inside a zkVM guest foldhash finds no clock and no address randomization, so its seeds
28//! follow from the guest program and its input. Crafting keys against a guest then takes
29//! replaying it up to the map under attack, which is harder than against FxHash but possible.
30
31use std::hash::{BuildHasher, Hasher};
32
33/// A `std::collections::HashMap` hashed with [`SeededState`].
34pub type SeededHashMap<K, V> = std::collections::HashMap<K, V, SeededState>;
35/// A `std::collections::HashSet` hashed with [`SeededState`].
36pub type SeededHashSet<K> = std::collections::HashSet<K, SeededState>;
37
38/// A foldhash `RandomState`, seeded per process and per map, whose hasher takes fixed-size
39/// keys through foldhash's integer path. See the module docs.
40#[derive(Clone, Debug, Default)]
41pub struct SeededState(foldhash::fast::RandomState);
42
43impl BuildHasher for SeededState {
44 type Hasher = SeededHasher;
45
46 #[inline]
47 fn build_hasher(&self) -> SeededHasher {
48 SeededHasher(self.0.build_hasher())
49 }
50}
51
52/// The hasher built by [`SeededState`].
53pub struct SeededHasher(foldhash::fast::FoldHasher<'static>);
54
55impl Hasher for SeededHasher {
56 /// `H256` and `Address` hash as one 32- or 20-byte write, and `U256` as a length prefix
57 /// plus one 32-byte write. Those go through `write_u128`, which folds 16 bytes per
58 /// multiply; every other length takes foldhash's byte path.
59 #[inline]
60 fn write(&mut self, bytes: &[u8]) {
61 if let Some((lo, rest)) = bytes.split_first_chunk::<16>() {
62 if let Ok(hi) = <[u8; 16]>::try_from(rest) {
63 self.0.write_u128(u128::from_ne_bytes(*lo));
64 self.0.write_u128(u128::from_ne_bytes(hi));
65 return;
66 }
67 if let Ok(hi) = <[u8; 4]>::try_from(rest) {
68 self.0.write_u128(u128::from_ne_bytes(*lo));
69 self.0.write_u32(u32::from_ne_bytes(hi));
70 return;
71 }
72 }
73 self.0.write(bytes);
74 }
75
76 #[inline]
77 fn write_u8(&mut self, i: u8) {
78 self.0.write_u8(i);
79 }
80
81 #[inline]
82 fn write_u16(&mut self, i: u16) {
83 self.0.write_u16(i);
84 }
85
86 #[inline]
87 fn write_u32(&mut self, i: u32) {
88 self.0.write_u32(i);
89 }
90
91 #[inline]
92 fn write_u64(&mut self, i: u64) {
93 self.0.write_u64(i);
94 }
95
96 #[inline]
97 fn write_u128(&mut self, i: u128) {
98 self.0.write_u128(i);
99 }
100
101 #[inline]
102 fn write_usize(&mut self, i: usize) {
103 self.0.write_usize(i);
104 }
105
106 #[inline]
107 fn finish(&self) -> u64 {
108 self.0.finish()
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115 use crate::{Address, H256, U256};
116 use std::hash::Hash;
117
118 /// Keys per test, hashed into as many buckets.
119 const KEYS: u64 = 4096;
120 /// Hashing 4096 keys uniformly into 4096 buckets fills the fullest one to about 6; a
121 /// hasher that ignores the part of the key that varies puts them all in one.
122 const MAX_BUCKET_LOAD: usize = 16;
123 /// The word FxHash 2.x XORs into a 17- to 32-byte key right before a multiply, which
124 /// makes keys carrying it hash alike there. Kept as a hostile key shape; nothing here
125 /// relies on rustc-hash still behaving that way.
126 const ZEROING_WORD: u64 = 0x13198a2e03707344;
127
128 /// The load of the fullest bucket once `keys` are spread over `KEYS` buckets by the low
129 /// bits of their hash, as hashbrown picks a key's first bucket.
130 fn fullest_bucket<K: Hash>(keys: impl Iterator<Item = K>) -> usize {
131 let state = SeededState::default();
132 let mut loads = vec![0usize; KEYS as usize];
133 for key in keys {
134 let bucket = state.hash_one(key) % KEYS;
135 if let Some(load) = loads.get_mut(bucket as usize) {
136 *load += 1;
137 }
138 }
139 loads.into_iter().max().unwrap_or_default()
140 }
141
142 /// An address whose bytes `at..at + 8` hold `word` and whose bytes 4..12 hold the zeroing
143 /// word, so only one region of the key varies.
144 fn address_with(at: usize, word: u64) -> Address {
145 let mut bytes = [0u8; 20];
146 for (byte, value) in bytes.iter_mut().skip(4).zip(ZEROING_WORD.to_le_bytes()) {
147 *byte = value;
148 }
149 for (byte, value) in bytes.iter_mut().skip(at).zip(word.to_le_bytes()) {
150 *byte = value;
151 }
152 Address::from(bytes)
153 }
154
155 #[test]
156 fn spreads_h256_keys_that_differ_in_either_half() {
157 let low = (0..KEYS).map(|i| H256::from(U256([i, 0, ZEROING_WORD, 0]).to_little_endian()));
158 assert!(fullest_bucket(low) <= MAX_BUCKET_LOAD);
159 let high = (0..KEYS).map(|i| H256::from(U256([0, 0, ZEROING_WORD, i]).to_little_endian()));
160 assert!(fullest_bucket(high) <= MAX_BUCKET_LOAD);
161 }
162
163 #[test]
164 fn spreads_u256_keys_that_differ_in_either_half() {
165 let low = (0..KEYS).map(|i| U256([i, 0, ZEROING_WORD, 0]));
166 assert!(fullest_bucket(low) <= MAX_BUCKET_LOAD);
167 let high = (0..KEYS).map(|i| U256([0, 0, ZEROING_WORD, i]));
168 assert!(fullest_bucket(high) <= MAX_BUCKET_LOAD);
169 }
170
171 #[test]
172 fn spreads_addresses_that_differ_in_either_part() {
173 // Bytes 12..20 straddle the 16-byte word and the 4-byte tail; 16..20 is the tail alone.
174 assert!(fullest_bucket((0..KEYS).map(|i| address_with(12, i))) <= MAX_BUCKET_LOAD);
175 assert!(fullest_bucket((0..KEYS).map(|i| address_with(16, i))) <= MAX_BUCKET_LOAD);
176 }
177
178 #[test]
179 fn spreads_byte_strings_on_both_paths() {
180 // 32-byte calldata takes the word path; 40 bytes takes foldhash's byte path.
181 let short = (0..KEYS).map(|i| U256([i, 0, ZEROING_WORD, 0]).to_little_endian().to_vec());
182 assert!(fullest_bucket(short) <= MAX_BUCKET_LOAD);
183 let long = (0..KEYS).map(|i| {
184 let mut bytes = vec![0u8; 40];
185 for (byte, value) in bytes.iter_mut().skip(32).zip(i.to_le_bytes()) {
186 *byte = value;
187 }
188 bytes
189 });
190 assert!(fullest_bucket(long) <= MAX_BUCKET_LOAD);
191 }
192
193 #[test]
194 fn two_maps_hash_the_same_key_apart() {
195 // Each map draws its own seed, so a key crafted against one map's buckets says
196 // nothing about another's.
197 let key = H256::repeat_byte(0x5a);
198 assert_ne!(
199 SeededState::default().hash_one(key),
200 SeededState::default().hash_one(key)
201 );
202 }
203
204 #[test]
205 fn equal_keys_hash_equal_on_both_paths() {
206 let state = SeededState::default();
207 let key = H256::from_low_u64_be(7);
208 assert_eq!(state.hash_one(key), state.hash_one(key));
209 let bytes = vec![1u8; 40];
210 assert_eq!(state.hash_one(&bytes), state.hash_one(&bytes));
211 }
212}