469 lines
14 KiB
Rust
469 lines
14 KiB
Rust
//! Eclipse attack mitigations.
|
|
//!
|
|
//! Eclipse attacks attempt to isolate a node by controlling all its peer connections.
|
|
//! This module implements several mitigations:
|
|
//!
|
|
//! - **Subnet diversity**: Limit peers from the same /16 subnet
|
|
//! - **Anchor connections**: Maintain persistent connections to trusted peers
|
|
//! - **Outbound ratio**: Ensure minimum outbound connections
|
|
//! - **Random rotation**: Periodically rotate a subset of peers
|
|
//! - **New peer trials**: Test new peers before fully trusting them
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::net::IpAddr;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use libp2p::PeerId;
|
|
|
|
/// Configuration for eclipse attack protection.
|
|
#[derive(Clone, Debug)]
|
|
pub struct EclipseConfig {
|
|
/// Maximum peers from the same /16 subnet.
|
|
pub max_peers_per_subnet: usize,
|
|
/// Minimum outbound connections (must be maintained).
|
|
pub min_outbound: usize,
|
|
/// Number of anchor (persistent) connections.
|
|
pub anchor_count: usize,
|
|
/// How often to rotate peers (0 = never).
|
|
pub rotation_interval: Duration,
|
|
/// Percentage of peers to rotate each interval.
|
|
pub rotation_percentage: f32,
|
|
/// Trial period for new peers before full trust.
|
|
pub trial_period: Duration,
|
|
/// Maximum trial peers at once.
|
|
pub max_trial_peers: usize,
|
|
}
|
|
|
|
impl Default for EclipseConfig {
|
|
fn default() -> Self {
|
|
EclipseConfig {
|
|
max_peers_per_subnet: 2,
|
|
min_outbound: 8,
|
|
anchor_count: 4,
|
|
rotation_interval: Duration::from_secs(3600), // 1 hour
|
|
rotation_percentage: 0.1, // 10%
|
|
trial_period: Duration::from_secs(300), // 5 minutes
|
|
max_trial_peers: 5,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Peer connection type.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum PeerType {
|
|
/// Outbound connection we initiated.
|
|
Outbound,
|
|
/// Inbound connection peer initiated.
|
|
Inbound,
|
|
/// Anchor peer (persistent, trusted).
|
|
Anchor,
|
|
/// Trial peer (new, not yet trusted).
|
|
Trial,
|
|
}
|
|
|
|
/// Information about a connected peer for eclipse protection.
|
|
#[derive(Clone, Debug)]
|
|
pub struct PeerInfo {
|
|
/// Peer ID.
|
|
pub peer_id: PeerId,
|
|
/// IP address.
|
|
pub ip: Option<IpAddr>,
|
|
/// Connection type.
|
|
pub peer_type: PeerType,
|
|
/// When the peer connected.
|
|
pub connected_at: Instant,
|
|
/// /16 subnet identifier.
|
|
pub subnet: Option<u32>,
|
|
}
|
|
|
|
impl PeerInfo {
|
|
/// Creates new peer info.
|
|
pub fn new(peer_id: PeerId, ip: Option<IpAddr>, peer_type: PeerType) -> Self {
|
|
let subnet = ip.and_then(|addr| Self::extract_subnet(&addr));
|
|
PeerInfo {
|
|
peer_id,
|
|
ip,
|
|
peer_type,
|
|
connected_at: Instant::now(),
|
|
subnet,
|
|
}
|
|
}
|
|
|
|
/// Extracts /16 subnet identifier from IP.
|
|
fn extract_subnet(ip: &IpAddr) -> Option<u32> {
|
|
match ip {
|
|
IpAddr::V4(addr) => {
|
|
let octets = addr.octets();
|
|
Some(((octets[0] as u32) << 8) | (octets[1] as u32))
|
|
}
|
|
IpAddr::V6(addr) => {
|
|
// Use first 32 bits for IPv6 subnet grouping
|
|
let segments = addr.segments();
|
|
Some(((segments[0] as u32) << 16) | (segments[1] as u32))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Checks if peer is in trial period.
|
|
pub fn is_trial(&self, trial_period: Duration) -> bool {
|
|
self.peer_type == PeerType::Trial || self.connected_at.elapsed() < trial_period
|
|
}
|
|
}
|
|
|
|
/// Eclipse protection manager.
|
|
pub struct EclipseProtection {
|
|
/// Configuration.
|
|
config: EclipseConfig,
|
|
/// Connected peers.
|
|
peers: HashMap<PeerId, PeerInfo>,
|
|
/// Peers per subnet.
|
|
subnet_counts: HashMap<u32, usize>,
|
|
/// Anchor peer IDs (persistent connections).
|
|
anchors: HashSet<PeerId>,
|
|
/// Last rotation time.
|
|
last_rotation: Instant,
|
|
/// Denied peer IDs (temporarily rejected).
|
|
denied: HashMap<PeerId, Instant>,
|
|
}
|
|
|
|
impl EclipseProtection {
|
|
/// Creates a new eclipse protection manager.
|
|
pub fn new(config: EclipseConfig) -> Self {
|
|
EclipseProtection {
|
|
config,
|
|
peers: HashMap::new(),
|
|
subnet_counts: HashMap::new(),
|
|
anchors: HashSet::new(),
|
|
last_rotation: Instant::now(),
|
|
denied: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Checks if a new connection should be allowed.
|
|
///
|
|
/// Returns `Ok(())` if allowed, `Err(reason)` if denied.
|
|
pub fn check_connection(
|
|
&self,
|
|
peer_id: &PeerId,
|
|
ip: Option<IpAddr>,
|
|
is_outbound: bool,
|
|
) -> Result<(), EclipseDenialReason> {
|
|
// Check if peer is temporarily denied
|
|
if let Some(denied_until) = self.denied.get(peer_id) {
|
|
if denied_until.elapsed() < Duration::from_secs(300) {
|
|
return Err(EclipseDenialReason::TemporarilyDenied);
|
|
}
|
|
}
|
|
|
|
// Check subnet diversity for inbound connections
|
|
if !is_outbound {
|
|
if let Some(ip) = ip {
|
|
if let Some(subnet) = PeerInfo::extract_subnet(&ip) {
|
|
let current_count = self.subnet_counts.get(&subnet).copied().unwrap_or(0);
|
|
if current_count >= self.config.max_peers_per_subnet {
|
|
return Err(EclipseDenialReason::SubnetLimitReached);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check trial peer limit
|
|
let trial_count = self.count_trial_peers();
|
|
if trial_count >= self.config.max_trial_peers && !self.anchors.contains(peer_id) {
|
|
return Err(EclipseDenialReason::TooManyTrialPeers);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Registers a new peer connection.
|
|
pub fn register_peer(&mut self, peer_id: PeerId, ip: Option<IpAddr>, is_outbound: bool) {
|
|
let peer_type = if self.anchors.contains(&peer_id) {
|
|
PeerType::Anchor
|
|
} else if is_outbound {
|
|
PeerType::Outbound
|
|
} else {
|
|
PeerType::Trial // Inbound connections start as trial
|
|
};
|
|
|
|
let info = PeerInfo::new(peer_id, ip, peer_type);
|
|
|
|
// Update subnet count
|
|
if let Some(subnet) = info.subnet {
|
|
*self.subnet_counts.entry(subnet).or_insert(0) += 1;
|
|
}
|
|
|
|
self.peers.insert(peer_id, info);
|
|
}
|
|
|
|
/// Removes a peer from tracking.
|
|
pub fn remove_peer(&mut self, peer_id: &PeerId) {
|
|
if let Some(info) = self.peers.remove(peer_id) {
|
|
if let Some(subnet) = info.subnet {
|
|
if let Some(count) = self.subnet_counts.get_mut(&subnet) {
|
|
*count = count.saturating_sub(1);
|
|
if *count == 0 {
|
|
self.subnet_counts.remove(&subnet);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Promotes a trial peer to regular status.
|
|
pub fn promote_peer(&mut self, peer_id: &PeerId) {
|
|
if let Some(info) = self.peers.get_mut(peer_id) {
|
|
if info.peer_type == PeerType::Trial {
|
|
info.peer_type = PeerType::Inbound;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Adds a peer to the anchor set.
|
|
pub fn add_anchor(&mut self, peer_id: PeerId) {
|
|
self.anchors.insert(peer_id);
|
|
if let Some(info) = self.peers.get_mut(&peer_id) {
|
|
info.peer_type = PeerType::Anchor;
|
|
}
|
|
}
|
|
|
|
/// Removes a peer from the anchor set.
|
|
pub fn remove_anchor(&mut self, peer_id: &PeerId) {
|
|
self.anchors.remove(peer_id);
|
|
}
|
|
|
|
/// Gets peers that should be rotated.
|
|
pub fn get_rotation_candidates(&mut self) -> Vec<PeerId> {
|
|
if self.last_rotation.elapsed() < self.config.rotation_interval {
|
|
return Vec::new();
|
|
}
|
|
|
|
self.last_rotation = Instant::now();
|
|
|
|
// Only rotate non-anchor, non-outbound peers
|
|
let candidates: Vec<_> = self
|
|
.peers
|
|
.iter()
|
|
.filter(|(_, info)| {
|
|
info.peer_type != PeerType::Anchor
|
|
&& info.peer_type != PeerType::Outbound
|
|
&& info.connected_at.elapsed() > self.config.trial_period
|
|
})
|
|
.map(|(id, _)| *id)
|
|
.collect();
|
|
|
|
let rotate_count =
|
|
(candidates.len() as f32 * self.config.rotation_percentage).ceil() as usize;
|
|
|
|
// Return random subset (simplified: just take first N)
|
|
candidates.into_iter().take(rotate_count).collect()
|
|
}
|
|
|
|
/// Checks if we need more outbound connections.
|
|
pub fn needs_outbound(&self) -> bool {
|
|
let outbound_count = self
|
|
.peers
|
|
.values()
|
|
.filter(|p| p.peer_type == PeerType::Outbound || p.peer_type == PeerType::Anchor)
|
|
.count();
|
|
|
|
outbound_count < self.config.min_outbound
|
|
}
|
|
|
|
/// Returns the number of outbound connections needed.
|
|
pub fn outbound_deficit(&self) -> usize {
|
|
let outbound_count = self
|
|
.peers
|
|
.values()
|
|
.filter(|p| p.peer_type == PeerType::Outbound || p.peer_type == PeerType::Anchor)
|
|
.count();
|
|
|
|
self.config.min_outbound.saturating_sub(outbound_count)
|
|
}
|
|
|
|
/// Temporarily denies a peer.
|
|
pub fn deny_peer(&mut self, peer_id: PeerId, _reason: &str) {
|
|
self.denied.insert(peer_id, Instant::now());
|
|
}
|
|
|
|
/// Cleans up expired denials.
|
|
pub fn cleanup_denials(&mut self) {
|
|
let expiry = Duration::from_secs(300);
|
|
self.denied.retain(|_, time| time.elapsed() < expiry);
|
|
}
|
|
|
|
/// Counts trial peers.
|
|
fn count_trial_peers(&self) -> usize {
|
|
self.peers
|
|
.values()
|
|
.filter(|p| p.is_trial(self.config.trial_period))
|
|
.count()
|
|
}
|
|
|
|
/// Gets connection statistics.
|
|
pub fn stats(&self) -> EclipseStats {
|
|
let mut inbound = 0;
|
|
let mut outbound = 0;
|
|
let mut anchor = 0;
|
|
let mut trial = 0;
|
|
|
|
for info in self.peers.values() {
|
|
match info.peer_type {
|
|
PeerType::Inbound => inbound += 1,
|
|
PeerType::Outbound => outbound += 1,
|
|
PeerType::Anchor => anchor += 1,
|
|
PeerType::Trial => trial += 1,
|
|
}
|
|
}
|
|
|
|
EclipseStats {
|
|
total_peers: self.peers.len(),
|
|
inbound,
|
|
outbound,
|
|
anchor,
|
|
trial,
|
|
unique_subnets: self.subnet_counts.len(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reason for denying a connection.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum EclipseDenialReason {
|
|
/// Too many peers from the same subnet.
|
|
SubnetLimitReached,
|
|
/// Peer is temporarily denied.
|
|
TemporarilyDenied,
|
|
/// Too many trial peers.
|
|
TooManyTrialPeers,
|
|
}
|
|
|
|
impl std::fmt::Display for EclipseDenialReason {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::SubnetLimitReached => write!(f, "subnet limit reached"),
|
|
Self::TemporarilyDenied => write!(f, "temporarily denied"),
|
|
Self::TooManyTrialPeers => write!(f, "too many trial peers"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Eclipse protection statistics.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct EclipseStats {
|
|
/// Total connected peers.
|
|
pub total_peers: usize,
|
|
/// Inbound connections.
|
|
pub inbound: usize,
|
|
/// Outbound connections.
|
|
pub outbound: usize,
|
|
/// Anchor connections.
|
|
pub anchor: usize,
|
|
/// Trial connections.
|
|
pub trial: usize,
|
|
/// Unique subnets represented.
|
|
pub unique_subnets: usize,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::net::Ipv4Addr;
|
|
|
|
fn random_peer() -> PeerId {
|
|
PeerId::random()
|
|
}
|
|
|
|
#[test]
|
|
fn test_subnet_extraction() {
|
|
let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));
|
|
let subnet = PeerInfo::extract_subnet(&ip).unwrap();
|
|
// 192.168.x.x -> (192 << 8) | 168 = 49320
|
|
assert_eq!(subnet, (192 << 8) | 168);
|
|
|
|
let ip2 = IpAddr::V4(Ipv4Addr::new(192, 168, 2, 100));
|
|
let subnet2 = PeerInfo::extract_subnet(&ip2).unwrap();
|
|
assert_eq!(subnet, subnet2); // Same /16 subnet
|
|
}
|
|
|
|
#[test]
|
|
fn test_subnet_diversity() {
|
|
let config = EclipseConfig {
|
|
max_peers_per_subnet: 2,
|
|
..Default::default()
|
|
};
|
|
let mut protection = EclipseProtection::new(config);
|
|
|
|
let ip = Some(IpAddr::V4(Ipv4Addr::new(10, 0, 1, 1)));
|
|
|
|
// First two should be allowed
|
|
let peer1 = random_peer();
|
|
assert!(protection.check_connection(&peer1, ip, false).is_ok());
|
|
protection.register_peer(peer1, ip, false);
|
|
|
|
let peer2 = random_peer();
|
|
assert!(protection.check_connection(&peer2, ip, false).is_ok());
|
|
protection.register_peer(peer2, ip, false);
|
|
|
|
// Third should be denied (same subnet)
|
|
let peer3 = random_peer();
|
|
assert_eq!(
|
|
protection.check_connection(&peer3, ip, false),
|
|
Err(EclipseDenialReason::SubnetLimitReached)
|
|
);
|
|
|
|
// Different subnet should be allowed
|
|
let different_ip = Some(IpAddr::V4(Ipv4Addr::new(10, 1, 1, 1)));
|
|
assert!(protection
|
|
.check_connection(&peer3, different_ip, false)
|
|
.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_outbound_tracking() {
|
|
let config = EclipseConfig {
|
|
min_outbound: 4,
|
|
..Default::default()
|
|
};
|
|
let mut protection = EclipseProtection::new(config);
|
|
|
|
assert!(protection.needs_outbound());
|
|
assert_eq!(protection.outbound_deficit(), 4);
|
|
|
|
// Add outbound peers
|
|
for _ in 0..4 {
|
|
let peer = random_peer();
|
|
protection.register_peer(peer, None, true);
|
|
}
|
|
|
|
assert!(!protection.needs_outbound());
|
|
assert_eq!(protection.outbound_deficit(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_anchor_peers() {
|
|
let config = EclipseConfig::default();
|
|
let mut protection = EclipseProtection::new(config);
|
|
|
|
let anchor = random_peer();
|
|
protection.add_anchor(anchor);
|
|
protection.register_peer(anchor, None, true);
|
|
|
|
let stats = protection.stats();
|
|
assert_eq!(stats.anchor, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_peer_denial() {
|
|
let config = EclipseConfig::default();
|
|
let mut protection = EclipseProtection::new(config);
|
|
|
|
let peer = random_peer();
|
|
protection.deny_peer(peer, "bad behavior");
|
|
|
|
assert_eq!(
|
|
protection.check_connection(&peer, None, false),
|
|
Err(EclipseDenialReason::TemporarilyDenied)
|
|
);
|
|
}
|
|
}
|