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,