Fix XX rekey dual-init race: tie-break also when pending_new_session set

The dual-initiation tie-breaker in the rekey arm of handle_msg3 (and
the FSP analogue in handle_session_setup) only fired when
rekey_in_progress was true. With Noise IK (one-message rekey on
master/maint) that is sufficient: msg1 IS the rekey, so both sides
being mid-handshake is the only state where the race can fire.

With Noise XX (three-message rekey on next), set_pending_session runs
when the initiator has processed msg2 and sent msg3, and that call
clears rekey_in_progress. Both sides' set_pending_session can run
before either peer's msg3 has landed. Each side then receives the
peer's msg3 in the post-pending state with rekey_in_progress=false,
falls into the "drop because pending_new_session is set" guard, and
discards the peer's handshake. Each side commits its own initiator
session at K-bit cutover. The two sessions use different Noise key
material, so the link breaks asymmetrically after cutover.

Unify both checks into a single tie-breaker that fires when either
rekey_in_progress() OR pending_new_session().is_some() is true. The
existing smaller-NodeAddr rule applies uniformly; abandon_rekey()
already clears both states and returns whichever index needs
freeing.

Mirrored to the FSP rekey msg1 path in handle_session_setup for the
same race shape.

Logging:

- The previously-silent drop in the rekey arm of handle_msg3 logs at
  info as a tie-break decision rather than as a silent drop.
  Pending_new_session is added as a log field on the tie-break
  win/lose lines so a reader can distinguish which of the two race
  states fired.
- Cutover-complete and K-bit-flip log lines gained our_addr and
  their_addr fields so the two endpoints' logs of the same handshake
  can be correlated.
- "Pending session set, awaiting K-bit cutover" lines remain at
  debug per the existing convention of info-for-cutover-completion-
  only.

