//! End-to-end session state. //! //! Tracks Noise XK sessions between this node and remote endpoints. //! Sessions are established via a three-message XK handshake //! (SessionSetup/SessionAck/SessionMsg3) carried inside SessionDatagram //! envelopes through the mesh. use std::time::Instant; use crate::NodeAddr; use crate::config::SessionMmpConfig; use crate::mmp::MmpSessionState; use crate::node::REKEY_JITTER_SECS; use crate::noise::{HandshakeState, NoiseSession}; use rand::RngExt; use secp256k1::PublicKey; /// Draw a fresh per-session rekey jitter from `[-REKEY_JITTER_SECS, +REKEY_JITTER_SECS]`. fn draw_rekey_jitter() -> i64 { rand::rng().random_range(-REKEY_JITTER_SECS..=REKEY_JITTER_SECS) } /// State machine for an end-to-end session. /// /// `Established` is intentionally larger than the handshake variants: /// `NoiseSession` carries ring's `LessSafeKey` (×2, send + recv), each of /// which embeds the precomputed Poly1305 key + per-implementation AEAD /// state. That precomputation is exactly the win — it lets the per-packet /// AEAD skip key derivation and dispatch straight to NEON / AVX. Boxing /// the variant would add an allocation per session and double-indirection /// on every encrypt/decrypt, working against that win. #[allow(clippy::large_enum_variant)] pub(crate) enum EndToEndState { /// We initiated: sent SessionSetup with Noise XK msg1, awaiting SessionAck. Initiating(HandshakeState), /// XK responder: processed msg1, sent msg2, awaiting msg3. /// Transitions to Established when msg3 arrives. AwaitingMsg3(HandshakeState), /// Handshake complete, NoiseSession available for encrypt/decrypt. Established(NoiseSession), } /// Which key epoch a frame decrypted against in the trial-decrypt /// cascade. Reported by [`SessionEntry::fsp_trial_decrypt`] so the /// receive path can react to a non-`Current` epoch. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum EpochSlot { /// The current (active) session — steady-state traffic. Current, /// The pending (new, not yet promoted) session — the peer cut over /// before this endpoint did. Pending, /// The previous (draining) session — old-epoch stragglers. Previous, } impl EndToEndState { /// Check if the session is established and ready for data. pub(crate) fn is_established(&self) -> bool { matches!(self, EndToEndState::Established(_)) } /// Check if we are the initiator (waiting for ack). pub(crate) fn is_initiating(&self) -> bool { matches!(self, EndToEndState::Initiating(_)) } /// Check if we are an XK responder awaiting msg3. pub(crate) fn is_awaiting_msg3(&self) -> bool { matches!(self, EndToEndState::AwaitingMsg3(_)) } } /// A single end-to-end session with a remote node. /// /// The state is wrapped in `Option` to allow taking ownership of the /// handshake state during transitions without placeholder values. /// The state is `None` only transiently during handler processing. pub(crate) struct SessionEntry { /// Remote node's address (session table key). #[allow(dead_code)] remote_addr: NodeAddr, /// Remote node's static public key. remote_pubkey: PublicKey, /// Current session state. `None` only during state transitions. state: Option, /// When the session was created (Unix milliseconds). #[cfg_attr(not(test), allow(dead_code))] created_at: u64, /// Last application data activity timestamp (Unix milliseconds). /// Only updated for DataPacket send/receive and session establishment. /// MMP reports do not update this field. Used for idle session timeout. last_activity: u64, /// When the session transitioned to Established (Unix milliseconds). /// Used to compute session-relative timestamps for the FSP inner header. /// Set to 0 until the session is established. session_start_ms: u64, /// Remaining data packets that should include COORDS_PRESENT. /// Initialized from config when session becomes Established; /// reset on CoordsRequired receipt. coords_warmup_remaining: u8, /// Whether this node initiated the Noise handshake. /// Used for spin bit role assignment in session-layer MMP. is_initiator: bool, /// Session-layer MMP state. Initialized on Established transition. mmp: Option, // === Traffic Counters === /// Total data packets sent on this session. packets_sent: u64, /// Total data packets received on this session. packets_recv: u64, /// Total data bytes sent on this session (FSP payload). bytes_sent: u64, /// Total data bytes received on this session (FSP payload). bytes_recv: u64, // === Handshake Resend === /// Encoded session-layer payload for resend (SessionSetup or SessionAck). /// Cleared on Established transition. handshake_payload: Option>, /// Number of resends performed. resend_count: u32, /// When the next resend should fire (Unix ms). 0 = no resend scheduled. next_resend_at_ms: u64, // === Rekey (Key Rotation) === /// Current K-bit epoch value (alternates each rekey). current_k_bit: bool, /// Previous NoiseSession during drain window after cutover. previous_noise_session: Option, /// When drain window started (Unix ms). 0 = no drain. drain_started_ms: u64, /// Last time an inbound frame authenticated against the `previous` /// slot (Unix ms). 0 = the `previous` slot has not been used since /// the drain began. /// /// Drives peer-progress-aware retirement of the old epoch: the drain /// window does not expire until `DRAIN_WINDOW_SECS` have elapsed /// since the LATER of the cutover and the last `previous`-slot /// decrypt. The old epoch therefore lives as long as the peer is /// still transmitting on it — closing the rep-003 gap where the /// initiator erased the `previous` slot on a fixed wall-clock timer /// while the peer (having never received msg3) was still sealing /// every frame in the old epoch, leaving the trial-decrypt cascade /// with no slot that could decrypt them. previous_last_used_ms: u64, /// In-progress rekey state (runs alongside Established session). rekey_state: Option, /// Pending completed session awaiting K-bit cutover. pending_new_session: Option, /// Whether we initiated the current rekey. rekey_initiator: bool, /// Dampening: last time peer sent us a rekey msg1 (Unix ms). last_peer_rekey_ms: u64, /// When the FSP rekey handshake completed (initiator sent msg3, Unix ms). /// Drives the initiator's liveness-bound cutover timer. Cleared on /// cutover. The timer is no longer safety-critical: overlapping-epoch /// trial-decrypt covers any cutover skew. It only bounds how long the /// initiator advertises the old K-bit. rekey_completed_ms: u64, /// Encoded SessionMsg3 payload retained for retransmission (initiator). /// Set when the rekey initiator sends msg3; cleared once the responder /// is confirmed on the new epoch (an authenticated peer frame against /// `pending` or new `current`) or the rekey cycle is abandoned. /// Retransmission lifetime is tied to responder reception of msg3, /// decoupled from the initiator's own cutover. rekey_msg3_payload: Option>, /// When the next rekey msg3 retransmission should fire (Unix ms). /// 0 = no retransmission scheduled. rekey_msg3_next_resend_ms: u64, /// Number of rekey msg3 retransmissions performed so far. rekey_msg3_resend_count: u32, /// Whether the rekey peer has been observed on the new epoch — set /// when an inbound frame authenticates against `pending` or against /// the post-cutover `current`. Stops msg3 retransmission. peer_new_epoch_confirmed: bool, /// Per-session symmetric jitter applied to the rekey timer trigger. /// Drawn once at construction (and at each cutover) uniformly from /// `[-REKEY_JITTER_SECS, +REKEY_JITTER_SECS]`. Desynchronizes /// dual-initiation in symmetric-start meshes; mean interval is /// preserved. rekey_jitter_secs: i64, } impl SessionEntry { /// Create a new session entry. pub(crate) fn new( remote_addr: NodeAddr, remote_pubkey: PublicKey, state: EndToEndState, now_ms: u64, is_initiator: bool, ) -> Self { Self { remote_addr, remote_pubkey, state: Some(state), created_at: now_ms, last_activity: now_ms, session_start_ms: 0, coords_warmup_remaining: 0, is_initiator, mmp: None, packets_sent: 0, packets_recv: 0, bytes_sent: 0, bytes_recv: 0, handshake_payload: None, resend_count: 0, next_resend_at_ms: 0, current_k_bit: false, previous_noise_session: None, drain_started_ms: 0, previous_last_used_ms: 0, rekey_state: None, pending_new_session: None, rekey_initiator: false, last_peer_rekey_ms: 0, rekey_completed_ms: 0, rekey_msg3_payload: None, rekey_msg3_next_resend_ms: 0, rekey_msg3_resend_count: 0, peer_new_epoch_confirmed: false, rekey_jitter_secs: draw_rekey_jitter(), } } /// Get the remote node's public key. pub(crate) fn remote_pubkey(&self) -> &PublicKey { &self.remote_pubkey } /// Get the current session state. #[cfg(test)] pub(crate) fn state(&self) -> &EndToEndState { self.state .as_ref() .expect("session state taken but not restored") } /// Get mutable access to the session state. pub(crate) fn state_mut(&mut self) -> &mut EndToEndState { self.state .as_mut() .expect("session state taken but not restored") } /// Replace the session state. pub(crate) fn set_state(&mut self, state: EndToEndState) { self.state = Some(state); } /// Take the state out, leaving `None`. /// /// The caller must call `set_state()` to restore a valid state, /// or discard the entry entirely. pub(crate) fn take_state(&mut self) -> Option { self.state.take() } /// Update the last application data activity timestamp. /// /// Only call for DataPacket send/receive and session establishment, /// not for MMP reports. Used by the idle session timeout. pub(crate) fn touch(&mut self, now_ms: u64) { self.last_activity = now_ms; } /// Check if the session is established. pub(crate) fn is_established(&self) -> bool { self.state.as_ref().is_some_and(|s| s.is_established()) } /// Check if we are the initiator (waiting for ack). pub(crate) fn is_initiating(&self) -> bool { self.state.as_ref().is_some_and(|s| s.is_initiating()) } /// Check if we are an XK responder awaiting msg3. pub(crate) fn is_awaiting_msg3(&self) -> bool { self.state.as_ref().is_some_and(|s| s.is_awaiting_msg3()) } /// Get creation time. #[cfg(test)] pub(crate) fn created_at(&self) -> u64 { self.created_at } /// Get last activity time. pub(crate) fn last_activity(&self) -> u64 { self.last_activity } /// Remaining DataPackets that should include COORDS_PRESENT. pub(crate) fn coords_warmup_remaining(&self) -> u8 { self.coords_warmup_remaining } /// Set the coords warmup counter (used on Established transition /// and CoordsRequired reset). pub(crate) fn set_coords_warmup_remaining(&mut self, value: u8) { self.coords_warmup_remaining = value; } /// Mark the session as started (transition to Established). /// /// Records the current time as the session start for computing /// session-relative timestamps in the FSP inner header. pub(crate) fn mark_established(&mut self, now_ms: u64) { self.session_start_ms = now_ms; } /// Compute a session-relative timestamp for the FSP inner header. /// /// Returns `(now_ms - session_start_ms)` truncated to u32. /// Wraps naturally at ~49.7 days, which is fine for relative timing. pub(crate) fn session_timestamp(&self, now_ms: u64) -> u32 { now_ms.wrapping_sub(self.session_start_ms) as u32 } /// Whether this node initiated the Noise handshake. #[cfg_attr(not(test), allow(dead_code))] pub(crate) fn is_initiator(&self) -> bool { self.is_initiator } /// Get a reference to the session-layer MMP state, if initialized. pub(crate) fn mmp(&self) -> Option<&MmpSessionState> { self.mmp.as_ref() } /// Get a mutable reference to the session-layer MMP state, if initialized. pub(crate) fn mmp_mut(&mut self) -> Option<&mut MmpSessionState> { self.mmp.as_mut() } /// Initialize session-layer MMP state (called on Established transition). pub(crate) fn init_mmp(&mut self, config: &SessionMmpConfig) { self.mmp = Some(MmpSessionState::new(config, self.is_initiator)); } // === Traffic Counters === /// Record a sent data packet. pub(crate) fn record_sent(&mut self, bytes: usize) { self.packets_sent += 1; self.bytes_sent += bytes as u64; } /// Record a received data packet. pub(crate) fn record_recv(&mut self, bytes: usize) { self.packets_recv += 1; self.bytes_recv += bytes as u64; } /// Get traffic counters: (packets_sent, packets_recv, bytes_sent, bytes_recv). pub(crate) fn traffic_counters(&self) -> (u64, u64, u64, u64) { ( self.packets_sent, self.packets_recv, self.bytes_sent, self.bytes_recv, ) } // === Handshake Resend === /// Store the encoded session-layer payload for potential resend. /// /// For initiators, this is the SessionSetup payload bytes. /// For responders, this is the SessionAck payload bytes. /// The payload is re-wrapped in a fresh SessionDatagram on each resend /// so routing can adapt to topology changes. pub(crate) fn set_handshake_payload(&mut self, payload: Vec, next_resend_at_ms: u64) { self.handshake_payload = Some(payload); self.resend_count = 0; self.next_resend_at_ms = next_resend_at_ms; } /// Get the stored handshake payload for resend. pub(crate) fn handshake_payload(&self) -> Option<&[u8]> { self.handshake_payload.as_deref() } /// Clear the stored handshake payload (called on Established transition). pub(crate) fn clear_handshake_payload(&mut self) { self.handshake_payload = None; self.next_resend_at_ms = 0; } /// Number of resends performed so far. pub(crate) fn resend_count(&self) -> u32 { self.resend_count } /// When the next resend should fire (Unix ms). 0 = no resend scheduled. pub(crate) fn next_resend_at_ms(&self) -> u64 { self.next_resend_at_ms } /// Record a resend and schedule the next one. pub(crate) fn record_resend(&mut self, next_resend_at_ms: u64) { self.resend_count += 1; self.next_resend_at_ms = next_resend_at_ms; } // === Rekey (Key Rotation) === /// Current K-bit epoch value. pub(crate) fn current_k_bit(&self) -> bool { self.current_k_bit } /// Whether a rekey is currently in progress. pub(crate) fn has_rekey_in_progress(&self) -> bool { self.rekey_state.is_some() } /// Get the pending new session (completed rekey, not yet cut over). pub(crate) fn pending_new_session(&self) -> Option<&NoiseSession> { self.pending_new_session.as_ref() } /// Whether we initiated the current rekey. pub(crate) fn is_rekey_initiator(&self) -> bool { self.rekey_initiator } /// Check if rekey initiation is dampened. pub(crate) fn is_rekey_dampened(&self, now_ms: u64, dampening_ms: u64) -> bool { if self.last_peer_rekey_ms == 0 { return false; } now_ms.saturating_sub(self.last_peer_rekey_ms) < dampening_ms } /// Record that the peer initiated a rekey (for dampening). pub(crate) fn record_peer_rekey(&mut self, now_ms: u64) { self.last_peer_rekey_ms = now_ms; } /// When the session transitioned to Established (for rekey timer). pub(crate) fn session_start_ms(&self) -> u64 { self.session_start_ms } /// Get the current send counter from the established NoiseSession. pub(crate) fn send_counter(&self) -> u64 { match self.state.as_ref() { Some(EndToEndState::Established(s)) => s.current_send_counter(), _ => 0, } } /// When the FSP rekey handshake completed (initiator sent msg3). pub(crate) fn rekey_completed_ms(&self) -> u64 { self.rekey_completed_ms } /// Per-session symmetric rekey-timer jitter offset (seconds). /// /// Drawn at session construction and at each rekey cutover; uniform /// over `[-REKEY_JITTER_SECS, +REKEY_JITTER_SECS]`. Callers add this /// to the configured `node.rekey.after_secs` to obtain the effective /// trigger interval for this session. pub(crate) fn rekey_jitter_secs(&self) -> i64 { self.rekey_jitter_secs } /// Record when the FSP rekey handshake completed (initiator side). pub(crate) fn set_rekey_completed_ms(&mut self, ms: u64) { self.rekey_completed_ms = ms; } // === Rekey msg3 Retransmission (Initiator, liveness-only) === /// Retain the encoded SessionMsg3 payload for retransmission and /// schedule the first resend. Called by the rekey initiator after /// sending msg3. /// /// Retransmission is a liveness mechanism: the responder must /// eventually receive msg3 to derive the new session. It runs until /// the peer is confirmed on the new epoch, independent of the /// initiator's own cutover. pub(crate) fn set_rekey_msg3_payload(&mut self, payload: Vec, next_resend_at_ms: u64) { self.rekey_msg3_payload = Some(payload); self.rekey_msg3_next_resend_ms = next_resend_at_ms; self.rekey_msg3_resend_count = 0; self.peer_new_epoch_confirmed = false; } /// Get the retained rekey msg3 payload for retransmission. pub(crate) fn rekey_msg3_payload(&self) -> Option<&[u8]> { self.rekey_msg3_payload.as_deref() } /// When the next rekey msg3 retransmission should fire (Unix ms). pub(crate) fn rekey_msg3_next_resend_ms(&self) -> u64 { self.rekey_msg3_next_resend_ms } /// Number of rekey msg3 retransmissions performed so far. pub(crate) fn rekey_msg3_resend_count(&self) -> u32 { self.rekey_msg3_resend_count } /// Record a rekey msg3 retransmission and schedule the next one. pub(crate) fn record_rekey_msg3_resend(&mut self, next_resend_at_ms: u64) { self.rekey_msg3_resend_count += 1; self.rekey_msg3_next_resend_ms = next_resend_at_ms; } /// Clear the retained rekey msg3 payload (responder confirmed on the /// new epoch, or the cycle abandoned). pub(crate) fn clear_rekey_msg3_payload(&mut self) { self.rekey_msg3_payload = None; self.rekey_msg3_next_resend_ms = 0; self.rekey_msg3_resend_count = 0; } /// Whether the rekey peer has been observed on the new epoch. #[cfg(test)] pub(crate) fn peer_new_epoch_confirmed(&self) -> bool { self.peer_new_epoch_confirmed } /// Mark the rekey peer as confirmed on the new epoch and stop msg3 /// retransmission. Called when an inbound frame authenticates against /// the `pending` or post-cutover `current` session. pub(crate) fn confirm_peer_new_epoch(&mut self) { self.peer_new_epoch_confirmed = true; self.clear_rekey_msg3_payload(); } // === Overlapping-epoch trial-decrypt slots === /// Mutable access to the current (active) `NoiseSession`, if established. pub(crate) fn current_noise_session_mut(&mut self) -> Option<&mut NoiseSession> { match self.state.as_mut() { Some(EndToEndState::Established(s)) => Some(s), _ => None, } } /// Trial-decrypt an encrypted FSP frame against every live key epoch. /// /// During a rekey window an endpoint may legitimately receive frames /// sealed in up to three epochs: `current` (steady state), `pending` /// (the peer cut over before this endpoint did), and `previous` /// (drain-window stragglers sealed in the old epoch). This cascade /// makes the receive path able to decrypt any of them, so no cutover /// ordering and no packet reordering can cause a decrypt failure. /// /// The received K-bit is an ordering hint only — it selects which /// slot to try first to save one cheap `check()` rejection on the /// common cut-over-in-progress packet. Correctness never depends on /// it. /// /// Continues the cascade on **any** error, including /// `ReplayDetected`: a replay rejection from a non-matching slot is /// just "wrong key, try the next one." This is replay-safe by /// construction — `decrypt_with_replay_check_and_aad` only mutates a /// slot's `ReplayWindow` after a successful decrypt, so a failed /// trial against the wrong slot leaves that slot untouched. Only the /// slot that authenticates the packet advances its window. /// /// A successful decrypt against the `previous` slot refreshes the /// drain deadline (`refresh_previous_use`): the old epoch must stay /// retained as long as the peer is still sealing frames in it. See /// [`SessionEntry::drain_expired`] for why. /// /// Returns the plaintext plus the slot it decrypted against, or /// `None` if every live slot failed (a genuine drop). pub(crate) fn fsp_trial_decrypt( &mut self, ciphertext: &[u8], counter: u64, aad: &[u8], received_k_bit: bool, now_ms: u64, ) -> Option<(Vec, EpochSlot)> { // Hint: if the received K-bit differs from our current epoch and // we hold a pending session, the peer has likely cut over — try // `pending` first. Pure optimisation; the cascade still tries // every slot regardless. let pending_first = received_k_bit != self.current_k_bit && self.pending_new_session.is_some(); let order: [EpochSlot; 3] = if pending_first { [EpochSlot::Pending, EpochSlot::Current, EpochSlot::Previous] } else { [EpochSlot::Current, EpochSlot::Pending, EpochSlot::Previous] }; for slot in order { let session = match slot { EpochSlot::Current => self.current_noise_session_mut(), EpochSlot::Pending => self.pending_new_session.as_mut(), EpochSlot::Previous => self.previous_noise_session.as_mut(), }; if let Some(session) = session && let Ok(pt) = session.decrypt_with_replay_check_and_aad(ciphertext, counter, aad) { // The peer is still transmitting on the old epoch: // push the drain deadline out so the `previous` slot // is not retired out from under a peer still using it. if slot == EpochSlot::Previous { self.refresh_previous_use(now_ms); } return Some((pt, slot)); } } None } /// Store a completed rekey session. pub(crate) fn set_pending_session(&mut self, session: NoiseSession) { self.pending_new_session = Some(session); self.rekey_state = None; } /// Set the rekey handshake state (in-progress XK handshake). pub(crate) fn set_rekey_state(&mut self, state: HandshakeState, is_initiator: bool) { self.rekey_state = Some(state); self.rekey_initiator = is_initiator; } /// Take the rekey state for processing. pub(crate) fn take_rekey_state(&mut self) -> Option { self.rekey_state.take() } /// Promote the pending session to current (the cutover). /// /// Moves the current session to `previous` (for drain), promotes /// `pending` to `current`, flips the K-bit, stamps the drain window. /// Shared by both cutover triggers: the initiator's liveness timer /// (`cutover_to_new_session`) and a successful `pending` trial-decrypt /// (`handle_peer_kbit_flip`). fn promote_pending(&mut self, now_ms: u64) -> bool { let new_session = match self.pending_new_session.take() { Some(s) => s, None => return false, }; // Demote current to previous for drain if let Some(EndToEndState::Established(old)) = self.state.take() { self.previous_noise_session = Some(old); } self.drain_started_ms = now_ms; // Fresh drain: no `previous`-slot use observed yet. The drain // deadline starts from the cutover and is pushed out by any // subsequent `previous`-slot decrypt. self.previous_last_used_ms = 0; // Promote pending to current self.state = Some(EndToEndState::Established(new_session)); self.current_k_bit = !self.current_k_bit; self.session_start_ms = now_ms; self.rekey_state = None; self.rekey_initiator = false; self.rekey_completed_ms = 0; self.rekey_jitter_secs = draw_rekey_jitter(); // Reset MMP counters to avoid metric discontinuity let now = Instant::now(); if let Some(mmp) = &mut self.mmp { mmp.reset_for_rekey(now); } true } /// Cut over to the pending new session on the initiator's liveness /// timer. /// /// This is the unconditional timer-driven cutover (option A). It is /// safe on any schedule: overlapping-epoch trial-decrypt covers the /// skew between the two endpoints' cutovers. It does NOT confirm the /// peer is on the new epoch — the responder may still be awaiting /// msg3 — so msg3 retransmission must continue past this cutover. pub(crate) fn cutover_to_new_session(&mut self, now_ms: u64) -> bool { self.promote_pending(now_ms) } /// Promote the pending session because a peer frame authenticated /// against it. /// /// A frame that decrypts against `pending` is itself the cutover /// signal — proof the peer derived the new session and moved to it. /// This unifies the trigger: the header K-bit is only a hint, the /// authenticated decrypt is the actual proof. Used by the /// trial-decrypt cascade on a successful `pending` decrypt. pub(crate) fn handle_peer_kbit_flip(&mut self, now_ms: u64) -> bool { self.promote_pending(now_ms) } /// Check if the drain window has expired. /// /// Peer-progress-aware: the deadline is `drain_ms` after the LATER /// of the cutover (`drain_started_ms`) and the last inbound frame /// that authenticated against the `previous` slot /// (`previous_last_used_ms`). The old epoch is therefore retired /// only once the peer has been silent on it for a full drain /// window — never while the peer is still transmitting on it. /// /// This closes the rep-003 gap: a fixed wall-clock drain timer, /// started unilaterally at the initiator's Option-A cutover, would /// erase the `previous` slot 10 s later even if the peer (having /// lost msg3) was still sealing every frame in the old epoch — the /// trial-decrypt cascade then had no slot to decrypt them, a /// permanent silent decrypt failure. A peer that never catches up /// is instead handled by the FSP session liveness path (fresh /// handshake / teardown of a genuinely dead link). pub(crate) fn drain_expired(&self, now_ms: u64, drain_ms: u64) -> bool { if self.drain_started_ms == 0 { return false; } let deadline_anchor = self.drain_started_ms.max(self.previous_last_used_ms); now_ms.saturating_sub(deadline_anchor) >= drain_ms } /// Whether a drain is in progress. pub(crate) fn is_draining(&self) -> bool { self.drain_started_ms > 0 } /// Refresh the drain deadline because an inbound frame authenticated /// against the `previous` slot — the peer is still using the old /// epoch, so it must stay retained. No-op if no drain is in /// progress (a `previous` slot installed only for test purposes). pub(crate) fn refresh_previous_use(&mut self, now_ms: u64) { if self.drain_started_ms > 0 { self.previous_last_used_ms = now_ms; } } /// Complete the drain: drop previous session. pub(crate) fn complete_drain(&mut self) { self.previous_noise_session = None; self.drain_started_ms = 0; self.previous_last_used_ms = 0; } /// Abandon an in-progress rekey. /// /// Drops the in-flight handshake state, the pending session, and any /// retained msg3 retransmission payload, returning the entry to a /// clean `Established` state. Safe under overlapping-epoch decrypt: /// an abandoned cycle never leaves a divergent unsafe state — the /// endpoints simply stay on `current` until a later cycle completes. pub(crate) fn abandon_rekey(&mut self) { self.rekey_state = None; self.pending_new_session = None; self.rekey_initiator = false; self.rekey_completed_ms = 0; self.clear_rekey_msg3_payload(); self.peer_new_epoch_confirmed = false; } // === Test-only helpers === /// Install a session directly in the `previous` (draining) slot. #[cfg(test)] pub(crate) fn set_previous_session_for_test(&mut self, session: NoiseSession, now_ms: u64) { self.previous_noise_session = Some(session); self.drain_started_ms = now_ms; } /// Read the highest received counter of the `previous` slot, if any. #[cfg(test)] pub(crate) fn previous_highest_counter(&self) -> Option { self.previous_noise_session .as_ref() .map(|s| s.highest_received_counter()) } /// Read the highest received counter of the `pending` slot, if any. #[cfg(test)] pub(crate) fn pending_highest_counter(&self) -> Option { self.pending_new_session .as_ref() .map(|s| s.highest_received_counter()) } /// Read the highest received counter of the `current` slot, if /// established. #[cfg(test)] pub(crate) fn current_highest_counter(&self) -> Option { match self.state.as_ref() { Some(EndToEndState::Established(s)) => Some(s.highest_received_counter()), _ => None, } } } #[cfg(test)] mod overlapping_epoch_tests { use super::*; use crate::node::session_wire::{FSP_FLAG_K, build_fsp_header}; use crate::noise::HandshakeState; use secp256k1::{Keypair, Secp256k1, SecretKey}; /// Deterministic keypair from a single seed byte. fn keypair(seed: u8) -> Keypair { let secp = Secp256k1::new(); let mut bytes = [1u8; 32]; bytes[0] = seed; let sk = SecretKey::from_slice(&bytes).expect("valid secret key"); Keypair::from_secret_key(&secp, &sk) } /// Run a full XK handshake and return `(initiator_session, /// responder_session)` — a paired sender/receiver. fn xk_pair(init_seed: u8, resp_seed: u8) -> (NoiseSession, NoiseSession) { let init_kp = keypair(init_seed); let resp_kp = keypair(resp_seed); let mut initiator = HandshakeState::new_xk_initiator(init_kp, resp_kp.public_key()); initiator.set_local_epoch([0xA1, 0xB2, 0xC3, 0xD4, 0x11, 0x22, 0x33, 0x44]); let mut responder = HandshakeState::new_xk_responder(resp_kp); responder.set_local_epoch([0xD4, 0xC3, 0xB2, 0xA1, 0x44, 0x33, 0x22, 0x11]); let msg1 = initiator.write_xk_message_1().unwrap(); responder.read_xk_message_1(&msg1).unwrap(); let msg2 = responder.write_xk_message_2().unwrap(); initiator.read_xk_message_2(&msg2).unwrap(); let msg3 = initiator.write_xk_message_3().unwrap(); responder.read_xk_message_3(&msg3).unwrap(); ( initiator.into_session().unwrap(), responder.into_session().unwrap(), ) } /// Seal an FSP frame: returns `(ciphertext, counter, header_bytes, /// k_bit)` exactly as the send path produces them. fn seal(sender: &mut NoiseSession, plaintext: &[u8], k_bit: bool) -> (Vec, u64, [u8; 12]) { let counter = sender.current_send_counter(); let flags = if k_bit { FSP_FLAG_K } else { 0 }; let header = build_fsp_header(counter, flags, plaintext.len() as u16); let ciphertext = sender.encrypt_with_aad(plaintext, &header).unwrap(); (ciphertext, counter, header) } /// Build a `SessionEntry` whose `current` slot is `session`. fn entry_with_current(session: NoiseSession) -> SessionEntry { let addr = NodeAddr::from_bytes([7u8; 16]); let pubkey = keypair(99).public_key(); let mut entry = SessionEntry::new( addr, pubkey, EndToEndState::Established(session), 1_000, true, ); entry.mark_established(1_000); entry } // 1. A frame sealed in `current` decrypts against `current`; the // `pending` and `previous` windows are left untouched. #[test] fn trial_decrypt_picks_current() { let (mut cur_send, cur_recv) = xk_pair(1, 2); let (_p_send, p_recv) = xk_pair(3, 4); let (_o_send, o_recv) = xk_pair(5, 6); let mut entry = entry_with_current(cur_recv); entry.set_pending_session(p_recv); entry.set_previous_session_for_test(o_recv, 1_000); let (ct, counter, hdr) = seal(&mut cur_send, b"steady-state", false); let (pt, slot) = entry .fsp_trial_decrypt(&ct, counter, &hdr, false, 2_000) .expect("current frame must decrypt"); assert_eq!(pt, b"steady-state"); assert_eq!(slot, EpochSlot::Current); assert_eq!(entry.pending_highest_counter(), Some(0)); assert_eq!(entry.previous_highest_counter(), Some(0)); } // 2. A frame sealed in `pending` decrypts via the cascade and the // entry promotes pending -> current, current -> previous, K-bit // flips. #[test] fn trial_decrypt_picks_pending_and_promotes() { let (_cur_send, cur_recv) = xk_pair(1, 2); let (mut p_send, p_recv) = xk_pair(3, 4); let mut entry = entry_with_current(cur_recv); let k_before = entry.current_k_bit(); entry.set_pending_session(p_recv); // Peer sealed in the new epoch with the flipped K-bit. let (ct, counter, hdr) = seal(&mut p_send, b"new-epoch", !k_before); let (pt, slot) = entry .fsp_trial_decrypt(&ct, counter, &hdr, !k_before, 2_000) .expect("pending frame must decrypt"); assert_eq!(pt, b"new-epoch"); assert_eq!(slot, EpochSlot::Pending); // Receive path promotes on a pending hit. entry.handle_peer_kbit_flip(2_000); assert!(entry.pending_new_session().is_none()); assert!(entry.previous_highest_counter().is_some()); assert_ne!(entry.current_k_bit(), k_before); } // 3. After cutover, an old-epoch frame decrypts against `previous` // with no state change. #[test] fn trial_decrypt_picks_previous_during_drain() { let (mut old_send, old_recv) = xk_pair(1, 2); let (_new_send, new_recv) = xk_pair(3, 4); // Start with the old session, install the new as pending, cut over. let mut entry = entry_with_current(new_recv); entry.set_previous_session_for_test(old_recv, 1_500); let k_after = entry.current_k_bit(); // Old-epoch straggler still in flight after our cutover. let (ct, counter, hdr) = seal(&mut old_send, b"old-straggler", !k_after); let (pt, slot) = entry .fsp_trial_decrypt(&ct, counter, &hdr, !k_after, 3_000) .expect("previous frame must decrypt"); assert_eq!(pt, b"old-straggler"); assert_eq!(slot, EpochSlot::Previous); // No promotion, no K-bit change. assert_eq!(entry.current_k_bit(), k_after); assert!(entry.is_draining()); } // 4. After a pending-driven promotion, an old-epoch straggler still // decrypts (against the now-`previous` slot) — the reordering // case that broke attempt 1. #[test] fn trial_decrypt_reordered_old_after_cutover() { let (mut cur_send, cur_recv) = xk_pair(1, 2); let (mut p_send, p_recv) = xk_pair(3, 4); let mut entry = entry_with_current(cur_recv); let k_before = entry.current_k_bit(); entry.set_pending_session(p_recv); // New-epoch frame promotes the entry. let (ct_new, c_new, hdr_new) = seal(&mut p_send, b"after-cutover", !k_before); let (_pt, slot) = entry .fsp_trial_decrypt(&ct_new, c_new, &hdr_new, !k_before, 2_000) .unwrap(); assert_eq!(slot, EpochSlot::Pending); entry.handle_peer_kbit_flip(2_000); // Now an OLD-epoch straggler arrives reordered after cutover. let (ct_old, c_old, hdr_old) = seal(&mut cur_send, b"reordered-old", k_before); let (pt, slot) = entry .fsp_trial_decrypt(&ct_old, c_old, &hdr_old, k_before, 2_500) .expect("reordered old-epoch frame must still decrypt"); assert_eq!(pt, b"reordered-old"); assert_eq!(slot, EpochSlot::Previous); } // 5. A genuine replay of a `current`-epoch counter is rejected (all // slots fail) and does not corrupt the other windows; a `current` // replay while `pending` holds the real key still authenticates // against `pending`. #[test] fn trial_decrypt_replay_is_per_slot() { let (mut cur_send, cur_recv) = xk_pair(1, 2); let (mut p_send, p_recv) = xk_pair(3, 4); let mut entry = entry_with_current(cur_recv); let k_before = entry.current_k_bit(); entry.set_pending_session(p_recv); // First delivery on `current`. let (ct, counter, hdr) = seal(&mut cur_send, b"first", k_before); let (_pt, slot) = entry .fsp_trial_decrypt(&ct, counter, &hdr, k_before, 2_000) .unwrap(); assert_eq!(slot, EpochSlot::Current); // Replaying the exact same `current` frame: all slots fail. assert!( entry .fsp_trial_decrypt(&ct, counter, &hdr, k_before, 2_100) .is_none(), "a genuine replay must be rejected by every slot" ); // The pending window must not have been corrupted by the failed // trials. assert_eq!(entry.pending_highest_counter(), Some(0)); // A pending-epoch frame sharing counter 0 with `current` (whose // counter 0 is already consumed) still authenticates against // `pending`. let (ct_p, c_p, hdr_p) = seal(&mut p_send, b"pending-c0", !k_before); assert_eq!(c_p, 0); let (pt, slot) = entry .fsp_trial_decrypt(&ct_p, c_p, &hdr_p, !k_before, 2_200) .expect("pending frame must decrypt despite current replay overlap"); assert_eq!(pt, b"pending-c0"); assert_eq!(slot, EpochSlot::Pending); } // 6. A failed trial against a non-winning slot leaves that slot's // ReplayWindow::highest() unchanged (the section-4 invariant). #[test] fn trial_decrypt_failed_slot_leaves_replay_window_intact() { let (_cur_send, cur_recv) = xk_pair(1, 2); let (mut p_send, p_recv) = xk_pair(3, 4); let (_o_send, o_recv) = xk_pair(5, 6); let mut entry = entry_with_current(cur_recv); let k_before = entry.current_k_bit(); entry.set_pending_session(p_recv); entry.set_previous_session_for_test(o_recv, 1_000); // Advance the pending sender so its frame carries a non-zero // counter — the cascade will still try (and fail) `current` and // `previous` first. for _ in 0..4 { let _ = seal(&mut p_send, b"warmup", !k_before); } let (ct, counter, hdr) = seal(&mut p_send, b"pending-hit", !k_before); assert_eq!(counter, 4); let (_pt, slot) = entry .fsp_trial_decrypt(&ct, counter, &hdr, false, 2_000) // hint says "current first" .expect("pending frame must decrypt"); assert_eq!(slot, EpochSlot::Pending); // current and previous were tried and failed: their windows must // be untouched (highest still 0). assert_eq!(entry.current_highest_counter(), Some(0)); assert_eq!(entry.previous_highest_counter(), Some(0)); // Only the winning slot advanced. assert_eq!(entry.pending_highest_counter(), Some(4)); } // 7. The retained msg3 payload is cleared once a peer frame // authenticates against `pending`/new-`current`, not at the // initiator's own cutover. #[test] fn msg3_retransmit_stops_on_peer_new_epoch_confirmed() { let (_cur_send, cur_recv) = xk_pair(1, 2); let (mut p_send, p_recv) = xk_pair(3, 4); let mut entry = entry_with_current(cur_recv); entry.set_pending_session(p_recv); entry.set_rekey_completed_ms(1_000); entry.set_rekey_msg3_payload(vec![0xAB; 73], 1_500); // The initiator's own liveness-timer cutover must NOT clear the // retained msg3 payload — the responder may not have it yet. assert!(entry.cutover_to_new_session(2_000)); assert!( entry.rekey_msg3_payload().is_some(), "cutover alone must not stop msg3 retransmission" ); assert!(!entry.peer_new_epoch_confirmed()); // A peer frame authenticated against the post-cutover `current` // (new epoch) confirms the responder and clears the payload. let (ct, counter, hdr) = seal(&mut p_send, b"peer-on-new-epoch", entry.current_k_bit()); let k_now = entry.current_k_bit(); let (_pt, slot) = entry .fsp_trial_decrypt(&ct, counter, &hdr, k_now, 2_500) .unwrap(); assert_eq!(slot, EpochSlot::Current); // Receive-path logic: current hit + retained payload + no pending // => responder confirmed. assert!(entry.rekey_msg3_payload().is_some() && entry.pending_new_session().is_none()); entry.confirm_peer_new_epoch(); assert!(entry.peer_new_epoch_confirmed()); assert!(entry.rekey_msg3_payload().is_none()); } // 8. After exhausting the retransmission budget, abandon_rekey runs // and the entry is a clean Established with no pending. #[test] fn msg3_retransmit_budget_exhaustion_abandons_cleanly() { let (_cur_send, cur_recv) = xk_pair(1, 2); let (_p_send, p_recv) = xk_pair(3, 4); let mut entry = entry_with_current(cur_recv); entry.set_pending_session(p_recv); entry.set_rekey_completed_ms(1_000); entry.set_rekey_msg3_payload(vec![0xCD; 73], 1_500); // Simulate the resend driver exhausting its budget. let max_resends = 8; for i in 0..max_resends { entry.record_rekey_msg3_resend(2_000 + i as u64 * 100); } assert_eq!(entry.rekey_msg3_resend_count(), max_resends); // Budget exhausted -> abandon. entry.abandon_rekey(); assert!(entry.rekey_msg3_payload().is_none()); assert!(entry.pending_new_session().is_none()); assert!(!entry.has_rekey_in_progress()); assert!(entry.is_established()); assert!(!entry.peer_new_epoch_confirmed()); } // 9. The initiator cuts over on its timer while the responder has // not; both directions still decrypt (overlapping epochs). // // Naming: in each `xk_pair` the `.0` session is node A's view and // `.1` is node B's view; they seal frames the other one decrypts. A // is the rekey initiator, B the responder. #[test] fn initiator_cutover_safe_before_responder() { // Pre-rekey ("old") epoch and post-rekey ("new") epoch pairs. let (old_a, old_b) = xk_pair(1, 2); let (new_a, mut new_b) = xk_pair(3, 4); // Node A (initiator): current = old_a, pending = new_a, retains a // msg3 payload, then cuts over on its own liveness timer before // the responder has received msg3. let mut a = entry_with_current(old_a); a.set_rekey_completed_ms(1_000); a.set_rekey_msg3_payload(vec![0xEE; 73], 1_500); a.set_pending_session(new_a); assert!(a.cutover_to_new_session(2_000)); // A now: current = new_a, previous = old_a, K-bit flipped, msg3 // payload still retained (responder not yet confirmed). assert!(a.rekey_msg3_payload().is_some()); // Node B (responder): still entirely on the old epoch — no // pending, msg3 not yet received. B's `current` slot is `old_b`. let mut b = entry_with_current(old_b); // A -> B sealed in the NEW epoch (B has no pending slot yet). // This is the one residual liveness-bounded drop, closed by msg3 // retransmission. Confirm it is a clean drop, not a panic. let (ct_new, c_new, hdr_new) = seal(&mut new_b, b"new-from-a", true); assert!( b.fsp_trial_decrypt(&ct_new, c_new, &hdr_new, true, 2_100) .is_none(), "responder without msg3 drops the new-epoch frame cleanly" ); // B -> A still sealed in the OLD epoch (B seals from its own // `current` slot). A kept the old session as `previous`, so it // decrypts fine despite the cutover skew. let (ct_old, c_old, hdr_old) = { let b_old = b.current_noise_session_mut().unwrap(); seal(b_old, b"old-from-b", false) }; let (pt, slot) = a .fsp_trial_decrypt(&ct_old, c_old, &hdr_old, false, 2_200) .expect("initiator must still decrypt the responder's old-epoch frame"); assert_eq!(pt, b"old-from-b"); assert_eq!(slot, EpochSlot::Previous); // Once B receives msg3 it derives the new session as pending; A's // new-epoch frames then decrypt and drive B's promotion. let (new_a2, mut new_b2) = xk_pair(3, 4); b.set_pending_session(new_a2); let (ct_new2, c_new2, hdr_new2) = seal(&mut new_b2, b"new-from-a-2", true); let (pt, slot) = b .fsp_trial_decrypt(&ct_new2, c_new2, &hdr_new2, true, 2_300) .expect("responder must decrypt new-epoch frame once pending is installed"); assert_eq!(pt, b"new-from-a-2"); assert_eq!(slot, EpochSlot::Pending); } // 10. Drain-window expiry is peer-progress-aware (the rep-003 // scenario). After the initiator cuts over, a responder that // never received msg3 keeps transmitting on the OLD epoch. The // `previous` slot must NOT be retired while those old-epoch // frames keep decrypting — even well past the 10 s fixed // window — and must be retired once the peer finally goes // silent on the old epoch. #[test] fn drain_expiry_is_peer_progress_aware() { const DRAIN_MS: u64 = 10_000; let cutover_ms = 1_000; // Build the post-cutover state via the production cutover path: // start with the old session as `current`, the new session as // `pending`, then cut over so `current` = new epoch, `previous` // = old epoch, and the drain clock is stamped at the cutover. let (mut old_send, old_recv) = xk_pair(1, 2); let (_new_send, new_recv) = xk_pair(3, 4); let mut entry = entry_with_current(old_recv); entry.set_pending_session(new_recv); assert!(entry.cutover_to_new_session(cutover_ms)); assert!(entry.is_draining()); // The peer (responder) is still on the OLD epoch — it never // received msg3. Each old-epoch frame must decrypt against // `previous` and push the drain deadline out. Deliver frames at // t = 5s, 15s, 25s — the latter two are well past the fixed // 10 s window measured from the cutover at t=1s. let k_old = !entry.current_k_bit(); for &t in &[5_000u64, 15_000, 25_000] { let (ct, counter, hdr) = seal(&mut old_send, b"still-old-epoch", k_old); let (_pt, slot) = entry .fsp_trial_decrypt(&ct, counter, &hdr, k_old, t) .expect("old-epoch frame must still decrypt while peer uses it"); assert_eq!(slot, EpochSlot::Previous); // Even though `now - drain_started_ms` exceeds DRAIN_MS, the // window is NOT expired: the peer just used `previous`. assert!( !entry.drain_expired(t, DRAIN_MS), "previous slot must not be retired while peer keeps using it (t={t})" ); assert!( entry.previous_highest_counter().is_some(), "previous slot must remain live (t={t})" ); } // The peer cuts over (or dies): no more old-epoch frames. The // last `previous`-slot use was at t=25_000; the window now // elapses DRAIN_MS after that, NOT DRAIN_MS after the cutover. assert!( !entry.drain_expired(34_999, DRAIN_MS), "window must not expire before DRAIN_MS past the last previous use" ); assert!( entry.drain_expired(35_000, DRAIN_MS), "window must expire DRAIN_MS after the last previous-slot decrypt" ); // Once expired, complete_drain retires the previous slot. entry.complete_drain(); assert!(entry.previous_highest_counter().is_none()); assert!(!entry.is_draining()); } // 11. Absent any peer traffic on the old epoch, drain expiry still // fires on the plain wall-clock window measured from the // cutover — the peer-progress refinement must not delay // retirement when the peer cut over cleanly (no `previous`-slot // use at all). #[test] fn drain_expiry_unaffected_when_peer_off_old_epoch() { const DRAIN_MS: u64 = 10_000; let cutover_ms = 1_000; let (_old_send, old_recv) = xk_pair(1, 2); let (_new_send, new_recv) = xk_pair(3, 4); let mut entry = entry_with_current(old_recv); entry.set_pending_session(new_recv); assert!(entry.cutover_to_new_session(cutover_ms)); // No old-epoch frames ever arrive: `previous_last_used_ms` stays // 0, the deadline anchor is the cutover time. assert!( !entry.drain_expired(cutover_ms + DRAIN_MS - 1, DRAIN_MS), "window must not expire early" ); assert!( entry.drain_expired(cutover_ms + DRAIN_MS, DRAIN_MS), "window must expire on the plain wall-clock timer when peer is off the old epoch" ); } }