diff --git a/src/control/queries.rs b/src/control/queries.rs index 07cebbd..9958118 100644 --- a/src/control/queries.rs +++ b/src/control/queries.rs @@ -1316,17 +1316,18 @@ pub fn show_connections(node: &Node) -> Value { let now = now_ms(); let connections: Vec = node .connections() - .map(|conn| { + .map(|(_, machine)| { + let link_id = machine.link_id(); let mut conn_json = json!({ - "link_id": conn.link_id().as_u64(), - "direction": format!("{}", conn.direction()), - "handshake_state": node.connection_handshake_state(conn.link_id()), - "started_at_ms": node.connection_started_at(conn.link_id()), - "idle_ms": now.saturating_sub(node.connection_last_activity(conn.link_id())), - "resend_count": node.connection_resend_count(conn.link_id()), + "link_id": link_id.as_u64(), + "direction": format!("{}", machine.conn_direction()), + "handshake_state": node.connection_handshake_state(link_id), + "started_at_ms": node.connection_started_at(link_id), + "idle_ms": now.saturating_sub(node.connection_last_activity(link_id)), + "resend_count": node.connection_resend_count(link_id), }); - if let Some(identity) = node.connection_expected_identity(conn.link_id()) { + if let Some(identity) = node.connection_expected_identity(link_id) { conn_json["expected_peer"] = json!(identity.npub()); } diff --git a/src/lib.rs b/src/lib.rs index 8d31898..4624828 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -95,7 +95,7 @@ pub use cache::{CacheEntry, CacheError, CacheStats, CoordCache}; pub use proto::fmp::{PromotionResult, cross_connection_winner}; // Re-export peer types -pub use peer::{ActivePeer, ConnectivityState, PeerConnection, PeerError}; +pub use peer::{ActivePeer, ConnectivityState, PeerError}; // Re-export node types pub use node::{Node, NodeError, NodeState, UpdatePeersOutcome}; diff --git a/src/node/dataplane/peer_actions.rs b/src/node/dataplane/peer_actions.rs index e81b415..05d87bd 100644 --- a/src/node/dataplane/peer_actions.rs +++ b/src/node/dataplane/peer_actions.rs @@ -251,8 +251,9 @@ impl Node { let wire_msg2 = if ambient.is_outbound { None } else { - self.leg(&promote_link) - .and_then(|c| c.handshake_msg2().map(|m| m.to_vec())) + self.peer_machines + .get(&promote_link) + .and_then(|m| m.conn_handshake_msg2().map(|b| b.to_vec())) }; if ambient.is_outbound { @@ -595,18 +596,21 @@ impl Node { if our_inbound_wins { // Larger node side: swap to the inbound session so it pairs // with the peer's kept outbound session. - let inbound_session = - match self.leg_mut(&link).and_then(|c| c.take_session()) { - Some(s) => s, - None => { - self.remove_link(&link); - self.remove_peer_machine(link); - self.stats_mut().record_reject(RejectReason::Handshake( - HandshakeReject::BadState, - )); - return; - } - }; + let inbound_session = match self + .peer_machines + .get_mut(&link) + .and_then(|m| m.take_session()) + { + Some(s) => s, + None => { + self.remove_link(&link); + self.remove_peer_machine(link); + self.stats_mut().record_reject(RejectReason::Handshake( + HandshakeReject::BadState, + )); + return; + } + }; if let Some(peer_ref) = self.peers.get_mut(&peer) { let old_our_index = peer_ref.replace_session(inbound_session, our_index, their_index); @@ -686,7 +690,7 @@ impl Node { // Rekey: process as responder, store new session as pending. let noise_session = { - let Some(conn) = self.leg_mut(&link) else { + let Some(machine) = self.peer_machines.get_mut(&link) else { warn!(link_id = %link, "Connection removed during rekey msg3 processing"); self.links.remove(&link); self.remove_peer_machine(link); @@ -695,7 +699,7 @@ impl Node { )); return; }; - conn.take_session() + machine.take_session() }; let our_new_index = our_index; diff --git a/src/node/handlers/handshake.rs b/src/node/handlers/handshake.rs index 823017c..758cd94 100644 --- a/src/node/handlers/handshake.rs +++ b/src/node/handlers/handshake.rs @@ -11,8 +11,10 @@ use crate::node::acl::PeerAclContext; use crate::node::dataplane::PeerActionCtx; use crate::node::reject::{HandshakeReject, RejectReason}; use crate::node::{Node, NodeError}; -use crate::peer::machine::{CrossConnOutcome, PeerAction, PeerEvent, PeerMachine, TimerKind}; -use crate::peer::{ActivePeer, PeerConnection}; +use crate::peer::ActivePeer; +use crate::peer::machine::{ + CrossConnOutcome, HandshakeCrypto, PeerAction, PeerEvent, PeerMachine, TimerKind, +}; use crate::proto::fmp::wire::{Msg1Header, Msg2Header, Msg3Header, build_msg2, build_msg3}; use crate::proto::fmp::{ Disconnect, DisconnectReason, EstablishSnapshot, InboundDecision, InboundReject, @@ -247,19 +249,24 @@ impl Node { // === CRYPTO COST PAID HERE === let link_id = self.allocate_link_id(); - let mut conn = PeerConnection::inbound_with_transport( - link_id, - packet.transport_id, - packet.remote_addr.clone(), - packet.timestamp_ms, - ); + + // The control machine drives the handshake, so it is built here, above + // the crypto. It stays a local: it enters `peer_machines` only at the + // promote tails, so a rejected msg1 still leaves no registry trace and + // allocates no index. + let mut machine = PeerMachine::new_inbound(link_id, packet.timestamp_ms); + // Seed the carrier with the transport and address msg1 arrived on, so + // the promotion hand-off reads them from it. + machine.set_conn_transport_id(packet.transport_id); + machine.set_conn_source_addr(packet.remote_addr.clone()); + machine.set_leg(HandshakeCrypto::new()); // Create FMP negotiation payload for msg2 (includes profile, MMP bits, bloom TLV) let neg_payload = NegotiationPayload::fmp(1, 1, self.node_profile()).encode(); let our_keypair = self.identity().keypair(); let noise_msg1 = &packet.data[header.noise_msg1_offset..]; - let msg2_response = match conn.receive_handshake_init( + let msg2_response = match machine.receive_handshake_init( our_keypair, self.startup_epoch(), noise_msg1, @@ -301,8 +308,7 @@ impl Node { } }; - conn.set_our_index(our_index); - conn.set_their_index(header.sender_idx); + machine.set_conn_their_index(header.sender_idx); // Create link let link = Link::connectionless( @@ -317,27 +323,19 @@ impl Node { self.addr_to_link.insert(addr_key, link_id); // Build the msg2 response, storing it on the surviving carrier for - // potential resend before the connection is embedded on the machine - // below. + // potential resend before the machine enters the registry below. let wire_msg2 = build_msg2(our_index, header.sender_idx, &msg2_response); - // The leg's persistent control machine is born carrying its pending - // connection, parked at `SentMsg2` awaiting msg3 (identity is unknown - // until then). Inserted before the msg2 send below so no suspension - // point observes a leg in flight without a machine. `handle_msg3` - // steps this same machine; every teardown path disposes it with the - // embedded leg. - let mut machine = PeerMachine::inbound_msg2_sent(link_id, our_index, packet.timestamp_ms); - // The inbound leg carries the transport ID and peer index from msg1, but - // the machine's carrier is only written on the outbound dial and at msg3. - // Seed both here so the promotion hand-off reads them from the surviving - // carrier, matching the leg's inbound seeds. - machine.set_conn_transport_id(packet.transport_id); - machine.set_conn_their_index(header.sender_idx); + // The machine built above the crypto now parks at `SentMsg2` awaiting + // msg3 (identity is unknown until then), recording the index it owns. + // It enters the registry before the msg2 send below so no suspension + // point observes a handshake in flight without a machine. `handle_msg3` + // steps this same machine; every teardown path disposes it along with + // the Noise handles it carries. + machine.park_inbound_msg2_sent(our_index); // Store the framed msg2 on the surviving carrier for duplicate-msg1 - // resend while the connection is still pending. + // resend while the handshake is still pending. machine.set_conn_handshake_msg2(wire_msg2.clone()); - machine.set_leg(conn); self.peer_machines.insert(link_id, machine); if let Some(transport) = self.transports.get(&packet.transport_id) { @@ -357,8 +355,8 @@ impl Node { error = %e, "Failed to send msg2" ); - // Clean up on failure (the machine disposal drops the - // embedded connection with it) + // Clean up on failure (disposing the machine drops the + // Noise handles it carries with it) self.links.remove(&link_id); self.addr_to_link .remove(&(packet.transport_id, packet.remote_addr)); @@ -439,8 +437,8 @@ impl Node { }; // Check if this is a rekey msg2: the handshake state is on the - // ActivePeer (not a PeerConnection), so the link's machine — if one - // survives at all — carries no pending connection. A bare machine + // ActivePeer, not in a handshake carrier, so the link's machine — if + // one survives at all — carries no pending handshake. A bare machine // lookup would NOT discriminate here: an established peer's machine // stays keyed by this link, so the pending connection's presence is // what marks a fresh establish. Look for a peer with matching @@ -627,7 +625,7 @@ impl Node { let our_profile = self.node_profile(); let (peer_identity, msg3_bytes, our_index) = { - let Some(conn) = self.leg_mut(&link_id) else { + let Some(machine) = self.peer_machines.get_mut(&link_id) else { warn!(link_id = %link_id, "Connection removed during msg2 processing"); self.pending_outbound.remove(&key); self.stats_mut() @@ -640,7 +638,7 @@ impl Node { // Process Noise msg2 and generate msg3 let noise_msg2 = &packet.data[header.noise_msg2_offset..]; - let (msg3_bytes, received_negotiation) = match conn.complete_handshake( + let (msg3_bytes, received_negotiation) = match machine.complete_handshake( noise_msg2, Some(&neg_payload), packet.timestamp_ms, @@ -652,18 +650,15 @@ impl Node { error = %e, "Handshake completion failed" ); - // Drop the leg's Noise handle (byte-identical point) and - // record the failure on the control machine as `send_failed` - // — the failure state's new home. The machine PHASE stays - // exactly where the old leg-carried failure left it - // (`Handshaking{SentMsg1}`): the stale-connection sweep - // reclaims the leg via the machine `is_failed()` at the next - // tick, before any projection or resend, byte-identical to - // the pre-collapse leg mark. - conn.mark_failed(); - if let Some(machine) = self.peer_machines.get_mut(&link_id) { - machine.mark_send_failed(); - } + // Drop the Noise handle (byte-identical point) and record + // the failure on the control machine as `send_failed` — the + // failure state's home. The machine PHASE stays exactly + // where the old failure left it (`Handshaking{SentMsg1}`): + // the stale-connection sweep reclaims the handshake via the + // machine `is_failed()` at the next tick, before any + // projection or resend. + machine.mark_failed(); + machine.mark_send_failed(); self.stats_mut() .record_reject(RejectReason::Handshake(HandshakeReject::BadState)); return; @@ -672,17 +667,15 @@ impl Node { // Process peer's FMP negotiation payload from msg2 if let Some(neg_bytes) = &received_negotiation { - match process_fmp_negotiation(our_profile, conn, neg_bytes) { + match process_fmp_negotiation(our_profile, machine, neg_bytes) { Ok(()) => {} Err(e) => { warn!(link_id = %link_id, our_profile = %our_profile, error = %e, "FMP negotiation failed"); // Failure moves to the machine (`send_failed`); the phase // stays `Handshaking{SentMsg1}` so the sweep reclaims the - // leg exactly as the pre-collapse leg mark did. - conn.mark_failed(); - if let Some(machine) = self.peer_machines.get_mut(&link_id) { - machine.mark_send_failed(); - } + // handshake exactly as the pre-collapse mark did. + machine.mark_failed(); + machine.mark_send_failed(); self.stats_mut() .record_reject(RejectReason::Handshake(HandshakeReject::BadState)); return; @@ -691,11 +684,11 @@ impl Node { } // Store their index - conn.set_their_index(header.sender_idx); - conn.set_source_addr(packet.remote_addr.clone()); + machine.set_conn_their_index(header.sender_idx); + machine.set_conn_source_addr(packet.remote_addr.clone()); // Get peer identity for promotion (learned from msg2 in XX) - let peer_identity = match conn.expected_identity() { + let peer_identity = match machine.conn_expected_identity() { Some(id) => *id, None => { warn!(link_id = %link_id, "No identity after handshake"); @@ -705,24 +698,17 @@ impl Node { } }; - let our_index = conn.our_index(); + let our_index = machine.our_index(); (peer_identity, msg3_bytes, our_index) }; let peer_node_addr = *peer_identity.node_addr(); - // Mirror the leg's completion `touch` and the negotiated peer profile - // onto the surviving carrier so the connection's last-activity advances - // at msg2 completion (matching the leg's clock) and the promotion - // hand-off reads the profile from the carrier. - let negotiated_profile = self.leg(&link_id).and_then(|conn| conn.peer_profile()); - if let Some(machine) = self.peer_machines.get_mut(&link_id) { - machine.touch_conn(packet.timestamp_ms); - if let Some(profile) = negotiated_profile { - machine.set_conn_peer_profile(profile); - } - } + // The completion `touch` and the negotiated peer profile are written + // directly onto the surviving carrier by `complete_handshake` and + // `process_fmp_negotiation` above, so last-activity advances at msg2 + // completion and the promotion hand-off reads the profile from it. // ACL check: with XX, this is the first point where the initiator // knows the responder's identity. @@ -784,12 +770,10 @@ impl Node { ); // Failure moves to the machine (`send_failed`); the phase // stays `Handshaking{SentMsg1}` (promote has not run yet) so - // the sweep reclaims the leg exactly as the pre-collapse leg - // mark did. - if let Some(conn) = self.leg_mut(&link_id) { - conn.mark_failed(); - } + // the sweep reclaims the handshake exactly as the + // pre-collapse mark did. if let Some(machine) = self.peer_machines.get_mut(&link_id) { + machine.mark_failed(); machine.mark_send_failed(); } self.stats_mut() @@ -887,10 +871,10 @@ impl Node { // right after the take — unconditionally, whether or not a // connection was carried — so none of this block's exits leave a // dangling machine. - let taken_conn = self - .peer_machines - .get_mut(&link_id) - .and_then(|machine| machine.take_leg()); + let (taken_conn, carrier_our_index) = match self.peer_machines.get_mut(&link_id) { + Some(machine) => (machine.take_leg(), machine.our_index()), + None => (None, None), + }; self.remove_peer_machine(link_id); let mut conn = match taken_conn { @@ -907,8 +891,8 @@ impl Node { if swap { // We're the smaller node. Swap to outbound session + indices. // The peer will keep their inbound session (complement of ours). - let outbound_our_index = conn.our_index(); - let outbound_session = conn.take_session(); + let outbound_our_index = carrier_our_index; + let outbound_session = conn.noise_session.take(); let (outbound_session, outbound_our_index) = match ( outbound_session, @@ -976,7 +960,7 @@ impl Node { // their outbound session, that index is exactly what they'll use. // The msg2 sender_idx we see here is the peer's INBOUND our_index, // which becomes stale after the peer swaps. - let outbound_our_index = conn.our_index(); + let outbound_our_index = carrier_our_index; if let Some(peer) = self.peers.get(&peer_node_addr) { debug!( @@ -1106,8 +1090,8 @@ impl Node { let our_profile = self.node_profile(); let (peer_identity, our_index, remote_epoch) = { // Get the pending connection - let conn = match self.leg_mut(&link_id) { - Some(c) => c, + let machine = match self.peer_machines.get_mut(&link_id) { + Some(m) => m, None => { debug!( link_id = %link_id, @@ -1122,7 +1106,7 @@ impl Node { // Process msg3 — learns initiator's identity and epoch let noise_msg3 = &packet.data[header.noise_msg3_offset..]; let received_negotiation = - match conn.complete_handshake_msg3(noise_msg3, packet.timestamp_ms) { + match machine.complete_handshake_msg3(noise_msg3, packet.timestamp_ms) { Ok(neg) => neg, Err(e) => { warn!( @@ -1131,10 +1115,11 @@ impl Node { "Msg3 processing failed" ); // Clean up. Capture the index before disposing the - // machine (and the connection embedded on it); reading + // machine (and the Noise handles it carries); reading // it after the disposal would always return None and // leak the allocated index. - let our_idx_to_free = self.leg(&link_id).and_then(|c| c.our_index()); + let our_idx_to_free = + self.peer_machines.get(&link_id).and_then(|m| m.our_index()); self.remove_link(&link_id); self.remove_peer_machine(link_id); if let Some(idx) = our_idx_to_free { @@ -1148,7 +1133,7 @@ impl Node { // Process peer's FMP negotiation payload from msg3 if let Some(neg_bytes) = &received_negotiation { - match process_fmp_negotiation(our_profile, conn, neg_bytes) { + match process_fmp_negotiation(our_profile, machine, neg_bytes) { Ok(()) => {} Err(e) => { warn!(link_id = %link_id, our_profile = %our_profile, error = %e, "FMP negotiation failed"); @@ -1162,7 +1147,7 @@ impl Node { } // Learn peer identity from msg3 - let peer_identity = match conn.expected_identity() { + let peer_identity = match machine.conn_expected_identity() { Some(id) => *id, None => { warn!("Identity not learned from msg3"); @@ -1174,22 +1159,17 @@ impl Node { } }; - let our_index = conn.our_index(); - let remote_epoch = conn.remote_epoch(); + let our_index = machine.our_index(); + let remote_epoch = machine.conn_remote_epoch(); (peer_identity, our_index, remote_epoch) }; let peer_node_addr = *peer_identity.node_addr(); - // Mirror the negotiated peer profile onto the surviving carrier so the - // promotion hand-off reads it from the carrier, not the leg. - let negotiated_profile = self.leg(&link_id).and_then(|conn| conn.peer_profile()); - if let Some(profile) = negotiated_profile - && let Some(machine) = self.peer_machines.get_mut(&link_id) - { - machine.set_conn_peer_profile(profile); - } + // The negotiated peer profile is written straight onto the surviving + // carrier by `process_fmp_negotiation` above, so the promotion hand-off + // reads it from there. // ACL check: with XX, this is the first point where the responder // knows the initiator's identity. @@ -1208,8 +1188,8 @@ impl Node { // and the initiator has a matching session from processing // msg2. Reason `Other` is used instead of `SecurityViolation` // to avoid naming the ACL mechanism on the wire. - let reject_info = match self.leg_mut(&link_id) { - Some(conn) => match (conn.their_index(), conn.take_session()) { + let reject_info = match self.peer_machines.get_mut(&link_id) { + Some(machine) => match (machine.conn_their_index(), machine.take_session()) { (Some(idx), Some(session)) => Some((idx, session)), _ => None, }, @@ -1486,14 +1466,14 @@ impl Node { link_id = %link_id, "Leaf node rejecting additional peer (single-peer enforcement)" ); - // Clean up the connection (taken off its machine first) and its - // control machine - if let Some(conn) = self - .peer_machines - .get_mut(&link_id) - .and_then(|machine| machine.take_leg()) - && let Some(idx) = conn.our_index() - { + // Detach the Noise handles from the machine and free the index the + // handshake allocated, then dispose of the machine itself. + if let Some(idx) = self.peer_machines.get_mut(&link_id).and_then(|machine| { + // Freeing stays conditional on a handshake actually being + // attached, as it was when the index lived on the leg. + machine.take_leg()?; + machine.our_index() + }) { let _ = self.index_allocator.free(idx); } self.remove_link(&link_id); @@ -1501,67 +1481,65 @@ impl Node { return Err(NodeError::MaxPeersExceeded { max: 1 }); } - // Take the pending connection off its control machine. The machine + // Take the pending connection off its control machine, and read the + // carrier fields the promotion needs in the same borrow. The machine // survives the promotion (it becomes the active peer's control // machine), left with no pending connection. - let mut connection = self + // + // The connection is detached before anything is validated, so every + // error return below leaves the machine leg-less — the caller disposes + // of it. Gathering the carrier reads up front is only a borrow shape: + // they are infallible, so the order in which the missing-field errors + // are reported below is unchanged. + let machine = self .peer_machines .get_mut(&link_id) - .and_then(|machine| machine.take_leg()) .ok_or(NodeError::ConnectionNotFound(link_id))?; + let mut connection = machine + .take_leg() + .ok_or(NodeError::ConnectionNotFound(link_id))?; + let carrier_our_index = machine.our_index(); + let carrier_their_index = machine.conn_their_index(); + let carrier_transport_id = machine.conn_transport_id(); + let carrier_source_addr = machine.conn_source_addr().cloned(); + let carrier_is_outbound = machine.conn_is_outbound(); + let carrier_remote_epoch = machine.conn_remote_epoch(); + let link_stats = machine.conn_link_stats().clone(); + // The negotiated peer profile, read from the same carrier borrow. + let peer_profile = machine + .conn_peer_profile() + .unwrap_or(crate::proto::fmp::NodeProfile::Full); // Verify handshake is complete and extract session - if !connection.has_session() { + if connection.noise_session.is_none() { return Err(NodeError::HandshakeIncomplete(link_id)); } let noise_session = connection - .take_session() + .noise_session + .take() .ok_or(NodeError::NoSession(link_id))?; - let our_index = connection - .our_index() - .ok_or_else(|| NodeError::PromotionFailed { - link_id, - reason: "missing our_index".into(), - })?; - let their_index = self - .peer_machines - .get(&link_id) - .and_then(|machine| machine.conn_their_index()) - .ok_or_else(|| NodeError::PromotionFailed { - link_id, - reason: "missing their_index".into(), - })?; - let transport_id = self - .peer_machines - .get(&link_id) - .and_then(|machine| machine.conn_transport_id()) - .ok_or_else(|| NodeError::PromotionFailed { - link_id, - reason: "missing transport_id".into(), - })?; - let current_addr = connection - .source_addr() - .ok_or_else(|| NodeError::PromotionFailed { - link_id, - reason: "missing source_addr".into(), - })? - .clone(); - let link_stats = self - .peer_machines - .get(&link_id) - .map(|machine| machine.conn_link_stats().clone()) - .unwrap_or_default(); - let remote_epoch = connection.remote_epoch(); - let peer_profile = self - .peer_machines - .get(&link_id) - .and_then(|machine| machine.conn_peer_profile()) - .unwrap_or(crate::proto::fmp::NodeProfile::Full); + let our_index = carrier_our_index.ok_or_else(|| NodeError::PromotionFailed { + link_id, + reason: "missing our_index".into(), + })?; + let their_index = carrier_their_index.ok_or_else(|| NodeError::PromotionFailed { + link_id, + reason: "missing their_index".into(), + })?; + let transport_id = carrier_transport_id.ok_or_else(|| NodeError::PromotionFailed { + link_id, + reason: "missing transport_id".into(), + })?; + let current_addr = carrier_source_addr.ok_or_else(|| NodeError::PromotionFailed { + link_id, + reason: "missing source_addr".into(), + })?; + let remote_epoch = carrier_remote_epoch; let peer_node_addr = *verified_identity.node_addr(); - let is_outbound = connection.is_outbound(); + let is_outbound = carrier_is_outbound; // Check for cross-connection if let Some(existing_peer) = self.peers.get(&peer_node_addr) { @@ -1719,12 +1697,13 @@ impl Node { // the 30s handshake timeout. let pending_to_same_peer: Vec = self .connections() - .filter(|conn| { - conn.expected_identity() + .filter(|(_, machine)| { + machine + .conn_expected_identity() .map(|id| *id.node_addr() == peer_node_addr) .unwrap_or(false) }) - .map(|conn| conn.link_id()) + .map(|(_, machine)| machine.link_id()) .collect(); for pending_link_id in &pending_to_same_peer { @@ -1819,20 +1798,20 @@ impl Node { /// Process an FMP negotiation payload received from a peer. /// /// Decodes the payload, validates profile pairing, and stores the -/// results on the PeerConnection. +/// results on the peer's control machine. fn process_fmp_negotiation( our_profile: crate::proto::fmp::NodeProfile, - conn: &mut PeerConnection, + machine: &mut PeerMachine, neg_bytes: &[u8], ) -> Result<(), crate::proto::Error> { // The decode -> validate -> profile decision is the pure core split; the // shell records the result on the connection and logs. let their_profile = decide_fmp_negotiation(our_profile, neg_bytes)?; - conn.set_negotiation_results(their_profile); + machine.set_conn_peer_profile(their_profile); debug!( - link_id = %conn.link_id(), + link_id = %machine.link_id(), our_profile = %our_profile, peer_profile = %their_profile, "FMP negotiation complete" diff --git a/src/node/handlers/rekey.rs b/src/node/handlers/rekey.rs index d4de118..f62cb6f 100644 --- a/src/node/handlers/rekey.rs +++ b/src/node/handlers/rekey.rs @@ -270,7 +270,7 @@ impl Node { /// /// Creates a new XX handshake as initiator, sends msg1 over the existing /// link (same transport, same remote address), and stores the handshake - /// state on the ActivePeer. No new Link or PeerConnection is created. + /// state on the ActivePeer. No new Link or handshake leg is created. async fn initiate_rekey(&mut self, node_addr: &NodeAddr) { let peer = match self.peers.get(node_addr) { Some(p) => p, @@ -302,7 +302,7 @@ impl Node { } }; - // Create XX initiator handshake directly (no PeerConnection) + // Create XX initiator handshake directly (no handshake leg) let our_keypair = self.identity().keypair(); let mut hs = HandshakeState::new_initiator(our_keypair); hs.set_local_epoch(self.startup_epoch()); @@ -348,7 +348,7 @@ impl Node { } } - // Store handshake state on the ActivePeer (not a separate PeerConnection) + // Store handshake state on the ActivePeer (not a separate handshake leg) let resend_interval = self.config().node.rate_limit.handshake_resend_interval_ms; let now_ms = Self::now_ms(); if let Some(peer) = self.peers.get_mut(node_addr) { diff --git a/src/node/handlers/timeout.rs b/src/node/handlers/timeout.rs index 2c6e0ce..62919a3 100644 --- a/src/node/handlers/timeout.rs +++ b/src/node/handlers/timeout.rs @@ -19,18 +19,18 @@ impl LifecycleView for Node { // reap. self.peer_machines .iter() - .filter_map(|(link_id, machine)| machine.leg().map(|conn| (link_id, machine, conn))) - .filter(|(link_id, machine, _conn)| { + .filter(|(_, machine)| machine.leg().is_some()) + .filter(|(link_id, machine)| { machine.is_failed() || (machine.conn_is_timed_out(now_ms, timeout_ms) && !self.peer_timers.get(*link_id).is_some_and(|timers| { timers.contains_key(&TimerKind::HandshakeTimeout) })) }) - .map(|(link_id, _machine, conn)| ConnSnapshot { + .map(|(link_id, machine)| ConnSnapshot { link: *link_id, - is_outbound: conn.is_outbound(), - retry_addr: conn.expected_identity().map(|id| *id.node_addr()), + is_outbound: machine.conn_is_outbound(), + retry_addr: machine.conn_expected_identity().map(|id| *id.node_addr()), resend_count: 0, msg1: Vec::new(), }) @@ -77,8 +77,12 @@ impl Node { .peer_machines .get(&link) .is_some_and(|machine| machine.is_failed()); - if let Some(conn) = self.leg(&link) { - let direction = conn.direction(); + if let Some(machine) = self + .peer_machines + .get(&link) + .filter(|machine| machine.leg().is_some()) + { + let direction = machine.conn_direction(); if is_failed { debug!( link_id = %link, @@ -116,7 +120,7 @@ impl Node { // dangling machine. A no-op for promoted peers — `promote_connection` // already consumed their connection, so this reaper never runs for // them. - let conn = match self + let _detached_leg = match self .peer_machines .get_mut(&link_id) .and_then(|machine| machine.take_leg()) @@ -124,16 +128,16 @@ impl Node { Some(c) => c, None => return, }; - // Read the transport ID off the surviving carrier before disposing the - // machine (the leg no longer projects it). - let transport_id = self - .peer_machines - .get(&link_id) - .and_then(|machine| machine.conn_transport_id()); + // Read the transport ID and session index off the surviving carrier + // before disposing the machine (the leg no longer projects them). + let (transport_id, our_index) = match self.peer_machines.get(&link_id) { + Some(machine) => (machine.conn_transport_id(), machine.our_index()), + None => (None, None), + }; self.remove_peer_machine(link_id); // Free session index and pending_outbound/pending_inbound if allocated - if let Some(idx) = conn.our_index() { + if let Some(idx) = our_index { if let Some(tid) = transport_id { self.pending_outbound.remove(&(tid, idx.as_u32())); self.pending_inbound.remove(&(tid, idx.as_u32())); @@ -190,24 +194,31 @@ impl Node { .collect(); for link in timer_links { // The idle-timeout threshold reads the survivor carrier's - // last-activity (the leg no longer projects it); the leg still - // supplies direction/identity for the retry decision below. + // last-activity; presence of a pending handshake is what decides + // between reaping and dropping an orphan timer. let timed_out = self .peer_machines .get(&link) .is_some_and(|machine| machine.conn_is_timed_out(now_ms, timeout_ms)); - let (reap, retry_peer) = match self.leg(&link) { - Some(conn) if timed_out => { - let retry_peer = if conn.is_outbound() { - conn.expected_identity().map(|id| *id.node_addr()) + let (reap, retry_peer) = match self.has_pending_leg(&link) { + true if timed_out => { + let retry_peer = if self + .peer_machines + .get(&link) + .is_some_and(|machine| machine.conn_is_outbound()) + { + self.peer_machines + .get(&link) + .and_then(|machine| machine.conn_expected_identity()) + .map(|id| *id.node_addr()) } else { None }; (true, retry_peer) } // Not yet idle-timed-out: leave the timer for a later tick. - Some(_) => (false, None), - None => { + true => (false, None), + false => { // Orphan timer (connection already reaped elsewhere) — drop it. if let Some(timers) = self.peer_timers.get_mut(&link) { timers.remove(&TimerKind::HandshakeTimeout); @@ -266,9 +277,10 @@ impl Node { // Skip a resend whose target peer already promoted via the inbound // cross-connection path — resending msg1 would start a new handshake // (session mismatch). - if let Some(conn) = self.leg(&link) - && conn - .expected_identity() + if let Some(machine) = self.peer_machines.get(&link) + && machine.leg().is_some() + && machine + .conn_expected_identity() .map(|id| self.peers.contains_key(id.node_addr())) .unwrap_or(false) { @@ -327,17 +339,14 @@ impl Node { continue; }; - let (transport_id, remote_addr) = match self.leg(&link) { - Some(conn) => match ( - self.peer_machines - .get(&link) - .and_then(|machine| machine.conn_transport_id()), - conn.source_addr(), - ) { - (Some(tid), Some(addr)) => (tid, addr.clone()), - _ => continue, - }, - None => continue, + let (transport_id, remote_addr) = match self.peer_machines.get(&link) { + Some(machine) if machine.leg().is_some() => { + match (machine.conn_transport_id(), machine.conn_source_addr()) { + (Some(tid), Some(addr)) => (tid, addr.clone()), + _ => continue, + } + } + _ => continue, }; let sent = if let Some(transport) = self.transports.get(&transport_id) { diff --git a/src/node/lifecycle/mod.rs b/src/node/lifecycle/mod.rs index 74f7e25..37c99ed 100644 --- a/src/node/lifecycle/mod.rs +++ b/src/node/lifecycle/mod.rs @@ -15,8 +15,7 @@ use crate::node::acl::PeerAclContext; use crate::node::dataplane::PeerActionCtx; use crate::nostr::{BootstrapEvent, NostrRendezvous}; use crate::nostr::{BootstrapHandoffResult, EstablishedTraversal}; -use crate::peer::PeerConnection; -use crate::peer::machine::{PeerEvent, PeerMachine}; +use crate::peer::machine::{HandshakeCrypto, PeerEvent, PeerMachine}; use crate::proto::fmp::wire::build_msg1; use crate::proto::fmp::{Disconnect, DisconnectReason}; use crate::transport::{Link, LinkDirection, LinkId, TransportAddr, TransportId, packet_channel}; @@ -372,27 +371,28 @@ impl Node { } fn is_connecting_to_peer(&self, peer_node_addr: &NodeAddr) -> bool { - self.connections().any(|conn| { - conn.expected_identity() + self.connections().any(|(_, machine)| { + machine + .conn_expected_identity() .map(|id| id.node_addr() == peer_node_addr) .unwrap_or(false) }) } - fn is_connecting_to_peer_on_path( + pub(in crate::node) fn is_connecting_to_peer_on_path( &self, peer_node_addr: &NodeAddr, transport_id: TransportId, remote_addr: &TransportAddr, ) -> bool { self.peer_machines.values().any(|machine| { - machine.leg().is_some_and(|conn| { - conn.expected_identity() + machine.leg().is_some() + && machine + .conn_expected_identity() .map(|id| id.node_addr() == peer_node_addr) .unwrap_or(false) - && machine.conn_transport_id() == Some(transport_id) - && conn.source_addr() == Some(remote_addr) - }) + && machine.conn_transport_id() == Some(transport_id) + && machine.conn_source_addr() == Some(remote_addr) }) || self.peering.pending_connects.iter().any(|pending| { pending .peer_identity @@ -607,9 +607,22 @@ impl Node { ) -> Result<(), NodeError> { let peer_node_addr = *peer_identity.node_addr(); - // Create connection in handshake phase (outbound knows expected identity) let current_time_ms = Self::now_ms(); - let mut connection = PeerConnection::outbound(link_id, peer_identity, current_time_ms); + + // The control machine drives the handshake, so it takes the crypto + // carrier before the crypto runs. The machine was born at dial and + // persisted in `initiate_connection`, so every live caller already has + // one; recover with a fresh one if a direct caller ever skips the dial. + debug_assert!( + self.peer_machines.contains_key(&link_id), + "outbound msg1 prepared for link {link_id} with no dial-time machine" + ); + self.peer_machines + .entry(link_id) + .or_insert_with(|| { + PeerMachine::new_outbound(link_id, Some(peer_identity), current_time_ms) + }) + .set_leg(HandshakeCrypto::new()); // Allocate a session index for this handshake let our_index = match self.index_allocator.allocate() { @@ -626,23 +639,30 @@ impl Node { // Start the Noise handshake and get message 1 let our_keypair = self.identity().keypair(); - let noise_msg1 = - match connection.start_handshake(our_keypair, self.startup_epoch(), current_time_ms) { - Ok(msg) => msg, - Err(e) => { - // Clean up the index, link, and dial-time machine - let _ = self.index_allocator.free(our_index); - self.links.remove(&link_id); - self.addr_to_link - .remove(&(transport_id, remote_addr.clone())); - self.remove_peer_machine(link_id); - return Err(NodeError::HandshakeFailed(e.to_string())); - } - }; + let startup_epoch = self.startup_epoch(); + let noise_msg1 = match self + .peer_machines + .get_mut(&link_id) + .expect("dial-time machine carries the connection") + .start_handshake(our_keypair, startup_epoch, current_time_ms) + { + Ok(msg) => msg, + Err(e) => { + // Clean up the index, link, and dial-time machine + let _ = self.index_allocator.free(our_index); + self.links.remove(&link_id); + self.addr_to_link + .remove(&(transport_id, remote_addr.clone())); + self.remove_peer_machine(link_id); + return Err(NodeError::HandshakeFailed(e.to_string())); + } + }; // Set index and transport info on the connection - connection.set_our_index(our_index); - connection.set_source_addr(remote_addr.clone()); + self.peer_machines + .get_mut(&link_id) + .expect("dial-time machine carries the connection") + .set_conn_source_addr(remote_addr.clone()); // Build wire format msg1: [0x01][sender_idx:4 LE][noise_msg1:82] let wire_msg1 = build_msg1(our_index, &noise_msg1); @@ -665,20 +685,13 @@ impl Node { self.pending_outbound .insert((transport_id, our_index.as_u32()), link_id); - // The dial-born machine (persisted in `initiate_connection`) carries - // the prepared connection from here. Every live caller dialed first, - // so the machine exists; recover with a fresh one if a direct caller - // ever skips the dial. - debug_assert!( - self.peer_machines.contains_key(&link_id), - "outbound msg1 prepared for link {link_id} with no dial-time machine" - ); - let machine = self.peer_machines.entry(link_id).or_insert_with(|| { - PeerMachine::new_outbound(link_id, Some(peer_identity), current_time_ms) - }); + let machine = self + .peer_machines + .get_mut(&link_id) + .expect("dial-time machine carries the connection"); // The dial-born machine carrier was stamped at dial; re-stamp it with the - // leg's msg1-prep clock so the surviving `started_at`/`last_activity` - // carry the leg's provenance. The two clocks differ when a connect + // msg1-prep clock so the surviving `started_at`/`last_activity` carry + // the preparation's provenance. The two clocks differ when a connect // round-trip separates dial from msg1 preparation. machine.set_conn_started_at(current_time_ms); machine.touch_conn(current_time_ms); @@ -686,14 +699,12 @@ impl Node { // projects it to the promotion hand-off); holds even if a direct caller // reached here without the dial-time `on_dial` write. machine.set_conn_transport_id(transport_id); - // Record our session index on the surviving carrier — the same index just - // written on the leg above — so the carrier is the single index home on - // the outbound path (the inbound path writes it at authorize). + // Record our session index on the surviving carrier, the single index + // home on the outbound path (the inbound path writes it at authorize). machine.set_conn_our_index(our_index); - // Store the msg1 wire on the surviving carrier (the leg no longer holds - // the resend source); the retransmit driver reads it from here. + // Store the msg1 wire on the surviving carrier (the connection does not + // hold the resend source); the retransmit driver reads it from here. machine.set_conn_handshake_msg1(wire_msg1, first_resend_at_ms); - machine.set_leg(connection); Ok(()) } @@ -712,14 +723,17 @@ impl Node { remote_addr: TransportAddr, peer_identity: Option, ) -> Result<(), NodeError> { - // Create connection in handshake phase. Anonymous discovery + // Create the control machine in handshake phase. Anonymous discovery // (no peer_identity) leaves identity to be learned from XX msg2. + // + // The machine drives the Noise crypto, so it is built before the leaf + // runs — but it stays a LOCAL until all fallible setup has succeeded. + // The index-alloc and Noise Err returns below therefore still just drop + // it, leaving no registry trace and needing no disposal, exactly as the + // pre-collapse local connection did. let current_time_ms = Self::now_ms(); - let mut connection = if let Some(identity) = peer_identity { - PeerConnection::outbound(link_id, identity, current_time_ms) - } else { - PeerConnection::outbound_anonymous(link_id, current_time_ms) - }; + let mut machine = PeerMachine::new_outbound(link_id, peer_identity, current_time_ms); + machine.set_leg(HandshakeCrypto::new()); // Allocate a session index for this handshake let our_index = match self.index_allocator.allocate() { @@ -735,7 +749,7 @@ impl Node { // Start the Noise handshake and get message 1 let our_keypair = self.identity().keypair(); let noise_msg1 = - match connection.start_handshake(our_keypair, self.startup_epoch(), current_time_ms) { + match machine.start_handshake(our_keypair, self.startup_epoch(), current_time_ms) { Ok(msg) => msg, Err(e) => { // Clean up the index and link @@ -746,15 +760,15 @@ impl Node { } }; - // Set index and transport info on the connection - connection.set_our_index(our_index); - connection.set_transport_id(transport_id); - connection.set_source_addr(remote_addr.clone()); + // Set index and transport info on the surviving carrier + machine.set_conn_our_index(our_index); + machine.set_conn_transport_id(transport_id); + machine.set_conn_source_addr(remote_addr.clone()); // Build wire format msg1: [0x01][sender_idx:4 LE][noise_msg1:82] let wire_msg1 = build_msg1(our_index, &noise_msg1); - if let Some(id) = connection.expected_identity() { + if let Some(id) = machine.conn_expected_identity() { debug!( peer = %self.peer_display_name(id.node_addr()), transport_id = %transport_id, @@ -773,37 +787,28 @@ impl Node { ); } - // Store msg1 for resend and schedule first resend + // Store msg1 for resend and schedule first resend. The carrier holds + // the resend wire; the retransmit driver reads it from there. let resend_interval = self.config().node.rate_limit.handshake_resend_interval_ms; let first_resend_at_ms = current_time_ms + resend_interval; - connection.set_handshake_msg1(wire_msg1.clone(), first_resend_at_ms); + machine.set_conn_handshake_msg1(wire_msg1.clone(), first_resend_at_ms); // Track in pending_outbound for msg2 dispatch self.pending_outbound .insert((transport_id, our_index.as_u32()), link_id); - // The leg's persistent control machine is born carrying its pending - // connection, after all fallible setup (the index-alloc and Noise Err - // returns above predate it and need no disposal). Both production - // callers dial anonymously (identified dials persist their machine at - // dial and go through `prepare_outbound_msg1`), so the machine starts - // identity-less; `handle_msg2` crystallizes the identity the XX - // handshake learns. Inserted before the send below so no suspension - // point observes a leg in flight without a machine. The send-failure - // arm retains the failed connection, and the machine keeps carrying - // it — the stale-connection reaper disposes both together. No timers - // are armed and no event is dispatched: this path sends msg1 inline, - // so the machine parks at `Discovered` until msg2. - let mut machine = PeerMachine::new_outbound(link_id, peer_identity, current_time_ms); - // Seed the surviving carrier with the leg's msg1-prep provenance so the - // started_at/last_activity timestamps, the transport ID, our index, and - // the msg1 resend wire all live on the carrier — the promotion hand-off - // and the retransmit driver read them there, not the leg. - machine.set_conn_started_at(current_time_ms); - machine.touch_conn(current_time_ms); - machine.set_conn_transport_id(transport_id); - machine.set_conn_our_index(our_index); - machine.set_conn_handshake_msg1(wire_msg1.clone(), first_resend_at_ms); - machine.set_leg(connection); + // The persistent control machine enters the registry here, after all + // fallible setup. Both production callers dial anonymously (identified + // dials persist their machine at dial and go through + // `prepare_outbound_msg1`), so the machine starts identity-less; + // `handle_msg2` crystallizes the identity the XX handshake learns. + // Inserted before the send below so no suspension point observes a + // handshake in flight without a machine. The send-failure arm retains + // the failed handshake and the machine keeps carrying it — the + // stale-connection reaper disposes both together. No timers are armed + // and no event is dispatched: this path sends msg1 inline, so the + // machine parks at `Discovered` until msg2. The carrier was born on + // this same `current_time_ms`, so its `started_at`/`last_activity` + // already carry the msg1-prep provenance. self.peer_machines.insert(link_id, machine); // Send the wire format handshake message @@ -866,7 +871,11 @@ impl Node { Some(w) => w.to_vec(), None => return, }; - let our_index = self.leg(&link_id).and_then(|c| c.our_index()); + let our_index = self + .peer_machines + .get(&link_id) + .filter(|machine| machine.leg().is_some()) + .and_then(|machine| machine.our_index()); // Send the wire format handshake message if let Some(transport) = self.transports.get(&transport_id) { @@ -1130,12 +1139,13 @@ impl Node { let now_ms = Self::now_ms(); let stale: Vec = self .connections() - .filter(|conn| { - conn.expected_identity() + .filter(|(_, machine)| { + machine + .conn_expected_identity() .map(|id| id.node_addr() == &peer_addr) .unwrap_or(false) }) - .map(|conn| conn.link_id()) + .map(|(_, machine)| machine.link_id()) .collect(); for link_id in stale { self.cleanup_stale_connection(link_id, now_ms); @@ -2959,11 +2969,11 @@ impl Node { let connected: HashSet = self.peers.keys().copied().collect(); let connecting: HashSet = self .connections() - .filter_map(|conn| conn.expected_identity().map(|id| *id.node_addr())) + .filter_map(|(_, machine)| machine.conn_expected_identity().map(|id| *id.node_addr())) .collect(); let mut in_flight_by_peer: HashMap = HashMap::new(); - for conn in self.connections() { - if let Some(id) = conn.expected_identity() { + for (_, machine) in self.connections() { + if let Some(id) = machine.conn_expected_identity() { *in_flight_by_peer.entry(*id.node_addr()).or_default() += 1; } } @@ -3035,8 +3045,9 @@ impl Node { let in_flight_for_peer = self .connections() - .filter(|conn| { - conn.expected_identity() + .filter(|(_, machine)| { + machine + .conn_expected_identity() .map(|identity| identity.node_addr() == peer_node_addr) .unwrap_or(false) }) diff --git a/src/node/mod.rs b/src/node/mod.rs index a2244ab..a91496f 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -50,8 +50,8 @@ use self::reloadable::Reloadable; pub(crate) const REKEY_JITTER_SECS: i64 = 15; use crate::cache::CoordCache; use crate::node::session::SessionEntry; +use crate::peer::ActivePeer; use crate::peer::machine::{PeerMachine, TimerKind}; -use crate::peer::{ActivePeer, PeerConnection}; use crate::proto::bloom::{BloomFilter, BloomState}; use crate::proto::fmp::Fmp; use crate::proto::fmp::NodeProfile; @@ -367,9 +367,9 @@ pub struct Node { // === Per-Peer Control Machines === /// Per-peer lifecycle control FSMs, keyed by the stable `LinkId` that spans - /// the handshake→active lifetime. Each machine owns its pending handshake - /// connection (the `PeerConnection` leg) while the handshake is in - /// progress — the single LinkId-keyed per-peer map on `Node`; `peers` + /// the handshake→active lifetime. Each machine owns its handshake crypto + /// carrier while the handshake is in progress — the single LinkId-keyed + /// per-peer map on `Node`; `peers` /// stays byte-unchanged (hot path pristine). /// Machines are inserted at dial and inbound msg1, and stepped in production /// by the handshake handlers, the rekey-cadence and liveness-reap routers, @@ -2018,15 +2018,17 @@ impl Node { // --- connections (show_connections) --- let connection_rows: Vec = self .connections() - .map(|conn| snap::ConnectionRow { - link_id: conn.link_id().as_u64(), - direction: format!("{}", conn.direction()), - handshake_state: self.connection_handshake_state(conn.link_id()).to_string(), - started_at_ms: self.connection_started_at(conn.link_id()), - last_activity_ms: self.connection_last_activity(conn.link_id()), - resend_count: self.connection_resend_count(conn.link_id()), + .map(|(_, machine)| snap::ConnectionRow { + link_id: machine.link_id().as_u64(), + direction: format!("{}", machine.conn_direction()), + handshake_state: self + .connection_handshake_state(machine.link_id()) + .to_string(), + started_at_ms: self.connection_started_at(machine.link_id()), + last_activity_ms: self.connection_last_activity(machine.link_id()), + resend_count: self.connection_resend_count(machine.link_id()), expected_peer: self - .connection_expected_identity(conn.link_id()) + .connection_expected_identity(machine.link_id()) .map(|id| id.npub()), }) .collect(); @@ -2442,35 +2444,29 @@ impl Node { // === Connection Management (Handshake Phase) === - /// The pending connection for `link_id`, read through the control machine - /// that carries it. - fn leg(&self, link_id: &LinkId) -> Option<&PeerConnection> { + /// Whether `link_id` has a pending handshake, read through the control + /// machine that carries it. + fn has_pending_leg(&self, link_id: &LinkId) -> bool { self.peer_machines .get(link_id) - .and_then(|machine| machine.leg()) - } - - /// Mutable access to the pending connection for `link_id`. - fn leg_mut(&mut self, link_id: &LinkId) -> Option<&mut PeerConnection> { - self.peer_machines - .get_mut(link_id) - .and_then(|machine| machine.leg_mut()) - } - - /// Add a pending connection. - /// - /// Seeds a control machine for the leg when none exists yet and embeds the - /// connection on it, for callers that insert a connection directly rather - /// than through the dial or inbound-msg1 paths, which build the machine - /// themselves. - pub fn add_connection(&mut self, connection: PeerConnection) -> Result<(), NodeError> { - let link_id = connection.link_id(); - - if self - .peer_machines - .get(&link_id) .is_some_and(|machine| machine.leg().is_some()) - { + } + + /// Test-support: seed a control machine for `seed.link_id` directly, + /// without the caller having to stage a pending handshake by hand. + /// + /// The machine is chosen the way the establish paths choose it — outbound + /// when the seed names a peer, inbound otherwise — and its carrier is + /// seeded with every field a promotion reads. Built through + /// `entry(..).or_insert_with(..)`, so an existing handshake-less machine + /// keeps its constructor-side fields rather than being rebuilt. + /// Post-construction `started_at` and the stored handshake bytes are not + /// seeded; the establish paths write those at their own points. + #[cfg(test)] + pub(crate) fn seed_handshake_machine(&mut self, seed: HandshakeSeed) -> Result<(), NodeError> { + let link_id = seed.link_id; + + if self.has_pending_leg(&link_id) { return Err(NodeError::ConnectionAlreadyExists(link_id)); } @@ -2480,55 +2476,45 @@ impl Node { }); } - let machine = self.peer_machines.entry(link_id).or_insert_with(|| { - let now = connection.started_at(); - if connection.is_outbound() { - PeerMachine::new_outbound(link_id, connection.expected_identity().copied(), now) - } else { - PeerMachine::new_inbound(link_id, now) - } - }); - // Seed the surviving carrier's peer index and transport from the - // pre-built leg so the promotion hand-off reads them from the machine, - // matching the establish paths that write them on the machine directly. - if let Some(their) = connection.their_index() { - machine.set_conn_their_index(their); + let started_at_ms = seed.started_at_ms; + let expected_identity = seed.expected_identity; + let machine = + self.peer_machines + .entry(link_id) + .or_insert_with(|| match expected_identity { + Some(_) => PeerMachine::new_outbound(link_id, expected_identity, started_at_ms), + None => PeerMachine::new_inbound(link_id, started_at_ms), + }); + if let Some(index) = seed.our_index { + machine.set_conn_our_index(index); } - if let Some(tid) = connection.transport_id() { - machine.set_conn_transport_id(tid); + if let Some(index) = seed.their_index { + machine.set_conn_their_index(index); } - machine.set_leg(connection); + if let Some(id) = seed.transport_id { + machine.set_conn_transport_id(id); + } + if let Some(addr) = seed.source_addr { + machine.set_conn_source_addr(addr); + } + machine.set_leg(crate::peer::machine::HandshakeCrypto::new()); Ok(()) } - /// Get a connection by LinkId. - pub fn get_connection(&self, link_id: &LinkId) -> Option<&PeerConnection> { - self.leg(link_id) - } - - /// Get a mutable connection by LinkId. - pub fn get_connection_mut(&mut self, link_id: &LinkId) -> Option<&mut PeerConnection> { - self.leg_mut(link_id) - } - - /// Remove a connection, disposing its control machine alongside - /// (the disposal complement of `add_connection`'s machine seeding). The - /// connection is taken off the machine BEFORE the machine is dropped, so - /// the caller still receives it. - pub fn remove_connection(&mut self, link_id: &LinkId) -> Option { - let connection = self - .peer_machines - .get_mut(link_id) - .and_then(|machine| machine.take_leg()); - self.remove_peer_machine(*link_id); - connection - } - - /// Iterate over all connections. - pub fn connections(&self) -> impl Iterator { + /// Iterate over the control machines that carry a pending connection. + /// + /// Carrying a pending connection is what makes a machine handshake-phase, + /// so the filter below is the membership rule. It is the same predicate + /// that `connection_count` applies, and the one the stale-connection sweep + /// narrows further. + /// + /// Internal to the crate: this yields the control machine, which is not + /// part of the published surface. Callers outside the crate that need a + /// view of the pending handshakes go through the operator queries. + pub(crate) fn connections(&self) -> impl Iterator { self.peer_machines - .values() - .filter_map(|machine| machine.leg()) + .iter() + .filter(|(_, machine)| machine.leg().is_some()) } // === Peer Management (Active Phase) === @@ -3289,3 +3275,60 @@ impl fmt::Debug for Node { .finish() } } + +/// Test-support seed spec for [`Node::seed_handshake_machine`]. +/// +/// Carries the carrier fields a seeded machine needs before any crypto runs. +/// Only fields the establish paths write at seed time belong here; the Noise +/// handshake is driven afterwards through the machine's own crypto methods. +#[cfg(test)] +#[derive(Debug, Clone)] +pub(crate) struct HandshakeSeed { + link_id: LinkId, + expected_identity: Option, + started_at_ms: u64, + transport_id: Option, + source_addr: Option, + our_index: Option, + their_index: Option, +} + +#[cfg(test)] +impl HandshakeSeed { + /// Outbound leg: we know who we are dialing. + pub(crate) fn outbound( + link_id: LinkId, + expected_identity: PeerIdentity, + started_at_ms: u64, + ) -> Self { + Self { + link_id, + expected_identity: Some(expected_identity), + started_at_ms, + transport_id: None, + source_addr: None, + our_index: None, + their_index: None, + } + } + + pub(crate) fn with_transport_id(mut self, transport_id: TransportId) -> Self { + self.transport_id = Some(transport_id); + self + } + + pub(crate) fn with_source_addr(mut self, source_addr: TransportAddr) -> Self { + self.source_addr = Some(source_addr); + self + } + + pub(crate) fn with_our_index(mut self, index: crate::utils::index::SessionIndex) -> Self { + self.our_index = Some(index); + self + } + + pub(crate) fn with_their_index(mut self, index: crate::utils::index::SessionIndex) -> Self { + self.their_index = Some(index); + self + } +} diff --git a/src/node/tests/acl.rs b/src/node/tests/acl.rs index 58b869f..8fdef8b 100644 --- a/src/node/tests/acl.rs +++ b/src/node/tests/acl.rs @@ -47,6 +47,12 @@ async fn test_outbound_connect_denied_by_denylist() { assert_eq!(node.peer_count(), 0); } +// The master-line `test_inbound_msg1_denied_by_acl` has no counterpart here. +// Under XX, msg1 is an ephemeral-only exchange that carries no identity, so +// there is no peer to authorize at that step and no ACL decision to assert. +// The equivalent gate on this line runs once msg3 has revealed the initiator's +// static key; `test_inbound_msg3_denied_by_acl` covers it there. + #[tokio::test] async fn test_outbound_msg2_denied_after_acl_reload() { let (dir, mut node_a) = make_acl_node(); @@ -56,14 +62,23 @@ async fn test_outbound_msg2_denied_after_acl_reload() { let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity().pubkey_full()); let link_id_a = node_a.allocate_link_id(); - let mut conn_a = PeerConnection::outbound(link_id_a, peer_b_identity, 1000); let our_index_a = node_a.index_allocator.allocate().unwrap(); - let noise_msg1 = conn_a - .start_handshake(node_a.identity().keypair(), node_a.startup_epoch(), 1000) + node_a + .seed_handshake_machine( + HandshakeSeed::outbound(link_id_a, peer_b_identity, 1000) + .with_our_index(our_index_a) + .with_transport_id(transport_id) + .with_source_addr(remote_addr.clone()), + ) + .unwrap(); + let keypair_a = node_a.identity().keypair(); + let epoch_a = node_a.startup_epoch(); + let noise_msg1 = node_a + .peer_machines + .get_mut(&link_id_a) + .unwrap() + .start_handshake(keypair_a, epoch_a, 1000) .unwrap(); - conn_a.set_our_index(our_index_a); - conn_a.set_transport_id(transport_id); - conn_a.set_source_addr(remote_addr.clone()); let link_a = Link::connectionless( link_id_a, @@ -76,12 +91,11 @@ async fn test_outbound_msg2_denied_after_acl_reload() { node_a .addr_to_link .insert((transport_id, remote_addr.clone()), link_id_a); - node_a.add_connection(conn_a).unwrap(); node_a .pending_outbound .insert((transport_id, our_index_a.as_u32()), link_id_a); - let mut conn_b = PeerConnection::inbound(LinkId::new(2), 1000); + let mut conn_b = inbound_leg(LinkId::new(2), 1000); let responder_epoch = [0x11; 8]; let noise_msg2 = conn_b .receive_handshake_init( @@ -175,14 +189,25 @@ async fn test_inbound_msg3_denied_triggers_disconnect() { let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity().pubkey_full()); let link_id_a = node_a.allocate_link_id(); - let mut conn_a = PeerConnection::outbound(link_id_a, peer_b_identity, 1000); let our_index_a = node_a.index_allocator.allocate().unwrap(); - let noise_msg1 = conn_a - .start_handshake(node_a.identity().keypair(), node_a.startup_epoch(), 1000) + // Mirror the production dial path: the seam seeds the outbound leg's + // control machine, which owns the handshake crypto. + node_a + .seed_handshake_machine( + HandshakeSeed::outbound(link_id_a, peer_b_identity, 1000) + .with_our_index(our_index_a) + .with_transport_id(transport_id_a) + .with_source_addr(addr_b.clone()), + ) + .unwrap(); + let our_keypair_a = node_a.identity().keypair(); + let startup_epoch_a = node_a.startup_epoch(); + let noise_msg1 = node_a + .peer_machines + .get_mut(&link_id_a) + .unwrap() + .start_handshake(our_keypair_a, startup_epoch_a, 1000) .unwrap(); - conn_a.set_our_index(our_index_a); - conn_a.set_transport_id(transport_id_a); - conn_a.set_source_addr(addr_b.clone()); let wire_msg1 = build_msg1(our_index_a, &noise_msg1); let link_a = Link::connectionless( @@ -196,9 +221,6 @@ async fn test_inbound_msg3_denied_triggers_disconnect() { node_a .addr_to_link .insert((transport_id_a, addr_b.clone()), link_id_a); - // Mirror the production dial path: the seam seeds the outbound leg's - // control machine and embeds the connection on it. - node_a.add_connection(conn_a).unwrap(); node_a .pending_outbound .insert((transport_id_a, our_index_a.as_u32()), link_id_a); @@ -308,3 +330,9 @@ async fn test_outbound_connect_not_denied_by_allowlist_miss() { assert!(!matches!(result, Err(NodeError::AccessDenied(_)))); } + +// The master-line `test_acl_rejected_msg1_leaves_no_registry_trace` asserts a +// no-registry-trace property at the msg1 ACL denial. XX has no ACL decision at +// msg1 (identity is unknown until msg3), so the property has no landing site at +// that step. The same guard belongs at the msg3 `authorize_peer` gate, where +// this line actually rejects an inbound peer. diff --git a/src/node/tests/bootstrap.rs b/src/node/tests/bootstrap.rs index 6eb31bd..840d85b 100644 --- a/src/node/tests/bootstrap.rs +++ b/src/node/tests/bootstrap.rs @@ -143,9 +143,8 @@ async fn test_adopted_traversal_skips_already_connected_peer() { let transport_id = TransportId::new(1); let link_id = LinkId::new(1); - let (conn, peer_identity) = make_completed_connection(&mut node, link_id, transport_id, 1_000); + let peer_identity = seed_completed_connection(&mut node, link_id, transport_id, 1_000); let peer_node_addr = *peer_identity.node_addr(); - node.add_connection(conn).unwrap(); node.promote_connection(link_id, peer_identity, 2_000) .unwrap(); diff --git a/src/node/tests/decrypt_failure.rs b/src/node/tests/decrypt_failure.rs index 8509339..b590be3 100644 --- a/src/node/tests/decrypt_failure.rs +++ b/src/node/tests/decrypt_failure.rs @@ -28,10 +28,9 @@ fn test_decrypt_failure_threshold_removes_peer() { // Build a fully-promoted active peer with our_index/transport_id set // so peers_by_index is populated by promote_connection. - let (conn, identity) = make_completed_connection(&mut node, link_id, transport_id, 1_000); + let identity = seed_completed_connection(&mut node, link_id, transport_id, 1_000); let node_addr = *identity.node_addr(); - node.add_connection(conn).unwrap(); node.promote_connection(link_id, identity, 2_000).unwrap(); // Sanity: peer is registered and indexed. diff --git a/src/node/tests/handshake.rs b/src/node/tests/handshake.rs index 68ccd55..5e60ba4 100644 --- a/src/node/tests/handshake.rs +++ b/src/node/tests/handshake.rs @@ -56,19 +56,28 @@ async fn test_two_node_handshake_udp() { let peer_b_node_addr = *peer_b_identity.node_addr(); let link_id_a = node_a.allocate_link_id(); - let mut conn_a = PeerConnection::outbound(link_id_a, peer_b_identity, 1000); // Allocate session index for A's outbound let our_index_a = node_a.index_allocator.allocate().unwrap(); + node_a + .seed_handshake_machine( + HandshakeSeed::outbound(link_id_a, peer_b_identity, 1000) + .with_our_index(our_index_a) + .with_transport_id(transport_id_a) + .with_source_addr(remote_addr_b.clone()), + ) + .unwrap(); + // Start handshake (generates Noise XX msg1) let our_keypair_a = node_a.identity().keypair(); - let noise_msg1 = conn_a - .start_handshake(our_keypair_a, node_a.startup_epoch(), 1000) + let startup_epoch_a = node_a.startup_epoch(); + let noise_msg1 = node_a + .peer_machines + .get_mut(&link_id_a) + .unwrap() + .start_handshake(our_keypair_a, startup_epoch_a, 1000) .unwrap(); - conn_a.set_our_index(our_index_a); - conn_a.set_transport_id(transport_id_a); - conn_a.set_source_addr(remote_addr_b.clone()); // Build wire msg1 and track in node state let wire_msg1 = build_msg1(our_index_a, &noise_msg1); @@ -81,7 +90,6 @@ async fn test_two_node_handshake_udp() { Duration::from_millis(100), ); node_a.links.insert(link_id_a, link_a); - node_a.add_connection(conn_a).unwrap(); node_a .pending_outbound .insert((transport_id_a, our_index_a.as_u32()), link_id_a); @@ -319,16 +327,24 @@ async fn test_run_rx_loop_handshake() { let peer_b_node_addr = *peer_b_identity.node_addr(); let link_id_a = node_a.allocate_link_id(); - let mut conn_a = PeerConnection::outbound(link_id_a, peer_b_identity, 1000); let our_index_a = node_a.index_allocator.allocate().unwrap(); - let our_keypair_a = node_a.identity().keypair(); - let noise_msg1 = conn_a - .start_handshake(our_keypair_a, node_a.startup_epoch(), 1000) + node_a + .seed_handshake_machine( + HandshakeSeed::outbound(link_id_a, peer_b_identity, 1000) + .with_our_index(our_index_a) + .with_transport_id(transport_id_a) + .with_source_addr(remote_addr_b.clone()), + ) + .unwrap(); + let our_keypair_a = node_a.identity().keypair(); + let startup_epoch_a = node_a.startup_epoch(); + let noise_msg1 = node_a + .peer_machines + .get_mut(&link_id_a) + .unwrap() + .start_handshake(our_keypair_a, startup_epoch_a, 1000) .unwrap(); - conn_a.set_our_index(our_index_a); - conn_a.set_transport_id(transport_id_a); - conn_a.set_source_addr(remote_addr_b.clone()); let wire_msg1 = build_msg1(our_index_a, &noise_msg1); @@ -340,7 +356,6 @@ async fn test_run_rx_loop_handshake() { Duration::from_millis(100), ); node_a.links.insert(link_id_a, link_a); - node_a.add_connection(conn_a).unwrap(); node_a .pending_outbound .insert((transport_id_a, our_index_a.as_u32()), link_id_a); @@ -505,15 +520,23 @@ async fn test_cross_connection_both_initiate() { // Node A initiates to Node B let link_id_a_out = node_a.allocate_link_id(); - let mut conn_a = PeerConnection::outbound(link_id_a_out, peer_b_identity, 1000); let our_index_a = node_a.index_allocator.allocate().unwrap(); - let our_keypair_a = node_a.identity().keypair(); - let noise_msg1_a = conn_a - .start_handshake(our_keypair_a, node_a.startup_epoch(), 1000) + node_a + .seed_handshake_machine( + HandshakeSeed::outbound(link_id_a_out, peer_b_identity, 1000) + .with_our_index(our_index_a) + .with_transport_id(transport_id_a) + .with_source_addr(remote_addr_b.clone()), + ) + .unwrap(); + let our_keypair_a = node_a.identity().keypair(); + let startup_epoch_a = node_a.startup_epoch(); + let noise_msg1_a = node_a + .peer_machines + .get_mut(&link_id_a_out) + .unwrap() + .start_handshake(our_keypair_a, startup_epoch_a, 1000) .unwrap(); - conn_a.set_our_index(our_index_a); - conn_a.set_transport_id(transport_id_a); - conn_a.set_source_addr(remote_addr_b.clone()); let wire_msg1_a = build_msg1(our_index_a, &noise_msg1_a); @@ -528,22 +551,29 @@ async fn test_cross_connection_both_initiate() { node_a .addr_to_link .insert((transport_id_a, remote_addr_b.clone()), link_id_a_out); - node_a.add_connection(conn_a).unwrap(); node_a .pending_outbound .insert((transport_id_a, our_index_a.as_u32()), link_id_a_out); // Node B initiates to Node A let link_id_b_out = node_b.allocate_link_id(); - let mut conn_b = PeerConnection::outbound(link_id_b_out, peer_a_identity, 1000); let our_index_b = node_b.index_allocator.allocate().unwrap(); - let our_keypair_b = node_b.identity().keypair(); - let noise_msg1_b = conn_b - .start_handshake(our_keypair_b, node_b.startup_epoch(), 1000) + node_b + .seed_handshake_machine( + HandshakeSeed::outbound(link_id_b_out, peer_a_identity, 1000) + .with_our_index(our_index_b) + .with_transport_id(transport_id_b) + .with_source_addr(remote_addr_a.clone()), + ) + .unwrap(); + let our_keypair_b = node_b.identity().keypair(); + let startup_epoch_b = node_b.startup_epoch(); + let noise_msg1_b = node_b + .peer_machines + .get_mut(&link_id_b_out) + .unwrap() + .start_handshake(our_keypair_b, startup_epoch_b, 1000) .unwrap(); - conn_b.set_our_index(our_index_b); - conn_b.set_transport_id(transport_id_b); - conn_b.set_source_addr(remote_addr_a.clone()); let wire_msg1_b = build_msg1(our_index_b, &noise_msg1_b); @@ -558,7 +588,6 @@ async fn test_cross_connection_both_initiate() { node_b .addr_to_link .insert((transport_id_b, remote_addr_a.clone()), link_id_b_out); - node_b.add_connection(conn_b).unwrap(); node_b .pending_outbound .insert((transport_id_b, our_index_b.as_u32()), link_id_b_out); @@ -703,17 +732,24 @@ async fn test_stale_connection_cleanup() { // Create outbound connection with a timestamp far in the past let past_time_ms = 1000; // A very early timestamp let link_id = node.allocate_link_id(); - let mut conn = PeerConnection::outbound(link_id, peer_identity, past_time_ms); // Allocate session index and set transport info let our_index = node.index_allocator.allocate().unwrap(); + node.seed_handshake_machine( + HandshakeSeed::outbound(link_id, peer_identity, past_time_ms) + .with_our_index(our_index) + .with_transport_id(transport_id) + .with_source_addr(remote_addr.clone()), + ) + .unwrap(); let our_keypair = node.identity().keypair(); - let _noise_msg1 = conn - .start_handshake(our_keypair, node.startup_epoch(), past_time_ms) + let startup_epoch = node.startup_epoch(); + let _noise_msg1 = node + .peer_machines + .get_mut(&link_id) + .unwrap() + .start_handshake(our_keypair, startup_epoch, past_time_ms) .unwrap(); - conn.set_our_index(our_index); - conn.set_transport_id(transport_id); - conn.set_source_addr(remote_addr.clone()); // Set up all the state that initiate_peer_connection would create let link = Link::connectionless( @@ -726,7 +762,6 @@ async fn test_stale_connection_cleanup() { node.links.insert(link_id, link); node.addr_to_link .insert((transport_id, remote_addr.clone()), link_id); - node.add_connection(conn).unwrap(); node.pending_outbound .insert((transport_id, our_index.as_u32()), link_id); @@ -782,16 +817,23 @@ async fn test_failed_connection_cleanup() { .map(|d| d.as_millis() as u64) .unwrap_or(0); let link_id = node.allocate_link_id(); - let mut conn = PeerConnection::outbound(link_id, peer_identity, now_ms); let our_index = node.index_allocator.allocate().unwrap(); + node.seed_handshake_machine( + HandshakeSeed::outbound(link_id, peer_identity, now_ms) + .with_our_index(our_index) + .with_transport_id(transport_id) + .with_source_addr(remote_addr.clone()), + ) + .unwrap(); let our_keypair = node.identity().keypair(); - let _noise_msg1 = conn - .start_handshake(our_keypair, node.startup_epoch(), now_ms) + let startup_epoch = node.startup_epoch(); + let _noise_msg1 = node + .peer_machines + .get_mut(&link_id) + .unwrap() + .start_handshake(our_keypair, startup_epoch, now_ms) .unwrap(); - conn.set_our_index(our_index); - conn.set_transport_id(transport_id); - conn.set_source_addr(remote_addr.clone()); let link = Link::connectionless( link_id, @@ -803,7 +845,6 @@ async fn test_failed_connection_cleanup() { node.links.insert(link_id, link); node.addr_to_link .insert((transport_id, remote_addr.clone()), link_id); - node.add_connection(conn).unwrap(); node.pending_outbound .insert((transport_id, our_index.as_u32()), link_id); @@ -814,7 +855,7 @@ async fn test_failed_connection_cleanup() { let machine = node .peer_machines .get_mut(&link_id) - .expect("machine seeded by add_connection"); + .expect("machine seeded by the handshake seeder"); let alloc = &mut node.index_allocator; let actions = machine.step( crate::peer::machine::PeerEvent::HandshakeSendFailed, @@ -859,24 +900,24 @@ async fn test_msg1_stored_for_resend() { .map(|d| d.as_millis() as u64) .unwrap_or(0); let link_id = node.allocate_link_id(); - let mut conn = PeerConnection::outbound(link_id, peer_identity, now_ms); + let mut conn = outbound_leg(link_id, peer_identity, now_ms); let our_index = node.index_allocator.allocate().unwrap(); let our_keypair = node.identity().keypair(); let noise_msg1 = conn .start_handshake(our_keypair, node.startup_epoch(), now_ms) .unwrap(); - conn.set_our_index(our_index); - conn.set_transport_id(transport_id); - conn.set_source_addr(remote_addr.clone()); + conn.set_conn_our_index(our_index); + conn.set_conn_transport_id(transport_id); + conn.set_conn_source_addr(remote_addr.clone()); // Build wire msg1 and store it (as initiate_peer_connection does) let wire_msg1 = build_msg1(our_index, &noise_msg1); let resend_interval = node.config().node.rate_limit.handshake_resend_interval_ms; - conn.set_handshake_msg1(wire_msg1.clone(), now_ms + resend_interval); + conn.set_conn_handshake_msg1(wire_msg1.clone(), now_ms + resend_interval); // Verify stored msg1 matches what was built - assert_eq!(conn.handshake_msg1().unwrap(), &wire_msg1); + assert_eq!(conn.conn_handshake_msg1().unwrap(), &wire_msg1); } /// Test that resend scheduling respects max_resends and backoff. @@ -890,20 +931,17 @@ async fn test_resend_scheduling() { let now_ms = 100_000u64; // Use a fixed time for predictable testing let link_id = node.allocate_link_id(); - let mut conn = PeerConnection::outbound(link_id, peer_identity, now_ms); + let mut conn = outbound_leg(link_id, peer_identity, now_ms); let our_index = node.index_allocator.allocate().unwrap(); let our_keypair = node.identity().keypair(); let noise_msg1 = conn .start_handshake(our_keypair, node.startup_epoch(), now_ms) .unwrap(); - conn.set_our_index(our_index); - conn.set_transport_id(transport_id); - conn.set_source_addr(remote_addr.clone()); + conn.set_conn_source_addr(remote_addr.clone()); // Store msg1 with first resend at now + 1000ms let wire_msg1 = crate::proto::fmp::wire::build_msg1(our_index, &noise_msg1); - conn.set_handshake_msg1(wire_msg1.clone(), now_ms + 1000); let link = Link::connectionless( link_id, @@ -937,7 +975,9 @@ async fn test_resend_scheduling() { // The msg1 wire lives on the machine's carrier (the retransmit driver's // resend source), mirroring `prepare_outbound_msg1`. machine.set_conn_handshake_msg1(wire_msg1, now_ms + 1000); - machine.set_leg(conn); + machine.set_conn_our_index(our_index); + machine.set_conn_transport_id(transport_id); + machine.set_leg(conn.take_leg().unwrap()); node.peer_machines.insert(link_id, machine); node.peer_timers.entry(link_id).or_default().insert( crate::peer::machine::TimerKind::HandshakeRetransmit, @@ -976,15 +1016,13 @@ async fn test_handshake_timeout_drive() { let dial_ms = 1000u64; let link_id = node.allocate_link_id(); - let mut conn = PeerConnection::outbound(link_id, peer_identity, dial_ms); + let mut conn = outbound_leg(link_id, peer_identity, dial_ms); let our_index = node.index_allocator.allocate().unwrap(); let our_keypair = node.identity().keypair(); let _ = conn .start_handshake(our_keypair, node.startup_epoch(), dial_ms) .unwrap(); - conn.set_our_index(our_index); - conn.set_transport_id(transport_id); - conn.set_source_addr(remote_addr.clone()); + conn.set_conn_source_addr(remote_addr.clone()); let link = Link::connectionless( link_id, @@ -1013,7 +1051,9 @@ async fn test_handshake_timeout_drive() { dial_ms, &mut node.index_allocator, ); - machine.set_leg(conn); + machine.set_conn_our_index(our_index); + machine.set_conn_transport_id(transport_id); + machine.set_leg(conn.take_leg().unwrap()); node.peer_machines.insert(link_id, machine); node.peer_timers.entry(link_id).or_default().insert( crate::peer::machine::TimerKind::HandshakeTimeout, @@ -1041,17 +1081,17 @@ async fn test_handshake_timeout_drive() { ); } -/// Test that msg2 is stored on PeerConnection for responder resend. +/// Test that msg2 is stored on the control machine's carrier for responder resend. #[test] fn test_msg2_stored_on_connection() { - let mut conn = PeerConnection::inbound(LinkId::new(1), 1000); + let mut machine = crate::peer::machine::PeerMachine::new_inbound(LinkId::new(1), 1000); - assert!(conn.handshake_msg2().is_none()); + assert!(machine.conn_handshake_msg2().is_none()); let msg2_bytes = vec![0x01, 0x02, 0x03, 0x04]; - conn.set_handshake_msg2(msg2_bytes.clone()); + machine.set_conn_handshake_msg2(msg2_bytes.clone()); - assert_eq!(conn.handshake_msg2().unwrap(), &msg2_bytes); + assert_eq!(machine.conn_handshake_msg2().unwrap(), &msg2_bytes); } /// Test that duplicate msg2 is silently dropped when pending_outbound is already cleared. @@ -1480,15 +1520,28 @@ async fn drive_to_msg3( let peer_identity = PeerIdentity::from_pubkey_full(responder.node.identity().pubkey_full()); let link_id = initiator.node.allocate_link_id(); - let mut conn = PeerConnection::outbound(link_id, peer_identity, now_ms); let our_index = initiator.node.index_allocator.allocate().unwrap(); - let our_keypair = initiator.node.identity().keypair(); - let noise_msg1 = conn - .start_handshake(our_keypair, initiator.node.startup_epoch(), now_ms) + // Mirror the production dial path: the seam seeds the identified outbound + // leg's control machine at dial, and the promote feedback later + // crystallizes that same machine in place. + initiator + .node + .seed_handshake_machine( + HandshakeSeed::outbound(link_id, peer_identity, now_ms) + .with_our_index(our_index) + .with_transport_id(initiator.transport_id) + .with_source_addr(responder.addr.clone()), + ) + .unwrap(); + let our_keypair = initiator.node.identity().keypair(); + let startup_epoch = initiator.node.startup_epoch(); + let noise_msg1 = initiator + .node + .peer_machines + .get_mut(&link_id) + .unwrap() + .start_handshake(our_keypair, startup_epoch, now_ms) .unwrap(); - conn.set_our_index(our_index); - conn.set_transport_id(initiator.transport_id); - conn.set_source_addr(responder.addr.clone()); let wire_msg1 = build_msg1(our_index, &noise_msg1); let link = Link::connectionless( @@ -1503,10 +1556,6 @@ async fn drive_to_msg3( .node .addr_to_link .insert((initiator.transport_id, responder.addr.clone()), link_id); - // Mirror the production dial path: the seam seeds the identified outbound - // leg's control machine (carrying the connection) at dial, and the promote - // feedback later crystallizes that same machine in place. - initiator.node.add_connection(conn).unwrap(); initiator .node .pending_outbound @@ -1693,10 +1742,11 @@ async fn test_inbound_machine_born_at_msg1_and_crystallized_at_promote() { // After msg1 the responder's window leg carries a machine parked at // `SentMsg2`, seeded with the leg's msg1-allocated index. assert_eq!(responder.node.connection_count(), 1); - let leg_link = responder.node.connections().next().unwrap().link_id(); + let leg_link = responder.node.connections().next().unwrap().1.link_id(); let leg_index = responder .node - .get_connection(&leg_link) + .peer_machines + .get(&leg_link) .unwrap() .our_index(); assert!(leg_index.is_some(), "msg1 allocated the leg index"); @@ -1862,7 +1912,7 @@ async fn test_anonymous_dial_births_identityless_machine_at_leg_birth() { .expect("anonymous dial"); assert_eq!(initiator.node.connection_count(), 1); - let leg_link = initiator.node.connections().next().unwrap().link_id(); + let leg_link = initiator.node.connections().next().unwrap().1.link_id(); let machine = initiator .node .peer_machines @@ -1897,7 +1947,7 @@ async fn test_anonymous_msg2_crystallizes_identity_and_promotes() { .initiate_connection(initiator.transport_id, responder.addr.clone(), None) .await .expect("anonymous dial"); - let leg_link = initiator.node.connections().next().unwrap().link_id(); + let leg_link = initiator.node.connections().next().unwrap().1.link_id(); initiator.node.debug_assert_peer_maps_coherent(); // Responder answers msg1 with msg2; the initiator's msg2 processing learns @@ -1957,7 +2007,7 @@ async fn test_anonymous_self_connect_drop_disposes_machine() { .initiate_connection(node.transport_id, self_addr, None) .await .expect("anonymous self dial"); - let leg_link = node.node.connections().next().unwrap().link_id(); + let leg_link = node.node.connections().next().unwrap().1.link_id(); assert_eq!(node.node.peer_machines.len(), 1); // We answer our own msg1, then our msg2 processing discovers the learned @@ -1969,7 +2019,7 @@ async fn test_anonymous_self_connect_drop_disposes_machine() { assert_eq!(node.node.peer_count(), 0, "no promotion"); assert!( - node.node.get_connection(&leg_link).is_none(), + !node.node.has_pending_leg(&leg_link), "self-connect drop removes the outbound leg" ); assert!( diff --git a/src/node/tests/mod.rs b/src/node/tests/mod.rs index a540503..629e7e1 100644 --- a/src/node/tests/mod.rs +++ b/src/node/tests/mod.rs @@ -1,5 +1,6 @@ use super::*; use crate::PeerIdentity; +use crate::peer::machine::HandshakeCrypto; use crate::transport::{LinkDirection, ReceivedPacket, TransportAddr, packet_channel}; use crate::utils::index::SessionIndex; use std::time::Duration; @@ -83,31 +84,83 @@ pub(super) fn make_peer_identity() -> PeerIdentity { PeerIdentity::from_pubkey(identity.pubkey()) } -/// Create a PeerConnection with a completed Noise XX handshake. +/// A control machine carrying a fresh outbound connection, for tests that +/// drive one end of a handshake without a whole node behind it. The machine +/// owns the handshake operations, so it is what the crypto runs on. +pub(super) fn outbound_leg( + link_id: LinkId, + expected_identity: PeerIdentity, + current_time_ms: u64, +) -> PeerMachine { + let mut machine = PeerMachine::new_outbound(link_id, Some(expected_identity), current_time_ms); + machine.set_leg(HandshakeCrypto::new()); + machine +} + +/// The responder twin of [`outbound_leg`]. +pub(super) fn inbound_leg(link_id: LinkId, current_time_ms: u64) -> PeerMachine { + let mut machine = PeerMachine::new_inbound(link_id, current_time_ms); + machine.set_leg(HandshakeCrypto::new()); + machine +} + +/// Seed a control machine whose leg carries a completed Noise XX handshake. /// -/// Returns (connection, peer_identity) where the connection is outbound, -/// in Complete state, with session, indices, and transport info set. -pub(super) fn make_completed_connection( +/// Returns the peer identity. The leg is outbound, in Complete state, with +/// session, indices, and transport info set, and is installed on the node +/// through [`Node::seed_handshake_machine`]. +pub(super) fn seed_completed_connection( node: &mut Node, link_id: LinkId, transport_id: TransportId, current_time_ms: u64, -) -> (PeerConnection, PeerIdentity) { +) -> PeerIdentity { + let our_index = node.index_allocator.allocate().unwrap(); + seed_completed_connection_with(node, link_id, current_time_ms, |seed| { + seed.with_our_index(our_index) + .with_their_index(SessionIndex::new(42)) + .with_transport_id(transport_id) + .with_source_addr(TransportAddr::from_string("127.0.0.1:5000")) + }) +} + +/// [`seed_completed_connection`] with the seed left to the caller, for tests +/// that need a leg deliberately missing one of the fields promotion requires. +/// +/// The Noise exchange runs on the already-seeded machine, where it used to +/// run before the leg was handed over. That reordering is neutral: on XX the +/// responder's `receive_handshake_init` learns nothing (identity arrives at +/// msg3), and the seeded reads (`link_id`, `started_at`, `is_outbound`, +/// `their_index`, `transport_id`) are untouched by the handshake. +pub(super) fn seed_completed_connection_with( + node: &mut Node, + link_id: LinkId, + current_time_ms: u64, + shape: impl FnOnce(HandshakeSeed) -> HandshakeSeed, +) -> PeerIdentity { let peer_identity_full = Identity::generate(); // Must use from_pubkey_full to preserve parity for ECDH let peer_identity = PeerIdentity::from_pubkey_full(peer_identity_full.pubkey_full()); - // Create outbound connection - let mut conn = PeerConnection::outbound(link_id, peer_identity, current_time_ms); + node.seed_handshake_machine(shape(HandshakeSeed::outbound( + link_id, + peer_identity, + current_time_ms, + ))) + .unwrap(); // Run initiator side of handshake let our_keypair = node.identity().keypair(); - let msg1 = conn - .start_handshake(our_keypair, node.startup_epoch(), current_time_ms) + let startup_epoch = node.startup_epoch(); + let msg1 = node + .peer_machines + .get_mut(&link_id) + .unwrap() + .start_handshake(our_keypair, startup_epoch, current_time_ms) .unwrap(); // Run responder side to generate msg2 - let mut resp_conn = PeerConnection::inbound(LinkId::new(999), current_time_ms); + let mut resp_conn = inbound_leg(LinkId::new(999), current_time_ms); let peer_keypair = peer_identity_full.keypair(); let mut resp_epoch = [0u8; 8]; rand::Rng::fill_bytes(&mut rand::rng(), &mut resp_epoch); @@ -116,7 +169,10 @@ pub(super) fn make_completed_connection( .unwrap(); // Complete initiator handshake (XX: generates msg3) - let (msg3, _neg) = conn + let (msg3, _neg) = node + .peer_machines + .get_mut(&link_id) + .unwrap() .complete_handshake(&msg2, None, current_time_ms) .unwrap(); @@ -125,12 +181,5 @@ pub(super) fn make_completed_connection( .complete_handshake_msg3(&msg3, current_time_ms) .unwrap(); - // Set indices and transport info - let our_index = node.index_allocator.allocate().unwrap(); - conn.set_our_index(our_index); - conn.set_their_index(SessionIndex::new(42)); - conn.set_transport_id(transport_id); - conn.set_source_addr(TransportAddr::from_string("127.0.0.1:5000")); - - (conn, peer_identity) + peer_identity } diff --git a/src/node/tests/routing.rs b/src/node/tests/routing.rs index 5123581..cd6b1c2 100644 --- a/src/node/tests/routing.rs +++ b/src/node/tests/routing.rs @@ -29,9 +29,8 @@ fn test_routing_direct_peer() { let transport_id = TransportId::new(1); let link_id = LinkId::new(1); - let (conn, identity) = make_completed_connection(&mut node, link_id, transport_id, 1000); + let identity = seed_completed_connection(&mut node, link_id, transport_id, 1000); let peer_addr = *identity.node_addr(); - node.add_connection(conn).unwrap(); node.promote_connection(link_id, identity, 2000).unwrap(); let result = node.find_next_hop(&peer_addr); @@ -58,15 +57,13 @@ fn test_routing_bloom_filter_hit() { // Create two peers let link_id1 = LinkId::new(1); - let (conn1, id1) = make_completed_connection(&mut node, link_id1, transport_id, 1000); + let id1 = seed_completed_connection(&mut node, link_id1, transport_id, 1000); let peer1_addr = *id1.node_addr(); - node.add_connection(conn1).unwrap(); node.promote_connection(link_id1, id1, 2000).unwrap(); let link_id2 = LinkId::new(2); - let (conn2, id2) = make_completed_connection(&mut node, link_id2, transport_id, 1000); + let id2 = seed_completed_connection(&mut node, link_id2, transport_id, 1000); let peer2_addr = *id2.node_addr(); - node.add_connection(conn2).unwrap(); node.promote_connection(link_id2, id2, 2000).unwrap(); // Set up tree: we are root, both peers are our children @@ -115,10 +112,9 @@ fn test_routing_bloom_filter_multiple_hits_tiebreak() { let mut peer_addrs = Vec::new(); for i in 1..=3 { let link_id = LinkId::new(i); - let (conn, id) = make_completed_connection(&mut node, link_id, transport_id, 1000); + let id = seed_completed_connection(&mut node, link_id, transport_id, 1000); let addr = *id.node_addr(); peer_addrs.push(addr); - node.add_connection(conn).unwrap(); node.promote_connection(link_id, id, 2000).unwrap(); } @@ -166,9 +162,8 @@ fn test_routing_tree_fallback() { // Create a peer let link_id = LinkId::new(1); - let (conn, id) = make_completed_connection(&mut node, link_id, transport_id, 1000); + let id = seed_completed_connection(&mut node, link_id, transport_id, 1000); let peer_addr = *id.node_addr(); - node.add_connection(conn).unwrap(); node.promote_connection(link_id, id, 2000).unwrap(); // Set up tree state through the public API. @@ -220,9 +215,8 @@ fn test_routing_bloom_hit_not_closer_falls_through_to_tree() { // tree_peer: child of self, on the path to dest (greedy tree pick). let tree_link = LinkId::new(1); - let (tree_conn, tree_id) = make_completed_connection(&mut node, tree_link, transport_id, 1000); + let tree_id = seed_completed_connection(&mut node, tree_link, transport_id, 1000); let tree_peer_addr = *tree_id.node_addr(); - node.add_connection(tree_conn).unwrap(); node.promote_connection(tree_link, tree_id, 2000).unwrap(); // bloom_peer: also a child of self, but with a stale/false-positive @@ -230,10 +224,8 @@ fn test_routing_bloom_hit_not_closer_falls_through_to_tree() { // ours, so the self-distance check in select_best_candidate excludes // it — leaving zero viable bloom candidates. let bloom_link = LinkId::new(2); - let (bloom_conn, bloom_id) = - make_completed_connection(&mut node, bloom_link, transport_id, 1000); + let bloom_id = seed_completed_connection(&mut node, bloom_link, transport_id, 1000); let bloom_peer_addr = *bloom_id.node_addr(); - node.add_connection(bloom_conn).unwrap(); node.promote_connection(bloom_link, bloom_id, 2000).unwrap(); // Tree topology (we are root): @@ -300,8 +292,7 @@ fn test_routing_tree_no_coords_in_cache() { // Create a peer let link_id = LinkId::new(1); - let (conn, id) = make_completed_connection(&mut node, link_id, transport_id, 1000); - node.add_connection(conn).unwrap(); + let id = seed_completed_connection(&mut node, link_id, transport_id, 1000); node.promote_connection(link_id, id, 2000).unwrap(); // Destination not in bloom filters and not in coord cache @@ -319,9 +310,8 @@ fn test_routing_refreshes_coord_cache_ttl() { // Create a peer let link_id = LinkId::new(1); - let (conn, id) = make_completed_connection(&mut node, link_id, transport_id, 1000); + let id = seed_completed_connection(&mut node, link_id, transport_id, 1000); let peer_addr = *id.node_addr(); - node.add_connection(conn).unwrap(); node.promote_connection(link_id, id, 2000).unwrap(); // Set up tree coordinates @@ -364,15 +354,13 @@ fn test_routing_bloom_hit_without_coords_returns_none() { // Create two peers let link_id1 = LinkId::new(1); - let (conn1, id1) = make_completed_connection(&mut node, link_id1, transport_id, 1000); + let id1 = seed_completed_connection(&mut node, link_id1, transport_id, 1000); let peer1_addr = *id1.node_addr(); - node.add_connection(conn1).unwrap(); node.promote_connection(link_id1, id1, 2000).unwrap(); let link_id2 = LinkId::new(2); - let (conn2, id2) = make_completed_connection(&mut node, link_id2, transport_id, 1000); + let id2 = seed_completed_connection(&mut node, link_id2, transport_id, 1000); let peer2_addr = *id2.node_addr(); - node.add_connection(conn2).unwrap(); node.promote_connection(link_id2, id2, 2000).unwrap(); let dest = make_node_addr(99); @@ -404,9 +392,8 @@ fn test_routing_discovery_coord_cache() { // Create a peer let link_id = LinkId::new(1); - let (conn, id) = make_completed_connection(&mut node, link_id, transport_id, 1000); + let id = seed_completed_connection(&mut node, link_id, transport_id, 1000); let peer_addr = *id.node_addr(); - node.add_connection(conn).unwrap(); node.promote_connection(link_id, id, 2000).unwrap(); // Set up tree: we are root, peer is our child diff --git a/src/node/tests/session.rs b/src/node/tests/session.rs index 85e1f7d..a431f96 100644 --- a/src/node/tests/session.rs +++ b/src/node/tests/session.rs @@ -1005,9 +1005,7 @@ fn test_identity_cache_populated_on_promote() { let transport_id = TransportId::new(1); let link_id = LinkId::new(1); - let (conn, peer_identity) = make_completed_connection(&mut node, link_id, transport_id, 1000); - - node.add_connection(conn).unwrap(); + let peer_identity = seed_completed_connection(&mut node, link_id, transport_id, 1000); // Promote let result = node diff --git a/src/node/tests/spanning_tree.rs b/src/node/tests/spanning_tree.rs index 7a4286b..1ee532b 100644 --- a/src/node/tests/spanning_tree.rs +++ b/src/node/tests/spanning_tree.rs @@ -137,16 +137,26 @@ pub(super) async fn initiate_handshake(nodes: &mut [TestNode], i: usize, j: usiz let transport_id = initiator.transport_id; let link_id = initiator.node.allocate_link_id(); - let mut conn = PeerConnection::outbound(link_id, peer_identity, 1000); let our_index = initiator.node.index_allocator.allocate().unwrap(); - let our_keypair = initiator.node.identity().keypair(); - let noise_msg1 = conn - .start_handshake(our_keypair, initiator.node.startup_epoch(), 1000) + initiator + .node + .seed_handshake_machine( + HandshakeSeed::outbound(link_id, peer_identity, 1000) + .with_our_index(our_index) + .with_transport_id(transport_id) + .with_source_addr(responder_addr.clone()), + ) + .unwrap(); + let our_keypair = initiator.node.identity().keypair(); + let startup_epoch = initiator.node.startup_epoch(); + let noise_msg1 = initiator + .node + .peer_machines + .get_mut(&link_id) + .unwrap() + .start_handshake(our_keypair, startup_epoch, 1000) .unwrap(); - conn.set_our_index(our_index); - conn.set_transport_id(transport_id); - conn.set_source_addr(responder_addr.clone()); let wire_msg1 = build_msg1(our_index, &noise_msg1); @@ -162,7 +172,6 @@ pub(super) async fn initiate_handshake(nodes: &mut [TestNode], i: usize, j: usiz .node .addr_to_link .insert((transport_id, responder_addr.clone()), link_id); - initiator.node.add_connection(conn).unwrap(); initiator .node .pending_outbound diff --git a/src/node/tests/unit.rs b/src/node/tests/unit.rs index 1a11717..a67e75c 100644 --- a/src/node/tests/unit.rs +++ b/src/node/tests/unit.rs @@ -138,7 +138,7 @@ async fn test_try_peer_addresses_races_all_concrete_udp_candidates() { let mut addrs = node .connections() - .filter_map(|conn| conn.source_addr().and_then(|addr| addr.as_str())) + .filter_map(|(_, machine)| machine.conn_source_addr().and_then(|addr| addr.as_str())) .collect::>(); addrs.sort(); assert_eq!(addrs, vec!["127.0.0.1:10", "127.0.0.1:9"]); @@ -337,14 +337,14 @@ fn test_node_connection_management() { let identity = make_peer_identity(); let link_id = LinkId::new(1); - let conn = PeerConnection::outbound(link_id, identity, 1000); + node.seed_handshake_machine(HandshakeSeed::outbound(link_id, identity, 1000)) + .unwrap(); - node.add_connection(conn).unwrap(); assert_eq!(node.connection_count(), 1); - assert!(node.get_connection(&link_id).is_some()); + assert!(node.has_pending_leg(&link_id)); - node.remove_connection(&link_id); + node.remove_peer_machine(link_id); assert_eq!(node.connection_count(), 0); } @@ -354,29 +354,27 @@ fn test_node_connection_duplicate() { let identity = make_peer_identity(); let link_id = LinkId::new(1); - let conn1 = PeerConnection::outbound(link_id, identity, 1000); - let conn2 = PeerConnection::outbound(link_id, identity, 2000); + node.seed_handshake_machine(HandshakeSeed::outbound(link_id, identity, 1000)) + .unwrap(); - node.add_connection(conn1).unwrap(); - let result = node.add_connection(conn2); + let result = node.seed_handshake_machine(HandshakeSeed::outbound(link_id, identity, 2000)); assert!(matches!(result, Err(NodeError::ConnectionAlreadyExists(_)))); } #[cfg(debug_assertions)] #[test] -fn test_peer_maps_coherent_after_add_connection() { +fn test_peer_maps_coherent_after_seeding_a_handshake() { let mut node = make_node(); let identity = make_peer_identity(); let link_id = LinkId::new(1); - let conn = PeerConnection::outbound(link_id, identity, 1000); - - node.add_connection(conn).unwrap(); + node.seed_handshake_machine(HandshakeSeed::outbound(link_id, identity, 1000)) + .unwrap(); assert!( node.peer_machines.contains_key(&link_id), - "add_connection seeds a control machine for its leg" + "seeding a handshake creates its control machine" ); node.debug_assert_peer_maps_coherent(); } @@ -388,9 +386,8 @@ fn test_peer_maps_coherent_through_establish() { let transport_id = TransportId::new(1); let link_id = LinkId::new(1); - let (conn, identity) = make_completed_connection(&mut node, link_id, transport_id, 1000); + let identity = seed_completed_connection(&mut node, link_id, transport_id, 1000); - node.add_connection(conn).unwrap(); node.debug_assert_peer_maps_coherent(); let result = node.promote_connection(link_id, identity, 2000).unwrap(); @@ -421,16 +418,111 @@ fn test_peer_maps_coherence_detects_orphaned_machine() { ); } +/// Promotion detaches the pending connection before it validates anything, and +/// then validates the required fields in a fixed order. Both properties are +/// depended on: the caller of a rejected promotion disposes of a machine it +/// expects to be leg-less, and the reported error names the first field that is +/// missing, not an arbitrary one. +#[test] +fn test_promote_connection_error_order_and_leg_detach() { + fn expect_promotion_failure( + shape: impl FnOnce(HandshakeSeed) -> HandshakeSeed, + expected_reason: &str, + ) { + let mut node = make_node(); + let link_id = LinkId::new(1); + let identity = seed_completed_connection_with(&mut node, link_id, 1000, shape); + + let err = node + .promote_connection(link_id, identity, 2000) + .expect_err("promotion must reject an incomplete connection"); + match err { + NodeError::PromotionFailed { reason, .. } => assert_eq!( + reason, expected_reason, + "promotion named the wrong missing field" + ), + other => panic!("expected PromotionFailed, got {other:?}"), + } + assert!( + node.peer_machines + .get(&link_id) + .expect("the control machine survives a failed promotion") + .leg() + .is_none(), + "a failed promotion must leave the machine leg-less" + ); + assert_eq!(node.peer_count(), 0); + } + + // Each case leaves every later field missing as well, so a promotion that + // gathered all of them before validating would name the wrong one. + expect_promotion_failure(|seed| seed, "missing our_index"); + expect_promotion_failure( + |seed| seed.with_our_index(SessionIndex::new(7)), + "missing their_index", + ); + expect_promotion_failure( + |seed| { + seed.with_our_index(SessionIndex::new(7)) + .with_their_index(SessionIndex::new(42)) + }, + "missing transport_id", + ); + expect_promotion_failure( + |seed| { + seed.with_our_index(SessionIndex::new(7)) + .with_their_index(SessionIndex::new(42)) + .with_transport_id(TransportId::new(1)) + }, + "missing source_addr", + ); + + // An unknown link and a machine carrying no connection are both + // ConnectionNotFound, ahead of every field check. + let mut node = make_node(); + let identity = make_peer_identity(); + assert!(matches!( + node.promote_connection(LinkId::new(9), identity, 2000), + Err(NodeError::ConnectionNotFound(_)) + )); + + let link_id = LinkId::new(2); + node.peer_machines + .insert(link_id, PeerMachine::new_inbound(link_id, 1000)); + assert!(matches!( + node.promote_connection(link_id, identity, 2000), + Err(NodeError::ConnectionNotFound(_)) + )); + + // A connection that never ran the handshake fails on the session check, + // after the detach and ahead of every field check — the seed below + // supplies none of the four fields above. + let link_id = LinkId::new(3); + node.seed_handshake_machine(HandshakeSeed::outbound(link_id, identity, 1000)) + .unwrap(); + assert!(matches!( + node.promote_connection(link_id, identity, 2000), + Err(NodeError::HandshakeIncomplete(_)) + )); + assert!( + node.peer_machines + .get(&link_id) + .expect("the control machine survives a failed promotion") + .leg() + .is_none(), + "an incomplete handshake must still leave the machine leg-less" + ); +} + #[test] fn test_node_promote_connection() { let mut node = make_node(); let transport_id = TransportId::new(1); let link_id = LinkId::new(1); - let (conn, identity) = make_completed_connection(&mut node, link_id, transport_id, 1000); + let identity = seed_completed_connection(&mut node, link_id, transport_id, 1000); let node_addr = *identity.node_addr(); - node.add_connection(conn).unwrap(); assert_eq!(node.connection_count(), 1); assert_eq!(node.peer_count(), 0); @@ -467,10 +559,9 @@ fn test_node_cross_connection_resolution() { // First connection and promotion (becomes active peer) let link_id1 = LinkId::new(1); - let (conn1, identity) = make_completed_connection(&mut node, link_id1, transport_id, 1000); + let identity = seed_completed_connection(&mut node, link_id1, transport_id, 1000); let node_addr = *identity.node_addr(); - node.add_connection(conn1).unwrap(); node.promote_connection(link_id1, identity, 1500).unwrap(); assert_eq!(node.peer_count(), 1); @@ -500,8 +591,7 @@ fn test_node_peer_limit() { // Add two peers via promotion for i in 0..2 { let link_id = LinkId::new(i as u64 + 1); - let (conn, identity) = make_completed_connection(&mut node, link_id, transport_id, 1000); - node.add_connection(conn).unwrap(); + let identity = seed_completed_connection(&mut node, link_id, transport_id, 1000); node.promote_connection(link_id, identity, 2000).unwrap(); } @@ -509,8 +599,7 @@ fn test_node_peer_limit() { // Third should fail let link_id = LinkId::new(3); - let (conn, identity) = make_completed_connection(&mut node, link_id, transport_id, 3000); - node.add_connection(conn).unwrap(); + let identity = seed_completed_connection(&mut node, link_id, transport_id, 3000); let result = node.promote_connection(link_id, identity, 4000); assert!(matches!(result, Err(NodeError::MaxPeersExceeded { .. }))); @@ -558,22 +647,19 @@ fn test_node_sendable_peers() { // Add a healthy peer let link_id1 = LinkId::new(1); - let (conn1, identity1) = make_completed_connection(&mut node, link_id1, transport_id, 1000); + let identity1 = seed_completed_connection(&mut node, link_id1, transport_id, 1000); let node_addr1 = *identity1.node_addr(); - node.add_connection(conn1).unwrap(); node.promote_connection(link_id1, identity1, 2000).unwrap(); // Add another peer and mark it stale (still sendable) let link_id2 = LinkId::new(2); - let (conn2, identity2) = make_completed_connection(&mut node, link_id2, transport_id, 1000); - node.add_connection(conn2).unwrap(); + let identity2 = seed_completed_connection(&mut node, link_id2, transport_id, 1000); node.promote_connection(link_id2, identity2, 2000).unwrap(); // Add a third peer and mark it disconnected (not sendable) let link_id3 = LinkId::new(3); - let (conn3, identity3) = make_completed_connection(&mut node, link_id3, transport_id, 1000); + let identity3 = seed_completed_connection(&mut node, link_id3, transport_id, 1000); let node_addr3 = *identity3.node_addr(); - node.add_connection(conn3).unwrap(); node.promote_connection(link_id3, identity3, 2000).unwrap(); node.get_peer_mut(&node_addr3).unwrap().mark_disconnected(); @@ -708,20 +794,25 @@ fn test_promote_cleans_up_pending_outbound_to_same_peer() { // This simulates A having sent msg1 to B before B was running. let pending_link_id = LinkId::new(1); let pending_time_ms = 1000; - let mut pending_conn = - PeerConnection::outbound(pending_link_id, peer_b_identity, pending_time_ms); + let pending_index = node.index_allocator.allocate().unwrap(); + let pending_addr = TransportAddr::from_string("10.0.0.2:2121"); + node.seed_handshake_machine( + HandshakeSeed::outbound(pending_link_id, peer_b_identity, pending_time_ms) + .with_our_index(pending_index) + .with_transport_id(transport_id) + .with_source_addr(pending_addr.clone()), + ) + .unwrap(); let our_keypair = node.identity().keypair(); - let _msg1 = pending_conn - .start_handshake(our_keypair, node.startup_epoch(), pending_time_ms) + let startup_epoch = node.startup_epoch(); + let _msg1 = node + .peer_machines + .get_mut(&pending_link_id) + .unwrap() + .start_handshake(our_keypair, startup_epoch, pending_time_ms) .unwrap(); - let pending_index = node.index_allocator.allocate().unwrap(); - pending_conn.set_our_index(pending_index); - pending_conn.set_transport_id(transport_id); - let pending_addr = TransportAddr::from_string("10.0.0.2:2121"); - pending_conn.set_source_addr(pending_addr.clone()); - let pending_link = Link::connectionless( pending_link_id, transport_id, @@ -732,7 +823,6 @@ fn test_promote_cleans_up_pending_outbound_to_same_peer() { node.links.insert(pending_link_id, pending_link); node.addr_to_link .insert((transport_id, pending_addr.clone()), pending_link_id); - node.add_connection(pending_conn).unwrap(); node.pending_outbound .insert((transport_id, pending_index.as_u32()), pending_link_id); @@ -747,16 +837,27 @@ fn test_promote_cleans_up_pending_outbound_to_same_peer() { let completing_link_id = LinkId::new(2); let completing_time_ms = 2000; - let mut completing_conn = - PeerConnection::outbound(completing_link_id, peer_b_identity, completing_time_ms); + let completing_index = node.index_allocator.allocate().unwrap(); + node.seed_handshake_machine( + HandshakeSeed::outbound(completing_link_id, peer_b_identity, completing_time_ms) + .with_our_index(completing_index) + .with_their_index(SessionIndex::new(99)) + .with_transport_id(transport_id) + .with_source_addr(TransportAddr::from_string("10.0.0.2:4001")), + ) + .unwrap(); let our_keypair = node.identity().keypair(); - let msg1 = completing_conn - .start_handshake(our_keypair, node.startup_epoch(), completing_time_ms) + let startup_epoch = node.startup_epoch(); + let msg1 = node + .peer_machines + .get_mut(&completing_link_id) + .unwrap() + .start_handshake(our_keypair, startup_epoch, completing_time_ms) .unwrap(); // B responds - let mut resp_conn = PeerConnection::inbound(LinkId::new(999), completing_time_ms); + let mut resp_conn = inbound_leg(LinkId::new(999), completing_time_ms); let peer_keypair = peer_b_full.keypair(); let mut resp_epoch = [0u8; 8]; rand::Rng::fill_bytes(&mut rand::rng(), &mut resp_epoch); @@ -764,21 +865,16 @@ fn test_promote_cleans_up_pending_outbound_to_same_peer() { .receive_handshake_init(peer_keypair, resp_epoch, &msg1, None, completing_time_ms) .unwrap(); - let (msg3, _neg) = completing_conn + let (msg3, _neg) = node + .peer_machines + .get_mut(&completing_link_id) + .unwrap() .complete_handshake(&msg2, None, completing_time_ms) .unwrap(); resp_conn .complete_handshake_msg3(&msg3, completing_time_ms) .unwrap(); - let completing_index = node.index_allocator.allocate().unwrap(); - completing_conn.set_our_index(completing_index); - completing_conn.set_their_index(SessionIndex::new(99)); - completing_conn.set_transport_id(transport_id); - completing_conn.set_source_addr(TransportAddr::from_string("10.0.0.2:4001")); - - node.add_connection(completing_conn).unwrap(); - // Now 2 connections, 1 link (pending has link, completing doesn't yet need one for this test) assert_eq!(node.connection_count(), 2); assert_eq!(node.index_allocator.count(), 2); @@ -1042,9 +1138,8 @@ fn test_schedule_retry_skips_connected_peer() { // Promote a peer so it's in the peers map let link_id = LinkId::new(1); - let (conn, identity) = make_completed_connection(&mut node, link_id, transport_id, 1000); + let identity = seed_completed_connection(&mut node, link_id, transport_id, 1000); let node_addr = *identity.node_addr(); - node.add_connection(conn).unwrap(); node.promote_connection(link_id, identity, 2000).unwrap(); assert_eq!(node.peer_count(), 1); @@ -1061,10 +1156,9 @@ async fn test_try_peer_addresses_skips_connected_peer() { let mut node = make_node(); let transport_id = TransportId::new(1); let link_id = LinkId::new(1); - let (conn, peer_identity) = make_completed_connection(&mut node, link_id, transport_id, 1000); + let peer_identity = seed_completed_connection(&mut node, link_id, transport_id, 1000); let peer_config = crate::config::PeerConfig::new(peer_identity.npub(), "udp", "127.0.0.1:9"); - node.add_connection(conn).unwrap(); node.promote_connection(link_id, peer_identity, 2000) .unwrap(); let link_count = node.link_count(); @@ -1091,8 +1185,8 @@ async fn test_try_peer_addresses_skips_connecting_peer() { let mut node = make_node(); let peer_identity = make_peer_identity(); let peer_config = crate::config::PeerConfig::new(peer_identity.npub(), "udp", "127.0.0.1:9"); - let pending = PeerConnection::outbound(LinkId::new(1), peer_identity, 1000); - node.add_connection(pending).unwrap(); + node.seed_handshake_machine(HandshakeSeed::outbound(LinkId::new(1), peer_identity, 1000)) + .unwrap(); node.try_peer_addresses(&peer_config, peer_identity, true) .await @@ -1256,7 +1350,7 @@ async fn update_peers_races_new_alternative_without_dropping_active_peer() { assert_eq!( node.connections() .next() - .and_then(|conn| conn.source_addr()), + .and_then(|(_, machine)| machine.conn_source_addr()), Some(&new_addr) ); let active = node.get_peer(&peer_node_addr).unwrap(); @@ -1273,8 +1367,7 @@ async fn test_nostr_traversal_failure_skips_connected_peer() { let mut node = make_node(); let transport_id = TransportId::new(1); let link_id = LinkId::new(1); - let (conn, peer_identity) = make_completed_connection(&mut node, link_id, transport_id, 1000); - node.add_connection(conn).unwrap(); + let peer_identity = seed_completed_connection(&mut node, link_id, transport_id, 1000); node.promote_connection(link_id, peer_identity, 2000) .unwrap(); @@ -1307,8 +1400,7 @@ async fn test_nostr_traversal_established_skips_connected_peer() { let mut node = make_node(); let transport_id = TransportId::new(1); let link_id = LinkId::new(1); - let (conn, peer_identity) = make_completed_connection(&mut node, link_id, transport_id, 1000); - node.add_connection(conn).unwrap(); + let peer_identity = seed_completed_connection(&mut node, link_id, transport_id, 1000); node.promote_connection(link_id, peer_identity, 2000) .unwrap(); let link_count = node.link_count(); @@ -1528,7 +1620,7 @@ fn test_promote_clears_retry_pending() { let transport_id = TransportId::new(1); let link_id = LinkId::new(1); - let (conn, identity) = make_completed_connection(&mut node, link_id, transport_id, 1000); + let identity = seed_completed_connection(&mut node, link_id, transport_id, 1000); let node_addr = *identity.node_addr(); // Simulate a retry entry existing for this peer @@ -1538,7 +1630,6 @@ fn test_promote_clears_retry_pending() { ); assert_eq!(node.peering.reconciler.retry_pending.len(), 1); - node.add_connection(conn).unwrap(); node.promote_connection(link_id, identity, 2000).unwrap(); assert!( @@ -2006,15 +2097,25 @@ async fn drive_xx_handshake( // node_a initiates the outbound handshake. let link_id_a = node_a.allocate_link_id(); - let mut conn_a = PeerConnection::outbound(link_id_a, peer_b_identity, 1000); let our_index_a = node_a.index_allocator.allocate().unwrap(); - let our_keypair_a = node_a.identity().keypair(); - let noise_msg1 = conn_a - .start_handshake(our_keypair_a, node_a.startup_epoch(), 1000) + // Mirror the production dial path: the seam seeds the outbound leg's + // control machine, which owns the handshake crypto. + node_a + .seed_handshake_machine( + HandshakeSeed::outbound(link_id_a, peer_b_identity, 1000) + .with_our_index(our_index_a) + .with_transport_id(transport_id) + .with_source_addr(remote_addr_b.clone()), + ) + .unwrap(); + let our_keypair_a = node_a.identity().keypair(); + let startup_epoch_a = node_a.startup_epoch(); + let noise_msg1 = node_a + .peer_machines + .get_mut(&link_id_a) + .unwrap() + .start_handshake(our_keypair_a, startup_epoch_a, 1000) .unwrap(); - conn_a.set_our_index(our_index_a); - conn_a.set_transport_id(transport_id); - conn_a.set_source_addr(remote_addr_b.clone()); let wire_msg1 = build_msg1(our_index_a, &noise_msg1); let link_a = Link::connectionless( @@ -2025,9 +2126,6 @@ async fn drive_xx_handshake( Duration::from_millis(100), ); node_a.links.insert(link_id_a, link_a); - // Mirror the production dial path: the seam seeds the outbound leg's - // control machine and embeds the connection on it. - node_a.add_connection(conn_a).unwrap(); node_a .pending_outbound .insert((transport_id, our_index_a.as_u32()), link_id_a); @@ -2276,3 +2374,332 @@ async fn start_skips_system_tun_when_app_owned() { node.stop().await.unwrap(); } + +/// A connection whose handshake failed is retained with BOTH Noise handles +/// empty, and the stale-connection sweep depends on that: presence of the +/// pending connection — not presence of a handle — is what marks a machine as +/// handshake-phase. If presence were ever derived from the handles, every +/// failed connection would become invisible to the sweep and leak forever, +/// holding a peering-budget slot and a wrong `connection_count` permanently. +#[test] +fn test_failed_connection_is_retained_and_reaped() { + use crate::proto::fmp::LifecycleView; + + let mut node = make_node(); + let link_id = LinkId::new(1); + let peer_identity = make_peer_identity(); + + node.seed_handshake_machine(HandshakeSeed::outbound(link_id, peer_identity, 1000)) + .unwrap(); + let our_keypair = node.identity().keypair(); + let startup_epoch = node.startup_epoch(); + node.peer_machines + .get_mut(&link_id) + .unwrap() + .start_handshake(our_keypair, startup_epoch, 1000) + .unwrap(); + + // The send of that stored initiation fails. + let machine = node.peer_machines.get_mut(&link_id).unwrap(); + machine.mark_failed(); + machine.mark_send_failed(); + + // Both handles are now empty — the initiation handle was dropped and no + // session was ever reached — yet the connection is deliberately retained. + let leg = node.peer_machines.get(&link_id).unwrap().leg().unwrap(); + assert!( + leg.noise_handshake.is_none() && leg.noise_session.is_none(), + "a failed connection holds neither handle" + ); + + // (a) it still counts while it waits for the sweep + assert_eq!( + node.connection_count(), + 1, + "a failed connection stays counted until it is reaped" + ); + + // (b) the sweep yields it + let stale = node.stale_connections(2000, 30_000); + assert_eq!( + stale.len(), + 1, + "the sweep must see a failed connection despite its empty handles" + ); + assert_eq!(stale[0].link, link_id); + + // (c) reaping it clears the carrier + node.remove_peer_machine(link_id); + assert_eq!(node.connection_count(), 0); +} + +/// Handshake-phase membership is decided by whether a crypto carrier is +/// ATTACHED, never by whether either Noise handle inside it is populated. +/// +/// The distinction is the whole reason the carrier is a struct rather than a +/// pair of bare handle fields. A carrier legitimately sits attached and empty: +/// `mark_failed` drops the initiation handle and deliberately keeps the +/// carrier so the sweep can reclaim it, `take_session` empties the other, and +/// every handshake begins with both handles unset. If presence were derived +/// from the handles, every failed handshake would vanish from the sweep, +/// the count, and the peering budget at once — a permanent leak that no +/// existing test would notice. +/// +/// This drives the empty-carrier shape past every presence predicate on +/// `Node` and asserts each one reports "present", then detaches and asserts +/// each reports "absent". +#[test] +fn handshake_presence_tracks_the_carrier_not_the_noise_handles() { + use crate::proto::fmp::LifecycleView; + + let mut node = make_node(); + let link_id = LinkId::new(31); + let transport_id = TransportId::new(9); + let peer_identity = make_peer_identity(); + let peer_addr = TransportAddr::from_string("10.0.0.9:9999"); + + node.seed_handshake_machine( + HandshakeSeed::outbound(link_id, peer_identity, 1000) + .with_transport_id(transport_id) + .with_source_addr(peer_addr.clone()), + ) + .unwrap(); + + // A freshly seeded carrier holds neither handle — the construction window. + let leg = node.peer_machines.get(&link_id).unwrap().leg().unwrap(); + assert!( + leg.noise_handshake.is_none() && leg.noise_session.is_none(), + "the seeded carrier must start with both handles empty" + ); + + // Every presence predicate must see it, handles or not. + let assert_present = |node: &Node, when: &str| { + assert_eq!(node.connection_count(), 1, "connection_count: {when}"); + assert_eq!(node.connections().count(), 1, "connections(): {when}"); + assert!(node.has_pending_leg(&link_id), "has_pending_leg: {when}"); + assert_eq!( + node.stale_connections(1_000_000, 30_000).len(), + 1, + "stale_connections: {when}" + ); + assert!( + node.is_connecting_to_peer_on_path(peer_identity.node_addr(), transport_id, &peer_addr), + "is_connecting_to_peer_on_path: {when}" + ); + // The last two predicates sit inline in functions with no callable + // seam, so these MIRROR them rather than exercising them: the shape is + // pinned here, but a mutation at the production site would not fail + // this test. Both sites read `machine.leg().is_some()` verbatim. + assert!( + node.peer_machines.values().any(|machine| { + machine.leg().is_some() && machine.conn_transport_id() == Some(transport_id) + }), + "transport-in-use: {when}" + ); + // The complement of the rekey-msg2 discriminator: a machine carrying + // a pending handshake marks a fresh establish, so `handle_msg2` must + // NOT take its rekey-completion branch. + assert!( + node.peer_machines + .get(&link_id) + .is_some_and(|machine| machine.leg().is_some()), + "rekey-msg2 discriminator: {when}" + ); + // Fires the live-carrier coherence assertion; a machine that had gone + // invisible would panic here rather than fail an assert_eq above. + node.debug_assert_peer_maps_coherent(); + }; + + assert_present(&node, "freshly seeded, both handles empty"); + + // Drive to the failed shape: the initiation handle is dropped and the + // carrier is deliberately retained for the sweep. + let our_keypair = node.identity().keypair(); + let startup_epoch = node.startup_epoch(); + let machine = node.peer_machines.get_mut(&link_id).unwrap(); + machine + .start_handshake(our_keypair, startup_epoch, 1000) + .unwrap(); + assert!( + machine.leg().unwrap().noise_handshake.is_some(), + "start_handshake arms the initiation handle" + ); + machine.mark_failed(); + machine.mark_send_failed(); + + let leg = node.peer_machines.get(&link_id).unwrap().leg().unwrap(); + assert!( + leg.noise_handshake.is_none() && leg.noise_session.is_none(), + "a failed handshake holds neither handle" + ); + assert_present(&node, "failed, both handles empty"); + + // Detaching the carrier — and only that — ends handshake-phase membership. + node.peer_machines.get_mut(&link_id).unwrap().take_leg(); + assert_eq!(node.connection_count(), 0, "connection_count after detach"); + assert_eq!(node.connections().count(), 0, "connections() after detach"); + assert!( + !node.has_pending_leg(&link_id), + "has_pending_leg after detach" + ); + assert_eq!( + node.stale_connections(1_000_000, 30_000).len(), + 0, + "stale_connections after detach" + ); + assert!( + !node.is_connecting_to_peer_on_path(peer_identity.node_addr(), transport_id, &peer_addr), + "is_connecting_to_peer_on_path after detach" + ); +} + +// The master-line `inbound_msg1_records_the_learned_identity_on_the_carrier` +// pins the identity learn onto the surviving carrier at msg1. That is an IK +// property: XX msg1 is ephemeral-only and learns nothing, so the responder has +// no identity to record there. The learn happens in `complete_handshake` (msg2, +// initiator) and `complete_handshake_msg3` (msg3, responder); the carrier guard +// belongs at those two points on this line. + +/// A msg1 that fails Noise processing must leave no trace in the registry. +/// The control machine is built above the crypto so it can drive the +/// handshake, but it stays a local until a promote tail inserts it — a +/// rejected msg1 drops it. +#[tokio::test] +async fn test_rejected_msg1_leaves_no_registry_trace() { + let mut node = make_node(); + + // Well-formed framing, garbage Noise payload: processing fails. + let wire_msg1 = crate::proto::fmp::wire::build_msg1( + SessionIndex::new(7), + &[0u8; crate::noise::HANDSHAKE_MSG1_SIZE], + ); + let packet = ReceivedPacket::with_timestamp( + TransportId::new(1), + TransportAddr::from_string("127.0.0.1:5000"), + wire_msg1, + 1000, + ); + + node.handle_msg1(packet).await; + + assert!( + node.peer_machines.is_empty(), + "a rejected msg1 must leave no control machine behind" + ); + assert_eq!(node.connection_count(), 0); + assert_eq!(node.peer_count(), 0); + assert_eq!(node.link_count(), 0); + assert!( + node.peers_by_index.is_empty(), + "a rejected msg1 must allocate no session index" + ); + assert_eq!( + node.stats().handshake.bad_state, + 1, + "the rejection is attributed to the handshake state-machine counter" + ); +} + +/// The outbound path registers its control machine at dial, before msg1 is +/// prepared, so a preparation failure has to unwind that registration rather +/// than drop a local. +#[tokio::test] +async fn test_failed_msg1_preparation_unwinds_the_dial_machine() { + let mut node = make_node(); + let link_id = LinkId::new(1); + let transport_id = TransportId::new(1); + let remote_addr = TransportAddr::from_string("127.0.0.1:5000"); + let peer_identity = make_peer_identity(); + + // Stand in for the dial: the machine exists before msg1 is prepared. + node.peer_machines.insert( + link_id, + PeerMachine::new_outbound(link_id, Some(peer_identity), 1000), + ); + // Force the index allocation inside msg1 preparation to fail. + node.index_allocator = crate::utils::index::IndexAllocator::with_max_attempts(0); + + let result = node.prepare_outbound_msg1(link_id, transport_id, &remote_addr, peer_identity); + + assert!(matches!(result, Err(NodeError::IndexAllocationFailed(_)))); + assert!( + !node.peer_machines.contains_key(&link_id), + "a failed msg1 preparation must unwind the dial-time machine" + ); + assert_eq!(node.connection_count(), 0); +} + +/// The link, direction, and peer address that promotion and the operator view +/// read now come from the control machine rather than the pending connection. +/// A machine's direction is seeded at construction and must match the side that +/// actually opened the link, and its address must be populated by the time +/// promotion needs it. +/// +/// This covers the two shapes that seed a carrier independently: the dial and +/// an accepted inbound message 1. The cross-connection winner derives both +/// values from a carrier one of those two already seeded, so it has nothing +/// separate to pin. +#[tokio::test] +async fn test_machine_carries_link_direction_and_address_on_dial_and_inbound() { + // Outbound: the dial builds the machine, msg1 preparation fills it in. + let mut node = make_node(); + let link_id = LinkId::new(1); + let transport_id = TransportId::new(1); + let remote_addr = TransportAddr::from_string("127.0.0.1:5000"); + let peer_identity = make_peer_identity(); + node.peer_machines.insert( + link_id, + PeerMachine::new_outbound(link_id, Some(peer_identity), 1000), + ); + node.prepare_outbound_msg1(link_id, transport_id, &remote_addr, peer_identity) + .unwrap(); + + let machine = node.peer_machines.get(&link_id).unwrap(); + assert_eq!(machine.link_id(), link_id); + assert!(machine.conn_is_outbound(), "a dial is outbound"); + assert!(!machine.conn_is_inbound()); + assert_eq!(machine.conn_direction(), LinkDirection::Outbound); + assert_eq!( + machine.conn_source_addr(), + Some(&remote_addr), + "the dialled address must reach the surviving carrier" + ); + + // Inbound: msg1 builds the machine from the packet. + let mut responder = make_node(); + let initiator = make_node(); + let responder_identity = PeerIdentity::from_pubkey_full(responder.identity().pubkey_full()); + let mut initiator_leg = outbound_leg(LinkId::new(9), responder_identity, 1000); + let noise_msg1 = initiator_leg + .start_handshake( + initiator.identity().keypair(), + initiator.startup_epoch(), + 1000, + ) + .unwrap(); + let inbound_addr = TransportAddr::from_string("127.0.0.1:6000"); + let packet = ReceivedPacket::with_timestamp( + TransportId::new(1), + inbound_addr.clone(), + crate::proto::fmp::wire::build_msg1(SessionIndex::new(7), &noise_msg1), + 1000, + ); + responder.handle_msg1(packet).await; + + // The responder completes at msg1 and promotes, so the machine survives as + // the active peer's control machine — the carrier outlives the connection. + let (link, machine) = responder + .peer_machines + .iter() + .next() + .expect("msg1 leaves a control machine behind"); + assert!(machine.conn_is_inbound(), "an accepted msg1 is inbound"); + assert!(!machine.conn_is_outbound()); + assert_eq!(machine.conn_direction(), LinkDirection::Inbound); + assert_eq!( + machine.conn_source_addr(), + Some(&inbound_addr), + "the sender's address must reach the surviving carrier" + ); + assert_eq!(machine.link_id(), *link); +} diff --git a/src/peer/active.rs b/src/peer/active.rs index 22a57e2..f3a7025 100644 --- a/src/peer/active.rs +++ b/src/peer/active.rs @@ -356,7 +356,7 @@ impl ActivePeer { /// Create from verified identity with existing link stats. /// - /// Used when promoting from PeerConnection, preserving handshake stats. + /// Used when promoting a completed handshake, preserving its link stats. /// For peers with Noise sessions, use `with_session` instead. pub fn with_stats( identity: PeerIdentity, diff --git a/src/peer/connection.rs b/src/peer/connection.rs deleted file mode 100644 index 1864716..0000000 --- a/src/peer/connection.rs +++ /dev/null @@ -1,669 +0,0 @@ -//! Peer Connection (Handshake Phase) -//! -//! Represents an in-progress connection before authentication completes. -//! PeerConnection tracks the Noise IK handshake and transitions to -//! ActivePeer upon successful authentication. The handshake *phase* (initial / -//! sent_msg1 / complete / failed) is no longer tracked here — it lives on the -//! per-peer control machine; the leg's crypto methods gate on the presence of -//! their Noise handles (`noise_handshake` / `noise_session`) directly. - -use crate::PeerIdentity; -use crate::noise::{self, NoiseError, NoiseSession}; -use crate::proto::fmp::{ConnectionState, NodeProfile}; -use crate::transport::{LinkDirection, LinkId, TransportAddr, TransportId}; -use crate::utils::index::SessionIndex; -use secp256k1::Keypair; -use std::fmt; - -/// A connection in the handshake phase, before authentication completes. -/// -/// For outbound connections, we know the expected peer identity from config. -/// For inbound connections, we learn the identity during the Noise handshake. -/// -/// This is the shell holder for the FMP crypto/state split: the pure -/// connection bookkeeping lives in [`ConnectionState`] (`proto::fmp::state`), -/// and the two Noise crypto handles stay here beside it. Pure public methods -/// delegate to `self.state`; the XX transition methods drive the crypto and -/// write results back through `self.state`'s setters. -pub struct PeerConnection { - /// Pure, runtime-agnostic connection bookkeeping. - state: ConnectionState, - - /// Noise handshake state (consumes on completion). - noise_handshake: Option, - - /// Completed Noise session (available after handshake complete). - noise_session: Option, -} - -impl PeerConnection { - /// Create a new outbound connection (we are initiating). - /// - /// For outbound, we know who we're trying to reach from configuration. - /// The Noise handshake will be initialized when `start_handshake` is called. - pub fn outbound( - link_id: LinkId, - expected_identity: PeerIdentity, - current_time_ms: u64, - ) -> Self { - Self { - state: ConnectionState::outbound(link_id, expected_identity, current_time_ms), - noise_handshake: None, - noise_session: None, - } - } - - /// Create a new outbound connection without a pre-known identity. - /// - /// Used for anonymous discovery on shared-media transports (Ethernet, - /// BLE) where the beacon doesn't carry identity. The peer's identity is - /// learned from XX msg2 during the handshake. - pub fn outbound_anonymous(link_id: LinkId, current_time_ms: u64) -> Self { - Self { - state: ConnectionState::outbound_anonymous(link_id, current_time_ms), - noise_handshake: None, - noise_session: None, - } - } - - /// Create a new inbound connection (they are initiating). - /// - /// For inbound, we don't know who they are until we decrypt their - /// identity from Noise message 1. - pub fn inbound(link_id: LinkId, current_time_ms: u64) -> Self { - Self { - state: ConnectionState::inbound(link_id, current_time_ms), - noise_handshake: None, - noise_session: None, - } - } - - /// Create a new inbound connection with transport information. - /// - /// Used when processing msg1 where we know the transport and source address. - pub fn inbound_with_transport( - link_id: LinkId, - transport_id: TransportId, - source_addr: TransportAddr, - current_time_ms: u64, - ) -> Self { - Self { - state: ConnectionState::inbound_with_transport( - link_id, - transport_id, - source_addr, - current_time_ms, - ), - noise_handshake: None, - noise_session: None, - } - } - - // === Accessors (delegated to the pure ConnectionState) === - - /// Get the link ID. - pub fn link_id(&self) -> LinkId { - self.state.link_id() - } - - /// Get the connection direction. - pub fn direction(&self) -> LinkDirection { - self.state.direction() - } - - /// Get the expected/learned peer identity, if known. - pub fn expected_identity(&self) -> Option<&PeerIdentity> { - self.state.expected_identity() - } - - /// Check if this is an outbound connection. - pub fn is_outbound(&self) -> bool { - self.state.is_outbound() - } - - /// Check if this is an inbound connection. - pub fn is_inbound(&self) -> bool { - self.state.is_inbound() - } - - /// When the connection started. Retained only to seed a control machine's - /// carrier from a pre-built leg (`Node::add_connection`); the operator-facing - /// `started_at_ms`/`last_activity_ms` telemetry now reads the machine carrier, - /// not the leg. - pub fn started_at(&self) -> u64 { - self.state.started_at() - } - - /// Connection duration so far. - pub fn duration(&self, current_time_ms: u64) -> u64 { - self.state.duration(current_time_ms) - } - - /// Time since last activity. - pub fn idle_time(&self, current_time_ms: u64) -> u64 { - self.state.idle_time(current_time_ms) - } - - // === Index Accessors === - - /// Get our session index (if set). - pub fn our_index(&self) -> Option { - self.state.our_index() - } - - /// Set our session index. - pub fn set_our_index(&mut self, index: SessionIndex) { - self.state.set_our_index(index); - } - - /// Get their session index (if known). - pub fn their_index(&self) -> Option { - self.state.their_index() - } - - /// Set their session index. - pub fn set_their_index(&mut self, index: SessionIndex) { - self.state.set_their_index(index); - } - - /// Get the transport ID (if set). - pub fn transport_id(&self) -> Option { - self.state.transport_id() - } - - /// Set the transport ID. - pub fn set_transport_id(&mut self, id: TransportId) { - self.state.set_transport_id(id); - } - - /// Get the source address (if known). - pub fn source_addr(&self) -> Option<&TransportAddr> { - self.state.source_addr() - } - - /// Set the source address. - pub fn set_source_addr(&mut self, addr: TransportAddr) { - self.state.set_source_addr(addr); - } - - // === Epoch Accessors === - - /// Get the remote peer's startup epoch (available after handshake). - pub fn remote_epoch(&self) -> Option<[u8; 8]> { - self.state.remote_epoch() - } - - // === Negotiation Results === - - /// Get the peer's negotiated node profile, if learned. - pub fn peer_profile(&self) -> Option { - self.state.peer_profile() - } - - /// Record the peer's node profile learned during FMP negotiation. - pub fn set_negotiation_results(&mut self, peer_profile: NodeProfile) { - self.state.set_negotiation_results(peer_profile); - } - - // === Handshake Resend === - - /// Store the wire-format msg1 bytes for resend and schedule the first resend. - pub fn set_handshake_msg1(&mut self, msg1: Vec, first_resend_at_ms: u64) { - self.state.set_handshake_msg1(msg1, first_resend_at_ms); - } - - /// Store the wire-format msg2 bytes for resend on duplicate msg1. - pub fn set_handshake_msg2(&mut self, msg2: Vec) { - self.state.set_handshake_msg2(msg2); - } - - /// Get the stored msg1 bytes (if any). - pub fn handshake_msg1(&self) -> Option<&[u8]> { - self.state.handshake_msg1() - } - - /// Get the stored msg2 bytes (if any). - pub fn handshake_msg2(&self) -> Option<&[u8]> { - self.state.handshake_msg2() - } - - // === Noise Handshake Operations === - - /// Start the handshake as initiator and generate message 1. - /// - /// For outbound connections only. Returns the Noise XX msg1 bytes. - /// XX msg1 is ephemeral-only (33 bytes) — no identity or epoch. - pub fn start_handshake( - &mut self, - our_keypair: Keypair, - epoch: [u8; 8], - current_time_ms: u64, - ) -> Result, NoiseError> { - if self.state.direction() != LinkDirection::Outbound { - return Err(NoiseError::WrongState { - expected: "outbound connection".to_string(), - got: "inbound connection".to_string(), - }); - } - - // XX initiator: no remote static needed upfront. The old `!= Initial` - // phase guard is dropped — this method creates the Noise handshake - // handle, so there is no `take().expect()` to protect, and its Err path - // was unreachable in production (one call per fresh outbound leg). - let mut hs = noise::HandshakeState::new_initiator(our_keypair); - hs.set_local_epoch(epoch); - let msg1 = hs.write_message_1()?; - - self.noise_handshake = Some(hs); - self.state.touch(current_time_ms); - - Ok(msg1) - } - - /// Initialize responder and process incoming message 1. - /// - /// For inbound connections only. Returns the Noise XX msg2 bytes. - /// XX: identity is NOT learned from msg1 (only ephemeral exchange). - /// The responder learns the initiator's identity from msg3. - /// The handshake remains in ReceivedMsg1 state (not Complete). - /// - /// If `negotiation_payload` is provided, it is encrypted and appended - /// to the returned msg2 bytes. - pub fn receive_handshake_init( - &mut self, - our_keypair: Keypair, - epoch: [u8; 8], - message: &[u8], - negotiation_payload: Option<&[u8]>, - current_time_ms: u64, - ) -> Result, NoiseError> { - if self.state.direction() != LinkDirection::Inbound { - return Err(NoiseError::WrongState { - expected: "inbound connection".to_string(), - got: "outbound connection".to_string(), - }); - } - - let mut hs = noise::HandshakeState::new_responder(our_keypair); - hs.set_local_epoch(epoch); - - // Process XX message 1 (ephemeral only — no identity learned) - hs.read_message_1(message)?; - - // Generate XX message 2 (sends our static + epoch) - let mut msg2 = hs.write_message_2()?; - - // Append encrypted negotiation payload if provided - if let Some(payload) = negotiation_payload { - let encrypted = hs.encrypt_payload(payload)?; - msg2.extend_from_slice(&encrypted); - } - - // XX: handshake NOT complete yet — need msg3. Keep the Noise handshake - // handle for complete_handshake_msg3(); the phase (formerly - // `ReceivedMsg1`) now lives on the control machine, so no phase is set - // here. The handle's presence is the leg-local `ReceivedMsg1` signal. - self.noise_handshake = Some(hs); - self.state.touch(current_time_ms); - - Ok(msg2) - } - - /// Complete the handshake by processing message 2 and generating message 3. - /// - /// For outbound connections only (initiator). Processes the responder's - /// msg2 (learning their identity and epoch), then generates msg3. - /// Returns the Noise XX msg3 bytes to send. - /// - /// If `negotiation_payload` is provided, it is encrypted and appended - /// to the returned msg3 bytes. If the received msg2 contains a negotiation - /// payload (bytes beyond the base XX msg2), it is decrypted and returned. - pub fn complete_handshake( - &mut self, - message: &[u8], - negotiation_payload: Option<&[u8]>, - current_time_ms: u64, - ) -> Result<(Vec, Option>), NoiseError> { - // The leg is at `SentMsg1` iff its Noise handshake handle is present - // (set by `start_handshake`, taken here on completion). Gate on the - // handle directly now that the phase enum is gone — byte-equivalent to - // the old `!= SentMsg1` guard for every reachable transition. - if self.noise_handshake.is_none() { - return Err(NoiseError::WrongState { - expected: "sent_msg1 state".to_string(), - got: "no active handshake".to_string(), - }); - } - - let mut hs = self - .noise_handshake - .take() - .expect("noise handshake must exist in SentMsg1 state"); - - // Split msg2 into base XX part and optional negotiation - let base_size = noise::HANDSHAKE_MSG2_SIZE; - let (base_msg2, extra) = if message.len() > base_size { - (&message[..base_size], Some(&message[base_size..])) - } else { - (message, None) - }; - - // Process XX msg2 (learns responder identity + epoch) - hs.read_message_2(base_msg2)?; - - // Decrypt negotiation payload from msg2 if present - let received_negotiation = if let Some(encrypted) = extra { - Some(hs.decrypt_payload(encrypted)?) - } else { - None - }; - - // Learn responder identity from msg2 - let remote_static = *hs - .remote_static() - .expect("remote static available after XX msg2"); - self.state - .set_expected_identity(PeerIdentity::from_pubkey_full(remote_static)); - - // Capture remote epoch from msg2 - self.state.set_remote_epoch(hs.remote_epoch()); - - // Generate XX msg3 - let mut msg3 = hs.write_message_3()?; - - // Append encrypted negotiation payload if provided - if let Some(payload) = negotiation_payload { - let encrypted = hs.encrypt_payload(payload)?; - msg3.extend_from_slice(&encrypted); - } - - // Handshake complete for initiator - let session = hs.into_session()?; - self.noise_session = Some(session); - self.state.touch(current_time_ms); - - Ok((msg3, received_negotiation)) - } - - /// Complete the responder handshake by processing message 3. - /// - /// For inbound connections only (responder). Processes the initiator's - /// msg3, learning their identity and epoch. - /// - /// If the msg3 contains a negotiation payload (bytes beyond base XX msg3), - /// it is decrypted and returned. - pub fn complete_handshake_msg3( - &mut self, - message: &[u8], - current_time_ms: u64, - ) -> Result>, NoiseError> { - // The responder leg is at `ReceivedMsg1` iff its Noise handshake handle - // is present (set by `receive_handshake_init`, taken here on msg3). Gate - // on the handle directly now that the phase enum is gone — byte-equivalent - // to the old `!= ReceivedMsg1` guard for every reachable transition, and - // it protects the `take().expect()` below. - if self.noise_handshake.is_none() { - return Err(NoiseError::WrongState { - expected: "received_msg1 state".to_string(), - got: "no active handshake".to_string(), - }); - } - - let mut hs = self - .noise_handshake - .take() - .expect("noise handshake must exist in ReceivedMsg1 state"); - - // Split msg3 into base XX part and optional negotiation - let base_size = noise::HANDSHAKE_MSG3_SIZE; - let (base_msg3, extra) = if message.len() > base_size { - (&message[..base_size], Some(&message[base_size..])) - } else { - (message, None) - }; - - // Process XX msg3 (learns initiator identity + epoch) - hs.read_message_3(base_msg3)?; - - // Decrypt negotiation payload from msg3 if present - let received_negotiation = if let Some(encrypted) = extra { - Some(hs.decrypt_payload(encrypted)?) - } else { - None - }; - - // Learn initiator identity from msg3 - let remote_static = *hs - .remote_static() - .expect("remote static available after XX msg3"); - self.state - .set_expected_identity(PeerIdentity::from_pubkey_full(remote_static)); - - // Capture remote epoch from msg3 - self.state.set_remote_epoch(hs.remote_epoch()); - - // Handshake complete for responder. The completion signal is now the - // Noise session's presence (the phase enum is gone); no phase is set. - let session = hs.into_session()?; - self.noise_session = Some(session); - self.state.touch(current_time_ms); - - Ok(received_negotiation) - } - - /// Take the completed Noise session. - /// - /// Returns the NoiseSession for use in ActivePeer. Can only be called - /// once after handshake completes. - pub fn take_session(&mut self) -> Option { - // The session exists iff the handshake reached `Complete`, so taking it - // unconditionally is byte-equivalent to the old `== Complete` gate. - self.noise_session.take() - } - - /// Check if we have a completed session ready to take. - pub fn has_session(&self) -> bool { - self.noise_session.is_some() - } - - // === State Transitions (for manual control if needed) === - - /// Drop the shell-owned crypto handshake handle. The failure *state* now - /// lives on the control machine (`PeerMachine`); this only releases the - /// leg's Noise handle at the identical point it was released before, so a - /// subsequent `complete_handshake` on this leg still reports `WrongState`. - pub fn mark_failed(&mut self) { - self.noise_handshake = None; - } - - // === Validation === - - /// Check if the connection has timed out. - pub fn is_timed_out(&self, current_time_ms: u64, timeout_ms: u64) -> bool { - self.state.is_timed_out(current_time_ms, timeout_ms) - } -} - -impl fmt::Debug for PeerConnection { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("PeerConnection") - .field("link_id", &self.state.link_id()) - .field("direction", &self.state.direction()) - .field("expected_identity", &self.state.expected_identity()) - .field("has_noise_handshake", &self.noise_handshake.is_some()) - .field("has_noise_session", &self.noise_session.is_some()) - .field("our_index", &self.state.our_index()) - .field("their_index", &self.state.their_index()) - .field("transport_id", &self.state.transport_id()) - .field("started_at", &self.state.started_at()) - .field("last_activity", &self.state.last_activity()) - .finish() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::Identity; - use rand::Rng; - - fn make_peer_identity() -> PeerIdentity { - let identity = Identity::generate(); - PeerIdentity::from_pubkey(identity.pubkey()) - } - - fn make_keypair() -> Keypair { - let identity = Identity::generate(); - identity.keypair() - } - - fn make_epoch() -> [u8; 8] { - let mut epoch = [0u8; 8]; - rand::rng().fill_bytes(&mut epoch); - epoch - } - - #[test] - fn test_outbound_connection() { - let identity = make_peer_identity(); - let conn = PeerConnection::outbound(LinkId::new(1), identity, 1000); - - assert!(conn.is_outbound()); - assert!(!conn.is_inbound()); - assert!(!conn.has_session()); - assert!(conn.expected_identity().is_some()); - assert_eq!(conn.started_at(), 1000); - } - - #[test] - fn test_inbound_connection() { - let conn = PeerConnection::inbound(LinkId::new(2), 2000); - - assert!(conn.is_inbound()); - assert!(!conn.is_outbound()); - assert!(!conn.has_session()); - assert!(conn.expected_identity().is_none()); - assert_eq!(conn.started_at(), 2000); - } - - #[test] - fn test_full_handshake_flow() { - // Create identities - let initiator_identity = Identity::generate(); - let responder_identity = Identity::generate(); - - let initiator_keypair = initiator_identity.keypair(); - let responder_keypair = responder_identity.keypair(); - let initiator_epoch = make_epoch(); - let responder_epoch = make_epoch(); - - // Use from_pubkey_full to preserve parity for ECDH - let responder_peer_id = PeerIdentity::from_pubkey_full(responder_identity.pubkey_full()); - - // Create connections - let mut initiator_conn = PeerConnection::outbound(LinkId::new(1), responder_peer_id, 1000); - let mut responder_conn = PeerConnection::inbound(LinkId::new(2), 1000); - - // Initiator starts XX handshake - let msg1 = initiator_conn - .start_handshake(initiator_keypair, initiator_epoch, 1100) - .unwrap(); - // Post-msg1 the initiator holds an in-flight handshake, not yet a session. - assert!(!initiator_conn.has_session()); - - // Responder processes msg1 and sends msg2 (XX: does NOT complete yet) - let msg2 = responder_conn - .receive_handshake_init(responder_keypair, responder_epoch, &msg1, None, 1200) - .unwrap(); - // XX: the responder parks awaiting msg3 — it holds an in-flight - // handshake, not yet a session (the deleted phase was `ReceivedMsg1`). - assert!(!responder_conn.has_session()); - // Responder does NOT know initiator's identity yet (XX property) - assert!(responder_conn.expected_identity().is_none()); - - // Initiator processes msg2 and generates msg3 - let (msg3, _neg) = initiator_conn - .complete_handshake(&msg2, None, 1300) - .unwrap(); - // The initiator completes at msg3 generation: it now holds a session. - assert!(initiator_conn.has_session()); - - // Initiator learned responder's identity from msg2 - let discovered = initiator_conn.expected_identity().unwrap(); - assert_eq!(discovered.pubkey(), responder_identity.pubkey()); - assert_eq!(initiator_conn.remote_epoch(), Some(responder_epoch)); - - // Responder processes msg3 (completes handshake): it now holds a session. - let _neg = responder_conn.complete_handshake_msg3(&msg3, 1400).unwrap(); - assert!(responder_conn.has_session()); - - // Responder learned initiator's identity from msg3 - let discovered = responder_conn.expected_identity().unwrap(); - assert_eq!(discovered.pubkey(), initiator_identity.pubkey()); - assert_eq!(responder_conn.remote_epoch(), Some(initiator_epoch)); - - // Both have sessions - assert!(initiator_conn.has_session()); - assert!(responder_conn.has_session()); - - // Take and verify sessions work - let mut init_session = initiator_conn.take_session().unwrap(); - let mut resp_session = responder_conn.take_session().unwrap(); - - // Encrypt/decrypt test - let plaintext = b"test message"; - let ciphertext = init_session.encrypt(plaintext).unwrap(); - let decrypted = resp_session.decrypt(&ciphertext).unwrap(); - assert_eq!(decrypted, plaintext); - } - - #[test] - fn test_connection_timing() { - let identity = make_peer_identity(); - let conn = PeerConnection::outbound(LinkId::new(1), identity, 1000); - - assert_eq!(conn.duration(1500), 500); - assert_eq!(conn.idle_time(1500), 500); - assert!(!conn.is_timed_out(1500, 1000)); - assert!(conn.is_timed_out(2500, 1000)); - } - - #[test] - fn test_connection_failure() { - // `mark_failed` releases the leg's Noise handshake handle. The failure - // *state* now lives on the control machine, but the leg-local effect is - // still observable: a completion attempt afterward reports `WrongState` - // (the handle-presence gate) and no session is produced. - let identity = make_peer_identity(); - let keypair = make_keypair(); - let mut conn = PeerConnection::outbound(LinkId::new(1), identity, 1000); - conn.start_handshake(keypair, make_epoch(), 1100).unwrap(); - - conn.mark_failed(); - - assert!(!conn.has_session()); - assert!(conn.complete_handshake(&[0u8; 96], None, 1200).is_err()); - } - - #[test] - fn test_wrong_direction_errors() { - let identity = make_peer_identity(); - let keypair = make_keypair(); - - // Outbound can't receive_handshake_init - let mut outbound = PeerConnection::outbound(LinkId::new(1), identity, 1000); - assert!( - outbound - .receive_handshake_init(keypair, make_epoch(), &[0u8; 33], None, 1100) - .is_err() - ); - - // Inbound can't start_handshake - let mut inbound = PeerConnection::inbound(LinkId::new(2), 1000); - assert!( - inbound - .start_handshake(keypair, make_epoch(), 1100) - .is_err() - ); - } -} diff --git a/src/peer/machine.rs b/src/peer/machine.rs index 3e8df0a..3b0b005 100644 --- a/src/peer/machine.rs +++ b/src/peer/machine.rs @@ -43,7 +43,7 @@ //! //! The XX cross-connection swap ([`CrossConnect`](InboundDecision::CrossConnect)) //! and rekey-responder ([`RekeyRespond`](InboundDecision::RekeyRespond)) arms both -//! move a `NoiseSession` between a `PeerConnection` and an `ActivePeer` +//! move a `NoiseSession` between a handshake carrier and an `ActivePeer` //! (`take_session`/`replace_session`/`set_pending_session`) — effects that cannot //! ride in a plain-data [`PeerAction`]. The machine owns only the *classification //! dispatch* + the index-plane facts; it emits a plain-data trigger @@ -83,16 +83,17 @@ #![allow(dead_code)] -use crate::peer::PeerConnection; +use crate::noise::{self, NoiseError, NoiseSession}; use crate::proto::fmp::{ ConnAction, ConnSnapshot, ConnectionState, EstablishSnapshot, Fmp, InboundDecision, InboundReject, NodeProfile, OutboundDecision, OutboundSnapshot, PeerSnapshot, PromotionResult, RekeyCfg, RekeyResendSnapshot, WireOutcome, }; use crate::proto::link::LinkMessageType; -use crate::transport::{LinkId, LinkStats, TransportAddr, TransportId}; +use crate::transport::{LinkDirection, LinkId, LinkStats, TransportAddr, TransportId}; use crate::utils::index::{IndexAllocator, SessionIndex}; use crate::{NodeAddr, PeerIdentity}; +use secp256k1::Keypair; // ============================================================================ // Timing placeholders @@ -491,6 +492,55 @@ pub(crate) enum PeerAction { // The machine (control tier) // ============================================================================ +/// The handshake operations are only reachable while a pending connection is +/// attached; every path that drives the crypto attaches it first. +fn no_pending_connection() -> NoiseError { + NoiseError::WrongState { + expected: "attached connection".to_string(), + got: "no connection".to_string(), + } +} + +/// The handshake-phase Noise crypto, owned by the control machine. +/// +/// PRESENCE OF THIS STRUCT (`PeerMachine::leg().is_some()`) IS THE +/// HANDSHAKE-PHASE CARRIER SIGNAL — it is what `Node::connections()`, +/// `connection_count()`, the stale-connection sweep, the transport-in-use +/// check, and the peering budget all key on. It is attached and detached at +/// exactly the points the pending connection was, and its presence is NOT a +/// function of whether either handle is populated. +/// +/// A present-but-empty value is legal and load-bearing: `mark_failed` drops +/// the initiation handle while deliberately retaining the carrier so the +/// sweep can reclaim it, and `take_session` empties the other. Deriving +/// presence from handle presence would make every failed handshake invisible +/// to the sweep — a permanent leak. See the presence tests in this module. +pub(crate) struct HandshakeCrypto { + /// Noise handshake state (consumed on completion). + pub(crate) noise_handshake: Option, + /// Completed Noise session (available once the handshake completes). + pub(crate) noise_session: Option, +} + +impl HandshakeCrypto { + /// A fresh carrier holding neither handle, as every handshake begins. + pub(crate) fn new() -> Self { + Self { + noise_handshake: None, + noise_session: None, + } + } +} + +impl std::fmt::Debug for HandshakeCrypto { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HandshakeCrypto") + .field("has_noise_handshake", &self.noise_handshake.is_some()) + .field("has_noise_session", &self.noise_session.is_some()) + .finish() + } +} + /// Per-peer control FSM. Holds control-tier lifecycle state only; the /// send-critical state is published as `PeerSendState` and mutated via the /// emitted [`PeerAction`]s. @@ -501,12 +551,13 @@ pub(crate) struct PeerMachine { /// The crystallized peer node address. Known up front for outbound (from the /// dial identity); learned at msg3 for inbound (from [`WireOutcome`]). node_addr: Option, - /// The pending handshake connection this machine owns while the leg is in - /// the handshake window. `None` before the connection is built (the dial - /// window) and after promotion consumes it (the machine survives as the - /// active peer's control machine). Pure storage — the machine never reads - /// or drives it; the shell reaches it through the accessors below. - leg: Option, + /// The handshake-phase Noise crypto this machine owns while it is in the + /// handshake window. `None` before the handshake begins (the dial window) + /// and after promotion consumes it (the machine survives as the active + /// peer's control machine). Its presence — not the state of the handles + /// inside it — is what marks this machine as carrying a pending + /// handshake; see [`HandshakeCrypto`]. + leg: Option, /// Pure handshake-phase bookkeeping (link/direction/indices/transport/ /// stored handshake bytes/epoch). Reused verbatim from the FMP state core. conn: ConnectionState, @@ -624,12 +675,21 @@ impl PeerMachine { /// their timeout). pub(crate) fn inbound_msg2_sent(link: LinkId, our_index: SessionIndex, now: u64) -> Self { let mut machine = Self::new_inbound(link, now); - machine.conn.set_our_index(our_index); - machine.state = PeerState::Handshaking { - link, + machine.park_inbound_msg2_sent(our_index); + machine + } + + /// Park an inbound machine at `Handshaking{SentMsg2}`, recording the index + /// it allocated. The msg1-inline path builds its machine above the crypto + /// (the Noise step runs on the machine) and so cannot use the birth ctor, + /// which needs an index that does not exist until after the crypto; this + /// applies the same transition to the machine already in hand. + pub(crate) fn park_inbound_msg2_sent(&mut self, our_index: SessionIndex) { + self.conn.set_our_index(our_index); + self.state = PeerState::Handshaking { + link: self.link, phase: HandshakePhase::SentMsg2, }; - machine } /// New machine for an ALREADY-established peer: the post-handshake state a @@ -685,28 +745,312 @@ impl PeerMachine { self.state } - /// The pending handshake connection, if this leg is still in the - /// handshake window. - pub(crate) fn leg(&self) -> Option<&PeerConnection> { + /// The handshake crypto carrier, if this machine is still in the + /// handshake window. Presence answers "is there a pending handshake + /// here", independently of whether either handle is populated. + pub(crate) fn leg(&self) -> Option<&HandshakeCrypto> { self.leg.as_ref() } - /// Mutable access to the pending handshake connection. - pub(crate) fn leg_mut(&mut self) -> Option<&mut PeerConnection> { - self.leg.as_mut() - } - - /// Take the pending handshake connection off the machine (promotion and + /// Take the handshake crypto carrier off the machine (promotion and /// teardown consume it by value). - pub(crate) fn take_leg(&mut self) -> Option { + pub(crate) fn take_leg(&mut self) -> Option { self.leg.take() } - /// Embed a pending handshake connection on the machine. - pub(crate) fn set_leg(&mut self, leg: PeerConnection) { + /// Attach a handshake crypto carrier to the machine. + pub(crate) fn set_leg(&mut self, leg: HandshakeCrypto) { self.leg = Some(leg); } + // === Noise handshake operations === + // + // Mechanism, not decision: these are called by the shell, are never + // reached from `step()`, and no event triggers them. Each drives the + // Noise crypto on the pending connection and records the results on both + // that connection and the surviving carrier, so a reader of either sees + // the same value at the same point. + + /// Start the handshake as initiator and generate message 1. + /// + /// For outbound connections only. Returns the Noise XX msg1 bytes. + /// XX msg1 is ephemeral-only (33 bytes) — no identity or epoch. + pub(crate) fn start_handshake( + &mut self, + our_keypair: Keypair, + epoch: [u8; 8], + current_time_ms: u64, + ) -> Result, NoiseError> { + let msg1 = { + let direction = self.conn.direction(); + let leg = self.leg.as_mut().ok_or_else(no_pending_connection)?; + + if direction != LinkDirection::Outbound { + return Err(NoiseError::WrongState { + expected: "outbound connection".to_string(), + got: "inbound connection".to_string(), + }); + } + + // XX initiator: no remote static needed upfront, so an anonymous + // dial (no expected identity) is a first-class case rather than an + // error. The old `!= Initial` phase guard is dropped — this method + // creates the Noise handshake handle, so there is no + // `take().expect()` to protect, and its Err path was unreachable in + // production (one call per fresh outbound handshake). + let mut hs = noise::HandshakeState::new_initiator(our_keypair); + hs.set_local_epoch(epoch); + let msg1 = hs.write_message_1()?; + + leg.noise_handshake = Some(hs); + + msg1 + }; + self.conn.touch(current_time_ms); + + Ok(msg1) + } + + /// Initialize responder and process incoming message 1. + /// + /// For inbound connections only. Returns the Noise XX msg2 bytes. + /// XX: identity is NOT learned from msg1 (only ephemeral exchange). + /// The responder learns the initiator's identity from msg3. + /// + /// If `negotiation_payload` is provided, it is encrypted and appended + /// to the returned msg2 bytes. + pub(crate) fn receive_handshake_init( + &mut self, + our_keypair: Keypair, + epoch: [u8; 8], + message: &[u8], + negotiation_payload: Option<&[u8]>, + current_time_ms: u64, + ) -> Result, NoiseError> { + let msg2 = { + let direction = self.conn.direction(); + let leg = self.leg.as_mut().ok_or_else(no_pending_connection)?; + + if direction != LinkDirection::Inbound { + return Err(NoiseError::WrongState { + expected: "inbound connection".to_string(), + got: "outbound connection".to_string(), + }); + } + + let mut hs = noise::HandshakeState::new_responder(our_keypair); + hs.set_local_epoch(epoch); + + // Process XX message 1 (ephemeral only — no identity learned) + hs.read_message_1(message)?; + + // Generate XX message 2 (sends our static + epoch) + let mut msg2 = hs.write_message_2()?; + + // Append encrypted negotiation payload if provided + if let Some(payload) = negotiation_payload { + let encrypted = hs.encrypt_payload(payload)?; + msg2.extend_from_slice(&encrypted); + } + + // XX: handshake NOT complete yet — need msg3. Keep the Noise + // handshake handle for `complete_handshake_msg3()`; the phase + // (formerly `ReceivedMsg1`) lives on this machine, so no phase is + // set here. The handle's presence is the crypto-local + // `ReceivedMsg1` signal. + leg.noise_handshake = Some(hs); + + msg2 + }; + self.conn.touch(current_time_ms); + + Ok(msg2) + } + + /// Complete the handshake by processing message 2 and generating message 3. + /// + /// For outbound connections only (initiator). Processes the responder's + /// msg2 (learning their identity and epoch), then generates msg3. + /// Returns the Noise XX msg3 bytes to send. + /// + /// If `negotiation_payload` is provided, it is encrypted and appended + /// to the returned msg3 bytes. If the received msg2 contains a negotiation + /// payload (bytes beyond the base XX msg2), it is decrypted and returned. + pub(crate) fn complete_handshake( + &mut self, + message: &[u8], + negotiation_payload: Option<&[u8]>, + current_time_ms: u64, + ) -> Result<(Vec, Option>), NoiseError> { + let (msg3, received_negotiation, learned_identity, remote_epoch) = { + let leg = self.leg.as_mut().ok_or_else(no_pending_connection)?; + + // The connection is at `SentMsg1` iff its Noise handshake handle is + // present (set by `start_handshake`, taken here on completion). + // Gating on the handle directly is byte-equivalent to the old + // `!= SentMsg1` guard for every reachable transition. + if leg.noise_handshake.is_none() { + return Err(NoiseError::WrongState { + expected: "sent_msg1 state".to_string(), + got: "no active handshake".to_string(), + }); + } + + let mut hs = leg + .noise_handshake + .take() + .expect("noise handshake must exist in SentMsg1 state"); + + // Split msg2 into base XX part and optional negotiation + let base_size = noise::HANDSHAKE_MSG2_SIZE; + let (base_msg2, extra) = if message.len() > base_size { + (&message[..base_size], Some(&message[base_size..])) + } else { + (message, None) + }; + + // Process XX msg2 (learns responder identity + epoch) + hs.read_message_2(base_msg2)?; + + // Decrypt negotiation payload from msg2 if present + let received_negotiation = if let Some(encrypted) = extra { + Some(hs.decrypt_payload(encrypted)?) + } else { + None + }; + + // Learn responder identity from msg2 + let remote_static = *hs + .remote_static() + .expect("remote static available after XX msg2"); + let learned_identity = PeerIdentity::from_pubkey_full(remote_static); + + // Capture remote epoch from msg2 + let remote_epoch = hs.remote_epoch(); + + // Generate XX msg3 + let mut msg3 = hs.write_message_3()?; + + // Append encrypted negotiation payload if provided + if let Some(payload) = negotiation_payload { + let encrypted = hs.encrypt_payload(payload)?; + msg3.extend_from_slice(&encrypted); + } + + // Handshake complete for initiator + let session = hs.into_session()?; + leg.noise_session = Some(session); + + (msg3, received_negotiation, learned_identity, remote_epoch) + }; + self.conn.set_expected_identity(learned_identity); + self.conn.set_remote_epoch(remote_epoch); + self.conn.touch(current_time_ms); + + Ok((msg3, received_negotiation)) + } + + /// Complete the responder handshake by processing message 3. + /// + /// For inbound connections only (responder). Processes the initiator's + /// msg3, learning their identity and epoch. + /// + /// If the msg3 contains a negotiation payload (bytes beyond base XX msg3), + /// it is decrypted and returned. + pub(crate) fn complete_handshake_msg3( + &mut self, + message: &[u8], + current_time_ms: u64, + ) -> Result>, NoiseError> { + let (received_negotiation, learned_identity, remote_epoch) = { + let leg = self.leg.as_mut().ok_or_else(no_pending_connection)?; + + // The responder is at `ReceivedMsg1` iff its Noise handshake handle + // is present (set by `receive_handshake_init`, taken here on msg3). + // Gating on the handle directly is byte-equivalent to the old + // `!= ReceivedMsg1` guard for every reachable transition, and it + // protects the `take().expect()` below. + if leg.noise_handshake.is_none() { + return Err(NoiseError::WrongState { + expected: "received_msg1 state".to_string(), + got: "no active handshake".to_string(), + }); + } + + let mut hs = leg + .noise_handshake + .take() + .expect("noise handshake must exist in ReceivedMsg1 state"); + + // Split msg3 into base XX part and optional negotiation + let base_size = noise::HANDSHAKE_MSG3_SIZE; + let (base_msg3, extra) = if message.len() > base_size { + (&message[..base_size], Some(&message[base_size..])) + } else { + (message, None) + }; + + // Process XX msg3 (learns initiator identity + epoch) + hs.read_message_3(base_msg3)?; + + // Decrypt negotiation payload from msg3 if present + let received_negotiation = if let Some(encrypted) = extra { + Some(hs.decrypt_payload(encrypted)?) + } else { + None + }; + + // Learn initiator identity from msg3 + let remote_static = *hs + .remote_static() + .expect("remote static available after XX msg3"); + let learned_identity = PeerIdentity::from_pubkey_full(remote_static); + + // Capture remote epoch from msg3 + let remote_epoch = hs.remote_epoch(); + + // Handshake complete for responder. The completion signal is the + // Noise session's presence (the phase enum is gone); no phase is + // set. + let session = hs.into_session()?; + leg.noise_session = Some(session); + + (received_negotiation, learned_identity, remote_epoch) + }; + self.conn.set_expected_identity(learned_identity); + self.conn.set_remote_epoch(remote_epoch); + self.conn.touch(current_time_ms); + + Ok(received_negotiation) + } + + /// Take the completed Noise session. + /// + /// Returns the NoiseSession for use in ActivePeer. Can only be called + /// once after the handshake completes. + pub(crate) fn take_session(&mut self) -> Option { + // The session exists iff the handshake reached `Complete`, so taking it + // unconditionally is byte-equivalent to the old `== Complete` gate. + self.leg.as_mut().and_then(|leg| leg.noise_session.take()) + } + + /// Check if we have a completed session ready to take. + pub(crate) fn has_session(&self) -> bool { + self.leg + .as_ref() + .is_some_and(|leg| leg.noise_session.is_some()) + } + + /// Drop the crypto handshake handle. The failure *state* lives on this + /// machine; this only releases the Noise handle at the identical point it + /// was released before, so a subsequent `complete_handshake` still reports + /// `WrongState`. + pub(crate) fn mark_failed(&mut self) { + if let Some(leg) = self.leg.as_mut() { + leg.noise_handshake = None; + } + } + /// The session index we allocated for this peer, read from the surviving /// carrier. Populated once the index is allocated on either establish path /// (inbound at msg1, outbound at msg1 preparation). `None` before @@ -775,13 +1119,23 @@ impl PeerMachine { /// Expected peer identity of the surviving carrier — the home for the /// operator-visible `expected_peer` now that the leg no longer projects it. - /// Outbound carries the dial identity from construction; inbound stays `None` - /// in this view (the identity learned mid-handshake never rests here, as the - /// leg is consumed by promotion within the same message-handling step). + /// Outbound carries the dial identity from construction; inbound records + /// the identity discovered in msg1, written here by `receive_handshake_init` + /// at the same point it reaches the pending connection. Everything that + /// names a peer mid-handshake reads this, including the stale-connection + /// sweep's retry address. pub(crate) fn conn_expected_identity(&self) -> Option<&PeerIdentity> { self.conn.expected_identity() } + /// Remote startup epoch of the surviving carrier, recorded by the + /// handshake operations at the message that reveals it (msg1 inbound, + /// msg2 outbound). Promotion reads it to seed the active peer and to + /// detect a peer restart across a reconnect. + pub(crate) fn conn_remote_epoch(&self) -> Option<[u8; 8]> { + self.conn.remote_epoch() + } + /// Stored wire-format msg1 of the surviving carrier — the resend source for /// the outbound handshake retransmit, now that the leg no longer carries it. pub(crate) fn conn_handshake_msg1(&self) -> Option<&[u8]> { @@ -806,6 +1160,39 @@ impl PeerMachine { self.conn.set_handshake_msg2(msg2); } + /// The link this machine controls. + pub(crate) fn link_id(&self) -> LinkId { + self.link + } + + /// Which side opened this connection, read from the surviving carrier. + /// Seeded at construction: an outbound machine carries an outbound + /// connection state and an inbound machine an inbound one. + pub(crate) fn conn_direction(&self) -> LinkDirection { + self.conn.direction() + } + + /// Whether we opened this connection. + pub(crate) fn conn_is_outbound(&self) -> bool { + self.conn.is_outbound() + } + + /// Whether the peer opened this connection. + pub(crate) fn conn_is_inbound(&self) -> bool { + self.conn.is_inbound() + } + + /// The peer's address on this link, read from the surviving carrier. + /// Written at the same three points the connection's own copy was: the + /// inbound seed, the outbound dial, and message-2 completion. + pub(crate) fn conn_source_addr(&self) -> Option<&TransportAddr> { + self.conn.source_addr() + } + + pub(crate) fn set_conn_source_addr(&mut self, addr: TransportAddr) { + self.conn.set_source_addr(addr); + } + /// Peer session index of the surviving carrier — the source for the /// promotion hand-off now that the leg no longer projects it. pub(crate) fn conn_their_index(&self) -> Option { @@ -830,23 +1217,19 @@ impl PeerMachine { self.conn.peer_profile() } - /// Record the negotiated peer profile on the surviving carrier, mirroring - /// the leg's `set_negotiation_results` write. + /// Record the negotiated peer profile on the surviving carrier. pub(crate) fn set_conn_peer_profile(&mut self, profile: NodeProfile) { self.conn.set_negotiation_results(profile); } - /// Record the peer session index on the surviving carrier. Seeds the - /// carrier from a pre-built leg (`Node::add_connection`) so the promotion - /// hand-off matches the establish paths that write it on the machine. + /// Record the peer session index on the surviving carrier, so the + /// promotion hand-off reads it from the machine. pub(crate) fn set_conn_their_index(&mut self, index: SessionIndex) { self.conn.set_their_index(index); } - /// Record the transport ID on the surviving carrier. Populated on the - /// inbound establish path (the leg seeds it at msg1, but the machine's - /// carrier is only written on the outbound dial) and when seeding the - /// carrier from a pre-built leg (`Node::add_connection`). + /// Record the transport ID on the surviving carrier. Written on the + /// inbound establish path at msg1 and on the outbound dial. pub(crate) fn set_conn_transport_id(&mut self, id: TransportId) { self.conn.set_transport_id(id); } @@ -1060,11 +1443,9 @@ impl PeerMachine { /// (`is_handshaking_sent_msg1`) survives until the sweep, and no timer /// actions are emitted. fn on_handshake_send_failed(&mut self) -> Vec { - if let Some(leg) = self.leg.as_mut() { - // Drop the leg's Noise handshake handle at the identical point as - // before; the failure *state* is recorded on the machine. - leg.mark_failed(); - } + // Drop the Noise handshake handle at the identical point as before; + // the failure *state* is recorded on the machine. + self.mark_failed(); self.send_failed = true; Vec::new() } @@ -3082,7 +3463,7 @@ mod tests { 100, &mut alloc, ); - m.set_leg(PeerConnection::outbound(LinkId::new(1), peer, 100)); + m.set_leg(HandshakeCrypto::new()); assert!(m.is_handshaking_sent_msg1()); assert!(!m.is_failed()); assert_eq!(m.displayed_handshake_state(), "sent_msg1"); @@ -3400,4 +3781,194 @@ mod tests { assert_eq!(m.state(), PeerState::Active { addr }); assert_eq!(alloc.count(), 0); } + + // ---- Moved from the handshake leg's own test module -------------------- + // These exercise the Noise handshake operations, which now live on the + // control machine. Constructed as a machine with a leg attached; the + // assertions are unchanged. + + fn make_peer_identity() -> PeerIdentity { + let identity = Identity::generate(); + PeerIdentity::from_pubkey(identity.pubkey()) + } + + fn make_keypair() -> Keypair { + let identity = Identity::generate(); + identity.keypair() + } + + fn make_epoch() -> [u8; 8] { + let mut epoch = [0u8; 8]; + rand::Rng::fill_bytes(&mut rand::rng(), &mut epoch); + epoch + } + + fn outbound_leg( + link_id: LinkId, + expected_identity: PeerIdentity, + current_time_ms: u64, + ) -> PeerMachine { + let mut machine = + PeerMachine::new_outbound(link_id, Some(expected_identity), current_time_ms); + machine.set_leg(HandshakeCrypto::new()); + machine + } + + fn inbound_leg(link_id: LinkId, current_time_ms: u64) -> PeerMachine { + let mut machine = PeerMachine::new_inbound(link_id, current_time_ms); + machine.set_leg(HandshakeCrypto::new()); + machine + } + + #[test] + fn test_outbound_connection() { + let identity = make_peer_identity(); + let conn = outbound_leg(LinkId::new(1), identity, 1000); + + assert!(conn.conn_is_outbound()); + assert!(!conn.conn_is_inbound()); + assert!(!conn.has_session()); + assert!(conn.conn_expected_identity().is_some()); + assert_eq!(conn.conn_started_at(), 1000); + } + + #[test] + fn test_inbound_connection() { + let conn = inbound_leg(LinkId::new(2), 2000); + + assert!(conn.conn_is_inbound()); + assert!(!conn.conn_is_outbound()); + assert!(!conn.has_session()); + assert!(conn.conn_expected_identity().is_none()); + assert_eq!(conn.conn_started_at(), 2000); + } + + #[test] + fn test_full_handshake_flow() { + // Create identities + let initiator_identity = Identity::generate(); + let responder_identity = Identity::generate(); + + let initiator_keypair = initiator_identity.keypair(); + let responder_keypair = responder_identity.keypair(); + let initiator_epoch = make_epoch(); + let responder_epoch = make_epoch(); + + // Use from_pubkey_full to preserve parity for ECDH + let responder_peer_id = PeerIdentity::from_pubkey_full(responder_identity.pubkey_full()); + + // Create connections + let mut initiator_conn = outbound_leg(LinkId::new(1), responder_peer_id, 1000); + let mut responder_conn = inbound_leg(LinkId::new(2), 1000); + + // Initiator starts XX handshake + let msg1 = initiator_conn + .start_handshake(initiator_keypair, initiator_epoch, 1100) + .unwrap(); + // Post-msg1 the initiator holds an in-flight handshake, not yet a session. + assert!(!initiator_conn.has_session()); + + // Responder processes msg1 and sends msg2 (XX: does NOT complete yet) + let msg2 = responder_conn + .receive_handshake_init(responder_keypair, responder_epoch, &msg1, None, 1200) + .unwrap(); + // XX: the responder parks awaiting msg3 — it holds an in-flight + // handshake, not yet a session (the deleted phase was `ReceivedMsg1`). + assert!(!responder_conn.has_session()); + // Responder does NOT know initiator's identity yet (XX property) + assert!(responder_conn.conn_expected_identity().is_none()); + + // Initiator processes msg2 and generates msg3 + let (msg3, _neg) = initiator_conn + .complete_handshake(&msg2, None, 1300) + .unwrap(); + // The initiator completes at msg3 generation: it now holds a session. + assert!(initiator_conn.has_session()); + + // Initiator learned responder's identity from msg2 + let discovered = initiator_conn.conn_expected_identity().unwrap(); + assert_eq!(discovered.pubkey(), responder_identity.pubkey()); + assert_eq!(initiator_conn.conn_remote_epoch(), Some(responder_epoch)); + + // Responder processes msg3 (completes handshake): it now holds a session. + let _neg = responder_conn.complete_handshake_msg3(&msg3, 1400).unwrap(); + assert!(responder_conn.has_session()); + + // Responder learned initiator's identity from msg3 + let discovered = responder_conn.conn_expected_identity().unwrap(); + assert_eq!(discovered.pubkey(), initiator_identity.pubkey()); + assert_eq!(responder_conn.conn_remote_epoch(), Some(initiator_epoch)); + + // Both have sessions + assert!(initiator_conn.has_session()); + assert!(responder_conn.has_session()); + + // Take and verify sessions work + let mut init_session = initiator_conn.take_session().unwrap(); + let mut resp_session = responder_conn.take_session().unwrap(); + + // Encrypt/decrypt test + let plaintext = b"test message"; + let ciphertext = init_session.encrypt(plaintext).unwrap(); + let decrypted = resp_session.decrypt(&ciphertext).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn test_connection_failure() { + // `mark_failed` releases the leg's Noise handshake handle. The failure + // *state* now lives on the control machine, but the leg-local effect is + // still observable: a completion attempt afterward reports `WrongState` + // (the handle-presence gate) and no session is produced. + let identity = make_peer_identity(); + let keypair = make_keypair(); + let mut conn = outbound_leg(LinkId::new(1), identity, 1000); + conn.start_handshake(keypair, make_epoch(), 1100).unwrap(); + + conn.mark_failed(); + + assert!(!conn.has_session()); + assert!(conn.complete_handshake(&[0u8; 96], None, 1200).is_err()); + } + + #[test] + fn test_wrong_direction_errors() { + let identity = make_peer_identity(); + let keypair = make_keypair(); + + // Outbound can't receive_handshake_init + let mut outbound = outbound_leg(LinkId::new(1), identity, 1000); + assert!( + outbound + .receive_handshake_init(keypair, make_epoch(), &[0u8; 33], None, 1100) + .is_err() + ); + + // Inbound can't start_handshake + let mut inbound = inbound_leg(LinkId::new(2), 1000); + assert!( + inbound + .start_handshake(keypair, make_epoch(), 1100) + .is_err() + ); + } +} + +/// T-SANSIO: the action vocabulary must stay plain, comparable data. +/// +/// `PeerAction` is the sans-IO boundary between the pure reducer and its +/// driver. Requiring `Clone + Eq` is a compile-time statement that no variant +/// may carry a runtime handle (a socket, a `JoinHandle`, a channel sender), +/// since none of those are `Clone + Eq`. If a future variant smuggles one in, +/// this bound stops compiling. +#[cfg(test)] +mod action_contract { + use super::PeerAction; + + fn assert_clone_eq() {} + + #[test] + fn peer_action_is_plain_comparable_data() { + assert_clone_eq::(); + } } diff --git a/src/peer/mod.rs b/src/peer/mod.rs index 690dfd0..1443fe3 100644 --- a/src/peer/mod.rs +++ b/src/peer/mod.rs @@ -1,17 +1,16 @@ //! Peer Management //! //! Two-phase peer lifecycle: -//! 1. **PeerConnection** - Handshake phase, before identity is verified +//! 1. **PeerMachine** with a handshake carrier attached - handshake phase, +//! before identity is verified //! 2. **ActivePeer** - Authenticated phase, after successful Noise handshake mod active; #[cfg(any(target_os = "linux", target_os = "macos"))] pub(crate) mod connected_udp; -mod connection; pub(crate) mod machine; pub use active::{ActivePeer, ConnectivityState}; -pub use connection::PeerConnection; use crate::NodeAddr; use crate::transport::LinkId; diff --git a/src/proto/fmp/core.rs b/src/proto/fmp/core.rs index b042380..7c65db4 100644 --- a/src/proto/fmp/core.rs +++ b/src/proto/fmp/core.rs @@ -199,10 +199,12 @@ pub(crate) struct RekeyCfg { /// /// On XX the responder learns the initiator's identity and startup epoch only /// once `msg3` completes the Noise handshake (unlike IK, which learns them at -/// `msg1`). The shell-side Noise step (`complete_handshake_msg3`) yields these; -/// the core reads them to classify. The Noise bytes themselves never reach the -/// core — the fresh session extraction stays a shell effect. Only the two facts -/// the classification depends on travel here: the peer's node address (for the +/// `msg1`). The shell-side Noise step (`complete_handshake_msg3`) runs on the +/// control machine and reads **no** `Node` registry state — the essential +/// invariant of this decomposition — and yields these; the core reads them to +/// classify. The Noise bytes themselves never reach the core — the fresh +/// session extraction stays a shell effect. Only the two facts the +/// classification depends on travel here: the peer's node address (for the /// smaller-NodeAddr tie-breaks) and the peer's captured startup epoch (for /// restart detection). pub(crate) struct WireOutcome { @@ -424,7 +426,7 @@ pub(crate) enum InboundReject { /// cross-connection and the tie-break decides whether we swap our session to the /// (winning) outbound one or keep our existing inbound session. The rekey-msg2 /// completion path is handled by a separate shell driver (it mutates -/// `ActivePeer`, not a `PeerConnection`) and never reaches this decision. +/// `ActivePeer`, not a pending handshake) and never reaches this decision. #[derive(Debug, PartialEq, Eq)] pub(crate) enum OutboundDecision { /// No existing peer for this identity: promote the completed outbound diff --git a/src/proto/fmp/mod.rs b/src/proto/fmp/mod.rs index 10bac15..59409ef 100644 --- a/src/proto/fmp/mod.rs +++ b/src/proto/fmp/mod.rs @@ -19,8 +19,9 @@ //! decision logic (version agreement, profile validation). //! - `limits.rs` — the pure connection-retry backoff math. //! - `state.rs` — [`ConnectionState`], the pure handshake-phase connection -//! bookkeeping (owned by the shell `PeerConnection` beside its Noise crypto -//! handles), plus [`Fmp`], the (stateless) lifecycle anchor owned by `Node`. +//! bookkeeping (owned by the per-peer control machine beside its Noise +//! crypto carrier), plus [`Fmp`], the (stateless) lifecycle anchor owned by +//! `Node`. //! The handshake phase itself lives on the per-peer control machine. //! - `wire.rs` — the FMP wire codec: XX handshake message types, disconnect //! reasons, the orderly disconnect message, and the negotiation payload. diff --git a/src/proto/fmp/state.rs b/src/proto/fmp/state.rs index 2db8008..cbf5230 100644 --- a/src/proto/fmp/state.rs +++ b/src/proto/fmp/state.rs @@ -9,11 +9,11 @@ //! //! [`ConnectionState`] owns every **pure** field of the handshake-phase //! connection. The Noise crypto handles (`noise::HandshakeState`, -//! `NoiseSession`) stay shell-owned in -//! [`PeerConnection`](crate::peer::PeerConnection), which holds a -//! `ConnectionState` alongside them and drives the two halves side by side. The -//! shell's XX transition methods drive the Noise objects, then write learned -//! results back through the pure setters here (`set_expected_identity`, +//! `NoiseSession`) stay shell-owned in the per-peer control machine's +//! handshake carrier, which the machine drives alongside its own +//! `ConnectionState`. The machine's transition methods drive the Noise +//! objects, then write learned results back through the pure setters here +//! (`set_expected_identity`, //! `set_remote_epoch`, `touch`). //! //! This state is `no_std`+`alloc`-clean with respect to transport: the @@ -36,10 +36,9 @@ use crate::utils::index::SessionIndex; /// Pure, runtime-agnostic bookkeeping for a connection in the handshake phase. /// /// Owns every non-crypto field of the handshake-phase connection. The Noise -/// crypto handles live beside it in the shell -/// [`PeerConnection`](crate::peer::PeerConnection); this struct is written only -/// as plain data — the shell extracts learned identity/epoch out of the crypto -/// objects and sets them here through the setters. +/// crypto handles live beside it on the per-peer control machine; this struct +/// is written only as plain data — the machine extracts learned identity and +/// epoch out of the crypto objects and sets them here through the setters. #[derive(Debug)] pub struct ConnectionState { // === Link Reference === @@ -181,6 +180,7 @@ impl ConnectionState { /// Create the pure state for a new inbound connection with transport info. /// /// Used when processing msg1 where we know the transport and source address. + #[cfg(test)] pub fn inbound_with_transport( link_id: LinkId, transport_id: TransportId, @@ -244,6 +244,7 @@ impl ConnectionState { } /// Connection duration so far. + #[cfg(test)] pub fn duration(&self, current_time_ms: u64) -> u64 { current_time_ms.saturating_sub(self.started_at) } diff --git a/src/transport/tcp/mod.rs b/src/transport/tcp/mod.rs index f146a50..ed4b63c 100644 --- a/src/transport/tcp/mod.rs +++ b/src/transport/tcp/mod.rs @@ -124,7 +124,7 @@ impl TcpTransport { /// node-wide `node.limits.max_connections` > built-in default. This is a /// per-transport *raw-accept* ceiling; the true node-wide peer budget is /// still enforced downstream by the handshake-phase `max_connections` - /// admission check (`Node::add_connection`), so deriving this ceiling + /// admission check, so deriving this ceiling /// from `max_connections` does not let multiple transports exceed the /// node-wide total — it only stops the transport from rejecting inbound /// below the configured node budget.