use super::*;
use crate::node::session_wire::{FSP_FLAG_K, build_fsp_header};
use secp256k1::{Keypair, Secp256k1, SecretKey};
fn keypair(seed: u8) -> Keypair {
let secp = Secp256k1::new();
let mut bytes = [1u8; 32];
bytes[0] = seed;
let sk = SecretKey::from_slice(&bytes).expect("valid secret key");
Keypair::from_secret_key(&secp, &sk)
}
fn xk_pair(init_seed: u8, resp_seed: u8) -> (NoiseSession, NoiseSession) {
let init_kp = keypair(init_seed);
let resp_kp = keypair(resp_seed);
let mut initiator = HandshakeState::new_xk_initiator(init_kp, resp_kp.public_key());
initiator.set_local_epoch([0xA1, 0xB2, 0xC3, 0xD4, 0x11, 0x22, 0x33, 0x44]);
let mut responder = HandshakeState::new_xk_responder(resp_kp);
responder.set_local_epoch([0xD4, 0xC3, 0xB2, 0xA1, 0x44, 0x33, 0x22, 0x11]);
let msg1 = initiator.write_xk_message_1().unwrap();
responder.read_xk_message_1(&msg1).unwrap();
let msg2 = responder.write_xk_message_2().unwrap();
initiator.read_xk_message_2(&msg2).unwrap();
let msg3 = initiator.write_xk_message_3().unwrap();
responder.read_xk_message_3(&msg3).unwrap();
(
initiator.into_session().unwrap(),
responder.into_session().unwrap(),
)
}
fn seal(sender: &mut NoiseSession, plaintext: &[u8], k_bit: bool) -> (Vec<u8>, u64, [u8; 12]) {
let counter = sender.current_send_counter();
let flags = if k_bit { FSP_FLAG_K } else { 0 };
let header = build_fsp_header(counter, flags, plaintext.len() as u16);
let ciphertext = sender.encrypt_with_aad(plaintext, &header).unwrap();
(ciphertext, counter, header)
}
fn entry_with_current(session: NoiseSession) -> SessionEntry {
let addr = NodeAddr::from_bytes([7u8; 16]);
let pubkey = keypair(99).public_key();
let mut entry = SessionEntry::new(
addr,
pubkey,
EndToEndState::Established(session),
1_000,
true,
);
entry.mark_established(1_000);
entry
}
#[test]
fn open_fsp_established_frame_picks_current() {
let (mut cur_send, cur_recv) = xk_pair(1, 2);
let (_p_send, p_recv) = xk_pair(3, 4);
let (_o_send, o_recv) = xk_pair(5, 6);
let mut entry = entry_with_current(cur_recv);
entry.set_pending_session(p_recv);
entry.set_previous_session_for_test(o_recv, 1_000);
let (ct, counter, hdr) = seal(&mut cur_send, b"steady-state", false);
let (pt, slot) = entry
.open_fsp_established_frame(&ct, counter, &hdr, false, 2_000)
.expect("current frame must decrypt");
assert_eq!(pt, b"steady-state");
assert_eq!(slot, EpochSlot::Current);
assert_eq!(entry.pending_highest_counter(), Some(0));
assert_eq!(entry.previous_highest_counter(), Some(0));
}
#[test]
fn open_fsp_established_frame_picks_pending_and_promotes() {
let (_cur_send, cur_recv) = xk_pair(1, 2);
let (mut p_send, p_recv) = xk_pair(3, 4);
let mut entry = entry_with_current(cur_recv);
let k_before = entry.current_k_bit();
entry.set_pending_session(p_recv);
let (ct, counter, hdr) = seal(&mut p_send, b"new-epoch", !k_before);
let (pt, slot) = entry
.open_fsp_established_frame(&ct, counter, &hdr, !k_before, 2_000)
.expect("pending frame must decrypt");
assert_eq!(pt, b"new-epoch");
assert_eq!(slot, EpochSlot::Pending);
entry.handle_peer_kbit_flip(2_000);
assert!(entry.pending_new_session().is_none());
assert!(entry.previous_highest_counter().is_some());
assert_ne!(entry.current_k_bit(), k_before);
}
#[test]
fn open_fsp_established_frame_picks_previous_during_drain() {
let (mut old_send, old_recv) = xk_pair(1, 2);
let (_new_send, new_recv) = xk_pair(3, 4);
let mut entry = entry_with_current(new_recv);
entry.set_previous_session_for_test(old_recv, 1_500);
let k_after = entry.current_k_bit();
let (ct, counter, hdr) = seal(&mut old_send, b"old-straggler", !k_after);
let (pt, slot) = entry
.open_fsp_established_frame(&ct, counter, &hdr, !k_after, 3_000)
.expect("previous frame must decrypt");
assert_eq!(pt, b"old-straggler");
assert_eq!(slot, EpochSlot::Previous);
assert_eq!(entry.current_k_bit(), k_after);
assert!(entry.is_draining());
}
#[test]
fn open_fsp_established_frame_accepts_reordered_old_after_cutover() {
let (mut cur_send, cur_recv) = xk_pair(1, 2);
let (mut p_send, p_recv) = xk_pair(3, 4);
let mut entry = entry_with_current(cur_recv);
let k_before = entry.current_k_bit();
entry.set_pending_session(p_recv);
let (ct_new, c_new, hdr_new) = seal(&mut p_send, b"after-cutover", !k_before);
let (_pt, slot) = entry
.open_fsp_established_frame(&ct_new, c_new, &hdr_new, !k_before, 2_000)
.unwrap();
assert_eq!(slot, EpochSlot::Pending);
entry.handle_peer_kbit_flip(2_000);
let (ct_old, c_old, hdr_old) = seal(&mut cur_send, b"reordered-old", k_before);
let (pt, slot) = entry
.open_fsp_established_frame(&ct_old, c_old, &hdr_old, k_before, 2_500)
.expect("reordered old-epoch frame must still decrypt");
assert_eq!(pt, b"reordered-old");
assert_eq!(slot, EpochSlot::Previous);
}
#[test]
fn open_fsp_established_frame_replay_is_per_slot() {
let (mut cur_send, cur_recv) = xk_pair(1, 2);
let (mut p_send, p_recv) = xk_pair(3, 4);
let mut entry = entry_with_current(cur_recv);
let k_before = entry.current_k_bit();
entry.set_pending_session(p_recv);
let (ct, counter, hdr) = seal(&mut cur_send, b"first", k_before);
let (_pt, slot) = entry
.open_fsp_established_frame(&ct, counter, &hdr, k_before, 2_000)
.unwrap();
assert_eq!(slot, EpochSlot::Current);
assert!(
entry
.open_fsp_established_frame(&ct, counter, &hdr, k_before, 2_100)
.is_err(),
"a genuine replay must be rejected by every slot"
);
assert_eq!(entry.pending_highest_counter(), Some(0));
let (ct_p, c_p, hdr_p) = seal(&mut p_send, b"pending-c0", !k_before);
assert_eq!(c_p, 0);
let (pt, slot) = entry
.open_fsp_established_frame(&ct_p, c_p, &hdr_p, !k_before, 2_200)
.expect("pending frame must decrypt despite current replay overlap");
assert_eq!(pt, b"pending-c0");
assert_eq!(slot, EpochSlot::Pending);
}
#[test]
fn apply_fsp_receive_sync_rejects_rx_loop_seen_counter() {
let (mut cur_send, cur_recv) = xk_pair(1, 2);
let mut entry = entry_with_current(cur_recv);
let k_bit = entry.current_k_bit();
let (ct, counter, hdr) = seal(&mut cur_send, b"slow-path-first", k_bit);
entry
.open_fsp_established_frame(&ct, counter, &hdr, k_bit, 2_000)
.expect("slow path receive should consume replay counter");
let sync = FspReceiveSync {
counter,
slot: EpochSlot::Current,
received_k_bit: k_bit,
timestamp: 0x0102_0304,
plaintext_len: b"slow-path-first".len(),
ce_flag: false,
path_mtu: 1_280,
spin_bit: false,
};
assert!(
!entry
.apply_fsp_receive_sync_result(sync, 2_100, Instant::now())
.is_applied(),
"rx-loop mirror must not dispatch a worker-authenticated replay"
);
}
#[test]
fn open_fsp_established_frame_failed_slot_leaves_replay_window_intact() {
let (_cur_send, cur_recv) = xk_pair(1, 2);
let (mut p_send, p_recv) = xk_pair(3, 4);
let (_o_send, o_recv) = xk_pair(5, 6);
let mut entry = entry_with_current(cur_recv);
let k_before = entry.current_k_bit();
entry.set_pending_session(p_recv);
entry.set_previous_session_for_test(o_recv, 1_000);
for _ in 0..4 {
let _ = seal(&mut p_send, b"warmup", !k_before);
}
let (ct, counter, hdr) = seal(&mut p_send, b"pending-hit", !k_before);
assert_eq!(counter, 4);
let (_pt, slot) = entry
.open_fsp_established_frame(&ct, counter, &hdr, false, 2_000)
.expect("pending frame must decrypt");
assert_eq!(slot, EpochSlot::Pending);
assert_eq!(entry.current_highest_counter(), Some(0));
assert_eq!(entry.previous_highest_counter(), Some(0));
assert_eq!(entry.pending_highest_counter(), Some(4));
}
#[test]
fn open_fsp_established_frame_failed_all_epochs_does_not_consume_replay() {
let (mut cur_send, cur_recv) = xk_pair(1, 2);
let (_p_send, p_recv) = xk_pair(3, 4);
let (_o_send, o_recv) = xk_pair(5, 6);
let mut entry = entry_with_current(cur_recv);
let k_bit = entry.current_k_bit();
entry.set_pending_session(p_recv);
entry.set_previous_session_for_test(o_recv, 1_000);
for _ in 0..3 {
let _ = seal(&mut cur_send, b"warmup", k_bit);
}
let (ciphertext, counter, header) = seal(&mut cur_send, b"current-after-forgery", k_bit);
assert_eq!(counter, 3);
let mut forged = ciphertext.clone();
let last = forged.last_mut().expect("ciphertext has an AEAD tag");
*last ^= 0x55;
assert_eq!(
entry.open_fsp_established_frame(&forged, counter, &header, k_bit, 2_000),
Err(FspOpenError::NoLiveEpochAccepted),
"forged ciphertext must fail without being accepted into any replay window"
);
assert_eq!(entry.current_highest_counter(), Some(0));
assert_eq!(entry.pending_highest_counter(), Some(0));
assert_eq!(entry.previous_highest_counter(), Some(0));
let (plaintext, slot) = entry
.open_fsp_established_frame(&ciphertext, counter, &header, k_bit, 2_100)
.expect("valid frame must still open after forged failure");
assert_eq!(plaintext, b"current-after-forgery");
assert_eq!(slot, EpochSlot::Current);
assert_eq!(entry.current_highest_counter(), Some(counter));
assert_eq!(entry.pending_highest_counter(), Some(0));
assert_eq!(entry.previous_highest_counter(), Some(0));
assert!(
entry
.open_fsp_established_frame(&ciphertext, counter, &header, k_bit, 2_200)
.is_err(),
"the valid frame is replay-protected after successful open"
);
}
#[cfg(unix)]
#[test]
fn reserve_fsp_worker_send_owns_counter_header_and_cipher() {
use ring::aead::Aad;
let (send_session, mut recv_session) = xk_pair(1, 2);
let mut entry = entry_with_current(send_session);
let flags = FSP_FLAG_K;
let plaintext = b"worker-sealed-fsp-frame";
let reservation = entry
.reserve_fsp_worker_send(flags, plaintext.len() as u16)
.expect("counter reservation should succeed")
.expect("established session should expose a send cipher");
assert_eq!(reservation.counter, 0);
assert_eq!(
entry.send_counter(),
1,
"reservation is the only session mutation before worker dispatch"
);
assert_eq!(
reservation.header,
build_fsp_header(reservation.counter, flags, plaintext.len() as u16)
);
let mut ciphertext = plaintext.to_vec();
reservation
.cipher
.seal_in_place_append_tag(
crate::noise::CipherState::counter_to_nonce(reservation.counter),
Aad::from(&reservation.header),
&mut ciphertext,
)
.expect("worker-style FSP seal should succeed");
assert_eq!(
entry.send_counter(),
1,
"worker cipher use must not mutate the owning session"
);
assert_eq!(
recv_session
.decrypt_with_replay_check_and_aad(
&ciphertext,
reservation.counter,
&reservation.header,
)
.expect("receiver should accept worker-sealed FSP frame"),
plaintext
);
let next = entry
.reserve_fsp_worker_send(flags, plaintext.len() as u16)
.expect("second counter reservation should succeed")
.expect("established session should still expose a send cipher");
assert_eq!(next.counter, 1);
assert_eq!(
next.header,
build_fsp_header(next.counter, flags, plaintext.len() as u16)
);
}
#[test]
fn msg3_retransmit_stops_on_peer_new_epoch_confirmed() {
let (_cur_send, cur_recv) = xk_pair(1, 2);
let (mut p_send, p_recv) = xk_pair(3, 4);
let mut entry = entry_with_current(cur_recv);
entry.set_pending_session(p_recv);
entry.set_rekey_completed_ms(1_000);
entry.set_rekey_msg3_payload(vec![0xAB; 73], 1_500);
assert!(entry.cutover_to_new_session(2_000));
assert!(entry.rekey_msg3_payload().is_some());
assert!(!entry.peer_new_epoch_confirmed());
let k_now = entry.current_k_bit();
let (ct, counter, hdr) = seal(&mut p_send, b"peer-on-new-epoch", k_now);
let (_pt, slot) = entry
.open_fsp_established_frame(&ct, counter, &hdr, k_now, 2_500)
.unwrap();
assert_eq!(slot, EpochSlot::Current);
assert!(entry.rekey_msg3_payload().is_some() && entry.pending_new_session().is_none());
entry.confirm_peer_new_epoch();
assert!(entry.peer_new_epoch_confirmed());
assert!(entry.rekey_msg3_payload().is_none());
}
#[test]
fn msg3_retransmit_budget_exhaustion_abandons_cleanly() {
let (_cur_send, cur_recv) = xk_pair(1, 2);
let (_p_send, p_recv) = xk_pair(3, 4);
let mut entry = entry_with_current(cur_recv);
entry.set_pending_session(p_recv);
entry.set_rekey_completed_ms(1_000);
entry.set_rekey_msg3_payload(vec![0xCD; 73], 1_500);
let max_resends = 8;
for i in 0..max_resends {
entry.record_rekey_msg3_resend(2_000 + i as u64 * 100);
}
assert_eq!(entry.rekey_msg3_resend_count(), max_resends);
entry.abandon_rekey();
assert!(entry.rekey_msg3_payload().is_none());
assert!(entry.pending_new_session().is_none());
assert!(!entry.has_rekey_in_progress());
assert!(entry.is_established());
assert!(!entry.peer_new_epoch_confirmed());
}
#[test]
fn initiator_cutover_keeps_responder_old_epoch_decryptable() {
let (old_a, old_b) = xk_pair(1, 2);
let (new_a, mut new_b) = xk_pair(3, 4);
let mut a = entry_with_current(old_a);
a.set_rekey_completed_ms(1_000);
a.set_rekey_msg3_payload(vec![0xEE; 73], 1_500);
a.set_pending_session(new_a);
assert!(a.cutover_to_new_session(2_000));
assert!(a.rekey_msg3_payload().is_some());
let mut b = entry_with_current(old_b);
let (ct_new, c_new, hdr_new) = seal(&mut new_b, b"new-from-a", true);
assert!(
b.open_fsp_established_frame(&ct_new, c_new, &hdr_new, true, 2_100)
.is_err(),
"responder without msg3 drops the new-epoch frame cleanly"
);
let (ct_old, c_old, hdr_old) = {
let b_old = b.current_noise_session_mut().unwrap();
seal(b_old, b"old-from-b", false)
};
let (pt, slot) = a
.open_fsp_established_frame(&ct_old, c_old, &hdr_old, false, 2_200)
.expect("initiator must still decrypt the responder's old-epoch frame");
assert_eq!(pt, b"old-from-b");
assert_eq!(slot, EpochSlot::Previous);
let (new_a2, mut new_b2) = xk_pair(3, 4);
b.set_pending_session(new_a2);
let (ct_new2, c_new2, hdr_new2) = seal(&mut new_b2, b"new-from-a-2", true);
let (pt, slot) = b
.open_fsp_established_frame(&ct_new2, c_new2, &hdr_new2, true, 2_300)
.expect("responder must decrypt new-epoch frame once pending is installed");
assert_eq!(pt, b"new-from-a-2");
assert_eq!(slot, EpochSlot::Pending);
}
#[test]
fn drain_expiry_is_peer_progress_aware() {
const DRAIN_MS: u64 = 10_000;
let cutover_ms = 1_000;
let (mut old_send, old_recv) = xk_pair(1, 2);
let (_new_send, new_recv) = xk_pair(3, 4);
let mut entry = entry_with_current(old_recv);
entry.set_pending_session(new_recv);
assert!(entry.cutover_to_new_session(cutover_ms));
assert!(entry.is_draining());
let k_old = !entry.current_k_bit();
for &t in &[5_000u64, 15_000, 25_000] {
let (ct, counter, hdr) = seal(&mut old_send, b"still-old-epoch", k_old);
let (_pt, slot) = entry
.open_fsp_established_frame(&ct, counter, &hdr, k_old, t)
.expect("old-epoch frame must still decrypt while peer uses it");
assert_eq!(slot, EpochSlot::Previous);
assert!(
!entry.drain_expired(t, DRAIN_MS),
"previous slot must not be retired while peer keeps using it"
);
assert!(entry.previous_highest_counter().is_some());
}
assert!(!entry.drain_expired(34_999, DRAIN_MS));
assert!(entry.drain_expired(35_000, DRAIN_MS));
entry.complete_drain();
assert!(entry.previous_highest_counter().is_none());
assert!(!entry.is_draining());
}
#[test]
fn drain_expiry_unaffected_when_peer_off_old_epoch() {
const DRAIN_MS: u64 = 10_000;
let cutover_ms = 1_000;
let (_old_send, old_recv) = xk_pair(1, 2);
let (_new_send, new_recv) = xk_pair(3, 4);
let mut entry = entry_with_current(old_recv);
entry.set_pending_session(new_recv);
assert!(entry.cutover_to_new_session(cutover_ms));
assert!(!entry.drain_expired(cutover_ms + DRAIN_MS - 1, DRAIN_MS));
assert!(entry.drain_expired(cutover_ms + DRAIN_MS, DRAIN_MS));
}