Bind the FSP session address to the authenticated peer key, on both the initial and rekey paths

The responder recorded a session under the source address carried in the
datagram without ever checking that address against the static key the
Noise handshake had just authenticated. A peer could therefore complete a
genuine handshake while claiming another node's address, and the identity
cache, the session map and the address the IPv6 shim reconstructs on
delivery would all attribute its traffic to the node it named.

Derive the address from the authenticated key at the point the key first
becomes available in msg3, and reject the handshake when it does not match
the claimed source. The entry has already been removed by that point, so
returning drops the half-open session and neither the identity nor the
session is recorded.

The rekey responder path needed its own check rather than inheriting that
one. It returns before the initial path's code is reached, and it never
read the peer's static key at all, so a rekey could complete under an
established session with a different key than the one that opened it. It
now requires the key to be unchanged, which is the stronger comparison
available there, and abandons the rekey while keeping the existing session
intact on mismatch. Tearing the session down instead would have handed an
attacker a way to kill established sessions.

Both comparisons are on x-only keys. A stored peer key may carry a
synthesized even parity because npubs encode no parity, while the
handshake learns the true point, so comparing full keys would reject
roughly half of legitimate peers on every rekey.

Both rejections are counted separately in the session reject statistics.
The tests drive real Noise handshakes through the datagram entry point and
construct the mismatch rather than asserting the comparison exists.
This commit is contained in:
Johnathan Corgan
2026-08-09 06:48:01 +00:00
parent f40cc16bdb
commit 0155345984
4 changed files with 321 additions and 2 deletions
+43
View File
@@ -32,6 +32,14 @@ pub struct SessionStats {
/// before Established; SessionAck outside Initiating; SessionMsg3
/// outside AwaitingMsg3).
pub bad_state: u64,
/// Inbound XK msg3 whose initiator static key does not derive the
/// source address the datagram claimed. The half-open session is
/// dropped and no identity is registered.
pub addr_mismatch: u64,
/// Inbound rekey XK msg3 whose initiator static key differs from
/// the key the session was established with. The rekey is
/// abandoned and the existing session is left intact.
pub rekey_key_mismatch: u64,
}
impl SessionStats {
@@ -39,6 +47,8 @@ impl SessionStats {
SessionStatsSnapshot {
unknown_session: self.unknown_session,
bad_state: self.bad_state,
addr_mismatch: self.addr_mismatch,
rekey_key_mismatch: self.rekey_key_mismatch,
}
}
@@ -46,6 +56,8 @@ impl SessionStats {
match reason {
SessionReject::UnknownSession => self.unknown_session += 1,
SessionReject::BadState => self.bad_state += 1,
SessionReject::AddrMismatch => self.addr_mismatch += 1,
SessionReject::RekeyKeyMismatch => self.rekey_key_mismatch += 1,
}
}
}
@@ -303,6 +315,8 @@ pub struct BloomStatsSnapshot {
pub struct SessionStatsSnapshot {
pub unknown_session: u64,
pub bad_state: u64,
pub addr_mismatch: u64,
pub rekey_key_mismatch: u64,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -359,6 +373,35 @@ mod tests {
assert_eq!(stats.unknown_session, 0);
}
#[test]
fn session_stats_record_reject_addr_mismatch() {
let mut stats = SessionStats::default();
stats.record_reject(SessionReject::AddrMismatch);
stats.record_reject(SessionReject::AddrMismatch);
assert_eq!(stats.addr_mismatch, 2);
assert_eq!(stats.rekey_key_mismatch, 0);
assert_eq!(stats.unknown_session, 0);
}
#[test]
fn session_stats_record_reject_rekey_key_mismatch() {
let mut stats = SessionStats::default();
stats.record_reject(SessionReject::RekeyKeyMismatch);
assert_eq!(stats.rekey_key_mismatch, 1);
assert_eq!(stats.addr_mismatch, 0);
}
#[test]
fn session_stats_snapshot_carries_identity_binding_counters() {
let mut stats = SessionStats::default();
stats.record_reject(SessionReject::AddrMismatch);
stats.record_reject(SessionReject::RekeyKeyMismatch);
stats.record_reject(SessionReject::RekeyKeyMismatch);
let snap = stats.snapshot();
assert_eq!(snap.addr_mismatch, 1);
assert_eq!(snap.rekey_key_mismatch, 2);
}
#[test]
fn node_stats_record_reject_dispatches_to_session() {
let mut stats = NodeStats::new();