Skip to main content

phantom_protocol/transport/
reputation.rs

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