mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-22 07:48:26 +00:00
Verify the peer static on both FMP handshake paths, not just at rekey
Under Noise XX the peer static key is learned during the handshake rather than pinned in advance, and neither of the two paths that learn one was checking it against what we already knew. That let an attacker who could observe and inject on path substitute their own identity, on both a fresh dial and an established link. On a fresh dial we recorded who we meant to reach and then overwrote it with whoever answered, without ever comparing the two. Promotion, the ACL check and the peer registry all then ran on the answering identity, so an attacker who raced the real peer to msg2 became the peer and the intended node was never reached. On an established link, a rekey msg2 was matched to its peer only by the session index we had put in the cleartext msg1 header, so anyone who saw that header could answer with their own static and take the link over at the cutover. Both are now compared before anything is committed. The dial-time expectation moves into its own field with no setter, so the handshake cannot overwrite it the way it used to; the identity learned from msg2 keeps landing where it always did. Anonymous dials still promote whoever answers, which is what shared-media discovery means, and that branch is chosen locally when we dial rather than from anything on the wire, so it cannot be reached as an exemption. Both comparisons are decisions made in the synchronous core alongside the existing classifiers. The gates sit ahead of every mutation, not merely ahead of the session install, so a forged msg2 no longer poisons the recorded peer epoch and never earns a msg3. A rejected dial is rescheduled from the dial-time expectation rather than the answering identity, so refusing an impostor does not silently retire a configured peer from the dial schedule. Only this branch was exposed. The released lines use Noise IK, where the initiator pins the responder static before it dials and a wrong key fails the AEAD outright. No wire change: both gates compare a value we already learn against one we already hold, and legitimate handshakes behave exactly as before. Six tests cover it, driving the real cadence and dial paths with a third node answering the intercepted message. Each was checked to fail when the decision is forced to accept. Local CI 37/37.
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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<u8>, 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<u8>, 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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<NodeAddr>,
|
||||
/// 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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -52,6 +52,23 @@ pub struct ConnectionState {
|
||||
/// Updated after receiving their static key in the handshake.
|
||||
expected_identity: Option<PeerIdentity>,
|
||||
|
||||
/// 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<PeerIdentity>,
|
||||
|
||||
// === 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
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user