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 25fe87ff60
commit 4af3730be6
2 changed files with 227 additions and 20 deletions
+72 -20
View File
@@ -49,8 +49,27 @@ impl Node {
// Extract K-bit from flags
let received_k_bit = header.flags & FLAG_KEY_EPOCH != 0;
let ciphertext = &packet.data[header.ciphertext_offset()..];
// 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
// `promoted_plaintext` and the normal current/previous decrypt
// below is skipped — the trial-decrypt already advanced `pending`'s
// replay window, so re-decrypting would be rejected as a replay.
let mut promoted_plaintext: Option<Vec<u8>> = None;
{
let peer = self.peers.get(&node_addr).unwrap();
let k_bit_flipped =
@@ -58,31 +77,64 @@ impl Node {
if k_bit_flipped {
let display_name = self.peer_display_name(&node_addr);
info!(
peer = %display_name,
"Peer K-bit flip detected, promoting new session"
);
let peer = self.peers.get_mut(&node_addr).unwrap();
if let Some(_old_our_index) = peer.handle_peer_kbit_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"
// Authenticate the frame against the pending session before
// promoting. 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()
});
if let Some(plaintext) = pending_plaintext {
info!(
peer = %display_name,
"Peer new-epoch frame authenticated, K-bit flip promoting new session"
);
peer.reset_decrypt_failures();
if let Some(_old_our_index) = peer.handle_peer_kbit_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"
);
}
// The trial-decrypt already advanced the pending
// session's replay window; `handle_peer_kbit_flip`
// moved that same session object to `current`. Deliver
// this plaintext and skip the inline decrypt below — a
// re-decrypt of the same counter would be rejected as a
// replay.
promoted_plaintext = Some(plaintext);
}
// 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.
}
}
// Decrypt: try current session first, then previous (drain fallback)
let ciphertext = &packet.data[header.ciphertext_offset()..];
let plaintext = {
// Decrypt: deliver the just-promoted plaintext if the K-bit flip
// authenticated above; otherwise try current session first, then
// previous (drain fallback).
let plaintext = if let Some(plaintext) = promoted_plaintext {
plaintext
} else {
let peer = self.peers.get_mut(&node_addr).unwrap();
let session = match peer.noise_session_mut() {
Some(s) => s,
+155
View File
@@ -870,6 +870,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
@@ -1388,4 +1394,153 @@ 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: a
// genuine new-epoch frame still authenticates afterwards.
let (ct2, counter2, hdr2) = {
// Re-derive a real pending sender that matches the stored pending
// receiver is not possible (keys are internal), so instead assert
// the current session still decrypts steady-state traffic — the
// fall-through path the handler takes on a non-authenticating flip.
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"[..]));
}
}