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}