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//! IP diversity configuration and helpers used by the DHT routing-table
17//! Sybil defenses.
18
19use anyhow::Result;
20use serde::{Deserialize, Serialize};
21use std::net::{IpAddr, Ipv6Addr};
22
23/// Max nodes sharing an exact IP address per bucket/close-group.
24/// Used by `DhtCoreEngine` when `IPDiversityConfig::max_per_ip` is `None`.
25pub const IP_EXACT_LIMIT: usize = 2;
26
27/// Canonicalize an IP address: map IPv4-mapped IPv6 (`::ffff:a.b.c.d`) to
28/// its IPv4 equivalent so that diversity limits are enforced uniformly
29/// regardless of which address family the transport layer reports.
30pub fn canonicalize_ip(ip: IpAddr) -> IpAddr {
31    match ip {
32        IpAddr::V6(v6) => v6
33            .to_ipv4_mapped()
34            .map(IpAddr::V4)
35            .unwrap_or(IpAddr::V6(v6)),
36        other => other,
37    }
38}
39
40/// Compute the subnet diversity limit from the active K value.
41/// At least 1 node per subnet is always permitted.
42pub const fn ip_subnet_limit(k: usize) -> usize {
43    if k / 4 > 0 { k / 4 } else { 1 }
44}
45
46/// Configuration for IP diversity enforcement at two tiers: exact IP and subnet.
47///
48/// Limits are applied **per-bucket** and **per-close-group** (the K closest
49/// nodes to self), matching how geographic diversity is enforced.  When a
50/// candidate would exceed a limit, it may still be admitted via swap-closer
51/// logic: if the candidate is closer (XOR distance) to self than the
52/// farthest same-subnet peer in the scope, that farther peer is evicted.
53///
54/// By default every limit is `None`, meaning the K-based defaults from
55/// `DhtCoreEngine` apply (fractions of the bucket size K).  Setting an
56/// explicit `Some(n)` overrides the K-based default for that tier.
57#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58pub struct IPDiversityConfig {
59    /// Override for max nodes sharing an exact IP address per bucket/close-group.
60    /// When `None`, uses the default of 2.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub max_per_ip: Option<usize>,
63
64    /// Override for max nodes in the same subnet (/24 IPv4, /48 IPv6).
65    /// When `None`, uses the K-based default (~25% of bucket size).
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub max_per_subnet: Option<usize>,
68}
69
70impl IPDiversityConfig {
71    /// Create a testnet configuration with relaxed diversity requirements.
72    ///
73    /// This is useful for testing environments like Digital Ocean where all nodes
74    /// share the same ASN (AS14061). The relaxed limits allow many nodes from the
75    /// same provider while still maintaining some diversity tracking.
76    ///
77    /// Currently identical to [`permissive`](Self::permissive) but kept as a
78    /// separate constructor so testnet limits can diverge independently (e.g.
79    /// allowing same-subnet but limiting per-IP) without changing local-dev
80    /// callers.
81    ///
82    /// # Warning
83    ///
84    /// This configuration should NEVER be used in production as it significantly
85    /// weakens Sybil attack protection.
86    #[must_use]
87    pub fn testnet() -> Self {
88        Self::permissive()
89    }
90
91    /// Create a permissive configuration that effectively disables diversity checks.
92    ///
93    /// This is useful for local development and unit testing where all nodes
94    /// run on localhost or the same machine.
95    #[must_use]
96    pub fn permissive() -> Self {
97        Self {
98            max_per_ip: Some(usize::MAX),
99            max_per_subnet: Some(usize::MAX),
100        }
101    }
102
103    /// Validate IP diversity parameter safety constraints.
104    ///
105    /// Returns `Err` if any explicit limit is less than 1.
106    pub fn validate(&self) -> Result<()> {
107        if let Some(limit) = self.max_per_ip
108            && limit < 1
109        {
110            anyhow::bail!("max_per_ip must be >= 1 (got {limit})");
111        }
112        if let Some(limit) = self.max_per_subnet
113            && limit < 1
114        {
115            anyhow::bail!("max_per_subnet must be >= 1 (got {limit})");
116        }
117        Ok(())
118    }
119}
120
121/// GeoIP/ASN provider trait.
122///
123/// Used by `BgpGeoProvider` in the transport layer; kept here so it can be
124/// shared across crates without a circular dependency.
125#[allow(dead_code)]
126pub trait GeoProvider: std::fmt::Debug {
127    /// Look up geo/ASN information for an IP address.
128    fn lookup(&self, ip: Ipv6Addr) -> GeoInfo;
129}
130
131/// Geo information for a peer's IP address.
132#[derive(Debug, Clone)]
133#[allow(dead_code)]
134pub struct GeoInfo {
135    /// Autonomous System Number
136    pub asn: Option<u32>,
137    /// Country code
138    pub country: Option<String>,
139    /// Whether the IP belongs to a known hosting provider
140    pub is_hosting_provider: bool,
141    /// Whether the IP belongs to a known VPN provider
142    pub is_vpn_provider: bool,
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn test_ip_diversity_config_default() {
151        let config = IPDiversityConfig::default();
152        assert!(config.max_per_ip.is_none());
153        assert!(config.max_per_subnet.is_none());
154    }
155
156    #[test]
157    fn test_canonicalize_ipv4_mapped() {
158        let mapped: IpAddr = "::ffff:10.0.0.1".parse().unwrap();
159        let canonical = canonicalize_ip(mapped);
160        let expected: IpAddr = "10.0.0.1".parse().unwrap();
161        assert_eq!(canonical, expected);
162    }
163
164    #[test]
165    fn test_canonicalize_native_ipv6_unchanged() {
166        let v6: IpAddr = "2001:db8::1".parse().unwrap();
167        assert_eq!(canonicalize_ip(v6), v6);
168    }
169
170    #[test]
171    fn test_ip_subnet_limit() {
172        assert_eq!(ip_subnet_limit(20), 5);
173        assert_eq!(ip_subnet_limit(8), 2);
174        assert_eq!(ip_subnet_limit(1), 1);
175        assert_eq!(ip_subnet_limit(0), 1);
176    }
177}