mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-09 16:24:45 +00:00
Pin consecutive decrypt-failure counter and threshold-20 force removal
Cover the security-relevant defensive signal: sustained decrypt failures indicate key drift or active probing; threshold-trip force- removes the peer. src/peer/active.rs (+43): two unit tests on the counter struct itself - test_increment_decrypt_failures_monotonic asserts each increment_decrypt_failures() call returns count+1 for at least 25 iterations - test_reset_decrypt_failures_zeroes_counter asserts the reset helper zeroes a non-zero counter and is idempotent src/node/tests/decrypt_failure.rs (new, 93 lines): end-to-end test - Builds a Node + connected peer via existing make_completed_connection / add_connection / promote_connection harness so peers_by_index is exercised, not just peers - Drives the peer to threshold-20 by calling handle_decrypt_failure 20 times; asserts iterations 1..20 leave the peer registered with monotonically increasing counter, then iteration 20 evicts from both peers and peers_by_index src/node/handlers/encrypted.rs: visibility-only widen on handle_decrypt_failure from private to pub(in crate::node) so the in-tree test can drive the threshold logic without re-implementing it. Same pattern as the already-pub(in crate::node) handle_encrypted_frame in the same file. Threshold pinned: DECRYPT_FAILURE_THRESHOLD = 20 at src/node/handlers/encrypted.rs:11.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ mod ble;
|
||||
mod bloom;
|
||||
mod bloom_poison;
|
||||
mod bootstrap;
|
||||
mod decrypt_failure;
|
||||
mod disconnect;
|
||||
mod discovery;
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user