mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
Merge maint into master (FSP rekey overlapping-epoch, drain-erase fix)
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)"
|
||||
// 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"
|
||||
);
|
||||
self.sessions.insert(*src_addr, entry);
|
||||
return;
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
} 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;
|
||||
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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user