Switch FMP handshake from Noise IK to XX with version negotiation

Replace the 2-message IK handshake with a 3-message XX handshake for
FMP link establishment. XX requires no prior knowledge of the peer's
static key — both identities are revealed during the handshake
(responder in msg2, initiator in msg3). This is the foundation for
the forklift upgrade that enables rolling protocol upgrades.

Changes:
- Noise XX state machine alongside IK/XK (8 unit tests)
- Protocol negotiation payload codec: format byte, packed version
  min/max, 64-bit feature bitfield, TLV extensions (11 unit tests)
- FMP wire format version 0→1, msg3 header/builder, TCP stream framing
- FMP handshake switched to XX: PeerConnection 3-message flow,
  handle_msg1 simplified (no identity), handle_msg2 sends msg3 and
  promotes initiator, new handle_msg3 promotes responder with
  restart/rekey/cross-connection detection
- Rekey handshake switched to XX with negotiation payload hash chain
  fix (decrypt-and-discard in complete_rekey_msg2/msg3)
- Negotiation payload in msg2/msg3 (FMP version [1,1], features=0)
- Debug logging for handshake promotion paths
- Integration test convergence timeouts adjusted for extra round-trip

Squashed commits:
- Add Noise XX state machine alongside IK/XK
- Add protocol negotiation payload codec
- FMP wire format prep: version 1, msg3 header support
- Switch FMP handshake from Noise IK to XX
- Increase convergence timeouts for XX 3-message handshake
- Fix negotiation hash chain desync in rekey handshake
This commit is contained in:
Johnathan Corgan
2026-04-11 08:16:01 +00:00
parent 9ccaae5044
commit 179689d6f2
20 changed files with 2486 additions and 977 deletions
+122 -20
View File
@@ -5,10 +5,10 @@
use crate::bloom::BloomFilter;
use crate::mmp::{MmpConfig, MmpPeerState};
use crate::utils::index::SessionIndex;
use crate::noise::{HandshakeState as NoiseHandshakeState, NoiseError, NoiseSession};
use crate::transport::{LinkId, LinkStats, TransportAddr, TransportId};
use crate::tree::{ParentDeclaration, TreeCoordinate};
use crate::utils::index::SessionIndex;
use crate::{FipsAddress, NodeAddr, PeerIdentity};
use secp256k1::XOnlyPublicKey;
use std::fmt;
@@ -32,10 +32,7 @@ pub enum ConnectivityState {
impl ConnectivityState {
/// Check if the peer is usable for sending traffic.
pub fn can_send(&self) -> bool {
matches!(
self,
ConnectivityState::Connected | ConnectivityState::Stale
)
matches!(self, ConnectivityState::Connected | ConnectivityState::Stale)
}
/// Check if this is a terminal state requiring cleanup.
@@ -182,6 +179,11 @@ pub struct ActivePeer {
rekey_msg1: Option<Vec<u8>>,
/// In-progress rekey: next resend timestamp (Unix ms).
rekey_msg1_next_resend: u64,
// === Rekey Responder State (XX pattern) ===
/// In-progress rekey responder: Noise handshake state awaiting msg3.
rekey_responder_handshake: Option<NoiseHandshakeState>,
/// In-progress rekey responder: our new session index.
rekey_responder_our_index: Option<SessionIndex>,
}
impl ActivePeer {
@@ -233,6 +235,8 @@ impl ActivePeer {
rekey_our_index: None,
rekey_msg1: None,
rekey_msg1_next_resend: 0,
rekey_responder_handshake: None,
rekey_responder_our_index: None,
}
}
@@ -313,6 +317,8 @@ impl ActivePeer {
rekey_our_index: None,
rekey_msg1: None,
rekey_msg1_next_resend: 0,
rekey_responder_handshake: None,
rekey_responder_our_index: None,
}
}
@@ -747,7 +753,12 @@ impl ActivePeer {
// === Filter Updates ===
/// Update peer's inbound filter.
pub fn update_filter(&mut self, filter: BloomFilter, sequence: u64, current_time_ms: u64) {
pub fn update_filter(
&mut self,
filter: BloomFilter,
sequence: u64,
current_time_ms: u64,
) {
self.inbound_filter = Some(filter);
self.filter_sequence = sequence;
self.filter_received_at = current_time_ms;
@@ -962,11 +973,12 @@ impl ActivePeer {
self.rekey_msg1_next_resend = 0;
self.rekey_in_progress = false;
// Return whichever index needs freeing
self.rekey_our_index.take().or_else(|| {
self.pending_new_session = None;
self.pending_their_index = None;
self.pending_our_index.take()
})
self.rekey_our_index.take()
.or_else(|| {
self.pending_new_session = None;
self.pending_their_index = None;
self.pending_our_index.take()
})
}
// === Rekey Handshake State (Initiator) ===
@@ -991,33 +1003,92 @@ impl ActivePeer {
self.rekey_our_index
}
/// Complete the rekey by processing msg2 (initiator side).
/// Complete the rekey by processing msg2 (initiator side, XX pattern).
///
/// Takes the stored handshake state, reads msg2, and returns the
/// completed NoiseSession. Clears the handshake-related fields but
/// leaves rekey_our_index for set_pending_session to use.
pub fn complete_rekey_msg2(&mut self, msg2_bytes: &[u8]) -> Result<NoiseSession, NoiseError> {
let mut hs = self
.rekey_handshake
/// Takes the stored handshake state, reads XX msg2, generates XX msg3,
/// and returns (msg3_bytes, completed NoiseSession). Clears the
/// handshake-related fields but leaves rekey_our_index for
/// set_pending_session to use.
pub fn complete_rekey_msg2(
&mut self,
msg2_bytes: &[u8],
) -> Result<(Vec<u8>, NoiseSession), NoiseError> {
let mut hs = self.rekey_handshake
.take()
.ok_or_else(|| NoiseError::WrongState {
expected: "rekey handshake in progress".to_string(),
got: "no handshake state".to_string(),
})?;
hs.read_message_2(msg2_bytes)?;
// Split msg2 into base XX part and any extra (negotiation payload)
let base_size = crate::noise::XX_HANDSHAKE_MSG2_SIZE;
let (base_msg2, extra) = if msg2_bytes.len() > base_size {
(&msg2_bytes[..base_size], Some(&msg2_bytes[base_size..]))
} else {
(msg2_bytes, None)
};
hs.read_xx_message_2(base_msg2)?;
// Must decrypt negotiation payload (if present) to keep hash chain
// in sync, even though rekey doesn't use the negotiation result.
if let Some(encrypted_neg) = extra {
let _ = hs.decrypt_payload(encrypted_neg)?;
}
let msg3 = hs.write_xx_message_3()?;
let session = hs.into_session()?;
// Clear msg1 resend state
self.rekey_msg1 = None;
self.rekey_msg1_next_resend = 0;
Ok((msg3, session))
}
/// Complete the rekey by processing msg3 (responder side, XX pattern).
///
/// Takes the stored responder handshake state, reads XX msg3, and returns
/// the completed NoiseSession.
pub fn complete_rekey_msg3(
&mut self,
msg3_bytes: &[u8],
) -> Result<NoiseSession, NoiseError> {
let mut hs = self.rekey_responder_handshake
.take()
.ok_or_else(|| NoiseError::WrongState {
expected: "rekey responder handshake awaiting msg3".to_string(),
got: "no responder handshake state".to_string(),
})?;
// Split msg3 into base XX part and any extra (negotiation payload)
let base_size = crate::noise::XX_HANDSHAKE_MSG3_SIZE;
let (base_msg3, extra) = if msg3_bytes.len() > base_size {
(&msg3_bytes[..base_size], Some(&msg3_bytes[base_size..]))
} else {
(msg3_bytes, None)
};
hs.read_xx_message_3(base_msg3)?;
// Must decrypt negotiation payload (if present) to keep hash chain
// in sync, even though rekey doesn't use the negotiation result.
if let Some(encrypted_neg) = extra {
let _ = hs.decrypt_payload(encrypted_neg)?;
}
let session = hs.into_session()?;
self.rekey_responder_our_index = None;
Ok(session)
}
/// Check if msg1 needs resending.
pub fn needs_msg1_resend(&self, now_ms: u64) -> bool {
self.rekey_in_progress && self.rekey_msg1.is_some() && now_ms >= self.rekey_msg1_next_resend
self.rekey_in_progress
&& self.rekey_msg1.is_some()
&& now_ms >= self.rekey_msg1_next_resend
}
/// Get msg1 bytes for resend (without consuming).
@@ -1029,6 +1100,37 @@ impl ActivePeer {
pub fn set_msg1_next_resend(&mut self, next_ms: u64) {
self.rekey_msg1_next_resend = next_ms;
}
// === Rekey Responder State (XX pattern) ===
/// Whether this peer has a rekey responder handshake awaiting msg3.
pub fn has_rekey_responder_handshake(&self) -> bool {
self.rekey_responder_handshake.is_some()
}
/// Get the rekey responder our_index.
pub fn rekey_responder_our_index(&self) -> Option<SessionIndex> {
self.rekey_responder_our_index
}
/// Store rekey responder handshake state after sending msg2.
///
/// Called when processing a rekey msg1 from the peer. The handshake
/// state is held here until msg3 arrives to complete the rekey.
pub fn set_rekey_responder_state(
&mut self,
handshake: NoiseHandshakeState,
our_index: SessionIndex,
) {
self.rekey_responder_handshake = Some(handshake);
self.rekey_responder_our_index = Some(our_index);
}
/// Clear rekey responder state (on failure or abandonment).
pub fn clear_rekey_responder(&mut self) {
self.rekey_responder_handshake = None;
self.rekey_responder_our_index = None;
}
}
#[cfg(test)]