Files
fips/src/node/session.rs
T
Johnathan Corgan 6e5cb8965f Make FSP session rekey hitless under packet loss and reordering
An FSP session rekey could leave the two endpoints holding different
key sets for a brief window: if a handshake message was lost in
transit, one side rotated to the new keys while the other did not.
Traffic sealed in one key epoch then reached a peer still on the
other epoch and failed to decrypt, producing bursts of AEAD
decryption failures and dropped connectivity until a later rekey
cycle reconverged the pair. Choreographing the cutover order cannot
close this window: any fixed ordering still leaves a skew that
packet reordering widens.

Make rekey correctness independent of cutover timing by overlapping
the key epochs on the receive path. During a rekey transition the
receiver trial-decrypts each frame against every live session it
holds: current, the not-yet-promoted pending session, and the
draining previous session. The K-bit becomes a hint that orders the
trial-decrypt cascade rather than a hard gate, and a frame that
authenticates against the pending session is itself the cutover
signal. No rotation ordering and no packet reordering can then cause
a decryption failure.

The pre-rekey Noise session is held in the `previous` slot until the
peer has demonstrably moved off it. Its drain deadline is anchored
on the most recent frame the peer authenticated against that slot,
refreshed each time the trial-decrypt cascade lands there, rather
than on a fixed wall-clock timer started unilaterally at the local
cutover. A peer that never received the new keys keeps authenticating
against `previous` and the slot stays live; without this, a fixed
timer would erase the only key set that could decrypt the peer's
frames, producing a permanent silent decrypt failure on a live data
path. A peer that never catches up is handled by the existing FSP
session liveness path rather than by silent decrypt failure.

The lost-handshake liveness gap is closed separately by retransmitting
the third rekey handshake message until the peer is confirmed on the
new keys, with a bounded retry budget after which the rekey cycle is
cleanly abandoned and retried on the next timer.

Adds unit tests covering the trial-decrypt cascade (epoch selection,
promotion on pending decrypt, reordered old-epoch stragglers after
cutover, per-slot replay-window integrity), the msg3 retransmission
lifecycle, and the peer-progress-aware drain retirement.
2026-05-23 01:54:04 +00:00

1266 lines
51 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<EndToEndState>,
/// 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<MmpSessionState>,
// === 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<Vec<u8>>,
/// 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<NoiseSession>,
/// 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<HandshakeState>,
/// Pending completed session awaiting K-bit cutover.
pending_new_session: Option<NoiseSession>,
/// 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<Vec<u8>>,
/// 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<EndToEndState> {
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<u8>, 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<u8>, 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<u8>, 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<HandshakeState> {
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<u64> {
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<u64> {
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<u64> {
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<u8>, 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"
);
}
}