mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-09 00:04:54 +00:00
fmp: authenticate inbound frame against pending session before K-bit cutover promotion
This commit is contained in:
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user