//! 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, /// Connection type. pub peer_type: PeerType, /// When the peer connected. pub connected_at: Instant, /// /16 subnet identifier. pub subnet: Option, } impl PeerInfo { /// Creates new peer info. pub fn new(peer_id: PeerId, ip: Option, 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 { 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, /// Peers per subnet. subnet_counts: HashMap, /// Anchor peer IDs (persistent connections). anchors: HashSet, /// Last rotation time. last_rotation: Instant, /// Denied peer IDs (temporarily rejected). denied: HashMap, } 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, 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, 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 { 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) ); } }