Merge maint into master (FSP rekey overlapping-epoch, drain-erase fix)

This commit is contained in:
Johnathan Corgan
2026-05-23 01:54:11 +00:00
6 changed files with 1011 additions and 107 deletions
+20
View File
@@ -133,6 +133,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- FSP session rekey is now hitless under packet loss and reordering.
Previously, a 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 keys while the other did not, and traffic
sealed in one key epoch 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 reconverged the pair. The
receive path now trial-decrypts each frame against every live key
epoch (current, pending, and the draining previous session) for the
duration of the rekey transition, so no rotation ordering and no
packet reordering can cause a decryption failure. The previous-epoch
slot is retained as long as the peer keeps using it, with its drain
deadline anchored on the last frame the peer authenticates against
it rather than a fixed wall-clock timer, so a peer that did not
receive the new keys is not stranded by a silent permanent decrypt
failure. The lost-handshake case is closed 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. There are no FSP decryption
failures across a rekey under lossy, jittery links.
- AUR packaging: the `fips` and `fips-git` PKGBUILDs now install the
`fips-dns-setup` and `fips-dns-teardown` helpers into
`/usr/lib/fips/`, matching the Debian package. The AUR `package()`
+127 -8
View File
@@ -19,8 +19,14 @@ const DRAIN_WINDOW_SECS: u64 = 10;
/// a peer's rekey msg1.
const REKEY_DAMPENING_SECS: u64 = 30;
/// Delay FSP initiator cutover after handshake completion to allow
/// XK msg3 to reach the responder before K-bit-flipped data arrives.
/// Liveness bound on how long the FSP rekey initiator holds the
/// `current` + `pending` state before cutting over to the new epoch.
///
/// This is NOT safety-critical: overlapping-epoch trial-decrypt covers
/// any skew between the two endpoints' cutovers. The timer only bounds
/// how long the initiator advertises the old K-bit. An opportunistic
/// early cutover also fires if the initiator authenticates a peer frame
/// against its own `pending` session (the responder cut over first).
const FSP_CUTOVER_DELAY_MS: u64 = 2000;
impl Node {
@@ -291,12 +297,114 @@ impl Node {
}
}
/// Retransmit FSP rekey msg3 until the responder is confirmed on the
/// new epoch.
///
/// Called from the tick loop. The rekey initiator retains its msg3
/// wire payload after the first send (`handle_session_ack`); this
/// driver resends it on the handshake resend interval (with backoff)
/// while the payload is still retained.
///
/// This is a **liveness-only** mechanism. Overlapping-epoch
/// trial-decrypt makes the rekey transition safe regardless of
/// cutover skew; retransmission only guarantees the responder
/// eventually derives the new session. Its lifetime is tied to the
/// responder *receiving* msg3 — the retained payload is cleared when
/// an inbound peer frame authenticates against `pending` or
/// post-cutover `current` — decoupled from the initiator's own
/// cutover. The initiator may cut over on its liveness timer while
/// the responder still lacks the new session; retransmission
/// continues, and overlapping-epoch decrypt keeps both directions
/// working meanwhile.
///
/// After `handshake_max_resends` attempts with no confirmed progress,
/// the rekey cycle is abandoned cleanly (`abandon_rekey`): the
/// pending session is dropped and the next cycle retries fresh. This
/// is safe — an abandoned cycle never leaves a divergent unsafe
/// state.
pub(in crate::node) async fn resend_pending_session_msg3(&mut self, now_ms: u64) {
if !self.config.node.rekey.enabled || self.sessions.is_empty() {
return;
}
let interval_ms = self.config.node.rate_limit.handshake_resend_interval_ms;
let backoff = self.config.node.rate_limit.handshake_resend_backoff;
let max_resends = self.config.node.rate_limit.handshake_max_resends;
let ttl = self.config.node.session.default_ttl;
let my_addr = *self.node_addr();
// Collect rekey initiators whose msg3 retransmission is due.
let mut to_resend: Vec<(NodeAddr, Vec<u8>)> = Vec::new();
let mut to_abandon: Vec<NodeAddr> = Vec::new();
for (node_addr, entry) in &self.sessions {
// Only the rekey initiator retains a msg3 payload.
let payload = match entry.rekey_msg3_payload() {
Some(p) => p,
None => continue,
};
if entry.rekey_msg3_next_resend_ms() == 0 || now_ms < entry.rekey_msg3_next_resend_ms()
{
continue;
}
if entry.rekey_msg3_resend_count() >= max_resends {
to_abandon.push(*node_addr);
continue;
}
to_resend.push((*node_addr, payload.to_vec()));
}
// Abandon rekey cycles that exhausted their retransmission budget.
for node_addr in to_abandon {
if let Some(entry) = self.sessions.get_mut(&node_addr) {
entry.abandon_rekey();
}
warn!(
peer = %self.peer_display_name(&node_addr),
"FSP rekey aborted: msg3 unconfirmed after max retransmissions, abandoning cycle"
);
}
// Retransmit msg3 for cycles still within budget.
for (node_addr, payload) in to_resend {
let mut datagram = SessionDatagram::new(my_addr, node_addr, payload).with_ttl(ttl);
let sent = match self.send_session_datagram(&mut datagram).await {
Ok(_) => true,
Err(e) => {
debug!(
peer = %self.peer_display_name(&node_addr),
error = %e,
"FSP rekey msg3 retransmission failed"
);
false
}
};
if sent && let Some(entry) = self.sessions.get_mut(&node_addr) {
let count = entry.rekey_msg3_resend_count() + 1;
let next = now_ms + (interval_ms as f64 * backoff.powi(count as i32)) as u64;
entry.record_rekey_msg3_resend(next);
trace!(
peer = %self.peer_display_name(&node_addr),
resend = count,
"Resent FSP rekey msg3"
);
}
}
}
/// Periodic session (FSP) rekey check. Called from the tick loop.
///
/// For each established session:
/// - If the initiator has a pending session, perform K-bit cutover
/// - If the initiator holds a pending session past the liveness
/// timer, perform the K-bit cutover (overlapping-epoch decrypt
/// makes this safe on any schedule — see `FSP_CUTOVER_DELAY_MS`)
/// - If the drain window has expired, clean up the previous session
/// - If the rekey timer/counter fires, initiate a new XK handshake
///
/// msg3 retransmission is handled separately by
/// `resend_pending_session_msg3`; its lifetime is tied to the
/// responder receiving msg3, not to this initiator's cutover.
pub(in crate::node) async fn check_session_rekey(&mut self) {
if !self.config.node.rekey.enabled {
return;
@@ -317,10 +425,14 @@ impl Node {
continue;
}
// 1. Initiator-side cutover: completed rekey, pending session ready.
// Defer cutover until msg3 has had time to reach the responder.
// Without this delay, K-bit-flipped data can arrive before
// msg3, causing decryption failures on the responder.
// 1. Initiator-side cutover (option A): completed rekey,
// pending session ready, liveness timer elapsed. This is
// an unconditional timer, NOT gated on responder progress —
// overlapping-epoch trial-decrypt covers the cutover skew,
// so flipping the K-bit here is always safe. An
// opportunistic early cutover also happens in
// `handle_encrypted_session_msg` if the initiator
// authenticates a peer frame against its own `pending`.
if entry.pending_new_session().is_some()
&& !entry.has_rekey_in_progress()
&& entry.is_rekey_initiator()
@@ -340,7 +452,14 @@ impl Node {
continue;
}
if entry.pending_new_session().is_some() {
continue; // Responder with pending session, wait for initiator's K-bit
continue; // Pending session present, awaiting cutover
}
if entry.rekey_msg3_payload().is_some() {
// Initiator already cut over on its liveness timer but is
// still retransmitting msg3 to a responder not yet
// confirmed on the new epoch. Don't start another rekey
// until the current cycle's msg3 is delivered or abandoned.
continue;
}
if entry.is_rekey_dampened(now_ms, dampening_ms) {
continue;
+1
View File
@@ -255,6 +255,7 @@ impl Node {
self.resend_pending_handshakes(now_ms).await;
self.resend_pending_rekeys(now_ms).await;
self.resend_pending_session_handshakes(now_ms).await;
self.resend_pending_session_msg3(now_ms).await;
self.purge_idle_sessions(now_ms);
self.process_pending_retries(now_ms).await;
self.check_tree_state().await;
+82 -58
View File
@@ -8,7 +8,7 @@
use crate::NodeAddr;
use crate::mmp::report::ReceiverReport;
use crate::mmp::{MAX_SESSION_REPORT_INTERVAL_MS, MIN_SESSION_REPORT_INTERVAL_MS};
use crate::node::session::{EndToEndState, SessionEntry};
use crate::node::session::{EndToEndState, EpochSlot, SessionEntry};
use crate::node::session_wire::{
FSP_COMMON_PREFIX_SIZE, FSP_FLAG_CP, FSP_FLAG_K, FSP_HEADER_SIZE, FSP_PHASE_ESTABLISHED,
FSP_PHASE_MSG1, FSP_PHASE_MSG2, FSP_PHASE_MSG3, FSP_PORT_HEADER_SIZE, FSP_PORT_IPV6_SHIM,
@@ -202,74 +202,87 @@ impl Node {
}
}
// K-bit flip detection: peer has cut over to the new session.
// The received K-bit is only an ordering hint for the
// trial-decrypt cascade — it picks which key epoch to try first.
// Correctness never depends on it; promotion is driven by which
// slot actually authenticates the frame, not by the header bit.
let received_k_bit = header.flags & FSP_FLAG_K != 0;
{
let entry = self.sessions.get(src_addr).unwrap();
let k_bit_flipped =
received_k_bit != entry.current_k_bit() && entry.pending_new_session().is_some();
if k_bit_flipped {
let display_name = self.peer_display_name(src_addr);
info!(
peer = %display_name,
"Peer FSP K-bit flip detected, promoting new session"
);
let now_ms = Self::now_ms();
let entry = self.sessions.get_mut(src_addr).unwrap();
entry.handle_peer_kbit_flip(now_ms);
}
}
let mut entry = match self.sessions.remove(src_addr) {
Some(e) => e,
None => return,
};
// Decrypt with AAD = the 12-byte header
let session = match entry.state_mut() {
EndToEndState::Established(s) => s,
_ => {
debug!(src = %self.peer_display_name(src_addr), "Encrypted message but session not established");
let now_ms = Self::now_ms();
// Overlapping-epoch trial-decrypt: try current, pending and
// previous so any epoch the peer might have sealed this frame in
// can be decrypted. This makes rekey correctness independent of
// cutover timing — no ordering and no reordering can cause a
// decrypt failure. A successful `previous`-slot decrypt also
// refreshes the drain deadline so the old epoch is retained as
// long as the peer keeps using it.
let (plaintext, slot) = match entry.fsp_trial_decrypt(
ciphertext,
header.counter,
&header.header_bytes,
received_k_bit,
now_ms,
) {
Some(result) => result,
None => {
// Every live slot failed — a genuine drop. The upper
// layer retransmits.
debug!(
src = %self.peer_display_name(src_addr),
counter = header.counter,
"Session AEAD decryption failed (all epochs)"
);
self.sessions.insert(*src_addr, entry);
return;
}
};
let plaintext = match session.decrypt_with_replay_check_and_aad(
ciphertext,
header.counter,
&header.header_bytes,
) {
Ok(pt) => pt,
Err(e) => {
// Current session failed — try previous session (drain window)
if let Some(prev_session) = entry.previous_noise_session_mut() {
match prev_session.decrypt_with_replay_check_and_aad(
ciphertext,
header.counter,
&header.header_bytes,
) {
Ok(pt) => pt,
Err(_) => {
debug!(
error = %e, src = %self.peer_display_name(src_addr), counter = header.counter,
"Session AEAD decryption failed (current and previous)"
);
self.sessions.insert(*src_addr, entry);
return;
}
}
} else {
debug!(
error = %e, src = %self.peer_display_name(src_addr), counter = header.counter,
"Session AEAD decryption failed"
);
self.sessions.insert(*src_addr, entry);
return;
// React to the epoch the frame decrypted against.
match slot {
EpochSlot::Pending => {
// A frame that authenticates against `pending` is itself
// the cutover signal — proof the peer derived the new
// session and moved to it. Promote now: current →
// previous, pending → current, flip the K-bit. The
// header K-bit is no longer the gating event; the
// authenticated decrypt is.
info!(
peer = %self.peer_display_name(src_addr),
"Peer FSP new-epoch frame authenticated, FSP rekey cutover complete, promoting new session"
);
// The peer derived the new session, so it received msg3:
// confirm it on the new epoch and stop retransmitting.
// `handle_peer_kbit_flip` consumes the pending session,
// so confirm first.
if entry.rekey_msg3_payload().is_some() {
entry.confirm_peer_new_epoch();
}
entry.handle_peer_kbit_flip(now_ms);
}
EpochSlot::Current => {
// If we still retain a msg3 retransmission payload but no
// longer hold a `pending` session, we are the rekey
// initiator that already cut over on its own timer:
// `current` is now the new epoch, so a frame decrypting
// against it confirms the responder reached the new
// epoch. Stop retransmitting msg3.
if entry.rekey_msg3_payload().is_some() && entry.pending_new_session().is_none() {
entry.confirm_peer_new_epoch();
}
}
};
EpochSlot::Previous => {
// The peer is still on the old epoch. `fsp_trial_decrypt`
// already refreshed the drain deadline so the `previous`
// slot is not retired while the peer keeps using it —
// no further state change here, just deliver.
}
}
self.sessions.insert(*src_addr, entry);
@@ -655,7 +668,7 @@ impl Node {
let msg3_wire = SessionMsg3::new(msg3);
let msg3_payload = msg3_wire.encode();
let my_addr = *self.node_addr();
let mut datagram = SessionDatagram::new(my_addr, *src_addr, msg3_payload)
let mut datagram = SessionDatagram::new(my_addr, *src_addr, msg3_payload.clone())
.with_ttl(self.config.node.session.default_ttl);
if let Err(e) = self.send_session_datagram(&mut datagram).await {
@@ -676,13 +689,24 @@ impl Node {
}
};
// Retain msg3 for retransmission (liveness): a single msg3
// loss must not leave the responder without the new session.
// Retransmission runs until the responder is confirmed on the
// new epoch — an authenticated peer frame against `pending` or
// post-cutover `current` — decoupled from this initiator's own
// cutover. The initiator may cut over on its liveness timer
// before the responder receives msg3; overlapping-epoch
// decrypt keeps both directions safe meanwhile.
let now_ms = Self::now_ms();
let resend_interval = self.config.node.rate_limit.handshake_resend_interval_ms;
entry.set_pending_session(session);
entry.set_rekey_completed_ms(Self::now_ms());
entry.set_rekey_completed_ms(now_ms);
entry.set_rekey_msg3_payload(msg3_payload, now_ms + resend_interval);
self.sessions.insert(*src_addr, entry);
debug!(
src = %self.peer_display_name(src_addr),
"FSP rekey: completed XK as initiator, pending cutover"
"FSP rekey: completed XK as initiator, msg3 sent, pending cutover"
);
return;
}
+771 -37
View File
@@ -40,6 +40,20 @@ pub(crate) enum EndToEndState {
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 {
@@ -117,6 +131,20 @@ pub(crate) struct SessionEntry {
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.
@@ -126,8 +154,27 @@ pub(crate) struct SessionEntry {
/// 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).
/// Used to defer cutover until msg3 has time to reach the responder.
/// 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
@@ -165,11 +212,16 @@ impl SessionEntry {
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(),
}
}
@@ -371,11 +423,6 @@ impl SessionEntry {
self.pending_new_session.as_ref()
}
/// Get the previous session for decryption fallback during drain.
pub(crate) fn previous_noise_session_mut(&mut self) -> Option<&mut NoiseSession> {
self.previous_noise_session.as_mut()
}
/// Whether we initiated the current rekey.
pub(crate) fn is_rekey_initiator(&self) -> bool {
self.rekey_initiator
@@ -427,6 +474,147 @@ impl SessionEntry {
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);
@@ -444,11 +632,14 @@ impl SessionEntry {
self.rekey_state.take()
}
/// Cut over to the pending new session (initiator side).
/// Promote the pending session to current (the cutover).
///
/// Moves current session to previous (for drain), promotes pending to current,
/// flips the K-bit.
pub(crate) fn cutover_to_new_session(&mut self, now_ms: u64) -> bool {
/// 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,
@@ -459,6 +650,10 @@ impl SessionEntry {
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));
@@ -477,38 +672,53 @@ impl SessionEntry {
true
}
/// Handle receiving a K-bit flip from the peer (responder side).
/// 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 {
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;
// 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_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
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 {
self.drain_started_ms > 0 && now_ms.saturating_sub(self.drain_started_ms) >= drain_ms
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.
@@ -516,16 +726,540 @@ impl SessionEntry {
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"
);
}
}
+10 -4
View File
@@ -416,17 +416,23 @@ FAILED=0
# FSP session rekey trails link-layer rekey in practice. Wait boundedly for
# at least one initiator and responder cutover before the final assertions.
wait_for_log_pattern_count "FSP rekey cutover complete" 1 "$FIRST_REKEY_TIMEOUT" || true
wait_for_log_pattern_count "Peer FSP K-bit flip detected" 1 "$REKEY_SETTLE" || true
#
# The responder-side cutover is driven by the overlapping-epoch
# trial-decrypt cascade: a frame that authenticates against the pending
# session is itself the cutover signal, logged as "Peer FSP new-epoch
# frame authenticated". The header K-bit is only an ordering hint now,
# so there is no longer a standalone "K-bit flip detected" event.
wait_for_log_pattern_count "FSP rekey cutover complete (initiator)" 1 "$FIRST_REKEY_TIMEOUT" || true
wait_for_log_pattern_count "Peer FSP new-epoch frame authenticated" 1 "$REKEY_SETTLE" || true
# Positive checks: rekey machinery worked
assert_min_count "Rekey cutover complete (initiator), K-bit flipped" 4 \
"FMP rekey initiator cutovers (>= 2 cycles)"
# FSP rekey checks (sessions between non-adjacent nodes)
assert_min_count "FSP rekey cutover complete" 1 \
assert_min_count "FSP rekey cutover complete (initiator)" 1 \
"FSP session rekey initiator cutovers"
assert_min_count "Peer FSP K-bit flip detected" 1 \
assert_min_count "Peer FSP new-epoch frame authenticated" 1 \
"FSP session rekey responder cutovers"
# Negative checks: no bad things happened