fmp: authenticate inbound frame against pending session before K-bit cutover promotion

This commit is contained in:
Johnathan Corgan
2026-06-04 21:42:23 +00:00
parent 22a41cb1a0
commit 36c830edfd
2 changed files with 245 additions and 29 deletions
+94 -29
View File
@@ -50,44 +50,109 @@ impl Node {
let received_k_bit = header.flags & FLAG_KEY_EPOCH != 0;
// K-bit flip detection: peer has cut over to the new session.
// Check and perform cutover in a scoped borrow.
//
// The header K-bit is NOT a sufficient gating event on its own.
// Under jitter the FMP rekey interval shrinks and the two
// directions' rekeys interleave, so a node can hold a `pending`
// session from rekey N while the peer's observed K-bit flip
// actually belongs to rekey N+1. Promoting on the bare bit then
// installs the WRONG Noise session as current — the two endpoints
// diverge, every subsequent frame fails AEAD on the far side, the
// receiver starves, and the link is declared dead at the heartbeat
// timeout (routing failure, green crypto). This mirrors the FSP fix
// (node/session.rs / node/handlers/session.rs): the authenticated
// decrypt, not the header bit, is the cutover signal. Trial-decrypt
// the frame against `pending` first; only promote if it
// authenticates. On success the same frame is delivered via
// `process_authentic_fmp_plaintext` and we return — it must not
// fall through to a second decrypt, which would be rejected as a
// replay (the trial-decrypt already advanced `pending`'s window).
{
let peer = self.peers.get(&node_addr).unwrap();
let Some(peer) = self.peers.get(&node_addr) else {
return;
};
let k_bit_flipped =
received_k_bit != peer.current_k_bit() && peer.pending_new_session().is_some();
if k_bit_flipped {
let ciphertext = &packet.data[header.ciphertext_offset()..];
let display_name = self.peer_display_name(&node_addr);
info!(
peer = %display_name,
"Peer K-bit flip detected, promoting new session"
);
let Some(peer) = self.peers.get_mut(&node_addr) else {
return;
};
// Authenticate the frame against the pending session.
// Trial-decrypt mutates `pending`'s replay window only on
// success, so a failed trial leaves it untouched.
let pending_plaintext = peer.pending_new_session_mut().and_then(|pending| {
pending
.decrypt_with_replay_check_and_aad(
ciphertext,
header.counter,
&header.header_bytes,
)
.ok()
});
let peer = self.peers.get_mut(&node_addr).unwrap();
let did_flip = peer.handle_peer_kbit_flip().is_some();
if did_flip {
// New index was pre-registered in peers_by_index during
// msg1 handling (handshake.rs). Verify, don't duplicate.
debug_assert!(
peer.transport_id().is_some()
&& peer.our_index().is_some()
&& self.peers_by_index.contains_key(&(
peer.transport_id().unwrap(),
peer.our_index().unwrap().as_u32()
)),
"peers_by_index should contain pre-registered new index after K-bit flip"
if let Some(plaintext) = pending_plaintext {
info!(
peer = %display_name,
"Peer new-epoch frame authenticated, K-bit flip promoting new session"
);
// The trial-decrypt already advanced the pending
// session's replay window; `handle_peer_kbit_flip`
// moves that same session object to `current`, so no
// re-decrypt.
let did_flip = peer.handle_peer_kbit_flip().is_some();
if did_flip {
// New index was pre-registered in peers_by_index
// during msg1 handling (handshake.rs). Verify,
// don't duplicate.
debug_assert!(
peer.transport_id().is_some()
&& peer.our_index().is_some()
&& self.peers_by_index.contains_key(&(
peer.transport_id().unwrap(),
peer.our_index().unwrap().as_u32()
)),
"peers_by_index should contain pre-registered new index after K-bit flip"
);
}
// Re-register the (now-promoted) session with the
// decrypt worker: cache_key = (transport_id, our_index)
// changed at the flip, so the old worker entry is
// stranded and every packet on the new session would
// miss the worker's HashMap lookup. Without this,
// throughput drops back to the inline-decrypt path
// after each rekey.
#[cfg(unix)]
if did_flip {
self.register_decrypt_worker_session(&node_addr);
}
// Deliver the frame we just authenticated via the
// canonical post-decrypt path, then return — it must
// not fall through to a second decrypt attempt.
let ce_flag = header.flags & FLAG_CE != 0;
let sp_flag = header.flags & FLAG_SP != 0;
self.process_authentic_fmp_plaintext(
&node_addr,
packet.transport_id,
&packet.remote_addr,
packet.timestamp_ms,
packet.data.len(),
header.counter,
ce_flag,
sp_flag,
&plaintext,
)
.await;
return;
}
// Re-register the (now-promoted) session with the decrypt
// worker: cache_key = (transport_id, our_index) changed at
// the flip, so the old worker entry is stranded and every
// packet on the new session would miss the worker's
// HashMap lookup. Without this, throughput drops back to
// the inline-decrypt path after each rekey.
#[cfg(unix)]
if did_flip {
self.register_decrypt_worker_session(&node_addr);
}
// Pending did NOT authenticate this frame: the flip belongs
// to a different rekey epoch (stale pending). Do not
// promote. Fall through to the normal current/previous
// decrypt; the genuine cutover is recognized when a frame
// that authenticates against `pending` arrives.
}
}