Skip to main content

saorsa_core/
security.rs

1// Copyright 2024 Saorsa Labs Limited
2//
3// This software is dual-licensed under:
4// - GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later)
5// - Commercial License
6//
7// For AGPL-3.0 license, see LICENSE-AGPL-3.0
8// For commercial licensing, contact: david@saorsalabs.com
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under these licenses is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
14//! Security module
15//!
16//! This module provides Sybil protection for the P2P network via IP diversity
17//! enforcement to prevent large-scale Sybil attacks while maintaining network
18//! openness.
19
20use anyhow::{Result, anyhow};
21use lru::LruCache;
22use serde::{Deserialize, Serialize};
23use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
24use std::num::NonZeroUsize;
25
26/// Maximum subnet tracking entries before evicting oldest (prevents memory DoS)
27const BOOTSTRAP_MAX_TRACKED_SUBNETS: usize = 50_000;
28
29/// Max nodes sharing an exact IP address per bucket/close-group.
30/// Used by both `DhtCoreEngine` and `BootstrapIpLimiter` when
31/// `IPDiversityConfig::max_per_ip` is `None`.
32pub const IP_EXACT_LIMIT: usize = 2;
33
34/// Default K value for `BootstrapIpLimiter` when the actual K is not known
35/// (e.g. standalone test construction). Matches `DHTConfig::DEFAULT_K_VALUE`.
36#[cfg(test)]
37const DEFAULT_K_VALUE: usize = 20;
38
39/// Canonicalize an IP address: map IPv4-mapped IPv6 (`::ffff:a.b.c.d`) to
40/// its IPv4 equivalent so that diversity limits are enforced uniformly
41/// regardless of which address family the transport layer reports.
42pub fn canonicalize_ip(ip: IpAddr) -> IpAddr {
43    match ip {
44        IpAddr::V6(v6) => v6
45            .to_ipv4_mapped()
46            .map(IpAddr::V4)
47            .unwrap_or(IpAddr::V6(v6)),
48        other => other,
49    }
50}
51
52/// Compute the subnet diversity limit from the active K value.
53/// At least 1 node per subnet is always permitted.
54pub const fn ip_subnet_limit(k: usize) -> usize {
55    if k / 4 > 0 { k / 4 } else { 1 }
56}
57
58/// Configuration for IP diversity enforcement at two tiers: exact IP and subnet.
59///
60/// Limits are applied **per-bucket** and **per-close-group** (the K closest
61/// nodes to self), matching how geographic diversity is enforced.  When a
62/// candidate would exceed a limit, it may still be admitted via swap-closer
63/// logic: if the candidate is closer (XOR distance) to self than the
64/// farthest same-subnet peer in the scope, that farther peer is evicted.
65///
66/// By default every limit is `None`, meaning the K-based defaults from
67/// `DhtCoreEngine` apply (fractions of the bucket size K).  Setting an
68/// explicit `Some(n)` overrides the K-based default for that tier.
69#[derive(Debug, Clone, Default, Serialize, Deserialize)]
70pub struct IPDiversityConfig {
71    /// Override for max nodes sharing an exact IP address per bucket/close-group.
72    /// When `None`, uses the default of 2.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub max_per_ip: Option<usize>,
75
76    /// Override for max nodes in the same subnet (/24 IPv4, /48 IPv6).
77    /// When `None`, uses the K-based default (~25% of bucket size).
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub max_per_subnet: Option<usize>,
80}
81
82impl IPDiversityConfig {
83    /// Create a testnet configuration with relaxed diversity requirements.
84    ///
85    /// This is useful for testing environments like Digital Ocean where all nodes
86    /// share the same ASN (AS14061). The relaxed limits allow many nodes from the
87    /// same provider while still maintaining some diversity tracking.
88    ///
89    /// Currently identical to [`permissive`](Self::permissive) but kept as a
90    /// separate constructor so testnet limits can diverge independently (e.g.
91    /// allowing same-subnet but limiting per-IP) without changing local-dev
92    /// callers.
93    ///
94    /// # Warning
95    ///
96    /// This configuration should NEVER be used in production as it significantly
97    /// weakens Sybil attack protection.
98    #[must_use]
99    pub fn testnet() -> Self {
100        Self::permissive()
101    }
102
103    /// Create a permissive configuration that effectively disables diversity checks.
104    ///
105    /// This is useful for local development and unit testing where all nodes
106    /// run on localhost or the same machine.
107    #[must_use]
108    pub fn permissive() -> Self {
109        Self {
110            max_per_ip: Some(usize::MAX),
111            max_per_subnet: Some(usize::MAX),
112        }
113    }
114
115    /// Validate IP diversity parameter safety constraints (Section 4 points 1-2).
116    ///
117    /// Returns `Err` if any explicit limit is less than 1.
118    pub fn validate(&self) -> Result<()> {
119        if let Some(limit) = self.max_per_ip
120            && limit < 1
121        {
122            anyhow::bail!("max_per_ip must be >= 1 (got {limit})");
123        }
124        if let Some(limit) = self.max_per_subnet
125            && limit < 1
126        {
127            anyhow::bail!("max_per_subnet must be >= 1 (got {limit})");
128        }
129        Ok(())
130    }
131}
132
133/// IP diversity enforcement system
134///
135/// Tracks per-IP and per-subnet counts to prevent Sybil attacks.
136/// Uses simple 2-tier limits: exact IP and subnet (/24 IPv4, /48 IPv6).
137#[derive(Debug)]
138pub struct BootstrapIpLimiter {
139    config: IPDiversityConfig,
140    /// Allow loopback addresses (127.0.0.1, ::1) to bypass diversity checks.
141    ///
142    /// This flag is intentionally separate from `IPDiversityConfig` so that it
143    /// has a single source of truth in the owning component (`NodeConfig`,
144    /// `BootstrapManager`, etc.) rather than being copied into every config.
145    allow_loopback: bool,
146    /// K value from DHT config, used to derive subnet limits consistent with
147    /// the routing table's `ip_subnet_limit(k)`.
148    k_value: usize,
149    /// Count of nodes per exact IP address
150    ip_counts: LruCache<IpAddr, usize>,
151    /// Count of nodes per subnet (/24 IPv4, /48 IPv6)
152    subnet_counts: LruCache<IpAddr, usize>,
153}
154
155impl BootstrapIpLimiter {
156    /// Create a new IP diversity enforcer with loopback disabled and default K.
157    ///
158    /// Uses [`DEFAULT_K_VALUE`] — production code should prefer
159    /// [`with_loopback_and_k`](Self::with_loopback_and_k) to stay consistent
160    /// with the configured bucket size.
161    #[cfg(test)]
162    pub fn new(config: IPDiversityConfig) -> Self {
163        Self::with_loopback(config, false)
164    }
165
166    /// Create a new IP diversity enforcer with explicit loopback setting and
167    /// default K value.
168    ///
169    /// Uses [`DEFAULT_K_VALUE`] — production code should prefer
170    /// [`with_loopback_and_k`](Self::with_loopback_and_k) to stay consistent
171    /// with the configured bucket size.
172    #[cfg(test)]
173    pub fn with_loopback(config: IPDiversityConfig, allow_loopback: bool) -> Self {
174        Self::with_loopback_and_k(config, allow_loopback, DEFAULT_K_VALUE)
175    }
176
177    /// Create a new IP diversity enforcer with explicit loopback setting and K value.
178    ///
179    /// The `k_value` is used to derive the subnet limit (`k/4`) so that bootstrap
180    /// and routing table diversity limits stay consistent.
181    pub fn with_loopback_and_k(
182        config: IPDiversityConfig,
183        allow_loopback: bool,
184        k_value: usize,
185    ) -> Self {
186        let cache_size =
187            NonZeroUsize::new(BOOTSTRAP_MAX_TRACKED_SUBNETS).unwrap_or(NonZeroUsize::MIN);
188        Self {
189            config,
190            allow_loopback,
191            k_value,
192            ip_counts: LruCache::new(cache_size),
193            subnet_counts: LruCache::new(cache_size),
194        }
195    }
196
197    /// Mask an IP to its subnet prefix (/24 for IPv4, /48 for IPv6).
198    fn subnet_key(ip: IpAddr) -> IpAddr {
199        match ip {
200            IpAddr::V4(v4) => {
201                let o = v4.octets();
202                IpAddr::V4(Ipv4Addr::new(o[0], o[1], o[2], 0))
203            }
204            IpAddr::V6(v6) => {
205                let mut o = v6.octets();
206                // Zero out bytes 6-15 (host portion of /48)
207                for b in &mut o[6..] {
208                    *b = 0;
209                }
210                IpAddr::V6(Ipv6Addr::from(o))
211            }
212        }
213    }
214
215    /// Check if a new node with the given IP can be accepted under diversity limits.
216    pub fn can_accept(&self, ip: IpAddr) -> bool {
217        let ip = canonicalize_ip(ip);
218
219        // Loopback: bypass all checks when allowed, reject outright when not.
220        if ip.is_loopback() {
221            return self.allow_loopback;
222        }
223
224        // Reject addresses that are never valid peer endpoints.
225        if ip.is_unspecified() || ip.is_multicast() {
226            return false;
227        }
228
229        let ip_limit = self.config.max_per_ip.unwrap_or(IP_EXACT_LIMIT);
230        let subnet_limit = self
231            .config
232            .max_per_subnet
233            .unwrap_or(ip_subnet_limit(self.k_value));
234
235        // Check exact IP limit
236        if let Some(&count) = self.ip_counts.peek(&ip)
237            && count >= ip_limit
238        {
239            return false;
240        }
241
242        // Check subnet limit
243        let subnet = Self::subnet_key(ip);
244        if let Some(&count) = self.subnet_counts.peek(&subnet)
245            && count >= subnet_limit
246        {
247            return false;
248        }
249
250        true
251    }
252
253    /// Track a new node's IP address in the diversity enforcer.
254    ///
255    /// Returns an error if the IP would exceed diversity limits.
256    pub fn track(&mut self, ip: IpAddr) -> Result<()> {
257        let ip = canonicalize_ip(ip);
258        if !self.can_accept(ip) {
259            return Err(anyhow!("IP diversity limits exceeded"));
260        }
261
262        let count = self.ip_counts.get(&ip).copied().unwrap_or(0) + 1;
263        self.ip_counts.put(ip, count);
264
265        let subnet = Self::subnet_key(ip);
266        let count = self.subnet_counts.get(&subnet).copied().unwrap_or(0) + 1;
267        self.subnet_counts.put(subnet, count);
268
269        Ok(())
270    }
271
272    /// Remove a tracked IP address from the diversity enforcer.
273    #[allow(dead_code)]
274    pub fn untrack(&mut self, ip: IpAddr) {
275        let ip = canonicalize_ip(ip);
276        if let Some(count) = self.ip_counts.peek_mut(&ip) {
277            *count = count.saturating_sub(1);
278            if *count == 0 {
279                self.ip_counts.pop(&ip);
280            }
281        }
282
283        let subnet = Self::subnet_key(ip);
284        if let Some(count) = self.subnet_counts.peek_mut(&subnet) {
285            *count = count.saturating_sub(1);
286            if *count == 0 {
287                self.subnet_counts.pop(&subnet);
288            }
289        }
290    }
291}
292
293#[cfg(test)]
294impl BootstrapIpLimiter {
295    #[allow(dead_code)]
296    pub fn config(&self) -> &IPDiversityConfig {
297        &self.config
298    }
299}
300
301/// GeoIP/ASN provider trait.
302///
303/// Used by `BgpGeoProvider` in the transport layer; kept here so it can be
304/// shared across crates without a circular dependency.
305#[allow(dead_code)]
306pub trait GeoProvider: std::fmt::Debug {
307    /// Look up geo/ASN information for an IP address.
308    fn lookup(&self, ip: Ipv6Addr) -> GeoInfo;
309}
310
311/// Geo information for a peer's IP address.
312#[derive(Debug, Clone)]
313#[allow(dead_code)]
314pub struct GeoInfo {
315    /// Autonomous System Number
316    pub asn: Option<u32>,
317    /// Country code
318    pub country: Option<String>,
319    /// Whether the IP belongs to a known hosting provider
320    pub is_hosting_provider: bool,
321    /// Whether the IP belongs to a known VPN provider
322    pub is_vpn_provider: bool,
323}
324
325// Ed25519 compatibility removed
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn test_ip_diversity_config_default() {
333        let config = IPDiversityConfig::default();
334
335        assert!(config.max_per_ip.is_none());
336        assert!(config.max_per_subnet.is_none());
337    }
338
339    #[test]
340    fn test_bootstrap_ip_limiter_creation() {
341        let config = IPDiversityConfig {
342            max_per_ip: None,
343            max_per_subnet: Some(1),
344        };
345        let enforcer = BootstrapIpLimiter::with_loopback(config.clone(), true);
346
347        assert_eq!(enforcer.config.max_per_subnet, config.max_per_subnet);
348    }
349
350    #[test]
351    fn test_can_accept_basic() {
352        let config = IPDiversityConfig::default();
353        let enforcer = BootstrapIpLimiter::new(config);
354
355        let ip: IpAddr = "192.168.1.1".parse().unwrap();
356        assert!(enforcer.can_accept(ip));
357    }
358
359    #[test]
360    fn test_ip_limit_enforcement() {
361        let config = IPDiversityConfig {
362            max_per_ip: Some(1),
363            max_per_subnet: Some(usize::MAX),
364        };
365        let mut enforcer = BootstrapIpLimiter::new(config);
366
367        let ip: IpAddr = "10.0.0.1".parse().unwrap();
368
369        // First node should be accepted
370        assert!(enforcer.can_accept(ip));
371        enforcer.track(ip).unwrap();
372
373        // Second node with same IP should be rejected
374        assert!(!enforcer.can_accept(ip));
375        assert!(enforcer.track(ip).is_err());
376    }
377
378    #[test]
379    fn test_subnet_limit_enforcement_ipv4() {
380        let config = IPDiversityConfig {
381            max_per_ip: Some(usize::MAX),
382            max_per_subnet: Some(2),
383        };
384        let mut enforcer = BootstrapIpLimiter::new(config);
385
386        // Two IPs in same /24 subnet
387        let ip1: IpAddr = "10.0.1.1".parse().unwrap();
388        let ip2: IpAddr = "10.0.1.2".parse().unwrap();
389        let ip3: IpAddr = "10.0.1.3".parse().unwrap();
390
391        enforcer.track(ip1).unwrap();
392        enforcer.track(ip2).unwrap();
393
394        // Third in same /24 should be rejected
395        assert!(!enforcer.can_accept(ip3));
396        assert!(enforcer.track(ip3).is_err());
397
398        // Different /24 should still be accepted
399        let ip_other: IpAddr = "10.0.2.1".parse().unwrap();
400        assert!(enforcer.can_accept(ip_other));
401    }
402
403    #[test]
404    fn test_subnet_limit_enforcement_ipv6() {
405        let config = IPDiversityConfig {
406            max_per_ip: Some(usize::MAX),
407            max_per_subnet: Some(1),
408        };
409        let mut enforcer = BootstrapIpLimiter::new(config);
410
411        // Two IPs in same /48 subnet
412        let ip1: IpAddr = "2001:db8:85a3:1234::1".parse().unwrap();
413        let ip2: IpAddr = "2001:db8:85a3:5678::2".parse().unwrap();
414
415        enforcer.track(ip1).unwrap();
416
417        // Second in same /48 should be rejected
418        assert!(!enforcer.can_accept(ip2));
419
420        // Different /48 should be accepted
421        let ip_other: IpAddr = "2001:db8:aaaa::1".parse().unwrap();
422        assert!(enforcer.can_accept(ip_other));
423    }
424
425    #[test]
426    fn test_track_and_untrack() {
427        let config = IPDiversityConfig {
428            max_per_ip: Some(1),
429            max_per_subnet: Some(usize::MAX),
430        };
431        let mut enforcer = BootstrapIpLimiter::new(config);
432
433        let ip: IpAddr = "10.0.0.1".parse().unwrap();
434
435        // Track
436        enforcer.track(ip).unwrap();
437        assert!(!enforcer.can_accept(ip));
438
439        // Untrack
440        enforcer.untrack(ip);
441        assert!(enforcer.can_accept(ip));
442
443        // Can track again after untrack
444        enforcer.track(ip).unwrap();
445        assert!(!enforcer.can_accept(ip));
446    }
447
448    #[test]
449    fn test_loopback_bypass() {
450        let config = IPDiversityConfig {
451            max_per_ip: Some(1),
452            max_per_subnet: Some(1),
453        };
454
455        // With loopback enabled
456        let enforcer = BootstrapIpLimiter::with_loopback(config.clone(), true);
457        let loopback_v4: IpAddr = "127.0.0.1".parse().unwrap();
458        let loopback_v6: IpAddr = "::1".parse().unwrap();
459        assert!(enforcer.can_accept(loopback_v4));
460        assert!(enforcer.can_accept(loopback_v6));
461
462        // With loopback disabled (default) — rejected outright, not tracked
463        let enforcer_no_lb = BootstrapIpLimiter::new(config);
464        assert!(
465            !enforcer_no_lb.can_accept(loopback_v4),
466            "loopback should be rejected when allow_loopback=false"
467        );
468        assert!(
469            !enforcer_no_lb.can_accept(loopback_v6),
470            "loopback IPv6 should be rejected when allow_loopback=false"
471        );
472    }
473
474    #[test]
475    fn test_subnet_key_ipv4() {
476        let ip: IpAddr = "192.168.42.100".parse().unwrap();
477        let subnet = BootstrapIpLimiter::subnet_key(ip);
478        let expected: IpAddr = "192.168.42.0".parse().unwrap();
479        assert_eq!(subnet, expected);
480    }
481
482    #[test]
483    fn test_subnet_key_ipv6() {
484        let ip: IpAddr = "2001:db8:85a3:1234:5678:8a2e:0370:7334".parse().unwrap();
485        let subnet = BootstrapIpLimiter::subnet_key(ip);
486        let expected: IpAddr = "2001:db8:85a3::".parse().unwrap();
487        assert_eq!(subnet, expected);
488    }
489
490    #[test]
491    fn test_default_ip_limit_is_two() {
492        let config = IPDiversityConfig::default();
493        let mut enforcer = BootstrapIpLimiter::new(config);
494
495        let ip1: IpAddr = "10.0.0.1".parse().unwrap();
496
497        // Default IP limit is 2, so two tracks should succeed
498        enforcer.track(ip1).unwrap();
499        enforcer.track(ip1).unwrap();
500
501        // Third should fail
502        assert!(!enforcer.can_accept(ip1));
503    }
504
505    #[test]
506    fn test_default_subnet_limit_matches_k() {
507        // With default K=20, subnet limit should be K/4 = 5
508        let config = IPDiversityConfig::default();
509        let mut enforcer = BootstrapIpLimiter::new(config);
510
511        // Track 5 IPs in the same /24 subnet — all should succeed
512        for i in 1..=5 {
513            let ip: IpAddr = format!("10.0.1.{i}").parse().unwrap();
514            enforcer.track(ip).unwrap();
515        }
516
517        // 6th in same subnet should be rejected
518        let ip6: IpAddr = "10.0.1.6".parse().unwrap();
519        assert!(
520            !enforcer.can_accept(ip6),
521            "6th peer in same /24 should exceed K/4=5 subnet limit"
522        );
523    }
524
525    #[test]
526    fn test_ipv4_mapped_ipv6_counts_as_ipv4() {
527        let config = IPDiversityConfig {
528            max_per_ip: Some(1),
529            max_per_subnet: Some(usize::MAX),
530        };
531        let mut enforcer = BootstrapIpLimiter::new(config);
532
533        // Track using native IPv4
534        let ipv4: IpAddr = "10.0.0.1".parse().unwrap();
535        enforcer.track(ipv4).unwrap();
536
537        // IPv4-mapped IPv6 form of the same address should be rejected
538        let mapped: IpAddr = "::ffff:10.0.0.1".parse().unwrap();
539        assert!(
540            !enforcer.can_accept(mapped),
541            "IPv4-mapped IPv6 should be canonicalized and hit the IPv4 limit"
542        );
543    }
544
545    #[test]
546    fn test_multicast_rejected() {
547        let config = IPDiversityConfig::default();
548        let enforcer = BootstrapIpLimiter::new(config);
549
550        let multicast_v4: IpAddr = "224.0.0.1".parse().unwrap();
551        assert!(!enforcer.can_accept(multicast_v4));
552
553        let multicast_v6: IpAddr = "ff02::1".parse().unwrap();
554        assert!(!enforcer.can_accept(multicast_v6));
555    }
556
557    #[test]
558    fn test_unspecified_rejected() {
559        let config = IPDiversityConfig::default();
560        let enforcer = BootstrapIpLimiter::new(config);
561
562        let unspec_v4: IpAddr = "0.0.0.0".parse().unwrap();
563        assert!(!enforcer.can_accept(unspec_v4));
564
565        let unspec_v6: IpAddr = "::".parse().unwrap();
566        assert!(!enforcer.can_accept(unspec_v6));
567    }
568
569    #[test]
570    fn test_untrack_ipv4_mapped_ipv6() {
571        let config = IPDiversityConfig {
572            max_per_ip: Some(1),
573            max_per_subnet: Some(usize::MAX),
574        };
575        let mut enforcer = BootstrapIpLimiter::new(config);
576
577        // Track using native IPv4
578        let ipv4: IpAddr = "10.0.0.1".parse().unwrap();
579        enforcer.track(ipv4).unwrap();
580        assert!(!enforcer.can_accept(ipv4));
581
582        // Untrack using the IPv4-mapped IPv6 form — should still decrement
583        let mapped: IpAddr = "::ffff:10.0.0.1".parse().unwrap();
584        enforcer.untrack(mapped);
585        assert!(
586            enforcer.can_accept(ipv4),
587            "untrack via mapped form should decrement the IPv4 counter"
588        );
589    }
590}