mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-12 09:33:23 +00:00
Resolve simultaneous-init cross-connection in XX handle_msg3
After the master→next merge brought PR #53's bootstrap-handoff socket adoption onto next's XX three-message handshake, three integration jobs (rekey, nat-cone, nat-lan) regressed: the FMP handshake completed but post-handshake encrypted frames silently dropped — packets_recv stayed at 0 and link-dead timeout fired every 30s. Root cause: in symmetric bootstrap-handoff both sides initiate XX in parallel, running two concurrent handshakes (each side's outbound paired with the peer's inbound). Each side's handle_msg2 (immediate response to its own outbound msg1) ran before the peer's outbound msg3 arrived; peers.contains_key was false, so the "Normal path" promoted the outbound connection. When the peer's msg3 then arrived, peers contained the peer at the same epoch and the request fell through to the "duplicate handshake from same epoch" branch, which tore down the inbound link without applying the cross-connection tie-breaker. Both sides ended up keeping their own outbound session whose Noise key material pairs with the peer's discarded inbound, so each side's their_index pointed at the peer's inbound session index that was never registered in peers_by_index. handle_msg2 already has the symmetric handler for the inverse ordering (msg3-then-msg2). Add the missing simultaneous-init handler in handle_msg3: when the existing peer is on a different link from this msg3's pending_inbound link and the session is fresh (<30s, so this isn't a rekey), apply cross_connection_winner with this_is_outbound=false. The larger-node side swaps to the inbound session via replace_session and updates peers_by_index from outbound_idx to inbound_idx; the smaller-node side keeps its outbound session and frees the inbound's allocated index. Both sides converge on the same Noise session pair. Verified: cargo test --lib (1144 passed), cargo fmt + clippy clean, nat-cone + nat-lan + symmetric NAT scenarios pass, rekey integration test 70/70 pairs across all phases.
This commit is contained in:
@@ -882,9 +882,86 @@ impl Node {
|
||||
}
|
||||
_ => {
|
||||
// Same epoch (or no epoch stored).
|
||||
// Check for rekey: session must be at least 30s old.
|
||||
let session_age_secs =
|
||||
existing_peer.session_established_at().elapsed().as_secs();
|
||||
|
||||
// Simultaneous-init cross-connection (msg2-then-msg3 ordering).
|
||||
//
|
||||
// When both sides initiate XX in parallel (typical in
|
||||
// bootstrap-handoff after Nostr UDP punch), each side runs
|
||||
// two handshakes concurrently — its own outbound paired with
|
||||
// the peer's inbound, and the peer's outbound paired with our
|
||||
// inbound. If our outbound's msg2 arrives before the peer's
|
||||
// outbound's msg3, handle_msg2 promoted our outbound under
|
||||
// the "Normal path" (peers_contains_key was false). Now
|
||||
// msg3 arrives for the unrelated inbound link with the peer
|
||||
// already promoted at the same epoch — apply the same
|
||||
// tie-breaker handle_msg2 uses for the inverse ordering, so
|
||||
// both sides converge on a single Noise session pair.
|
||||
if existing_peer.link_id() != link_id && session_age_secs < 30 {
|
||||
let our_inbound_wins = cross_connection_winner(
|
||||
self.identity.node_addr(),
|
||||
&peer_node_addr,
|
||||
false, // this connection is inbound
|
||||
);
|
||||
|
||||
if our_inbound_wins {
|
||||
// Larger node side: swap to the inbound session so
|
||||
// it pairs with the peer's kept outbound session.
|
||||
let inbound_session = match self
|
||||
.connections
|
||||
.get_mut(&link_id)
|
||||
.and_then(|c| c.take_session())
|
||||
{
|
||||
Some(s) => s,
|
||||
None => {
|
||||
self.connections.remove(&link_id);
|
||||
self.remove_link(&link_id);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(peer) = self.peers.get_mut(&peer_node_addr) {
|
||||
let old_our_index = peer.replace_session(
|
||||
inbound_session,
|
||||
our_index,
|
||||
header.sender_idx,
|
||||
);
|
||||
let Some(transport_id) = peer.transport_id() else {
|
||||
self.connections.remove(&link_id);
|
||||
self.remove_link(&link_id);
|
||||
return;
|
||||
};
|
||||
if let Some(old_idx) = old_our_index {
|
||||
self.peers_by_index
|
||||
.remove(&(transport_id, old_idx.as_u32()));
|
||||
let _ = self.index_allocator.free(old_idx);
|
||||
}
|
||||
self.peers_by_index
|
||||
.insert((transport_id, our_index.as_u32()), peer_node_addr);
|
||||
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&peer_node_addr),
|
||||
new_our_index = %our_index,
|
||||
new_their_index = %header.sender_idx,
|
||||
"Simultaneous-init (msg3): swapped to inbound session (our inbound wins)"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Smaller node side: keep the existing outbound
|
||||
// session, drop the inbound's allocated index.
|
||||
let _ = self.index_allocator.free(our_index);
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&peer_node_addr),
|
||||
"Simultaneous-init (msg3): keeping outbound session (our outbound wins)"
|
||||
);
|
||||
}
|
||||
|
||||
self.connections.remove(&link_id);
|
||||
self.remove_link(&link_id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for rekey: session must be at least 30s old.
|
||||
if self.config.node.rekey.enabled
|
||||
&& existing_peer.has_session()
|
||||
&& existing_peer.is_healthy()
|
||||
|
||||
Reference in New Issue
Block a user