diff --git a/src/node/handlers/handshake.rs b/src/node/handlers/handshake.rs index 758cd94..537c42d 100644 --- a/src/node/handlers/handshake.rs +++ b/src/node/handlers/handshake.rs @@ -17,9 +17,10 @@ use crate::peer::machine::{ }; use crate::proto::fmp::wire::{Msg1Header, Msg2Header, Msg3Header, build_msg2, build_msg3}; use crate::proto::fmp::{ - Disconnect, DisconnectReason, EstablishSnapshot, InboundDecision, InboundReject, - NegotiationPayload, OutboundSnapshot, PromotionResult, WireOutcome, cross_connection_winner, - decide_fmp_negotiation, + DialMsg2Decision, DialMsg2Reject, DialMsg2Snapshot, Disconnect, DisconnectReason, + EstablishSnapshot, InboundDecision, InboundReject, NegotiationPayload, OutboundSnapshot, + PromotionResult, RekeyMsg2Decision, RekeyMsg2Reject, RekeyMsg2Snapshot, WireOutcome, + cross_connection_winner, decide_fmp_negotiation, }; use crate::transport::{Link, LinkDirection, LinkId, ReceivedPacket}; use crate::utils::index::SessionIndex; @@ -479,107 +480,165 @@ impl Node { let mut rekey_completed = false; if let Some(peer) = self.peers.get_mut(&peer_node_addr) { match peer.complete_rekey_msg2(noise_msg2) { - Ok((msg3_bytes, session, remote_epoch)) => { - let our_index = peer.rekey_our_index().unwrap_or(header.receiver_idx); - // Detect a peer restart: the epoch carried in this - // rekey msg2 differs from the one recorded at the - // last handshake. Compute before updating the field. - let remote_epoch_changed = matches!( - (peer.remote_epoch(), remote_epoch), - (Some(old), Some(new)) if old != new - ); - if remote_epoch.is_some() { - peer.set_remote_epoch(remote_epoch); - } - - // Send msg3 before setting pending session - let wire_msg3 = build_msg3(our_index, header.sender_idx, &msg3_bytes); - let msg3_sent = if let (Some(tid), Some(addr)) = - (transport_id, &remote_addr) - && let Some(transport) = self.transports.get(&tid) - { - match transport.send(addr, &wire_msg3).await { - Ok(_) => { - debug!( - peer = %display_name, - "Sent rekey msg3" - ); - true - } - Err(e) => { - warn!( - peer = %display_name, - error = %e, - "Failed to send rekey msg3" - ); - false - } - } - } else { - false - }; - - if msg3_sent { - peer.set_pending_session(session, our_index, header.sender_idx); - - // Retain msg3 for retransmission until the - // responder is confirmed on the new epoch. - // FMP sends msg3 exactly once otherwise; a - // lost datagram leaves the responder without - // the new session, so when the initiator cuts - // over its new-epoch frames silently miss at - // the peer → 30s link-dead. Mirrors FSP's - // resend_pending_session_msg3 liveness path. - peer.set_rekey_msg3_payload( - wire_msg3.clone(), - msg3_now_ms + msg3_resend_interval, - ); - - if let Some(tid) = transport_id { - self.peers_by_index - .insert((tid, our_index.as_u32()), peer_node_addr); - } - - // Peer restart detected during this rekey: - // drop the stale FSP session-layer entry so the - // session map does not linger out of sync with - // the freshly rekeyed FMP link. Only after a - // successful msg3 send (the rekey actually - // completed); on a send failure the rekey is - // abandoned above and no teardown is warranted. - if remote_epoch_changed { - if self.sessions.remove(&peer_node_addr).is_some() { - debug!( - peer = %display_name, - "Cleared stale FSP session after peer restart during FMP rekey" - ); - } - debug!( - peer = %display_name, - "Peer restart detected during FMP rekey, replacing stale endpoint session" + Ok((msg3_bytes, session, remote_epoch, learned_peer)) => { + // Static-key continuity gate. The rekey msg2 was + // matched to this peer by the session index WE + // allocated, which travels in the cleartext rekey + // msg1 header and is observable on path; under XX + // the responder's static is learned from msg2 + // rather than pinned a priori, so crypto success + // alone does not prove the peer already holding + // this link is the one that answered. The core + // decides. A Reject costs the established session + // nothing: its send/recv cipher state is never + // touched here and set_remote_epoch is confined to + // the Install arm, so the working session survives + // intact and usable. The rekey cycle, by contrast, + // is already gone — complete_rekey_msg2 above + // consumed the handshake state and cleared the + // msg1-resend fields — which is why the reject arm + // must abandon the cycle rather than retry it. + let continuity = self.fmp.rekey_outbound(&RekeyMsg2Snapshot { + established_peer: peer_node_addr, + learned_peer, + }); + match continuity { + RekeyMsg2Decision::Install => { + let our_index = + peer.rekey_our_index().unwrap_or(header.receiver_idx); + // Detect a peer restart: the epoch carried in this + // rekey msg2 differs from the one recorded at the + // last handshake. Compute before updating the field. + let remote_epoch_changed = matches!( + (peer.remote_epoch(), remote_epoch), + (Some(old), Some(new)) if old != new ); - } - - debug!( - peer = %display_name, - our_addr = %self.identity().node_addr(), - new_our_index = %our_index, - new_their_index = %header.sender_idx, - "rekey-msg2 initiator: pending session set, awaiting K-bit cutover" - ); - - rekey_completed = true; - } else { - // msg3 send failed — abandon rekey - if let Some(idx) = peer.abandon_rekey() { - if let Some(tid) = peer.transport_id() { - self.peers_by_index.remove(&(tid, idx.as_u32())); + if remote_epoch.is_some() { + peer.set_remote_epoch(remote_epoch); + } + + // Send msg3 before setting pending session + let wire_msg3 = + build_msg3(our_index, header.sender_idx, &msg3_bytes); + let msg3_sent = if let (Some(tid), Some(addr)) = + (transport_id, &remote_addr) + && let Some(transport) = self.transports.get(&tid) + { + match transport.send(addr, &wire_msg3).await { + Ok(_) => { + debug!( + peer = %display_name, + "Sent rekey msg3" + ); + true + } + Err(e) => { + warn!( + peer = %display_name, + error = %e, + "Failed to send rekey msg3" + ); + false + } + } + } else { + false + }; + + if msg3_sent { + peer.set_pending_session( + session, + our_index, + header.sender_idx, + ); + + // Retain msg3 for retransmission until the + // responder is confirmed on the new epoch. + // FMP sends msg3 exactly once otherwise; a + // lost datagram leaves the responder without + // the new session, so when the initiator cuts + // over its new-epoch frames silently miss at + // the peer → 30s link-dead. Mirrors FSP's + // resend_pending_session_msg3 liveness path. + peer.set_rekey_msg3_payload( + wire_msg3.clone(), + msg3_now_ms + msg3_resend_interval, + ); + + if let Some(tid) = transport_id { + self.peers_by_index + .insert((tid, our_index.as_u32()), peer_node_addr); + } + + // Peer restart detected during this rekey: + // drop the stale FSP session-layer entry so the + // session map does not linger out of sync with + // the freshly rekeyed FMP link. Only after a + // successful msg3 send (the rekey actually + // completed); on a send failure the rekey is + // abandoned above and no teardown is warranted. + if remote_epoch_changed { + if self.sessions.remove(&peer_node_addr).is_some() { + debug!( + peer = %display_name, + "Cleared stale FSP session after peer restart during FMP rekey" + ); + } + debug!( + peer = %display_name, + "Peer restart detected during FMP rekey, replacing stale endpoint session" + ); + } + + debug!( + peer = %display_name, + our_addr = %self.identity().node_addr(), + new_our_index = %our_index, + new_their_index = %header.sender_idx, + "rekey-msg2 initiator: pending session set, awaiting K-bit cutover" + ); + + rekey_completed = true; + } else { + // msg3 send failed — abandon rekey + if let Some(idx) = peer.abandon_rekey() { + if let Some(tid) = peer.transport_id() { + self.peers_by_index.remove(&(tid, idx.as_u32())); + } + let _ = self.index_allocator.free(idx); + } + self.stats_mut().record_reject(RejectReason::Handshake( + HandshakeReject::BadState, + )); } - let _ = self.index_allocator.free(idx); } - self.stats_mut().record_reject(RejectReason::Handshake( - HandshakeReject::BadState, - )); + RekeyMsg2Decision::Reject { + reason: RekeyMsg2Reject::StaticMismatch, + } => { + // Not our peer: the freshly derived session + // is never installed (it falls out of + // scope here), this rekey cycle is + // abandoned, and the current session, its + // indices and its recorded epoch are left + // exactly as they were. No msg3 is sent, + // so the impostor learns nothing beyond + // what it already observed on the wire. + warn!( + peer = %display_name, + established = %peer_node_addr, + learned = %learned_peer, + "rekey-msg2 initiator: learned static is not the established peer, keeping current session" + ); + if let Some(idx) = peer.abandon_rekey() { + if let Some(tid) = peer.transport_id() { + self.peers_by_index.remove(&(tid, idx.as_u32())); + } + let _ = self.index_allocator.free(idx); + } + self.stats_mut().record_reject(RejectReason::Handshake( + HandshakeReject::BadState, + )); + } } } Err(e) => { @@ -624,7 +683,7 @@ impl Node { } let our_profile = self.node_profile(); - let (peer_identity, msg3_bytes, our_index) = { + let (peer_identity, dialed_identity, msg3_bytes, our_index) = { 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); @@ -698,9 +757,13 @@ impl Node { } }; + // The dial intent, read from the same carrier and untouched by the + // completion above. `None` on an anonymous shared-media leg. + let dialed_identity = machine.conn_dialed_identity().copied(); + let our_index = machine.our_index(); - (peer_identity, msg3_bytes, our_index) + (peer_identity, dialed_identity, msg3_bytes, our_index) }; let peer_node_addr = *peer_identity.node_addr(); @@ -710,6 +773,69 @@ impl Node { // `process_fmp_negotiation` above, so last-activity advances at msg2 // completion and the promotion hand-off reads the profile from it. + // Dial-identity gate. Under XX the responder's static arrives in msg2 + // rather than being pinned at dial (as IK pinned it), so an on-path + // party that observes our msg1 and answers it first produces a + // perfectly valid handshake under its own static. Crypto success + // therefore proves only that someone answered — the core decides + // whether that someone is who we dialed. This sits ahead of every + // subsequent step, including the msg3 send, so a substituted responder + // never gets its handshake completed and nothing downstream ever sees + // its identity. Nothing outside the doomed machine has been mutated by + // the crypto above, which is why rejecting here needs only to dispose + // of the leg. + let dialed_peer = dialed_identity.map(|id| *id.node_addr()); + let continuity = self.fmp.dial_outbound(&DialMsg2Snapshot { + dialed_peer, + learned_peer: peer_node_addr, + }); + match continuity { + // The dial named nobody, or it named whoever answered: fall + // through to the ACL gate and promotion below, unchanged. + DialMsg2Decision::Accept => {} + DialMsg2Decision::Reject { + reason: DialMsg2Reject::StaticMismatch, + } => { + warn!( + link_id = %link_id, + dialed = ?dialed_peer, + learned = %peer_node_addr, + "msg2 answered by a different static than the one dialed, dropping the leg" + ); + // Free everything this leg holds. `our_index` is the index WE + // allocated at msg1 preparation, read back off the machine — + // never the `receiver_idx` the msg2 header supplied, which an + // attacker chooses. Capture happened above, before the disposal + // that would make it unreadable. + self.pending_outbound.remove(&key); + self.remove_peer_machine(link_id); + self.remove_link(&link_id); + if let Some(idx) = our_index { + let _ = self.index_allocator.free(idx); + } + // Put the dial back on the retry schedule. The disposal above + // takes the leg out of both reapers — its machine and its + // handshake timer are gone — so the stuck-leg sweep that + // normally reaches `note_handshake_timeout` never runs for it, + // and that reflex is the only thing that seeds `retry_pending` + // for a configured peer. Without this call a single rejected + // dial would retire the peer for the process lifetime: the + // configured-peer floor dials once at startup and every later + // dial comes off `retry_pending`. + // + // The reschedule targets `dialed_peer`, the dial-time + // expectation, NOT the machine's expected identity — the + // completion above already overwrote that with the answering + // static, so reading it here would re-dial the impostor. + if let Some(peer) = dialed_peer { + self.note_handshake_timeout(peer, packet.timestamp_ms); + } + self.stats_mut() + .record_reject(RejectReason::Handshake(HandshakeReject::BadState)); + return; + } + } + // ACL check: with XX, this is the first point where the initiator // knows the responder's identity. if self @@ -733,13 +859,14 @@ impl Node { if peer_node_addr == *self.identity().node_addr() { // Reachable by any outbound leg whose msg2 static key turns out to - // be our own — usually an anonymous shared-media beacon, but an - // identified dial misdirected at ourselves lands here too (the - // learned identity overwrites the dial-time expectation and is - // never compared against it). This leg never promotes; its machine - // goes with it (dropping the embedded pending connection). The - // index, link, and `pending_outbound` entry are deliberately NOT - // freed here (pre-existing shape). + // be our own: an anonymous shared-media beacon that echoed us back + // at ourselves, or a dial that named our own identity and reached + // it. An identified dial that reached someone ELSE no longer + // arrives here — the dial-identity gate above catches it first, + // and only a dialed == learned == us leg gets this far. This leg + // never promotes; its machine goes with it (dropping the embedded + // pending connection). The index, link, and `pending_outbound` + // entry are deliberately NOT freed here (pre-existing shape). debug!(link_id = %link_id, "Discovered self via shared-media beacon, dropping"); self.remove_peer_machine(link_id); self.stats_mut() diff --git a/src/node/tests/handshake.rs b/src/node/tests/handshake.rs index 5e60ba4..3c1f27a 100644 --- a/src/node/tests/handshake.rs +++ b/src/node/tests/handshake.rs @@ -2030,3 +2030,522 @@ async fn test_anonymous_self_connect_drop_disposes_machine() { stop_hs(&mut node).await; } + +// =========================================================================== +// Initiator rekey static-key continuity +// +// The rekey msg2 is dispatched to its peer by the session index the initiator +// itself allocated, and that index travels in the CLEARTEXT rekey msg1 header. +// Under XX the responder's static arrives in msg2 rather than being pinned at +// dial (as IK pinned it), so an on-path party that beats the real peer to the +// reply produces a perfectly valid handshake under its own static. The +// continuity gate is what stops that session from taking the peer's slot. +// =========================================================================== + +/// Establish initiator↔responder, start a real rekey on the initiator, then +/// let a third node answer the rekey msg1 with a valid XX msg2 built from its +/// OWN static. The initiator must reject it and keep the established session +/// live and usable. +#[tokio::test] +async fn test_rekey_msg2_foreign_static_rejected() { + let mut rekey_config = Config::new(); + rekey_config.node.rekey.enabled = true; + rekey_config.node.rekey.after_secs = 1; + + let mut initiator = make_hs_node(rekey_config).await; + let mut responder = make_hs_node(Config::new()).await; + let mut attacker = make_hs_node(Config::new()).await; + + let responder_addr = + *PeerIdentity::from_pubkey_full(responder.node.identity().pubkey_full()).node_addr(); + let initiator_addr = + *PeerIdentity::from_pubkey_full(initiator.node.identity().pubkey_full()).node_addr(); + let attacker_addr = + *PeerIdentity::from_pubkey_full(attacker.node.identity().pubkey_full()).node_addr(); + + // Establish the link both ways. + let msg3 = drive_to_msg3(&mut initiator, &mut responder, 1000).await; + responder.node.handle_msg3(msg3).await; + assert_eq!(initiator.node.peer_count(), 1); + assert_eq!(responder.node.peer_count(), 1); + + // Record what the established session must still look like afterwards. + let session_hash = *initiator + .node + .get_peer(&responder_addr) + .unwrap() + .noise_session() + .unwrap() + .handshake_hash(); + let peer_link = initiator.node.get_peer(&responder_addr).unwrap().link_id(); + + // Age the session past the (jittered) rekey threshold and let the real + // cadence fire, so the rekey msg1 and its index are produced exactly as in + // production. + initiator + .node + .get_peer_mut(&responder_addr) + .unwrap() + .test_backdate_session_established(std::time::Duration::from_secs(120)); + let baseline = initiator.node.index_allocator.count(); + initiator.node.check_rekey().await; + let rekey_index = initiator + .node + .get_peer(&responder_addr) + .unwrap() + .rekey_our_index() + .expect("cadence started a rekey"); + assert_eq!( + initiator.node.index_allocator.count(), + baseline + 1, + "rekey allocated its own index" + ); + + // The attacker observes the cleartext rekey msg1 on path and answers it + // first, under its own static. The real responder never sees it. + let rekey_msg1 = recv_phase(&mut responder.packet_rx, 1, "rekey msg1").await; + attacker.node.handle_msg1(rekey_msg1).await; + let forged_msg2 = recv_phase(&mut initiator.packet_rx, 2, "forged rekey msg2").await; + initiator.node.handle_msg2(forged_msg2).await; + + // The impostor never becomes (or displaces) a peer. + assert_eq!(initiator.node.peer_count(), 1, "peer set unchanged"); + assert!( + initiator.node.get_peer(&attacker_addr).is_none(), + "impostor must not enter the peer set" + ); + let peer = initiator.node.get_peer(&responder_addr).expect("kept"); + assert!( + peer.pending_new_session().is_none(), + "a foreign static must not be installed as the pending session" + ); + assert!( + !peer.rekey_in_progress(), + "the rejected rekey cycle is abandoned" + ); + assert_eq!(peer.link_id(), peer_link, "the peer keeps its link"); + + // The established session is byte-for-byte the one we started with, still + // bound to the real responder. + assert_eq!( + peer.noise_session().unwrap().handshake_hash(), + &session_hash, + "the established session was not replaced" + ); + assert_eq!( + peer.noise_session().unwrap().remote_static_xonly(), + responder.node.identity().pubkey(), + "the established session stays bound to the real peer" + ); + + // The rekey index is returned and its msg2 dispatch entry is gone, so a + // late (or replayed) msg2 on that index cannot re-enter the dead cycle. + assert_eq!( + initiator.node.index_allocator.count(), + baseline, + "the rejected rekey must free its index" + ); + assert!( + !initiator + .node + .pending_outbound + .contains_key(&(initiator.transport_id, rekey_index.as_u32())), + "the rejected rekey's dispatch entry must not survive" + ); + initiator.node.debug_assert_peer_maps_coherent(); + + // ...and it is still usable: the initiator encrypts under the surviving + // session and the real responder decrypts it. + let probe = b"link still live after the rejected rekey"; + let counter = initiator + .node + .get_peer(&responder_addr) + .unwrap() + .noise_session() + .unwrap() + .current_send_counter(); + let ciphertext = initiator + .node + .get_peer_mut(&responder_addr) + .unwrap() + .noise_session_mut() + .unwrap() + .encrypt(probe) + .expect("encrypt under the surviving session"); + let plaintext = responder + .node + .get_peer_mut(&initiator_addr) + .unwrap() + .noise_session_mut() + .unwrap() + .decrypt_with_replay_check(&ciphertext, counter) + .expect("the peer still decrypts under the original session"); + assert_eq!(plaintext, probe); + + stop_hs(&mut initiator).await; + stop_hs(&mut responder).await; + stop_hs(&mut attacker).await; +} + +/// The same cadence-driven rekey, answered by the REAL peer, still installs the +/// pending session — the gate must be invisible on the legitimate path. +#[tokio::test] +async fn test_rekey_msg2_matching_static_installs() { + let mut rekey_config = Config::new(); + rekey_config.node.rekey.enabled = true; + rekey_config.node.rekey.after_secs = 1; + + let mut initiator = make_hs_node(rekey_config).await; + let mut responder = make_hs_node(Config::new()).await; + + let responder_addr = + *PeerIdentity::from_pubkey_full(responder.node.identity().pubkey_full()).node_addr(); + + let msg3 = drive_to_msg3(&mut initiator, &mut responder, 1000).await; + responder.node.handle_msg3(msg3).await; + assert_eq!(initiator.node.peer_count(), 1); + + initiator + .node + .get_peer_mut(&responder_addr) + .unwrap() + .test_backdate_session_established(std::time::Duration::from_secs(120)); + initiator.node.check_rekey().await; + let rekey_index = initiator + .node + .get_peer(&responder_addr) + .unwrap() + .rekey_our_index() + .expect("cadence started a rekey"); + + // The real peer answers its own rekey msg1. + let rekey_msg1 = recv_phase(&mut responder.packet_rx, 1, "rekey msg1").await; + responder.node.handle_msg1(rekey_msg1).await; + let rekey_msg2 = recv_phase(&mut initiator.packet_rx, 2, "rekey msg2").await; + initiator.node.handle_msg2(rekey_msg2).await; + + let peer = initiator.node.get_peer(&responder_addr).expect("kept"); + assert!( + peer.pending_new_session().is_some(), + "a matching static installs the pending session" + ); + assert_eq!( + peer.pending_new_session().unwrap().remote_static_xonly(), + responder.node.identity().pubkey(), + "the pending session is bound to the real peer" + ); + assert!( + initiator + .node + .peers_by_index + .contains_key(&(initiator.transport_id, rekey_index.as_u32())), + "the rekey index maps to the peer, awaiting K-bit cutover" + ); + initiator.node.debug_assert_peer_maps_coherent(); + + stop_hs(&mut initiator).await; + stop_hs(&mut responder).await; +} + +// =========================================================================== +// Initiator dial-identity pinning (initial outbound handshake) +// +// The rekey gate above protects a link that is already established; this one +// protects the link being formed, and needs no rekey to reach. Under XX the +// responder's static arrives in msg2 rather than being pinned at dial (as IK +// pinned it), so an on-path party that observes our msg1 and answers it first +// produces a perfectly valid handshake under its own static. The dial-identity +// gate is what stops that leg from being promoted as the peer we dialed. +// +// The three cases below are the whole decision surface: a named dial answered +// by a stranger (reject), a named dial answered by its peer (promote), and an +// anonymous dial, which names nobody and so must still promote whoever answers +// - the carve-out that keeps shared-media discovery working. +// =========================================================================== + +/// A named dial answered by a foreign static must not promote, and must leave +/// no residue behind: no machine, no leg, no link, no `pending_outbound` +/// dispatch entry, and no orphaned session index. +#[tokio::test] +async fn test_dial_msg2_foreign_static_rejected() { + use std::time::Duration; + use tokio::time::timeout; + + let mut initiator = make_hs_node(Config::new()).await; + let mut intended = make_hs_node(Config::new()).await; + let mut attacker = make_hs_node(Config::new()).await; + + let intended_identity = PeerIdentity::from_pubkey_full(intended.node.identity().pubkey_full()); + let intended_addr = *intended_identity.node_addr(); + let attacker_addr = + *PeerIdentity::from_pubkey_full(attacker.node.identity().pubkey_full()).node_addr(); + assert_ne!(intended_addr, attacker_addr); + + let baseline = initiator.node.index_allocator.count(); + + // A named dial whose msg1 reaches the attacker instead of the peer. From + // the initiator's side this is indistinguishable from an on-path party + // racing the real responder's msg2, and it is the same thing the code sees. + initiator + .node + .initiate_connection( + initiator.transport_id, + attacker.addr.clone(), + Some(intended_identity), + ) + .await + .expect("named dial"); + + let leg_link = initiator.node.connections().next().unwrap().1.link_id(); + let leg_index = initiator + .node + .peer_machines + .get(&leg_link) + .unwrap() + .our_index() + .expect("msg1 preparation allocated our index"); + assert_eq!( + initiator.node.index_allocator.count(), + baseline + 1, + "the dial allocated its own index" + ); + + // The attacker answers the dial with a valid XX msg2 under its own static. + let msg1 = recv_phase(&mut attacker.packet_rx, 1, "msg1").await; + attacker.node.handle_msg1(msg1).await; + let forged_msg2 = recv_phase(&mut initiator.packet_rx, 2, "forged msg2").await; + initiator.node.handle_msg2(forged_msg2).await; + + // Nothing is promoted - not the impostor, and not the peer we dialed + // (whose identity never authenticated anything here). + assert_eq!(initiator.node.peer_count(), 0, "no promotion"); + assert!( + initiator.node.get_peer(&attacker_addr).is_none(), + "the impostor must not enter the peer set" + ); + assert!( + initiator.node.get_peer(&intended_addr).is_none(), + "the dialed peer must not be credited with a handshake it never ran" + ); + + // No registry residue: the leg, its machine, its link, its dispatch entry + // and its index are all gone. + assert!( + !initiator.node.has_pending_leg(&leg_link), + "the rejected leg is torn down" + ); + assert!( + !initiator.node.peer_machines.contains_key(&leg_link), + "the rejected leg's machine is disposed" + ); + assert!( + !initiator.node.links.contains_key(&leg_link), + "the rejected leg's link is removed" + ); + assert!( + !initiator + .node + .pending_outbound + .contains_key(&(initiator.transport_id, leg_index.as_u32())), + "the rejected leg's dispatch entry must not survive, or a replayed \ + msg2 could re-enter the dead leg" + ); + assert_eq!( + initiator.node.index_allocator.count(), + baseline, + "the rejected dial must free the index it allocated" + ); + initiator.node.debug_assert_peer_maps_coherent(); + + // The gate sits ahead of the msg3 send, so the impostor's handshake is + // never completed: it is left waiting on a msg3 that never comes. + assert!( + timeout(Duration::from_millis(250), attacker.packet_rx.recv()) + .await + .is_err(), + "no msg3 may be sent to a responder that substituted its identity" + ); + assert_eq!( + attacker.node.peer_count(), + 0, + "the impostor never completes its own side either" + ); + + stop_hs(&mut initiator).await; + stop_hs(&mut intended).await; + stop_hs(&mut attacker).await; +} + +/// The rejected dial must stay on the dial schedule. Disposing the leg takes it +/// out of both reapers, so the handshake-timeout sweep that normally reschedules +/// a stuck outbound dial never sees it; the reject arm has to fire that reflex +/// itself. Without it a configured peer is dialed once at startup and, after one +/// substituted msg2, never again for the life of the process - a persistent +/// outbound blackhole costing the attacker a single packet. The retry must also +/// name the peer we dialed, not the static that answered. +#[tokio::test] +async fn test_dial_msg2_foreign_static_reschedules_dial() { + let intended_local = Identity::generate(); + let intended_identity = + PeerIdentity::from_npub(&intended_local.npub()).expect("generated npub parses"); + let intended_addr = *intended_identity.node_addr(); + + let mut attacker = make_hs_node(Config::new()).await; + let attacker_addr = + *PeerIdentity::from_pubkey_full(attacker.node.identity().pubkey_full()).node_addr(); + assert_ne!(intended_addr, attacker_addr); + + // The dialed peer is a configured auto-connect peer: that is the only + // shape the retry machinery will seed a schedule entry for, and it is the + // shape the blackhole strands. + let mut config = Config::new(); + config.peers.push(crate::config::PeerConfig::new( + intended_local.npub(), + "udp", + "10.0.0.2:2121", + )); + let mut initiator = make_hs_node(config).await; + + assert!( + initiator.node.peering.reconciler.retry_pending.is_empty(), + "nothing is scheduled before the dial" + ); + + initiator + .node + .initiate_connection( + initiator.transport_id, + attacker.addr.clone(), + Some(intended_identity), + ) + .await + .expect("named dial"); + + let msg1 = recv_phase(&mut attacker.packet_rx, 1, "msg1").await; + attacker.node.handle_msg1(msg1).await; + let forged_msg2 = recv_phase(&mut initiator.packet_rx, 2, "forged msg2").await; + initiator.node.handle_msg2(forged_msg2).await; + + assert_eq!(initiator.node.peer_count(), 0, "no promotion"); + assert!( + initiator + .node + .peering + .reconciler + .retry_pending + .contains_key(&intended_addr), + "the rejected dial must leave the peer we dialed scheduled for retry, \ + or the configured peer is never dialed again" + ); + assert!( + !initiator + .node + .peering + .reconciler + .retry_pending + .contains_key(&attacker_addr), + "the retry must name the peer we dialed, never the static that answered" + ); + + stop_hs(&mut initiator).await; + stop_hs(&mut attacker).await; +} + +/// The same named dial, answered by the peer it named, still promotes - the +/// gate must be invisible on the legitimate path. +#[tokio::test] +async fn test_dial_msg2_matching_static_promotes() { + let mut initiator = make_hs_node(Config::new()).await; + let mut responder = make_hs_node(Config::new()).await; + + let responder_identity = + PeerIdentity::from_pubkey_full(responder.node.identity().pubkey_full()); + let responder_addr = *responder_identity.node_addr(); + + initiator + .node + .initiate_connection( + initiator.transport_id, + responder.addr.clone(), + Some(responder_identity), + ) + .await + .expect("named dial"); + let leg_link = initiator.node.connections().next().unwrap().1.link_id(); + + let msg1 = recv_phase(&mut responder.packet_rx, 1, "msg1").await; + responder.node.handle_msg1(msg1).await; + let msg2 = recv_phase(&mut initiator.packet_rx, 2, "msg2").await; + initiator.node.handle_msg2(msg2).await; + + assert_eq!(initiator.node.peer_count(), 1); + let peer = initiator.node.get_peer(&responder_addr).expect("promoted"); + assert_eq!(peer.link_id(), leg_link, "promote keeps the leg's link"); + initiator.node.debug_assert_peer_maps_coherent(); + + // The responder completes its own side from the msg3 the gate let through. + let msg3 = recv_phase(&mut responder.packet_rx, 3, "msg3").await; + responder.node.handle_msg3(msg3).await; + assert_eq!(responder.node.peer_count(), 1); + responder.node.debug_assert_peer_maps_coherent(); + + stop_hs(&mut initiator).await; + stop_hs(&mut responder).await; +} + +/// The anonymous carve-out. A shared-media dial names nobody, so the msg2 +/// static is the ONLY identity the leg will ever have and there is no intent +/// for it to contradict - it must promote exactly as before. A gate that +/// pinned the wrong thing (or pinned unconditionally) stops anonymous +/// discovery promoting anything at all, and this is where that shows up. +/// +/// Whether a leg is anonymous is settled here, at construction, from what the +/// caller passed - never from anything on the wire - so no responder can steer +/// a named dial onto this path. +#[tokio::test] +async fn test_anonymous_dial_msg2_promotes_whoever_answers() { + let mut initiator = make_hs_node(Config::new()).await; + let mut responder = make_hs_node(Config::new()).await; + + let responder_addr = + *PeerIdentity::from_pubkey_full(responder.node.identity().pubkey_full()).node_addr(); + + initiator + .node + .initiate_connection(initiator.transport_id, responder.addr.clone(), None) + .await + .expect("anonymous dial"); + let leg_link = initiator.node.connections().next().unwrap().1.link_id(); + assert_eq!( + initiator + .node + .peer_machines + .get(&leg_link) + .unwrap() + .conn_dialed_identity(), + None, + "an anonymous dial records no dial intent, which is what selects the \ + no-comparison branch" + ); + + let msg1 = recv_phase(&mut responder.packet_rx, 1, "msg1").await; + responder.node.handle_msg1(msg1).await; + let msg2 = recv_phase(&mut initiator.packet_rx, 2, "msg2").await; + initiator.node.handle_msg2(msg2).await; + + assert_eq!( + initiator.node.peer_count(), + 1, + "an anonymous dial promotes whoever answered it" + ); + let peer = initiator.node.get_peer(&responder_addr).expect("promoted"); + assert_eq!(peer.link_id(), leg_link); + initiator.node.debug_assert_peer_maps_coherent(); + + let msg3 = recv_phase(&mut responder.packet_rx, 3, "msg3").await; + responder.node.handle_msg3(msg3).await; + assert_eq!(responder.node.peer_count(), 1); + + stop_hs(&mut initiator).await; + stop_hs(&mut responder).await; +} diff --git a/src/node/tests/unit.rs b/src/node/tests/unit.rs index 787dcba..0d675c7 100644 --- a/src/node/tests/unit.rs +++ b/src/node/tests/unit.rs @@ -2582,7 +2582,11 @@ fn xx_handshake_records_each_learned_identity_on_the_carrier() { let responder_identity = PeerIdentity::from_pubkey_full(responder.pubkey_full()); // The dial-time expectation is a third party: whoever actually answers - // overwrites it, and it is never compared against the learned key. + // overwrites it here. The dial intent is not lost with it — it lives on in + // the carrier's separate write-once `dialed_identity`, which `handle_msg2` + // holds the learned key up against before promoting. That comparison is the + // caller's; this crypto leaf performs the write regardless, which is what + // is asserted below. let decoy = PeerIdentity::from_pubkey_full(Identity::generate().pubkey_full()); assert_ne!(decoy, responder_identity); @@ -2609,6 +2613,17 @@ fn xx_handshake_records_each_learned_identity_on_the_carrier() { Some(&responder_identity), "the identity learned from msg2 must be recorded on the surviving carrier" ); + assert_eq!( + conn_i.conn_dialed_identity(), + Some(&decoy), + "the dial intent must survive the msg2 write, or there is nothing left \ + to compare the learned identity against" + ); + assert_eq!( + conn_r.conn_dialed_identity(), + None, + "an inbound leg dialed nobody" + ); conn_r.complete_handshake_msg3(&noise_msg3, 1300).unwrap(); assert_eq!( diff --git a/src/peer/active.rs b/src/peer/active.rs index f3a7025..b011450 100644 --- a/src/peer/active.rs +++ b/src/peer/active.rs @@ -19,9 +19,16 @@ use std::fmt; use std::time::Instant; /// Result of completing a rekey msg2 on the initiator (XX pattern): -/// the XX msg3 bytes to send, the completed Noise session, and the remote -/// peer's startup epoch (for peer-restart detection). -type RekeyMsg2Completion = (Vec, NoiseSession, Option<[u8; 8]>); +/// the XX msg3 bytes to send, the completed Noise session, the remote +/// peer's startup epoch (for peer-restart detection), and the node address +/// derived from the static key the returned session is bound to. +/// +/// XX transmits the responder's static in msg2 rather than pinning it a priori +/// (as IK did), so the learned identity is surfaced here for the caller's +/// static-continuity decision: a cryptographically valid handshake alone does +/// not establish that the rekey was answered by the peer already holding the +/// link. +type RekeyMsg2Completion = (Vec, NoiseSession, Option<[u8; 8]>, NodeAddr); /// Draw a fresh per-session rekey jitter from `[-REKEY_JITTER_SECS, +REKEY_JITTER_SECS]`. fn draw_rekey_jitter() -> i64 { @@ -1268,11 +1275,17 @@ impl ActivePeer { /// Complete the rekey by processing msg2 (initiator side, XX pattern). /// - /// Takes the stored handshake state, reads XX msg2, generates XX msg3, - /// and returns (msg3_bytes, completed NoiseSession, remote startup epoch). - /// Clears the handshake-related fields but leaves rekey_our_index for - /// set_pending_session to use. The remote epoch is surfaced so the caller - /// can detect a peer restart (changed epoch) during recovery rekey. + /// Takes the stored handshake state, reads XX msg2, generates XX msg3, and + /// returns (msg3_bytes, completed NoiseSession, remote startup epoch, + /// learned peer node address). Clears the handshake-related fields but + /// leaves rekey_our_index for set_pending_session to use. The remote epoch + /// is surfaced so the caller can detect a peer restart (changed epoch) + /// during recovery rekey; the learned node address is surfaced so the + /// caller can gate the install on static-key continuity. + /// + /// Completing the handshake here is deliberately identity-agnostic: this + /// is the crypto leaf, and whether the learned identity may replace the + /// peer's session is a decision, taken by the caller against the FMP core. pub fn complete_rekey_msg2( &mut self, msg2_bytes: &[u8], @@ -1308,12 +1321,17 @@ impl ActivePeer { let msg3 = hs.write_message_3()?; let session = hs.into_session()?; + // Derive the learned identity from the session rather than the consumed + // handshake so the address returned is provably the one the session + // about to be installed is bound to. + let learned_peer = NodeAddr::from_pubkey(&session.remote_static_xonly()); + // Clear msg1 resend state self.rekey_msg1 = None; self.rekey_msg1_next_resend = 0; self.rekey_msg1_resend_count = 0; - Ok((msg3, session, remote_epoch)) + Ok((msg3, session, remote_epoch, learned_peer)) } /// Complete the rekey by processing msg3 (responder side, XX pattern). diff --git a/src/peer/machine.rs b/src/peer/machine.rs index 3b0b005..3ef8e9e 100644 --- a/src/peer/machine.rs +++ b/src/peer/machine.rs @@ -876,6 +876,15 @@ impl PeerMachine { /// 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. + /// + /// Deliberately identity-agnostic, like its rekey sibling + /// `ActivePeer::complete_rekey_msg2`: this is the crypto leaf, and whether + /// the identity that answered may be admitted as the one dialed is a + /// decision, taken by the caller against the FMP core. The learned identity + /// lands on the carrier unconditionally — on an anonymous leg that write is + /// the only way the carrier ever gets an identity — while the dial intent + /// sits untouched in `conn_dialed_identity` for the caller to compare + /// against. pub(crate) fn complete_handshake( &mut self, message: &[u8], @@ -1128,6 +1137,16 @@ impl PeerMachine { self.conn.expected_identity() } + /// The identity this leg was *dialed at*, as opposed to the one that + /// answered. `Some` only on an identified outbound dial; `None` on an + /// anonymous shared-media dial and on every inbound leg. Fixed when the + /// carrier is built and untouched by the handshake, so `handle_msg2` can + /// hold the msg2-learned identity up against the dial intent instead of + /// finding the intent already overwritten by it. + pub(crate) fn conn_dialed_identity(&self) -> Option<&PeerIdentity> { + self.conn.dialed_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 diff --git a/src/proto/fmp/core.rs b/src/proto/fmp/core.rs index f43f847..aa1d04d 100644 --- a/src/proto/fmp/core.rs +++ b/src/proto/fmp/core.rs @@ -283,6 +283,49 @@ pub(crate) struct OutboundSnapshot { pub our_outbound_wins: bool, } +/// A snapshot of the two identities the *initiator's rekey* `msg2` completion +/// decision compares, taken by the shell once the Noise step has run. +/// +/// A rekey `msg2` is dispatched to its peer by the session index the initiator +/// itself allocated, which travels in the cleartext rekey-`msg1` header and is +/// therefore observable on path. Under XX the responder's static is *learned* +/// from `msg2` rather than pinned a priori (as it was under IK), so a +/// cryptographically valid `msg2` on its own proves only that *someone* +/// answered — not that the peer we are already bonded to answered. This +/// snapshot carries the two facts that settle it; no Noise material reaches the +/// core. +pub(crate) struct RekeyMsg2Snapshot { + /// The node address of the established peer whose rekey this `msg2` claims + /// to complete — the `Node::peers` key the rekey index resolved to. + pub established_peer: NodeAddr, + /// The node address derived from the static key learned in this `msg2`, + /// i.e. the identity the completed Noise session would actually be bound to. + pub learned_peer: NodeAddr, +} + +/// A snapshot of the two identities the *initial outbound* `msg2` completion +/// decision compares, taken by the shell once the Noise step has run. +/// +/// The sibling of [`RekeyMsg2Snapshot`] one rung earlier in the lifecycle: the +/// rekey gate protects a link that is already established, this one protects the +/// link being formed. Same root cause — XX *learns* the responder's static from +/// `msg2` instead of pinning it a priori as IK did, so a cryptographically valid +/// `msg2` proves only that *someone* answered the dial, never that the peer we +/// meant to reach answered it. No Noise material reaches the core. +pub(crate) struct DialMsg2Snapshot { + /// The node address this leg was dialed at, or `None` if the dial named no + /// peer. + /// + /// `None` is an *anonymous* leg — a shared-media beacon dial, whose beacon + /// asserts no identity for anyone to substitute — and it is decided locally + /// when the leg is built, never from anything on the wire, so no peer can + /// steer an identified dial into the `None` branch. + pub dialed_peer: Option, + /// The node address derived from the static key learned in this `msg2`, + /// i.e. the identity this leg would actually be promoted under. + pub learned_peer: NodeAddr, +} + /// A registry/transport effect the async shell performs on the core's behalf. /// /// The scaffold subset covers the maintain/teardown half of the lifecycle. The @@ -443,6 +486,71 @@ pub(crate) enum OutboundDecision { CrossConnectionKeep, } +/// The classification outcome for one initiator-side rekey `msg2` completion, +/// decided purely from the [`RekeyMsg2Snapshot`]. The shell matches on this and +/// drives the effects; the core consumes nothing and touches no live state. +/// +/// This is the initiator-side counterpart of the continuity the responder gets +/// structurally: an inbound rekey completes keyed by the node address derived +/// from the authenticated static, so a non-matching static resolves to a +/// different peer and can never displace an established session. The initiator +/// dispatches by its own session index instead, so the same guarantee has to be +/// stated as an explicit decision. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum RekeyMsg2Decision { + /// The rekey was answered by the peer we are bonded to: install the fresh + /// session as the peer's pending (post-rekey) session awaiting K-bit + /// cutover, the unchanged pre-existing behaviour. + Install, + /// The rekey was answered by some other identity: drop this `msg2` with a + /// handshake reject, abandon the rekey cycle, and leave the established + /// session completely undisturbed. `reason` selects only the diagnostic + /// log line. + Reject { reason: RekeyMsg2Reject }, +} + +/// Why an initiator-side rekey `msg2` was dropped by the core classification. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum RekeyMsg2Reject { + /// The static learned in `msg2` derives to a different node address than + /// the established peer's — an on-path party raced a valid XX `msg2` of its + /// own against the observable rekey `msg1`, or the peer's identity changed + /// underneath the link. Either way the established session is kept. + StaticMismatch, +} + +/// The classification outcome for one initiator-side *initial* `msg2` +/// completion, decided purely from the [`DialMsg2Snapshot`]. The shell matches +/// on this and drives the effects; the core consumes nothing and touches no +/// live state. +/// +/// The responder gets the equivalent guarantee structurally — an inbound leg is +/// promoted under the node address derived from the authenticated static, so +/// there is no prior expectation for a foreign static to contradict. Only the +/// initiator holds an intent that the wire can disagree with, so only here does +/// it have to be stated as an explicit decision. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum DialMsg2Decision { + /// Either the dial named no peer (an anonymous leg, where any answer is a + /// legitimate first contact) or it named the peer that answered. Carry on + /// into the ACL gate and promotion, the unchanged pre-existing behaviour. + Accept, + /// The dial named a peer and someone else answered: drop this `msg2` with a + /// handshake reject and promote nothing. `reason` selects only the + /// diagnostic log line. + Reject { reason: DialMsg2Reject }, +} + +/// Why an initiator-side initial `msg2` was dropped by the core classification. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum DialMsg2Reject { + /// The static learned in `msg2` derives to a different node address than the + /// one dialed — an on-path party observed the outbound `msg1` and raced a + /// valid XX `msg2` under its own static, or the address we dialed is no + /// longer the peer we recorded there. Either way this leg never promotes. + StaticMismatch, +} + impl Fmp { /// Decide the teardown choreography for the stale/failed connections the /// shell snapshotted. For each connection, an outbound one with a known @@ -691,6 +799,50 @@ impl Fmp { OutboundDecision::CrossConnectionKeep } } + + /// Classify one initiator-side rekey `msg2` completion: the static-key + /// continuity gate. Pure: reads only `snap`, mutates nothing. + /// + /// A rekey may only replace the session of the peer that already holds the + /// link, so the identity learned from `msg2` must derive to the established + /// peer's node address. Anything else is + /// [`Reject`](RekeyMsg2Decision::Reject) — the shell abandons the rekey and + /// keeps the current session. A match is + /// [`Install`](RekeyMsg2Decision::Install), the unchanged legitimate path. + pub(crate) fn rekey_outbound(&self, snap: &RekeyMsg2Snapshot) -> RekeyMsg2Decision { + if snap.learned_peer != snap.established_peer { + return RekeyMsg2Decision::Reject { + reason: RekeyMsg2Reject::StaticMismatch, + }; + } + RekeyMsg2Decision::Install + } + + /// Classify one initiator-side *initial* `msg2` completion: the dial-time + /// identity gate. Pure: reads only `snap`, mutates nothing. + /// + /// A dial that named a peer may only be completed by that peer, so the + /// identity learned from `msg2` must derive to the dialed node address. + /// Anything else is [`Reject`](DialMsg2Decision::Reject) and the shell + /// promotes nothing. + /// + /// A dial that named nobody has nothing to contradict: an anonymous + /// shared-media beacon asserts no identity, so answering one and being + /// admitted is peering rather than substitution, and the ACL — which runs + /// against the learned identity either way — stays the correct and only gate + /// there. That carve-out cannot be reached as an exemption, because whether + /// `dialed_peer` is `Some` is settled when the leg is built and never by the + /// wire. + pub(crate) fn dial_outbound(&self, snap: &DialMsg2Snapshot) -> DialMsg2Decision { + if let Some(dialed) = snap.dialed_peer + && dialed != snap.learned_peer + { + return DialMsg2Decision::Reject { + reason: DialMsg2Reject::StaticMismatch, + }; + } + DialMsg2Decision::Accept + } } /// Exponential-backoff schedule for the next handshake/rekey msg1 resend: diff --git a/src/proto/fmp/mod.rs b/src/proto/fmp/mod.rs index 59409ef..e54e99d 100644 --- a/src/proto/fmp/mod.rs +++ b/src/proto/fmp/mod.rs @@ -14,7 +14,10 @@ //! //! - `core.rs` — the [`LifecycleView`] read-seam trait, the [`ConnAction`] //! effect vocabulary, the snapshot types, the [`InboundDecision`] establish -//! classification, the pure `poll_*`/`establish_inbound` decisions, the +//! classification, the [`RekeyMsg2Decision`] initiator-side rekey `msg2` +//! static-continuity classification, the [`DialMsg2Decision`] +//! initiator-side initial `msg2` dial-identity classification, the pure +//! `poll_*`/`establish_inbound`/`rekey_outbound`/`dial_outbound` decisions, the //! [`cross_connection_winner`] tie-break helper, and the FMP negotiation //! decision logic (version agreement, profile validation). //! - `limits.rs` — the pure connection-retry backoff math. @@ -38,9 +41,10 @@ pub(crate) mod wire; mod tests; pub(crate) use core::{ - ConnAction, ConnSnapshot, EstablishSnapshot, InboundDecision, InboundReject, LifecycleView, - OutboundDecision, OutboundSnapshot, PeerSnapshot, RekeyCfg, RekeyResendSnapshot, WireOutcome, - decide_fmp_negotiation, + ConnAction, ConnSnapshot, DialMsg2Decision, DialMsg2Reject, DialMsg2Snapshot, + EstablishSnapshot, InboundDecision, InboundReject, LifecycleView, OutboundDecision, + OutboundSnapshot, PeerSnapshot, RekeyCfg, RekeyMsg2Decision, RekeyMsg2Reject, + RekeyMsg2Snapshot, RekeyResendSnapshot, WireOutcome, decide_fmp_negotiation, }; pub use core::{PromotionResult, cross_connection_winner}; pub(crate) use limits::backoff_ms; diff --git a/src/proto/fmp/state.rs b/src/proto/fmp/state.rs index cbf5230..606d022 100644 --- a/src/proto/fmp/state.rs +++ b/src/proto/fmp/state.rs @@ -52,6 +52,23 @@ pub struct ConnectionState { /// Updated after receiving their static key in the handshake. expected_identity: Option, + /// The identity this leg was *dialed at*, fixed at construction and never + /// written again — `Some` only for an identified outbound dial (a + /// configured peer, an mDNS/rendezvous npub, or discovery that carried a + /// `pubkey_hint`), `None` for an anonymous shared-media dial and for every + /// inbound leg. + /// + /// This is deliberately *not* [`expected_identity`](Self::expected_identity). + /// That field carries a different fact — *who answered* — and under XX the + /// answer arrives late (msg2 for the initiator, msg3 for the responder) and + /// must overwrite whatever was there, because on an anonymous leg it is the + /// only way the carrier ever acquires an identity at all. Holding both facts + /// in one field meant the second silently destroyed the first, leaving + /// nothing for the initiator to check the answer against. Keeping the dial + /// intent in its own write-once field is what makes the check possible; see + /// [`Fmp::dial_outbound`](super::core::Fmp::dial_outbound). + dialed_identity: Option, + // === Timing === /// When the connection attempt started (Unix milliseconds). started_at: u64, @@ -112,6 +129,7 @@ impl ConnectionState { link_id, direction: LinkDirection::Outbound, expected_identity: Some(expected_identity), + dialed_identity: Some(expected_identity), started_at: current_time_ms, last_activity: current_time_ms, link_stats: LinkStats::new(), @@ -138,6 +156,7 @@ impl ConnectionState { link_id, direction: LinkDirection::Outbound, expected_identity: None, + dialed_identity: None, started_at: current_time_ms, last_activity: current_time_ms, link_stats: LinkStats::new(), @@ -162,6 +181,7 @@ impl ConnectionState { link_id, direction: LinkDirection::Inbound, expected_identity: None, + dialed_identity: None, started_at: current_time_ms, last_activity: current_time_ms, link_stats: LinkStats::new(), @@ -191,6 +211,7 @@ impl ConnectionState { link_id, direction: LinkDirection::Inbound, expected_identity: None, + dialed_identity: None, started_at: current_time_ms, last_activity: current_time_ms, link_stats: LinkStats::new(), @@ -223,6 +244,16 @@ impl ConnectionState { self.expected_identity.as_ref() } + /// Get the identity this leg was dialed at, if it was an identified dial. + /// + /// Decided locally at construction and never from the wire, so no peer can + /// steer an identified dial into reporting `None` here. There is no setter + /// by design — the whole point of the field is that the handshake cannot + /// move it. + pub fn dialed_identity(&self) -> Option<&PeerIdentity> { + self.dialed_identity.as_ref() + } + /// Check if this is an outbound connection. pub fn is_outbound(&self) -> bool { self.direction == LinkDirection::Outbound diff --git a/src/proto/fmp/tests/core.rs b/src/proto/fmp/tests/core.rs index 395e965..f6ec246 100644 --- a/src/proto/fmp/tests/core.rs +++ b/src/proto/fmp/tests/core.rs @@ -5,8 +5,9 @@ use super::util::{ wire_outcome, }; use crate::proto::fmp::{ - ConnAction, Fmp, InboundDecision, InboundReject, NegotiationPayload, NodeProfile, - OutboundDecision, OutboundSnapshot, RekeyCfg, cross_connection_winner, + ConnAction, DialMsg2Decision, DialMsg2Reject, DialMsg2Snapshot, Fmp, InboundDecision, + InboundReject, NegotiationPayload, NodeProfile, OutboundDecision, OutboundSnapshot, RekeyCfg, + RekeyMsg2Decision, RekeyMsg2Reject, RekeyMsg2Snapshot, cross_connection_winner, }; use crate::testutil::make_node_addr; use crate::transport::LinkId; @@ -580,6 +581,81 @@ fn establish_outbound_cross_connection_loss_keeps() { ); } +// =========================================================================== +// rekey_outbound — initiator rekey msg2 static-continuity classification +// =========================================================================== + +#[test] +fn rekey_outbound_matching_static_installs() { + let fmp = Fmp::new(); + let peer = make_node_addr(1); + let snap = RekeyMsg2Snapshot { + established_peer: peer, + learned_peer: peer, + }; + assert_eq!(fmp.rekey_outbound(&snap), RekeyMsg2Decision::Install); +} + +#[test] +fn rekey_outbound_foreign_static_rejects() { + let fmp = Fmp::new(); + let snap = RekeyMsg2Snapshot { + established_peer: make_node_addr(1), + learned_peer: make_node_addr(2), + }; + assert_eq!( + fmp.rekey_outbound(&snap), + RekeyMsg2Decision::Reject { + reason: RekeyMsg2Reject::StaticMismatch + } + ); +} + +// =========================================================================== +// dial_outbound — initiator initial msg2 dial-identity classification +// =========================================================================== + +#[test] +fn dial_outbound_matching_static_accepts() { + let fmp = Fmp::new(); + let peer = make_node_addr(1); + let snap = DialMsg2Snapshot { + dialed_peer: Some(peer), + learned_peer: peer, + }; + assert_eq!(fmp.dial_outbound(&snap), DialMsg2Decision::Accept); +} + +#[test] +fn dial_outbound_foreign_static_rejects() { + let fmp = Fmp::new(); + let snap = DialMsg2Snapshot { + dialed_peer: Some(make_node_addr(1)), + learned_peer: make_node_addr(2), + }; + assert_eq!( + fmp.dial_outbound(&snap), + DialMsg2Decision::Reject { + reason: DialMsg2Reject::StaticMismatch + } + ); +} + +/// The anonymous carve-out: a dial that named nobody has no intent for the +/// answer to contradict, so any static is accepted and the ACL remains the only +/// gate. Breaking this stops shared-media discovery promoting anything. +#[test] +fn dial_outbound_anonymous_accepts_any_static() { + let fmp = Fmp::new(); + for learned in [make_node_addr(1), make_node_addr(2), make_node_addr(200)] { + let snap = DialMsg2Snapshot { + dialed_peer: None, + learned_peer: learned, + }; + assert_eq!(fmp.dial_outbound(&snap), DialMsg2Decision::Accept); + } +} + // ===== cross_connection_winner tie-break tests ===== #[test]