From f44de56fd85fec2f0f884e696611cb1fc435d703 Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Fri, 24 Jul 2026 03:39:47 +0000 Subject: [PATCH] Add an in-process test that a rekey cutover preserves the data plane Drives a real IK rekey handshake to K-bit cutover between two loopback nodes and asserts an encrypted datagram sent after the cutover decodes on the new session, with no spurious peer teardown. The rekey is forced deterministically: rekey.after_messages = 1 crosses the initiator's trigger on the first sent datagram, and both sessions are backdated past the responder's 30s rekey-acceptance gate, so no wall clock is read. The cutover is proven to actually occur (the live session index changes), which is what makes the continuity assertion meaningful rather than vacuous. Adds a make_test_node_with_config loopback helper for setting the rekey thresholds. The rekey timing and choreography decisions themselves are already covered exhaustively by the sans-IO poll_rekey tests. --- src/node/tests/session.rs | 153 +++++++++++++++++++++++++++++++- src/node/tests/spanning_tree.rs | 25 ++++++ 2 files changed, 176 insertions(+), 2 deletions(-) diff --git a/src/node/tests/session.rs b/src/node/tests/session.rs index 4ad7c65..a671c39 100644 --- a/src/node/tests/session.rs +++ b/src/node/tests/session.rs @@ -3,8 +3,9 @@ use super::*; use crate::node::session::EndToEndState; use crate::node::tests::spanning_tree::{ - TestNode, cleanup_nodes, generate_random_edges, lock_large_network_test, - process_available_packets, run_tree_test, run_tree_test_with_mtus, verify_tree_convergence, + TestNode, cleanup_nodes, drain_all_packets, generate_random_edges, initiate_handshake, + lock_large_network_test, make_test_node_with_config, process_available_packets, run_tree_test, + run_tree_test_with_mtus, verify_tree_convergence, }; use crate::proto::fsp::SessionAck; use crate::proto::link::SessionDatagram; @@ -1094,6 +1095,154 @@ async fn test_tun_outbound_established_session() { cleanup_nodes(&mut nodes).await; } +/// A completed rekey cutover must not break the data plane: an encrypted +/// datagram sent after the K-bit cutover decodes on the new session, and the +/// peer is not spuriously torn down. +/// +/// This is the wire-continuity half of the rekey property the Docker `rekey` +/// suites exercised. The rekey timing and choreography decision itself lives in +/// the sans-IO cores and is covered exhaustively there +/// (`proto/fsp/tests/core.rs`, `proto/fmp/tests/core.rs`); this drives a real +/// IK rekey handshake over the loopback transport so the AEAD continuity across +/// the cutover is asserted end to end. +/// +/// Deterministic, no wall-clock wait: `rekey.after_messages = 1` makes the +/// first sent datagram cross the initiator's trigger, and both sessions are +/// backdated past the responder's 30s rekey-acceptance gate. +#[tokio::test] +async fn rekey_cutover_preserves_data_plane() { + // node 0 rekeys on the message counter; time never triggers it. + let mut cfg0 = crate::config::Config::new(); + cfg0.node.rekey.enabled = true; + cfg0.node.rekey.after_messages = 1; + cfg0.node.rekey.after_secs = u64::MAX; + let cfg1 = crate::config::Config::new(); + + let mut nodes = vec![ + make_test_node_with_config(cfg0).await, + make_test_node_with_config(cfg1).await, + ]; + + // FMP peering + FSP session between the two loopback nodes. + initiate_handshake(&mut nodes, 0, 1).await; + drain_all_packets(&mut nodes, false).await; + let node0_addr = *nodes[0].node.node_addr(); + let node1_addr = *nodes[1].node.node_addr(); + assert!(nodes[0].node.get_peer(&node1_addr).is_some()); + assert!(nodes[1].node.get_peer(&node0_addr).is_some()); + populate_all_coord_caches(&mut nodes); + + let node1_pubkey = nodes[1].node.identity().pubkey_full(); + nodes[0] + .node + .initiate_session(node1_addr, node1_pubkey) + .await + .unwrap(); + for _ in 0..4 { + tokio::time::sleep(Duration::from_millis(10)).await; + process_available_packets(&mut nodes).await; + } + assert!( + nodes[0] + .node + .get_session(&node1_addr) + .unwrap() + .state() + .is_established(), + "session established" + ); + + // node 1's TUN receiver observes decoded plaintext. + let (tun_tx, tun_rx) = std::sync::mpsc::channel(); + nodes[1].node.supervisor.tun_tx = Some(tun_tx); + let src_fips = crate::FipsAddress::from_node_addr(&node0_addr); + let dst_fips = crate::FipsAddress::from_node_addr(&node1_addr); + + // Baseline: data decodes on the original session, and this send bumps the + // counter across the rekey trigger. + let pre = build_ipv6_packet(&src_fips, &dst_fips, b"pre-rekey-payload"); + nodes[0].node.handle_tun_outbound(pre.clone()).await; + tokio::time::sleep(Duration::from_millis(10)).await; + process_available_packets(&mut nodes).await; + let pre_delivered: Vec> = std::iter::from_fn(|| tun_rx.try_recv().ok()).collect(); + assert_eq!( + pre_delivered, + vec![pre.clone()], + "baseline datagram must decode before the rekey" + ); + + let idx_before = nodes[0].node.get_peer(&node1_addr).unwrap().our_index(); + + // Age both sessions past the responder's 30s rekey-acceptance gate so the + // rekey msg1 is treated as a rekey rather than a fresh connection. + nodes[0] + .node + .get_peer_mut(&node1_addr) + .unwrap() + .test_backdate_session_established(Duration::from_secs(31)); + nodes[1] + .node + .get_peer_mut(&node0_addr) + .unwrap() + .test_backdate_session_established(Duration::from_secs(31)); + + // Drive the real rekey handshake (msg1/msg2/msg3 over loopback) to cutover. + for _ in 0..6 { + nodes[0].node.check_rekey().await; + nodes[1].node.check_rekey().await; + for _ in 0..3 { + tokio::time::sleep(Duration::from_millis(5)).await; + process_available_packets(&mut nodes).await; + } + } + + // The cutover actually happened: node 0's live session index changed and no + // rekey is left dangling. Guards against a vacuous pass where the rekey + // never fired. + let idx_after = nodes[0].node.get_peer(&node1_addr).unwrap().our_index(); + assert_ne!( + idx_after, idx_before, + "rekey must cut the live session over to a new index" + ); + assert!( + !nodes[0] + .node + .get_peer(&node1_addr) + .unwrap() + .rekey_in_progress(), + "rekey must have completed, not left in progress" + ); + + // Continuity: a datagram sent after the cutover decodes on the NEW session. + let post = build_ipv6_packet(&src_fips, &dst_fips, b"post-rekey-payload"); + nodes[0].node.handle_tun_outbound(post.clone()).await; + tokio::time::sleep(Duration::from_millis(10)).await; + process_available_packets(&mut nodes).await; + let post_delivered: Vec> = std::iter::from_fn(|| tun_rx.try_recv().ok()).collect(); + assert_eq!( + post_delivered, + vec![post.clone()], + "datagram sent after the cutover must decode on the new session" + ); + + // No spurious teardown across the rekey. + assert!( + nodes[0].node.get_peer(&node1_addr).is_some(), + "peer must survive the rekey" + ); + assert!( + nodes[0] + .node + .get_session(&node1_addr) + .unwrap() + .state() + .is_established(), + "session must remain established after the rekey" + ); + + cleanup_nodes(&mut nodes).await; +} + #[tokio::test] async fn test_tun_outbound_triggers_session_initiation() { // Two connected nodes, no session yet. diff --git a/src/node/tests/spanning_tree.rs b/src/node/tests/spanning_tree.rs index 84af809..d0446d9 100644 --- a/src/node/tests/spanning_tree.rs +++ b/src/node/tests/spanning_tree.rs @@ -100,6 +100,31 @@ pub(super) async fn make_test_node_with_mtu(mtu: u16) -> TestNode { } } +/// Create a loopback test node from an explicit `Config`, e.g. to set the +/// `node.rekey` thresholds a rekey-behaviour test needs. Otherwise identical to +/// [`make_test_node`] (default 1280 MTU, in-process loopback transport). +pub(super) async fn make_test_node_with_config(config: crate::config::Config) -> TestNode { + let mut node = make_node_with(config); + let transport_id = TransportId::new(1); + + let (tx, rx) = tokio::sync::mpsc::unbounded_channel::(); + let addr = next_loopback_addr(); + + LOOPBACK_REGISTRY.lock().unwrap().insert(addr.clone(), tx); + + let loopback = + LoopbackTransport::with_mtu(transport_id, addr.clone(), 1280, LOOPBACK_REGISTRY.clone()); + node.transports + .insert(transport_id, TransportHandle::Loopback(loopback)); + + TestNode { + node, + transport_id, + packet_rx: rx, + addr, + } +} + /// Initiate a Noise handshake from nodes[i] to nodes[j]. /// /// Sends msg1 over UDP. The drain loop will handle msg1 processing,