mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"[..]));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user