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.
}
}
+151
View File
@@ -948,6 +948,12 @@ impl ActivePeer {
self.pending_new_session.as_ref()
}
/// Mutable access to the pending new session, for trial-decrypt of an
/// inbound frame before promoting it on a peer K-bit flip.
pub fn pending_new_session_mut(&mut self) -> Option<&mut NoiseSession> {
self.pending_new_session.as_mut()
}
/// Store a completed rekey session and its indices.
///
/// Called when the rekey handshake completes. The session is held
@@ -1470,4 +1476,149 @@ mod tests {
assert!(peer.rekey_msg1().is_none());
assert_eq!(peer.rekey_msg1_resend_count(), 0);
}
// === FMP rekey cutover: authenticate-before-promote ===
//
// IK-adapted analogue of the FSP trial-decrypt tests
// (node/session.rs `trial_decrypt_picks_pending_and_promotes` /
// `trial_decrypt_failed_slot_leaves_replay_window_intact`). The FMP
// cutover is gated on an authenticated decrypt against `pending`, not
// the bare header K-bit. These tests exercise that primitive:
// `pending_new_session_mut()` trial-decrypt followed by
// `handle_peer_kbit_flip()` promotion.
/// Complete an IK handshake and return the (sender, receiver) session
/// pair. The receiver decrypts what the sender seals.
fn ik_session_pair() -> (NoiseSession, NoiseSession) {
let initiator_id = Identity::generate();
let responder_id = Identity::generate();
let mut initiator =
NoiseHandshakeState::new_initiator(initiator_id.keypair(), responder_id.pubkey_full());
initiator.set_local_epoch([0xA1, 0xB2, 0xC3, 0xD4, 0x11, 0x22, 0x33, 0x44]);
let mut responder = NoiseHandshakeState::new_responder(responder_id.keypair());
responder.set_local_epoch([0xD4, 0xC3, 0xB2, 0xA1, 0x44, 0x33, 0x22, 0x11]);
let msg1 = initiator.write_message_1().unwrap();
responder.read_message_1(&msg1).unwrap();
let msg2 = responder.write_message_2().unwrap();
initiator.read_message_2(&msg2).unwrap();
(
initiator.into_session().unwrap(),
responder.into_session().unwrap(),
)
}
/// Seal an FMP frame the way the send path does: returns
/// `(ciphertext, counter, header_bytes)` for the given K-bit.
fn seal_fmp(
sender: &mut NoiseSession,
receiver_idx: SessionIndex,
plaintext: &[u8],
k_bit: bool,
) -> (Vec<u8>, u64, [u8; 16]) {
use crate::node::wire::{FLAG_KEY_EPOCH, build_established_header};
let counter = sender.current_send_counter();
let flags = if k_bit { FLAG_KEY_EPOCH } else { 0 };
let header = build_established_header(receiver_idx, counter, flags, plaintext.len() as u16);
let ciphertext = sender.encrypt_with_aad(plaintext, &header).unwrap();
(ciphertext, counter, header)
}
/// Build a peer whose `current` slot is `current_recv`.
fn peer_with_current(current_recv: NoiseSession) -> ActivePeer {
let identity = make_peer_identity();
ActivePeer::with_session(
identity,
LinkId::new(1),
1_000,
current_recv,
SessionIndex::new(1),
SessionIndex::new(2),
TransportId::new(1),
TransportAddr::from_string("hci0/AA:BB:CC:DD:EE:01"),
LinkStats::new(),
true,
&MmpConfig::default(),
None,
)
}
// A genuine new-epoch frame authenticates against `pending` and the
// peer promotes: pending -> current, K-bit flips, plaintext delivered.
#[test]
fn cutover_pending_authenticates_and_promotes() {
let (_cur_send, cur_recv) = ik_session_pair();
let (mut pend_send, pend_recv) = ik_session_pair();
let mut peer = peer_with_current(cur_recv);
let k_before = peer.current_k_bit();
peer.set_pending_session(pend_recv, SessionIndex::new(3), SessionIndex::new(4));
// Peer sealed in the new epoch with the flipped K-bit.
let (ct, counter, hdr) = seal_fmp(
&mut pend_send,
SessionIndex::new(3),
b"new-epoch",
!k_before,
);
// Trial-decrypt against pending succeeds (the cutover signal).
let plaintext = peer
.pending_new_session_mut()
.and_then(|p| p.decrypt_with_replay_check_and_aad(&ct, counter, &hdr).ok())
.expect("new-epoch frame must authenticate against pending");
assert_eq!(plaintext, b"new-epoch");
// Promotion moves pending -> current and flips the K-bit.
assert!(peer.handle_peer_kbit_flip().is_some());
assert!(peer.pending_new_session().is_none());
assert_eq!(peer.current_k_bit(), !k_before);
assert!(peer.previous_session().is_some());
}
// A stale/mismatched frame on a K-bit flip does NOT authenticate
// against `pending`: no promotion, `pending` preserved with its replay
// window intact, and the genuine current session still decrypts a
// subsequent steady-state frame.
#[test]
fn cutover_stale_frame_does_not_promote() {
let (mut cur_send, cur_recv) = ik_session_pair();
let (_pend_send, pend_recv) = ik_session_pair();
// A third, unrelated session whose ciphertext will NOT authenticate
// against `pending` (wrong keys) — simulates a flip belonging to a
// different rekey epoch.
let (mut stale_send, _stale_recv) = ik_session_pair();
let mut peer = peer_with_current(cur_recv);
let k_before = peer.current_k_bit();
peer.set_pending_session(pend_recv, SessionIndex::new(3), SessionIndex::new(4));
// Frame carries the flipped K-bit but is sealed in an unrelated
// session: it must fail to authenticate against `pending`.
let (ct, counter, hdr) =
seal_fmp(&mut stale_send, SessionIndex::new(3), b"stale", !k_before);
let result = peer
.pending_new_session_mut()
.and_then(|p| p.decrypt_with_replay_check_and_aad(&ct, counter, &hdr).ok());
assert!(
result.is_none(),
"stale frame must not authenticate against pending"
);
// No promotion happened: pending preserved, K-bit unchanged.
assert!(peer.pending_new_session().is_some());
assert_eq!(peer.current_k_bit(), k_before);
// The trial-decrypt left pending's replay window untouched, and the
// genuine current session still decrypts steady-state traffic — the
// fall-through path the handler takes on a non-authenticating flip.
let (ct2, counter2, hdr2) =
seal_fmp(&mut cur_send, SessionIndex::new(1), b"steady", k_before);
let cur_pt = peer.noise_session_mut().and_then(|s| {
s.decrypt_with_replay_check_and_aad(&ct2, counter2, &hdr2)
.ok()
});
assert_eq!(cur_pt.as_deref(), Some(&b"steady"[..]));
}
}