Verification: under the un-fixed handler, both sides log
"rekey-msg3 drop: pending_new_session already set" within 242
microseconds of each other, with rekey_in_progress=false and
different pending session indices. Failure pattern matches: six of
twenty pairs FAILED post-rekey, all involving the single-peer node.
Six consecutive post-fix CI attempts on the same test green across
rekey, rekey-accept-off, and rekey-outbound-only suites.
This commit is contained in:
Johnathan Corgan
2026-05-13 23:58:10 +00:00
parent 195b3bad01
commit 183ad148de
5 changed files with 98 additions and 43 deletions
+14
View File
@@ -99,6 +99,20 @@ with v0.3.x peers.
## [Unreleased]
### Fixed
- XX rekey dual-initiation race that broke six pair-directions
post-rekey when both endpoints initiated rekey simultaneously.
The `handle_msg3` tie-breaker only fired when `rekey_in_progress`
was still true, but XX's three-message handshake lets both sides
clear that flag (via `set_pending_session`) before either's msg3
lands. The drop-on-pending-session guard then silently discarded
the peer's msg3, each side cut over to its own initiator session,
and the link broke asymmetrically. The tie-breaker now also fires
when `pending_new_session().is_some()`, applying the same
smaller-NodeAddr resolution rule. Mirrored to the FSP rekey msg1
path for symmetry.
## [0.3.0] - 2026-05-11
### Added
+6
View File
@@ -60,8 +60,14 @@ impl Node {
if k_bit_flipped {
let display_name = self.peer_display_name(&node_addr);
let pending_our = peer.pending_our_index();
let pending_their = peer.pending_their_index();
info!(
peer = %display_name,
our_addr = %self.identity.node_addr(),
their_addr = %node_addr,
pending_our_index = ?pending_our,
pending_their_index = ?pending_their,
"Peer K-bit flip detected, promoting new session"
);
+39 -24
View File
@@ -387,9 +387,10 @@ impl Node {
debug!(
peer = %display_name,
our_addr = %self.identity.node_addr(),
new_our_index = %our_index,
new_their_index = %header.sender_idx,
"Rekey completed (initiator), pending K-bit cutover"
"rekey-msg2 initiator: pending session set, awaiting K-bit cutover"
);
} else {
// msg3 send failed — abandon rekey
@@ -1015,36 +1016,46 @@ impl Node {
&& existing_peer.is_healthy()
&& session_age_secs >= 30
{
// Guard: already have a pending session from a completed
// rekey (waiting for K-bit cutover). Don't overwrite.
if existing_peer.pending_new_session().is_some() {
debug!(
peer = %self.peer_display_name(&peer_node_addr),
"Rekey msg3 received but already have pending session, dropping"
);
self.connections.remove(&link_id);
self.links.remove(&link_id);
return;
}
// Dual-initiation detection: both sides sent msg1
// simultaneously. Apply tie-breaker.
if existing_peer.rekey_in_progress() {
// Dual-initiation detection: both sides initiated rekey
// simultaneously. Two states can reach this point:
// - rekey_in_progress=true: both sides still mid-handshake
// - pending_new_session=Some && !rekey_in_progress: both
// sides already completed their initiator path
// (set_pending_session cleared rekey_in_progress)
// The IK fix only caught the first state; the XX three-message
// handshake widens the window so the second state is reached
// when both sides' set_pending_session runs before either's
// msg3 lands at the peer. Apply the smaller-NodeAddr
// tie-breaker uniformly in both states so both sides converge
// on a single Noise session post-cutover.
if existing_peer.rekey_in_progress()
|| existing_peer.pending_new_session().is_some()
{
let our_addr = self.identity.node_addr();
if our_addr < &peer_node_addr {
// We win as initiator — drop their msg3/handshake.
debug!(
// We win — keep our session, drop their msg3.
info!(
peer = %self.peer_display_name(&peer_node_addr),
"Dual rekey initiation: we win (smaller addr), dropping their msg3"
our_addr = %our_addr,
their_addr = %peer_node_addr,
rekey_in_progress = existing_peer.rekey_in_progress(),
pending_new_session = existing_peer.pending_new_session().is_some(),
"rekey-msg3 tie-break: we win (smaller addr), drop their msg3"
);
self.connections.remove(&link_id);
self.links.remove(&link_id);
return;
}
// We lose — abandon our rekey, become responder below.
debug!(
// We lose — abandon our rekey/pending, fall through as responder.
// abandon_rekey clears both rekey_in_progress and any pending
// session state, returning whichever index needs freeing.
info!(
peer = %self.peer_display_name(&peer_node_addr),
"Dual rekey initiation: we lose (larger addr), abandoning ours"
our_addr = %our_addr,
their_addr = %peer_node_addr,
rekey_in_progress = existing_peer.rekey_in_progress(),
pending_new_session = existing_peer.pending_new_session().is_some(),
"rekey-msg3 tie-break: we lose (larger addr), abandon ours"
);
if let Some(peer) = self.peers.get_mut(&peer_node_addr)
&& let Some(idx) = peer.abandon_rekey()
@@ -1103,8 +1114,10 @@ impl Node {
debug!(
peer = %self.peer_display_name(&peer_node_addr),
our_addr = %self.identity.node_addr(),
new_our_index = %our_new_index,
"Rekey completed (responder), pending K-bit cutover"
new_their_index = %header.sender_idx,
"rekey-msg3 responder: pending session set, awaiting K-bit cutover"
);
return;
}
@@ -1282,8 +1295,10 @@ impl Node {
debug!(
peer = %display_name,
our_addr = %self.identity.node_addr(),
new_our_index = %our_index,
"Rekey msg3 completed (responder), pending K-bit cutover"
new_their_index = %header.sender_idx,
"rekey-msg3 responder (existing link): pending session set, awaiting K-bit cutover"
);
}
Err(e) => {
+8 -2
View File
@@ -10,7 +10,7 @@ use crate::node::Node;
use crate::node::wire::build_msg1;
use crate::noise::HandshakeState;
use crate::protocol::{SessionDatagram, SessionSetup};
use tracing::{debug, trace, warn};
use tracing::{debug, info, trace, warn};
/// Keep previous session alive for this long after cutover.
const DRAIN_WINDOW_SECS: u64 = 10;
@@ -95,8 +95,14 @@ impl Node {
)),
"peers_by_index should contain pre-registered new index after cutover"
);
debug!(
let our_index = peer.our_index();
let their_index = peer.their_index();
info!(
peer = %self.peer_display_name(&node_addr),
our_addr = %self.identity.node_addr(),
their_addr = %node_addr,
our_index = ?our_index,
their_index = ?their_index,
"Rekey cutover complete (initiator), K-bit flipped"
);
}
+31 -17
View File
@@ -189,6 +189,8 @@ impl Node {
let display_name = self.peer_display_name(src_addr);
info!(
peer = %display_name,
our_addr = %self.identity.node_addr(),
their_addr = %src_addr,
"Peer FSP K-bit flip detected, promoting new session"
);
let now_ms = Self::now_ms();
@@ -432,33 +434,41 @@ impl Node {
let rekey_in_progress = existing.has_rekey_in_progress();
let has_pending = existing.pending_new_session().is_some();
// Dual-initiation detection: both sides sent SessionSetup
// simultaneously. Apply tie-breaker — smaller NodeAddr
// wins as initiator (same as initial session setup).
if rekey_in_progress {
// Dual-initiation detection: both sides initiated rekey
// simultaneously. Two states can reach this point:
// - rekey_in_progress=true: both sides still mid-handshake
// - pending_new_session=Some && !rekey_in_progress: we've
// already completed our initiator path
// (set_pending_session cleared rekey_in_progress) and
// the peer's msg1 arrives now. Symmetric of the FMP
// msg3 case at handle_msg3.
// Apply the smaller-NodeAddr tie-breaker uniformly so
// both sides converge on a single Noise session.
if rekey_in_progress || has_pending {
if self.identity.node_addr() < src_addr {
// We win as initiator — drop their msg1.
debug!(
// We win — keep our session, drop their msg1.
info!(
src = %self.peer_display_name(src_addr),
"Dual FSP rekey initiation: we win (smaller addr), dropping their msg1"
our_addr = %self.identity.node_addr(),
their_addr = %src_addr,
rekey_in_progress = rekey_in_progress,
pending_new_session = has_pending,
"FSP rekey-msg1 tie-break: we win (smaller addr), drop their msg1"
);
return;
}
// We lose — abandon our rekey, become responder below.
debug!(
// We lose — abandon our rekey/pending, fall through as responder.
info!(
src = %self.peer_display_name(src_addr),
"Dual FSP rekey initiation: we lose (larger addr), abandoning ours"
our_addr = %self.identity.node_addr(),
their_addr = %src_addr,
rekey_in_progress = rekey_in_progress,
pending_new_session = has_pending,
"FSP rekey-msg1 tie-break: we lose (larger addr), abandon ours"
);
if let Some(entry) = self.sessions.get_mut(src_addr) {
entry.abandon_rekey();
}
} else if has_pending {
// Guard: already have a pending session waiting for K-bit cutover
debug!(
src = %self.peer_display_name(src_addr),
"FSP rekey msg1 received but already have pending session, dropping"
);
return;
}
let our_keypair = self.identity.keypair();
let mut handshake = HandshakeState::new_responder(our_keypair);
@@ -666,6 +676,8 @@ impl Node {
debug!(
src = %self.peer_display_name(src_addr),
our_addr = %self.identity.node_addr(),
their_addr = %src_addr,
"FSP rekey: completed XX as initiator, pending cutover"
);
return;
@@ -847,6 +859,8 @@ impl Node {
debug!(
src = %self.peer_display_name(src_addr),
our_addr = %self.identity.node_addr(),
their_addr = %src_addr,
"FSP rekey: completed XX as responder, pending cutover"
);
return;