Skip to main content

phantom_protocol/transport/
reputation.rs

1//! Per-IP reputation tracking for adaptive PoW-difficulty escalation (DOS-2).
2//!
3//! Pairs with the stateless cookie + proof-of-work gate in
4//! `transport::handshake`: an IP that repeatedly fails handshakes within a
5//! sliding window accrues violations and pays an exponentially escalating PoW
6//! difficulty (capped at `MAX_DIFFICULTY`), while clean / new IPs and resumption-
7//! ticket holders pay nothing extra. The tracking map is bounded
8//! ([`ReputationTracker::with_capacity`]) so a spoofed / varied-source-IP flood
9//! cannot turn this CPU-DoS defense into a memory-DoS.
10
11use dashmap::DashMap;
12use std::net::IpAddr;
13use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
14use std::time::{SystemTime, UNIX_EPOCH};
15
16const WINDOW_SECS: u64 = 60;
17const BASE_DIFFICULTY: u8 = 8;
18const MAX_DIFFICULTY: u8 = 20;
19/// Default cap on the number of tracked IPs (DOS-2). Bounds memory under a
20/// spoofed / varied-source-IP flood; ~100k entries is a few MB and far above any
21/// honest working set. A wired tracker without this bound would convert a
22/// CPU-DoS into a memory-DoS.
23const DEFAULT_MAX_ENTRIES: usize = 100_000;
24
25struct ReputationEntry {
26    violations: AtomicU32,
27    last_seen_secs: AtomicU64,
28}
29
30impl ReputationEntry {
31    fn new(now: u64) -> Self {
32        Self {
33            violations: AtomicU32::new(1),
34            last_seen_secs: AtomicU64::new(now),
35        }
36    }
37}
38
39/// Tracks per-IP reputation and computes a PoW-difficulty **escalation** for
40/// abusive sources (DOS-2). A clean / new IP contributes 0 (so well-behaved
41/// clients are never penalized); an IP that accrues handshake violations within
42/// the sliding window pays an escalating difficulty, capped at
43/// `MAX_DIFFICULTY`. The map is bounded (see [`Self::with_capacity`]).
44pub struct ReputationTracker {
45    entries: DashMap<IpAddr, ReputationEntry>,
46    max_entries: usize,
47}
48
49impl ReputationTracker {
50    pub fn new() -> Self {
51        Self::with_capacity(DEFAULT_MAX_ENTRIES)
52    }
53
54    /// Create a tracker bounded to at most `max_entries` tracked IPs (DOS-2).
55    pub fn with_capacity(max_entries: usize) -> Self {
56        Self {
57            entries: DashMap::new(),
58            max_entries: max_entries.max(1),
59        }
60    }
61
62    fn now_secs() -> u64 {
63        SystemTime::now()
64            .duration_since(UNIX_EPOCH)
65            .unwrap_or_default()
66            .as_secs()
67    }
68
69    /// Record a violation (e.g., failed handshake)
70    pub fn record_violation(&self, ip: IpAddr) {
71        let now = Self::now_secs();
72
73        if let Some(entry) = self.entries.get(&ip) {
74            let last_seen = entry.last_seen_secs.load(Ordering::Relaxed);
75            if now > last_seen + WINDOW_SECS {
76                // Window expired, reset
77                entry.violations.store(1, Ordering::Relaxed);
78            } else {
79                entry.violations.fetch_add(1, Ordering::Relaxed);
80            }
81            entry.last_seen_secs.store(now, Ordering::Relaxed);
82        } else {
83            // DOS-2: bound the map. Drop expired entries first; if still at the
84            // cap, skip — the untracked IP still pays the global difficulty tier,
85            // so a spoofed/varied-IP flood cannot grow this map without limit.
86            if self.entries.len() >= self.max_entries {
87                self.gc();
88                if self.entries.len() >= self.max_entries {
89                    return;
90                }
91            }
92            self.entries.insert(ip, ReputationEntry::new(now));
93        }
94    }
95
96    /// Reset violations (e.g., successful session established or valid TLS ticket)
97    pub fn reset_violations(&self, ip: IpAddr) {
98        self.entries.remove(&ip);
99    }
100
101    /// Calculate dynamic PoW difficulty based on reputation
102    pub fn calculate_difficulty(&self, ip: IpAddr, has_ticket: bool) -> u8 {
103        if has_ticket {
104            return 0; // Skip PoW for known returning clients
105        }
106
107        let now = Self::now_secs();
108
109        if let Some(entry) = self.entries.get(&ip) {
110            let last_seen = entry.last_seen_secs.load(Ordering::Relaxed);
111            if now > last_seen + WINDOW_SECS {
112                // Window expired — the IP is treated as clean again.
113                return 0;
114            }
115
116            let violations = entry.violations.load(Ordering::Relaxed);
117            if violations == 0 {
118                return 0;
119            }
120
121            // Exponential escalation from BASE for IPs WITH recent violations.
122            // Clamp the shift exponent so a large violation count cannot overflow
123            // the shift (the result is capped at MAX_DIFFICULTY anyway).
124            let exp = (violations - 1).min(8);
125            let diff = (BASE_DIFFICULTY as u32 + (1u32 << exp)).min(MAX_DIFFICULTY as u32);
126            diff as u8
127        } else {
128            // Clean / new IP — no extra PoW beyond the global load tier (DOS-2:
129            // do not penalize well-behaved clients).
130            0
131        }
132    }
133
134    /// Garbage collect expired entries
135    pub fn gc(&self) {
136        let now = Self::now_secs();
137        let before = self.entries.len();
138        self.entries.retain(|_, v| {
139            let last_seen = v.last_seen_secs.load(Ordering::Relaxed);
140            now <= last_seen + WINDOW_SECS
141        });
142        let after = self.entries.len();
143        if before > after {
144            log::info!(
145                "Reputation GC: removed {} expired entries, {} remaining",
146                before - after,
147                after
148            );
149        }
150    }
151}
152
153impl Default for ReputationTracker {
154    fn default() -> Self {
155        Self::new()
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    fn ip(n: u8) -> IpAddr {
164        IpAddr::from([10, 0, 0, n])
165    }
166
167    /// **DOS-2.** Per-IP escalation must not penalize well-behaved clients: a
168    /// clean/new IP (and any resumption-ticket holder) contributes 0, so legit
169    /// clients stay 1-RTT when the global load tier is idle.
170    #[test]
171    fn clean_ip_pays_no_extra_difficulty() {
172        let rep = ReputationTracker::new();
173        assert_eq!(rep.calculate_difficulty(ip(1), false), 0, "new IP");
174        assert_eq!(rep.calculate_difficulty(ip(1), true), 0, "ticket holder");
175    }
176
177    /// Repeated violations from one IP escalate the PoW difficulty (capped at
178    /// MAX_DIFFICULTY); a reset (successful handshake) clears it.
179    #[test]
180    fn repeated_violations_escalate_and_reset_clears() {
181        let rep = ReputationTracker::new();
182        let a = ip(2);
183        assert_eq!(rep.calculate_difficulty(a, false), 0);
184        rep.record_violation(a);
185        let d1 = rep.calculate_difficulty(a, false);
186        assert!(
187            d1 >= BASE_DIFFICULTY,
188            "first violation escalates to >= base, got {d1}"
189        );
190        rep.record_violation(a);
191        rep.record_violation(a);
192        let d3 = rep.calculate_difficulty(a, false);
193        assert!(
194            d3 >= d1 && d3 <= MAX_DIFFICULTY,
195            "escalates further, capped at max; got {d3}"
196        );
197        rep.reset_violations(a);
198        assert_eq!(
199            rep.calculate_difficulty(a, false),
200            0,
201            "reset clears escalation"
202        );
203    }
204
205    /// **DOS-2.** A spoofed/varied-IP flood must not grow the map without limit.
206    #[test]
207    fn map_is_bounded() {
208        let rep = ReputationTracker::with_capacity(8);
209        for n in 0..100u8 {
210            rep.record_violation(IpAddr::from([10, 1, 0, n]));
211        }
212        assert!(
213            rep.entries.len() <= 8,
214            "map must stay bounded, got {}",
215            rep.entries.len()
216        );
217    }
218}