From 05e3853ca12be07f5aea6d32eeecd1d5a43054dd Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Mon, 23 Feb 2026 19:56:12 +0000 Subject: [PATCH] Suppress repeated replay detection log messages during link transitions Add per-peer replay suppression counter to ActivePeer. Log the first 3 replay detections at DEBUG, then suppress with a one-time notice. Emit a summary count on session replacement or peer removal. Non-replay decryption errors continue to be logged unconditionally. --- src/node/handlers/dispatch.rs | 10 +++++++ src/node/handlers/encrypted.rs | 42 ++++++++++++++++++++++---- src/node/handlers/handshake.rs | 9 ++++++ src/peer/active.rs | 55 ++++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 6 deletions(-) diff --git a/src/node/handlers/dispatch.rs b/src/node/handlers/dispatch.rs index d16ea5f..59d1cdf 100644 --- a/src/node/handlers/dispatch.rs +++ b/src/node/handlers/dispatch.rs @@ -98,6 +98,16 @@ impl Node { } }; + // Log suppressed replay detection summary before teardown + let suppressed = peer.replay_suppressed_count(); + if suppressed > 0 { + debug!( + peer = %self.peer_display_name(node_addr), + count = suppressed, + "Suppressed replay detections during link transition" + ); + } + // MMP teardown log (before we drop the peer) if let Some(mmp) = peer.mmp() { let name = self.peer_aliases.get(node_addr) diff --git a/src/node/handlers/encrypted.rs b/src/node/handlers/encrypted.rs index 53c429e..502c5a3 100644 --- a/src/node/handlers/encrypted.rs +++ b/src/node/handlers/encrypted.rs @@ -1,5 +1,6 @@ //! Encrypted frame handling (hot path). +use crate::noise::NoiseError; use crate::node::Node; use crate::node::wire::{EncryptedHeader, strip_inner_header, FLAG_CE, FLAG_SP}; use crate::transport::ReceivedPacket; @@ -63,12 +64,41 @@ impl Node { ) { Ok(p) => p, Err(e) => { - debug!( - peer = %self.peer_display_name(&node_addr), - counter = header.counter, - error = %e, - "Decryption failed" - ); + if matches!(e, NoiseError::ReplayDetected(_)) { + // Suppress repeated replay detections during link transitions. + // Re-borrow peer mutably for suppression counter update. + if let Some(peer) = self.peers.get_mut(&node_addr) { + let count = peer.increment_replay_suppressed(); + if count <= 3 { + debug!( + peer = %self.peer_display_name(&node_addr), + counter = header.counter, + error = %e, + "Decryption failed" + ); + } else if count == 4 { + debug!( + peer = %self.peer_display_name(&node_addr), + "Suppressing further replay detection messages" + ); + } + // count > 4: silently suppress + } else { + debug!( + peer = %self.peer_display_name(&node_addr), + counter = header.counter, + error = %e, + "Decryption failed" + ); + } + } else { + debug!( + peer = %self.peer_display_name(&node_addr), + counter = header.counter, + error = %e, + "Decryption failed" + ); + } return; } }; diff --git a/src/node/handlers/handshake.rs b/src/node/handlers/handshake.rs index 9591ee6..a06cabb 100644 --- a/src/node/handlers/handshake.rs +++ b/src/node/handlers/handshake.rs @@ -450,6 +450,7 @@ impl Node { }; if let Some(peer) = self.peers.get_mut(&peer_node_addr) { + let suppressed = peer.replay_suppressed_count(); let old_our_index = peer.replace_session( outbound_session, outbound_our_index, @@ -467,6 +468,14 @@ impl Node { peer_node_addr, ); + if suppressed > 0 { + debug!( + peer = %self.peer_display_name(&peer_node_addr), + count = suppressed, + "Suppressed replay detections during link transition" + ); + } + info!( peer = %self.peer_display_name(&peer_node_addr), new_our_index = %outbound_our_index, diff --git a/src/peer/active.rs b/src/peer/active.rs index dd70d7e..cbe7fde 100644 --- a/src/peer/active.rs +++ b/src/peer/active.rs @@ -143,6 +143,10 @@ pub struct ActivePeer { /// Wire-format msg2 for resend on duplicate msg1 (responder only). /// Cleared after the handshake timeout window. handshake_msg2: Option>, + + // === Replay Detection Suppression === + /// Number of replay detections suppressed since last session reset. + replay_suppressed_count: u32, } impl ActivePeer { @@ -177,6 +181,7 @@ impl ActivePeer { mmp: None, last_heartbeat_sent: None, handshake_msg2: None, + replay_suppressed_count: 0, } } @@ -240,6 +245,7 @@ impl ActivePeer { mmp: Some(MmpPeerState::new(mmp_config, is_initiator)), last_heartbeat_sent: None, handshake_msg2: None, + replay_suppressed_count: 0, } } @@ -339,12 +345,14 @@ impl ActivePeer { /// This replaces the entire session so both nodes use matching keys. /// /// Returns the old our_index so the caller can update peers_by_index. + /// Also resets the replay suppression counter since the session changed. pub fn replace_session( &mut self, new_session: NoiseSession, new_our_index: SessionIndex, new_their_index: SessionIndex, ) -> Option { + self.reset_replay_suppressed(); let old_our_index = self.our_index; self.noise_session = Some(new_session); self.our_index = Some(new_our_index); @@ -387,6 +395,26 @@ impl ActivePeer { self.handshake_msg2 = None; } + // === Replay Detection Suppression === + + /// Increment replay suppression counter. Returns the new count. + pub fn increment_replay_suppressed(&mut self) -> u32 { + self.replay_suppressed_count += 1; + self.replay_suppressed_count + } + + /// Reset replay suppression counter, returning previous count. + pub fn reset_replay_suppressed(&mut self) -> u32 { + let count = self.replay_suppressed_count; + self.replay_suppressed_count = 0; + count + } + + /// Current replay suppression count. + pub fn replay_suppressed_count(&self) -> u32 { + self.replay_suppressed_count + } + // === Epoch Accessors === /// Get the remote peer's startup epoch (from handshake). @@ -794,4 +822,31 @@ mod tests { assert_eq!(peer.link_stats().packets_sent, 1); assert_eq!(peer.link_stats().packets_recv, 1); } + + #[test] + fn test_replay_suppression_counter() { + let identity = make_peer_identity(); + let mut peer = ActivePeer::new(identity, LinkId::new(1), 1000); + + // Initial count is zero + assert_eq!(peer.replay_suppressed_count(), 0); + + // Increment returns new count + assert_eq!(peer.increment_replay_suppressed(), 1); + assert_eq!(peer.increment_replay_suppressed(), 2); + assert_eq!(peer.increment_replay_suppressed(), 3); + assert_eq!(peer.replay_suppressed_count(), 3); + + // Reset returns previous count and zeroes it + assert_eq!(peer.reset_replay_suppressed(), 3); + assert_eq!(peer.replay_suppressed_count(), 0); + + // Can increment again after reset + assert_eq!(peer.increment_replay_suppressed(), 1); + assert_eq!(peer.replay_suppressed_count(), 1); + + // Reset when zero returns zero + peer.reset_replay_suppressed(); + assert_eq!(peer.reset_replay_suppressed(), 0); + } }