mirror of
https://github.com/jmcorgan/fips.git
synced 2026-10-05 11:08:25 +00:00
The test asserted that node 0 decrypts inline by reading the supervisor's decrypt pool field, which exists only on Unix, so the Windows test build failed. Windows never spawns the pools and always decrypts inline, so the assertion is needed only where a pool could exist.
9181 lines
317 KiB
Rust
9181 lines
317 KiB
Rust
//! End-to-end session establishment tests.
|
|
|
|
use super::*;
|
|
use crate::node::session::EndToEndState;
|
|
use crate::node::tests::spanning_tree::{
|
|
TestNode, cleanup_nodes, drain_all_packets, generate_random_edges, initiate_handshake,
|
|
lock_large_network_test, make_test_node_with_config, pinned_tree, populate_all_coord_caches,
|
|
process_available_packets, run_tree_test, run_tree_test_with_configs, run_tree_test_with_mtus,
|
|
run_tree_test_with_profiles, run_tree_test_with_profiles_leaf_smallest,
|
|
verify_tree_convergence,
|
|
};
|
|
use crate::proto::fsp::{SessionAck, SessionMsg3};
|
|
use crate::proto::link::SessionDatagram;
|
|
|
|
/// A stand-in for the authenticated FMP link peer a datagram arrived over.
|
|
///
|
|
/// Tests that call `handle_session_payload` directly have no link underneath
|
|
/// them. The setup limiter keys on this address, so a test wanting to drain a
|
|
/// bucket has to drive `handle_session_datagram` instead.
|
|
fn stub_link_peer() -> NodeAddr {
|
|
make_node_addr(0xFE)
|
|
}
|
|
|
|
/// Render each node's address and elected tree root.
|
|
///
|
|
/// Attached to mixed-profile failure messages: the failures worth diagnosing
|
|
/// there are root-election partitions, which are keyed on the random per-node
|
|
/// address ordering and so are invisible without the addresses that produced
|
|
/// them.
|
|
fn mesh_state(nodes: &[TestNode]) -> String {
|
|
nodes
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, tn)| {
|
|
format!(
|
|
"node[{i}] addr={} root={} self_rooted={}",
|
|
tn.node.node_addr(),
|
|
tn.node.tree_state().root(),
|
|
tn.node.tree_state().is_root()
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("; ")
|
|
}
|
|
|
|
// ============================================================================
|
|
// Unit tests: SessionEntry data structure
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_session_entry_new_initiating() {
|
|
use crate::noise::HandshakeState;
|
|
|
|
let identity_a = Identity::generate();
|
|
let identity_b = Identity::generate();
|
|
|
|
let handshake = HandshakeState::new_initiator(identity_a.keypair());
|
|
|
|
let entry = crate::node::session::SessionEntry::new(
|
|
*identity_b.node_addr(),
|
|
identity_b.pubkey_full(),
|
|
EndToEndState::Initiating(handshake),
|
|
1000,
|
|
true,
|
|
);
|
|
|
|
assert!(entry.state().is_initiating());
|
|
assert!(!entry.state().is_established());
|
|
assert!(!entry.state().is_awaiting_msg3());
|
|
assert_eq!(entry.created_at(), 1000);
|
|
assert_eq!(entry.last_activity(), 1000);
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_entry_rekey_jitter_in_range() {
|
|
use crate::node::REKEY_JITTER_SECS;
|
|
use crate::noise::HandshakeState;
|
|
|
|
// Every newly constructed SessionEntry's jitter must lie in the
|
|
// symmetric range [-REKEY_JITTER_SECS, +REKEY_JITTER_SECS].
|
|
for _ in 0..100 {
|
|
let identity_a = Identity::generate();
|
|
let identity_b = Identity::generate();
|
|
let handshake = HandshakeState::new_initiator(identity_a.keypair());
|
|
let entry = crate::node::session::SessionEntry::new(
|
|
*identity_b.node_addr(),
|
|
identity_b.pubkey_full(),
|
|
EndToEndState::Initiating(handshake),
|
|
1000,
|
|
true,
|
|
);
|
|
let j = entry.rekey_jitter_secs();
|
|
assert!(
|
|
(-REKEY_JITTER_SECS..=REKEY_JITTER_SECS).contains(&j),
|
|
"jitter {} outside [-{}, +{}]",
|
|
j,
|
|
REKEY_JITTER_SECS,
|
|
REKEY_JITTER_SECS
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_entry_rekey_jitter_mean_near_zero() {
|
|
use crate::noise::HandshakeState;
|
|
|
|
// Sanity check that the distribution is roughly symmetric and not
|
|
// stuck at one extreme. With N=200 draws from a uniform ~30-second
|
|
// range, the empirical mean should be well under 5 in absolute value.
|
|
let mut sum: i64 = 0;
|
|
let n: i64 = 200;
|
|
for _ in 0..n {
|
|
let identity_a = Identity::generate();
|
|
let identity_b = Identity::generate();
|
|
let handshake = HandshakeState::new_initiator(identity_a.keypair());
|
|
let entry = crate::node::session::SessionEntry::new(
|
|
*identity_b.node_addr(),
|
|
identity_b.pubkey_full(),
|
|
EndToEndState::Initiating(handshake),
|
|
1000,
|
|
true,
|
|
);
|
|
sum += entry.rekey_jitter_secs();
|
|
}
|
|
let mean = sum / n;
|
|
assert!(
|
|
mean.abs() < 5,
|
|
"empirical mean {} not within 5 of 0 over {} samples",
|
|
mean,
|
|
n
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_entry_touch() {
|
|
use crate::noise::HandshakeState;
|
|
|
|
let identity_a = Identity::generate();
|
|
let identity_b = Identity::generate();
|
|
|
|
let handshake = HandshakeState::new_initiator(identity_a.keypair());
|
|
|
|
let mut entry = crate::node::session::SessionEntry::new(
|
|
*identity_b.node_addr(),
|
|
identity_b.pubkey_full(),
|
|
EndToEndState::Initiating(handshake),
|
|
1000,
|
|
true,
|
|
);
|
|
|
|
entry.touch(2000);
|
|
assert_eq!(entry.last_activity(), 2000);
|
|
assert_eq!(entry.created_at(), 1000);
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_table_operations() {
|
|
use crate::noise::HandshakeState;
|
|
|
|
let mut node = make_node();
|
|
let identity_b = Identity::generate();
|
|
|
|
let handshake = HandshakeState::new_initiator(node.identity().keypair());
|
|
|
|
let dest_addr = *identity_b.node_addr();
|
|
let entry = crate::node::session::SessionEntry::new(
|
|
dest_addr,
|
|
identity_b.pubkey_full(),
|
|
EndToEndState::Initiating(handshake),
|
|
1000,
|
|
true,
|
|
);
|
|
|
|
node.sessions.insert(dest_addr, entry);
|
|
assert_eq!(node.session_count(), 1);
|
|
assert!(node.get_session(&dest_addr).is_some());
|
|
assert!(node.get_session(&make_node_addr(0xFF)).is_none());
|
|
|
|
let removed = node.remove_session(&dest_addr);
|
|
assert!(removed.is_some());
|
|
assert_eq!(node.session_count(), 0);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Integration tests: 2-node direct session establishment
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_session_direct_peer_handshake() {
|
|
// Two directly connected nodes: A initiates a session with B
|
|
let edges = vec![(0, 1)];
|
|
let mut nodes = run_tree_test(2, &edges, false).await;
|
|
verify_tree_convergence(&nodes);
|
|
populate_all_coord_caches(&mut nodes);
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
let node1_pubkey = nodes[1].node.identity().pubkey_full();
|
|
|
|
// Node 0 initiates session with Node 1
|
|
nodes[0]
|
|
.node
|
|
.initiate_session(node1_addr, node1_pubkey)
|
|
.await
|
|
.expect("initiate_session failed");
|
|
|
|
// Node 0 should have a session in Initiating state
|
|
assert_eq!(nodes[0].node.session_count(), 1);
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.state()
|
|
.is_initiating()
|
|
);
|
|
|
|
// Process packets: SessionSetup arrives at Node 1
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
let count = process_available_packets(&mut nodes).await;
|
|
assert!(count > 0, "Expected SessionSetup packet to arrive");
|
|
|
|
// Node 1 should now have a session in AwaitingMsg3 state (XX: identity not yet known)
|
|
assert_eq!(nodes[1].node.session_count(), 1);
|
|
assert!(
|
|
nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.unwrap()
|
|
.state()
|
|
.is_awaiting_msg3()
|
|
);
|
|
|
|
// Process packets: SessionAck arrives at Node 0, Node 0 sends SessionMsg3
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
let count = process_available_packets(&mut nodes).await;
|
|
assert!(count > 0, "Expected SessionAck packet to arrive");
|
|
|
|
// Node 0 should now be Established (transitions after sending msg3)
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.state()
|
|
.is_established()
|
|
);
|
|
|
|
// Process packets: SessionMsg3 arrives at Node 1
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
let count = process_available_packets(&mut nodes).await;
|
|
assert!(count > 0, "Expected SessionMsg3 packet to arrive");
|
|
|
|
// Node 1 should now be Established (transitions after processing msg3)
|
|
assert!(
|
|
nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.unwrap()
|
|
.state()
|
|
.is_established()
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_session_direct_peer_data_transfer() {
|
|
// Two nodes: establish session, then send data
|
|
let edges = vec![(0, 1)];
|
|
let mut nodes = run_tree_test(2, &edges, false).await;
|
|
verify_tree_convergence(&nodes);
|
|
populate_all_coord_caches(&mut nodes);
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
let node1_pubkey = nodes[1].node.identity().pubkey_full();
|
|
|
|
// Establish session (XX: 3 messages — Setup, Ack, Msg3)
|
|
nodes[0]
|
|
.node
|
|
.initiate_session(node1_addr, node1_pubkey)
|
|
.await
|
|
.unwrap();
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await; // Setup → Node 1
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await; // Ack → Node 0, Node 0 sends Msg3
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await; // Msg3 → Node 1
|
|
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.state()
|
|
.is_established()
|
|
);
|
|
assert!(
|
|
nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.unwrap()
|
|
.state()
|
|
.is_established()
|
|
);
|
|
|
|
// Send data from Node 0 to Node 1
|
|
let test_data = b"Hello, FIPS session!";
|
|
nodes[0]
|
|
.node
|
|
.send_session_data(&node1_addr, 0, 0, test_data)
|
|
.await
|
|
.expect("send_session_data failed");
|
|
|
|
// Process packets: encrypted data arrives at Node 1
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
let count = process_available_packets(&mut nodes).await;
|
|
assert!(count > 0, "Expected encrypted data to arrive");
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Integration tests: 3-node forwarded session
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_session_3node_forwarded_handshake() {
|
|
// A—B—C: Node A initiates session with Node C through transit node B
|
|
let edges = vec![(0, 1), (1, 2)];
|
|
let mut nodes = run_tree_test(3, &edges, false).await;
|
|
verify_tree_convergence(&nodes);
|
|
populate_all_coord_caches(&mut nodes);
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node2_addr = *nodes[2].node.node_addr();
|
|
let node2_pubkey = nodes[2].node.identity().pubkey_full();
|
|
|
|
// Node 0 initiates session with Node 2
|
|
nodes[0]
|
|
.node
|
|
.initiate_session(node2_addr, node2_pubkey)
|
|
.await
|
|
.expect("initiate_session failed");
|
|
|
|
// Process: SessionSetup: 0→1 (forwarded by transit B)
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
|
|
// Process: SessionSetup: 1→2 (arrives at destination C)
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
|
|
// Node 2 should have an AwaitingMsg3 session (XX: identity not yet known)
|
|
assert!(
|
|
nodes[2].node.get_session(&node0_addr).is_some(),
|
|
"Node 2 should have a session entry for Node 0"
|
|
);
|
|
assert!(
|
|
nodes[2]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.unwrap()
|
|
.state()
|
|
.is_awaiting_msg3()
|
|
);
|
|
|
|
// Process: SessionAck: 2→1 (forwarded by transit B)
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
|
|
// Process: SessionAck: 1→0 (arrives at initiator A, sends SessionMsg3)
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
|
|
// Node 0 should now be Established (transitions after sending msg3)
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node2_addr)
|
|
.unwrap()
|
|
.state()
|
|
.is_established()
|
|
);
|
|
|
|
// Process: SessionMsg3: 0→1 (forwarded by transit B)
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
|
|
// Process: SessionMsg3: 1→2 (arrives at responder C)
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
|
|
// Node 2 should now be Established (transitions after processing msg3)
|
|
assert!(
|
|
nodes[2]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.unwrap()
|
|
.state()
|
|
.is_established()
|
|
);
|
|
|
|
// Transit node B should NOT have a session
|
|
assert_eq!(
|
|
nodes[1].node.session_count(),
|
|
0,
|
|
"Transit node should have no sessions"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_session_3node_forwarded_data() {
|
|
// A—B—C: Establish session, send data end-to-end
|
|
let edges = vec![(0, 1), (1, 2)];
|
|
let mut nodes = run_tree_test(3, &edges, false).await;
|
|
verify_tree_convergence(&nodes);
|
|
populate_all_coord_caches(&mut nodes);
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node2_addr = *nodes[2].node.node_addr();
|
|
let node2_pubkey = nodes[2].node.identity().pubkey_full();
|
|
|
|
// Establish session (needs more hops)
|
|
nodes[0]
|
|
.node
|
|
.initiate_session(node2_addr, node2_pubkey)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Drain packets until handshake completes (multi-hop needs several rounds)
|
|
for _ in 0..10 {
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
}
|
|
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node2_addr)
|
|
.map(|s| s.state().is_established())
|
|
.unwrap_or(false),
|
|
"Session should be established after handshake rounds"
|
|
);
|
|
|
|
// Send data
|
|
let test_data = b"End-to-end through transit node B";
|
|
nodes[0]
|
|
.node
|
|
.send_session_data(&node2_addr, 0, 0, test_data)
|
|
.await
|
|
.expect("send_session_data failed");
|
|
|
|
// Drain data packet through transit node
|
|
for _ in 0..5 {
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
}
|
|
|
|
// Node 2 should be Established (transitioned during XX handshake msg3)
|
|
assert!(
|
|
nodes[2]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.unwrap()
|
|
.state()
|
|
.is_established()
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Edge cases
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_session_initiate_idempotent() {
|
|
// Calling initiate_session twice should be idempotent
|
|
let edges = vec![(0, 1)];
|
|
let mut nodes = run_tree_test(2, &edges, false).await;
|
|
verify_tree_convergence(&nodes);
|
|
populate_all_coord_caches(&mut nodes);
|
|
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
let node1_pubkey = nodes[1].node.identity().pubkey_full();
|
|
|
|
// First call
|
|
nodes[0]
|
|
.node
|
|
.initiate_session(node1_addr, node1_pubkey)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(nodes[0].node.session_count(), 1);
|
|
|
|
// Second call should be a no-op
|
|
nodes[0]
|
|
.node
|
|
.initiate_session(node1_addr, node1_pubkey)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(nodes[0].node.session_count(), 1);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_session_send_data_no_session_fails() {
|
|
let mut node = make_node();
|
|
let fake_addr = make_node_addr(0xAA);
|
|
|
|
let result = node.send_session_data(&fake_addr, 0, 0, b"test").await;
|
|
assert!(result.is_err(), "Should fail with no session");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_session_ack_for_unknown_session() {
|
|
// Receiving a SessionAck when we have no Initiating session should be dropped
|
|
let edges = vec![(0, 1)];
|
|
let mut nodes = run_tree_test(2, &edges, false).await;
|
|
verify_tree_convergence(&nodes);
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
|
|
// Fabricate a SessionAck and deliver directly
|
|
let src_coords = nodes[1].node.tree_state().my_coords().clone();
|
|
let dest_coords = nodes[0].node.tree_state().my_coords().clone();
|
|
let ack = SessionAck::new(src_coords, dest_coords).with_handshake(vec![0u8; 57]);
|
|
let datagram = SessionDatagram::new(node1_addr, node0_addr, ack.encode());
|
|
|
|
// Send through link layer
|
|
let encoded = datagram.encode();
|
|
nodes[1]
|
|
.node
|
|
.send_encrypted_link_message(&node0_addr, &encoded)
|
|
.await
|
|
.unwrap();
|
|
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
|
|
// Node 0 should have no sessions (ack was for unknown session)
|
|
assert_eq!(nodes[0].node.session_count(), 0);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Large-scale test: 100-node session establishment + bidirectional data
|
|
// ============================================================================
|
|
|
|
/// Drain packets until quiescent (2 consecutive idle rounds).
|
|
async fn drain_to_quiescence(nodes: &mut [TestNode]) {
|
|
let mut idle_rounds = 0;
|
|
for _ in 0..40 {
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
let count = process_available_packets(nodes).await;
|
|
if count == 0 {
|
|
idle_rounds += 1;
|
|
if idle_rounds >= 2 {
|
|
break;
|
|
}
|
|
} else {
|
|
idle_rounds = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Mixed-profile lifecycle over loopback: a Full / Full / NonRouting / Leaf
|
|
/// mesh comes up, each node peers to the degree its role and topology
|
|
/// dictate, and end-to-end data reaches every reachable pair — including the
|
|
/// one multi-hop path, a Leaf node reaching a non-adjacent Full node through
|
|
/// the Full router between them. This is the in-process replacement for the
|
|
/// Docker `mixed-profile` suite: the node roles are the subject, and none of
|
|
/// it needs a real transport.
|
|
///
|
|
/// Topology (matching the retired Docker suite):
|
|
///
|
|
/// ```text
|
|
/// A (Full) ── B (Full)
|
|
/// │ \ │
|
|
/// │ \ │
|
|
/// D (Leaf) C (NonRouting)
|
|
/// ```
|
|
#[tokio::test]
|
|
async fn mixed_profile_nodes_converge_and_forward() {
|
|
use crate::proto::fmp::NodeProfile;
|
|
|
|
// 0=A Full, 1=B Full, 2=C NonRouting, 3=D Leaf.
|
|
let profiles = [
|
|
NodeProfile::Full,
|
|
NodeProfile::Full,
|
|
NodeProfile::NonRouting,
|
|
NodeProfile::Leaf,
|
|
];
|
|
// A-B, A-C, A-D, B-C.
|
|
let edges = [(0, 1), (0, 2), (0, 3), (1, 2)];
|
|
let mut nodes = run_tree_test_with_profiles(&profiles, &edges, false).await;
|
|
|
|
// Peer degree per role + topology: A=3 (B,C,D), B=2 (A,C), C=2 (A,B), D=1 (A).
|
|
let peer_counts: Vec<usize> = nodes.iter().map(|n| n.node.peers().count()).collect();
|
|
assert_eq!(
|
|
peer_counts,
|
|
vec![3, 2, 2, 1],
|
|
"mixed-profile peer degrees (A=3, B=2, C=2, D=1)"
|
|
);
|
|
|
|
populate_all_coord_caches(&mut nodes);
|
|
|
|
// TUN receiver on every node so delivered plaintext can be observed.
|
|
let mut tun_rx = Vec::with_capacity(nodes.len());
|
|
for tn in nodes.iter_mut() {
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
tn.node.supervisor.tun_tx = Some(tx);
|
|
tun_rx.push(rx);
|
|
}
|
|
|
|
let info: Vec<(NodeAddr, secp256k1::PublicKey)> = nodes
|
|
.iter()
|
|
.map(|tn| (*tn.node.node_addr(), tn.node.identity().pubkey_full()))
|
|
.collect();
|
|
|
|
// Reachability the Docker suite asserted: F<->F, F<->N and F<->L between
|
|
// adjacent nodes, plus the Leaf<->Full multi-hop pair D<->B routed through
|
|
// the Full node A between them. Each is checked end to end.
|
|
//
|
|
// Unlike `leaf_smallest_addr_does_not_partition_multihop`, which pins the
|
|
// Leaf to the smallest NodeAddr to make one partition mode deterministic,
|
|
// this mesh draws its identities at random, so the multi-hop pair here is
|
|
// exercised against whatever root election the address ordering produces.
|
|
let reach: &[(usize, usize)] = &[
|
|
(0, 1), // A -> B Full <-> Full
|
|
(1, 0), // B -> A
|
|
(0, 2), // A -> C Full <-> NonRouting
|
|
(2, 0), // C -> A
|
|
(1, 2), // B -> C
|
|
(2, 1), // C -> B
|
|
(0, 3), // A -> D Full <-> Leaf
|
|
(3, 0), // D -> A
|
|
(3, 1), // D -> B Leaf -> Full, two hops via A
|
|
(1, 3), // B -> D Full -> Leaf, two hops via A
|
|
];
|
|
|
|
for &(src, dst) in reach {
|
|
let (dst_addr, dst_pubkey) = info[dst];
|
|
let src_addr = info[src].0;
|
|
let initiated = nodes[src].node.initiate_session(dst_addr, dst_pubkey).await;
|
|
if let Err(e) = initiated {
|
|
panic!(
|
|
"initiate_session {src}->{dst} failed: {e:?}; {}",
|
|
mesh_state(&nodes)
|
|
);
|
|
}
|
|
drain_to_quiescence(&mut nodes).await;
|
|
|
|
let payload = format!("mp-{src}-{dst}").into_bytes();
|
|
let src_fips = crate::FipsAddress::from_node_addr(&src_addr);
|
|
let dst_fips = crate::FipsAddress::from_node_addr(&dst_addr);
|
|
let ipv6 = build_ipv6_packet(&src_fips, &dst_fips, &payload);
|
|
let sent = nodes[src].node.send_ipv6_packet(&dst_addr, &ipv6).await;
|
|
if let Err(e) = sent {
|
|
panic!("send {src}->{dst} failed: {e:?}; {}", mesh_state(&nodes));
|
|
}
|
|
drain_to_quiescence(&mut nodes).await;
|
|
|
|
// TUN receives the decompressed IPv6 packet; match the upper-layer
|
|
// payload after the 40-byte header, as the other forwarding tests do.
|
|
let found = std::iter::from_fn(|| tun_rx[dst].try_recv().ok())
|
|
.any(|pkt| pkt.len() >= 40 && pkt[40..] == payload[..]);
|
|
if !found {
|
|
panic!(
|
|
"datagram {src}->{dst} must be delivered to node {dst}'s TUN; {}",
|
|
mesh_state(&nodes)
|
|
);
|
|
}
|
|
}
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
/// Regression: a Leaf holding the smallest NodeAddr must not self-elect as root
|
|
/// and partition the mesh, so a multi-hop session from the Leaf to a
|
|
/// non-adjacent Full node still establishes and delivers.
|
|
///
|
|
/// Topology A(Full) — B(Full), with D(Leaf) hanging off A. D is pinned to the
|
|
/// strictly smallest NodeAddr — the condition that made D self-elect as a second
|
|
/// root, partitioning A/B's tree from D's and leaving B unable to route its
|
|
/// handshake reply back to D. The multi-hop pair D->B (routed through A) is the
|
|
/// one the Docker `mixed-profile` suite covered.
|
|
///
|
|
/// Only Full and Leaf profiles appear here so the elected root is always the
|
|
/// smaller of the two Full nodes: the separate global-min-NonRouting partition
|
|
/// (a distinct open problem for the leaf/non-routing tree-participation model) is
|
|
/// deliberately kept out so this test isolates the Leaf fix.
|
|
///
|
|
/// Without the leaf gate D self-roots every run and D->B fails; with it, D
|
|
/// attaches under A and the session establishes.
|
|
#[tokio::test]
|
|
async fn leaf_smallest_addr_does_not_partition_multihop() {
|
|
use crate::proto::fmp::NodeProfile;
|
|
|
|
// 0=A Full, 1=B Full, 2=D Leaf (pinned smallest).
|
|
let profiles = [NodeProfile::Full, NodeProfile::Full, NodeProfile::Leaf];
|
|
// A-B, A-D (D is a single-upstream leaf hanging off A).
|
|
let edges = [(0, 1), (0, 2)];
|
|
let mut nodes = run_tree_test_with_profiles_leaf_smallest(&profiles, 2, &edges).await;
|
|
|
|
// The mesh must be a single tree rooted at a Full node: D (the smallest
|
|
// addr) must NOT be its own root. A partition shows up as D self-rooted.
|
|
assert!(
|
|
!nodes[2].node.tree_state().is_root(),
|
|
"the Leaf (smallest addr) must not self-elect as root"
|
|
);
|
|
let roots: Vec<_> = nodes.iter().map(|n| *n.node.tree_state().root()).collect();
|
|
assert!(
|
|
roots.iter().all(|r| *r == roots[0]),
|
|
"all nodes must share one root (no partition); got {roots:?}"
|
|
);
|
|
|
|
populate_all_coord_caches(&mut nodes);
|
|
|
|
// TUN receiver on B so delivered plaintext can be observed.
|
|
let (tx, b_rx) = std::sync::mpsc::channel();
|
|
nodes[1].node.supervisor.tun_tx = Some(tx);
|
|
|
|
let d_addr = *nodes[2].node.node_addr();
|
|
let (b_addr, b_pubkey) = (
|
|
*nodes[1].node.node_addr(),
|
|
nodes[1].node.identity().pubkey_full(),
|
|
);
|
|
|
|
// D -> B: the multi-hop pair routed through A.
|
|
nodes[2]
|
|
.node
|
|
.initiate_session(b_addr, b_pubkey)
|
|
.await
|
|
.expect("D->B initiate_session must succeed (no partition)");
|
|
drain_to_quiescence(&mut nodes).await;
|
|
|
|
let payload = b"leaf-multihop".to_vec();
|
|
let d_fips = crate::FipsAddress::from_node_addr(&d_addr);
|
|
let b_fips = crate::FipsAddress::from_node_addr(&b_addr);
|
|
let ipv6 = build_ipv6_packet(&d_fips, &b_fips, &payload);
|
|
nodes[2]
|
|
.node
|
|
.send_ipv6_packet(&b_addr, &ipv6)
|
|
.await
|
|
.expect("D->B send must succeed");
|
|
drain_to_quiescence(&mut nodes).await;
|
|
|
|
let found = std::iter::from_fn(|| b_rx.try_recv().ok())
|
|
.any(|pkt| pkt.len() >= 40 && pkt[40..] == payload[..]);
|
|
assert!(
|
|
found,
|
|
"D->B multi-hop datagram must be delivered to B's TUN"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_session_100_nodes() {
|
|
let _guard = lock_large_network_test().await;
|
|
|
|
use rand::rngs::StdRng;
|
|
use rand::{RngExt, SeedableRng};
|
|
use std::sync::mpsc;
|
|
use std::time::Instant;
|
|
|
|
// Same random topology as other 100-node tests
|
|
const NUM_NODES: usize = 100;
|
|
const TARGET_EDGES: usize = 250;
|
|
const SEED: u64 = 42;
|
|
|
|
let start = Instant::now();
|
|
|
|
let edges = generate_random_edges(NUM_NODES, TARGET_EDGES, SEED);
|
|
let mut nodes = run_tree_test(NUM_NODES, &edges, false).await;
|
|
verify_tree_convergence(&nodes);
|
|
populate_all_coord_caches(&mut nodes);
|
|
|
|
let setup_time = start.elapsed();
|
|
|
|
// Collect identities: (node_addr, pubkey) for all nodes
|
|
let all_info: Vec<(NodeAddr, secp256k1::PublicKey)> = nodes
|
|
.iter()
|
|
.map(|tn| (*tn.node.node_addr(), tn.node.identity().pubkey_full()))
|
|
.collect();
|
|
|
|
// Each node picks one random target for its outbound session.
|
|
// Use deterministic RNG so failures are reproducible.
|
|
let mut rng = StdRng::seed_from_u64(SEED + 1);
|
|
let mut session_pairs: Vec<(usize, usize)> = Vec::with_capacity(NUM_NODES);
|
|
for src in 0..NUM_NODES {
|
|
let mut dst = rng.random_range(0..NUM_NODES);
|
|
while dst == src {
|
|
dst = rng.random_range(0..NUM_NODES);
|
|
}
|
|
session_pairs.push((src, dst));
|
|
}
|
|
|
|
// === Phase 1: Establish all sessions ===
|
|
|
|
let session_start = Instant::now();
|
|
|
|
for &(src, dst) in &session_pairs {
|
|
let (dest_addr, dest_pubkey) = all_info[dst];
|
|
|
|
nodes[src]
|
|
.node
|
|
.initiate_session(dest_addr, dest_pubkey)
|
|
.await
|
|
.expect("initiate_session failed");
|
|
|
|
drain_to_quiescence(&mut nodes).await;
|
|
}
|
|
|
|
drain_to_quiescence(&mut nodes).await;
|
|
let session_time = session_start.elapsed();
|
|
|
|
// Verify all initiator sessions reached Established before data phase
|
|
let mut handshake_failures: Vec<(usize, usize)> = Vec::new();
|
|
for &(src, dst) in &session_pairs {
|
|
let dest_addr = all_info[dst].0;
|
|
let ok = nodes[src]
|
|
.node
|
|
.get_session(&dest_addr)
|
|
.map(|e| e.state().is_established())
|
|
.unwrap_or(false);
|
|
if !ok {
|
|
handshake_failures.push((src, dst));
|
|
}
|
|
}
|
|
assert!(
|
|
handshake_failures.is_empty(),
|
|
"Handshake failed for {} pairs (first: {:?})",
|
|
handshake_failures.len(),
|
|
handshake_failures.first()
|
|
);
|
|
|
|
// === Phase 2: Inject TUN receivers and snapshot link stats ===
|
|
|
|
// Install a tun_tx on every node so delivered datagrams can be counted.
|
|
let mut tun_receivers: Vec<mpsc::Receiver<Vec<u8>>> = Vec::with_capacity(NUM_NODES);
|
|
for tn in nodes.iter_mut() {
|
|
let (tx, rx) = mpsc::channel();
|
|
tn.node.supervisor.tun_tx = Some(tx);
|
|
tun_receivers.push(rx);
|
|
}
|
|
|
|
// Snapshot per-peer link stats before data phase
|
|
let link_pkts_sent_before: Vec<Vec<(NodeAddr, u64)>> = nodes
|
|
.iter()
|
|
.map(|tn| {
|
|
tn.node
|
|
.peers()
|
|
.map(|p| (*p.node_addr(), p.link_stats().packets_sent))
|
|
.collect()
|
|
})
|
|
.collect();
|
|
|
|
// === Phase 3: Bidirectional data transfer ===
|
|
//
|
|
// For each session pair:
|
|
// 1. Initiator sends one datagram to responder
|
|
// 2. Responder sends one datagram back to initiator
|
|
//
|
|
// Batched per pair with draining between each.
|
|
|
|
let data_start = Instant::now();
|
|
let mut send_forward_ok = 0usize;
|
|
let mut send_forward_err = 0usize;
|
|
let mut send_reverse_ok = 0usize;
|
|
let mut send_reverse_err = 0usize;
|
|
|
|
for (pair_idx, &(src, dst)) in session_pairs.iter().enumerate() {
|
|
let dest_addr = all_info[dst].0;
|
|
let src_addr = all_info[src].0;
|
|
|
|
// Build IPv6 packets with pair index as payload
|
|
let src_fips = crate::FipsAddress::from_node_addr(&src_addr);
|
|
let dst_fips = crate::FipsAddress::from_node_addr(&dest_addr);
|
|
|
|
// Forward: initiator → responder
|
|
let fwd_payload = format!("fwd-{}", pair_idx).into_bytes();
|
|
let fwd_ipv6 = build_ipv6_packet(&src_fips, &dst_fips, &fwd_payload);
|
|
match nodes[src]
|
|
.node
|
|
.send_ipv6_packet(&dest_addr, &fwd_ipv6)
|
|
.await
|
|
{
|
|
Ok(()) => send_forward_ok += 1,
|
|
Err(_) => send_forward_err += 1,
|
|
}
|
|
|
|
drain_to_quiescence(&mut nodes).await;
|
|
|
|
// Reverse: responder → initiator
|
|
// (Responder should already be Established after XX msg3)
|
|
let rev_payload = format!("rev-{}", pair_idx).into_bytes();
|
|
let rev_ipv6 = build_ipv6_packet(&dst_fips, &src_fips, &rev_payload);
|
|
match nodes[dst].node.send_ipv6_packet(&src_addr, &rev_ipv6).await {
|
|
Ok(()) => send_reverse_ok += 1,
|
|
Err(_) => send_reverse_err += 1,
|
|
}
|
|
|
|
drain_to_quiescence(&mut nodes).await;
|
|
}
|
|
|
|
let data_time = data_start.elapsed();
|
|
|
|
// === Phase 4: Collect delivered datagrams from TUN receivers ===
|
|
|
|
let mut delivered_per_node: Vec<Vec<Vec<u8>>> = Vec::with_capacity(NUM_NODES);
|
|
for rx in tun_receivers.iter_mut() {
|
|
let mut packets = Vec::new();
|
|
while let Ok(pkt) = rx.try_recv() {
|
|
packets.push(pkt);
|
|
}
|
|
delivered_per_node.push(packets);
|
|
}
|
|
|
|
let total_delivered: usize = delivered_per_node.iter().map(|v| v.len()).sum();
|
|
|
|
// Verify each pair's forward and reverse datagrams arrived
|
|
let mut fwd_delivered = 0usize;
|
|
let mut rev_delivered = 0usize;
|
|
let mut fwd_missing: Vec<(usize, usize)> = Vec::new();
|
|
let mut rev_missing: Vec<(usize, usize)> = Vec::new();
|
|
|
|
for (pair_idx, &(src, dst)) in session_pairs.iter().enumerate() {
|
|
let fwd_payload = format!("fwd-{}", pair_idx).into_bytes();
|
|
let rev_payload = format!("rev-{}", pair_idx).into_bytes();
|
|
|
|
// After decompression, TUN receives full IPv6 packets.
|
|
// Check that delivered packet's upper-layer payload matches.
|
|
let fwd_found = delivered_per_node[dst]
|
|
.iter()
|
|
.any(|pkt| pkt.len() >= 40 && pkt[40..] == fwd_payload);
|
|
if fwd_found {
|
|
fwd_delivered += 1;
|
|
} else if fwd_missing.len() < 20 {
|
|
fwd_missing.push((src, dst));
|
|
}
|
|
|
|
let rev_found = delivered_per_node[src]
|
|
.iter()
|
|
.any(|pkt| pkt.len() >= 40 && pkt[40..] == rev_payload);
|
|
if rev_found {
|
|
rev_delivered += 1;
|
|
} else if rev_missing.len() < 20 {
|
|
rev_missing.push((src, dst));
|
|
}
|
|
}
|
|
|
|
// === Phase 5: Final session state ===
|
|
|
|
let mut total_established = 0usize;
|
|
let mut total_responding = 0usize;
|
|
let mut total_initiating = 0usize;
|
|
let mut fully_established_nodes = 0usize;
|
|
|
|
for tn in &nodes {
|
|
let mut all_est = true;
|
|
for (_, entry) in tn.node.sessions.iter() {
|
|
if entry.state().is_established() {
|
|
total_established += 1;
|
|
} else if entry.state().is_awaiting_msg3() {
|
|
total_responding += 1;
|
|
all_est = false;
|
|
} else {
|
|
total_initiating += 1;
|
|
all_est = false;
|
|
}
|
|
}
|
|
if tn.node.session_count() > 0 && all_est {
|
|
fully_established_nodes += 1;
|
|
}
|
|
}
|
|
|
|
let session_counts: Vec<usize> = nodes.iter().map(|tn| tn.node.session_count()).collect();
|
|
let total_sessions: usize = session_counts.iter().sum();
|
|
let min_sessions = *session_counts.iter().min().unwrap();
|
|
let max_sessions = *session_counts.iter().max().unwrap();
|
|
|
|
// === Phase 6: Link and routing statistics ===
|
|
|
|
// Link stats delta: packets sent during data phase
|
|
let mut data_link_pkts_sent: u64 = 0;
|
|
let mut total_link_pkts_sent: u64 = 0;
|
|
let mut total_link_pkts_recv: u64 = 0;
|
|
let mut total_link_bytes_sent: u64 = 0;
|
|
let mut total_link_bytes_recv: u64 = 0;
|
|
|
|
for (i, tn) in nodes.iter().enumerate() {
|
|
for peer in tn.node.peers() {
|
|
let stats = peer.link_stats();
|
|
// Delta for this peer since before data phase
|
|
let before = link_pkts_sent_before[i]
|
|
.iter()
|
|
.find(|(addr, _)| addr == peer.node_addr())
|
|
.map(|(_, pkts)| *pkts)
|
|
.unwrap_or(0);
|
|
data_link_pkts_sent += stats.packets_sent.saturating_sub(before);
|
|
|
|
// Totals (cumulative since node creation)
|
|
total_link_pkts_sent += stats.packets_sent;
|
|
total_link_pkts_recv += stats.packets_recv;
|
|
total_link_bytes_sent += stats.bytes_sent;
|
|
total_link_bytes_recv += stats.bytes_recv;
|
|
}
|
|
}
|
|
|
|
// Estimate average hop count from link packet overhead.
|
|
// Each data datagram traverses N link hops, each producing 1 link send.
|
|
// We sent 200 datagrams total (100 forward + 100 reverse).
|
|
let total_data_datagrams = (send_forward_ok + send_reverse_ok) as u64;
|
|
let avg_hops = if total_data_datagrams > 0 {
|
|
data_link_pkts_sent as f64 / total_data_datagrams as f64
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
// Coord cache stats
|
|
let coord_cache_sizes: Vec<usize> =
|
|
nodes.iter().map(|tn| tn.node.coord_cache().len()).collect();
|
|
let total_coord_entries: usize = coord_cache_sizes.iter().sum();
|
|
let min_coord = *coord_cache_sizes.iter().min().unwrap();
|
|
let max_coord = *coord_cache_sizes.iter().max().unwrap();
|
|
|
|
// === Report ===
|
|
|
|
eprintln!("\n === Session 100-Node Test ===");
|
|
eprintln!(
|
|
" Topology: {} nodes, {} edges (seed {})",
|
|
NUM_NODES,
|
|
edges.len(),
|
|
SEED
|
|
);
|
|
eprintln!(
|
|
" Session pairs: {} (1 outbound per node, random target)",
|
|
session_pairs.len()
|
|
);
|
|
|
|
eprintln!("\n --- Handshake ---");
|
|
eprintln!(
|
|
" Initiator established: {}/{}",
|
|
session_pairs.len(),
|
|
session_pairs.len()
|
|
);
|
|
|
|
eprintln!("\n --- Data Transfer ---");
|
|
eprintln!(
|
|
" Forward (initiator->responder): {} sent, {} errors",
|
|
send_forward_ok, send_forward_err
|
|
);
|
|
eprintln!(
|
|
" Reverse (responder->initiator): {} sent, {} errors",
|
|
send_reverse_ok, send_reverse_err
|
|
);
|
|
eprintln!(
|
|
" TUN delivery: {} total ({} expected)",
|
|
total_delivered,
|
|
send_forward_ok + send_reverse_ok
|
|
);
|
|
eprintln!(
|
|
" Forward delivered: {}/{} | Reverse delivered: {}/{}",
|
|
fwd_delivered, send_forward_ok, rev_delivered, send_reverse_ok
|
|
);
|
|
|
|
eprintln!("\n --- Final Session State ---");
|
|
eprintln!(
|
|
" Entries: {} total ({} established, {} responding, {} initiating)",
|
|
total_sessions, total_established, total_responding, total_initiating
|
|
);
|
|
eprintln!(
|
|
" Per node: min={} max={} avg={:.1}",
|
|
min_sessions,
|
|
max_sessions,
|
|
total_sessions as f64 / NUM_NODES as f64
|
|
);
|
|
eprintln!(
|
|
" All-established nodes: {}/{}",
|
|
fully_established_nodes, NUM_NODES
|
|
);
|
|
|
|
eprintln!("\n --- Routing ---");
|
|
eprintln!(
|
|
" Data-phase link hops: {} ({:.1} avg hops/datagram over {} datagrams)",
|
|
data_link_pkts_sent, avg_hops, total_data_datagrams
|
|
);
|
|
eprintln!(
|
|
" Lifetime link totals: {} pkts sent, {} pkts recv, {:.1} KB sent, {:.1} KB recv",
|
|
total_link_pkts_sent,
|
|
total_link_pkts_recv,
|
|
total_link_bytes_sent as f64 / 1024.0,
|
|
total_link_bytes_recv as f64 / 1024.0
|
|
);
|
|
eprintln!(
|
|
" Coord cache: total={} min={} max={} avg={:.1}",
|
|
total_coord_entries,
|
|
min_coord,
|
|
max_coord,
|
|
total_coord_entries as f64 / NUM_NODES as f64
|
|
);
|
|
|
|
eprintln!("\n --- Timing ---");
|
|
eprintln!(
|
|
" Setup: {:.1}s | Handshake: {:.1}s | Data: {:.1}s | Total: {:.1}s",
|
|
setup_time.as_secs_f64(),
|
|
session_time.as_secs_f64(),
|
|
data_time.as_secs_f64(),
|
|
start.elapsed().as_secs_f64()
|
|
);
|
|
|
|
if !fwd_missing.is_empty() {
|
|
eprintln!(
|
|
"\n First {} undelivered forward datagrams:",
|
|
fwd_missing.len()
|
|
);
|
|
for &(src, dst) in &fwd_missing {
|
|
eprintln!(" node {} -> node {}", src, dst);
|
|
}
|
|
}
|
|
if !rev_missing.is_empty() {
|
|
eprintln!(
|
|
"\n First {} undelivered reverse datagrams:",
|
|
rev_missing.len()
|
|
);
|
|
for &(src, dst) in &rev_missing {
|
|
eprintln!(" node {} <- node {}", src, dst);
|
|
}
|
|
}
|
|
|
|
// === Assertions ===
|
|
|
|
assert_eq!(send_forward_err, 0, "All forward sends should succeed");
|
|
assert_eq!(
|
|
send_reverse_err, 0,
|
|
"All reverse sends should succeed (responder Established after XX msg3)"
|
|
);
|
|
assert_eq!(
|
|
fwd_delivered, send_forward_ok,
|
|
"All forward datagrams should be delivered to responder TUN"
|
|
);
|
|
assert_eq!(
|
|
rev_delivered, send_reverse_ok,
|
|
"All reverse datagrams should be delivered to initiator TUN"
|
|
);
|
|
assert_eq!(
|
|
total_established, total_sessions,
|
|
"All {} session entries should be Established, \
|
|
but {} responding, {} initiating",
|
|
total_sessions, total_responding, total_initiating
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Data plane integration tests: TUN → session → link → TUN
|
|
// ============================================================================
|
|
|
|
/// Build a minimal valid IPv6 packet with given source and destination addresses.
|
|
fn build_ipv6_packet(
|
|
src: &crate::FipsAddress,
|
|
dst: &crate::FipsAddress,
|
|
payload: &[u8],
|
|
) -> Vec<u8> {
|
|
let payload_len = payload.len() as u16;
|
|
let mut packet = vec![0u8; 40 + payload.len()];
|
|
// Version (6) + traffic class high nibble
|
|
packet[0] = 0x60;
|
|
// Payload length (u16 BE)
|
|
packet[4] = (payload_len >> 8) as u8;
|
|
packet[5] = (payload_len & 0xff) as u8;
|
|
// Next header: 59 = No Next Header
|
|
packet[6] = 59;
|
|
// Hop limit
|
|
packet[7] = 64;
|
|
// Source address (bytes 8-23)
|
|
packet[8..24].copy_from_slice(src.as_bytes());
|
|
// Destination address (bytes 24-39)
|
|
packet[24..40].copy_from_slice(dst.as_bytes());
|
|
// Payload
|
|
packet[40..].copy_from_slice(payload);
|
|
packet
|
|
}
|
|
|
|
#[test]
|
|
fn test_identity_cache_populated_on_promote() {
|
|
use crate::proto::fmp::PromotionResult;
|
|
|
|
let mut node = make_node();
|
|
let transport_id = TransportId::new(1);
|
|
let link_id = LinkId::new(1);
|
|
|
|
let peer_identity = seed_completed_connection(&mut node, link_id, transport_id, 1000);
|
|
|
|
// Promote
|
|
let result = node
|
|
.promote_connection(link_id, peer_identity, 2000)
|
|
.unwrap();
|
|
assert!(matches!(result, PromotionResult::Promoted(_)));
|
|
|
|
// Identity cache should contain the peer
|
|
let peer_addr = *peer_identity.node_addr();
|
|
let mut prefix = [0u8; 15];
|
|
prefix.copy_from_slice(&peer_addr.as_bytes()[0..15]);
|
|
let cached = node.lookup_by_fips_prefix(&prefix);
|
|
assert!(
|
|
cached.is_some(),
|
|
"Identity cache should contain promoted peer"
|
|
);
|
|
let (cached_addr, cached_pk) = cached.unwrap();
|
|
assert_eq!(cached_addr, peer_addr);
|
|
assert_eq!(cached_pk, peer_identity.pubkey_full());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_tun_outbound_established_session() {
|
|
// Two directly connected nodes, session established.
|
|
// Inject IPv6 packet via handle_tun_outbound on Node 0,
|
|
// verify plaintext arrives at Node 1's tun_tx.
|
|
let edges = vec![(0, 1)];
|
|
let mut nodes = run_tree_test(2, &edges, false).await;
|
|
verify_tree_convergence(&nodes);
|
|
populate_all_coord_caches(&mut nodes);
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
let node1_pubkey = nodes[1].node.identity().pubkey_full();
|
|
|
|
let src_fips = crate::FipsAddress::from_node_addr(&node0_addr);
|
|
let dst_fips = crate::FipsAddress::from_node_addr(&node1_addr);
|
|
|
|
// Establish session (XX: 3 messages — Setup, Ack, Msg3)
|
|
nodes[0]
|
|
.node
|
|
.initiate_session(node1_addr, node1_pubkey)
|
|
.await
|
|
.unwrap();
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await; // Setup → Node 1
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await; // Ack → Node 0, Node 0 sends Msg3
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await; // Msg3 → Node 1
|
|
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.state()
|
|
.is_established()
|
|
);
|
|
|
|
// Install TUN receiver on Node 1
|
|
let (tun_tx, tun_rx) = std::sync::mpsc::channel();
|
|
nodes[1].node.supervisor.tun_tx = Some(tun_tx);
|
|
|
|
// Build and inject an IPv6 packet
|
|
let test_payload = b"data-plane-test-12345";
|
|
let ipv6_packet = build_ipv6_packet(&src_fips, &dst_fips, test_payload);
|
|
|
|
nodes[0].node.handle_tun_outbound(ipv6_packet.clone()).await;
|
|
|
|
// Process packets: encrypted data → Node 1
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
|
|
// Verify plaintext arrived at Node 1's TUN
|
|
let delivered: Vec<Vec<u8>> = std::iter::from_fn(|| tun_rx.try_recv().ok()).collect();
|
|
assert_eq!(delivered.len(), 1, "Exactly one packet should be delivered");
|
|
assert_eq!(
|
|
delivered[0], ipv6_packet,
|
|
"Delivered packet should match original"
|
|
);
|
|
|
|
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
|
|
/// XX rekey handshake over the loopback transport so the AEAD continuity across
|
|
/// the cutover is asserted end to end.
|
|
///
|
|
/// Deterministic, no wall-clock wait and no clock read on the responder:
|
|
/// `rekey.after_messages = 1` makes the first sent datagram cross the
|
|
/// initiator's trigger, and the responder classifies the arrival from the rekey
|
|
/// marker the initiator declares in its msg3 negotiation TLV.
|
|
///
|
|
/// The message-count arm is what carries this test's value. It is the arm the
|
|
/// retired age-based discriminator got wrong, and suppressing the marker so the
|
|
/// responder falls back to the cross-connection path reds the post-cutover
|
|
/// assertion, which is what shows the test discriminates the two arms rather
|
|
/// than merely observing a session index change.
|
|
#[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, 1280).await,
|
|
make_test_node_with_config(cfg1, 1280).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<Vec<u8>> = 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();
|
|
|
|
// No session backdating here, unlike the master-branch original: on this
|
|
// line the responder classifies the arrival from the sender's msg3
|
|
// declaration and `establish_inbound` reads no clock at all, so aging the
|
|
// session past a 30s acceptance gate would age it past nothing.
|
|
|
|
// 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<Vec<u8>> = 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;
|
|
}
|
|
|
|
/// Deliver queued packets between the nodes until a round moves none, for at
|
|
/// most 50 rounds of 10 ms.
|
|
async fn pump_until_quiet(nodes: &mut [TestNode]) {
|
|
for _ in 0..50 {
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
if process_available_packets(nodes).await == 0 {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A two-node pair with an FSP session over an FMP link whose link sessions
|
|
/// have been aged, with a TUN receiver on each node. Built by
|
|
/// [`aged_link_pair`].
|
|
struct AgedLinkPair {
|
|
nodes: Vec<TestNode>,
|
|
node0_addr: NodeAddr,
|
|
node1_addr: NodeAddr,
|
|
fips0: crate::FipsAddress,
|
|
fips1: crate::FipsAddress,
|
|
tun0_rx: std::sync::mpsc::Receiver<Vec<u8>>,
|
|
tun1_rx: std::sync::mpsc::Receiver<Vec<u8>>,
|
|
}
|
|
|
|
/// Build a two-node pair from `cfg0` and `cfg1`, peer node 0 to node 1 over
|
|
/// FMP, open an FSP session from node 0, show that both directions decode,
|
|
/// then backdate both link sessions by `age`.
|
|
async fn aged_link_pair(
|
|
cfg0: crate::config::Config,
|
|
cfg1: crate::config::Config,
|
|
age: Duration,
|
|
) -> AgedLinkPair {
|
|
let mut nodes = vec![
|
|
make_test_node_with_config(cfg0, 1280).await,
|
|
make_test_node_with_config(cfg1, 1280).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;
|
|
}
|
|
for (i, remote) in [(0, node1_addr), (1, node0_addr)] {
|
|
assert!(
|
|
nodes[i]
|
|
.node
|
|
.get_session(&remote)
|
|
.is_some_and(|s| s.state().is_established()),
|
|
"node {i} session established"
|
|
);
|
|
}
|
|
|
|
// Each node's TUN receiver observes the plaintext the other one sent.
|
|
let (tun0_tx, tun0_rx) = std::sync::mpsc::channel();
|
|
nodes[0].node.supervisor.tun_tx = Some(tun0_tx);
|
|
let (tun1_tx, tun1_rx) = std::sync::mpsc::channel();
|
|
nodes[1].node.supervisor.tun_tx = Some(tun1_tx);
|
|
let fips0 = crate::FipsAddress::from_node_addr(&node0_addr);
|
|
let fips1 = crate::FipsAddress::from_node_addr(&node1_addr);
|
|
|
|
// Baseline: both directions decode before the caller's scenario, so a
|
|
// later failure is the scenario's and not the harness's.
|
|
let pre_fwd = build_ipv6_packet(&fips0, &fips1, b"pre-rekey 0 to 1");
|
|
let pre_rev = build_ipv6_packet(&fips1, &fips0, b"pre-rekey 1 to 0");
|
|
nodes[0].node.handle_tun_outbound(pre_fwd.clone()).await;
|
|
nodes[1].node.handle_tun_outbound(pre_rev.clone()).await;
|
|
pump_until_quiet(&mut nodes).await;
|
|
let got: Vec<Vec<u8>> = std::iter::from_fn(|| tun1_rx.try_recv().ok()).collect();
|
|
assert_eq!(got, vec![pre_fwd], "baseline node 0 to node 1 must decode");
|
|
let got: Vec<Vec<u8>> = std::iter::from_fn(|| tun0_rx.try_recv().ok()).collect();
|
|
assert_eq!(got, vec![pre_rev], "baseline node 1 to node 0 must decode");
|
|
|
|
// Age both link sessions.
|
|
nodes[0]
|
|
.node
|
|
.get_peer_mut(&node1_addr)
|
|
.unwrap()
|
|
.test_backdate_session_established(age);
|
|
nodes[1]
|
|
.node
|
|
.get_peer_mut(&node0_addr)
|
|
.unwrap()
|
|
.test_backdate_session_established(age);
|
|
|
|
AgedLinkPair {
|
|
nodes,
|
|
node0_addr,
|
|
node1_addr,
|
|
fips0,
|
|
fips1,
|
|
tun0_rx,
|
|
tun1_rx,
|
|
}
|
|
}
|
|
|
|
/// A two-node pair whose FMP link rekey node 0 has completed and cut over to,
|
|
/// while node 0's msg3 is held back from node 1, which is therefore still on
|
|
/// the old link session. Built by [`rekey_pair_cut_over_with_held_msg3`].
|
|
struct HeldMsg3Pair {
|
|
nodes: Vec<TestNode>,
|
|
node0_addr: NodeAddr,
|
|
node1_addr: NodeAddr,
|
|
fips0: crate::FipsAddress,
|
|
fips1: crate::FipsAddress,
|
|
tun0_rx: std::sync::mpsc::Receiver<Vec<u8>>,
|
|
tun1_rx: std::sync::mpsc::Receiver<Vec<u8>>,
|
|
node0_idx_before: Option<crate::utils::index::SessionIndex>,
|
|
node1_idx_before: Option<crate::utils::index::SessionIndex>,
|
|
held_msg3: crate::transport::ReceivedPacket,
|
|
}
|
|
|
|
/// Build a two-node pair with an FSP session, age both link sessions past
|
|
/// node 0's rekey trigger, and run node 0's FMP rekey through msg1 and msg2.
|
|
/// node 0's msg3 is pulled out of node 1's queue, and node 0 then cuts over
|
|
/// to its new session on its own tick, so node 0 is on the new link session
|
|
/// and node 1 is still on the old one when this returns.
|
|
async fn rekey_pair_cut_over_with_held_msg3() -> HeldMsg3Pair {
|
|
use crate::proto::fmp::wire::{CommonPrefix, PHASE_MSG3};
|
|
use crate::transport::ReceivedPacket;
|
|
|
|
const REKEY_AFTER_SECS: u64 = 60;
|
|
|
|
// node 0 rekeys on time; node 1 only ever responds.
|
|
let mut cfg0 = crate::config::Config::new();
|
|
cfg0.node.rekey.enabled = true;
|
|
cfg0.node.rekey.after_secs = REKEY_AFTER_SECS;
|
|
cfg0.node.rekey.after_messages = u64::MAX;
|
|
let mut cfg1 = crate::config::Config::new();
|
|
cfg1.node.rekey.enabled = true;
|
|
cfg1.node.rekey.after_secs = u64::MAX;
|
|
cfg1.node.rekey.after_messages = u64::MAX;
|
|
|
|
let age = Duration::from_secs(REKEY_AFTER_SECS + crate::node::REKEY_JITTER_SECS as u64 + 1);
|
|
let AgedLinkPair {
|
|
mut nodes,
|
|
node0_addr,
|
|
node1_addr,
|
|
fips0,
|
|
fips1,
|
|
tun0_rx,
|
|
tun1_rx,
|
|
} = aged_link_pair(cfg0, cfg1, age).await;
|
|
let node0_idx_before = nodes[0].node.get_peer(&node1_addr).unwrap().our_index();
|
|
let node1_idx_before = nodes[1].node.get_peer(&node0_addr).unwrap().our_index();
|
|
|
|
// node 0 starts the rekey; node 1 answers its msg1 with msg2.
|
|
nodes[0].node.check_rekey().await;
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_peer(&node1_addr)
|
|
.unwrap()
|
|
.rekey_in_progress(),
|
|
"node 0 must have started a rekey"
|
|
);
|
|
assert_eq!(
|
|
process_available_packets(&mut nodes[1..]).await,
|
|
1,
|
|
"node 1 must have exactly node 0's rekey msg1 queued"
|
|
);
|
|
|
|
// node 0 reads the msg2, holds the new session as pending and sends msg3.
|
|
assert_eq!(
|
|
process_available_packets(&mut nodes[..1]).await,
|
|
1,
|
|
"node 0 must have exactly node 1's msg2 queued"
|
|
);
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_peer(&node1_addr)
|
|
.unwrap()
|
|
.pending_new_session()
|
|
.is_some(),
|
|
"node 0 must hold the new session once it has read msg2"
|
|
);
|
|
|
|
// Hold node 0's msg3 back from node 1.
|
|
let mut held: Vec<ReceivedPacket> =
|
|
std::iter::from_fn(|| nodes[1].packet_rx.try_recv().ok()).collect();
|
|
assert_eq!(held.len(), 1, "node 1 must have only node 0's msg3 queued");
|
|
let held_msg3 = held.remove(0);
|
|
assert_eq!(
|
|
CommonPrefix::parse(&held_msg3.data).map(|p| p.phase),
|
|
Some(PHASE_MSG3),
|
|
"the held packet must be node 0's msg3"
|
|
);
|
|
|
|
// node 0 cuts over on its own tick.
|
|
nodes[0].node.check_rekey().await;
|
|
let peer = nodes[0].node.get_peer(&node1_addr).unwrap();
|
|
assert_ne!(peer.our_index(), node0_idx_before, "node 0 must cut over");
|
|
assert_eq!(
|
|
nodes[1].node.get_peer(&node0_addr).unwrap().our_index(),
|
|
node1_idx_before,
|
|
"node 1 must still be on the old session"
|
|
);
|
|
|
|
HeldMsg3Pair {
|
|
nodes,
|
|
node0_addr,
|
|
node1_addr,
|
|
fips0,
|
|
fips1,
|
|
tun0_rx,
|
|
tun1_rx,
|
|
node0_idx_before,
|
|
node1_idx_before,
|
|
held_msg3,
|
|
}
|
|
}
|
|
|
|
/// A ReceiverReport about `highest` frames, as the link-layer message a peer
|
|
/// sends: the type byte followed by the body.
|
|
fn receiver_report_message(highest: u64) -> Vec<u8> {
|
|
crate::proto::mmp::ReceiverReport {
|
|
highest_counter: highest,
|
|
cumulative_packets_recv: highest,
|
|
cumulative_bytes_recv: highest * 100,
|
|
timestamp_echo: 0,
|
|
dwell_time: 0,
|
|
jitter: 0,
|
|
ecn_ce_count: 0,
|
|
owd_trend: 0,
|
|
burst_loss_count: 0,
|
|
cumulative_reorder_count: 0,
|
|
}
|
|
.encode()
|
|
}
|
|
|
|
/// A link rekey initiator that has cut over keeps its new session's MMP state
|
|
/// free of frames the responder sealed on the old session.
|
|
///
|
|
/// node 0 cuts over and resets its MMP receiver and metrics. node 1 has not
|
|
/// yet received msg3, so it still sends on the old session, and node 0
|
|
/// decrypts those frames against its previous session during the drain.
|
|
/// Their payload is still delivered, but they describe the old session: a
|
|
/// data frame's counter must not become the new session's highest counter,
|
|
/// which would make every new-session frame count as a reorder, and a
|
|
/// ReceiverReport about node 0's old-session traffic must not become the
|
|
/// baseline later reports are judged against, which would reject every
|
|
/// new-session report as regressed. Once node 1 completes and promotes, its
|
|
/// frames and reports on the new session are counted and accepted.
|
|
#[tokio::test]
|
|
async fn frames_on_the_previous_link_session_do_not_feed_the_new_sessions_mmp() {
|
|
let HeldMsg3Pair {
|
|
mut nodes,
|
|
node0_addr,
|
|
node1_addr,
|
|
fips0,
|
|
fips1,
|
|
tun0_rx,
|
|
tun1_rx,
|
|
node1_idx_before,
|
|
held_msg3,
|
|
..
|
|
} = rekey_pair_cut_over_with_held_msg3().await;
|
|
|
|
let mmp = nodes[0]
|
|
.node
|
|
.get_peer(&node1_addr)
|
|
.unwrap()
|
|
.mmp()
|
|
.expect("node 0 must run link MMP");
|
|
assert_eq!(mmp.receiver.highest_counter(), 0, "the cutover resets MMP");
|
|
assert_eq!(mmp.metrics.rr_counters(), None, "the cutover resets MMP");
|
|
let reports_before = mmp.metrics.reports_seen();
|
|
|
|
// node 1, still on the old session, sends data and then a report about
|
|
// node 0's old-session traffic. Only node 0's queue is delivered.
|
|
let old_rev = build_ipv6_packet(&fips1, &fips0, b"old session 1 to 0");
|
|
nodes[1].node.handle_tun_outbound(old_rev.clone()).await;
|
|
nodes[1]
|
|
.node
|
|
.send_encrypted_link_message(&node0_addr, &receiver_report_message(1_000))
|
|
.await
|
|
.unwrap();
|
|
for _ in 0..3 {
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
process_available_packets(&mut nodes[..1]).await;
|
|
}
|
|
|
|
let got: Vec<Vec<u8>> = std::iter::from_fn(|| tun0_rx.try_recv().ok()).collect();
|
|
assert_eq!(
|
|
got,
|
|
vec![old_rev],
|
|
"an old-session frame's payload must still be delivered"
|
|
);
|
|
let mmp = nodes[0].node.get_peer(&node1_addr).unwrap().mmp().unwrap();
|
|
assert_eq!(
|
|
mmp.metrics.reports_seen(),
|
|
reports_before,
|
|
"an old-session ReceiverReport must not reach the new session's metrics"
|
|
);
|
|
assert_eq!(mmp.metrics.rr_counters(), None);
|
|
assert_eq!(
|
|
mmp.receiver.highest_counter(),
|
|
0,
|
|
"an old-session frame's counter must not become the new session's highest"
|
|
);
|
|
|
|
// node 1 completes the rekey on the held msg3, and node 0's first
|
|
// new-epoch frame promotes it; node 1's frames and reports on the new
|
|
// session then feed node 0's MMP.
|
|
nodes[1].node.handle_msg3(held_msg3).await;
|
|
let new_fwd = build_ipv6_packet(&fips0, &fips1, b"new session 0 to 1");
|
|
nodes[0].node.handle_tun_outbound(new_fwd.clone()).await;
|
|
pump_until_quiet(&mut nodes).await;
|
|
let got: Vec<Vec<u8>> = std::iter::from_fn(|| tun1_rx.try_recv().ok()).collect();
|
|
assert_eq!(got, vec![new_fwd]);
|
|
assert_ne!(
|
|
nodes[1].node.get_peer(&node0_addr).unwrap().our_index(),
|
|
node1_idx_before,
|
|
"node 1 must promote on node 0's first new-epoch frame"
|
|
);
|
|
let new_rev = build_ipv6_packet(&fips1, &fips0, b"new session 1 to 0");
|
|
nodes[1].node.handle_tun_outbound(new_rev.clone()).await;
|
|
nodes[1]
|
|
.node
|
|
.send_encrypted_link_message(&node0_addr, &receiver_report_message(5))
|
|
.await
|
|
.unwrap();
|
|
pump_until_quiet(&mut nodes).await;
|
|
let got: Vec<Vec<u8>> = std::iter::from_fn(|| tun0_rx.try_recv().ok()).collect();
|
|
assert_eq!(got, vec![new_rev]);
|
|
let mmp = nodes[0].node.get_peer(&node1_addr).unwrap().mmp().unwrap();
|
|
assert!(
|
|
mmp.receiver.highest_counter() > 0,
|
|
"new-session frames must be counted"
|
|
);
|
|
assert_eq!(
|
|
mmp.metrics.rr_counters().map(|(highest, _, _)| highest),
|
|
Some(5),
|
|
"a new-session ReceiverReport must be accepted"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
/// The decrypt-worker path tells the sessions apart too. A frame the worker
|
|
/// decrypted under the previous session's index, bounced back after the
|
|
/// cutover, does not feed the new session's MMP; the same frame under the
|
|
/// current session's index does.
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn a_worker_decrypted_frame_on_the_previous_link_session_does_not_feed_mmp() {
|
|
use crate::node::decrypt_worker::DecryptFallback;
|
|
|
|
let HeldMsg3Pair {
|
|
mut nodes,
|
|
node1_addr,
|
|
node0_idx_before,
|
|
..
|
|
} = rekey_pair_cut_over_with_held_msg3().await;
|
|
let peer = nodes[0].node.get_peer(&node1_addr).unwrap();
|
|
let previous_idx = peer.previous_our_index().expect("node 0 must be draining");
|
|
assert_eq!(Some(previous_idx), node0_idx_before);
|
|
let current_idx = peer.our_index().expect("node 0 must have cut over");
|
|
|
|
// A bounced worker plaintext: the 4-byte session timestamp, then a
|
|
// ReceiverReport about 1000 frames.
|
|
let (transport_id, remote_addr) = (nodes[0].transport_id, nodes[1].addr.clone());
|
|
let bounce = |receiver_idx: u32, counter: u64| {
|
|
let mut data = 0u32.to_le_bytes().to_vec();
|
|
data.extend(receiver_report_message(1_000));
|
|
DecryptFallback {
|
|
source_node_addr: node1_addr,
|
|
transport_id,
|
|
remote_addr: remote_addr.clone(),
|
|
timestamp_ms: Node::now_ms(),
|
|
packet_len: data.len() + 32,
|
|
receiver_idx,
|
|
fmp_counter: counter,
|
|
fmp_flags: 0,
|
|
fmp_plaintext_len: data.len(),
|
|
packet_data: data,
|
|
fmp_plaintext_offset: 0,
|
|
}
|
|
};
|
|
|
|
let previous = bounce(previous_idx.as_u32(), 900);
|
|
nodes[0].node.process_decrypt_fallback(previous).await;
|
|
let mmp = nodes[0].node.get_peer(&node1_addr).unwrap().mmp().unwrap();
|
|
assert_eq!(
|
|
mmp.receiver.highest_counter(),
|
|
0,
|
|
"a previous-session frame's counter must not be counted"
|
|
);
|
|
assert_eq!(
|
|
mmp.metrics.rr_counters(),
|
|
None,
|
|
"a previous-session ReceiverReport must not be processed"
|
|
);
|
|
|
|
let current = bounce(current_idx.as_u32(), 1);
|
|
nodes[0].node.process_decrypt_fallback(current).await;
|
|
let mmp = nodes[0].node.get_peer(&node1_addr).unwrap().mmp().unwrap();
|
|
assert_eq!(mmp.receiver.highest_counter(), 1);
|
|
assert_eq!(mmp.metrics.rr_counters().map(|(h, _, _)| h), Some(1_000));
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
/// A link rekey initiator that cut over keeps resending a lost msg3 through
|
|
/// frames the responder sends on the old session.
|
|
///
|
|
/// node 0's msg3 is lost. node 1, which never received it, keeps sending on
|
|
/// the old link session, and node 0 decrypts those frames against its
|
|
/// previous session. Such a frame says nothing about msg3, so node 0 must
|
|
/// keep the msg3 for resend; the resend then completes node 1's side and the
|
|
/// link carries traffic both ways on the new session.
|
|
#[tokio::test]
|
|
async fn a_lost_link_rekey_msg3_is_still_resent_after_an_old_session_frame() {
|
|
let HeldMsg3Pair {
|
|
mut nodes,
|
|
node0_addr,
|
|
node1_addr,
|
|
fips0,
|
|
fips1,
|
|
tun0_rx,
|
|
tun1_rx,
|
|
node1_idx_before,
|
|
held_msg3,
|
|
..
|
|
} = rekey_pair_cut_over_with_held_msg3().await;
|
|
drop(held_msg3);
|
|
// Windows never spawns the worker pools, so there the decrypt is
|
|
// always inline and the pool field does not exist.
|
|
#[cfg(unix)]
|
|
assert!(
|
|
nodes[0].node.supervisor.decrypt_workers.is_none(),
|
|
"node 0 must decrypt inline, so this exercises the inline confirm"
|
|
);
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_peer(&node1_addr)
|
|
.unwrap()
|
|
.rekey_msg3_payload()
|
|
.is_some(),
|
|
"node 0 holds its msg3 for resend after the cutover"
|
|
);
|
|
|
|
// node 1, still on the old session, sends a frame; only node 0's queue is
|
|
// delivered.
|
|
let old_rev = build_ipv6_packet(&fips1, &fips0, b"old session 1 to 0");
|
|
nodes[1].node.handle_tun_outbound(old_rev.clone()).await;
|
|
for _ in 0..3 {
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
process_available_packets(&mut nodes[..1]).await;
|
|
}
|
|
let got: Vec<Vec<u8>> = std::iter::from_fn(|| tun0_rx.try_recv().ok()).collect();
|
|
assert_eq!(got, vec![old_rev], "the old-session frame was delivered");
|
|
let resend_at = {
|
|
let peer = nodes[0].node.get_peer(&node1_addr).unwrap();
|
|
assert!(
|
|
peer.rekey_msg3_payload().is_some(),
|
|
"an old-session frame must not confirm the msg3"
|
|
);
|
|
peer.rekey_msg3_next_resend_ms()
|
|
};
|
|
|
|
// The resend reaches node 1, which completes its side of the rekey.
|
|
nodes[0].node.resend_pending_fmp_rekey_msg3(resend_at).await;
|
|
pump_until_quiet(&mut nodes[1..]).await;
|
|
assert!(
|
|
nodes[1]
|
|
.node
|
|
.get_peer(&node0_addr)
|
|
.unwrap()
|
|
.pending_new_session()
|
|
.is_some(),
|
|
"the resent msg3 must give node 1 the new session"
|
|
);
|
|
|
|
// node 0's first new-session frame promotes node 1, and both directions
|
|
// then decode.
|
|
let new_fwd = build_ipv6_packet(&fips0, &fips1, b"new session 0 to 1");
|
|
nodes[0].node.handle_tun_outbound(new_fwd.clone()).await;
|
|
pump_until_quiet(&mut nodes).await;
|
|
let got: Vec<Vec<u8>> = std::iter::from_fn(|| tun1_rx.try_recv().ok()).collect();
|
|
assert_eq!(got, vec![new_fwd]);
|
|
assert_ne!(
|
|
nodes[1].node.get_peer(&node0_addr).unwrap().our_index(),
|
|
node1_idx_before,
|
|
"node 1 must promote on node 0's first new-session frame"
|
|
);
|
|
let new_rev = build_ipv6_packet(&fips1, &fips0, b"new session 1 to 0");
|
|
nodes[1].node.handle_tun_outbound(new_rev.clone()).await;
|
|
pump_until_quiet(&mut nodes).await;
|
|
let got: Vec<Vec<u8>> = std::iter::from_fn(|| tun0_rx.try_recv().ok()).collect();
|
|
assert_eq!(got, vec![new_rev]);
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_peer(&node1_addr)
|
|
.unwrap()
|
|
.rekey_msg3_payload()
|
|
.is_none(),
|
|
"a frame on the new session confirms the msg3"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
/// The decrypt-worker path confirms a rekey msg3 only on a frame under the
|
|
/// current session's index; a bounce under the previous index leaves the
|
|
/// msg3 for resend.
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn a_worker_decrypted_previous_session_frame_does_not_confirm_the_rekey_msg3() {
|
|
use crate::node::decrypt_worker::DecryptFallback;
|
|
|
|
let HeldMsg3Pair {
|
|
mut nodes,
|
|
node1_addr,
|
|
..
|
|
} = rekey_pair_cut_over_with_held_msg3().await;
|
|
let peer = nodes[0].node.get_peer(&node1_addr).unwrap();
|
|
let previous_idx = peer.previous_our_index().expect("node 0 must be draining");
|
|
let current_idx = peer.our_index().expect("node 0 must have cut over");
|
|
assert!(peer.rekey_msg3_payload().is_some());
|
|
|
|
let (transport_id, remote_addr) = (nodes[0].transport_id, nodes[1].addr.clone());
|
|
let bounce = |receiver_idx: u32, counter: u64| {
|
|
let mut data = 0u32.to_le_bytes().to_vec();
|
|
data.extend(receiver_report_message(1_000));
|
|
DecryptFallback {
|
|
source_node_addr: node1_addr,
|
|
transport_id,
|
|
remote_addr: remote_addr.clone(),
|
|
timestamp_ms: Node::now_ms(),
|
|
packet_len: data.len() + 32,
|
|
receiver_idx,
|
|
fmp_counter: counter,
|
|
fmp_flags: 0,
|
|
fmp_plaintext_len: data.len(),
|
|
packet_data: data,
|
|
fmp_plaintext_offset: 0,
|
|
}
|
|
};
|
|
|
|
nodes[0]
|
|
.node
|
|
.process_decrypt_fallback(bounce(previous_idx.as_u32(), 900))
|
|
.await;
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_peer(&node1_addr)
|
|
.unwrap()
|
|
.rekey_msg3_payload()
|
|
.is_some(),
|
|
"a previous-session bounce must not confirm the msg3"
|
|
);
|
|
|
|
nodes[0]
|
|
.node
|
|
.process_decrypt_fallback(bounce(current_idx.as_u32(), 1))
|
|
.await;
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_peer(&node1_addr)
|
|
.unwrap()
|
|
.rekey_msg3_payload()
|
|
.is_none(),
|
|
"a current-session bounce confirms the msg3"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_tun_outbound_triggers_session_initiation() {
|
|
// Two connected nodes, no session yet.
|
|
// Inject a TUN packet — should trigger session initiation,
|
|
// queue the packet, and deliver after handshake completes.
|
|
let edges = vec![(0, 1)];
|
|
let mut nodes = run_tree_test(2, &edges, false).await;
|
|
verify_tree_convergence(&nodes);
|
|
populate_all_coord_caches(&mut nodes);
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
|
|
let src_fips = crate::FipsAddress::from_node_addr(&node0_addr);
|
|
let dst_fips = crate::FipsAddress::from_node_addr(&node1_addr);
|
|
|
|
// No session yet
|
|
assert_eq!(nodes[0].node.session_count(), 0);
|
|
|
|
// Install TUN receiver on Node 1
|
|
let (tun_tx, tun_rx) = std::sync::mpsc::channel();
|
|
nodes[1].node.supervisor.tun_tx = Some(tun_tx);
|
|
|
|
// Build and inject an IPv6 packet (identity cache populated at peer promotion)
|
|
let test_payload = b"trigger-session-test";
|
|
let ipv6_packet = build_ipv6_packet(&src_fips, &dst_fips, test_payload);
|
|
|
|
nodes[0].node.handle_tun_outbound(ipv6_packet.clone()).await;
|
|
|
|
// Session should now be initiating
|
|
assert_eq!(nodes[0].node.session_count(), 1);
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.state()
|
|
.is_initiating()
|
|
);
|
|
|
|
// Drain packets until session established and queued packet delivered
|
|
drain_to_quiescence(&mut nodes).await;
|
|
|
|
// Session should be established on Node 0
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.state()
|
|
.is_established()
|
|
);
|
|
|
|
// Verify the queued packet was delivered to Node 1
|
|
let delivered: Vec<Vec<u8>> = std::iter::from_fn(|| tun_rx.try_recv().ok()).collect();
|
|
assert_eq!(
|
|
delivered.len(),
|
|
1,
|
|
"Queued packet should be delivered after handshake"
|
|
);
|
|
assert_eq!(delivered[0], ipv6_packet);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_tun_outbound_unknown_destination() {
|
|
// Inject a packet for an unknown destination — should get ICMPv6 back
|
|
let edges = vec![(0, 1)];
|
|
let mut nodes = run_tree_test(2, &edges, false).await;
|
|
verify_tree_convergence(&nodes);
|
|
|
|
// Install TUN receiver on Node 0 (for ICMPv6 response)
|
|
let (tun_tx, tun_rx) = std::sync::mpsc::channel();
|
|
nodes[0].node.supervisor.tun_tx = Some(tun_tx);
|
|
|
|
let src_fips = crate::FipsAddress::from_node_addr(nodes[0].node.node_addr());
|
|
|
|
// Build a packet to an unknown FIPS address (not in identity cache)
|
|
let unknown_addr = NodeAddr::from_bytes([0xAA; 16]);
|
|
let unknown_fips = crate::FipsAddress::from_node_addr(&unknown_addr);
|
|
let ipv6_packet = build_ipv6_packet(&src_fips, &unknown_fips, b"unknown");
|
|
|
|
nodes[0].node.handle_tun_outbound(ipv6_packet).await;
|
|
|
|
// Should receive ICMPv6 Destination Unreachable back on TUN
|
|
let delivered: Vec<Vec<u8>> = std::iter::from_fn(|| tun_rx.try_recv().ok()).collect();
|
|
assert_eq!(
|
|
delivered.len(),
|
|
1,
|
|
"Should receive ICMPv6 Destination Unreachable"
|
|
);
|
|
// Verify it's an ICMPv6 Destination Unreachable (type 1, code 0)
|
|
// ICMPv6 header starts at byte 40, type at byte 40, code at byte 41
|
|
assert!(delivered[0].len() >= 48, "ICMPv6 response too short");
|
|
assert_eq!(delivered[0][6], 58, "Next header should be ICMPv6 (58)");
|
|
assert_eq!(
|
|
delivered[0][40], 1,
|
|
"ICMPv6 type should be Destination Unreachable (1)"
|
|
);
|
|
assert_eq!(delivered[0][41], 0, "ICMPv6 code should be No Route (0)");
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_tun_outbound_3node_forwarded() {
|
|
// A—B—C: TUN packet from A destined for C, forwarded through B
|
|
let edges = vec![(0, 1), (1, 2)];
|
|
let mut nodes = run_tree_test(3, &edges, false).await;
|
|
verify_tree_convergence(&nodes);
|
|
populate_all_coord_caches(&mut nodes);
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node2_addr = *nodes[2].node.node_addr();
|
|
|
|
let src_fips = crate::FipsAddress::from_node_addr(&node0_addr);
|
|
let dst_fips = crate::FipsAddress::from_node_addr(&node2_addr);
|
|
|
|
// Register Node 2's identity in Node 0's cache
|
|
// (In production, this would come from the discovery protocol or DNS priming)
|
|
let node2_pubkey = nodes[2].node.identity().pubkey_full();
|
|
nodes[0].node.register_identity(node2_addr, node2_pubkey);
|
|
|
|
// Install TUN receiver on Node 2
|
|
let (tun_tx, tun_rx) = std::sync::mpsc::channel();
|
|
nodes[2].node.supervisor.tun_tx = Some(tun_tx);
|
|
|
|
// Build and inject an IPv6 packet (triggers session initiation to Node 2)
|
|
let test_payload = b"forwarded-data-plane";
|
|
let ipv6_packet = build_ipv6_packet(&src_fips, &dst_fips, test_payload);
|
|
|
|
nodes[0].node.handle_tun_outbound(ipv6_packet.clone()).await;
|
|
|
|
// Drain packets: handshake + queued data delivery
|
|
drain_to_quiescence(&mut nodes).await;
|
|
|
|
// Session should be established
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node2_addr)
|
|
.unwrap()
|
|
.state()
|
|
.is_established()
|
|
);
|
|
|
|
// Verify packet delivered to Node 2
|
|
let delivered: Vec<Vec<u8>> = std::iter::from_fn(|| tun_rx.try_recv().ok()).collect();
|
|
assert_eq!(delivered.len(), 1, "Packet should be delivered to Node 2");
|
|
assert_eq!(delivered[0], ipv6_packet);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_tun_outbound_pending_queue_flush() {
|
|
// Send multiple packets before session exists — all should be delivered
|
|
let edges = vec![(0, 1)];
|
|
let mut nodes = run_tree_test(2, &edges, false).await;
|
|
verify_tree_convergence(&nodes);
|
|
populate_all_coord_caches(&mut nodes);
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
|
|
let src_fips = crate::FipsAddress::from_node_addr(&node0_addr);
|
|
let dst_fips = crate::FipsAddress::from_node_addr(&node1_addr);
|
|
|
|
// Install TUN receiver on Node 1
|
|
let (tun_tx, tun_rx) = std::sync::mpsc::channel();
|
|
nodes[1].node.supervisor.tun_tx = Some(tun_tx);
|
|
|
|
// Send 5 packets before any session exists
|
|
let mut packets = Vec::new();
|
|
for i in 0..5u8 {
|
|
let payload = format!("queued-pkt-{}", i).into_bytes();
|
|
let ipv6_packet = build_ipv6_packet(&src_fips, &dst_fips, &payload);
|
|
packets.push(ipv6_packet.clone());
|
|
nodes[0].node.handle_tun_outbound(ipv6_packet).await;
|
|
}
|
|
|
|
// First packet triggers session initiation, rest are queued
|
|
assert_eq!(nodes[0].node.session_count(), 1);
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.state()
|
|
.is_initiating()
|
|
);
|
|
|
|
// Drain until session established and queued packets flushed
|
|
drain_to_quiescence(&mut nodes).await;
|
|
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.state()
|
|
.is_established()
|
|
);
|
|
|
|
// All 5 packets should have been delivered
|
|
let delivered: Vec<Vec<u8>> = std::iter::from_fn(|| tun_rx.try_recv().ok()).collect();
|
|
assert_eq!(
|
|
delivered.len(),
|
|
5,
|
|
"All 5 queued packets should be delivered"
|
|
);
|
|
for (i, pkt) in delivered.iter().enumerate() {
|
|
assert_eq!(*pkt, packets[i], "Packet {} should match", i);
|
|
}
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Unit tests: Session idle timeout
|
|
// ============================================================================
|
|
|
|
/// Helper: complete a Noise XX handshake and return the initiator's NoiseSession.
|
|
fn make_noise_session(
|
|
our_identity: &Identity,
|
|
remote_identity: &Identity,
|
|
) -> crate::noise::NoiseSession {
|
|
use crate::noise::HandshakeState;
|
|
|
|
let mut initiator = HandshakeState::new_initiator(our_identity.keypair());
|
|
let mut responder = HandshakeState::new_responder(remote_identity.keypair());
|
|
|
|
// Set epochs for both sides (required for handshake message encryption)
|
|
let mut init_epoch = [0u8; 8];
|
|
rand::Rng::fill_bytes(&mut rand::rng(), &mut init_epoch);
|
|
initiator.set_local_epoch(init_epoch);
|
|
let mut resp_epoch = [0u8; 8];
|
|
rand::Rng::fill_bytes(&mut rand::rng(), &mut resp_epoch);
|
|
responder.set_local_epoch(resp_epoch);
|
|
|
|
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();
|
|
let msg3 = initiator.write_message_3().unwrap();
|
|
responder.read_message_3(&msg3).unwrap();
|
|
|
|
initiator.into_session().unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn test_purge_idle_sessions_removes_expired() {
|
|
let mut node = make_node();
|
|
let remote = Identity::generate();
|
|
let remote_addr = *remote.node_addr();
|
|
|
|
let session = make_noise_session(node.identity(), &remote);
|
|
let entry = crate::node::session::SessionEntry::new(
|
|
remote_addr,
|
|
remote.pubkey_full(),
|
|
EndToEndState::Established(session),
|
|
1000, // created at t=1000ms
|
|
true,
|
|
);
|
|
|
|
node.sessions.insert(remote_addr, entry);
|
|
assert_eq!(node.session_count(), 1);
|
|
assert!(node.get_session(&remote_addr).unwrap().is_established());
|
|
|
|
// Purge at t=92s — should exceed default 90s idle timeout
|
|
let now_ms = 1000 + 92_000;
|
|
node.purge_idle_sessions(now_ms);
|
|
|
|
assert_eq!(node.session_count(), 0, "Idle session should be purged");
|
|
}
|
|
|
|
#[test]
|
|
fn test_purge_idle_sessions_keeps_active() {
|
|
let mut node = make_node();
|
|
let remote = Identity::generate();
|
|
let remote_addr = *remote.node_addr();
|
|
|
|
let session = make_noise_session(node.identity(), &remote);
|
|
let mut entry = crate::node::session::SessionEntry::new(
|
|
remote_addr,
|
|
remote.pubkey_full(),
|
|
EndToEndState::Established(session),
|
|
1000,
|
|
true,
|
|
);
|
|
|
|
// Touch at t=80s — recent activity
|
|
entry.touch(81_000);
|
|
|
|
node.sessions.insert(remote_addr, entry);
|
|
|
|
// Purge at t=92s — only 11s since last activity, well within 90s timeout
|
|
let now_ms = 92_000;
|
|
node.purge_idle_sessions(now_ms);
|
|
|
|
assert_eq!(
|
|
node.session_count(),
|
|
1,
|
|
"Active session should survive purge"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_purge_idle_sessions_ignores_initiating() {
|
|
use crate::noise::HandshakeState;
|
|
|
|
let mut node = make_node();
|
|
let remote = Identity::generate();
|
|
let remote_addr = *remote.node_addr();
|
|
|
|
let handshake = HandshakeState::new_initiator(node.identity().keypair());
|
|
let entry = crate::node::session::SessionEntry::new(
|
|
remote_addr,
|
|
remote.pubkey_full(),
|
|
EndToEndState::Initiating(handshake),
|
|
1000,
|
|
true,
|
|
);
|
|
|
|
node.sessions.insert(remote_addr, entry);
|
|
|
|
// Purge well past the idle timeout — Initiating sessions should not be touched
|
|
let now_ms = 1000 + 200_000;
|
|
node.purge_idle_sessions(now_ms);
|
|
|
|
assert_eq!(
|
|
node.session_count(),
|
|
1,
|
|
"Initiating session should not be purged by idle timeout"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_purge_idle_sessions_cleans_pending_packets() {
|
|
let mut node = make_node();
|
|
let remote = Identity::generate();
|
|
let remote_addr = *remote.node_addr();
|
|
|
|
let session = make_noise_session(node.identity(), &remote);
|
|
let entry = crate::node::session::SessionEntry::new(
|
|
remote_addr,
|
|
remote.pubkey_full(),
|
|
EndToEndState::Established(session),
|
|
1000,
|
|
true,
|
|
);
|
|
|
|
node.sessions.insert(remote_addr, entry);
|
|
|
|
// Insert some pending packets for this destination
|
|
let mut queue = std::collections::VecDeque::new();
|
|
queue.push_back(vec![1, 2, 3]);
|
|
node.pending_tun_packets.insert(remote_addr, queue);
|
|
assert!(node.pending_tun_packets.contains_key(&remote_addr));
|
|
|
|
// Purge after idle timeout
|
|
let now_ms = 1000 + 92_000;
|
|
node.purge_idle_sessions(now_ms);
|
|
|
|
assert_eq!(node.session_count(), 0);
|
|
assert!(
|
|
!node.pending_tun_packets.contains_key(&remote_addr),
|
|
"Pending packets should be cleaned up with idle session"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_purge_idle_sessions_disabled_when_zero() {
|
|
let mut config = Config::new();
|
|
config.node.session.idle_timeout_secs = 0;
|
|
let mut node = make_node_with(config);
|
|
|
|
let remote = Identity::generate();
|
|
let remote_addr = *remote.node_addr();
|
|
|
|
let session = make_noise_session(node.identity(), &remote);
|
|
let entry = crate::node::session::SessionEntry::new(
|
|
remote_addr,
|
|
remote.pubkey_full(),
|
|
EndToEndState::Established(session),
|
|
1000,
|
|
true,
|
|
);
|
|
|
|
node.sessions.insert(remote_addr, entry);
|
|
|
|
// Even way past any timeout, sessions should survive when disabled
|
|
let now_ms = 1000 + 1_000_000;
|
|
node.purge_idle_sessions(now_ms);
|
|
|
|
assert_eq!(
|
|
node.session_count(),
|
|
1,
|
|
"Sessions should not be purged when idle timeout is disabled"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_purge_idle_sessions_mmp_activity_does_not_prevent_purge() {
|
|
let mut node = make_node();
|
|
let remote = Identity::generate();
|
|
let remote_addr = *remote.node_addr();
|
|
|
|
let session = make_noise_session(node.identity(), &remote);
|
|
let entry = crate::node::session::SessionEntry::new(
|
|
remote_addr,
|
|
remote.pubkey_full(),
|
|
EndToEndState::Established(session),
|
|
1000, // created at t=1s
|
|
true,
|
|
);
|
|
|
|
// Do NOT call entry.touch() — simulates a session where only MMP
|
|
// reports have flowed (MMP no longer calls touch). last_activity
|
|
// remains at creation time (1000ms).
|
|
node.sessions.insert(remote_addr, entry);
|
|
|
|
// Purge at t=92s — 91s since creation, exceeds 90s idle timeout.
|
|
// Even though MMP reports would have been flowing, they no longer
|
|
// reset the idle timer.
|
|
let now_ms = 92_000;
|
|
node.purge_idle_sessions(now_ms);
|
|
|
|
assert_eq!(
|
|
node.session_count(),
|
|
0,
|
|
"Session with MMP-only activity should be purged"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Unit tests: COORDS_PRESENT warmup counter
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_coords_warmup_counter_default_zero_on_new() {
|
|
use crate::noise::HandshakeState;
|
|
|
|
let identity_a = Identity::generate();
|
|
let identity_b = Identity::generate();
|
|
|
|
let handshake = HandshakeState::new_initiator(identity_a.keypair());
|
|
|
|
let entry = crate::node::session::SessionEntry::new(
|
|
*identity_b.node_addr(),
|
|
identity_b.pubkey_full(),
|
|
EndToEndState::Initiating(handshake),
|
|
1000,
|
|
true,
|
|
);
|
|
|
|
assert_eq!(
|
|
entry.coords_warmup_remaining(),
|
|
0,
|
|
"Counter should be 0 for non-Established sessions"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_coords_warmup_counter_set_and_get() {
|
|
let node = make_node();
|
|
let remote = Identity::generate();
|
|
let remote_addr = *remote.node_addr();
|
|
|
|
let session = make_noise_session(node.identity(), &remote);
|
|
let mut entry = crate::node::session::SessionEntry::new(
|
|
remote_addr,
|
|
remote.pubkey_full(),
|
|
EndToEndState::Established(session),
|
|
1000,
|
|
true,
|
|
);
|
|
|
|
assert_eq!(entry.coords_warmup_remaining(), 0);
|
|
|
|
entry.set_coords_warmup_remaining(5);
|
|
assert_eq!(entry.coords_warmup_remaining(), 5);
|
|
|
|
entry.set_coords_warmup_remaining(0);
|
|
assert_eq!(entry.coords_warmup_remaining(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_coords_warmup_counter_decrement() {
|
|
let node = make_node();
|
|
let remote = Identity::generate();
|
|
let remote_addr = *remote.node_addr();
|
|
|
|
let session = make_noise_session(node.identity(), &remote);
|
|
let mut entry = crate::node::session::SessionEntry::new(
|
|
remote_addr,
|
|
remote.pubkey_full(),
|
|
EndToEndState::Established(session),
|
|
1000,
|
|
true,
|
|
);
|
|
|
|
entry.set_coords_warmup_remaining(3);
|
|
|
|
// Simulate the decrement pattern used in send_session_data
|
|
for expected in (0..3).rev() {
|
|
assert!(entry.coords_warmup_remaining() > 0);
|
|
entry.set_coords_warmup_remaining(entry.coords_warmup_remaining() - 1);
|
|
assert_eq!(entry.coords_warmup_remaining(), expected);
|
|
}
|
|
|
|
assert_eq!(
|
|
entry.coords_warmup_remaining(),
|
|
0,
|
|
"Counter should reach 0 after N decrements"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_coords_warmup_config_default() {
|
|
let config = crate::config::Config::new();
|
|
assert_eq!(
|
|
config.node.session.coords_warmup_packets, 5,
|
|
"Default coords_warmup_packets should be 5"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Unit tests: Identity cache
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_identity_cache_lru_eviction() {
|
|
let mut config = crate::Config::new();
|
|
config.node.cache.identity_size = 2;
|
|
let mut node = make_node_with(config);
|
|
|
|
let id1 = Identity::generate();
|
|
let id2 = Identity::generate();
|
|
let id3 = Identity::generate();
|
|
|
|
// Insert first two with explicit timestamps to ensure deterministic ordering
|
|
let mut prefix1 = [0u8; 15];
|
|
prefix1.copy_from_slice(&id1.node_addr().as_bytes()[0..15]);
|
|
node.identity_cache
|
|
.insert(prefix1, (*id1.node_addr(), id1.pubkey_full(), 1000));
|
|
|
|
let mut prefix2 = [0u8; 15];
|
|
prefix2.copy_from_slice(&id2.node_addr().as_bytes()[0..15]);
|
|
node.identity_cache
|
|
.insert(prefix2, (*id2.node_addr(), id2.pubkey_full(), 2000));
|
|
|
|
assert_eq!(node.identity_cache_len(), 2);
|
|
|
|
// Adding a third should evict the oldest (id1, timestamp 1000)
|
|
node.register_identity(*id3.node_addr(), id3.pubkey_full());
|
|
assert_eq!(node.identity_cache_len(), 2);
|
|
|
|
assert!(
|
|
node.lookup_by_fips_prefix(&prefix1).is_none(),
|
|
"Oldest entry should have been evicted"
|
|
);
|
|
|
|
let mut prefix3 = [0u8; 15];
|
|
prefix3.copy_from_slice(&id3.node_addr().as_bytes()[0..15]);
|
|
assert!(
|
|
node.lookup_by_fips_prefix(&prefix3).is_some(),
|
|
"Newest entry should be present"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_identity_cache_lookup() {
|
|
let mut node = make_node();
|
|
|
|
let remote = Identity::generate();
|
|
let remote_addr = *remote.node_addr();
|
|
|
|
node.register_identity(remote_addr, remote.pubkey_full());
|
|
|
|
let mut prefix = [0u8; 15];
|
|
prefix.copy_from_slice(&remote_addr.as_bytes()[0..15]);
|
|
|
|
let result = node.lookup_by_fips_prefix(&prefix);
|
|
assert!(result.is_some(), "Registered identity should be available");
|
|
|
|
let (addr, pk) = result.unwrap();
|
|
assert_eq!(addr, remote_addr);
|
|
assert_eq!(pk, remote.pubkey_full());
|
|
}
|
|
|
|
// ============================================================================
|
|
// Session-layer handshake resend tests
|
|
// ============================================================================
|
|
|
|
/// Test that SessionEntry handshake payload storage works correctly.
|
|
#[test]
|
|
fn test_session_entry_handshake_payload_storage() {
|
|
use crate::noise::HandshakeState;
|
|
|
|
let identity_a = Identity::generate();
|
|
let identity_b = Identity::generate();
|
|
|
|
let handshake = HandshakeState::new_initiator(identity_a.keypair());
|
|
|
|
let mut entry = crate::node::session::SessionEntry::new(
|
|
*identity_b.node_addr(),
|
|
identity_b.pubkey_full(),
|
|
EndToEndState::Initiating(handshake),
|
|
1000,
|
|
true,
|
|
);
|
|
|
|
// Initially no handshake payload
|
|
assert!(entry.handshake_payload().is_none());
|
|
assert_eq!(entry.resend_count(), 0);
|
|
assert_eq!(entry.next_resend_at_ms(), 0);
|
|
|
|
// Store a handshake payload
|
|
let payload = vec![0x01, 0x02, 0x03, 0x04];
|
|
entry.set_handshake_payload(payload.clone(), 2000);
|
|
|
|
assert_eq!(entry.handshake_payload().unwrap(), &payload);
|
|
assert_eq!(entry.resend_count(), 0);
|
|
assert_eq!(entry.next_resend_at_ms(), 2000);
|
|
}
|
|
|
|
/// Test that resend_count and next_resend_at_ms track correctly on SessionEntry.
|
|
#[test]
|
|
fn test_session_entry_resend_tracking() {
|
|
use crate::noise::HandshakeState;
|
|
|
|
let identity_a = Identity::generate();
|
|
let identity_b = Identity::generate();
|
|
|
|
let handshake = HandshakeState::new_initiator(identity_a.keypair());
|
|
|
|
let mut entry = crate::node::session::SessionEntry::new(
|
|
*identity_b.node_addr(),
|
|
identity_b.pubkey_full(),
|
|
EndToEndState::Initiating(handshake),
|
|
1000,
|
|
true,
|
|
);
|
|
|
|
entry.set_handshake_payload(vec![0x01], 2000);
|
|
|
|
// Record first resend
|
|
entry.record_resend(4000);
|
|
assert_eq!(entry.resend_count(), 1);
|
|
assert_eq!(entry.next_resend_at_ms(), 4000);
|
|
|
|
// Record second resend
|
|
entry.record_resend(8000);
|
|
assert_eq!(entry.resend_count(), 2);
|
|
assert_eq!(entry.next_resend_at_ms(), 8000);
|
|
}
|
|
|
|
/// Test that clear_handshake_payload clears payload and resets timer.
|
|
#[test]
|
|
fn test_session_entry_clear_handshake_payload() {
|
|
use crate::noise::HandshakeState;
|
|
|
|
let identity_a = Identity::generate();
|
|
let identity_b = Identity::generate();
|
|
|
|
let handshake = HandshakeState::new_initiator(identity_a.keypair());
|
|
|
|
let mut entry = crate::node::session::SessionEntry::new(
|
|
*identity_b.node_addr(),
|
|
identity_b.pubkey_full(),
|
|
EndToEndState::Initiating(handshake),
|
|
1000,
|
|
true,
|
|
);
|
|
|
|
entry.set_handshake_payload(vec![0x01, 0x02], 2000);
|
|
entry.record_resend(4000);
|
|
assert!(entry.handshake_payload().is_some());
|
|
assert_eq!(entry.resend_count(), 1);
|
|
|
|
// Clear on Established transition
|
|
entry.clear_handshake_payload();
|
|
assert!(entry.handshake_payload().is_none());
|
|
assert_eq!(entry.next_resend_at_ms(), 0);
|
|
// resend_count is NOT reset — it's a historical record
|
|
assert_eq!(entry.resend_count(), 1);
|
|
}
|
|
|
|
/// Test that session handshake timeout removes stale Initiating sessions.
|
|
#[tokio::test]
|
|
async fn test_session_handshake_timeout() {
|
|
use crate::noise::HandshakeState;
|
|
|
|
let mut node = make_node();
|
|
|
|
let identity_b = Identity::generate();
|
|
let handshake = HandshakeState::new_initiator(node.identity().keypair());
|
|
|
|
let dest_addr = *identity_b.node_addr();
|
|
|
|
// Create a session at time 1000
|
|
let entry = crate::node::session::SessionEntry::new(
|
|
dest_addr,
|
|
identity_b.pubkey_full(),
|
|
EndToEndState::Initiating(handshake),
|
|
1000,
|
|
true,
|
|
);
|
|
node.sessions.insert(dest_addr, entry);
|
|
|
|
assert!(node.sessions.contains_key(&dest_addr));
|
|
|
|
// Before timeout: session should remain
|
|
let timeout_secs = node.config().node.rate_limit.handshake_timeout_secs;
|
|
let before_timeout = 1000 + timeout_secs * 1000 - 1;
|
|
node.resend_pending_session_handshakes(before_timeout).await;
|
|
assert!(
|
|
node.sessions.contains_key(&dest_addr),
|
|
"Session should survive before timeout"
|
|
);
|
|
|
|
// After timeout: session should be removed
|
|
let after_timeout = 1000 + timeout_secs * 1000 + 1;
|
|
node.resend_pending_session_handshakes(after_timeout).await;
|
|
assert!(
|
|
!node.sessions.contains_key(&dest_addr),
|
|
"Timed-out session should be removed"
|
|
);
|
|
}
|
|
|
|
/// Test that session handshake timeout removes stale AwaitingMsg3 sessions.
|
|
#[tokio::test]
|
|
async fn test_session_awaiting_msg3_timeout() {
|
|
use crate::noise::HandshakeState;
|
|
|
|
let mut node = make_node();
|
|
|
|
let identity_a = Identity::generate();
|
|
let identity_b = Identity::generate();
|
|
|
|
let handshake = HandshakeState::new_responder(identity_b.keypair());
|
|
|
|
let src_addr = *identity_a.node_addr();
|
|
|
|
// Create an AwaitingMsg3 session at time 1000
|
|
let entry = crate::node::session::SessionEntry::new(
|
|
src_addr,
|
|
identity_a.pubkey_full(),
|
|
EndToEndState::AwaitingMsg3(handshake),
|
|
1000,
|
|
false,
|
|
);
|
|
node.sessions.insert(src_addr, entry);
|
|
|
|
assert!(node.sessions.contains_key(&src_addr));
|
|
|
|
// After timeout: session should be removed
|
|
let timeout_secs = node.config().node.rate_limit.handshake_timeout_secs;
|
|
let after_timeout = 1000 + timeout_secs * 1000 + 1;
|
|
node.resend_pending_session_handshakes(after_timeout).await;
|
|
assert!(
|
|
!node.sessions.contains_key(&src_addr),
|
|
"Timed-out AwaitingMsg3 session should be removed"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_tun_outbound_path_mtu_generates_ptb() {
|
|
// When a session's PathMtuState reports a lower MTU than the local
|
|
// transport (simulating a bottleneck learned via MtuExceeded signals),
|
|
// handle_tun_outbound should generate ICMPv6 Packet Too Big for
|
|
// oversized packets instead of forwarding them.
|
|
let edges = vec![(0, 1)];
|
|
let mut nodes = run_tree_test(2, &edges, false).await;
|
|
verify_tree_convergence(&nodes);
|
|
populate_all_coord_caches(&mut nodes);
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
let node1_pubkey = nodes[1].node.identity().pubkey_full();
|
|
|
|
let src_fips = crate::FipsAddress::from_node_addr(&node0_addr);
|
|
let dst_fips = crate::FipsAddress::from_node_addr(&node1_addr);
|
|
|
|
// Establish session (XX: 3 messages — Setup, Ack, Msg3)
|
|
nodes[0]
|
|
.node
|
|
.initiate_session(node1_addr, node1_pubkey)
|
|
.await
|
|
.unwrap();
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.state()
|
|
.is_established()
|
|
);
|
|
|
|
// Simulate receipt of MtuExceeded by reducing PathMtuState to a value
|
|
// lower than the local transport MTU.
|
|
let local_transport_mtu = nodes[0].node.transport_mtu();
|
|
let reduced_mtu = local_transport_mtu - 200;
|
|
{
|
|
let entry = nodes[0].node.get_session_mut(&node1_addr).unwrap();
|
|
let mmp = entry.mmp_mut().unwrap();
|
|
mmp.path_mtu
|
|
.apply_notification(reduced_mtu, crate::time::mono_ms());
|
|
assert_eq!(mmp.path_mtu.current_mtu(), reduced_mtu);
|
|
}
|
|
|
|
// Install TUN receiver on source node to capture ICMPv6 PTB
|
|
let (tun_tx, tun_rx) = std::sync::mpsc::channel();
|
|
nodes[0].node.supervisor.tun_tx = Some(tun_tx);
|
|
|
|
// Build an IPv6 packet that fits local MTU but exceeds path MTU
|
|
let reduced_ipv6_mtu = crate::upper::icmp::effective_ipv6_mtu(reduced_mtu) as usize;
|
|
let local_ipv6_mtu = nodes[0].node.effective_ipv6_mtu() as usize;
|
|
let oversized_payload = vec![0u8; reduced_ipv6_mtu - 39]; // 40-byte hdr + payload > reduced MTU
|
|
let ipv6_packet = build_ipv6_packet(&src_fips, &dst_fips, &oversized_payload);
|
|
assert!(
|
|
ipv6_packet.len() > reduced_ipv6_mtu,
|
|
"packet must exceed path MTU"
|
|
);
|
|
assert!(
|
|
ipv6_packet.len() <= local_ipv6_mtu,
|
|
"packet must fit local MTU"
|
|
);
|
|
|
|
nodes[0].node.handle_tun_outbound(ipv6_packet).await;
|
|
|
|
// Verify ICMPv6 Packet Too Big was generated
|
|
let ptb_messages: Vec<Vec<u8>> = std::iter::from_fn(|| tun_rx.try_recv().ok()).collect();
|
|
assert_eq!(
|
|
ptb_messages.len(),
|
|
1,
|
|
"Should generate exactly one ICMPv6 PTB"
|
|
);
|
|
|
|
let ptb = &ptb_messages[0];
|
|
assert_eq!(ptb[0] >> 4, 6, "Should be IPv6");
|
|
assert_eq!(ptb[6], 58, "Next header should be ICMPv6 (58)");
|
|
assert_eq!(ptb[40], 2, "ICMPv6 type should be Packet Too Big (2)");
|
|
assert_eq!(ptb[41], 0, "ICMPv6 code should be 0");
|
|
|
|
// Verify PTB source is the *remote peer* (original packet's destination),
|
|
// NOT the local node. Linux ignores PTBs whose source matches a local
|
|
// address, causing a PMTUD blackhole.
|
|
let ptb_src = std::net::Ipv6Addr::from(<[u8; 16]>::try_from(&ptb[8..24]).unwrap());
|
|
let ptb_dst = std::net::Ipv6Addr::from(<[u8; 16]>::try_from(&ptb[24..40]).unwrap());
|
|
assert_eq!(
|
|
ptb_src,
|
|
dst_fips.to_ipv6(),
|
|
"PTB source must be remote peer (original dst), not local node"
|
|
);
|
|
assert_eq!(
|
|
ptb_dst,
|
|
src_fips.to_ipv6(),
|
|
"PTB destination must be local node (original src)"
|
|
);
|
|
|
|
// Verify reported MTU (32-bit field at ICMPv6 header bytes 4-7)
|
|
let reported_mtu = u32::from_be_bytes([ptb[44], ptb[45], ptb[46], ptb[47]]);
|
|
assert_eq!(
|
|
reported_mtu, reduced_ipv6_mtu as u32,
|
|
"Reported MTU should match path IPv6 MTU"
|
|
);
|
|
|
|
// Verify a packet that fits within path MTU passes through (no PTB)
|
|
let (tun_tx2, tun_rx2) = std::sync::mpsc::channel();
|
|
nodes[0].node.supervisor.tun_tx = Some(tun_tx2);
|
|
let fitting_payload = vec![0u8; reduced_ipv6_mtu - 41]; // fits within path MTU
|
|
let fitting_packet = build_ipv6_packet(&src_fips, &dst_fips, &fitting_payload);
|
|
assert!(fitting_packet.len() <= reduced_ipv6_mtu);
|
|
|
|
nodes[0].node.handle_tun_outbound(fitting_packet).await;
|
|
|
|
// No PTB should be generated for a fitting packet
|
|
let ptb_messages2: Vec<Vec<u8>> = std::iter::from_fn(|| tun_rx2.try_recv().ok()).collect();
|
|
assert_eq!(
|
|
ptb_messages2.len(),
|
|
0,
|
|
"Should not generate PTB for fitting packet"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Integration test: Multi-hop PMTUD with heterogeneous MTUs
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_multihop_pmtud_heterogeneous_mtu() {
|
|
// Three-node chain: A(1400)—B(800)—C(800)
|
|
//
|
|
// Node B has a smaller transport MTU than A. When A sends an IPv6
|
|
// packet that fits A's local MTU (1294) but whose wire size after
|
|
// FIPS encapsulation exceeds B's transport MTU (800), B's forwarding
|
|
// path fails with MtuExceeded and sends an MtuExceeded signal back
|
|
// to A. A updates PathMtuState, and the next oversized packet
|
|
// generates ICMPv6 Packet Too Big on TUN.
|
|
//
|
|
// This exercises the full PMTUD loop:
|
|
// 1. Oversized packet forwarded A→B
|
|
// 2. B→C forward fails (B's transport MTU 800 exceeded)
|
|
// 3. B sends MtuExceeded signal back to A
|
|
// 4. A receives signal, updates PathMtuState for C
|
|
// 5. Next oversized packet → ICMPv6 PTB on TUN
|
|
let mtus = [1400, 800, 800];
|
|
let edges = vec![(0, 1), (1, 2)];
|
|
let mut nodes = run_tree_test_with_mtus(&mtus, &edges).await;
|
|
verify_tree_convergence(&nodes);
|
|
populate_all_coord_caches(&mut nodes);
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node2_addr = *nodes[2].node.node_addr();
|
|
|
|
let src_fips = crate::FipsAddress::from_node_addr(&node0_addr);
|
|
let dst_fips = crate::FipsAddress::from_node_addr(&node2_addr);
|
|
|
|
// Register Node 2's identity in Node 0's cache
|
|
let node2_pubkey = nodes[2].node.identity().pubkey_full();
|
|
nodes[0].node.register_identity(node2_addr, node2_pubkey);
|
|
|
|
// Establish session A→C via B (triggers routing through tree)
|
|
nodes[0]
|
|
.node
|
|
.initiate_session(node2_addr, node2_pubkey)
|
|
.await
|
|
.unwrap();
|
|
drain_to_quiescence(&mut nodes).await;
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node2_addr)
|
|
.unwrap()
|
|
.state()
|
|
.is_established(),
|
|
"Session A→C should be established"
|
|
);
|
|
|
|
// Exhaust coord warmup by sending small packets first.
|
|
// Without piggybacked coords, the wire packet is ~106 + IPv6 bytes,
|
|
// which fits B's receive buffer (mtu+100=900) for reasonable sizes.
|
|
// With coords (~66 extra), the wire could exceed B's recv buffer.
|
|
for _ in 0..5 {
|
|
let small = build_ipv6_packet(&src_fips, &dst_fips, &[0u8; 10]);
|
|
nodes[0]
|
|
.node
|
|
.send_ipv6_packet(&node2_addr, &small)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
drain_to_quiescence(&mut nodes).await;
|
|
|
|
// Build an IPv6 packet that fits A's local MTU (1294) but whose wire
|
|
// size (~750 + 106 = ~856 bytes) exceeds B's transport MTU (800).
|
|
// effective_ipv6_mtu(1400) = 1294, effective_ipv6_mtu(800) = 694
|
|
let oversized_payload = vec![0xABu8; 750 - 40]; // 710 bytes payload → 750-byte IPv6 packet
|
|
let ipv6_packet = build_ipv6_packet(&src_fips, &dst_fips, &oversized_payload);
|
|
assert_eq!(ipv6_packet.len(), 750);
|
|
let local_effective_mtu = crate::upper::icmp::effective_ipv6_mtu(1400) as usize;
|
|
assert!(
|
|
ipv6_packet.len() <= local_effective_mtu,
|
|
"packet ({}) must fit A's local MTU ({})",
|
|
ipv6_packet.len(),
|
|
local_effective_mtu
|
|
);
|
|
|
|
// Send the oversized packet — B should fail to forward and send
|
|
// MtuExceeded signal back.
|
|
nodes[0]
|
|
.node
|
|
.send_ipv6_packet(&node2_addr, &ipv6_packet)
|
|
.await
|
|
.unwrap();
|
|
drain_to_quiescence(&mut nodes).await;
|
|
|
|
// Verify PathMtuState was updated on A
|
|
let path_mtu = {
|
|
let entry = nodes[0].node.get_session(&node2_addr).unwrap();
|
|
let mmp = entry.mmp().expect("session should have MMP state");
|
|
mmp.path_mtu.current_mtu()
|
|
};
|
|
assert!(
|
|
path_mtu < 1400,
|
|
"PathMtuState should have decreased from MtuExceeded signal, got {}",
|
|
path_mtu
|
|
);
|
|
|
|
// Verify path_mtu_lookup (consulted by the TUN reader/writer at TCP MSS
|
|
// clamp time) also reflects the tightened bottleneck. The reactive
|
|
// MtuExceeded handler writes here so subsequent SYN clamps see the
|
|
// forward-path budget rather than the discovery reverse-path value.
|
|
let lookup_mtu = nodes[0]
|
|
.node
|
|
.path_mtu_lookup_get(&dst_fips)
|
|
.expect("path_mtu_lookup should have entry for C after MtuExceeded");
|
|
assert!(
|
|
lookup_mtu < 1400,
|
|
"path_mtu_lookup should have tightened from MtuExceeded signal, got {}",
|
|
lookup_mtu
|
|
);
|
|
|
|
// Now send ANOTHER oversized packet — this time handle_tun_outbound
|
|
// should check PathMtuState and generate ICMPv6 PTB on TUN instead
|
|
// of forwarding.
|
|
let (tun_tx2, tun_rx2) = std::sync::mpsc::channel();
|
|
nodes[0].node.supervisor.tun_tx = Some(tun_tx2);
|
|
|
|
nodes[0].node.handle_tun_outbound(ipv6_packet.clone()).await;
|
|
|
|
let ptb_messages: Vec<Vec<u8>> = std::iter::from_fn(|| tun_rx2.try_recv().ok()).collect();
|
|
assert_eq!(
|
|
ptb_messages.len(),
|
|
1,
|
|
"Should generate ICMPv6 PTB for oversized packet after PathMtuState update"
|
|
);
|
|
|
|
let ptb = &ptb_messages[0];
|
|
assert_eq!(ptb[0] >> 4, 6, "Should be IPv6");
|
|
assert_eq!(ptb[6], 58, "Next header should be ICMPv6 (58)");
|
|
assert_eq!(ptb[40], 2, "ICMPv6 type should be Packet Too Big (2)");
|
|
assert_eq!(ptb[41], 0, "ICMPv6 code should be 0");
|
|
|
|
// Verify PTB source is the *remote peer* (original packet's destination),
|
|
// NOT the local node. Linux ignores PTBs whose source matches a local
|
|
// address, causing a PMTUD blackhole.
|
|
let ptb_src = std::net::Ipv6Addr::from(<[u8; 16]>::try_from(&ptb[8..24]).unwrap());
|
|
let ptb_dst = std::net::Ipv6Addr::from(<[u8; 16]>::try_from(&ptb[24..40]).unwrap());
|
|
assert_eq!(
|
|
ptb_src,
|
|
dst_fips.to_ipv6(),
|
|
"PTB source must be remote peer (original dst), not local node"
|
|
);
|
|
assert_eq!(
|
|
ptb_dst,
|
|
src_fips.to_ipv6(),
|
|
"PTB destination must be local node (original src)"
|
|
);
|
|
|
|
// Verify reported MTU is the path MTU (not local MTU)
|
|
let reported_mtu = u32::from_be_bytes([ptb[44], ptb[45], ptb[46], ptb[47]]);
|
|
let expected_ipv6_mtu = crate::upper::icmp::effective_ipv6_mtu(path_mtu) as u32;
|
|
assert_eq!(
|
|
reported_mtu, expected_ipv6_mtu,
|
|
"ICMPv6 PTB MTU should match path IPv6 MTU (transport MTU {} - overhead)",
|
|
path_mtu
|
|
);
|
|
|
|
// Verify a fitting packet still passes through without PTB
|
|
let (tun_tx3, tun_rx3) = std::sync::mpsc::channel();
|
|
nodes[0].node.supervisor.tun_tx = Some(tun_tx3);
|
|
|
|
let fitting_payload = vec![0xCDu8; 600 - 40]; // 600-byte IPv6 packet, well within 694
|
|
let fitting_packet = build_ipv6_packet(&src_fips, &dst_fips, &fitting_payload);
|
|
assert!(fitting_packet.len() <= expected_ipv6_mtu as usize);
|
|
|
|
nodes[0].node.handle_tun_outbound(fitting_packet).await;
|
|
|
|
let ptb_messages3: Vec<Vec<u8>> = std::iter::from_fn(|| tun_rx3.try_recv().ok()).collect();
|
|
assert_eq!(
|
|
ptb_messages3.len(),
|
|
0,
|
|
"Should not generate PTB for packet fitting within path MTU"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Reactive MtuExceeded → path_mtu_lookup focused unit tests
|
|
//
|
|
// These exercise the receive-side write path that mirrors the bottleneck
|
|
// MTU into `path_mtu_lookup` (consulted by the TUN reader/writer at
|
|
// SYN-clamp time). Discovery's reverse-path response and the FMP-promotion
|
|
// seed populate the same lookup; the reactive channel keeps it
|
|
// authoritative under forward-path-asymmetry conditions.
|
|
// ============================================================================
|
|
|
|
/// Build an MtuExceeded inner payload (35 bytes: flags + dest + reporter + mtu LE).
|
|
///
|
|
/// `handle_mtu_exceeded` receives the payload after the dispatcher strips
|
|
/// the FSP prefix and msg_type byte, so the test wire is just the body.
|
|
fn build_mtu_exceeded_inner(dest: &NodeAddr, reporter: &NodeAddr, mtu: u16) -> Vec<u8> {
|
|
let mut buf = Vec::with_capacity(35);
|
|
buf.push(0x00); // flags (reserved)
|
|
buf.extend_from_slice(dest.as_bytes());
|
|
buf.extend_from_slice(reporter.as_bytes());
|
|
buf.extend_from_slice(&mtu.to_le_bytes());
|
|
buf
|
|
}
|
|
|
|
/// Install the half-open entry an inbound SessionSetup creates: keyed on an
|
|
/// address the sender merely claimed, awaiting msg3, not initiated by us.
|
|
///
|
|
/// This is the shape an attacker manufactures with one forged handshake
|
|
/// opening, so a routing signal naming `claimed` must not be admitted by it.
|
|
fn install_halfopen(node: &mut Node, claimed: NodeAddr) {
|
|
use crate::noise::HandshakeState;
|
|
|
|
// This branch's FSP rekey is Noise XX, so the responder is built without
|
|
// a pinned remote static; the entry's lifecycle state is what the helper
|
|
// is establishing, not the handshake pattern.
|
|
let handshake = HandshakeState::new_responder(node.identity().keypair());
|
|
let placeholder = node.identity().keypair().public_key();
|
|
let entry = crate::node::session::SessionEntry::new(
|
|
claimed,
|
|
placeholder,
|
|
EndToEndState::AwaitingMsg3(handshake),
|
|
1000,
|
|
false,
|
|
);
|
|
node.sessions.insert(claimed, entry);
|
|
}
|
|
|
|
/// Record that this node put a frame of `wire_len` bytes on the wire toward
|
|
/// `dest`, which is what corroborates a reactive `MtuExceeded` reporting a
|
|
/// smaller bottleneck. Honest path-MTU discovery produces this by sending;
|
|
/// a handler test that installs a session without sending has to state it.
|
|
fn note_sent_wire_len(node: &mut Node, dest: &NodeAddr, wire_len: usize) {
|
|
node.sessions
|
|
.get_mut(dest)
|
|
.expect("session must exist to corroborate a report")
|
|
.record_sent_wire_len(wire_len);
|
|
}
|
|
|
|
/// Install the entry `initiate_session` creates: an address this node chose
|
|
/// itself, with the handshake still in flight and MMP not yet initialized.
|
|
fn install_initiating(node: &mut Node, remote: &Identity) {
|
|
use crate::noise::HandshakeState;
|
|
|
|
// Noise XX on this branch: the initiator learns the remote static during
|
|
// the handshake rather than pinning it up front.
|
|
let handshake = HandshakeState::new_initiator(node.identity().keypair());
|
|
let remote_addr = *remote.node_addr();
|
|
let entry = crate::node::session::SessionEntry::new(
|
|
remote_addr,
|
|
remote.pubkey_full(),
|
|
EndToEndState::Initiating(handshake),
|
|
1000,
|
|
true,
|
|
);
|
|
node.sessions.insert(remote_addr, entry);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_handle_mtu_exceeded_writes_path_mtu_lookup_when_empty() {
|
|
use crate::node::tests::spanning_tree::make_test_node;
|
|
|
|
let mut tn = make_test_node().await;
|
|
|
|
let remote = Identity::generate();
|
|
install_established_session_with_mmp(&mut tn.node, &remote);
|
|
let dest = *remote.node_addr();
|
|
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
|
let dest_fips = crate::FipsAddress::from_node_addr(&dest);
|
|
|
|
assert!(
|
|
tn.node.path_mtu_lookup_get(&dest_fips).is_none(),
|
|
"lookup should start empty for this destination"
|
|
);
|
|
|
|
note_sent_wire_len(&mut tn.node, &dest, 1400);
|
|
let inner = build_mtu_exceeded_inner(&dest, &reporter, 1280);
|
|
tn.node.handle_mtu_exceeded(&reporter, &inner).await;
|
|
|
|
assert_eq!(
|
|
tn.node.path_mtu_lookup_get(&dest_fips),
|
|
Some(1280),
|
|
"MtuExceeded should populate path_mtu_lookup with the bottleneck MTU"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_handle_mtu_exceeded_tightens_existing_path_mtu_lookup() {
|
|
use crate::node::tests::spanning_tree::make_test_node;
|
|
|
|
let mut tn = make_test_node().await;
|
|
|
|
let remote = Identity::generate();
|
|
install_established_session_with_mmp(&mut tn.node, &remote);
|
|
let dest = *remote.node_addr();
|
|
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
|
let dest_fips = crate::FipsAddress::from_node_addr(&dest);
|
|
|
|
// Pre-seed with a generous value (e.g., from a discovery reverse-path
|
|
// response that didn't reflect the forward-path bottleneck).
|
|
tn.node.path_mtu_lookup_insert(dest_fips, 1500);
|
|
|
|
note_sent_wire_len(&mut tn.node, &dest, 1400);
|
|
let inner = build_mtu_exceeded_inner(&dest, &reporter, 1280);
|
|
tn.node.handle_mtu_exceeded(&reporter, &inner).await;
|
|
|
|
assert_eq!(
|
|
tn.node.path_mtu_lookup_get(&dest_fips),
|
|
Some(1280),
|
|
"MtuExceeded with smaller bottleneck must tighten the lookup"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_handle_mtu_exceeded_keeps_tighter_existing_path_mtu_lookup() {
|
|
use crate::node::tests::spanning_tree::make_test_node;
|
|
|
|
let mut tn = make_test_node().await;
|
|
|
|
let remote = Identity::generate();
|
|
install_established_session_with_mmp(&mut tn.node, &remote);
|
|
let dest = *remote.node_addr();
|
|
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
|
let dest_fips = crate::FipsAddress::from_node_addr(&dest);
|
|
|
|
// Pre-seed with a tighter value than the incoming signal (e.g., from
|
|
// a prior reactive event on a narrower hop). The clamp must never
|
|
// loosen — keep the existing value.
|
|
tn.node.path_mtu_lookup_insert(dest_fips, 1280);
|
|
|
|
let inner = build_mtu_exceeded_inner(&dest, &reporter, 1500);
|
|
tn.node.handle_mtu_exceeded(&reporter, &inner).await;
|
|
|
|
assert_eq!(
|
|
tn.node.path_mtu_lookup_get(&dest_fips),
|
|
Some(1280),
|
|
"MtuExceeded with looser bottleneck must not loosen a tighter existing value"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_handle_mtu_exceeded_below_floor_leaves_path_mtu_lookup_untouched() {
|
|
use crate::node::tests::spanning_tree::make_test_node;
|
|
|
|
// MtuExceeded is an unencrypted signal that any admitted member can send
|
|
// for any destination this node has bound. A bottleneck this small cannot
|
|
// describe a real path; storing it would drive the SYN-time MSS clamp to a
|
|
// single-digit or zero segment size. The session is installed so the
|
|
// admission gate lets the signal through and the floor is what refuses it;
|
|
// without one this would pass whether or not the floor exists.
|
|
let mut tn = make_test_node().await;
|
|
|
|
let remote = Identity::generate();
|
|
install_initiating(&mut tn.node, &remote);
|
|
let dest = *remote.node_addr();
|
|
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
|
let dest_fips = crate::FipsAddress::from_node_addr(&dest);
|
|
|
|
let inner = build_mtu_exceeded_inner(&dest, &reporter, 100);
|
|
tn.node.handle_mtu_exceeded(&reporter, &inner).await;
|
|
|
|
assert_eq!(
|
|
tn.node.path_mtu_lookup_get(&dest_fips),
|
|
None,
|
|
"a sub-floor MtuExceeded must leave no path_mtu_lookup entry behind"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_sub_floor_mtu_exceeded_is_counted_separately_from_all_mtu_exceeded() {
|
|
use crate::node::tests::spanning_tree::make_test_node;
|
|
|
|
// `mtu_exceeded` counts every MtuExceeded regardless of value, so the
|
|
// sub-floor subset is not separable from it. The signal is unencrypted,
|
|
// unauthenticated and unmetered, so that subset climbing on its own is
|
|
// the forged-signal signature and needs its own counter.
|
|
let mut tn = make_test_node().await;
|
|
|
|
// Bound the destination so the admission gate admits the signal and the
|
|
// floor is what classifies it.
|
|
let remote = Identity::generate();
|
|
install_initiating(&mut tn.node, &remote);
|
|
let dest = *remote.node_addr();
|
|
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
|
|
|
assert_eq!(
|
|
tn.node.metrics().errors.mtu_exceeded_below_floor.get(),
|
|
0,
|
|
"counter starts at zero on a fresh node"
|
|
);
|
|
|
|
let inner = build_mtu_exceeded_inner(
|
|
&dest,
|
|
&reporter,
|
|
crate::upper::icmp::MIN_ACTIONABLE_PATH_MTU - 1,
|
|
);
|
|
tn.node.handle_mtu_exceeded(&reporter, &inner).await;
|
|
|
|
assert_eq!(
|
|
tn.node.metrics().errors.mtu_exceeded_below_floor.get(),
|
|
1,
|
|
"a sub-floor MtuExceeded must bump the below-floor counter"
|
|
);
|
|
|
|
// The counter must discriminate: an actionable bottleneck is stored and
|
|
// must bump only the all-signals counter.
|
|
let inner = build_mtu_exceeded_inner(&dest, &reporter, 1280);
|
|
tn.node.handle_mtu_exceeded(&reporter, &inner).await;
|
|
|
|
assert_eq!(
|
|
tn.node.metrics().errors.mtu_exceeded_below_floor.get(),
|
|
1,
|
|
"an actionable MtuExceeded must not bump the below-floor counter"
|
|
);
|
|
assert_eq!(
|
|
tn.node.metrics().errors.mtu_exceeded.get(),
|
|
2,
|
|
"the all-signals counter must count both, sub-floor and actionable"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_handle_mtu_exceeded_at_the_floor_still_writes_path_mtu_lookup() {
|
|
use crate::node::tests::spanning_tree::make_test_node;
|
|
|
|
// The guard must reject only what is below the floor. Without this the
|
|
// floor could be widened arbitrarily and the test above would not notice.
|
|
let mut tn = make_test_node().await;
|
|
|
|
let remote = Identity::generate();
|
|
install_initiating(&mut tn.node, &remote);
|
|
let dest = *remote.node_addr();
|
|
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
|
let dest_fips = crate::FipsAddress::from_node_addr(&dest);
|
|
let floor = crate::upper::icmp::MIN_REACTIVE_PATH_MTU;
|
|
|
|
note_sent_wire_len(&mut tn.node, &dest, 1400);
|
|
let inner = build_mtu_exceeded_inner(&dest, &reporter, floor);
|
|
tn.node.handle_mtu_exceeded(&reporter, &inner).await;
|
|
|
|
assert_eq!(
|
|
tn.node.path_mtu_lookup_get(&dest_fips),
|
|
Some(floor),
|
|
"a bottleneck exactly at the floor is actionable and must be stored"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_forged_mtu_exceeded_of_zero_does_not_blackhole_the_session() {
|
|
// The security property itself. MtuExceeded arrives unencrypted with no
|
|
// sender check, so anyone who can reach this node can inject one. Applied
|
|
// unfiltered, a reported MTU of zero drives the session's path MTU to
|
|
// zero, and from then on the TUN send gate answers every packet with an
|
|
// ICMPv6 Packet Too Big instead of sending it: a total blackhole for that
|
|
// destination that survives until the daemon restarts.
|
|
let edges = vec![(0, 1)];
|
|
let mut nodes = run_tree_test(2, &edges, false).await;
|
|
verify_tree_convergence(&nodes);
|
|
populate_all_coord_caches(&mut nodes);
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
let node1_pubkey = nodes[1].node.identity().pubkey_full();
|
|
|
|
let src_fips = crate::FipsAddress::from_node_addr(&node0_addr);
|
|
let dst_fips = crate::FipsAddress::from_node_addr(&node1_addr);
|
|
|
|
nodes[0]
|
|
.node
|
|
.initiate_session(node1_addr, node1_pubkey)
|
|
.await
|
|
.unwrap();
|
|
for _ in 0..3 {
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
}
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.state()
|
|
.is_established()
|
|
);
|
|
|
|
// Forge the signal: an MtuExceeded claiming the path to node 1 carries
|
|
// nothing at all, reported by a node that is not on the path.
|
|
let reporter = NodeAddr::from_bytes([0xEE; 16]);
|
|
let inner = build_mtu_exceeded_inner(&node1_addr, &reporter, 0);
|
|
nodes[0].node.handle_mtu_exceeded(&reporter, &inner).await;
|
|
|
|
let (tun_tx, tun_rx) = std::sync::mpsc::channel();
|
|
nodes[0].node.supervisor.tun_tx = Some(tun_tx);
|
|
|
|
let payload = vec![0u8; 560];
|
|
let ipv6_packet = build_ipv6_packet(&src_fips, &dst_fips, &payload);
|
|
assert_eq!(ipv6_packet.len(), 600);
|
|
assert!(
|
|
ipv6_packet.len() <= nodes[0].node.effective_ipv6_mtu() as usize,
|
|
"the packet must fit the local MTU, so any PTB comes from the forged signal"
|
|
);
|
|
|
|
nodes[0].node.handle_tun_outbound(ipv6_packet).await;
|
|
|
|
let tun_messages: Vec<Vec<u8>> = std::iter::from_fn(|| tun_rx.try_recv().ok()).collect();
|
|
assert!(
|
|
tun_messages.is_empty(),
|
|
"a forged MtuExceeded of zero must not turn ordinary packets into \
|
|
ICMPv6 Packet Too Big; got {} message(s)",
|
|
tun_messages.len()
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_path_broken_releases_path_mtu_lookup_entry() {
|
|
use crate::node::tests::spanning_tree::make_test_node;
|
|
use crate::proto::routing::PathBroken;
|
|
|
|
// A PathBroken report declares the path to a destination gone. The stored
|
|
// path MTU described that path, so it must not be carried onto whatever
|
|
// path replaces it — otherwise a value learned once (or injected once)
|
|
// outlives every route change until the daemon restarts.
|
|
let mut tn = make_test_node().await;
|
|
|
|
// The signal is only acted on for a destination this node has itself
|
|
// bound, so the release is reachable only behind an installed session.
|
|
// Without one the admission gate refuses the signal and this test would
|
|
// observe the entry surviving for the wrong reason.
|
|
let remote = Identity::generate();
|
|
install_initiating(&mut tn.node, &remote);
|
|
let dest = *remote.node_addr();
|
|
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
|
let dest_fips = crate::FipsAddress::from_node_addr(&dest);
|
|
|
|
tn.node.path_mtu_lookup_insert(dest_fips, 700);
|
|
assert_eq!(tn.node.path_mtu_lookup_get(&dest_fips), Some(700));
|
|
|
|
// Build the body the dispatcher would hand the handler: encode() prepends
|
|
// a 4-byte FSP prefix and a msg_type byte, both already consumed there.
|
|
let encoded = PathBroken::new(dest, reporter).encode();
|
|
let inner = &encoded[5..];
|
|
assert!(
|
|
PathBroken::decode(inner).is_ok(),
|
|
"the test body must decode, or the handler returns early and the \
|
|
assertion below observes nothing"
|
|
);
|
|
|
|
tn.node
|
|
.handle_path_broken(&reporter, &reporter, inner)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
tn.node.path_mtu_lookup_get(&dest_fips),
|
|
None,
|
|
"PathBroken must release the stored path MTU for the dead path"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_path_broken_resets_the_session_source_path_mtu() {
|
|
use crate::node::tests::spanning_tree::make_test_node;
|
|
use crate::proto::routing::PathBroken;
|
|
|
|
// The other half of the same release. The map the SYN clamp reads is not
|
|
// the only store describing the dead path: the session's own source-side
|
|
// estimate gates every outbound packet, and the increase ladder is the
|
|
// only thing that would ever raise it again — three matching higher
|
|
// notifications spanning two notification intervals, which arrive only
|
|
// while the peer is still receiving our datagrams.
|
|
let mut tn = make_test_node().await;
|
|
|
|
// An Established session, not an Initiating one: an Initiating entry
|
|
// carries no MMP state at all, which would make the assertion vacuous.
|
|
let remote = Identity::generate();
|
|
install_established_session_with_mmp(&mut tn.node, &remote);
|
|
let dest = *remote.node_addr();
|
|
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
|
|
|
tn.node
|
|
.get_session_mut(&dest)
|
|
.expect("the session was just installed")
|
|
.mmp_mut()
|
|
.expect("install_established_session_with_mmp initialises MMP state")
|
|
.path_mtu
|
|
.apply_notification(800, 1_000);
|
|
assert_eq!(
|
|
tn.node
|
|
.get_session(&dest)
|
|
.and_then(|e| e.mmp())
|
|
.map(|m| m.path_mtu.current_mtu()),
|
|
Some(800),
|
|
"precondition: the source-side estimate is tightened before the path dies"
|
|
);
|
|
|
|
// Same construction as the sibling test: encode() prepends a 4-byte FSP
|
|
// prefix and a msg_type byte, both already consumed by the dispatcher.
|
|
let encoded = PathBroken::new(dest, reporter).encode();
|
|
let inner = &encoded[5..];
|
|
assert!(
|
|
PathBroken::decode(inner).is_ok(),
|
|
"the test body must decode, or the handler returns early and the \
|
|
assertion below observes nothing"
|
|
);
|
|
|
|
tn.node
|
|
.handle_path_broken(&reporter, &reporter, inner)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
tn.node
|
|
.get_session(&dest)
|
|
.and_then(|e| e.mmp())
|
|
.map(|m| m.path_mtu.current_mtu()),
|
|
Some(u16::MAX),
|
|
"PathBroken must return the source-side estimate to the no-measurement \
|
|
state, so the next send re-seeds it from the outbound transport"
|
|
);
|
|
}
|
|
|
|
/// A node with one UDP transport at `mtu`, and `path_mtu_lookup` seeded from
|
|
/// that transport's link MTU for a remote address. The remote is deliberately
|
|
/// *not* registered in `node.peers`: a test that wants the expiry pass to
|
|
/// reseed it must add the `ActivePeer` itself, so that the two tests below
|
|
/// can tell "restored by the reseed" apart from "never a candidate".
|
|
async fn node_with_link_seed(
|
|
mtu: u16,
|
|
) -> (
|
|
Node,
|
|
crate::NodeAddr,
|
|
crate::FipsAddress,
|
|
TransportId,
|
|
TransportAddr,
|
|
) {
|
|
use crate::transport::udp::UdpTransport;
|
|
use crate::transport::{TransportHandle, packet_channel};
|
|
|
|
let mut node = make_node();
|
|
let (packet_tx, packet_rx) = packet_channel(64);
|
|
node.supervisor.packet_tx = Some(packet_tx);
|
|
node.packet_rx = Some(packet_rx);
|
|
|
|
let (transport_packet_tx, _transport_packet_rx) = packet_channel(64);
|
|
let transport_id = TransportId::new(1);
|
|
let mut udp = UdpTransport::new(
|
|
transport_id,
|
|
Some("udp1".to_string()),
|
|
crate::config::UdpConfig {
|
|
bind_addr: Some("127.0.0.1:0".to_string()),
|
|
mtu: Some(mtu),
|
|
..Default::default()
|
|
},
|
|
transport_packet_tx,
|
|
);
|
|
udp.start_async().await.unwrap();
|
|
node.transports
|
|
.insert(transport_id, TransportHandle::Udp(udp));
|
|
|
|
let remote = Identity::generate();
|
|
let remote_addr = *remote.node_addr();
|
|
let remote_fips = crate::FipsAddress::from_node_addr(&remote_addr);
|
|
let transport_addr = TransportAddr::from_string("127.0.0.1:2121");
|
|
|
|
node.seed_path_mtu_for_link_peer(&remote_addr, transport_id, &transport_addr);
|
|
|
|
(node, remote_addr, remote_fips, transport_id, transport_addr)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_expired_path_mtu_keeps_the_link_peer_seed() {
|
|
use crate::peer::ActivePeer;
|
|
|
|
// The same regression the release helper's reseed half exists to
|
|
// prevent, reproduced on the expiry path. A tighter discovery value
|
|
// overwrites a direct peer's link MTU under keep-tighter, so expiring it
|
|
// with a bare removal would silently drop that peer to the conservative
|
|
// ceiling until its link re-handshakes.
|
|
let (mut node, remote_addr, remote_fips, transport_id, transport_addr) =
|
|
node_with_link_seed(1452).await;
|
|
|
|
let remote = Identity::generate();
|
|
let peer_identity = PeerIdentity::from_pubkey_full(remote.pubkey_full());
|
|
let mut peer = ActivePeer::new(peer_identity, LinkId::new(7), 0);
|
|
peer.set_current_addr(transport_id, transport_addr);
|
|
node.peers.insert(remote_addr, peer);
|
|
|
|
assert_eq!(
|
|
node.path_mtu_lookup_get(&remote_fips),
|
|
Some(1452),
|
|
"precondition: the direct-link seed is in place"
|
|
);
|
|
|
|
let t0 = 5_000_000u64;
|
|
node.path_mtu_lookup_learn(remote_fips, 800, t0);
|
|
assert_eq!(
|
|
node.path_mtu_lookup_get(&remote_fips),
|
|
Some(800),
|
|
"precondition: a tighter remote-learned value is sitting on the seed"
|
|
);
|
|
|
|
let ttl_ms = node.config().node.cache.coord_ttl_secs * 1000;
|
|
node.purge_expired_path_mtu(t0 + ttl_ms + 1);
|
|
|
|
assert_eq!(
|
|
node.path_mtu_lookup_get(&remote_fips),
|
|
Some(1452),
|
|
"expiring a remote value must restore the local link seed, not leave the \
|
|
destination with no entry at all"
|
|
);
|
|
|
|
for transport in node.transports.values_mut() {
|
|
transport.stop().await.ok();
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_local_path_mtu_seed_never_expires() {
|
|
// Discriminating half of the test above, which on its own cannot tell
|
|
// "the seed was restored by the reseed sweep" from "the seed was never a
|
|
// candidate for expiry". Here the remote is not in `node.peers`, so there
|
|
// is no reseed to mask the difference: a seed that carried a deadline
|
|
// would be removed and stay removed.
|
|
let (mut node, _remote_addr, remote_fips, _tid, _taddr) = node_with_link_seed(1452).await;
|
|
assert_eq!(
|
|
node.path_mtu_lookup_get(&remote_fips),
|
|
Some(1452),
|
|
"precondition: the direct-link seed is in place"
|
|
);
|
|
assert_eq!(
|
|
node.path_mtu_lookup_entry(&remote_fips)
|
|
.and_then(|e| e.learned_ms),
|
|
None,
|
|
"precondition: a locally derived seed carries no deadline"
|
|
);
|
|
|
|
let ttl_ms = node.config().node.cache.coord_ttl_secs * 1000;
|
|
node.purge_expired_path_mtu(10 * ttl_ms);
|
|
|
|
assert_eq!(
|
|
node.path_mtu_lookup_get(&remote_fips),
|
|
Some(1452),
|
|
"a locally derived link MTU describes a link this node can still see, \
|
|
so no amount of elapsed time may expire it"
|
|
);
|
|
|
|
for transport in node.transports.values_mut() {
|
|
transport.stop().await.ok();
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_mirrored_notification_path_mtu_survives_a_purge() {
|
|
// The proactive mirror exists because a peer repeating an identical value
|
|
// on a stable path never rewrites the entry: the handler returns early
|
|
// when the session-side MTU is unchanged. An entry from that carrier must
|
|
// therefore carry no deadline, or expiring it would permanently reopen
|
|
// the gap the mirror closed, for every long-lived multi-hop destination.
|
|
let mut node = make_node();
|
|
let remote = Identity::generate();
|
|
let remote_addr = *remote.node_addr();
|
|
let remote_fips = crate::FipsAddress::from_node_addr(&remote_addr);
|
|
|
|
install_established_session_with_mmp(&mut node, &remote);
|
|
|
|
let body = build_path_mtu_notification_body(1280);
|
|
node.handle_session_path_mtu_notification(&remote_addr, &body);
|
|
assert_eq!(
|
|
node.path_mtu_lookup_get(&remote_fips),
|
|
Some(1280),
|
|
"precondition: the mirror wrote the notified value"
|
|
);
|
|
|
|
let ttl_ms = node.config().node.cache.coord_ttl_secs * 1000;
|
|
node.purge_expired_path_mtu(10 * ttl_ms);
|
|
|
|
assert_eq!(
|
|
node.path_mtu_lookup_get(&remote_fips),
|
|
Some(1280),
|
|
"a value learned inside a session is released by the session, not by a \
|
|
timer, and must survive any number of expiry passes"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Routing-signal admission: the named destination must be an address this
|
|
// node bound itself, either by initiating toward it or by completing the
|
|
// handshake that binds an address to a peer's static key. These signals carry
|
|
// no end-to-end authentication, so without that gate any mesh member can name
|
|
// any address and have the effects applied.
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_mtu_exceeded_naming_a_dest_with_no_session_does_not_touch_path_mtu_lookup() {
|
|
let mut node = make_node();
|
|
|
|
let dest = NodeAddr::from_bytes([0xCC; 16]);
|
|
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
|
let dest_fips = crate::FipsAddress::from_node_addr(&dest);
|
|
|
|
assert!(
|
|
node.path_mtu_lookup_get(&dest_fips).is_none(),
|
|
"lookup should start empty for this destination"
|
|
);
|
|
|
|
let inner = build_mtu_exceeded_inner(&dest, &reporter, 1280);
|
|
node.handle_mtu_exceeded(&reporter, &inner).await;
|
|
|
|
assert_eq!(
|
|
node.path_mtu_lookup_get(&dest_fips),
|
|
None,
|
|
"a signal naming an address with no session must not write the clamp"
|
|
);
|
|
assert_eq!(node.stats().session.unknown_session, 1);
|
|
|
|
let errors = &node.metrics().errors;
|
|
assert_eq!(
|
|
errors.unbound.mtu.get(),
|
|
1,
|
|
"the refusal must be counted against the MtuExceeded counter"
|
|
);
|
|
assert_eq!(
|
|
errors.unbound.coords.get(),
|
|
0,
|
|
"an MtuExceeded refusal must not bump the CoordsRequired counter"
|
|
);
|
|
assert_eq!(
|
|
errors.unbound.broken.get(),
|
|
0,
|
|
"an MtuExceeded refusal must not bump the PathBroken counter"
|
|
);
|
|
assert_eq!(
|
|
errors.unbound.forged.get(),
|
|
0,
|
|
"an absent session is an unbound refusal, not a forged pairing"
|
|
);
|
|
assert_eq!(
|
|
errors.mtu_exceeded.get(),
|
|
1,
|
|
"the arrival counter is the denominator and counts refused arrivals too"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_mtu_exceeded_naming_a_dest_whose_entry_is_an_unauthenticated_responder_handshake_is_dropped()
|
|
{
|
|
let mut node = make_node();
|
|
|
|
let dest = NodeAddr::from_bytes([0xCC; 16]);
|
|
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
|
let dest_fips = crate::FipsAddress::from_node_addr(&dest);
|
|
|
|
// One forged SessionSetup naming `dest` would leave exactly this entry.
|
|
install_halfopen(&mut node, dest);
|
|
|
|
let inner = build_mtu_exceeded_inner(&dest, &reporter, 1280);
|
|
node.handle_mtu_exceeded(&reporter, &inner).await;
|
|
|
|
assert_eq!(
|
|
node.path_mtu_lookup_get(&dest_fips),
|
|
None,
|
|
"a half-open entry keyed on a claimed address must not admit the signal"
|
|
);
|
|
assert_eq!(node.stats().session.unknown_session, 1);
|
|
|
|
let errors = &node.metrics().errors;
|
|
assert_eq!(
|
|
errors.unbound.mtu.get(),
|
|
1,
|
|
"a half-open entry is an unbound refusal for MtuExceeded"
|
|
);
|
|
assert_eq!(
|
|
errors.unbound.forged.get(),
|
|
0,
|
|
"a half-open entry is a plausible pairing, not a forged one"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_mtu_exceeded_for_a_session_we_initiated_seeds_path_mtu_lookup_before_establishment() {
|
|
let mut node = make_node();
|
|
|
|
let remote = Identity::generate();
|
|
install_initiating(&mut node, &remote);
|
|
let dest = *remote.node_addr();
|
|
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
|
let dest_fips = crate::FipsAddress::from_node_addr(&dest);
|
|
|
|
note_sent_wire_len(&mut node, &dest, 1400);
|
|
let inner = build_mtu_exceeded_inner(&dest, &reporter, 1280);
|
|
node.handle_mtu_exceeded(&reporter, &inner).await;
|
|
|
|
assert_eq!(
|
|
node.path_mtu_lookup_get(&dest_fips),
|
|
Some(1280),
|
|
"an address we chose ourselves must still seed the clamp during handshake"
|
|
);
|
|
assert_eq!(
|
|
node.metrics().errors.unbound.mtu.get(),
|
|
0,
|
|
"an admitted signal must not be counted as refused"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_mtu_exceeded_from_a_third_party_forwarder_still_tightens_an_active_session() {
|
|
let mut node = make_node();
|
|
|
|
let remote = Identity::generate();
|
|
install_established_session_with_mmp(&mut node, &remote);
|
|
let dest = *remote.node_addr();
|
|
// A real transit reporter is neither us nor the destination.
|
|
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
|
let dest_fips = crate::FipsAddress::from_node_addr(&dest);
|
|
|
|
note_sent_wire_len(&mut node, &dest, 1400);
|
|
let inner = build_mtu_exceeded_inner(&dest, &reporter, 1280);
|
|
node.handle_mtu_exceeded(&reporter, &inner).await;
|
|
|
|
assert_eq!(
|
|
node.path_mtu_lookup_get(&dest_fips),
|
|
Some(1280),
|
|
"an on-path forwarder's report must still tighten the clamp"
|
|
);
|
|
assert_eq!(
|
|
node.sessions
|
|
.get(&dest)
|
|
.and_then(|e| e.mmp())
|
|
.map(|m| m.path_mtu.current_mtu()),
|
|
Some(1280),
|
|
"the session-side path MTU must also decrease"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_path_broken_naming_a_dest_with_no_session_does_not_flush_cached_coords() {
|
|
use crate::proto::routing::PathBroken;
|
|
|
|
let mut node = make_node();
|
|
|
|
let dest = NodeAddr::from_bytes([0xCC; 16]);
|
|
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
|
let coords = node.tree_state().my_coords().clone();
|
|
let _ = node.coord_cache_mut().insert(dest, coords, 1000);
|
|
|
|
let encoded = PathBroken::new(dest, reporter).encode();
|
|
node.handle_path_broken(&reporter, &reporter, &encoded[5..])
|
|
.await;
|
|
|
|
assert!(
|
|
node.coord_cache().get(&dest, 1000).is_some(),
|
|
"a signal naming an address with no session must not flush its coords"
|
|
);
|
|
assert_eq!(node.stats().session.unknown_session, 1);
|
|
|
|
let errors = &node.metrics().errors;
|
|
assert_eq!(
|
|
errors.unbound.broken.get(),
|
|
1,
|
|
"the refusal must be counted against the PathBroken counter"
|
|
);
|
|
assert_eq!(
|
|
errors.unbound.mtu.get(),
|
|
0,
|
|
"a PathBroken refusal must not bump the MtuExceeded counter"
|
|
);
|
|
assert_eq!(
|
|
errors.unbound.coords.get(),
|
|
0,
|
|
"a PathBroken refusal must not bump the CoordsRequired counter"
|
|
);
|
|
assert_eq!(
|
|
errors.unbound.forged.get(),
|
|
0,
|
|
"an absent session is an unbound refusal, not a forged pairing"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_path_broken_naming_a_dest_whose_entry_is_an_unauthenticated_responder_handshake_does_not_flush_cached_coords()
|
|
{
|
|
use crate::proto::routing::PathBroken;
|
|
|
|
let mut node = make_node();
|
|
|
|
let dest = NodeAddr::from_bytes([0xCC; 16]);
|
|
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
|
let coords = node.tree_state().my_coords().clone();
|
|
let _ = node.coord_cache_mut().insert(dest, coords, 1000);
|
|
|
|
// One forged SessionSetup naming `dest` would leave exactly this entry.
|
|
install_halfopen(&mut node, dest);
|
|
|
|
let encoded = PathBroken::new(dest, reporter).encode();
|
|
node.handle_path_broken(&reporter, &reporter, &encoded[5..])
|
|
.await;
|
|
|
|
assert!(
|
|
node.coord_cache().get(&dest, 1000).is_some(),
|
|
"a half-open entry keyed on a claimed address must not admit the signal"
|
|
);
|
|
assert_eq!(node.stats().session.unknown_session, 1);
|
|
|
|
let errors = &node.metrics().errors;
|
|
assert_eq!(
|
|
errors.unbound.broken.get(),
|
|
1,
|
|
"a half-open entry is an unbound refusal for PathBroken"
|
|
);
|
|
assert_eq!(
|
|
errors.unbound.forged.get(),
|
|
0,
|
|
"a half-open entry is a plausible pairing, not a forged one"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_path_broken_for_a_session_we_initiated_still_flushes_cached_coords() {
|
|
use crate::proto::routing::PathBroken;
|
|
|
|
let mut node = make_node();
|
|
|
|
let remote = Identity::generate();
|
|
install_initiating(&mut node, &remote);
|
|
let dest = *remote.node_addr();
|
|
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
|
let coords = node.tree_state().my_coords().clone();
|
|
let _ = node.coord_cache_mut().insert(dest, coords, 1000);
|
|
|
|
let encoded = PathBroken::new(dest, reporter).encode();
|
|
node.handle_path_broken(&reporter, &reporter, &encoded[5..])
|
|
.await;
|
|
|
|
assert!(
|
|
node.coord_cache().get(&dest, 1000).is_none(),
|
|
"handshake-time recovery must still flush coords for an address we chose"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_coords_required_naming_a_dest_with_no_session_is_counted_as_an_unknown_session_reject()
|
|
{
|
|
use crate::proto::routing::CoordsRequired;
|
|
|
|
let mut node = make_node();
|
|
|
|
let dest = NodeAddr::from_bytes([0xCC; 16]);
|
|
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
|
|
|
let encoded = CoordsRequired::new(dest, reporter).encode();
|
|
node.handle_coords_required(&reporter, &encoded[5..]).await;
|
|
assert_eq!(node.stats().session.unknown_session, 1);
|
|
|
|
// A second identical signal is refused the same way. This does not pin
|
|
// the gate's position relative to the response rate limiter: should_send
|
|
// returning false would not short-circuit the handler, so this counter
|
|
// reaches 2 either way. The ordering is pinned by
|
|
// test_coords_required_for_an_unbound_dest_never_reaches_the_response_rate_limiter.
|
|
node.handle_coords_required(&reporter, &encoded[5..]).await;
|
|
assert_eq!(node.stats().session.unknown_session, 2);
|
|
|
|
let errors = &node.metrics().errors;
|
|
assert_eq!(
|
|
errors.unbound.coords.get(),
|
|
2,
|
|
"both refusals must be counted against the CoordsRequired counter"
|
|
);
|
|
assert_eq!(
|
|
errors.unbound.broken.get(),
|
|
0,
|
|
"a CoordsRequired refusal must not bump the PathBroken counter"
|
|
);
|
|
assert_eq!(
|
|
errors.unbound.mtu.get(),
|
|
0,
|
|
"a CoordsRequired refusal must not bump the MtuExceeded counter"
|
|
);
|
|
assert_eq!(
|
|
errors.unbound.forged.get(),
|
|
0,
|
|
"an absent session is an unbound refusal, not a forged pairing"
|
|
);
|
|
assert_eq!(
|
|
errors.coords_required.get(),
|
|
2,
|
|
"the arrival counter is the denominator and counts refused arrivals too"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_coords_required_for_an_unbound_dest_never_reaches_the_response_rate_limiter() {
|
|
use crate::proto::routing::CoordsRequired;
|
|
|
|
let mut node = make_node();
|
|
|
|
let dest = NodeAddr::from_bytes([0xCC; 16]);
|
|
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
|
|
|
assert_eq!(
|
|
node.coords_response_rate_limiter.len(),
|
|
0,
|
|
"precondition: the response rate limiter holds nothing before the signal"
|
|
);
|
|
|
|
let encoded = CoordsRequired::new(dest, reporter).encode();
|
|
node.handle_coords_required(&reporter, &encoded[5..]).await;
|
|
|
|
assert_eq!(node.stats().session.unknown_session, 1);
|
|
assert_eq!(
|
|
node.coords_response_rate_limiter.len(),
|
|
0,
|
|
"an inadmissible signal must be refused before should_send can insert \
|
|
the attacker-chosen address into last_sent"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_coords_required_for_a_bound_dest_does_reach_the_response_rate_limiter() {
|
|
use crate::proto::routing::CoordsRequired;
|
|
|
|
let mut node = make_node();
|
|
|
|
let remote = Identity::generate();
|
|
install_initiating(&mut node, &remote);
|
|
let dest = *remote.node_addr();
|
|
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
|
|
|
let encoded = CoordsRequired::new(dest, reporter).encode();
|
|
node.handle_coords_required(&reporter, &encoded[5..]).await;
|
|
|
|
assert_eq!(node.stats().session.unknown_session, 0);
|
|
assert_eq!(
|
|
node.coords_response_rate_limiter.len(),
|
|
1,
|
|
"an admitted signal must still consult the response rate limiter"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_mtu_exceeded_whose_claimed_source_is_the_destination_it_names_is_dropped() {
|
|
let mut node = make_node();
|
|
|
|
let remote = Identity::generate();
|
|
install_established_session_with_mmp(&mut node, &remote);
|
|
let dest = *remote.node_addr();
|
|
let dest_fips = crate::FipsAddress::from_node_addr(&dest);
|
|
|
|
// The emitter of a routing signal is by construction a transit node for
|
|
// the datagram it is reporting on, so it is never that datagram's own
|
|
// destination. A signal claiming otherwise is malformed.
|
|
let inner = build_mtu_exceeded_inner(&dest, &dest, 1280);
|
|
node.handle_mtu_exceeded(&dest, &inner).await;
|
|
|
|
assert_eq!(
|
|
node.path_mtu_lookup_get(&dest_fips),
|
|
None,
|
|
"a signal whose claimed source is the destination it names must be dropped"
|
|
);
|
|
assert_eq!(node.stats().session.unknown_session, 1);
|
|
|
|
let errors = &node.metrics().errors;
|
|
assert_eq!(
|
|
errors.unbound.mtu.get(),
|
|
1,
|
|
"the refusal must still be counted against the MtuExceeded counter"
|
|
);
|
|
assert_eq!(
|
|
errors.unbound.forged.get(),
|
|
1,
|
|
"a src equal to the dest it names is a structurally impossible pairing"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_coords_required_naming_this_node_as_the_destination_counts_a_forged_pairing() {
|
|
use crate::proto::routing::CoordsRequired;
|
|
|
|
let mut node = make_node();
|
|
|
|
// A datagram addressed to this node is delivered locally before any
|
|
// forwarding, so no honest transit router ever emits a signal naming
|
|
// us as the destination. This clause can only be reached by fabrication.
|
|
let dest = *node.node_addr();
|
|
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
|
|
|
let encoded = CoordsRequired::new(dest, reporter).encode();
|
|
node.handle_coords_required(&reporter, &encoded[5..]).await;
|
|
|
|
assert_eq!(node.stats().session.unknown_session, 1);
|
|
let errors = &node.metrics().errors;
|
|
assert_eq!(
|
|
errors.unbound.coords.get(),
|
|
1,
|
|
"the refusal must be counted against the CoordsRequired counter"
|
|
);
|
|
assert_eq!(
|
|
errors.unbound.forged.get(),
|
|
1,
|
|
"a signal naming this node as the destination is a forged pairing"
|
|
);
|
|
assert_eq!(
|
|
errors.unbound.broken.get(),
|
|
0,
|
|
"a CoordsRequired refusal must not bump the PathBroken counter"
|
|
);
|
|
assert_eq!(
|
|
errors.unbound.mtu.get(),
|
|
0,
|
|
"a CoordsRequired refusal must not bump the MtuExceeded counter"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Proactive PathMtuNotification → path_mtu_lookup focused unit tests
|
|
//
|
|
// These exercise the receive-side write path that mirrors the proactive
|
|
// end-to-end echo into `path_mtu_lookup`. Without this mirror, new TCP
|
|
// flows opened on a path the proactive notification has tightened keep
|
|
// getting clamped by the staler discovery-time value until a reactive
|
|
// MtuExceeded fires for those flows — long-lived stable paths can sit
|
|
// in the gap indefinitely.
|
|
// ============================================================================
|
|
|
|
/// Build a PathMtuNotification body (2 bytes: path_mtu LE).
|
|
fn build_path_mtu_notification_body(mtu: u16) -> Vec<u8> {
|
|
mtu.to_le_bytes().to_vec()
|
|
}
|
|
|
|
/// Insert an Established session with MMP initialized so the proactive
|
|
/// PathMtuNotification handler can apply notifications.
|
|
fn install_established_session_with_mmp(node: &mut Node, remote: &Identity) {
|
|
let session = make_noise_session(node.identity(), remote);
|
|
let remote_addr = *remote.node_addr();
|
|
let mut entry = crate::node::session::SessionEntry::new(
|
|
remote_addr,
|
|
remote.pubkey_full(),
|
|
EndToEndState::Established(session),
|
|
1000,
|
|
true,
|
|
);
|
|
entry.init_mmp(&node.config().node.session_mmp);
|
|
node.sessions.insert(remote_addr, entry);
|
|
}
|
|
|
|
#[test]
|
|
fn test_handle_path_mtu_notification_writes_path_mtu_lookup_when_empty() {
|
|
let mut node = make_node();
|
|
let remote = Identity::generate();
|
|
let remote_addr = *remote.node_addr();
|
|
let remote_fips = crate::FipsAddress::from_node_addr(&remote_addr);
|
|
|
|
install_established_session_with_mmp(&mut node, &remote);
|
|
|
|
assert!(
|
|
node.path_mtu_lookup_get(&remote_fips).is_none(),
|
|
"lookup should start empty for this destination"
|
|
);
|
|
|
|
let body = build_path_mtu_notification_body(1280);
|
|
node.handle_session_path_mtu_notification(&remote_addr, &body);
|
|
|
|
assert_eq!(
|
|
node.path_mtu_lookup_get(&remote_fips),
|
|
Some(1280),
|
|
"PathMtuNotification should populate path_mtu_lookup with the reported MTU"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_handle_path_mtu_notification_tightens_existing_path_mtu_lookup() {
|
|
let mut node = make_node();
|
|
let remote = Identity::generate();
|
|
let remote_addr = *remote.node_addr();
|
|
let remote_fips = crate::FipsAddress::from_node_addr(&remote_addr);
|
|
|
|
install_established_session_with_mmp(&mut node, &remote);
|
|
|
|
// Pre-seed with a generous value (e.g., from the discovery seed at link
|
|
// promotion time, before the destination's proactive echo arrived).
|
|
node.path_mtu_lookup_insert(remote_fips, 1500);
|
|
|
|
let body = build_path_mtu_notification_body(1280);
|
|
node.handle_session_path_mtu_notification(&remote_addr, &body);
|
|
|
|
assert_eq!(
|
|
node.path_mtu_lookup_get(&remote_fips),
|
|
Some(1280),
|
|
"PathMtuNotification with smaller MTU must tighten the lookup"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_handle_path_mtu_notification_keeps_tighter_existing_path_mtu_lookup() {
|
|
let mut node = make_node();
|
|
let remote = Identity::generate();
|
|
let remote_addr = *remote.node_addr();
|
|
let remote_fips = crate::FipsAddress::from_node_addr(&remote_addr);
|
|
|
|
install_established_session_with_mmp(&mut node, &remote);
|
|
|
|
// Pre-seed with a tighter value than what the proactive notification
|
|
// reports (e.g., from a prior reactive MtuExceeded on a narrower hop).
|
|
// The mirror must never loosen the clamp.
|
|
node.path_mtu_lookup_insert(remote_fips, 1200);
|
|
|
|
let body = build_path_mtu_notification_body(1400);
|
|
node.handle_session_path_mtu_notification(&remote_addr, &body);
|
|
|
|
assert_eq!(
|
|
node.path_mtu_lookup_get(&remote_fips),
|
|
Some(1200),
|
|
"PathMtuNotification with looser MTU must not loosen a tighter existing value"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_handle_path_mtu_notification_no_session_no_op() {
|
|
let mut node = make_node();
|
|
let remote = Identity::generate();
|
|
let remote_addr = *remote.node_addr();
|
|
let remote_fips = crate::FipsAddress::from_node_addr(&remote_addr);
|
|
|
|
// No session installed. The handler should drop the notification entirely.
|
|
let body = build_path_mtu_notification_body(1280);
|
|
node.handle_session_path_mtu_notification(&remote_addr, &body);
|
|
|
|
assert!(
|
|
node.path_mtu_lookup_get(&remote_fips).is_none(),
|
|
"PathMtuNotification with no session must not touch path_mtu_lookup"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sub_floor_path_mtu_notification_is_ignored_and_counted() {
|
|
// The state machine returns the same `false` for a sub-floor refusal as
|
|
// for an ordinary no-change, so without a counter at the caller the
|
|
// refusal is indistinguishable from the common case. This arrives on the
|
|
// decrypted path, so a rising count means an authenticated peer is
|
|
// sending unusable values.
|
|
let mut node = make_node();
|
|
let remote = Identity::generate();
|
|
let remote_addr = *remote.node_addr();
|
|
let remote_fips = crate::FipsAddress::from_node_addr(&remote_addr);
|
|
|
|
install_established_session_with_mmp(&mut node, &remote);
|
|
|
|
assert_eq!(
|
|
node.metrics().errors.path_mtu_notif_below_floor.get(),
|
|
0,
|
|
"counter starts at zero on a fresh node"
|
|
);
|
|
|
|
let body = build_path_mtu_notification_body(crate::upper::icmp::MIN_ACTIONABLE_PATH_MTU - 1);
|
|
node.handle_session_path_mtu_notification(&remote_addr, &body);
|
|
|
|
assert_eq!(
|
|
node.metrics().errors.path_mtu_notif_below_floor.get(),
|
|
1,
|
|
"a sub-floor PathMtuNotification must bump the below-floor counter"
|
|
);
|
|
assert_eq!(
|
|
node.path_mtu_lookup_get(&remote_fips),
|
|
None,
|
|
"a sub-floor PathMtuNotification must leave no path_mtu_lookup entry"
|
|
);
|
|
|
|
// The counter must discriminate: an actionable value is applied and must
|
|
// not bump it.
|
|
let body = build_path_mtu_notification_body(1280);
|
|
node.handle_session_path_mtu_notification(&remote_addr, &body);
|
|
|
|
assert_eq!(
|
|
node.metrics().errors.path_mtu_notif_below_floor.get(),
|
|
1,
|
|
"an actionable PathMtuNotification must not bump the below-floor counter"
|
|
);
|
|
assert_eq!(
|
|
node.path_mtu_lookup_get(&remote_fips),
|
|
Some(1280),
|
|
"the actionable value must still be applied after a refused one"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_idle_session_purge_keeps_link_peer_path_mtu_seed() {
|
|
use crate::peer::ActivePeer;
|
|
use crate::transport::udp::UdpTransport;
|
|
use crate::transport::{TransportHandle, packet_channel};
|
|
|
|
// Releasing on idle expiry must not throw away what local configuration
|
|
// knows. Idle expiry removes an end-to-end session; the FMP link to a
|
|
// directly connected peer stays up, and its link MTU is seeded only on
|
|
// link promotion. A blanket removal here would drop that peer to the
|
|
// conservative ceiling for every later flow until the link re-handshakes.
|
|
let mut node = make_node();
|
|
let (packet_tx, packet_rx) = packet_channel(64);
|
|
node.supervisor.packet_tx = Some(packet_tx);
|
|
node.packet_rx = Some(packet_rx);
|
|
|
|
let (transport_packet_tx, _transport_packet_rx) = packet_channel(64);
|
|
let transport_id = TransportId::new(1);
|
|
let mut udp = UdpTransport::new(
|
|
transport_id,
|
|
Some("udp1".to_string()),
|
|
crate::config::UdpConfig {
|
|
bind_addr: Some("127.0.0.1:0".to_string()),
|
|
mtu: Some(1452),
|
|
..Default::default()
|
|
},
|
|
transport_packet_tx,
|
|
);
|
|
udp.start_async().await.unwrap();
|
|
node.transports
|
|
.insert(transport_id, TransportHandle::Udp(udp));
|
|
|
|
// A directly connected peer, seeded from its link MTU the way FMP
|
|
// promotion seeds it, with an end-to-end session on top.
|
|
let remote = Identity::generate();
|
|
let remote_addr = *remote.node_addr();
|
|
let remote_fips = crate::FipsAddress::from_node_addr(&remote_addr);
|
|
let transport_addr = TransportAddr::from_string("127.0.0.1:2121");
|
|
|
|
let peer_identity = PeerIdentity::from_pubkey_full(remote.pubkey_full());
|
|
let mut peer = ActivePeer::new(peer_identity, LinkId::new(7), 0);
|
|
peer.set_current_addr(transport_id, transport_addr.clone());
|
|
node.peers.insert(remote_addr, peer);
|
|
|
|
node.seed_path_mtu_for_link_peer(&remote_addr, transport_id, &transport_addr);
|
|
assert_eq!(
|
|
node.path_mtu_lookup_get(&remote_fips),
|
|
Some(1452),
|
|
"precondition: the direct-link seed is in place"
|
|
);
|
|
|
|
let session = make_noise_session(node.identity(), &remote);
|
|
let entry = crate::node::session::SessionEntry::new(
|
|
remote_addr,
|
|
remote.pubkey_full(),
|
|
EndToEndState::Established(session),
|
|
1000,
|
|
true,
|
|
);
|
|
node.sessions.insert(remote_addr, entry);
|
|
|
|
node.purge_idle_sessions(1000 + 92_000);
|
|
assert_eq!(node.session_count(), 0, "precondition: the session expired");
|
|
|
|
assert_eq!(
|
|
node.path_mtu_lookup_get(&remote_fips),
|
|
Some(1452),
|
|
"idle expiry must leave the locally derived link MTU in place"
|
|
);
|
|
|
|
for transport in node.transports.values_mut() {
|
|
transport.stop().await.ok();
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Session identity binding: XX msg3 source address / static key
|
|
// ============================================================================
|
|
|
|
/// Helper: drive a full XX exchange against `responder_identity` and return
|
|
/// the responder's half-completed handshake plus the initiator's msg3.
|
|
///
|
|
/// The msg3 is cryptographically valid for the responder and carries
|
|
/// `initiator_identity`'s static key, which is exactly the shape of the
|
|
/// defect: a peer that completes a real handshake while the datagram claims
|
|
/// somebody else's source address.
|
|
fn drive_xx_to_msg3(
|
|
initiator_identity: &Identity,
|
|
responder_identity: &Identity,
|
|
) -> (crate::noise::HandshakeState, Vec<u8>) {
|
|
use crate::noise::HandshakeState;
|
|
|
|
let mut initiator = HandshakeState::new_initiator(initiator_identity.keypair());
|
|
let mut responder = HandshakeState::new_responder(responder_identity.keypair());
|
|
|
|
let mut init_epoch = [0u8; 8];
|
|
rand::Rng::fill_bytes(&mut rand::rng(), &mut init_epoch);
|
|
initiator.set_local_epoch(init_epoch);
|
|
let mut resp_epoch = [0u8; 8];
|
|
rand::Rng::fill_bytes(&mut rand::rng(), &mut resp_epoch);
|
|
responder.set_local_epoch(resp_epoch);
|
|
|
|
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();
|
|
let msg3 = initiator.write_message_3().unwrap();
|
|
|
|
(responder, msg3)
|
|
}
|
|
|
|
/// Helper: generate an identity whose full public key has odd parity.
|
|
fn generate_odd_parity_identity() -> Identity {
|
|
loop {
|
|
let id = Identity::generate();
|
|
if id.pubkey_full().serialize()[0] == 0x03 {
|
|
return id;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_session_msg3_rejects_spoofed_source_address() {
|
|
let mut node = make_node();
|
|
let attacker = Identity::generate();
|
|
let victim = Identity::generate();
|
|
let victim_addr = *victim.node_addr();
|
|
|
|
let (responder, msg3) = drive_xx_to_msg3(&attacker, node.identity());
|
|
|
|
// Half-open session recorded under the victim's address, as
|
|
// handle_session_setup would have done from the claimed source.
|
|
let entry = crate::node::session::SessionEntry::new(
|
|
victim_addr,
|
|
node.identity().pubkey_full(),
|
|
EndToEndState::AwaitingMsg3(responder),
|
|
1000,
|
|
false,
|
|
);
|
|
node.sessions.insert(victim_addr, entry);
|
|
|
|
node.handle_session_payload(
|
|
&victim_addr,
|
|
&stub_link_peer(),
|
|
&SessionMsg3::new(msg3).encode(),
|
|
1280,
|
|
false,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
node.session_count(),
|
|
0,
|
|
"session must not be installed under an address the peer's key does not derive"
|
|
);
|
|
assert_eq!(
|
|
node.identity_cache_len(),
|
|
0,
|
|
"identity cache must not be poisoned with the spoofed address"
|
|
);
|
|
assert_eq!(node.stats().session.addr_mismatch, 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_session_msg3_accepts_matching_source_address() {
|
|
let mut node = make_node();
|
|
let peer = Identity::generate();
|
|
let peer_addr = *peer.node_addr();
|
|
|
|
let (responder, msg3) = drive_xx_to_msg3(&peer, node.identity());
|
|
|
|
let entry = crate::node::session::SessionEntry::new(
|
|
peer_addr,
|
|
node.identity().pubkey_full(),
|
|
EndToEndState::AwaitingMsg3(responder),
|
|
1000,
|
|
false,
|
|
);
|
|
node.sessions.insert(peer_addr, entry);
|
|
|
|
node.handle_session_payload(
|
|
&peer_addr,
|
|
&stub_link_peer(),
|
|
&SessionMsg3::new(msg3).encode(),
|
|
1280,
|
|
false,
|
|
)
|
|
.await;
|
|
|
|
assert!(
|
|
node.sessions
|
|
.get(&peer_addr)
|
|
.is_some_and(|e| e.is_established()),
|
|
"an honest initiator using its own address must still establish"
|
|
);
|
|
assert_eq!(node.identity_cache_len(), 1);
|
|
assert_eq!(node.stats().session.addr_mismatch, 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_rekey_msg3_rejects_different_static_key() {
|
|
let mut node = make_node();
|
|
let legit = Identity::generate();
|
|
let attacker = Identity::generate();
|
|
let peer_addr = *legit.node_addr();
|
|
|
|
let session = make_noise_session(node.identity(), &legit);
|
|
let mut entry = crate::node::session::SessionEntry::new(
|
|
peer_addr,
|
|
legit.pubkey_full(),
|
|
EndToEndState::Established(session),
|
|
1000,
|
|
true,
|
|
);
|
|
entry.mark_established(1000);
|
|
|
|
// Responder-side rekey armed, but driven by a different identity.
|
|
let (responder, msg3) = drive_xx_to_msg3(&attacker, node.identity());
|
|
entry.set_rekey_state(responder, false);
|
|
node.sessions.insert(peer_addr, entry);
|
|
|
|
node.handle_session_payload(
|
|
&peer_addr,
|
|
&stub_link_peer(),
|
|
&SessionMsg3::new(msg3).encode(),
|
|
1280,
|
|
false,
|
|
)
|
|
.await;
|
|
|
|
let entry = node
|
|
.sessions
|
|
.get(&peer_addr)
|
|
.expect("existing session must survive a spoofed rekey");
|
|
assert!(entry.is_established());
|
|
assert!(
|
|
entry.pending_new_session().is_none(),
|
|
"a rekey from a different static key must not become the pending session"
|
|
);
|
|
assert!(!entry.has_rekey_in_progress());
|
|
assert_eq!(node.stats().session.rekey_key_mismatch, 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_rekey_msg3_accepts_established_peer_key() {
|
|
let mut node = make_node();
|
|
let legit = Identity::generate();
|
|
let peer_addr = *legit.node_addr();
|
|
|
|
let session = make_noise_session(node.identity(), &legit);
|
|
let mut entry = crate::node::session::SessionEntry::new(
|
|
peer_addr,
|
|
legit.pubkey_full(),
|
|
EndToEndState::Established(session),
|
|
1000,
|
|
true,
|
|
);
|
|
entry.mark_established(1000);
|
|
|
|
let (responder, msg3) = drive_xx_to_msg3(&legit, node.identity());
|
|
entry.set_rekey_state(responder, false);
|
|
node.sessions.insert(peer_addr, entry);
|
|
|
|
node.handle_session_payload(
|
|
&peer_addr,
|
|
&stub_link_peer(),
|
|
&SessionMsg3::new(msg3).encode(),
|
|
1280,
|
|
false,
|
|
)
|
|
.await;
|
|
|
|
let entry = node.sessions.get(&peer_addr).expect("session present");
|
|
assert!(entry.pending_new_session().is_some());
|
|
assert_eq!(node.stats().session.rekey_key_mismatch, 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_rekey_msg3_accepts_odd_parity_peer_stored_as_even() {
|
|
let mut node = make_node();
|
|
let legit = generate_odd_parity_identity();
|
|
let peer_addr = *legit.node_addr();
|
|
|
|
// The stored key is the even-parity synthesis an npub-sourced peer
|
|
// identity produces; the peer's real key has odd parity. This must
|
|
// still be accepted, or every peer-initiated rekey against roughly
|
|
// half of all peers would be rejected.
|
|
let stored_pubkey = crate::identity::PeerIdentity::from_pubkey(legit.pubkey()).pubkey_full();
|
|
assert_ne!(
|
|
stored_pubkey,
|
|
legit.pubkey_full(),
|
|
"test fixture must actually differ in parity"
|
|
);
|
|
|
|
let session = make_noise_session(node.identity(), &legit);
|
|
let mut entry = crate::node::session::SessionEntry::new(
|
|
peer_addr,
|
|
stored_pubkey,
|
|
EndToEndState::Established(session),
|
|
1000,
|
|
true,
|
|
);
|
|
entry.mark_established(1000);
|
|
|
|
let (responder, msg3) = drive_xx_to_msg3(&legit, node.identity());
|
|
entry.set_rekey_state(responder, false);
|
|
node.sessions.insert(peer_addr, entry);
|
|
|
|
node.handle_session_payload(
|
|
&peer_addr,
|
|
&stub_link_peer(),
|
|
&SessionMsg3::new(msg3).encode(),
|
|
1280,
|
|
false,
|
|
)
|
|
.await;
|
|
|
|
let entry = node.sessions.get(&peer_addr).expect("session present");
|
|
assert!(
|
|
entry.pending_new_session().is_some(),
|
|
"a parity-normalized stored key must not reject a legitimate rekey"
|
|
);
|
|
assert_eq!(node.stats().session.rekey_key_mismatch, 0);
|
|
}
|
|
|
|
/// Install the shape the msg3 epoch-discard defect needs: an established
|
|
/// entry holding a completed rekey the peer has not yet cut over to, stamped
|
|
/// stale, with a second handshake armed beside it by a stranger's setup.
|
|
///
|
|
/// Returns the node, the peer's address and the cryptographically valid msg3
|
|
/// the stranger would send to finish the handshake it armed.
|
|
fn install_stale_pending_beside_a_stranger_armed_handshake(
|
|
peer: &Identity,
|
|
stranger: &Identity,
|
|
) -> (Node, crate::NodeAddr, Vec<u8>) {
|
|
let (mut node, peer_addr) = make_node_with_established_peer(false, peer);
|
|
let msg3 = arm_stranger_handshake_beside_stale_pending(&mut node, &peer_addr, peer, stranger);
|
|
(node, peer_addr, msg3)
|
|
}
|
|
|
|
/// Put a stale completed rekey and a stranger-armed handshake on an entry
|
|
/// that is already established, and return the msg3 that finishes the
|
|
/// stranger's handshake.
|
|
///
|
|
/// This is what a forged setup leaves behind once `pending_stale` has
|
|
/// lapsed: the veto no longer fires, so the fall-through arms a responder
|
|
/// handshake beside pending keys it does not touch. `set_pending_session`
|
|
/// clears `rekey_state`, so the arming has to follow it, as it does in the
|
|
/// handler.
|
|
fn arm_stranger_handshake_beside_stale_pending(
|
|
node: &mut Node,
|
|
peer_addr: &crate::NodeAddr,
|
|
peer: &Identity,
|
|
stranger: &Identity,
|
|
) -> Vec<u8> {
|
|
let pending = make_noise_session(node.identity(), peer);
|
|
let (responder, msg3) = drive_xx_to_msg3(stranger, node.identity());
|
|
|
|
let idle_ms = node.config().node.session.idle_timeout_secs * 1000;
|
|
let now_ms = wall_clock_ms();
|
|
let entry = node.sessions.get_mut(peer_addr).unwrap();
|
|
entry.set_pending_session(pending);
|
|
// Stale enough that `pending_stale` is true, which is what lets a forged
|
|
// setup arm the handshake this state starts from.
|
|
entry.set_rekey_completed_ms(now_ms - idle_ms - 60_000);
|
|
entry.set_rekey_state(responder, false);
|
|
entry.record_peer_rekey(now_ms);
|
|
|
|
msg3
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_forged_msg3_against_a_peer_armed_handshake_leaves_the_completed_epoch_intact() {
|
|
let peer = Identity::generate();
|
|
let stranger = Identity::generate();
|
|
let (mut node, peer_addr, valid_msg3) =
|
|
install_stale_pending_beside_a_stranger_armed_handshake(&peer, &stranger);
|
|
// Backdate the stamp the handshake's deadline runs from, so a restore
|
|
// that restamped it to the current time could not match by accident.
|
|
let armed_at = wall_clock_ms() - 5_000;
|
|
node.sessions
|
|
.get_mut(&peer_addr)
|
|
.unwrap()
|
|
.record_peer_rekey(armed_at);
|
|
|
|
// Garbage of the right length: `read_message_3` fails on the AEAD.
|
|
let forged = SessionMsg3::new(vec![0u8; crate::noise::HANDSHAKE_MSG3_SIZE]).encode();
|
|
node.handle_session_payload(&peer_addr, &stub_link_peer(), &forged, 1280, false)
|
|
.await;
|
|
|
|
let entry = node.sessions.get(&peer_addr).expect("session present");
|
|
assert_eq!(
|
|
entry.last_peer_rekey_ms(),
|
|
armed_at,
|
|
"the restore must not restamp the handshake's deadline, or a spray \
|
|
would hold it open"
|
|
);
|
|
assert!(
|
|
entry.pending_new_session().is_some(),
|
|
"an unauthenticated msg3 must not discard the key epoch the peer may \
|
|
already have cut over to"
|
|
);
|
|
assert!(
|
|
entry.is_established(),
|
|
"the running session must be left intact alongside the pending one"
|
|
);
|
|
assert!(
|
|
entry.has_rekey_in_progress() && !entry.is_rekey_initiator(),
|
|
"an unreadable msg3 must not discard the handshake it failed against"
|
|
);
|
|
|
|
// The handshake went back rolled back: the msg3 that genuinely finishes
|
|
// it still reads, and the key-mismatch arm, not the read, refuses it.
|
|
node.handle_session_payload(
|
|
&peer_addr,
|
|
&stub_link_peer(),
|
|
&SessionMsg3::new(valid_msg3).encode(),
|
|
1280,
|
|
false,
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
node.stats().session.rekey_key_mismatch,
|
|
1,
|
|
"the kept handshake must still read the msg3 that finishes it"
|
|
);
|
|
let entry = node.sessions.get(&peer_addr).expect("session present");
|
|
assert!(entry.pending_new_session().is_some());
|
|
assert!(!entry.has_rekey_in_progress());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_rekey_msg3_with_an_unreadable_negotiation_payload_leaves_the_completed_epoch_intact()
|
|
{
|
|
let peer = Identity::generate();
|
|
let stranger = Identity::generate();
|
|
let (mut node, peer_addr, valid_msg3) =
|
|
install_stale_pending_beside_a_stranger_armed_handshake(&peer, &stranger);
|
|
|
|
// The fifth failure path in this arm, and the one the maintenance branch
|
|
// has no counterpart for: XX msg3 can carry a negotiation payload behind
|
|
// the base message, so `read_message_3` succeeds and the decrypt of the
|
|
// trailer is what fails. Same defect class as its four siblings — nothing
|
|
// about the sender is established at that point either.
|
|
let mut payload = valid_msg3;
|
|
let base_len = payload.len();
|
|
payload.resize(base_len + crate::noise::TAG_SIZE + 8, 0);
|
|
|
|
node.handle_session_payload(
|
|
&peer_addr,
|
|
&stub_link_peer(),
|
|
&SessionMsg3::new(payload).encode(),
|
|
1280,
|
|
false,
|
|
)
|
|
.await;
|
|
|
|
let entry = node.sessions.get(&peer_addr).expect("session present");
|
|
assert!(
|
|
entry.pending_new_session().is_some(),
|
|
"a msg3 whose negotiation payload will not decrypt must not discard \
|
|
the completed epoch either"
|
|
);
|
|
assert!(
|
|
entry.is_established(),
|
|
"the running session must be left intact alongside the pending one"
|
|
);
|
|
assert!(
|
|
!entry.has_rekey_in_progress(),
|
|
"the handshake the payload failed against must still be abandoned"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_rekey_msg3_from_a_different_static_key_leaves_the_completed_epoch_intact() {
|
|
let peer = Identity::generate();
|
|
let stranger = Identity::generate();
|
|
let (mut node, peer_addr, valid_msg3) =
|
|
install_stale_pending_beside_a_stranger_armed_handshake(&peer, &stranger);
|
|
|
|
// Cryptographically valid for the handshake the stranger armed, so
|
|
// `read_message_3` succeeds and the key-mismatch branch decides.
|
|
node.handle_session_payload(
|
|
&peer_addr,
|
|
&stub_link_peer(),
|
|
&SessionMsg3::new(valid_msg3).encode(),
|
|
1280,
|
|
false,
|
|
)
|
|
.await;
|
|
|
|
let entry = node.sessions.get(&peer_addr).expect("session present");
|
|
assert!(
|
|
entry.pending_new_session().is_some(),
|
|
"a msg3 whose static key is not this session's peer must not discard \
|
|
the completed epoch either"
|
|
);
|
|
assert!(entry.is_established());
|
|
assert_eq!(
|
|
node.stats().session.rekey_key_mismatch,
|
|
1,
|
|
"the key mismatch must still be counted, so this test also pins that \
|
|
the refusal itself did not move"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Integration tests: a setup message naming an established peer
|
|
// ============================================================================
|
|
|
|
/// Build a two-node routable mesh whose nodes both have periodic rekey off.
|
|
async fn make_rekey_disabled_pair() -> Vec<TestNode> {
|
|
let configs = (0..2)
|
|
.map(|_| {
|
|
let mut config = Config::new();
|
|
config.node.rekey.enabled = false;
|
|
config
|
|
})
|
|
.collect();
|
|
let mut nodes = run_tree_test_with_configs(configs, &[(0, 1)]).await;
|
|
verify_tree_convergence(&nodes);
|
|
populate_all_coord_caches(&mut nodes);
|
|
nodes
|
|
}
|
|
|
|
/// Establish an FSP session from nodes[0] to nodes[1] and assert both sides
|
|
/// reached Established.
|
|
async fn establish_pair_session(nodes: &mut [TestNode]) {
|
|
let node1_pubkey = nodes[1].node.identity().pubkey_full();
|
|
establish_under(nodes, node1_pubkey).await;
|
|
}
|
|
|
|
/// Establish an FSP session from nodes[0] to nodes[1], dialling nodes[1]
|
|
/// under `key`, and assert both sides reached Established.
|
|
///
|
|
/// `key` is what nodes[0] stores as the session's peer key, so a caller can
|
|
/// pin a key that differs from nodes[1]'s own in parity.
|
|
async fn establish_under(nodes: &mut [TestNode], key: secp256k1::PublicKey) {
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
|
|
nodes[0]
|
|
.node
|
|
.initiate_session(node1_addr, key)
|
|
.await
|
|
.expect("initiate_session failed");
|
|
|
|
for _ in 0..3 {
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(nodes).await;
|
|
}
|
|
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.expect("initiator session present")
|
|
.is_established(),
|
|
"initiator session must be established before the test body"
|
|
);
|
|
assert!(
|
|
nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.expect("responder session present")
|
|
.is_established(),
|
|
"responder session must be established before the test body"
|
|
);
|
|
}
|
|
|
|
/// Forge a SessionSetup carrying an unrelated ephemeral but claiming
|
|
/// `nodes[0]`'s coordinates, as an attacker able to reach `nodes[1]` would.
|
|
///
|
|
/// XX msg1 is a bare ephemeral: unlike XK it needs no knowledge of the
|
|
/// responder's static key, so the forgery is even cheaper on this branch.
|
|
fn forge_setup_from_stranger(nodes: &[TestNode]) -> Vec<u8> {
|
|
use crate::noise::HandshakeState;
|
|
use crate::proto::fsp::SessionSetup;
|
|
|
|
let attacker = Identity::generate();
|
|
let mut handshake = HandshakeState::new_initiator(attacker.keypair());
|
|
handshake.set_local_epoch([0xA5; 8]);
|
|
let msg1 = handshake
|
|
.write_message_1()
|
|
.expect("attacker msg1 must build");
|
|
|
|
SessionSetup::new(
|
|
nodes[0].node.tree_state().my_coords().clone(),
|
|
nodes[1].node.tree_state().my_coords().clone(),
|
|
)
|
|
.with_handshake(msg1)
|
|
.encode()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_forged_setup_naming_established_peer_leaves_session_carrying_traffic_rekey_disabled()
|
|
{
|
|
let mut nodes = make_rekey_disabled_pair().await;
|
|
establish_pair_session(&mut nodes).await;
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
let recv_before = nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.unwrap()
|
|
.traffic_counters()
|
|
.1;
|
|
|
|
let forged = forge_setup_from_stranger(&nodes);
|
|
nodes[1]
|
|
.node
|
|
.handle_session_payload(&node0_addr, &node0_addr, &forged, 1280, false)
|
|
.await;
|
|
|
|
let entry = nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.expect("the established entry must survive an unauthenticated setup");
|
|
assert!(
|
|
entry.is_established(),
|
|
"an unauthenticated setup must not replace the established session"
|
|
);
|
|
assert!(
|
|
entry.has_rekey_in_progress(),
|
|
"the forged setup must have been observed as a side handshake, \
|
|
not dropped for an unrelated reason"
|
|
);
|
|
assert_eq!(
|
|
nodes[1].node.stats().session.rekey_armed,
|
|
1,
|
|
"arming a handshake from an unauthenticated setup must be counted, \
|
|
since the DEBUG line at that site is invisible at the default level"
|
|
);
|
|
|
|
// The session must still decrypt the real peer's next frame.
|
|
nodes[0]
|
|
.node
|
|
.send_session_data(&node1_addr, 0, 0, b"after the forgery")
|
|
.await
|
|
.expect("send_session_data failed");
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
|
|
let recv_after = nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.unwrap()
|
|
.traffic_counters()
|
|
.1;
|
|
assert!(
|
|
recv_after > recv_before,
|
|
"the real peer's frame must still decrypt: received {} packets before, {} after",
|
|
recv_before,
|
|
recv_after
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_genuine_peer_restart_reestablishes_session_with_rekey_disabled() {
|
|
let mut nodes = make_rekey_disabled_pair().await;
|
|
establish_pair_session(&mut nodes).await;
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
let node1_pubkey = nodes[1].node.identity().pubkey_full();
|
|
|
|
// Simulate node 0 restarting: it loses its session state but keeps its
|
|
// identity, so its setup message names an address node 1 still holds an
|
|
// established session for.
|
|
nodes[0].node.remove_session(&node1_addr);
|
|
nodes[0]
|
|
.node
|
|
.initiate_session(node1_addr, node1_pubkey)
|
|
.await
|
|
.expect("re-initiate_session failed");
|
|
|
|
for _ in 0..3 {
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
}
|
|
|
|
assert!(
|
|
nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.expect("responder entry present")
|
|
.pending_new_session()
|
|
.is_some(),
|
|
"the restarted peer's msg3 must have produced a pending session"
|
|
);
|
|
|
|
nodes[0]
|
|
.node
|
|
.send_session_data(&node1_addr, 0, 0, b"after the restart")
|
|
.await
|
|
.expect("send_session_data failed");
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
|
|
let entry = nodes[1].node.get_session(&node0_addr).unwrap();
|
|
assert!(
|
|
entry.pending_new_session().is_none(),
|
|
"the first frame on the new epoch must complete the cutover"
|
|
);
|
|
assert!(
|
|
entry.traffic_counters().1 > 0,
|
|
"node 1 must have decrypted the restarted peer's frame"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_genuine_peer_restart_reestablishes_session_with_rekey_enabled() {
|
|
let mut nodes = run_tree_test(2, &[(0, 1)], false).await;
|
|
verify_tree_convergence(&nodes);
|
|
populate_all_coord_caches(&mut nodes);
|
|
establish_pair_session(&mut nodes).await;
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
let node1_pubkey = nodes[1].node.identity().pubkey_full();
|
|
|
|
nodes[0].node.remove_session(&node1_addr);
|
|
nodes[0]
|
|
.node
|
|
.initiate_session(node1_addr, node1_pubkey)
|
|
.await
|
|
.expect("re-initiate_session failed");
|
|
|
|
for _ in 0..3 {
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
}
|
|
|
|
assert!(
|
|
nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.expect("responder entry present")
|
|
.pending_new_session()
|
|
.is_some(),
|
|
"the restarted peer's msg3 must have produced a pending session"
|
|
);
|
|
|
|
nodes[0]
|
|
.node
|
|
.send_session_data(&node1_addr, 0, 0, b"after the restart")
|
|
.await
|
|
.expect("send_session_data failed");
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
|
|
let entry = nodes[1].node.get_session(&node0_addr).unwrap();
|
|
assert!(
|
|
entry.pending_new_session().is_none(),
|
|
"the first frame on the new epoch must complete the cutover"
|
|
);
|
|
assert!(
|
|
entry.traffic_counters().1 > 0,
|
|
"node 1 must have decrypted the restarted peer's frame"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Integration tests: the per-link-peer session-setup limiter
|
|
// ============================================================================
|
|
|
|
/// Build a two-node routable mesh with the setup limiter sized for a test.
|
|
async fn make_setup_limited_pair(burst: u32, rate: f64) -> Vec<TestNode> {
|
|
let configs = (0..2)
|
|
.map(|_| {
|
|
let mut config = Config::new();
|
|
config.node.rekey.enabled = false;
|
|
config.node.rate_limit.session_setup_burst = burst;
|
|
config.node.rate_limit.session_setup_rate = rate;
|
|
config
|
|
})
|
|
.collect();
|
|
let mut nodes = run_tree_test_with_configs(configs, &[(0, 1)]).await;
|
|
verify_tree_convergence(&nodes);
|
|
populate_all_coord_caches(&mut nodes);
|
|
nodes
|
|
}
|
|
|
|
/// Deliver one forged SessionSetup to `nodes[1]` over the link from
|
|
/// `nodes[0]`, naming a fresh source address nobody has seen.
|
|
///
|
|
/// Driven through `handle_session_datagram` rather than
|
|
/// `handle_session_payload` for two reasons: it is the only path that binds
|
|
/// the link peer the limiter keys on, and its coordinate-cache warming is
|
|
/// what gives the forged address a route, without which the ack send fails
|
|
/// and the entry is never inserted even in unlimited code.
|
|
async fn deliver_forged_setup_over_link(nodes: &mut [TestNode]) {
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
let forged_src = *Identity::generate().node_addr();
|
|
|
|
let setup = forge_setup_from_stranger(nodes);
|
|
let datagram = SessionDatagram::new(forged_src, node1_addr, setup).with_ttl(64);
|
|
let encoded = datagram.encode();
|
|
|
|
nodes[1]
|
|
.node
|
|
.handle_session_datagram(&node0_addr, &encoded[1..], false)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_forged_setups_from_one_link_peer_stop_creating_session_entries_once_the_bucket_is_drained()
|
|
{
|
|
const BURST: u32 = 4;
|
|
// Slow enough that nothing refills during the test.
|
|
let mut nodes = make_setup_limited_pair(BURST, 0.5).await;
|
|
|
|
let before = nodes[1].node.sessions.len();
|
|
for _ in 0..BURST {
|
|
deliver_forged_setup_over_link(&mut nodes).await;
|
|
}
|
|
assert_eq!(
|
|
nodes[1].node.sessions.len(),
|
|
before + BURST as usize,
|
|
"the burst must be admitted, or this test would pass for the wrong reason"
|
|
);
|
|
assert_eq!(nodes[1].node.stats().session.setup_rate_limited, 0);
|
|
|
|
// Every SessionAck the handler emits goes out through
|
|
// `send_session_datagram`, which is the only thing that bumps this
|
|
// counter on a node with no transit traffic. A refused setup must not
|
|
// move it: that is the ack amplification bound, measured rather than
|
|
// argued from where the check sits.
|
|
let originated = nodes[1].node.metrics().forwarding.originated_packets.get();
|
|
|
|
for _ in 0..3 {
|
|
deliver_forged_setup_over_link(&mut nodes).await;
|
|
}
|
|
|
|
assert_eq!(
|
|
nodes[1].node.sessions.len(),
|
|
before + BURST as usize,
|
|
"a drained bucket must stop the session table growing"
|
|
);
|
|
assert_eq!(
|
|
nodes[1].node.stats().session.setup_rate_limited,
|
|
3,
|
|
"each refusal must be counted; the DEBUG line is invisible by default"
|
|
);
|
|
assert_eq!(
|
|
nodes[1].node.metrics().forwarding.originated_packets.get(),
|
|
originated,
|
|
"a refused setup must emit nothing at all, so it buys the sender no \
|
|
packet to an address it chose"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
/// The limiter's clock for the refill test: tokio's paused clock, which the
|
|
/// test moves with `tokio::time::advance` and nothing else moves.
|
|
fn paused_now() -> std::time::Instant {
|
|
tokio::time::Instant::now().into_std()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_a_drained_setup_bucket_refills_and_admits_the_next_legitimate_setup() {
|
|
const BURST: u32 = 2;
|
|
const RATE: f64 = 2.0;
|
|
let mut nodes = make_setup_limited_pair(BURST, RATE).await;
|
|
|
|
// From here the limiter reads a clock only the test moves, so the drain
|
|
// cannot race a refill however slowly each delivery runs, and the refill
|
|
// below is exactly the one the test grants.
|
|
tokio::time::pause();
|
|
nodes[1].node.setup_rate_limiter.set_clock(paused_now);
|
|
|
|
let before = nodes[1].node.stats().session.setup_rate_limited;
|
|
for _ in 0..=BURST {
|
|
deliver_forged_setup_over_link(&mut nodes).await;
|
|
}
|
|
assert_eq!(
|
|
nodes[1].node.stats().session.setup_rate_limited,
|
|
before + 1,
|
|
"the burst must be admitted and the one setup past it refused"
|
|
);
|
|
|
|
// A full burst back from empty at the configured rate, so the legitimate
|
|
// setup below meets a bucket the refill alone has restored. The denial
|
|
// is transient and clears on its own; how long it lasts is a function of
|
|
// the configured rate.
|
|
tokio::time::advance(Duration::from_secs_f64(f64::from(BURST) / RATE)).await;
|
|
establish_pair_session(&mut nodes).await;
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_a_drained_stranger_bucket_still_admits_a_setup_naming_an_established_peer() {
|
|
// Burst 2: one token for the genuine msg1 that establishes the pair, one
|
|
// for a forged stranger setup, and the third stranger setup is refused.
|
|
let mut nodes = make_setup_limited_pair(2, 0.5).await;
|
|
establish_pair_session(&mut nodes).await;
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
|
|
deliver_forged_setup_over_link(&mut nodes).await;
|
|
deliver_forged_setup_over_link(&mut nodes).await;
|
|
assert!(
|
|
nodes[1].node.stats().session.setup_rate_limited > 0,
|
|
"the stranger bucket must be drained before the established class is tested"
|
|
);
|
|
|
|
// The same message, but naming the established peer: this is the shape an
|
|
// inbound rekey arrives in. It creates no new table entry, so it draws on
|
|
// its own bucket rather than competing with stranger admission.
|
|
let setup = forge_setup_from_stranger(&nodes);
|
|
let datagram = SessionDatagram::new(node0_addr, node1_addr, setup).with_ttl(64);
|
|
let encoded = datagram.encode();
|
|
nodes[1]
|
|
.node
|
|
.handle_session_datagram(&node0_addr, &encoded[1..], false)
|
|
.await;
|
|
|
|
assert!(
|
|
nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.expect("the established session must still be there")
|
|
.has_rekey_in_progress(),
|
|
"a drained stranger bucket must not stop an established peer's rekey \
|
|
arming: suppressed rotation is silent, and the operator's only \
|
|
signal would be a flat rekey_armed"
|
|
);
|
|
assert_eq!(nodes[1].node.stats().session.rekey_armed, 1);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Integration tests: the session-table population cap
|
|
// ============================================================================
|
|
|
|
/// Build a two-node routable mesh with the session table capped for a test
|
|
/// and the setup limiter opened wide, so the cap is the only thing refusing.
|
|
async fn make_session_capped_pair(max_sessions: usize) -> Vec<TestNode> {
|
|
let configs = (0..2)
|
|
.map(|_| {
|
|
let mut config = Config::new();
|
|
config.node.rekey.enabled = false;
|
|
config.node.limits.max_sessions = max_sessions;
|
|
config.node.rate_limit.session_setup_burst = 10_000;
|
|
config.node.rate_limit.session_setup_rate = 10_000.0;
|
|
config
|
|
})
|
|
.collect();
|
|
let mut nodes = run_tree_test_with_configs(configs, &[(0, 1)]).await;
|
|
verify_tree_convergence(&nodes);
|
|
populate_all_coord_caches(&mut nodes);
|
|
nodes
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_forged_setups_stop_growing_the_session_table_once_the_cap_is_reached() {
|
|
// The table was the one remotely-grown map with no bound: each setup from
|
|
// an address nobody has seen inserted an entry, and neither existing limit
|
|
// reached it, the setup limiter governing arrival rate rather than
|
|
// population and the idle purge only reaching entries a peer stops using.
|
|
const MAX: usize = 8;
|
|
let share = MAX / 2;
|
|
let mut nodes = make_session_capped_pair(MAX).await;
|
|
|
|
for _ in 0..share {
|
|
deliver_forged_setup_over_link(&mut nodes).await;
|
|
}
|
|
assert_eq!(
|
|
nodes[1].node.sessions.len(),
|
|
share,
|
|
"the admissible entries must be admitted, or this test would pass for \
|
|
the wrong reason"
|
|
);
|
|
assert_eq!(nodes[1].node.stats().session.half_open_full, 0);
|
|
|
|
// Every SessionAck goes out through `send_session_datagram`, the only
|
|
// thing bumping this counter on a node with no transit traffic. A refused
|
|
// setup must not move it.
|
|
let originated = nodes[1].node.metrics().forwarding.originated_packets.get();
|
|
|
|
for _ in 0..4 {
|
|
deliver_forged_setup_over_link(&mut nodes).await;
|
|
}
|
|
|
|
assert_eq!(
|
|
nodes[1].node.sessions.len(),
|
|
share,
|
|
"a table at its bound must stop growing"
|
|
);
|
|
assert_eq!(
|
|
nodes[1].node.stats().session.half_open_full,
|
|
4,
|
|
"each refusal must be counted; the DEBUG line is invisible by default"
|
|
);
|
|
assert_eq!(
|
|
nodes[1].node.metrics().forwarding.originated_packets.get(),
|
|
originated,
|
|
"a refused setup must emit nothing at all"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_a_setup_that_would_grow_a_full_table_is_refused_and_counted() {
|
|
// The table-full arm specifically: one established entry against a cap of
|
|
// one, so the half-open share is not what refuses.
|
|
let mut nodes = make_session_capped_pair(1).await;
|
|
establish_pair_session(&mut nodes).await;
|
|
assert_eq!(
|
|
nodes[1].node.sessions.len(),
|
|
1,
|
|
"precondition: the table is full with the established peer"
|
|
);
|
|
|
|
let originated = nodes[1].node.metrics().forwarding.originated_packets.get();
|
|
deliver_forged_setup_over_link(&mut nodes).await;
|
|
|
|
assert_eq!(
|
|
nodes[1].node.sessions.len(),
|
|
1,
|
|
"a full table must not grow for a stranger"
|
|
);
|
|
assert_eq!(nodes[1].node.stats().session.table_full, 1);
|
|
assert_eq!(
|
|
nodes[1].node.metrics().forwarding.originated_packets.get(),
|
|
originated,
|
|
"a refused setup must cost no ack"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_a_full_session_table_still_serves_a_setup_naming_an_existing_entry() {
|
|
// The guard against writing the cap as "refuse strangers". A setup for an
|
|
// entry already present cannot grow the table, and refusing it would break
|
|
// the duplicate-ack resend an initiator depends on.
|
|
let mut nodes = make_session_capped_pair(1).await;
|
|
establish_pair_session(&mut nodes).await;
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
let refused_before = nodes[1].node.stats().session.table_full;
|
|
let originated = nodes[1].node.metrics().forwarding.originated_packets.get();
|
|
|
|
// A setup naming the established peer: the shape an inbound rekey has.
|
|
let setup = forge_setup_from_stranger(&nodes);
|
|
let datagram = SessionDatagram::new(node0_addr, node1_addr, setup).with_ttl(64);
|
|
let encoded = datagram.encode();
|
|
nodes[1]
|
|
.node
|
|
.handle_session_datagram(&node0_addr, &encoded[1..], false)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
nodes[1].node.stats().session.table_full,
|
|
refused_before,
|
|
"a setup that cannot grow the table must not be refused by the cap"
|
|
);
|
|
assert!(
|
|
nodes[1].node.metrics().forwarding.originated_packets.get() > originated,
|
|
"and it must still be answered"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_a_full_session_table_does_not_evict_an_established_session() {
|
|
// Pins refuse-not-evict. The setup that triggers the decision is
|
|
// unauthenticated at that point, so evicting would hand a stranger a way
|
|
// to tear down a session it has nothing to do with.
|
|
let mut nodes = make_session_capped_pair(1).await;
|
|
establish_pair_session(&mut nodes).await;
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
|
|
for _ in 0..4 {
|
|
deliver_forged_setup_over_link(&mut nodes).await;
|
|
}
|
|
|
|
assert!(
|
|
nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.expect("the established session must survive a flood at the cap")
|
|
.is_established(),
|
|
"a stranger's setup must never cost an established peer its session"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_the_session_table_admits_again_after_the_handshake_reaper_drains_it() {
|
|
// The cap is a ceiling, not a latch: half-open entries are reaped after
|
|
// `handshake_timeout_secs` and the room they free must be usable.
|
|
const MAX: usize = 8;
|
|
let share = MAX / 2;
|
|
let mut nodes = make_session_capped_pair(MAX).await;
|
|
|
|
for _ in 0..(share + 2) {
|
|
deliver_forged_setup_over_link(&mut nodes).await;
|
|
}
|
|
assert!(
|
|
nodes[1].node.stats().session.half_open_full > 0,
|
|
"precondition: the table is refusing before the reaper runs"
|
|
);
|
|
|
|
let timeout_ms = nodes[1]
|
|
.node
|
|
.config()
|
|
.node
|
|
.rate_limit
|
|
.handshake_timeout_secs
|
|
* 1000;
|
|
let now_ms = Node::now_ms();
|
|
nodes[1]
|
|
.node
|
|
.resend_pending_session_handshakes(now_ms + timeout_ms + 1)
|
|
.await;
|
|
assert_eq!(
|
|
nodes[1].node.sessions.len(),
|
|
0,
|
|
"precondition: the reaper freed the half-open entries"
|
|
);
|
|
|
|
deliver_forged_setup_over_link(&mut nodes).await;
|
|
assert_eq!(
|
|
nodes[1].node.sessions.len(),
|
|
1,
|
|
"room freed by the reaper must be usable, or the cap is a latch"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_half_open_setups_cannot_consume_more_than_their_share_of_the_table() {
|
|
// Half-open entries are unauthenticated and cheap to create, so they are
|
|
// held to a share of the table rather than being allowed to fill it and
|
|
// deny it to every peer that would complete a handshake.
|
|
const MAX: usize = 16;
|
|
let share = MAX / 2;
|
|
let mut nodes = make_session_capped_pair(MAX).await;
|
|
|
|
for _ in 0..(share + 2) {
|
|
deliver_forged_setup_over_link(&mut nodes).await;
|
|
}
|
|
|
|
assert_eq!(
|
|
nodes[1].node.sessions.len(),
|
|
share,
|
|
"half-open entries must stop at their share, well below the table cap"
|
|
);
|
|
assert_eq!(nodes[1].node.stats().session.half_open_full, 2);
|
|
assert_eq!(
|
|
nodes[1].node.stats().session.table_full,
|
|
0,
|
|
"the table itself is not full, so the refusals must be attributed to \
|
|
the share rather than to the cap"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_entry_size_stays_within_the_budget_the_cap_is_derived_from() {
|
|
// The default `max_sessions` is derived from what one entry costs.
|
|
// Measured at 6608 bytes of inline state when the cap was written, plus
|
|
// heap for the MMP window and handshake payloads, so 1024 sessions is
|
|
// roughly 7 MB. This is what fires if a large field is added later and
|
|
// the arithmetic behind that default stops holding.
|
|
const BUDGET: usize = 8192;
|
|
assert!(
|
|
std::mem::size_of::<SessionEntry>() <= BUDGET,
|
|
"SessionEntry is {} bytes, over the {} the max_sessions default \
|
|
assumes; re-derive the default or shrink the entry",
|
|
std::mem::size_of::<SessionEntry>(),
|
|
BUDGET
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Integration tests: a forged SessionAck against an in-flight initiation
|
|
// ============================================================================
|
|
|
|
/// A forged SessionAck of exactly the right length, carrying `from`'s tree
|
|
/// coordinates.
|
|
///
|
|
/// The leading 33 bytes of its handshake payload are a valid compressed
|
|
/// point, which is what makes it discriminate a rollback: random bytes
|
|
/// usually fail `PublicKey::from_slice` before anything has been mixed into
|
|
/// the symmetric state. The bytes after it are zeroed, so the read fails only
|
|
/// once the point has been mixed in.
|
|
fn forged_session_ack(from: &TestNode) -> Vec<u8> {
|
|
let mut payload = Identity::generate().pubkey_full().serialize().to_vec();
|
|
payload.resize(crate::noise::HANDSHAKE_MSG2_SIZE, 0);
|
|
assert_eq!(payload.len(), crate::noise::HANDSHAKE_MSG2_SIZE);
|
|
let coords = from.node.tree_state().my_coords().clone();
|
|
SessionAck::new(coords.clone(), coords)
|
|
.with_handshake(payload)
|
|
.encode()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_forged_session_ack_leaves_the_initiation_able_to_complete_on_the_genuine_ack() {
|
|
let mut nodes = make_rekey_disabled_pair().await;
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
let node1_pubkey = nodes[1].node.identity().pubkey_full();
|
|
|
|
// Initiate but do not pump: node 0 sits in Initiating with its msg1 in
|
|
// flight, which is the state the forgery targets.
|
|
nodes[0]
|
|
.node
|
|
.initiate_session(node1_addr, node1_pubkey)
|
|
.await
|
|
.expect("initiate_session failed");
|
|
let activity_before = nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.expect("initiating entry present")
|
|
.last_activity();
|
|
|
|
let forged = forged_session_ack(&nodes[1]);
|
|
|
|
nodes[0]
|
|
.node
|
|
.handle_session_payload(&node1_addr, &node1_addr, &forged, 1280, false)
|
|
.await;
|
|
|
|
let entry = nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.expect("an unauthenticated ack must not destroy the initiation");
|
|
assert!(
|
|
entry.is_initiating(),
|
|
"the entry must still be the initiation it was, not a broken one"
|
|
);
|
|
assert_eq!(
|
|
entry.last_activity(),
|
|
activity_before,
|
|
"the reinsert must not push the handshake sweep's deadline out, or a \
|
|
spray would keep a dead entry alive"
|
|
);
|
|
assert_eq!(
|
|
nodes[0].node.stats().session.ack_handshake_failed,
|
|
1,
|
|
"the refusal must be counted; its DEBUG line is invisible at the \
|
|
default log level"
|
|
);
|
|
|
|
// The genuine exchange now runs to completion over the same handshake.
|
|
for _ in 0..3 {
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
}
|
|
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.expect("initiator session present")
|
|
.is_established(),
|
|
"the initiation must still complete when the genuine ack arrives"
|
|
);
|
|
assert!(
|
|
nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.expect("responder session present")
|
|
.is_established(),
|
|
"and the responder must reach Established too"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
/// Answer the msg1 `node` has in flight to `dest_addr` with a cryptographically
|
|
/// well-formed XX msg2 under `responder`'s static key.
|
|
///
|
|
/// This is the shape the two exits below the msg2 read have to survive, and
|
|
/// the shape a bare garbage buffer cannot produce: nothing in XX binds msg2 to
|
|
/// the identity the initiator dialled, so any keypair at all answers a msg1
|
|
/// with a msg2 that reads cleanly. The msg1 comes off the entry's own retained
|
|
/// resend copy, so the ephemeral is the real one and the read genuinely
|
|
/// succeeds.
|
|
///
|
|
/// `good_negotiation` chooses between the encrypted negotiation payload a real
|
|
/// responder appends and a same-length run of zeroes, which reaches the
|
|
/// decrypt failure with everything before it intact.
|
|
fn answer_in_flight_msg1(
|
|
node: &Node,
|
|
dest_addr: &crate::NodeAddr,
|
|
responder: &Identity,
|
|
good_negotiation: bool,
|
|
) -> Vec<u8> {
|
|
use crate::proto::fmp::NegotiationPayload;
|
|
use crate::proto::fsp::SessionSetup;
|
|
|
|
let stored = node
|
|
.get_session(dest_addr)
|
|
.expect("initiating entry present")
|
|
.handshake_payload()
|
|
.expect("the initiator retains its msg1 for resend")
|
|
.to_vec();
|
|
// `set_handshake_payload` stores `SessionSetup::encode()`, which carries
|
|
// the 4-byte FSP common prefix that `decode` expects to be stripped.
|
|
let setup = SessionSetup::decode(&stored[4..]).expect("the retained msg1 must decode");
|
|
|
|
let mut handshake = crate::noise::HandshakeState::new_responder(responder.keypair());
|
|
handshake.set_local_epoch([0x77; 8]);
|
|
handshake
|
|
.read_message_1(&setup.handshake_payload)
|
|
.expect("the in-flight msg1 must read");
|
|
let mut msg2 = handshake.write_message_2().expect("msg2 must build");
|
|
|
|
let encrypted = handshake
|
|
.encrypt_payload(&NegotiationPayload::new(0, 0, 0).encode())
|
|
.expect("negotiation payload must encrypt");
|
|
if good_negotiation {
|
|
msg2.extend_from_slice(&encrypted);
|
|
} else {
|
|
msg2.resize(msg2.len() + encrypted.len(), 0);
|
|
}
|
|
|
|
let coords = node.tree_state().my_coords().clone();
|
|
SessionAck::new(coords.clone(), coords)
|
|
.with_handshake(msg2)
|
|
.encode()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_a_session_ack_whose_negotiation_payload_fails_leaves_the_initiation_alive() {
|
|
let mut nodes = make_rekey_disabled_pair().await;
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
let node1_pubkey = nodes[1].node.identity().pubkey_full();
|
|
|
|
nodes[0]
|
|
.node
|
|
.initiate_session(node1_addr, node1_pubkey)
|
|
.await
|
|
.expect("initiate_session failed");
|
|
let activity_before = nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.expect("initiating entry present")
|
|
.last_activity();
|
|
|
|
// Built under a stranger's key rather than node 1's, to pin the ordering:
|
|
// if the negotiation payload were checked after the identity comparison
|
|
// this would be counted as an identity mismatch instead.
|
|
let stranger = Identity::generate();
|
|
let forged = answer_in_flight_msg1(&nodes[0].node, &node1_addr, &stranger, false);
|
|
|
|
nodes[0]
|
|
.node
|
|
.handle_session_payload(&node1_addr, &node1_addr, &forged, 1280, false)
|
|
.await;
|
|
|
|
let entry = nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.expect("a bad negotiation payload must not destroy the initiation");
|
|
assert!(
|
|
entry.is_initiating(),
|
|
"the entry must still be the initiation it was, not a broken one"
|
|
);
|
|
assert_eq!(
|
|
entry.last_activity(),
|
|
activity_before,
|
|
"the reinsert must not push the handshake sweep's deadline out"
|
|
);
|
|
assert_eq!(
|
|
nodes[0].node.stats().session.ack_handshake_failed,
|
|
1,
|
|
"a failed negotiation payload is a failed ack, counted with the read"
|
|
);
|
|
assert_eq!(
|
|
nodes[0].node.stats().session.ack_identity_mismatch,
|
|
0,
|
|
"and it must not be attributed to the identity check it never reached"
|
|
);
|
|
|
|
// The rollback is the half that matters: the msg2 read succeeded here, so
|
|
// the handshake holds the stranger's material until it is put back.
|
|
for _ in 0..3 {
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
}
|
|
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.expect("initiator session present")
|
|
.is_established(),
|
|
"the initiation must still complete when the genuine ack arrives"
|
|
);
|
|
assert!(
|
|
nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.expect("responder session present")
|
|
.is_established(),
|
|
"and the responder must reach Established too"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_a_session_ack_under_the_wrong_static_key_leaves_the_initiation_alive() {
|
|
let mut nodes = make_rekey_disabled_pair().await;
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
let node1_pubkey = nodes[1].node.identity().pubkey_full();
|
|
|
|
nodes[0]
|
|
.node
|
|
.initiate_session(node1_addr, node1_pubkey)
|
|
.await
|
|
.expect("initiate_session failed");
|
|
let activity_before = nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.expect("initiating entry present")
|
|
.last_activity();
|
|
|
|
// Everything about this ack is valid except whose key it is under, which
|
|
// is the whole attack: the source address it claims is an envelope field,
|
|
// so a stranger answering our msg1 is indistinguishable from the peer we
|
|
// dialled having a different key.
|
|
let stranger = Identity::generate();
|
|
let forged = answer_in_flight_msg1(&nodes[0].node, &node1_addr, &stranger, true);
|
|
|
|
nodes[0]
|
|
.node
|
|
.handle_session_payload(&node1_addr, &node1_addr, &forged, 1280, false)
|
|
.await;
|
|
|
|
let entry = nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.expect("an ack under the wrong key must not destroy the initiation");
|
|
assert!(
|
|
entry.is_initiating(),
|
|
"the entry must still be the initiation it was, not a broken one"
|
|
);
|
|
assert_eq!(
|
|
entry.last_activity(),
|
|
activity_before,
|
|
"the reinsert must not push the handshake sweep's deadline out"
|
|
);
|
|
assert_eq!(
|
|
nodes[0].node.stats().session.ack_identity_mismatch,
|
|
1,
|
|
"the mismatch was a silent return before; it must be counted, and \
|
|
counted separately, since it means something a failed read does not"
|
|
);
|
|
assert_eq!(
|
|
nodes[0].node.stats().session.ack_handshake_failed,
|
|
0,
|
|
"and it must not be summed into the failed-read counter, which would \
|
|
hide both"
|
|
);
|
|
|
|
for _ in 0..3 {
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
}
|
|
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.expect("initiator session present")
|
|
.is_established(),
|
|
"the initiation must still complete when the genuine ack arrives"
|
|
);
|
|
assert!(
|
|
nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.expect("responder session present")
|
|
.is_established(),
|
|
"and the responder must reach Established too"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
/// A garbage msg3 that reaches the responder of an initial handshake after
|
|
/// the initiator has sent its genuine msg3 must not cost the genuine one the
|
|
/// half-open entry it completes.
|
|
#[tokio::test]
|
|
async fn test_forged_initial_msg3_leaves_the_responder_able_to_complete_on_the_genuine_msg3() {
|
|
let mut nodes = make_rekey_disabled_pair().await;
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
let node1_pubkey = nodes[1].node.identity().pubkey_full();
|
|
|
|
nodes[0]
|
|
.node
|
|
.initiate_session(node1_addr, node1_pubkey)
|
|
.await
|
|
.expect("initiate_session failed");
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes[1..]).await;
|
|
let activity_before = nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.filter(|e| e.is_awaiting_msg3())
|
|
.expect("node 1 must be awaiting msg3 after answering the setup")
|
|
.last_activity();
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes[..1]).await;
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.is_some_and(|e| e.is_established()),
|
|
"node 0 must be established once it has sent msg3"
|
|
);
|
|
|
|
// Hold node 0's genuine msg3 and deliver the forgery first.
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
let held: Vec<_> = std::iter::from_fn(|| nodes[1].packet_rx.try_recv().ok()).collect();
|
|
assert!(!held.is_empty(), "node 0's msg3 must be queued at node 1");
|
|
let forged = SessionMsg3::new(vec![0u8; crate::noise::HANDSHAKE_MSG3_SIZE]).encode();
|
|
nodes[1]
|
|
.node
|
|
.handle_session_payload(&node0_addr, &node0_addr, &forged, 1280, false)
|
|
.await;
|
|
let entry = nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.expect("an unauthenticated msg3 must not destroy the half-open entry");
|
|
assert!(entry.is_awaiting_msg3());
|
|
assert_eq!(
|
|
entry.last_activity(),
|
|
activity_before,
|
|
"the reinsert must not push the handshake sweep's deadline out, or a \
|
|
spray would keep a dead entry alive"
|
|
);
|
|
|
|
for packet in held {
|
|
nodes[1].node.handle_encrypted_frame(packet).await;
|
|
}
|
|
pump_until_quiet(&mut nodes).await;
|
|
assert!(
|
|
nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.is_some_and(|e| e.is_established()),
|
|
"the genuine msg3 must still complete the session at node 1"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Integration tests: a lost initial msg3
|
|
// ============================================================================
|
|
|
|
/// Build a two-node pair where node 0's initial msg3 was sent and dropped, so
|
|
/// node 0 is established and node 1 is still waiting for msg3.
|
|
///
|
|
/// Periodic rekey is off on both nodes: that is the configuration with no
|
|
/// other recovery, and it keeps the rekey drivers out of the picture. Every
|
|
/// step asserts its packet count, so a harness surprise fails loudly instead
|
|
/// of being read as the defect.
|
|
async fn pair_with_lost_initial_msg3() -> Vec<TestNode> {
|
|
use crate::proto::fmp::wire::{CommonPrefix, PHASE_ESTABLISHED};
|
|
|
|
let mut nodes = make_rekey_disabled_pair().await;
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
let node1_pubkey = nodes[1].node.identity().pubkey_full();
|
|
|
|
nodes[0]
|
|
.node
|
|
.initiate_session(node1_addr, node1_pubkey)
|
|
.await
|
|
.expect("initiate_session failed");
|
|
|
|
assert_eq!(
|
|
process_available_packets(&mut nodes[1..]).await,
|
|
1,
|
|
"node 1 must have exactly node 0's SessionSetup queued"
|
|
);
|
|
assert!(
|
|
nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.expect("responder entry present")
|
|
.is_awaiting_msg3(),
|
|
"node 1 must be awaiting msg3 after answering the SessionSetup"
|
|
);
|
|
|
|
assert_eq!(
|
|
process_available_packets(&mut nodes[..1]).await,
|
|
1,
|
|
"node 0 must have exactly node 1's SessionAck queued"
|
|
);
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.expect("initiator entry present")
|
|
.is_established(),
|
|
"node 0 must be established once it has sent msg3"
|
|
);
|
|
|
|
// Drop msg3: take it out of node 1's queue instead of processing it.
|
|
let dropped: Vec<_> = std::iter::from_fn(|| nodes[1].packet_rx.try_recv().ok()).collect();
|
|
assert_eq!(
|
|
dropped.len(),
|
|
1,
|
|
"node 1 must have only node 0's msg3 queued"
|
|
);
|
|
assert_eq!(
|
|
CommonPrefix::parse(&dropped[0].data).map(|p| p.phase),
|
|
Some(PHASE_ESTABLISHED),
|
|
"the dropped packet must be a link data frame carrying the msg3"
|
|
);
|
|
|
|
pump_until_quiet(&mut nodes).await;
|
|
assert!(
|
|
nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.expect("responder entry present")
|
|
.is_awaiting_msg3(),
|
|
"node 1 must still be awaiting msg3 after the drop"
|
|
);
|
|
assert_eq!(
|
|
nodes[1].packet_rx.len(),
|
|
0,
|
|
"nothing may be left queued at node 1"
|
|
);
|
|
|
|
nodes
|
|
}
|
|
|
|
/// One lost initial msg3 must cost one resend, not the session, and the
|
|
/// recovery must not depend on periodic rekey.
|
|
#[tokio::test]
|
|
async fn a_lost_initial_msg3_is_resent_and_the_responder_completes_the_session() {
|
|
let mut nodes = pair_with_lost_initial_msg3().await;
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
|
|
let (tun0_tx, tun0_rx) = std::sync::mpsc::channel();
|
|
nodes[0].node.supervisor.tun_tx = Some(tun0_tx);
|
|
let (tun1_tx, tun1_rx) = std::sync::mpsc::channel();
|
|
nodes[1].node.supervisor.tun_tx = Some(tun1_tx);
|
|
let fips0 = crate::FipsAddress::from_node_addr(&node0_addr);
|
|
let fips1 = crate::FipsAddress::from_node_addr(&node1_addr);
|
|
|
|
let interval_ms = nodes[0]
|
|
.node
|
|
.config()
|
|
.node
|
|
.rate_limit
|
|
.handshake_resend_interval_ms;
|
|
nodes[0]
|
|
.node
|
|
.resend_pending_session_handshakes(Node::now_ms() + interval_ms + 1)
|
|
.await;
|
|
pump_until_quiet(&mut nodes).await;
|
|
|
|
assert!(
|
|
nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.expect("responder entry present")
|
|
.is_established(),
|
|
"node 1 must complete the session once node 0 resends its msg3"
|
|
);
|
|
|
|
let fwd = build_ipv6_packet(&fips0, &fips1, b"after msg3 resend 0 to 1");
|
|
let rev = build_ipv6_packet(&fips1, &fips0, b"after msg3 resend 1 to 0");
|
|
nodes[0].node.handle_tun_outbound(fwd.clone()).await;
|
|
nodes[1].node.handle_tun_outbound(rev.clone()).await;
|
|
pump_until_quiet(&mut nodes).await;
|
|
let got: Vec<Vec<u8>> = std::iter::from_fn(|| tun1_rx.try_recv().ok()).collect();
|
|
assert_eq!(got, vec![fwd], "node 0 to node 1 must decode");
|
|
let got: Vec<Vec<u8>> = std::iter::from_fn(|| tun0_rx.try_recv().ok()).collect();
|
|
assert_eq!(got, vec![rev], "node 1 to node 0 must decode");
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
/// Drain and count the packets queued at `node` without processing them.
|
|
fn drain_queued(node: &mut TestNode) -> usize {
|
|
std::iter::from_fn(|| node.packet_rx.try_recv().ok()).count()
|
|
}
|
|
|
|
/// On a healthy session the retained msg3 is released by the responder's
|
|
/// first frame, so the resend window is one round trip wide and a later tick
|
|
/// sends nothing.
|
|
#[tokio::test]
|
|
async fn an_initiator_stops_resending_msg3_once_a_responder_frame_authenticates() {
|
|
let mut nodes = make_rekey_disabled_pair().await;
|
|
establish_pair_session(&mut nodes).await;
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
let (tun0_tx, tun0_rx) = std::sync::mpsc::channel();
|
|
nodes[0].node.supervisor.tun_tx = Some(tun0_tx);
|
|
let fips0 = crate::FipsAddress::from_node_addr(&node0_addr);
|
|
let fips1 = crate::FipsAddress::from_node_addr(&node1_addr);
|
|
let interval_ms = nodes[0]
|
|
.node
|
|
.config()
|
|
.node
|
|
.rate_limit
|
|
.handshake_resend_interval_ms;
|
|
|
|
// Control: before the responder has sent anything, the sweep does resend,
|
|
// so a silent sweep at the end is the release and not a dead driver.
|
|
let t1 = Node::now_ms() + interval_ms + 1;
|
|
nodes[0].node.resend_pending_session_handshakes(t1).await;
|
|
assert_eq!(
|
|
drain_queued(&mut nodes[1]),
|
|
1,
|
|
"control: the sweep must resend msg3 while the responder is unheard"
|
|
);
|
|
|
|
let rev = build_ipv6_packet(&fips1, &fips0, b"responder's first frame");
|
|
nodes[1].node.handle_tun_outbound(rev.clone()).await;
|
|
pump_until_quiet(&mut nodes).await;
|
|
let got: Vec<Vec<u8>> = std::iter::from_fn(|| tun0_rx.try_recv().ok()).collect();
|
|
assert_eq!(
|
|
got,
|
|
vec![rev],
|
|
"the responder's frame must authenticate at node 0"
|
|
);
|
|
assert_eq!(nodes[1].packet_rx.len(), 0, "nothing may be left queued");
|
|
|
|
nodes[0]
|
|
.node
|
|
.resend_pending_session_handshakes(t1 + 64_000)
|
|
.await;
|
|
assert_eq!(
|
|
nodes[1].packet_rx.len(),
|
|
0,
|
|
"a msg3 the responder has answered must not be resent"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
/// The resend is harmless to a responder that already completed: it is
|
|
/// refused as a bad-state reject and both directions keep decoding. This is
|
|
/// the wire-neutrality claim, observed rather than argued.
|
|
#[tokio::test]
|
|
async fn a_resent_msg3_reaching_an_established_responder_is_refused_and_the_session_keeps_working()
|
|
{
|
|
let mut nodes = make_rekey_disabled_pair().await;
|
|
establish_pair_session(&mut nodes).await;
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
let (tun0_tx, tun0_rx) = std::sync::mpsc::channel();
|
|
nodes[0].node.supervisor.tun_tx = Some(tun0_tx);
|
|
let (tun1_tx, tun1_rx) = std::sync::mpsc::channel();
|
|
nodes[1].node.supervisor.tun_tx = Some(tun1_tx);
|
|
let fips0 = crate::FipsAddress::from_node_addr(&node0_addr);
|
|
let fips1 = crate::FipsAddress::from_node_addr(&node1_addr);
|
|
let interval_ms = nodes[0]
|
|
.node
|
|
.config()
|
|
.node
|
|
.rate_limit
|
|
.handshake_resend_interval_ms;
|
|
let before = nodes[1].node.stats().session.bad_state;
|
|
|
|
nodes[0]
|
|
.node
|
|
.resend_pending_session_handshakes(Node::now_ms() + interval_ms + 1)
|
|
.await;
|
|
assert_eq!(
|
|
nodes[1].packet_rx.len(),
|
|
1,
|
|
"precondition: node 0 must have resent its msg3 to node 1"
|
|
);
|
|
pump_until_quiet(&mut nodes).await;
|
|
|
|
assert_eq!(
|
|
nodes[1].node.stats().session.bad_state,
|
|
before + 1,
|
|
"the duplicate msg3 must be refused as a bad-state reject"
|
|
);
|
|
assert!(
|
|
nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.expect("responder session present")
|
|
.is_established(),
|
|
"the duplicate msg3 must not disturb the responder's session"
|
|
);
|
|
|
|
let fwd = build_ipv6_packet(&fips0, &fips1, b"after duplicate msg3 0 to 1");
|
|
let rev = build_ipv6_packet(&fips1, &fips0, b"after duplicate msg3 1 to 0");
|
|
nodes[0].node.handle_tun_outbound(fwd.clone()).await;
|
|
nodes[1].node.handle_tun_outbound(rev.clone()).await;
|
|
pump_until_quiet(&mut nodes).await;
|
|
let got: Vec<Vec<u8>> = std::iter::from_fn(|| tun1_rx.try_recv().ok()).collect();
|
|
assert_eq!(got, vec![fwd], "node 0 to node 1 must decode");
|
|
let got: Vec<Vec<u8>> = std::iter::from_fn(|| tun0_rx.try_recv().ok()).collect();
|
|
assert_eq!(got, vec![rev], "node 1 to node 0 must decode");
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
/// The resend ladder is bounded in count and in time: after
|
|
/// `handshake_max_resends` resends nothing more is sent and the payload is no
|
|
/// longer held, while the session itself is kept.
|
|
#[tokio::test]
|
|
async fn initial_msg3_resends_stop_at_the_budget_and_release_the_payload() {
|
|
let mut nodes = pair_with_lost_initial_msg3().await;
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
let max_resends = nodes[0].node.config().node.rate_limit.handshake_max_resends;
|
|
|
|
// 64 s steps pass any single backoff interval at stock settings, so each
|
|
// step is due. Node 0 holds only its established entry, so the sweep's
|
|
// timeout pass cannot remove anything on it.
|
|
let mut now = Node::now_ms();
|
|
let mut sent = Vec::new();
|
|
for _ in 0..(max_resends + 2) {
|
|
now += 64_000;
|
|
nodes[0].node.resend_pending_session_handshakes(now).await;
|
|
sent.push(drain_queued(&mut nodes[1]));
|
|
}
|
|
let mut expected = vec![1; max_resends as usize];
|
|
expected.extend([0, 0]);
|
|
assert_eq!(
|
|
sent, expected,
|
|
"one resend per due tick up to the budget, then none"
|
|
);
|
|
|
|
let entry = nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.expect("the release must not tear the session down");
|
|
assert!(
|
|
entry.handshake_payload().is_none(),
|
|
"the msg3 must no longer be held once the budget is spent"
|
|
);
|
|
assert!(
|
|
entry.is_established(),
|
|
"node 0's session must stay established after the release"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
/// A lost initial msg3 must be kept and resent as the whole encoded
|
|
/// SessionMsg3, negotiation payload included, not as the bare handshake
|
|
/// message.
|
|
///
|
|
/// A bare msg3 would still establish the responder: it treats the
|
|
/// negotiation payload as optional, and the transport keys come from the
|
|
/// chaining key alone. What a bare msg3 changes is the responder's
|
|
/// handshake hash, which the negotiation ciphertext is mixed into. So the
|
|
/// retained bytes are pinned by length, and after the resend both ends must
|
|
/// hold the same handshake hash; the session completing cannot tell the two
|
|
/// apart.
|
|
#[tokio::test]
|
|
async fn a_lost_initial_msg3_is_retained_and_resent_with_its_negotiation_payload() {
|
|
let mut nodes = pair_with_lost_initial_msg3().await;
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
|
|
let retained = nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.expect("initiator entry present")
|
|
.handshake_payload()
|
|
.expect("node 0 must hold its msg3 for resend")
|
|
.to_vec();
|
|
assert_eq!(
|
|
retained[0], 0x03,
|
|
"the retained bytes must be an encoded SessionMsg3"
|
|
);
|
|
let msg3 = SessionMsg3::decode(&retained[4..]).expect("the retained SessionMsg3 must decode");
|
|
// What the initiator appends: the encrypted negotiation payload, that is
|
|
// its plaintext plus the AEAD tag.
|
|
let negotiation_len = crate::proto::fmp::NegotiationPayload::new(0, 0, 0)
|
|
.encode()
|
|
.len()
|
|
+ crate::noise::TAG_SIZE;
|
|
assert_eq!(
|
|
msg3.handshake_payload.len(),
|
|
crate::noise::HANDSHAKE_MSG3_SIZE + negotiation_len,
|
|
"the retained msg3 must carry the encrypted negotiation payload"
|
|
);
|
|
|
|
let interval_ms = nodes[0]
|
|
.node
|
|
.config()
|
|
.node
|
|
.rate_limit
|
|
.handshake_resend_interval_ms;
|
|
nodes[0]
|
|
.node
|
|
.resend_pending_session_handshakes(Node::now_ms() + interval_ms + 1)
|
|
.await;
|
|
pump_until_quiet(&mut nodes).await;
|
|
|
|
let hash0 = match nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.expect("initiator entry present")
|
|
.state()
|
|
{
|
|
EndToEndState::Established(session) => *session.handshake_hash(),
|
|
_ => panic!("node 0 must stay established"),
|
|
};
|
|
let hash1 = match nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.expect("responder entry present")
|
|
.state()
|
|
{
|
|
EndToEndState::Established(session) => *session.handshake_hash(),
|
|
_ => panic!("node 1 must complete the session on the resent msg3"),
|
|
};
|
|
assert_eq!(
|
|
hash0, hash1,
|
|
"the responder must have read the negotiation payload the initiator sent"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
/// A SessionAck that fails to read must not end an FSP rekey the node
|
|
/// initiated.
|
|
///
|
|
/// Nothing authenticates a SessionAck before its msg2 is read: the only tie
|
|
/// to the rekey is the datagram's source address, which the sender chooses.
|
|
/// So the rekey-initiator arm has to put its handshake back, rolled back to
|
|
/// its pre-read state, and let the genuine ack complete the cycle, as the
|
|
/// primary arm does for an initiation.
|
|
#[tokio::test]
|
|
async fn test_forged_session_ack_leaves_the_rekey_able_to_complete_on_the_genuine_ack() {
|
|
use crate::proto::fmp::wire::{CommonPrefix, PHASE_ESTABLISHED};
|
|
use crate::transport::ReceivedPacket;
|
|
|
|
// node 0 rekeys after one message; node 1 never initiates.
|
|
let mut cfg0 = Config::new();
|
|
cfg0.node.rekey.after_messages = 1;
|
|
let mut cfg1 = Config::new();
|
|
cfg1.node.rekey.after_messages = u64::MAX;
|
|
cfg1.node.rekey.after_secs = u64::MAX;
|
|
let mut nodes = run_tree_test_with_configs(vec![cfg0, cfg1], &[(0, 1)]).await;
|
|
verify_tree_convergence(&nodes);
|
|
populate_all_coord_caches(&mut nodes);
|
|
establish_pair_session(&mut nodes).await;
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
|
|
// One frame crosses node 0's rekey trigger.
|
|
nodes[0]
|
|
.node
|
|
.send_session_data(&node1_addr, 0, 0, b"before the rekey")
|
|
.await
|
|
.expect("send_session_data failed");
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
|
|
// node 0 sends its rekey SessionSetup; only node 1 is pumped, so node 1
|
|
// arms and its SessionAck waits in node 0's queue.
|
|
nodes[0].node.check_session_rekey().await;
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.is_some_and(|e| e.has_rekey_in_progress() && e.is_rekey_initiator()),
|
|
"node 0 must have initiated a rekey"
|
|
);
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes[1..]).await;
|
|
assert!(
|
|
nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.is_some_and(|e| e.has_rekey_in_progress() && !e.is_rekey_initiator()),
|
|
"node 1 must have armed as the rekey responder"
|
|
);
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
let held: Vec<ReceivedPacket> =
|
|
std::iter::from_fn(|| nodes[0].packet_rx.try_recv().ok()).collect();
|
|
assert!(
|
|
!held.is_empty(),
|
|
"node 1's SessionAck must be queued at node 0"
|
|
);
|
|
for packet in &held {
|
|
assert_eq!(
|
|
CommonPrefix::parse(&packet.data).map(|p| p.phase),
|
|
Some(PHASE_ESTABLISHED),
|
|
"every held packet must be a link frame"
|
|
);
|
|
}
|
|
|
|
// The forgery arrives first, under node 1's address.
|
|
let forged = forged_session_ack(&nodes[1]);
|
|
nodes[0]
|
|
.node
|
|
.handle_session_payload(&node1_addr, &node1_addr, &forged, 1280, false)
|
|
.await;
|
|
let entry = nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.expect("an unreadable ack must not remove the session");
|
|
assert!(
|
|
entry.has_rekey_in_progress() && entry.is_rekey_initiator(),
|
|
"the rekey must still be in flight after an ack that did not read"
|
|
);
|
|
assert_eq!(
|
|
nodes[0].node.stats().session.ack_handshake_failed,
|
|
1,
|
|
"the refusal must be counted"
|
|
);
|
|
|
|
// Release the genuine ack. This is the assertion that tells the outcomes
|
|
// apart: an initiator that abandoned on the forgery meets the genuine ack
|
|
// with no rekey in flight and completes nothing.
|
|
for packet in held {
|
|
nodes[0].node.handle_encrypted_frame(packet).await;
|
|
}
|
|
for _ in 0..3 {
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
}
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.pending_new_session()
|
|
.is_some(),
|
|
"node 0 must complete the rekey on the genuine ack"
|
|
);
|
|
assert!(
|
|
nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.unwrap()
|
|
.pending_new_session()
|
|
.is_some(),
|
|
"node 1 must hold the new session after msg3"
|
|
);
|
|
|
|
// node 0 cuts over on its liveness timer, and data decodes both ways on
|
|
// the new epoch.
|
|
let now_ms = wall_clock_ms();
|
|
nodes[0]
|
|
.node
|
|
.sessions
|
|
.get_mut(&node1_addr)
|
|
.unwrap()
|
|
.set_rekey_completed_ms(now_ms - 10_000);
|
|
nodes[0].node.check_session_rekey().await;
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.pending_new_session()
|
|
.is_none(),
|
|
"node 0 must have cut over"
|
|
);
|
|
|
|
let recv1_before = nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.unwrap()
|
|
.traffic_counters()
|
|
.1;
|
|
nodes[0]
|
|
.node
|
|
.send_session_data(&node1_addr, 0, 0, b"after the rekey 0 to 1")
|
|
.await
|
|
.expect("send_session_data failed");
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
let entry1 = nodes[1].node.get_session(&node0_addr).unwrap();
|
|
assert_eq!(
|
|
entry1.traffic_counters().1,
|
|
recv1_before + 1,
|
|
"node 0 to node 1 must decode on the new epoch"
|
|
);
|
|
assert!(
|
|
entry1.pending_new_session().is_none(),
|
|
"node 0's first new-epoch frame must complete node 1's cutover"
|
|
);
|
|
|
|
let recv0_before = nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.traffic_counters()
|
|
.1;
|
|
nodes[1]
|
|
.node
|
|
.send_session_data(&node0_addr, 0, 0, b"after the rekey 1 to 0")
|
|
.await
|
|
.expect("send_session_data failed");
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
assert_eq!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.traffic_counters()
|
|
.1,
|
|
recv0_before + 1,
|
|
"node 1 to node 0 must decode on the new epoch"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Integration tests: a rekey SessionAck under a key other than the peer's
|
|
// ============================================================================
|
|
|
|
/// Arm a rekey on `node`'s established session with `peer` and return its
|
|
/// XX msg1, without sending anything.
|
|
///
|
|
/// Builds and stores the initiator handshake exactly as rekey initiation
|
|
/// does. Nothing on the session retains a sent rekey msg1, so a test that
|
|
/// needs to answer it has to arm the rekey itself.
|
|
fn arm_rekey(node: &mut Node, peer: &NodeAddr) -> Vec<u8> {
|
|
let mut handshake = crate::noise::HandshakeState::new_initiator(node.identity().keypair());
|
|
handshake.set_local_epoch(node.startup_epoch());
|
|
let msg1 = handshake.write_message_1().expect("rekey msg1");
|
|
node.sessions
|
|
.get_mut(peer)
|
|
.expect("established session present")
|
|
.begin_rekey(handshake, Node::now_ms());
|
|
msg1
|
|
}
|
|
|
|
/// Encode the rekey SessionSetup `from` would send `to`, carrying `msg1`.
|
|
fn rekey_setup(from: &Node, to: &Node, msg1: &[u8]) -> Vec<u8> {
|
|
crate::proto::fsp::SessionSetup::new(
|
|
from.tree_state().my_coords().clone(),
|
|
to.tree_state().my_coords().clone(),
|
|
)
|
|
.with_handshake(msg1.to_vec())
|
|
.encode()
|
|
}
|
|
|
|
/// Answer `msg1` as a rekey responder holding `responder`'s static key, and
|
|
/// encode the msg2 as a SessionAck carrying `from`'s coordinates.
|
|
///
|
|
/// No negotiation payload is appended: the rekey arm reads the whole
|
|
/// handshake payload as a bare msg2, so anything appended would fail the
|
|
/// read and the caller would be measuring a msg2 that never read.
|
|
fn rekey_ack(msg1: &[u8], responder: &Identity, from: &Node) -> Vec<u8> {
|
|
let mut handshake = crate::noise::HandshakeState::new_responder(responder.keypair());
|
|
handshake.set_local_epoch([0x5a; 8]);
|
|
handshake.read_message_1(msg1).expect("stranger reads msg1");
|
|
let msg2 = handshake.write_message_2().expect("stranger writes msg2");
|
|
let coords = from.tree_state().my_coords().clone();
|
|
SessionAck::new(coords.clone(), coords)
|
|
.with_handshake(msg2)
|
|
.encode()
|
|
}
|
|
|
|
/// A SessionAck whose msg2 reads under a stranger's static key must be
|
|
/// refused before msg3 is written: nothing reaches the wire, nothing is
|
|
/// installed, the rekey stays in flight as ours, and the refusal is counted
|
|
/// as a rekey key mismatch.
|
|
///
|
|
/// Under XX a msg2 that reads proves only that its sender holds some key.
|
|
/// The ack's source address is an envelope field, so anyone who saw our
|
|
/// rekey msg1 can answer it under their own key.
|
|
#[tokio::test]
|
|
async fn test_a_rekey_ack_under_a_strangers_static_key_sends_no_msg3_installs_nothing_and_keeps_the_rekey()
|
|
{
|
|
let mut nodes = make_rekey_disabled_pair().await;
|
|
establish_pair_session(&mut nodes).await;
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
|
|
let msg1 = arm_rekey(&mut nodes[0].node, &node1_addr);
|
|
let stranger = Identity::generate();
|
|
let forged = rekey_ack(&msg1, &stranger, &nodes[1].node);
|
|
|
|
// Sibling control: node 0 originated its SessionSetup and msg3 through
|
|
// this counter while establishing, so it is live on this node.
|
|
let originated_before = nodes[0].node.metrics().forwarding.originated_packets.get();
|
|
assert!(
|
|
originated_before > 0,
|
|
"the originated-packets counter must be live on node 0"
|
|
);
|
|
|
|
nodes[0]
|
|
.node
|
|
.handle_session_payload(&node1_addr, &node1_addr, &forged, 1280, false)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
nodes[0].node.stats().session.ack_handshake_failed,
|
|
0,
|
|
"the stranger's msg2 must read, or this test measures a failed read"
|
|
);
|
|
assert_eq!(
|
|
nodes[0].node.metrics().forwarding.originated_packets.get(),
|
|
originated_before,
|
|
"no msg3 may reach a key the session was not opened with"
|
|
);
|
|
let entry = nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.expect("a refused ack must not remove the session");
|
|
assert!(
|
|
entry.pending_new_session().is_none(),
|
|
"a session keyed with the stranger must not be installed"
|
|
);
|
|
assert!(
|
|
entry.has_rekey_in_progress() && entry.is_rekey_initiator(),
|
|
"the rekey must still be in flight as ours"
|
|
);
|
|
assert_eq!(
|
|
nodes[0].node.stats().session.rekey_key_mismatch,
|
|
1,
|
|
"the refusal must be counted as a rekey key mismatch"
|
|
);
|
|
assert!(entry.is_established(), "the session must stay established");
|
|
|
|
// The current session still carries data.
|
|
let recv1_before = nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.unwrap()
|
|
.traffic_counters()
|
|
.1;
|
|
nodes[0]
|
|
.node
|
|
.send_session_data(&node1_addr, 0, 0, b"after the refused ack")
|
|
.await
|
|
.expect("send_session_data failed");
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
assert_eq!(
|
|
nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.unwrap()
|
|
.traffic_counters()
|
|
.1,
|
|
recv1_before + 1,
|
|
"the current session must still decode"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
/// After refusing a SessionAck under a stranger's static key, the initiator
|
|
/// must still complete the rekey on the peer's genuine ack and cut over to a
|
|
/// working session.
|
|
///
|
|
/// This is what proves the handshake is rolled back rather than merely put
|
|
/// back: left at its post-read state holding the stranger's material, it
|
|
/// could not read the genuine msg2.
|
|
#[tokio::test]
|
|
async fn test_a_rekey_ack_under_a_strangers_static_key_leaves_the_rekey_able_to_complete_on_the_genuine_ack()
|
|
{
|
|
use crate::proto::fmp::wire::{CommonPrefix, PHASE_ESTABLISHED};
|
|
use crate::transport::ReceivedPacket;
|
|
|
|
let mut nodes = make_rekey_disabled_pair().await;
|
|
establish_pair_session(&mut nodes).await;
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
|
|
let msg1 = arm_rekey(&mut nodes[0].node, &node1_addr);
|
|
|
|
// node 1 answers the genuine msg1; its SessionAck waits in node 0's queue.
|
|
let setup = rekey_setup(&nodes[0].node, &nodes[1].node, &msg1);
|
|
nodes[1]
|
|
.node
|
|
.handle_session_payload(&node0_addr, &node0_addr, &setup, 1280, false)
|
|
.await;
|
|
assert!(
|
|
nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.is_some_and(|e| e.has_rekey_in_progress() && !e.is_rekey_initiator()),
|
|
"node 1 must have armed as the rekey responder"
|
|
);
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
let held: Vec<ReceivedPacket> =
|
|
std::iter::from_fn(|| nodes[0].packet_rx.try_recv().ok()).collect();
|
|
assert!(
|
|
!held.is_empty(),
|
|
"node 1's SessionAck must be queued at node 0"
|
|
);
|
|
for packet in &held {
|
|
assert_eq!(
|
|
CommonPrefix::parse(&packet.data).map(|p| p.phase),
|
|
Some(PHASE_ESTABLISHED),
|
|
"every held packet must be a link frame"
|
|
);
|
|
}
|
|
|
|
// The stranger's ack arrives first, under node 1's address.
|
|
let stranger = Identity::generate();
|
|
let forged = rekey_ack(&msg1, &stranger, &nodes[1].node);
|
|
nodes[0]
|
|
.node
|
|
.handle_session_payload(&node1_addr, &node1_addr, &forged, 1280, false)
|
|
.await;
|
|
let entry = nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.expect("a refused ack must not remove the session");
|
|
assert!(
|
|
entry.has_rekey_in_progress() && entry.is_rekey_initiator(),
|
|
"the rekey must still be in flight after an ack under a stranger's key"
|
|
);
|
|
assert!(
|
|
entry.pending_new_session().is_none(),
|
|
"a session keyed with the stranger must not be installed"
|
|
);
|
|
assert_eq!(
|
|
nodes[0].node.stats().session.rekey_key_mismatch,
|
|
1,
|
|
"the refusal must be counted"
|
|
);
|
|
|
|
// Release the genuine ack.
|
|
for packet in held {
|
|
nodes[0].node.handle_encrypted_frame(packet).await;
|
|
}
|
|
for _ in 0..3 {
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
}
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.pending_new_session()
|
|
.is_some(),
|
|
"node 0 must complete the rekey on the genuine ack"
|
|
);
|
|
assert!(
|
|
nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.unwrap()
|
|
.pending_new_session()
|
|
.is_some(),
|
|
"node 1 must hold the new session after msg3"
|
|
);
|
|
assert_eq!(
|
|
nodes[0].node.stats().session.ack_handshake_failed,
|
|
0,
|
|
"the genuine msg2 must read against the restored handshake"
|
|
);
|
|
assert_eq!(
|
|
nodes[0].node.stats().session.rekey_key_mismatch,
|
|
1,
|
|
"the genuine ack must not be counted as a mismatch"
|
|
);
|
|
|
|
// node 0 cuts over on its liveness timer, and data decodes both ways on
|
|
// the new epoch.
|
|
let now_ms = wall_clock_ms();
|
|
nodes[0]
|
|
.node
|
|
.sessions
|
|
.get_mut(&node1_addr)
|
|
.unwrap()
|
|
.set_rekey_completed_ms(now_ms - 10_000);
|
|
nodes[0].node.check_session_rekey().await;
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.pending_new_session()
|
|
.is_none(),
|
|
"node 0 must have cut over"
|
|
);
|
|
|
|
let recv1_before = nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.unwrap()
|
|
.traffic_counters()
|
|
.1;
|
|
nodes[0]
|
|
.node
|
|
.send_session_data(&node1_addr, 0, 0, b"after the rekey 0 to 1")
|
|
.await
|
|
.expect("send_session_data failed");
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
let entry1 = nodes[1].node.get_session(&node0_addr).unwrap();
|
|
assert_eq!(
|
|
entry1.traffic_counters().1,
|
|
recv1_before + 1,
|
|
"node 0 to node 1 must decode on the new epoch"
|
|
);
|
|
assert!(
|
|
entry1.pending_new_session().is_none(),
|
|
"node 0's first new-epoch frame must complete node 1's cutover"
|
|
);
|
|
|
|
let recv0_before = nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.traffic_counters()
|
|
.1;
|
|
nodes[1]
|
|
.node
|
|
.send_session_data(&node0_addr, 0, 0, b"after the rekey 1 to 0")
|
|
.await
|
|
.expect("send_session_data failed");
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
assert_eq!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.traffic_counters()
|
|
.1,
|
|
recv0_before + 1,
|
|
"node 1 to node 0 must decode on the new epoch"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
/// A rekey with a peer whose key has odd parity, dialled by the even-parity
|
|
/// key an npub gives, must complete on the peer's genuine ack.
|
|
///
|
|
/// The stored key and the key msg2 proves differ as full keys and agree as
|
|
/// x-only keys, so this is the pair that tells an x-only comparison from a
|
|
/// full-key one.
|
|
#[tokio::test]
|
|
async fn test_a_rekey_ack_from_an_odd_parity_peer_dialled_by_its_even_parity_key_completes_the_rekey()
|
|
{
|
|
let mut cfg = Config::new();
|
|
cfg.node.rekey.enabled = false;
|
|
let mut nodes = pinned_tree(
|
|
vec![
|
|
(cfg.clone(), Identity::generate()),
|
|
(cfg, generate_odd_parity_identity()),
|
|
],
|
|
&[(0, 1)],
|
|
)
|
|
.await;
|
|
verify_tree_convergence(&nodes);
|
|
populate_all_coord_caches(&mut nodes);
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
|
|
let stored =
|
|
crate::identity::PeerIdentity::from_pubkey(nodes[1].node.identity().pubkey()).pubkey_full();
|
|
assert_ne!(
|
|
stored,
|
|
nodes[1].node.identity().pubkey_full(),
|
|
"test fixture must actually differ in parity"
|
|
);
|
|
establish_under(&mut nodes, stored).await;
|
|
assert_eq!(
|
|
*nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.remote_pubkey(),
|
|
stored,
|
|
"node 0 must hold the even-parity key as the session's peer key"
|
|
);
|
|
|
|
let msg1 = arm_rekey(&mut nodes[0].node, &node1_addr);
|
|
let setup = rekey_setup(&nodes[0].node, &nodes[1].node, &msg1);
|
|
nodes[1]
|
|
.node
|
|
.handle_session_payload(&node0_addr, &node0_addr, &setup, 1280, false)
|
|
.await;
|
|
for _ in 0..3 {
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
}
|
|
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.pending_new_session()
|
|
.is_some(),
|
|
"node 0 must complete the rekey with an odd-parity peer"
|
|
);
|
|
assert!(
|
|
nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.unwrap()
|
|
.pending_new_session()
|
|
.is_some(),
|
|
"node 1 must hold the new session after msg3"
|
|
);
|
|
assert_eq!(nodes[0].node.stats().session.rekey_key_mismatch, 0);
|
|
assert_eq!(nodes[0].node.stats().session.ack_handshake_failed, 0);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Integration tests: a destination with no cached coordinates
|
|
// ============================================================================
|
|
|
|
/// Build a two-node routable mesh with periodic rekey off and an established
|
|
/// FSP session from node 0 to node 1, with node 0's coordinate warmup budget
|
|
/// set to `warmup`.
|
|
///
|
|
/// node 1's budget is 0, so none of its frames carry coordinates: each one
|
|
/// that did would re-warm node 0's entry for node 1, and these tests need
|
|
/// that entry to stay gone once they remove it.
|
|
async fn make_warmup_pair(warmup: u8) -> Vec<TestNode> {
|
|
let configs = (0..2)
|
|
.map(|i| {
|
|
let mut config = Config::new();
|
|
config.node.rekey.enabled = false;
|
|
config.node.session.coords_warmup_packets = if i == 0 { warmup } else { 0 };
|
|
config
|
|
})
|
|
.collect();
|
|
let mut nodes = run_tree_test_with_configs(configs, &[(0, 1)]).await;
|
|
verify_tree_convergence(&nodes);
|
|
populate_all_coord_caches(&mut nodes);
|
|
establish_pair_session(&mut nodes).await;
|
|
pump_until_quiet(&mut nodes).await;
|
|
nodes
|
|
}
|
|
|
|
/// The address path `node` has cached for `addr`, if any.
|
|
///
|
|
/// Compared by address because coordinates carried in a session frame arrive
|
|
/// without the declaration metadata a node's own copy holds.
|
|
fn cached_path(node: &TestNode, addr: &NodeAddr) -> Option<Vec<NodeAddr>> {
|
|
node.node
|
|
.coord_cache()
|
|
.get(addr, wall_clock_ms())
|
|
.map(addr_path)
|
|
}
|
|
|
|
/// The address path of a coordinate, self to root.
|
|
fn addr_path(coords: &crate::proto::stp::TreeCoordinate) -> Vec<NodeAddr> {
|
|
coords.node_addrs().copied().collect()
|
|
}
|
|
|
|
/// Deliver a PathBroken naming `dest` to `nodes[at]`, reported by `reporter`.
|
|
/// The reporter must not be `dest`, or the signal is refused as forged before
|
|
/// it removes anything.
|
|
///
|
|
/// Nothing is pumped afterwards: the handler starts a lookup, and in a small
|
|
/// mesh its answer would refill the entry the signal removed before the test
|
|
/// could send into the miss.
|
|
async fn deliver_path_broken(
|
|
nodes: &mut [TestNode],
|
|
at: usize,
|
|
dest: NodeAddr,
|
|
reporter: NodeAddr,
|
|
) {
|
|
use crate::proto::routing::PathBroken;
|
|
let encoded = PathBroken::new(dest, reporter).encode();
|
|
nodes[at]
|
|
.node
|
|
.handle_path_broken(&reporter, &reporter, &encoded[5..])
|
|
.await;
|
|
}
|
|
|
|
/// A data frame to a destination whose coordinates this node does not have
|
|
/// cached must not carry this node's own coordinates in their place.
|
|
///
|
|
/// The shape reached in practice: a direct peer whose cache entry is gone,
|
|
/// here removed by a PathBroken from a third address. The destination warms
|
|
/// its cache from every coordinate-bearing frame, so a frame carrying the
|
|
/// sender's coordinates as the destination's leaves the destination holding
|
|
/// its own address under the sender's coordinates. Once the coordinates are
|
|
/// known again, the warmup budget the miss did not spend is spent on frames
|
|
/// that carry them.
|
|
#[tokio::test]
|
|
async fn test_a_data_frame_to_a_destination_with_no_cached_coordinates_carries_none_and_keeps_the_warmup()
|
|
{
|
|
let mut nodes = make_warmup_pair(1).await;
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
let node0_coords = nodes[0].node.tree_state().my_coords().clone();
|
|
let node1_coords = nodes[1].node.tree_state().my_coords().clone();
|
|
|
|
// A transit router reports the path to node 1 broken. It is a third
|
|
// address: a report "from" node 1 about node 1 is refused as forged.
|
|
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
|
deliver_path_broken(&mut nodes, 0, node1_addr, reporter).await;
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.coord_cache()
|
|
.get(&node1_addr, wall_clock_ms())
|
|
.is_none(),
|
|
"precondition: the PathBroken must have removed node 0's entry for node 1"
|
|
);
|
|
let warmup = |nodes: &[TestNode]| {
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.coords_warmup_remaining()
|
|
};
|
|
assert_eq!(warmup(&nodes), 1, "PathBroken resets the warmup budget");
|
|
|
|
let mismatch_before = nodes[1]
|
|
.node
|
|
.metrics()
|
|
.forwarding
|
|
.coord_warm_key_mismatch
|
|
.get();
|
|
let recv_before = nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.unwrap()
|
|
.traffic_counters()
|
|
.1;
|
|
nodes[0]
|
|
.node
|
|
.send_session_data(&node1_addr, 0, 0, b"after the cache miss")
|
|
.await
|
|
.expect("send_session_data failed");
|
|
// node 1 takes the frame, and node 0 then drops everything node 1 sent
|
|
// back. That includes the answer to the lookup the PathBroken started,
|
|
// which would both refill the cache and reset the warmup budget, and so
|
|
// hide whether the miss spent it.
|
|
process_available_packets(&mut nodes[1..]).await;
|
|
while nodes[0].packet_rx.try_recv().is_ok() {}
|
|
|
|
assert_eq!(
|
|
nodes[1]
|
|
.node
|
|
.metrics()
|
|
.forwarding
|
|
.coord_warm_key_mismatch
|
|
.get(),
|
|
mismatch_before,
|
|
"node 1 must not be sent node 0's coordinates as its own"
|
|
);
|
|
assert_ne!(
|
|
cached_path(&nodes[1], &node1_addr),
|
|
Some(addr_path(&node0_coords)),
|
|
"node 1 must not hold its own address under node 0's coordinates"
|
|
);
|
|
assert_eq!(
|
|
nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.unwrap()
|
|
.traffic_counters()
|
|
.1,
|
|
recv_before + 1,
|
|
"the frame must still be delivered"
|
|
);
|
|
assert_eq!(
|
|
warmup(&nodes),
|
|
1,
|
|
"a frame sent without coordinates must not spend the warmup budget"
|
|
);
|
|
|
|
// Node 0's cache is refilled without a discovery answer, so the only
|
|
// budget left to spend is the one the miss preserved. node 1's entry for
|
|
// node 0 is removed first, so the only way node 1 can learn node 0's
|
|
// coordinates again is from a frame that carries them.
|
|
let now_ms = wall_clock_ms();
|
|
nodes[0]
|
|
.node
|
|
.insert_coord_hint(node1_addr, node1_coords, now_ms);
|
|
nodes[1].node.coord_cache.remove(&node0_addr);
|
|
nodes[0]
|
|
.node
|
|
.send_session_data(&node1_addr, 0, 0, b"after the refill")
|
|
.await
|
|
.expect("send_session_data failed");
|
|
pump_until_quiet(&mut nodes).await;
|
|
|
|
assert_eq!(
|
|
cached_path(&nodes[1], &node0_addr),
|
|
Some(addr_path(&node0_coords)),
|
|
"the first frame after the refill must carry node 0's coordinates"
|
|
);
|
|
assert_eq!(
|
|
warmup(&nodes),
|
|
0,
|
|
"and it spends the warmup budget the miss preserved"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
/// A standalone CoordsWarmup to a destination with no cached coordinates
|
|
/// sends nothing: its only content would be this node's coordinates standing
|
|
/// in for the destination's.
|
|
#[tokio::test]
|
|
async fn test_a_coords_warmup_to_a_destination_with_no_cached_coordinates_sends_nothing() {
|
|
let mut nodes = make_warmup_pair(5).await;
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
|
|
nodes[0].node.coord_cache.remove(&node1_addr);
|
|
let warmup_before = nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.coords_warmup_remaining();
|
|
assert_eq!(
|
|
nodes[1].packet_rx.len(),
|
|
0,
|
|
"precondition: node 1's queue is empty"
|
|
);
|
|
|
|
nodes[0]
|
|
.node
|
|
.send_coords_warmup(&node1_addr)
|
|
.await
|
|
.expect("a skipped warmup is not an error");
|
|
|
|
assert_eq!(
|
|
nodes[1].packet_rx.len(),
|
|
0,
|
|
"no CoordsWarmup may be sent without the destination's coordinates"
|
|
);
|
|
assert_eq!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.coords_warmup_remaining(),
|
|
warmup_before,
|
|
"the warmup budget must not move"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
/// A rekey SessionSetup to a direct peer whose cache entry is gone carries
|
|
/// the peer's own announced coordinates, not this node's.
|
|
#[tokio::test]
|
|
async fn test_a_rekey_setup_to_a_peer_with_no_cached_coordinates_carries_the_peers_announced_ones()
|
|
{
|
|
// node 0 rekeys after one message; node 1 never initiates.
|
|
let mut cfg0 = Config::new();
|
|
cfg0.node.rekey.after_messages = 1;
|
|
let mut cfg1 = Config::new();
|
|
cfg1.node.rekey.after_messages = u64::MAX;
|
|
cfg1.node.rekey.after_secs = u64::MAX;
|
|
let mut nodes = run_tree_test_with_configs(vec![cfg0, cfg1], &[(0, 1)]).await;
|
|
verify_tree_convergence(&nodes);
|
|
populate_all_coord_caches(&mut nodes);
|
|
establish_pair_session(&mut nodes).await;
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
let node1_coords = nodes[1].node.tree_state().my_coords().clone();
|
|
assert_eq!(
|
|
nodes[0].node.tree_state().peer_coords(&node1_addr),
|
|
Some(&node1_coords),
|
|
"precondition: node 0 knows node 1's announced coordinates"
|
|
);
|
|
|
|
// One frame crosses node 0's rekey trigger, sent while the cache still
|
|
// holds node 1, so only the rekey setup can meet the miss.
|
|
nodes[0]
|
|
.node
|
|
.send_session_data(&node1_addr, 0, 0, b"before the rekey")
|
|
.await
|
|
.expect("send_session_data failed");
|
|
pump_until_quiet(&mut nodes).await;
|
|
|
|
// node 1's entry for its own address goes too, so what it holds after
|
|
// the rekey can only have come from the setup.
|
|
nodes[0].node.coord_cache.remove(&node1_addr);
|
|
nodes[1].node.coord_cache.remove(&node1_addr);
|
|
let mismatch_before = nodes[1]
|
|
.node
|
|
.metrics()
|
|
.forwarding
|
|
.coord_warm_key_mismatch
|
|
.get();
|
|
|
|
nodes[0].node.check_session_rekey().await;
|
|
for _ in 0..3 {
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
}
|
|
|
|
assert_eq!(
|
|
nodes[1]
|
|
.node
|
|
.metrics()
|
|
.forwarding
|
|
.coord_warm_key_mismatch
|
|
.get(),
|
|
mismatch_before,
|
|
"the rekey setup must not name node 0's coordinates as node 1's"
|
|
);
|
|
assert_eq!(
|
|
cached_path(&nodes[1], &node1_addr),
|
|
Some(addr_path(&node1_coords)),
|
|
"the setup's destination coordinates must be node 1's own"
|
|
);
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.pending_new_session()
|
|
.is_some(),
|
|
"the rekey must complete at node 0"
|
|
);
|
|
assert!(
|
|
nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.unwrap()
|
|
.pending_new_session()
|
|
.is_some(),
|
|
"and at node 1"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
/// A transit router must not end up holding the destination under the
|
|
/// source's coordinates after the source's cache entry for it is gone.
|
|
///
|
|
/// Not a red-first test: a source routes to a destination that is not a
|
|
/// direct peer only through its coordinate cache, so on a miss there is no
|
|
/// next hop and no frame reaches the transit router at all. This constructs
|
|
/// that rather than leaving it to a reading of the routing code.
|
|
#[tokio::test]
|
|
async fn test_a_transit_router_does_not_learn_the_source_coordinates_as_the_destinations() {
|
|
// Only A sends coordinates, so nothing but a frame from A can re-warm
|
|
// A's entry for B once it is removed.
|
|
let configs = (0..3)
|
|
.map(|i| {
|
|
let mut config = Config::new();
|
|
config.node.rekey.enabled = false;
|
|
if i != 0 {
|
|
config.node.session.coords_warmup_packets = 0;
|
|
}
|
|
config
|
|
})
|
|
.collect();
|
|
let mut nodes = run_tree_test_with_configs(configs, &[(0, 1), (1, 2)]).await;
|
|
verify_tree_convergence(&nodes);
|
|
populate_all_coord_caches(&mut nodes);
|
|
|
|
let a_addr = *nodes[0].node.node_addr();
|
|
let t_addr = *nodes[1].node.node_addr();
|
|
let b_addr = *nodes[2].node.node_addr();
|
|
let a_coords = nodes[0].node.tree_state().my_coords().clone();
|
|
let b_pubkey = nodes[2].node.identity().pubkey_full();
|
|
|
|
nodes[0]
|
|
.node
|
|
.initiate_session(b_addr, b_pubkey)
|
|
.await
|
|
.expect("initiate_session failed");
|
|
pump_until_quiet(&mut nodes).await;
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&b_addr)
|
|
.is_some_and(|e| e.is_established()),
|
|
"precondition: A and B must hold an established session"
|
|
);
|
|
assert!(
|
|
nodes[2]
|
|
.node
|
|
.get_session(&a_addr)
|
|
.is_some_and(|e| e.is_established()),
|
|
"precondition: B must hold the session too"
|
|
);
|
|
|
|
// T reports the path to B broken, and A's entry for B goes.
|
|
deliver_path_broken(&mut nodes, 0, b_addr, t_addr).await;
|
|
assert!(
|
|
nodes[0]
|
|
.node
|
|
.coord_cache()
|
|
.get(&b_addr, wall_clock_ms())
|
|
.is_none(),
|
|
"precondition: A's entry for B must be gone"
|
|
);
|
|
|
|
let mismatch_before = nodes[1]
|
|
.node
|
|
.metrics()
|
|
.forwarding
|
|
.coord_warm_key_mismatch
|
|
.get();
|
|
let sent = nodes[0]
|
|
.node
|
|
.send_session_data(&b_addr, 0, 0, b"after the cache miss")
|
|
.await;
|
|
pump_until_quiet(&mut nodes).await;
|
|
|
|
assert_ne!(
|
|
cached_path(&nodes[1], &b_addr),
|
|
Some(addr_path(&a_coords)),
|
|
"T must not hold B under A's coordinates"
|
|
);
|
|
assert_eq!(
|
|
nodes[1]
|
|
.node
|
|
.metrics()
|
|
.forwarding
|
|
.coord_warm_key_mismatch
|
|
.get(),
|
|
mismatch_before,
|
|
"T must not be sent a coordinate filed under the wrong address"
|
|
);
|
|
assert!(
|
|
sent.is_err(),
|
|
"with no coordinates cached for B, A has no route to it, which is why \
|
|
no transit router can see the frame"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Tick-loop maintenance with periodic rekey disabled
|
|
// ============================================================================
|
|
|
|
/// Build a node with the given periodic-rekey setting holding one established
|
|
/// session with `peer`, returning the node and the peer's address.
|
|
fn make_node_with_established_peer(
|
|
rekey_enabled: bool,
|
|
peer: &Identity,
|
|
) -> (Node, crate::NodeAddr) {
|
|
let mut config = Config::new();
|
|
config.node.rekey.enabled = rekey_enabled;
|
|
let mut node = make_node_with(config);
|
|
let peer_addr = install_established_peer(&mut node, peer);
|
|
(node, peer_addr)
|
|
}
|
|
|
|
/// Install one established session with `peer` on an existing node.
|
|
///
|
|
/// Split out of `make_node_with_established_peer` for the tests that must
|
|
/// choose the peer identity relative to the node's own address, which needs
|
|
/// the node to exist first.
|
|
fn install_established_peer(node: &mut Node, peer: &Identity) -> crate::NodeAddr {
|
|
let peer_addr = *peer.node_addr();
|
|
|
|
let session = make_noise_session(node.identity(), peer);
|
|
let mut entry = crate::node::session::SessionEntry::new(
|
|
peer_addr,
|
|
peer.pubkey_full(),
|
|
EndToEndState::Established(session),
|
|
1000,
|
|
true,
|
|
);
|
|
entry.mark_established(1000);
|
|
node.sessions.insert(peer_addr, entry);
|
|
peer_addr
|
|
}
|
|
|
|
/// Generate an identity whose address sorts strictly above `node_addr`.
|
|
///
|
|
/// The dual-initiation tie-break compares the two addresses directly, so a
|
|
/// test that wants a specific side of it has to pick the peer to match.
|
|
/// Roughly two draws on average, as with `generate_odd_parity_identity`.
|
|
fn peer_identity_sorting_above(node_addr: &crate::NodeAddr) -> Identity {
|
|
loop {
|
|
let id = Identity::generate();
|
|
if id.node_addr() > node_addr {
|
|
return id;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Build the initiator-side XX handshake `initiate_session_rekey` would
|
|
/// leave on the entry, without needing a route to send its msg1 over.
|
|
///
|
|
/// XX msg1 is a bare ephemeral, so the peer's static is not needed to build
|
|
/// it and `peer` is unused here; the parameter is kept so the call sites read
|
|
/// the same as the handshake they stand in for.
|
|
fn our_rekey_initiator_handshake(node: &Node, _peer: &Identity) -> crate::noise::HandshakeState {
|
|
let mut handshake = crate::noise::HandshakeState::new_initiator(node.identity().keypair());
|
|
handshake.set_local_epoch([0x11; 8]);
|
|
handshake
|
|
.write_message_1()
|
|
.expect("our own msg1 must build");
|
|
handshake
|
|
}
|
|
|
|
/// Generate an identity whose address sorts strictly below `node_addr`.
|
|
fn peer_identity_sorting_below(node_addr: &crate::NodeAddr) -> Identity {
|
|
loop {
|
|
let id = Identity::generate();
|
|
if id.node_addr() < node_addr {
|
|
return id;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_setup_naming_a_peer_whose_address_sorts_above_ours_keeps_our_rekey_and_counts_the_tiebreak()
|
|
{
|
|
let mut config = Config::new();
|
|
config.node.rekey.enabled = false;
|
|
let mut node = make_node_with(config);
|
|
|
|
// Our address sorts smaller, so the tie-break keeps us as initiator.
|
|
let peer = peer_identity_sorting_above(node.node_addr());
|
|
let peer_addr = install_established_peer(&mut node, &peer);
|
|
|
|
// Our own rekey is in flight as initiator.
|
|
let our_handshake = our_rekey_initiator_handshake(&node, &peer);
|
|
node.sessions
|
|
.get_mut(&peer_addr)
|
|
.unwrap()
|
|
.set_rekey_state(our_handshake, true);
|
|
|
|
let forged = forge_setup_for(&node);
|
|
node.handle_session_payload(&peer_addr, &stub_link_peer(), &forged, 1280, false)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
node.stats().session.rekey_tiebreak,
|
|
1,
|
|
"winning the dual-initiation tie-break must be counted; its DEBUG line \
|
|
is invisible at the default log level"
|
|
);
|
|
assert_eq!(node.stats().session.rekey_yielded, 0);
|
|
assert_eq!(
|
|
node.stats().session.rekey_armed,
|
|
0,
|
|
"we won, so nothing may have been armed for the sender"
|
|
);
|
|
assert!(
|
|
node.sessions
|
|
.get(&peer_addr)
|
|
.unwrap()
|
|
.has_rekey_in_progress(),
|
|
"our own rekey must survive, which is the behaviour the counter reports"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_setup_naming_a_peer_whose_address_sorts_below_ours_yields_our_rekey_and_counts_it() {
|
|
let mut config = Config::new();
|
|
config.node.rekey.enabled = false;
|
|
let mut node = make_node_with(config);
|
|
|
|
// Our address sorts larger, so the tie-break makes us the responder.
|
|
let peer = peer_identity_sorting_below(node.node_addr());
|
|
let peer_addr = install_established_peer(&mut node, &peer);
|
|
|
|
let our_handshake = our_rekey_initiator_handshake(&node, &peer);
|
|
node.sessions
|
|
.get_mut(&peer_addr)
|
|
.unwrap()
|
|
.set_rekey_state(our_handshake, true);
|
|
|
|
let forged = forge_setup_for(&node);
|
|
node.handle_session_payload(&peer_addr, &stub_link_peer(), &forged, 1280, false)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
node.stats().session.rekey_yielded,
|
|
1,
|
|
"yielding our own rekey to an unauthenticated setup message must be \
|
|
counted; a sustained rate here is local key rotation being suppressed"
|
|
);
|
|
assert_eq!(node.stats().session.rekey_tiebreak, 0);
|
|
// The yield counter is recorded before the SessionAck send, so this
|
|
// assertion needs no routing. The two below depend on the send failing:
|
|
// a standalone node has no peers and an empty coord cache, so
|
|
// `send_session_datagram` returns and the responder arming never runs.
|
|
assert_eq!(
|
|
node.stats().session.rekey_armed,
|
|
0,
|
|
"no route, so the handler returns before arming the responder side"
|
|
);
|
|
assert!(
|
|
!node
|
|
.sessions
|
|
.get(&peer_addr)
|
|
.unwrap()
|
|
.has_rekey_in_progress(),
|
|
"our rekey was abandoned by the yield"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_a_setup_against_a_peer_armed_handshake_is_dropped_and_keeps_the_handshake_and_the_completed_epoch()
|
|
{
|
|
let mut config = Config::new();
|
|
config.node.rekey.enabled = false;
|
|
let mut node = make_node_with(config);
|
|
|
|
// Our address sorts larger, which is the end that used to yield: the
|
|
// tie-break would make us the responder to the second setup.
|
|
let peer = peer_identity_sorting_below(node.node_addr());
|
|
let peer_addr = install_established_peer(&mut node, &peer);
|
|
|
|
// What a first setup leaves: a handshake the sender armed, beside a
|
|
// completed epoch too stale for `pending_outranks` to veto, so only the
|
|
// armed handshake stands between a second setup and the arming path.
|
|
let stranger = Identity::generate();
|
|
let valid_msg3 =
|
|
arm_stranger_handshake_beside_stale_pending(&mut node, &peer_addr, &peer, &stranger);
|
|
assert!(
|
|
!node.sessions.get(&peer_addr).unwrap().is_rekey_initiator(),
|
|
"the state under test is a handshake we did not arm"
|
|
);
|
|
|
|
let forged = forge_setup_for(&node);
|
|
node.handle_session_payload(&peer_addr, &stub_link_peer(), &forged, 1280, false)
|
|
.await;
|
|
|
|
let stats = &node.stats().session;
|
|
assert_eq!(
|
|
stats.rekey_held, 1,
|
|
"a setup meeting a peer-armed handshake must be dropped and counted"
|
|
);
|
|
assert_eq!(
|
|
(stats.rekey_yielded, stats.rekey_tiebreak, stats.rekey_armed),
|
|
(0, 0, 0),
|
|
"the tie-break is for two initiators; nothing may yield or arm here"
|
|
);
|
|
let entry = node.sessions.get(&peer_addr).expect("session present");
|
|
assert!(
|
|
entry.pending_new_session().is_some() && entry.is_established(),
|
|
"the completed epoch and the running session must be left intact"
|
|
);
|
|
assert!(
|
|
entry.has_rekey_in_progress() && !entry.is_rekey_initiator(),
|
|
"the handshake the peer armed must survive the setup, since a peer \
|
|
that read our SessionAck needs it for its msg3"
|
|
);
|
|
|
|
// The kept handshake is the same one: its own msg3 still reads.
|
|
node.handle_session_payload(
|
|
&peer_addr,
|
|
&stub_link_peer(),
|
|
&SessionMsg3::new(valid_msg3).encode(),
|
|
1280,
|
|
false,
|
|
)
|
|
.await;
|
|
assert_eq!(node.stats().session.rekey_key_mismatch, 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_a_responder_handshake_with_no_peer_rekey_stamp_is_not_expired_by_the_tick_loop() {
|
|
let peer = Identity::generate();
|
|
let stranger = Identity::generate();
|
|
let (mut node, peer_addr) = make_node_with_established_peer(false, &peer);
|
|
|
|
// Arm a responder-side handshake but leave `last_peer_rekey_ms` at zero.
|
|
// The expiry predicate's `!= 0` conjunct is what stops that unstamped
|
|
// zero being read as an age of the whole Unix epoch. This pins a
|
|
// defence-in-depth guard: the state is unreachable in production, since
|
|
// the only responder arming stamps the field on the adjacent line.
|
|
let (responder, _msg3) = drive_xx_to_msg3(&stranger, node.identity());
|
|
node.sessions
|
|
.get_mut(&peer_addr)
|
|
.unwrap()
|
|
.set_rekey_state(responder, false);
|
|
assert_eq!(
|
|
node.sessions.get(&peer_addr).unwrap().last_peer_rekey_ms(),
|
|
0,
|
|
"test fixture must actually leave the stamp unset"
|
|
);
|
|
|
|
node.check_session_rekey().await;
|
|
|
|
assert!(
|
|
node.sessions
|
|
.get(&peer_addr)
|
|
.unwrap()
|
|
.has_rekey_in_progress(),
|
|
"an unstamped handshake must not be read as infinitely old"
|
|
);
|
|
assert_eq!(node.stats().session.rekey_expired, 0);
|
|
}
|
|
|
|
/// Wall-clock milliseconds, matching the clock the tick loop reads.
|
|
fn wall_clock_ms() -> u64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_millis() as u64
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_superseded_key_epoch_is_drained_with_rekey_disabled() {
|
|
let peer = Identity::generate();
|
|
let (mut node, peer_addr) = make_node_with_established_peer(false, &peer);
|
|
|
|
let old = make_noise_session(node.identity(), &peer);
|
|
node.sessions
|
|
.get_mut(&peer_addr)
|
|
.unwrap()
|
|
.set_previous_session_for_test(old, 1000);
|
|
assert!(node.sessions.get(&peer_addr).unwrap().is_draining());
|
|
|
|
node.check_session_rekey().await;
|
|
|
|
assert!(
|
|
!node.sessions.get(&peer_addr).unwrap().is_draining(),
|
|
"a drain window that expired long ago must be completed even when \
|
|
periodic rekey is disabled"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_abandoned_peer_armed_rekey_expires_with_rekey_disabled() {
|
|
let peer = Identity::generate();
|
|
let attacker = Identity::generate();
|
|
let (mut node, peer_addr) = make_node_with_established_peer(false, &peer);
|
|
|
|
// A setup message armed a responder-side handshake whose msg3 never came.
|
|
let (responder, _msg3) = drive_xx_to_msg3(&attacker, node.identity());
|
|
let now_ms = wall_clock_ms();
|
|
let entry = node.sessions.get_mut(&peer_addr).unwrap();
|
|
entry.set_rekey_state(responder, false);
|
|
entry.record_peer_rekey(now_ms - 31_000);
|
|
|
|
node.check_session_rekey().await;
|
|
|
|
let entry = node.sessions.get(&peer_addr).unwrap();
|
|
assert!(
|
|
!entry.has_rekey_in_progress(),
|
|
"rekey state armed by a peer that never sent msg3 must not persist \
|
|
for the life of the session"
|
|
);
|
|
assert!(
|
|
entry.is_established(),
|
|
"expiring the abandoned handshake must leave the session intact"
|
|
);
|
|
assert_eq!(
|
|
node.stats().session.rekey_expired,
|
|
1,
|
|
"the expired handshake must be counted as a handshake timeout"
|
|
);
|
|
assert_eq!(
|
|
node.stats().session.pending_replaced,
|
|
0,
|
|
"no pending session was replaced, so that counter must not move"
|
|
);
|
|
}
|
|
|
|
/// Forge a SessionSetup addressed to `node` from an unrelated identity,
|
|
/// as an off-path sender naming an established peer would send it.
|
|
fn forge_setup_for(node: &Node) -> Vec<u8> {
|
|
use crate::noise::HandshakeState;
|
|
use crate::proto::fsp::SessionSetup;
|
|
|
|
let stranger = Identity::generate();
|
|
let mut handshake = HandshakeState::new_initiator(stranger.keypair());
|
|
handshake.set_local_epoch([0x5A; 8]);
|
|
let msg1 = handshake
|
|
.write_message_1()
|
|
.expect("stranger msg1 must build");
|
|
|
|
let coords = node.tree_state().my_coords().clone();
|
|
SessionSetup::new(coords.clone(), coords)
|
|
.with_handshake(msg1)
|
|
.encode()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_setup_naming_peer_with_pending_session_is_dropped_and_counted() {
|
|
let peer = Identity::generate();
|
|
let (mut node, peer_addr) = make_node_with_established_peer(false, &peer);
|
|
|
|
// A completed rekey is already waiting for the peer to cut over.
|
|
let pending = make_noise_session(node.identity(), &peer);
|
|
node.sessions
|
|
.get_mut(&peer_addr)
|
|
.unwrap()
|
|
.set_pending_session(pending);
|
|
|
|
let forged = forge_setup_for(&node);
|
|
node.handle_session_payload(&peer_addr, &stub_link_peer(), &forged, 1280, false)
|
|
.await;
|
|
|
|
let entry = node.sessions.get(&peer_addr).unwrap();
|
|
assert!(
|
|
!entry.has_rekey_in_progress(),
|
|
"a setup message must not arm a second handshake while a pending \
|
|
session is still waiting for cutover"
|
|
);
|
|
assert!(
|
|
entry.pending_new_session().is_some(),
|
|
"the pending session must survive the dropped setup message"
|
|
);
|
|
assert_eq!(
|
|
node.stats().session.rekey_pending,
|
|
1,
|
|
"the dropped setup message must be counted, since its DEBUG line is \
|
|
invisible at the default log level"
|
|
);
|
|
assert_eq!(
|
|
node.stats().session.rekey_armed,
|
|
0,
|
|
"nothing was armed, so the arming counter must not move"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_setup_never_discards_a_pending_session_when_our_address_loses_the_tiebreak() {
|
|
// The pre-port branch ran the dual-initiation tie-break on `has_pending`
|
|
// as well as `rekey_in_progress`, and called `abandon_rekey()` when this
|
|
// side's address sorted larger. That handed an unauthenticated setup
|
|
// message the power to discard a completed epoch the peer may already
|
|
// have moved to, on roughly half of all peer pairs. Rank the completed
|
|
// epoch above the setup message instead, on every pair.
|
|
// Draw node identities until this node's address sorts larger than the
|
|
// peer's, so the arm the pre-port code would have taken is the losing one.
|
|
let peer = Identity::generate();
|
|
let (mut node, peer_addr) = loop {
|
|
let (node, addr) = make_node_with_established_peer(false, &peer);
|
|
if !crate::proto::fsp::initiation_winner(node.identity().node_addr(), &addr) {
|
|
break (node, addr);
|
|
}
|
|
};
|
|
|
|
let pending = make_noise_session(node.identity(), &peer);
|
|
node.sessions
|
|
.get_mut(&peer_addr)
|
|
.unwrap()
|
|
.set_pending_session(pending);
|
|
|
|
let forged = forge_setup_for(&node);
|
|
node.handle_session_payload(&peer_addr, &stub_link_peer(), &forged, 1280, false)
|
|
.await;
|
|
|
|
let entry = node.sessions.get(&peer_addr).unwrap();
|
|
assert!(
|
|
entry.pending_new_session().is_some(),
|
|
"losing the address tie-break must not let an unauthenticated setup \
|
|
discard the epoch the peer may already have cut over to"
|
|
);
|
|
assert!(
|
|
!entry.has_rekey_in_progress(),
|
|
"a fresh pending session outranks the setup message, so nothing is armed"
|
|
);
|
|
assert_eq!(
|
|
node.stats().session.rekey_pending,
|
|
1,
|
|
"the refusal must be counted through the pending guard, not the \
|
|
tie-break"
|
|
);
|
|
assert_eq!(
|
|
node.stats().session.rekey_yielded,
|
|
0,
|
|
"no tie-break was lost: a pending session with no handshake beside it \
|
|
is no longer decided by address order"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_completed_peer_rekey_session_is_never_expired_by_the_tick_loop() {
|
|
let peer = Identity::generate();
|
|
let (mut node, peer_addr) = make_node_with_established_peer(false, &peer);
|
|
|
|
// A rekey the peer armed completed long ago, and the peer has not yet
|
|
// appeared on the new epoch. The keys are the epoch that peer cut over
|
|
// to, so no amount of waiting may discard them.
|
|
let pending = make_noise_session(node.identity(), &peer);
|
|
let idle_ms = node.config().node.session.idle_timeout_secs * 1000;
|
|
let now_ms = wall_clock_ms();
|
|
let entry = node.sessions.get_mut(&peer_addr).unwrap();
|
|
entry.set_pending_session(pending);
|
|
entry.set_rekey_completed_ms(now_ms - idle_ms - 60_000);
|
|
entry.record_peer_rekey(now_ms - idle_ms - 60_000);
|
|
|
|
for _ in 0..3 {
|
|
node.check_session_rekey().await;
|
|
}
|
|
|
|
let entry = node.sessions.get(&peer_addr).unwrap();
|
|
assert!(
|
|
entry.pending_new_session().is_some(),
|
|
"a completed rekey session must survive any wait for the peer's \
|
|
cutover: discarding it makes the peer's next frame undecryptable"
|
|
);
|
|
assert!(
|
|
entry.is_established(),
|
|
"the running session must be left intact alongside it"
|
|
);
|
|
assert_eq!(
|
|
node.stats().session.rekey_expired,
|
|
0,
|
|
"no armed handshake timed out, so that counter must not move"
|
|
);
|
|
assert_eq!(
|
|
node.stats().session.pending_replaced,
|
|
0,
|
|
"nothing replaced the pending session, so that counter must not move"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_expiring_an_armed_handshake_keeps_the_completed_session_beside_it() {
|
|
let peer = Identity::generate();
|
|
let (mut node, peer_addr) = make_node_with_established_peer(false, &peer);
|
|
|
|
// The peer's earlier rekey completed and is still waiting for its
|
|
// cutover; a later setup message armed a handshake whose msg3 never
|
|
// came. Expiring the handshake must not take the keys with it.
|
|
let pending = make_noise_session(node.identity(), &peer);
|
|
let (responder, _msg3) = drive_xx_to_msg3(&peer, node.identity());
|
|
let now_ms = wall_clock_ms();
|
|
let entry = node.sessions.get_mut(&peer_addr).unwrap();
|
|
entry.set_pending_session(pending);
|
|
entry.set_rekey_completed_ms(now_ms - 120_000);
|
|
entry.set_rekey_state(responder, false);
|
|
entry.record_peer_rekey(now_ms - 31_000);
|
|
|
|
node.check_session_rekey().await;
|
|
|
|
let entry = node.sessions.get(&peer_addr).unwrap();
|
|
assert!(
|
|
!entry.has_rekey_in_progress(),
|
|
"the armed handshake must still expire on the handshake timeout"
|
|
);
|
|
assert!(
|
|
entry.pending_new_session().is_some(),
|
|
"expiring the armed handshake must leave the completed session that \
|
|
the peer may already have cut over to"
|
|
);
|
|
assert_eq!(
|
|
node.stats().session.rekey_expired,
|
|
1,
|
|
"the expired handshake must be counted as a handshake timeout"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_fresh_peer_armed_rekey_is_not_expired() {
|
|
let peer = Identity::generate();
|
|
let (mut node, peer_addr) = make_node_with_established_peer(false, &peer);
|
|
|
|
let (responder, _msg3) = drive_xx_to_msg3(&peer, node.identity());
|
|
let now_ms = wall_clock_ms();
|
|
let entry = node.sessions.get_mut(&peer_addr).unwrap();
|
|
entry.set_rekey_state(responder, false);
|
|
entry.record_peer_rekey(now_ms);
|
|
|
|
node.check_session_rekey().await;
|
|
|
|
assert!(
|
|
node.sessions
|
|
.get(&peer_addr)
|
|
.unwrap()
|
|
.has_rekey_in_progress(),
|
|
"a handshake still within the timeout must not be expired out from \
|
|
under a peer whose msg3 is in flight"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Integration tests: a rekey this node initiated that is never answered
|
|
// ============================================================================
|
|
|
|
/// Build an established two-node pair, with the handshake timeout set to
|
|
/// `timeout_secs` on both nodes when given and left at its default otherwise.
|
|
///
|
|
/// `rekeys[i]` says whether node `i` rekeys after a single sent message;
|
|
/// otherwise it never starts a rekey of its own. The session is opened from
|
|
/// node 0, which says nothing about which node later initiates a rekey.
|
|
async fn rekey_pair(rekeys: [bool; 2], timeout_secs: Option<u64>) -> Vec<TestNode> {
|
|
let configs = rekeys
|
|
.iter()
|
|
.map(|&rekeys| {
|
|
let mut config = Config::new();
|
|
if let Some(secs) = timeout_secs {
|
|
config.node.rate_limit.handshake_timeout_secs = secs;
|
|
}
|
|
if rekeys {
|
|
config.node.rekey.after_messages = 1;
|
|
} else {
|
|
config.node.rekey.after_messages = u64::MAX;
|
|
config.node.rekey.after_secs = u64::MAX;
|
|
}
|
|
config
|
|
})
|
|
.collect();
|
|
let mut nodes = run_tree_test_with_configs(configs, &[(0, 1)]).await;
|
|
verify_tree_convergence(&nodes);
|
|
populate_all_coord_caches(&mut nodes);
|
|
establish_pair_session(&mut nodes).await;
|
|
nodes
|
|
}
|
|
|
|
/// Send one data frame from `nodes[from]` across its rekey trigger, deliver
|
|
/// it, then run `nodes[from]`'s tick so it sends a rekey SessionSetup, and
|
|
/// assert it now holds an initiated rekey.
|
|
async fn start_rekey(nodes: &mut [TestNode], from: usize) {
|
|
let peer = *nodes[1 - from].node.node_addr();
|
|
nodes[from]
|
|
.node
|
|
.send_session_data(&peer, 0, 0, b"crosses the rekey trigger")
|
|
.await
|
|
.expect("send_session_data failed");
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(nodes).await;
|
|
|
|
nodes[from].node.check_session_rekey().await;
|
|
assert!(
|
|
rekey_initiated(&nodes[from], &peer),
|
|
"node {from} must have initiated a rekey"
|
|
);
|
|
}
|
|
|
|
/// Whether `node` holds a rekey handshake toward `peer` that it initiated.
|
|
fn rekey_initiated(node: &TestNode, peer: &NodeAddr) -> bool {
|
|
node.node
|
|
.get_session(peer)
|
|
.is_some_and(|e| e.has_rekey_in_progress() && e.is_rekey_initiator())
|
|
}
|
|
|
|
/// Whether `node`'s session with `peer` holds a completed rekey session.
|
|
fn holds_pending(node: &TestNode, peer: &NodeAddr) -> bool {
|
|
node.node
|
|
.get_session(peer)
|
|
.is_some_and(|e| e.pending_new_session().is_some())
|
|
}
|
|
|
|
/// Discard every packet queued at `node` without processing it, as a lossy
|
|
/// link would, and return how many were dropped.
|
|
fn drop_queued(node: &mut TestNode) -> usize {
|
|
std::iter::from_fn(|| node.packet_rx.try_recv().ok()).count()
|
|
}
|
|
|
|
/// Run three delivery passes over every node.
|
|
async fn pump_all(nodes: &mut [TestNode]) {
|
|
for _ in 0..3 {
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(nodes).await;
|
|
}
|
|
}
|
|
|
|
/// A rekey whose SessionSetup was lost is retired once the handshake timeout
|
|
/// has passed, and the trigger then starts a fresh rekey that completes.
|
|
#[tokio::test]
|
|
async fn test_a_rekey_whose_session_setup_was_lost_is_retired_after_the_handshake_timeout_and_retried()
|
|
{
|
|
let mut nodes = rekey_pair([true, false], Some(1)).await;
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
|
|
start_rekey(&mut nodes, 0).await;
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
assert!(
|
|
drop_queued(&mut nodes[1]) > 0,
|
|
"node 0's SessionSetup must have been queued at node 1 to be lost"
|
|
);
|
|
assert_eq!(nodes[1].node.stats().session.rekey_armed, 0);
|
|
assert!(
|
|
!nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.unwrap()
|
|
.has_rekey_in_progress(),
|
|
"the setup must really have been dropped before node 1 armed"
|
|
);
|
|
|
|
tokio::time::sleep(Duration::from_millis(1200)).await;
|
|
nodes[0].node.check_session_rekey().await;
|
|
assert!(
|
|
!nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.has_rekey_in_progress(),
|
|
"an unanswered rekey this node initiated must be retired after the \
|
|
handshake timeout, or the trigger stays vetoed for good"
|
|
);
|
|
|
|
nodes[0].node.check_session_rekey().await;
|
|
assert!(
|
|
rekey_initiated(&nodes[0], &node1_addr),
|
|
"the trigger must start a fresh rekey once the old one is retired"
|
|
);
|
|
pump_all(&mut nodes).await;
|
|
assert_eq!(
|
|
nodes[1].node.stats().session.rekey_armed,
|
|
1,
|
|
"the retried setup must reach node 1"
|
|
);
|
|
assert!(
|
|
holds_pending(&nodes[0], &node1_addr) && holds_pending(&nodes[1], &node0_addr),
|
|
"the retried rekey must complete on both nodes"
|
|
);
|
|
assert_eq!(
|
|
nodes[0].node.stats().session.rekey_unanswered,
|
|
1,
|
|
"the retired handshake must be counted once"
|
|
);
|
|
assert_eq!(nodes[1].node.stats().session.rekey_unanswered, 0);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
/// A rekey whose SessionAck was lost is retired once the handshake timeout
|
|
/// has passed, beside the responder's own expiry, and the retry completes.
|
|
#[tokio::test]
|
|
async fn test_a_rekey_whose_session_ack_was_lost_is_retired_after_the_handshake_timeout_and_retried()
|
|
{
|
|
let mut nodes = rekey_pair([true, false], Some(1)).await;
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
|
|
start_rekey(&mut nodes, 0).await;
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes[1..]).await;
|
|
assert_eq!(
|
|
nodes[1].node.stats().session.rekey_armed,
|
|
1,
|
|
"node 1 must have armed as the rekey responder"
|
|
);
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
assert!(
|
|
drop_queued(&mut nodes[0]) > 0,
|
|
"node 1's SessionAck must have been queued at node 0 to be lost"
|
|
);
|
|
|
|
tokio::time::sleep(Duration::from_millis(1200)).await;
|
|
nodes[1].node.check_session_rekey().await;
|
|
assert_eq!(
|
|
nodes[1].node.stats().session.rekey_expired,
|
|
1,
|
|
"node 1's own handshake must expire on the existing responder rule"
|
|
);
|
|
assert!(
|
|
!nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.unwrap()
|
|
.has_rekey_in_progress()
|
|
);
|
|
|
|
nodes[0].node.check_session_rekey().await;
|
|
assert!(
|
|
!nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.has_rekey_in_progress(),
|
|
"an unanswered rekey this node initiated must be retired after the \
|
|
handshake timeout, or the trigger stays vetoed for good"
|
|
);
|
|
|
|
nodes[0].node.check_session_rekey().await;
|
|
assert!(
|
|
rekey_initiated(&nodes[0], &node1_addr),
|
|
"the trigger must start a fresh rekey once the old one is retired"
|
|
);
|
|
pump_all(&mut nodes).await;
|
|
assert_eq!(
|
|
nodes[1].node.stats().session.rekey_armed,
|
|
2,
|
|
"the retried setup must reach node 1"
|
|
);
|
|
assert!(
|
|
holds_pending(&nodes[0], &node1_addr) && holds_pending(&nodes[1], &node0_addr),
|
|
"the retried rekey must complete on both nodes"
|
|
);
|
|
assert_eq!(
|
|
nodes[0].node.stats().session.rekey_unanswered,
|
|
1,
|
|
"the retired handshake must be counted once"
|
|
);
|
|
assert_eq!(nodes[1].node.stats().session.rekey_unanswered, 0);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
/// Drive a lost SessionAck, then a retry that meets the responder's own
|
|
/// handshake from the first attempt, still armed because nothing has run the
|
|
/// responder's expiry yet.
|
|
///
|
|
/// Both nodes would rekey after one message; the initiator is picked at run
|
|
/// time so that the responder holds the smaller address when
|
|
/// `responder_smaller`, and the larger otherwise. Only the initiator's tick
|
|
/// is run before the responder has armed, after which the responder is
|
|
/// dampened and cannot start a rekey of its own inside the test.
|
|
///
|
|
/// A responder holding a handshake its peer armed drops the retry before
|
|
/// arming anything, at either address, since that handshake is the only one
|
|
/// a peer that read the SessionAck could finish. Both handshakes then
|
|
/// expire, and the next retry completes one timeout later.
|
|
async fn lostack_retry(responder_smaller: bool) {
|
|
let mut nodes = rekey_pair([true, true], Some(1)).await;
|
|
let node0_smaller =
|
|
crate::proto::fsp::initiation_winner(nodes[0].node.node_addr(), nodes[1].node.node_addr());
|
|
let resp = if responder_smaller == node0_smaller {
|
|
0
|
|
} else {
|
|
1
|
|
};
|
|
let init = 1 - resp;
|
|
let init_addr = *nodes[init].node.node_addr();
|
|
let resp_addr = *nodes[resp].node.node_addr();
|
|
|
|
// First attempt: the responder arms, and its SessionAck is lost.
|
|
start_rekey(&mut nodes, init).await;
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes[resp..=resp]).await;
|
|
assert_eq!(
|
|
nodes[resp].node.stats().session.rekey_armed,
|
|
1,
|
|
"the responder must have armed"
|
|
);
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
assert!(
|
|
drop_queued(&mut nodes[init]) > 0,
|
|
"the responder's SessionAck must have been queued to be lost"
|
|
);
|
|
|
|
tokio::time::sleep(Duration::from_millis(1200)).await;
|
|
nodes[init].node.check_session_rekey().await;
|
|
assert!(
|
|
!nodes[init]
|
|
.node
|
|
.get_session(&resp_addr)
|
|
.unwrap()
|
|
.has_rekey_in_progress(),
|
|
"an unanswered rekey this node initiated must be retired after the \
|
|
handshake timeout, or the trigger stays vetoed for good"
|
|
);
|
|
|
|
// The retry reaches a responder still holding the first handshake.
|
|
nodes[init].node.check_session_rekey().await;
|
|
assert!(
|
|
rekey_initiated(&nodes[init], &resp_addr),
|
|
"the trigger must start a fresh rekey once the old one is retired"
|
|
);
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes[resp..=resp]).await;
|
|
let stats = &nodes[resp].node.stats().session;
|
|
assert_eq!(
|
|
stats.rekey_held, 1,
|
|
"the retry must have met the responder's stale handshake"
|
|
);
|
|
assert_eq!(
|
|
(stats.rekey_tiebreak, stats.rekey_yielded),
|
|
(0, 0),
|
|
"a handshake the peer armed is not a dual initiation at either address"
|
|
);
|
|
pump_all(&mut nodes).await;
|
|
|
|
assert!(
|
|
!holds_pending(&nodes[init], &resp_addr) && !holds_pending(&nodes[resp], &init_addr),
|
|
"a retry the responder dropped completes nothing"
|
|
);
|
|
tokio::time::sleep(Duration::from_millis(1200)).await;
|
|
nodes[resp].node.check_session_rekey().await;
|
|
assert_eq!(
|
|
nodes[resp].node.stats().session.rekey_expired,
|
|
1,
|
|
"the responder's stale handshake must expire on its own rule"
|
|
);
|
|
nodes[init].node.check_session_rekey().await;
|
|
assert!(
|
|
!nodes[init]
|
|
.node
|
|
.get_session(&resp_addr)
|
|
.unwrap()
|
|
.has_rekey_in_progress(),
|
|
"the retry the responder dropped must itself be retired"
|
|
);
|
|
nodes[init].node.check_session_rekey().await;
|
|
assert!(
|
|
rekey_initiated(&nodes[init], &resp_addr),
|
|
"the trigger must start a second retry"
|
|
);
|
|
pump_all(&mut nodes).await;
|
|
|
|
assert!(
|
|
holds_pending(&nodes[init], &resp_addr) && holds_pending(&nodes[resp], &init_addr),
|
|
"the retried rekey must complete on both nodes"
|
|
);
|
|
// The first handshake and the retry the responder dropped.
|
|
assert_eq!(
|
|
nodes[init].node.stats().session.rekey_unanswered,
|
|
2,
|
|
"every retired handshake must be counted once"
|
|
);
|
|
assert_eq!(nodes[resp].node.stats().session.rekey_unanswered, 0);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
/// A retry dropped on the tie-break by a smaller responder still holding its
|
|
/// stale handshake completes once both handshakes have expired.
|
|
#[tokio::test]
|
|
async fn test_a_retry_dropped_by_a_smaller_responders_stale_handshake_completes_one_timeout_later()
|
|
{
|
|
lostack_retry(true).await;
|
|
}
|
|
|
|
/// A retry dropped by a larger responder still holding its stale handshake
|
|
/// completes once both handshakes have expired, as at a smaller one: the
|
|
/// larger end no longer yields a handshake its peer armed.
|
|
#[tokio::test]
|
|
async fn test_a_retry_dropped_by_a_larger_responders_stale_handshake_completes_one_timeout_later() {
|
|
lostack_retry(false).await;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Integration tests: a forgery inside a rekey responder's msg2-to-msg3 window
|
|
// ============================================================================
|
|
|
|
/// An unauthenticated message delivered to a rekey responder after the
|
|
/// initiator has read its SessionAck and before the initiator's msg3 arrives.
|
|
#[derive(Clone, Copy, Debug)]
|
|
enum WindowForgery {
|
|
/// Nothing is forged: the control the other two are measured against.
|
|
Nothing,
|
|
/// A SessionMsg3 of the right size whose first AEAD cannot open.
|
|
Msg3,
|
|
/// A SessionSetup carrying a stranger's ephemeral under the initiator's
|
|
/// address, delivered at the larger address, where the dual-initiation
|
|
/// tie-break would yield to it.
|
|
Setup,
|
|
}
|
|
|
|
/// What a rekey left behind once a forgery may have reached its window.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
struct WindowOutcome {
|
|
/// The responder completed the handshake on the initiator's genuine msg3.
|
|
responder_completed: bool,
|
|
/// The initiator cut over on its liveness timer.
|
|
initiator_cut_over: bool,
|
|
/// A frame the initiator sent after its cutover and after its whole msg3
|
|
/// resend budget decoded at the responder.
|
|
initiator_to_responder: bool,
|
|
/// A frame the responder sent decoded at the initiator.
|
|
responder_to_initiator: bool,
|
|
/// After the responder's own next rekey, frames decode both ways.
|
|
next_cycle_carries_both_ways: bool,
|
|
}
|
|
|
|
/// The outcome of a rekey nothing interfered with.
|
|
const WINDOW_HEALTHY: WindowOutcome = WindowOutcome {
|
|
responder_completed: true,
|
|
initiator_cut_over: true,
|
|
initiator_to_responder: true,
|
|
responder_to_initiator: true,
|
|
next_cycle_carries_both_ways: true,
|
|
};
|
|
|
|
/// Send one data frame from `nodes[from]` to `nodes[to]`, deliver it, and
|
|
/// report whether it decoded at `nodes[to]`.
|
|
async fn session_frame_decodes(nodes: &mut [TestNode], from: usize, to: usize) -> bool {
|
|
let from_addr = *nodes[from].node.node_addr();
|
|
let to_addr = *nodes[to].node.node_addr();
|
|
let received = |nodes: &[TestNode]| {
|
|
nodes[to]
|
|
.node
|
|
.get_session(&from_addr)
|
|
.expect("the session must survive")
|
|
.traffic_counters()
|
|
.1
|
|
};
|
|
let before = received(nodes);
|
|
nodes[from]
|
|
.node
|
|
.send_session_data(&to_addr, 0, 0, b"window forgery probe")
|
|
.await
|
|
.expect("send_session_data failed");
|
|
pump_all(nodes).await;
|
|
received(nodes) == before + 1
|
|
}
|
|
|
|
/// Cut `nodes[init]` over to the rekey it initiated by running its tick with
|
|
/// the liveness timer already elapsed, and report whether it did.
|
|
async fn cut_over_initiator(nodes: &mut [TestNode], init: usize, resp_addr: &NodeAddr) -> bool {
|
|
nodes[init]
|
|
.node
|
|
.sessions
|
|
.get_mut(resp_addr)
|
|
.unwrap()
|
|
.set_rekey_completed_ms(wall_clock_ms() - 10_000);
|
|
nodes[init].node.check_session_rekey().await;
|
|
let entry = nodes[init].node.get_session(resp_addr).unwrap();
|
|
entry.pending_new_session().is_none() && !entry.has_rekey_in_progress()
|
|
}
|
|
|
|
/// Drive a genuine FSP rekey to the point where the initiator has read the
|
|
/// SessionAck and derived the new session, deliver `forgery` to the
|
|
/// responder, then release the initiator's genuine msg3 and follow the cycle
|
|
/// through the initiator's cutover, its msg3 resend budget, and one data frame
|
|
/// each way. Then let the responder start the next rekey and check that it
|
|
/// carries frames both ways.
|
|
///
|
|
/// The responder is chosen at run time to hold the larger address, the end
|
|
/// at which the dual-initiation tie-break yields; a smaller responder wins it
|
|
/// and drops the forged setup regardless. The forged msg3 reaches the same
|
|
/// arm at either end.
|
|
///
|
|
/// Returns the outcome and a note of the responder's counters for the
|
|
/// failure message: msg3s refused for arriving with no handshake to read
|
|
/// them, and dual-initiation yields.
|
|
async fn rekey_with_window_forgery(forgery: WindowForgery) -> (WindowOutcome, String) {
|
|
use crate::transport::ReceivedPacket;
|
|
|
|
let mut nodes = rekey_pair([true, true], None).await;
|
|
let node0_smaller =
|
|
crate::proto::fsp::initiation_winner(nodes[0].node.node_addr(), nodes[1].node.node_addr());
|
|
let (init, resp) = if node0_smaller { (0, 1) } else { (1, 0) };
|
|
let init_addr = *nodes[init].node.node_addr();
|
|
let resp_addr = *nodes[resp].node.node_addr();
|
|
assert!(
|
|
!crate::proto::fsp::initiation_winner(&resp_addr, &init_addr),
|
|
"the responder must hold the larger address"
|
|
);
|
|
|
|
// The setup reaches the responder only; it arms and answers.
|
|
start_rekey(&mut nodes, init).await;
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes[resp..=resp]).await;
|
|
assert_eq!(
|
|
nodes[resp].node.stats().session.rekey_armed,
|
|
1,
|
|
"the responder must have armed"
|
|
);
|
|
|
|
// The SessionAck reaches the initiator only; it derives the new session
|
|
// and sends msg3, which waits in the responder's queue.
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes[init..=init]).await;
|
|
assert!(
|
|
holds_pending(&nodes[init], &resp_addr)
|
|
&& !nodes[init]
|
|
.node
|
|
.get_session(&resp_addr)
|
|
.unwrap()
|
|
.has_rekey_in_progress(),
|
|
"the initiator must have read the SessionAck and hold the new session"
|
|
);
|
|
assert!(
|
|
nodes[resp]
|
|
.node
|
|
.get_session(&init_addr)
|
|
.is_some_and(|e| e.has_rekey_in_progress() && !e.is_rekey_initiator()),
|
|
"the responder must still be waiting for msg3"
|
|
);
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
let held: Vec<ReceivedPacket> =
|
|
std::iter::from_fn(|| nodes[resp].packet_rx.try_recv().ok()).collect();
|
|
assert!(
|
|
!held.is_empty(),
|
|
"the initiator's msg3 must be queued at the responder"
|
|
);
|
|
|
|
match forgery {
|
|
WindowForgery::Nothing => {}
|
|
WindowForgery::Msg3 => {
|
|
let forged = SessionMsg3::new(vec![0u8; crate::noise::HANDSHAKE_MSG3_SIZE]).encode();
|
|
nodes[resp]
|
|
.node
|
|
.handle_session_payload(&init_addr, &init_addr, &forged, 1280, false)
|
|
.await;
|
|
}
|
|
WindowForgery::Setup => {
|
|
let forged = forge_setup_for(&nodes[resp].node);
|
|
nodes[resp]
|
|
.node
|
|
.handle_session_payload(&init_addr, &init_addr, &forged, 1280, false)
|
|
.await;
|
|
assert_eq!(
|
|
nodes[resp].node.stats().session.rekey_held,
|
|
1,
|
|
"the forged setup must have reached the armed handshake and \
|
|
been held off, or the test measures nothing"
|
|
);
|
|
}
|
|
}
|
|
|
|
// Release the genuine msg3.
|
|
for packet in held {
|
|
nodes[resp].node.handle_encrypted_frame(packet).await;
|
|
}
|
|
pump_all(&mut nodes).await;
|
|
let responder_completed = holds_pending(&nodes[resp], &init_addr);
|
|
|
|
// The initiator cuts over on its timer, then spends its msg3 resend
|
|
// budget: a resend is what recovers a msg3 that was merely lost.
|
|
let initiator_cut_over = cut_over_initiator(&mut nodes, init, &resp_addr).await;
|
|
let max_resends = nodes[init]
|
|
.node
|
|
.config()
|
|
.node
|
|
.rate_limit
|
|
.handshake_max_resends;
|
|
let base_ms = Node::now_ms();
|
|
for step in 1..=u64::from(max_resends) + 1 {
|
|
nodes[init]
|
|
.node
|
|
.resend_pending_session_msg3(base_ms + step * 64_000)
|
|
.await;
|
|
pump_all(&mut nodes).await;
|
|
}
|
|
|
|
let initiator_to_responder = session_frame_decodes(&mut nodes, init, resp).await;
|
|
let responder_to_initiator = session_frame_decodes(&mut nodes, resp, init).await;
|
|
|
|
// The responder's own next rekey, once past the dampening that the
|
|
// initiator's setup started. Its data frame above crossed its trigger.
|
|
nodes[resp]
|
|
.node
|
|
.sessions
|
|
.get_mut(&init_addr)
|
|
.unwrap()
|
|
.record_peer_rekey(wall_clock_ms() - 60_000);
|
|
nodes[resp].node.check_session_rekey().await;
|
|
assert!(
|
|
rekey_initiated(&nodes[resp], &init_addr),
|
|
"the responder must start the next rekey"
|
|
);
|
|
pump_all(&mut nodes).await;
|
|
let next_cycle_carries_both_ways = cut_over_initiator(&mut nodes, resp, &init_addr).await
|
|
&& session_frame_decodes(&mut nodes, resp, init).await
|
|
&& session_frame_decodes(&mut nodes, init, resp).await;
|
|
|
|
let stats = &nodes[resp].node.stats().session;
|
|
let note = format!(
|
|
"the responder refused {} msg3s and yielded {} times",
|
|
stats.bad_state, stats.rekey_yielded
|
|
);
|
|
cleanup_nodes(&mut nodes).await;
|
|
(
|
|
WindowOutcome {
|
|
responder_completed,
|
|
initiator_cut_over,
|
|
initiator_to_responder,
|
|
responder_to_initiator,
|
|
next_cycle_carries_both_ways,
|
|
},
|
|
note,
|
|
)
|
|
}
|
|
|
|
/// The control: with nothing forged, the procedure that measures the two
|
|
/// forgeries completes the rekey and carries frames both ways on both cycles.
|
|
#[tokio::test]
|
|
async fn a_rekey_with_nothing_forged_in_the_responders_window_completes_and_carries_traffic_both_ways()
|
|
{
|
|
let (outcome, note) = rekey_with_window_forgery(WindowForgery::Nothing).await;
|
|
assert_eq!(outcome, WINDOW_HEALTHY, "{note}");
|
|
}
|
|
|
|
/// A garbage msg3 that reaches a rekey responder after the initiator has read
|
|
/// the SessionAck must not cost the genuine msg3 its handshake.
|
|
#[tokio::test]
|
|
async fn a_forged_msg3_in_the_responders_window_does_not_split_the_rekey() {
|
|
let (outcome, note) = rekey_with_window_forgery(WindowForgery::Msg3).await;
|
|
assert_eq!(outcome, WINDOW_HEALTHY, "{note}");
|
|
}
|
|
|
|
/// A forged setup at a larger rekey responder, after the initiator has read
|
|
/// the SessionAck, must not cost the genuine msg3 its handshake.
|
|
#[tokio::test]
|
|
async fn a_forged_setup_at_the_larger_responder_in_its_window_does_not_split_the_rekey() {
|
|
let (outcome, note) = rekey_with_window_forgery(WindowForgery::Setup).await;
|
|
assert_eq!(outcome, WINDOW_HEALTHY, "{note}");
|
|
}
|
|
|
|
/// A forged SessionAck arriving midway through an unanswered rekey must not
|
|
/// restart its deadline: the rekey is retired on the timeout measured from
|
|
/// the setup this node sent.
|
|
#[tokio::test]
|
|
async fn test_forged_session_acks_do_not_hold_an_unanswered_rekey_open_past_its_deadline() {
|
|
let mut nodes = rekey_pair([true, false], Some(1)).await;
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
|
|
start_rekey(&mut nodes, 0).await;
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
assert!(
|
|
drop_queued(&mut nodes[1]) > 0,
|
|
"node 0's SessionSetup must have been queued at node 1 to be lost"
|
|
);
|
|
|
|
tokio::time::sleep(Duration::from_millis(600)).await;
|
|
let forged = forged_session_ack(&nodes[1]);
|
|
nodes[0]
|
|
.node
|
|
.handle_session_payload(&node1_addr, &node1_addr, &forged, 1280, false)
|
|
.await;
|
|
assert_eq!(nodes[0].node.stats().session.ack_handshake_failed, 1);
|
|
assert!(
|
|
rekey_initiated(&nodes[0], &node1_addr),
|
|
"the unreadable ack must have put the handshake back"
|
|
);
|
|
|
|
let forged_at = std::time::Instant::now();
|
|
tokio::time::sleep(Duration::from_millis(600)).await;
|
|
nodes[0].node.check_session_rekey().await;
|
|
println!(
|
|
"forged ack to check: {} ms",
|
|
forged_at.elapsed().as_millis()
|
|
);
|
|
assert!(
|
|
!nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.has_rekey_in_progress(),
|
|
"a forged SessionAck must not restart the deadline of the rekey this \
|
|
node initiated"
|
|
);
|
|
assert_eq!(
|
|
nodes[0].node.stats().session.rekey_unanswered,
|
|
1,
|
|
"the retired handshake must be counted once"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
/// Putting a handshake back after an unreadable SessionAck leaves the stamp
|
|
/// its deadline runs from exactly where arming wrote it.
|
|
#[tokio::test]
|
|
async fn test_an_unreadable_session_ack_does_not_push_out_the_rekey_deadline() {
|
|
let mut nodes = rekey_pair([true, false], None).await;
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
|
|
start_rekey(&mut nodes, 0).await;
|
|
let armed_at = nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.initiated_ms();
|
|
assert_ne!(armed_at, 0, "arming must stamp the deadline");
|
|
|
|
tokio::time::sleep(Duration::from_millis(5)).await;
|
|
let forged = forged_session_ack(&nodes[1]);
|
|
nodes[0]
|
|
.node
|
|
.handle_session_payload(&node1_addr, &node1_addr, &forged, 1280, false)
|
|
.await;
|
|
assert_eq!(nodes[0].node.stats().session.ack_handshake_failed, 1);
|
|
assert!(
|
|
rekey_initiated(&nodes[0], &node1_addr),
|
|
"the unreadable ack must have put the handshake back"
|
|
);
|
|
assert_eq!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.initiated_ms(),
|
|
armed_at,
|
|
"the restore must not restamp the deadline"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
/// Putting a handshake back after a SessionAck made under a stranger's
|
|
/// static key leaves the stamp its deadline runs from exactly where arming
|
|
/// wrote it.
|
|
#[tokio::test]
|
|
async fn a_rekey_ack_under_a_strangers_key_does_not_push_out_the_rekey_deadline() {
|
|
let mut nodes = make_rekey_disabled_pair().await;
|
|
establish_pair_session(&mut nodes).await;
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
|
|
let msg1 = arm_rekey(&mut nodes[0].node, &node1_addr);
|
|
let armed_at = nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.initiated_ms();
|
|
assert_ne!(armed_at, 0, "arming must stamp the deadline");
|
|
|
|
tokio::time::sleep(Duration::from_millis(5)).await;
|
|
let forged = rekey_ack(&msg1, &Identity::generate(), &nodes[1].node);
|
|
nodes[0]
|
|
.node
|
|
.handle_session_payload(&node1_addr, &node1_addr, &forged, 1280, false)
|
|
.await;
|
|
assert_eq!(
|
|
nodes[0].node.stats().session.rekey_key_mismatch,
|
|
1,
|
|
"the stranger's ack must be refused as a key mismatch"
|
|
);
|
|
assert!(
|
|
rekey_initiated(&nodes[0], &node1_addr),
|
|
"the refused ack must have put the handshake back"
|
|
);
|
|
assert_eq!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.initiated_ms(),
|
|
armed_at,
|
|
"the restore must not restamp the deadline"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
/// A SessionAck under a stranger's static key arriving midway through an
|
|
/// unanswered rekey must not restart its deadline: the rekey is retired on
|
|
/// the timeout measured from when it was armed.
|
|
///
|
|
/// No rekey trigger can fire on either node, so the only rekey in flight is
|
|
/// the one armed here.
|
|
#[tokio::test]
|
|
async fn stranger_keyed_rekey_acks_do_not_hold_an_unanswered_rekey_open_past_its_deadline() {
|
|
let mut nodes = rekey_pair([false, false], Some(1)).await;
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
|
|
let msg1 = arm_rekey(&mut nodes[0].node, &node1_addr);
|
|
|
|
tokio::time::sleep(Duration::from_millis(600)).await;
|
|
let forged = rekey_ack(&msg1, &Identity::generate(), &nodes[1].node);
|
|
nodes[0]
|
|
.node
|
|
.handle_session_payload(&node1_addr, &node1_addr, &forged, 1280, false)
|
|
.await;
|
|
assert_eq!(
|
|
nodes[0].node.stats().session.rekey_key_mismatch,
|
|
1,
|
|
"the stranger's ack must be refused as a key mismatch"
|
|
);
|
|
assert!(
|
|
rekey_initiated(&nodes[0], &node1_addr),
|
|
"the refused ack must have put the handshake back"
|
|
);
|
|
|
|
let forged_at = std::time::Instant::now();
|
|
tokio::time::sleep(Duration::from_millis(600)).await;
|
|
nodes[0].node.check_session_rekey().await;
|
|
println!(
|
|
"stranger ack to check: {} ms",
|
|
forged_at.elapsed().as_millis()
|
|
);
|
|
assert!(
|
|
!nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.has_rekey_in_progress(),
|
|
"an ack under a stranger's key must not restart the deadline of the \
|
|
rekey this node initiated"
|
|
);
|
|
assert_eq!(
|
|
nodes[0].node.stats().session.rekey_unanswered,
|
|
1,
|
|
"the retired handshake must be counted once"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
/// A fresh rekey this node initiated is not retired because the peer's own
|
|
/// last rekey is older than the handshake timeout.
|
|
#[tokio::test]
|
|
async fn test_a_fresh_initiated_rekey_is_not_retired_on_the_peers_older_rekey_stamp() {
|
|
let mut nodes = rekey_pair([true, false], None).await;
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
|
|
start_rekey(&mut nodes, 0).await;
|
|
// A peer rekey long past both the handshake timeout and the dampening.
|
|
nodes[0]
|
|
.node
|
|
.sessions
|
|
.get_mut(&node1_addr)
|
|
.unwrap()
|
|
.record_peer_rekey(wall_clock_ms() - 60_000);
|
|
let armed_at = nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.initiated_ms();
|
|
|
|
nodes[0].node.check_session_rekey().await;
|
|
|
|
assert!(
|
|
rekey_initiated(&nodes[0], &node1_addr),
|
|
"a fresh rekey this node initiated must not be retired on the peer's clock"
|
|
);
|
|
assert_eq!(
|
|
nodes[0]
|
|
.node
|
|
.get_session(&node1_addr)
|
|
.unwrap()
|
|
.initiated_ms(),
|
|
armed_at,
|
|
"the rekey must be the same one, not retired and re-armed"
|
|
);
|
|
let stats = &nodes[0].node.stats().session;
|
|
assert_eq!(stats.rekey_expired, 0);
|
|
assert_eq!(stats.rekey_unanswered, 0);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Integration tests: a peer's cutover after a long silence
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_silent_peers_cutover_still_lands_after_the_idle_timeout_has_passed() {
|
|
// Both nodes rekey after a single message, so one data frame drives a
|
|
// full FSP rekey cycle with node 0 as initiator.
|
|
let configs = (0..2)
|
|
.map(|_| {
|
|
let mut config = Config::new();
|
|
config.node.rekey.after_messages = 1;
|
|
config
|
|
})
|
|
.collect();
|
|
let mut nodes = run_tree_test_with_configs(configs, &[(0, 1)]).await;
|
|
verify_tree_convergence(&nodes);
|
|
populate_all_coord_caches(&mut nodes);
|
|
establish_pair_session(&mut nodes).await;
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
|
|
// One frame arms node 0's rekey trigger; the cycle then runs to
|
|
// completion, leaving node 1 holding the new epoch as `pending`.
|
|
nodes[0]
|
|
.node
|
|
.send_session_data(&node1_addr, 0, 0, b"before the rekey")
|
|
.await
|
|
.expect("send_session_data failed");
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
|
|
nodes[0].node.check_session_rekey().await;
|
|
for _ in 0..3 {
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
}
|
|
assert!(
|
|
nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.expect("responder entry present")
|
|
.pending_new_session()
|
|
.is_some(),
|
|
"the rekey cycle must have left node 1 holding a pending session"
|
|
);
|
|
|
|
// Node 0 emits nothing for longer than the idle timeout — with MMP in
|
|
// minimal mode and traffic flowing one way, nothing authenticates
|
|
// against node 1's pending slot in that time.
|
|
let now_ms = wall_clock_ms();
|
|
let idle_ms = nodes[1].node.config().node.session.idle_timeout_secs * 1000;
|
|
let stamp = now_ms - idle_ms - 10_000;
|
|
nodes[1]
|
|
.node
|
|
.sessions
|
|
.get_mut(&node0_addr)
|
|
.unwrap()
|
|
.set_rekey_completed_ms(stamp);
|
|
nodes[1]
|
|
.node
|
|
.sessions
|
|
.get_mut(&node0_addr)
|
|
.unwrap()
|
|
.record_peer_rekey(stamp);
|
|
for _ in 0..3 {
|
|
nodes[1].node.check_session_rekey().await;
|
|
}
|
|
|
|
// Node 0 now cuts over on its own liveness timer and speaks again.
|
|
nodes[0]
|
|
.node
|
|
.sessions
|
|
.get_mut(&node1_addr)
|
|
.unwrap()
|
|
.set_rekey_completed_ms(now_ms - 10_000);
|
|
nodes[0].node.check_session_rekey().await;
|
|
let recv_before = nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.unwrap()
|
|
.traffic_counters()
|
|
.1;
|
|
|
|
nodes[0]
|
|
.node
|
|
.send_session_data(&node1_addr, 0, 0, b"after the long silence")
|
|
.await
|
|
.expect("send_session_data failed");
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
|
|
let entry = nodes[1].node.get_session(&node0_addr).unwrap();
|
|
assert_eq!(
|
|
entry.traffic_counters().1,
|
|
recv_before + 1,
|
|
"the peer's first frame on the epoch it cut over to must still \
|
|
decrypt: received {} packets before the frame, {} after",
|
|
recv_before,
|
|
entry.traffic_counters().1
|
|
);
|
|
assert!(
|
|
entry.pending_new_session().is_none(),
|
|
"that frame must also complete node 1's cutover"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_peer_restart_reestablishes_through_a_pending_session_that_waited_out_the_timeout() {
|
|
// One-second idle timeout, and a rekey after a single message, so a
|
|
// genuine rekey cycle leaves a pending session that ages out of the
|
|
// veto within the test rather than after a minute and a half.
|
|
let configs = (0..2)
|
|
.map(|_| {
|
|
let mut config = Config::new();
|
|
config.node.rekey.after_messages = 1;
|
|
config.node.session.idle_timeout_secs = 1;
|
|
config
|
|
})
|
|
.collect();
|
|
let mut nodes = run_tree_test_with_configs(configs, &[(0, 1)]).await;
|
|
verify_tree_convergence(&nodes);
|
|
populate_all_coord_caches(&mut nodes);
|
|
establish_pair_session(&mut nodes).await;
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
let node1_pubkey = nodes[1].node.identity().pubkey_full();
|
|
|
|
// A real rekey cycle leaves node 1 holding a completed session whose
|
|
// cutover never comes, stamped by the handler that completed it.
|
|
nodes[0]
|
|
.node
|
|
.send_session_data(&node1_addr, 0, 0, b"before the rekey")
|
|
.await
|
|
.expect("send_session_data failed");
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
nodes[0].node.check_session_rekey().await;
|
|
for _ in 0..3 {
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
}
|
|
assert!(
|
|
nodes[1]
|
|
.node
|
|
.get_session(&node0_addr)
|
|
.expect("responder entry present")
|
|
.pending_new_session()
|
|
.is_some(),
|
|
"the rekey cycle must have left node 1 holding a pending session"
|
|
);
|
|
tokio::time::sleep(Duration::from_millis(1200)).await;
|
|
|
|
// Node 0 restarts and re-initiates. Its setup message must not be
|
|
// refused indefinitely on account of that pending session, or node 1's
|
|
// own sends keep the session alive and the peer is locked out for good.
|
|
nodes[0].node.remove_session(&node1_addr);
|
|
nodes[0]
|
|
.node
|
|
.initiate_session(node1_addr, node1_pubkey)
|
|
.await
|
|
.expect("re-initiate_session failed");
|
|
for _ in 0..3 {
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
}
|
|
|
|
assert_eq!(
|
|
nodes[1].node.stats().session.rekey_pending,
|
|
0,
|
|
"a pending session that has waited out the idle timeout must stop \
|
|
vetoing the peer's setup message"
|
|
);
|
|
assert_eq!(
|
|
nodes[1].node.stats().session.pending_replaced,
|
|
1,
|
|
"the restarted peer's authenticated msg3 must be what replaces the \
|
|
waiting session, and the replacement must be counted"
|
|
);
|
|
|
|
nodes[0]
|
|
.node
|
|
.send_session_data(&node1_addr, 0, 0, b"after the restart")
|
|
.await
|
|
.expect("send_session_data failed");
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
|
|
let entry = nodes[1].node.get_session(&node0_addr).unwrap();
|
|
assert!(
|
|
entry.pending_new_session().is_none(),
|
|
"the restarted peer's first frame must complete the cutover"
|
|
);
|
|
assert!(
|
|
entry.traffic_counters().1 > 0,
|
|
"node 1 must have decrypted the restarted peer's frame"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Reactive MtuExceeded: corroboration against what this node actually sent
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn a_reactive_mtu_exceeded_at_the_floor_no_longer_pins_a_session_this_node_has_not_overfilled()
|
|
{
|
|
// The defect itself. A report of exactly the floor is a legal value, and
|
|
// the admission gate cannot tell an honest forwarder from anyone else, so
|
|
// one packet drove a bound session's path MTU to the floor and pinned the
|
|
// FipsAddress-keyed entry the SYN-time MSS clamp reads. Nothing this node
|
|
// sent could have overflowed a hop at that size, so no honest report of it
|
|
// exists.
|
|
let mut node = make_node();
|
|
|
|
let remote = Identity::generate();
|
|
install_established_session_with_mmp(&mut node, &remote);
|
|
let dest = *remote.node_addr();
|
|
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
|
let dest_fips = crate::FipsAddress::from_node_addr(&dest);
|
|
|
|
let before = node
|
|
.sessions
|
|
.get(&dest)
|
|
.and_then(|e| e.mmp())
|
|
.map(|m| m.path_mtu.current_mtu());
|
|
|
|
let inner =
|
|
build_mtu_exceeded_inner(&dest, &reporter, crate::upper::icmp::MIN_REACTIVE_PATH_MTU);
|
|
node.handle_mtu_exceeded(&reporter, &inner).await;
|
|
|
|
assert_eq!(
|
|
node.sessions
|
|
.get(&dest)
|
|
.and_then(|e| e.mmp())
|
|
.map(|m| m.path_mtu.current_mtu()),
|
|
before,
|
|
"an uncorroborated report must leave the session path MTU alone"
|
|
);
|
|
assert_eq!(
|
|
node.path_mtu_lookup_get(&dest_fips),
|
|
None,
|
|
"an uncorroborated report must leave no clamp entry behind"
|
|
);
|
|
assert_eq!(
|
|
node.metrics().errors.mtu_exceeded_uncorroborated.get(),
|
|
1,
|
|
"the refusal must be counted apart from the below-floor refusal"
|
|
);
|
|
assert_eq!(
|
|
node.metrics().errors.mtu_exceeded_below_floor.get(),
|
|
0,
|
|
"the floor is not what refused this; the value is exactly at it"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_initiating_session_refuses_an_uncorroborated_report_and_accepts_a_corroborated_one() {
|
|
// The lookup write is the effect that survives on an initiating session,
|
|
// which has no MMP state at all, so this branch needs its own coverage:
|
|
// a guard placed on the apply rather than ahead of it would miss it.
|
|
let mut node = make_node();
|
|
|
|
let remote = Identity::generate();
|
|
install_initiating(&mut node, &remote);
|
|
let dest = *remote.node_addr();
|
|
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
|
let dest_fips = crate::FipsAddress::from_node_addr(&dest);
|
|
|
|
let inner = build_mtu_exceeded_inner(&dest, &reporter, 800);
|
|
node.handle_mtu_exceeded(&reporter, &inner).await;
|
|
assert_eq!(
|
|
node.path_mtu_lookup_get(&dest_fips),
|
|
None,
|
|
"nothing this node sent could have overflowed a hop at 800 bytes"
|
|
);
|
|
|
|
// A SessionSetup can itself be the datagram that overflows a hop, so an
|
|
// initiating session must still be able to act on a real report.
|
|
note_sent_wire_len(&mut node, &dest, 1400);
|
|
node.handle_mtu_exceeded(&reporter, &inner).await;
|
|
assert_eq!(
|
|
node.path_mtu_lookup_get(&dest_fips),
|
|
Some(800),
|
|
"a report corroborated by an oversized send must still be applied"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_second_reactive_decrease_needs_its_own_corroborating_send() {
|
|
// The evidence is spent on the decrease it vouched for. Otherwise one
|
|
// large send early in a session would vouch for every forged report for
|
|
// the rest of that session's life.
|
|
let mut node = make_node();
|
|
|
|
let remote = Identity::generate();
|
|
install_established_session_with_mmp(&mut node, &remote);
|
|
let dest = *remote.node_addr();
|
|
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
|
|
|
note_sent_wire_len(&mut node, &dest, 1400);
|
|
let first = build_mtu_exceeded_inner(&dest, &reporter, 1200);
|
|
node.handle_mtu_exceeded(&reporter, &first).await;
|
|
assert_eq!(
|
|
node.sessions
|
|
.get(&dest)
|
|
.and_then(|e| e.mmp())
|
|
.map(|m| m.path_mtu.current_mtu()),
|
|
Some(1200),
|
|
"the corroborated first decrease is accepted"
|
|
);
|
|
|
|
let second = build_mtu_exceeded_inner(&dest, &reporter, 600);
|
|
node.handle_mtu_exceeded(&reporter, &second).await;
|
|
assert_eq!(
|
|
node.sessions
|
|
.get(&dest)
|
|
.and_then(|e| e.mmp())
|
|
.map(|m| m.path_mtu.current_mtu()),
|
|
Some(1200),
|
|
"a further decrease needs evidence of its own"
|
|
);
|
|
|
|
// A genuine re-route onto a smaller hop is preceded by a send that hop
|
|
// drops, so the honest sequence still converges.
|
|
note_sent_wire_len(&mut node, &dest, 900);
|
|
node.handle_mtu_exceeded(&reporter, &second).await;
|
|
assert_eq!(
|
|
node.sessions
|
|
.get(&dest)
|
|
.and_then(|e| e.mmp())
|
|
.map(|m| m.path_mtu.current_mtu()),
|
|
Some(600),
|
|
"once this node has again sent something that does not fit, the report applies"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_corroborated_report_below_the_reactive_floor_is_still_refused() {
|
|
// Corroboration and the floor are independent refusals. A hop that really
|
|
// is tiny still cannot drive the clamp into the band where the derived
|
|
// MSS degenerates.
|
|
let mut node = make_node();
|
|
|
|
let remote = Identity::generate();
|
|
install_established_session_with_mmp(&mut node, &remote);
|
|
let dest = *remote.node_addr();
|
|
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
|
let dest_fips = crate::FipsAddress::from_node_addr(&dest);
|
|
|
|
note_sent_wire_len(&mut node, &dest, 1400);
|
|
let inner = build_mtu_exceeded_inner(
|
|
&dest,
|
|
&reporter,
|
|
crate::upper::icmp::MIN_REACTIVE_PATH_MTU - 1,
|
|
);
|
|
node.handle_mtu_exceeded(&reporter, &inner).await;
|
|
|
|
assert_eq!(node.path_mtu_lookup_get(&dest_fips), None);
|
|
assert_eq!(node.metrics().errors.mtu_exceeded_below_floor.get(), 1);
|
|
assert_eq!(node.metrics().errors.mtu_exceeded_uncorroborated.get(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_authenticated_path_mtu_notification_still_applies_at_the_actionable_floor() {
|
|
// The reactive guards must not leak onto the carrier that arrives inside
|
|
// an established session on the decrypted path, which is authenticated and
|
|
// needs no corroboration.
|
|
let mut node = make_node();
|
|
|
|
let remote = Identity::generate();
|
|
install_established_session_with_mmp(&mut node, &remote);
|
|
let dest = *remote.node_addr();
|
|
|
|
let floor = crate::upper::icmp::MIN_ACTIONABLE_PATH_MTU;
|
|
let body = build_path_mtu_notification_body(floor);
|
|
node.handle_session_path_mtu_notification(&dest, &body);
|
|
|
|
assert_eq!(
|
|
node.sessions
|
|
.get(&dest)
|
|
.and_then(|e| e.mmp())
|
|
.map(|m| m.path_mtu.current_mtu()),
|
|
Some(floor),
|
|
"the authenticated carrier still applies a value at the actionable floor"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_path_broken_flood_releases_the_stored_path_mtu_only_once_per_interval() {
|
|
use crate::proto::routing::PathBroken;
|
|
|
|
// PathBroken is unauthenticated and its release discards a bottleneck this
|
|
// node learned the hard way. Unlimited, the claim can be repeated as fast
|
|
// as it can be sent, so a genuinely learned value never survives.
|
|
let mut node = make_node();
|
|
|
|
let remote = Identity::generate();
|
|
install_initiating(&mut node, &remote);
|
|
let dest = *remote.node_addr();
|
|
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
|
let dest_fips = crate::FipsAddress::from_node_addr(&dest);
|
|
|
|
let encoded = PathBroken::new(dest, reporter).encode();
|
|
let inner = &encoded[5..];
|
|
|
|
node.path_mtu_lookup_insert(dest_fips, 700);
|
|
node.handle_path_broken(&reporter, &reporter, inner).await;
|
|
assert_eq!(
|
|
node.path_mtu_lookup_get(&dest_fips),
|
|
None,
|
|
"the first PathBroken still releases"
|
|
);
|
|
|
|
node.path_mtu_lookup_insert(dest_fips, 700);
|
|
node.handle_path_broken(&reporter, &reporter, inner).await;
|
|
assert_eq!(
|
|
node.path_mtu_lookup_get(&dest_fips),
|
|
Some(700),
|
|
"a second release for the same destination inside the interval is refused"
|
|
);
|
|
}
|
|
|
|
/// Session data whose next hop's encrypt worker has exited is still
|
|
/// delivered, sealed on the main loop with the FSP and FMP counters the
|
|
/// worker path reserved.
|
|
#[cfg(unix)]
|
|
mod dead_encrypt_worker {
|
|
use super::*;
|
|
use crate::config::UdpConfig;
|
|
use crate::node::tests::pool_with_dead_worker_for;
|
|
use crate::transport::udp::UdpTransport;
|
|
use crate::transport::{TransportAddr, TransportHandle, TransportId, packet_channel};
|
|
|
|
/// A test node on a real UDP socket: the worker path needs one.
|
|
async fn make_test_node_udp() -> TestNode {
|
|
let mut node = make_node();
|
|
let transport_id = TransportId::new(1);
|
|
let config = UdpConfig {
|
|
bind_addr: Some("127.0.0.1:0".to_string()),
|
|
mtu: Some(1280),
|
|
..Default::default()
|
|
};
|
|
let (packet_tx, packet_rx) = packet_channel(256);
|
|
let mut transport = UdpTransport::new(transport_id, None, config, packet_tx);
|
|
transport.start_async().await.unwrap();
|
|
let addr = TransportAddr::from_string(&transport.local_addr().unwrap().to_string());
|
|
node.transports
|
|
.insert(transport_id, TransportHandle::Udp(transport));
|
|
TestNode {
|
|
node,
|
|
transport_id,
|
|
packet_rx: crate::node::tests::spanning_tree::bridge_to_unbounded(packet_rx),
|
|
addr,
|
|
}
|
|
}
|
|
|
|
fn fsp_send_counter(node: &Node, dest: &NodeAddr) -> u64 {
|
|
match node.get_session(dest).expect("session").state() {
|
|
EndToEndState::Established(session) => session.current_send_counter(),
|
|
_ => panic!("session not established"),
|
|
}
|
|
}
|
|
|
|
fn fmp_send_counter(node: &Node, peer: &NodeAddr) -> u64 {
|
|
node.get_peer(peer)
|
|
.expect("peer")
|
|
.noise_session()
|
|
.expect("link session")
|
|
.current_send_counter()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_data_for_a_dead_workers_next_hop_is_still_delivered() {
|
|
let mut nodes = vec![make_test_node_udp().await, make_test_node_udp().await];
|
|
initiate_handshake(&mut nodes, 0, 1).await;
|
|
drain_all_packets(&mut nodes, false).await;
|
|
verify_tree_convergence(&nodes);
|
|
populate_all_coord_caches(&mut nodes);
|
|
establish_pair_session(&mut nodes).await;
|
|
drain_all_packets(&mut nodes, false).await;
|
|
|
|
let node0 = *nodes[0].node.node_addr();
|
|
let node1 = *nodes[1].node.node_addr();
|
|
let dest: std::net::SocketAddr = nodes[1].addr.to_string().parse().unwrap();
|
|
let pool = pool_with_dead_worker_for(dest);
|
|
nodes[0].node.supervisor.encrypt_workers = Some(pool.clone());
|
|
|
|
let fsp_before = fsp_send_counter(&nodes[0].node, &node1);
|
|
let fmp_before = fmp_send_counter(&nodes[0].node, &node1);
|
|
let recv_before = nodes[1]
|
|
.node
|
|
.get_session(&node0)
|
|
.unwrap()
|
|
.traffic_counters()
|
|
.1;
|
|
|
|
nodes[0]
|
|
.node
|
|
.send_session_data(&node1, 0, 0, b"for a dead worker")
|
|
.await
|
|
.expect("send_session_data");
|
|
// Read before anything else runs on A: one packet, one counter each.
|
|
assert_eq!(fsp_send_counter(&nodes[0].node, &node1), fsp_before + 1);
|
|
assert_eq!(fmp_send_counter(&nodes[0].node, &node1), fmp_before + 1);
|
|
assert_eq!(pool.liveness().refused_dispatches(), 1);
|
|
|
|
let delivered = |nodes: &[TestNode]| {
|
|
nodes[1]
|
|
.node
|
|
.get_session(&node0)
|
|
.unwrap()
|
|
.traffic_counters()
|
|
.1
|
|
};
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
|
|
while delivered(&nodes) == recv_before && tokio::time::Instant::now() < deadline {
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
}
|
|
assert_eq!(
|
|
delivered(&nodes),
|
|
recv_before + 1,
|
|
"the payload never reached the destination"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
}
|