Skip to main content

saorsa_core/
security.rs

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