Skip to main content

gossan_engine/
schedule.rs

1//! Blackrock permutation for randomized scan scheduling.
2//!
3//! Visits every `(IP, port)` pair in a pseudorandom order using a
4//! Feistel-network-based permutation. This is the same technique masscan
5//! uses to avoid sequential scanning patterns that trigger IDS alerts.
6//!
7//! Properties:
8//! - Bijective: every index maps to a unique output (no collisions, no gaps)
9//! - Deterministic: same seed produces same ordering (reproducible scans)
10//! - O(1) per lookup: no precomputation needed
11//! - Distributes targets evenly across subnets
12
13/// A Feistel-network-based permutation over `[0, range)`.
14///
15/// Maps each index to a unique pseudorandom output in the same range.
16/// Used to randomize scan order without storing the full permutation in memory.
17pub struct BlackrockPermutation {
18    range: u64,
19    half_bits: u32,
20    half_mask: u64,
21    seed: u64,
22    rounds: u32,
23}
24
25impl BlackrockPermutation {
26    /// Create a new permutation over `[0, range)` with the given seed.
27    ///
28    /// # Panics
29    ///
30    /// Panics if `range` is 0.
31    #[must_use]
32    pub fn new(range: u64, seed: u64) -> Self {
33        assert!(range > 0, "range must be > 0");
34
35        // Find the smallest split where left × right >= range
36        // We use a balanced Feistel network with equal halves
37        let total_bits = 64 - (range - 1).leading_zeros();
38        let half_bits = (total_bits + 1) / 2;
39        let half_mask = (1u64 << half_bits) - 1;
40
41        Self {
42            range,
43            half_bits,
44            half_mask,
45            seed,
46            rounds: 6, // 6 rounds is sufficient for good diffusion
47        }
48    }
49
50    /// Permute index `i` to its randomized output.
51    ///
52    /// If the output falls outside `[0, range)` (due to the Feistel network
53    /// operating on the next power-of-two), we "cycle walk" until we land
54    /// inside the valid range.
55    #[must_use]
56    pub fn shuffle(&self, mut index: u64) -> u64 {
57        loop {
58            let permuted = self.feistel(index);
59            if permuted < self.range {
60                return permuted;
61            }
62            // Cycle walk: try next value
63            index = permuted;
64        }
65    }
66
67    /// Inverse permutation: given an output, recover the original index.
68    #[must_use]
69    pub fn unshuffle(&self, mut permuted: u64) -> u64 {
70        loop {
71            let index = self.feistel_inverse(permuted);
72            if index < self.range {
73                return index;
74            }
75            permuted = index;
76        }
77    }
78
79    fn feistel(&self, input: u64) -> u64 {
80        let mut left = input >> self.half_bits;
81        let mut right = input & self.half_mask;
82
83        for round in 0..self.rounds {
84            let new_right = left ^ self.round_function(right, round);
85            left = right;
86            right = new_right & self.half_mask;
87        }
88
89        (left << self.half_bits) | right
90    }
91
92    fn feistel_inverse(&self, input: u64) -> u64 {
93        let mut left = input >> self.half_bits;
94        let mut right = input & self.half_mask;
95
96        for round in (0..self.rounds).rev() {
97            let new_left = right ^ self.round_function(left, round);
98            right = left;
99            left = new_left & self.half_mask;
100        }
101
102        (left << self.half_bits) | right
103    }
104
105    #[inline]
106    fn round_function(&self, value: u64, round: u32) -> u64 {
107        // Mix value with seed and round number
108        let mut h = value.wrapping_mul(0x9E37_79B9_7F4A_7C15);
109        h = h.wrapping_add(self.seed);
110        h = h.wrapping_add(round as u64);
111        h ^= h >> 17;
112        h = h.wrapping_mul(0xBF58_476D_1CE4_E5B9);
113        h ^= h >> 31;
114        h
115    }
116}
117
118/// Iterator that yields `(ip_index, port_index)` pairs in randomized order.
119///
120/// Given `N` IPs and `P` ports, iterates all `N × P` combinations
121/// exactly once in a pseudorandom order.
122pub struct ScanSchedule {
123    permutation: BlackrockPermutation,
124    num_ports: u64,
125    total: u64,
126    current: u64,
127}
128
129impl ScanSchedule {
130    /// Create a scan schedule over `num_ips × num_ports` targets.
131    #[must_use]
132    pub fn new(num_ips: u64, num_ports: u64, seed: u64) -> Self {
133        let total = num_ips.saturating_mul(num_ports);
134        let permutation = if total > 0 {
135            BlackrockPermutation::new(total, seed)
136        } else {
137            BlackrockPermutation::new(1, seed)
138        };
139
140        Self {
141            permutation,
142            num_ports,
143            total,
144            current: 0,
145        }
146    }
147
148    /// Total number of probes in this schedule.
149    #[must_use]
150    pub fn total(&self) -> u64 {
151        self.total
152    }
153}
154
155impl Iterator for ScanSchedule {
156    type Item = (u64, u64); // (ip_index, port_index)
157
158    fn next(&mut self) -> Option<Self::Item> {
159        if self.current >= self.total {
160            return None;
161        }
162
163        let permuted = self.permutation.shuffle(self.current);
164        self.current += 1;
165
166        let ip_index = permuted / self.num_ports;
167        let port_index = permuted % self.num_ports;
168        Some((ip_index, port_index))
169    }
170
171    fn size_hint(&self) -> (usize, Option<usize>) {
172        let remaining = (self.total - self.current) as usize;
173        (remaining, Some(remaining))
174    }
175}
176
177impl ExactSizeIterator for ScanSchedule {}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use std::collections::HashSet;
183
184    #[test]
185    fn permutation_is_bijective_small() {
186        let range = 100u64;
187        let perm = BlackrockPermutation::new(range, 42);
188        let mut seen = HashSet::new();
189
190        for i in 0..range {
191            let out = perm.shuffle(i);
192            assert!(out < range, "output {out} >= range {range} for input {i}");
193            assert!(seen.insert(out), "duplicate output {out} for input {i}");
194        }
195        assert_eq!(seen.len(), range as usize);
196    }
197
198    #[test]
199    fn permutation_is_bijective_large() {
200        let range = 10_000u64;
201        let perm = BlackrockPermutation::new(range, 0xDEAD_BEEF);
202        let mut seen = HashSet::new();
203
204        for i in 0..range {
205            let out = perm.shuffle(i);
206            assert!(out < range);
207            seen.insert(out);
208        }
209        assert_eq!(seen.len(), range as usize);
210    }
211
212    #[test]
213    fn permutation_is_deterministic() {
214        let perm = BlackrockPermutation::new(1000, 42);
215        let a = perm.shuffle(500);
216        let b = perm.shuffle(500);
217        assert_eq!(a, b);
218    }
219
220    #[test]
221    fn permutation_differs_by_seed() {
222        let a = BlackrockPermutation::new(1000, 1);
223        let b = BlackrockPermutation::new(1000, 2);
224        // Very unlikely (but not impossible) for all outputs to match
225        let mismatches = (0..1000).filter(|&i| a.shuffle(i) != b.shuffle(i)).count();
226        assert!(mismatches > 900, "seeds should produce different orderings");
227    }
228
229    #[test]
230    fn permutation_roundtrip() {
231        let perm = BlackrockPermutation::new(500, 99);
232        for i in 0..500 {
233            let shuffled = perm.shuffle(i);
234            let unshuffled = perm.unshuffle(shuffled);
235            assert_eq!(unshuffled, i, "roundtrip failed for {i}");
236        }
237    }
238
239    #[test]
240    fn schedule_covers_all_targets() {
241        let num_ips = 10u64;
242        let num_ports = 5u64;
243        let schedule = ScanSchedule::new(num_ips, num_ports, 42);
244        let pairs: Vec<_> = schedule.collect();
245
246        assert_eq!(pairs.len(), 50);
247
248        let mut seen = HashSet::new();
249        for (ip, port) in &pairs {
250            assert!(*ip < num_ips, "ip {ip} >= {num_ips}");
251            assert!(*port < num_ports, "port {port} >= {num_ports}");
252            assert!(seen.insert((*ip, *port)), "duplicate ({ip}, {port})");
253        }
254        assert_eq!(seen.len(), 50);
255    }
256
257    #[test]
258    fn schedule_exact_size() {
259        let schedule = ScanSchedule::new(100, 20, 42);
260        assert_eq!(schedule.len(), 2000);
261        assert_eq!(schedule.total(), 2000);
262    }
263
264    #[test]
265    fn schedule_is_not_sequential() {
266        let schedule = ScanSchedule::new(100, 10, 42);
267        let first_ten: Vec<_> = schedule.take(10).collect();
268
269        // Check that ip_indices are not monotonically increasing
270        let sequential = first_ten.windows(2).all(|w| w[0].0 <= w[1].0);
271        assert!(
272            !sequential,
273            "schedule should not be sequential: {first_ten:?}"
274        );
275    }
276}
277
278#[cfg(test)]
279mod proptests {
280    use super::*;
281    use proptest::prelude::*;
282
283    proptest! {
284        #[test]
285        fn permutation_stays_in_range(
286            range in 1u64..10_000,
287            seed in any::<u64>(),
288            index in 0u64..10_000,
289        ) {
290            let index = index % range;
291            let perm = BlackrockPermutation::new(range, seed);
292            let out = perm.shuffle(index);
293            prop_assert!(out < range, "output {out} >= range {range}");
294        }
295
296        #[test]
297        fn permutation_roundtrips(
298            range in 1u64..1_000,
299            seed in any::<u64>(),
300            index in 0u64..1_000,
301        ) {
302            let index = index % range;
303            let perm = BlackrockPermutation::new(range, seed);
304            let shuffled = perm.shuffle(index);
305            let back = perm.unshuffle(shuffled);
306            prop_assert_eq!(back, index);
307        }
308    }
309}