diff --git a/src/node/handlers/encrypted.rs b/src/node/handlers/encrypted.rs index 8fd9ab1..211c4f6 100644 --- a/src/node/handlers/encrypted.rs +++ b/src/node/handlers/encrypted.rs @@ -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> = 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, diff --git a/src/peer/active.rs b/src/peer/active.rs index 3a32593..3748641 100644 --- a/src/peer/active.rs +++ b/src/peer/active.rs @@ -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, 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"[..])); + } }