diff --git a/src/node/handlers/encrypted.rs b/src/node/handlers/encrypted.rs index 914b23e..8fd9ab1 100644 --- a/src/node/handlers/encrypted.rs +++ b/src/node/handlers/encrypted.rs @@ -215,7 +215,7 @@ impl Node { } /// Increment decrypt failure counter and force-remove peer if threshold exceeded. - fn handle_decrypt_failure(&mut self, node_addr: &crate::NodeAddr) { + pub(in crate::node) fn handle_decrypt_failure(&mut self, node_addr: &crate::NodeAddr) { if let Some(peer) = self.peers.get_mut(node_addr) { let count = peer.increment_decrypt_failures(); if count >= DECRYPT_FAILURE_THRESHOLD { diff --git a/src/node/tests/decrypt_failure.rs b/src/node/tests/decrypt_failure.rs new file mode 100644 index 0000000..6da703d --- /dev/null +++ b/src/node/tests/decrypt_failure.rs @@ -0,0 +1,93 @@ +//! Tests for the consecutive-decrypt-failure threshold force-removal path. +//! +//! Covers `Node::handle_decrypt_failure` (in `node/handlers/encrypted.rs`), +//! which increments `ActivePeer::increment_decrypt_failures` on each AEAD +//! verification failure and force-removes the peer once +//! `DECRYPT_FAILURE_THRESHOLD` consecutive failures are observed. The +//! threshold is a defensive signal against a peer whose session is +//! desynchronized or under attack, so regression coverage of the wiring +//! between counter, threshold, and peer eviction is security-relevant. + +use super::*; + +/// Drive a fully-promoted peer to the decrypt-failure threshold and verify +/// it is removed from both `peers` and `peers_by_index`. +/// +/// Setup uses the `make_completed_connection` harness so the peer has a +/// real `our_index`/`transport_id`, ensuring `remove_active_peer` exercises +/// the full `peers_by_index` cleanup path (not just the bare `peers` table). +#[test] +fn test_decrypt_failure_threshold_removes_peer() { + // Threshold constant in node/handlers/encrypted.rs (kept in sync with + // production code; see DECRYPT_FAILURE_THRESHOLD). + const THRESHOLD: u32 = 20; + + let mut node = make_node(); + let transport_id = TransportId::new(1); + let link_id = LinkId::new(1); + + // Build a fully-promoted active peer with our_index/transport_id set + // so peers_by_index is populated by promote_connection. + let (conn, identity) = make_completed_connection(&mut node, link_id, transport_id, 1_000); + let node_addr = *identity.node_addr(); + + node.add_connection(conn).unwrap(); + node.promote_connection(link_id, identity, 2_000).unwrap(); + + // Sanity: peer is registered and indexed. + assert_eq!(node.peer_count(), 1, "peer should be present after promote"); + let our_index = node + .get_peer(&node_addr) + .and_then(|p| p.our_index()) + .expect("promoted peer must have our_index"); + assert_eq!( + node.peers_by_index.get(&(transport_id, our_index.as_u32())), + Some(&node_addr), + "peers_by_index must be populated after promote" + ); + assert_eq!( + node.get_peer(&node_addr) + .unwrap() + .consecutive_decrypt_failures(), + 0, + "fresh peer's failure counter must start at zero" + ); + + // Drive failures up to (but not including) the threshold; peer must + // remain present and the counter must increase monotonically. + for expected in 1..THRESHOLD { + node.handle_decrypt_failure(&node_addr); + let count = node + .get_peer(&node_addr) + .expect("peer must still be present below threshold") + .consecutive_decrypt_failures(); + assert_eq!( + count, expected, + "counter should track failures pre-threshold" + ); + } + assert_eq!( + node.peer_count(), + 1, + "peer must remain registered until threshold is reached" + ); + + // The Nth failure crosses the threshold and triggers force-removal. + node.handle_decrypt_failure(&node_addr); + + assert!( + node.get_peer(&node_addr).is_none(), + "peer must be removed from peers table at threshold" + ); + assert_eq!( + node.peer_count(), + 0, + "peer_count must be zero after eviction" + ); + assert!( + !node + .peers_by_index + .contains_key(&(transport_id, our_index.as_u32())), + "peers_by_index entry must be cleaned up at threshold" + ); +} diff --git a/src/node/tests/mod.rs b/src/node/tests/mod.rs index b087570..dbd1892 100644 --- a/src/node/tests/mod.rs +++ b/src/node/tests/mod.rs @@ -10,6 +10,7 @@ mod ble; mod bloom; mod bloom_poison; mod bootstrap; +mod decrypt_failure; mod disconnect; mod discovery; #[cfg(target_os = "linux")] diff --git a/src/peer/active.rs b/src/peer/active.rs index 819064c..942c278 100644 --- a/src/peer/active.rs +++ b/src/peer/active.rs @@ -1209,4 +1209,47 @@ mod tests { peer.reset_replay_suppressed(); assert_eq!(peer.reset_replay_suppressed(), 0); } + + #[test] + fn test_increment_decrypt_failures_monotonic() { + let identity = make_peer_identity(); + let mut peer = ActivePeer::new(identity, LinkId::new(1), 1000); + + // Initial count is zero + assert_eq!(peer.consecutive_decrypt_failures(), 0); + + // Each call returns a strictly increasing count + let mut prev = 0u32; + for expected in 1..=25u32 { + let count = peer.increment_decrypt_failures(); + assert_eq!(count, expected, "increment must return monotonic count"); + assert!(count > prev, "count must strictly increase"); + assert_eq!(peer.consecutive_decrypt_failures(), count); + prev = count; + } + } + + #[test] + fn test_reset_decrypt_failures_zeroes_counter() { + let identity = make_peer_identity(); + let mut peer = ActivePeer::new(identity, LinkId::new(1), 1000); + + // Drive counter up + for _ in 0..7 { + peer.increment_decrypt_failures(); + } + assert_eq!(peer.consecutive_decrypt_failures(), 7); + + // Reset zeroes it + peer.reset_decrypt_failures(); + assert_eq!(peer.consecutive_decrypt_failures(), 0); + + // Reset on zero is a no-op (still zero, no panic) + peer.reset_decrypt_failures(); + assert_eq!(peer.consecutive_decrypt_failures(), 0); + + // Counter resumes at 1 after reset + assert_eq!(peer.increment_decrypt_failures(), 1); + assert_eq!(peer.consecutive_decrypt_failures(), 1); + } }