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}