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
+11 -9
View File
@@ -24,25 +24,27 @@ mod discovery;
mod error;
mod filter;
mod link;
mod negotiation;
mod session;
mod tree;
// Re-export all public types at protocol:: level
pub use discovery::{LookupRequest, LookupResponse};
pub use error::ProtocolError;
pub use filter::FilterAnnounce;
pub use link::{
Disconnect, DisconnectReason, HandshakeMessageType, LinkMessageType,
SESSION_DATAGRAM_HEADER_SIZE, SessionDatagram,
Disconnect, DisconnectReason, HandshakeMessageType, LinkMessageType, SessionDatagram,
SESSION_DATAGRAM_HEADER_SIZE,
};
pub use tree::TreeAnnounce;
pub use filter::FilterAnnounce;
pub use discovery::{LookupRequest, LookupResponse};
pub use negotiation::{NegotiationPayload, TlvEntry, NEGOTIATION_HEADER_SIZE};
pub use session::{
COORDS_REQUIRED_SIZE, CoordsRequired, FspFlags, FspInnerFlags, MTU_EXCEEDED_SIZE, MtuExceeded,
PATH_MTU_NOTIFICATION_SIZE, PathBroken, PathMtuNotification, SESSION_RECEIVER_REPORT_SIZE,
SESSION_SENDER_REPORT_SIZE, SessionAck, SessionFlags, SessionMessageType, SessionMsg3,
SessionReceiverReport, SessionSenderReport, SessionSetup,
CoordsRequired, FspFlags, FspInnerFlags, MtuExceeded, PathBroken, PathMtuNotification,
SessionAck, SessionFlags, SessionMessageType, SessionMsg3, SessionReceiverReport,
SessionSenderReport, SessionSetup, COORDS_REQUIRED_SIZE, MTU_EXCEEDED_SIZE,
PATH_MTU_NOTIFICATION_SIZE, SESSION_RECEIVER_REPORT_SIZE, SESSION_SENDER_REPORT_SIZE,
};
pub(crate) use session::{coords_wire_size, decode_optional_coords, encode_coords};
pub use tree::TreeAnnounce;
/// Protocol version for message compatibility.
pub const PROTOCOL_VERSION: u8 = 1;
+289
View File
@@ -0,0 +1,289 @@
//! Protocol negotiation payload codec.
//!
//! Encodes/decodes the negotiation payload embedded in XX handshake
//! messages (msg2/msg3). Each layer (FMP, FSP) uses the same wire
//! format with layer-specific version ranges and feature catalogs.
//!
//! ## Wire Format
//!
//! ```text
//! Byte 0: format (must be 0)
//! Byte 1: [version_min:4 high][version_max:4 low]
//! Bytes 2-9: feature bitfield (64 bits, LE)
//! Bytes 10+: TLV entries, each:
//! [field_num:2 LE][length:2 LE][value:N]
//! ```
use super::ProtocolError;
/// Size of the fixed negotiation header (format + version + features).
pub const NEGOTIATION_HEADER_SIZE: usize = 10;
/// Format byte value for the initial negotiation format.
const NEGOTIATION_FORMAT_V0: u8 = 0;
/// A TLV entry in the negotiation payload.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TlvEntry {
/// Field number identifying this TLV.
pub field_num: u16,
/// Raw value bytes.
pub value: Vec<u8>,
}
/// Protocol negotiation payload.
///
/// Carried in XX msg2/msg3 encrypted payloads. Shared codec for both
/// FMP and FSP layers, with layer-specific version ranges and feature
/// bit assignments.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NegotiationPayload {
/// Format byte (must be 0).
pub format: u8,
/// Minimum protocol version supported (4-bit, 0-15).
pub version_min: u8,
/// Maximum protocol version supported (4-bit, 0-15).
pub version_max: u8,
/// Feature bitfield (64 bits, LE).
pub features: u64,
/// Optional TLV extension entries.
pub tlv_entries: Vec<TlvEntry>,
}
impl NegotiationPayload {
/// Create a new negotiation payload.
pub fn new(version_min: u8, version_max: u8, features: u64) -> Self {
Self {
format: NEGOTIATION_FORMAT_V0,
version_min,
version_max,
features,
tlv_entries: Vec::new(),
}
}
/// Add a TLV entry.
pub fn with_tlv(mut self, field_num: u16, value: Vec<u8>) -> Self {
self.tlv_entries.push(TlvEntry { field_num, value });
self
}
/// Encode to wire format.
pub fn encode(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(NEGOTIATION_HEADER_SIZE);
buf.push(self.format);
buf.push((self.version_min << 4) | (self.version_max & 0x0F));
buf.extend_from_slice(&self.features.to_le_bytes());
for entry in &self.tlv_entries {
buf.extend_from_slice(&entry.field_num.to_le_bytes());
let len = entry.value.len() as u16;
buf.extend_from_slice(&len.to_le_bytes());
buf.extend_from_slice(&entry.value);
}
buf
}
/// Decode from wire format.
pub fn decode(data: &[u8]) -> Result<Self, ProtocolError> {
if data.len() < NEGOTIATION_HEADER_SIZE {
return Err(ProtocolError::MessageTooShort {
expected: NEGOTIATION_HEADER_SIZE,
got: data.len(),
});
}
let format = data[0];
if format != NEGOTIATION_FORMAT_V0 {
return Err(ProtocolError::Malformed(format!(
"unknown negotiation format: {format}"
)));
}
let version_min = data[1] >> 4;
let version_max = data[1] & 0x0F;
if version_min > version_max {
return Err(ProtocolError::Malformed(format!(
"version_min ({version_min}) > version_max ({version_max})"
)));
}
let features = u64::from_le_bytes(data[2..10].try_into().unwrap());
let mut tlv_entries = Vec::new();
let mut offset = NEGOTIATION_HEADER_SIZE;
while offset < data.len() {
// Need at least 4 bytes for field_num + length
if offset + 4 > data.len() {
return Err(ProtocolError::Malformed(
"truncated TLV header".to_string(),
));
}
let field_num = u16::from_le_bytes(data[offset..offset + 2].try_into().unwrap());
let length =
u16::from_le_bytes(data[offset + 2..offset + 4].try_into().unwrap()) as usize;
offset += 4;
if offset + length > data.len() {
return Err(ProtocolError::Malformed(format!(
"TLV field {field_num}: declared length {length} exceeds remaining data {}",
data.len() - offset
)));
}
let value = data[offset..offset + length].to_vec();
offset += length;
tlv_entries.push(TlvEntry { field_num, value });
}
Ok(Self {
format,
version_min,
version_max,
features,
tlv_entries,
})
}
/// Agree on a protocol version with a peer's negotiation payload.
///
/// Returns `min(our_max, their_max)`, rejecting if the agreed version
/// is below either side's minimum.
pub fn agree_version(&self, other: &Self) -> Result<u8, ProtocolError> {
let agreed = self.version_max.min(other.version_max);
if agreed < self.version_min || agreed < other.version_min {
return Err(ProtocolError::Malformed(format!(
"version mismatch: ours [{},{}] theirs [{},{}]",
self.version_min, self.version_max, other.version_min, other.version_max
)));
}
Ok(agreed)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encode_decode_roundtrip() {
let payload = NegotiationPayload::new(1, 3, 0x00000000_0000002A);
let encoded = payload.encode();
assert_eq!(encoded.len(), NEGOTIATION_HEADER_SIZE);
let decoded = NegotiationPayload::decode(&encoded).unwrap();
assert_eq!(decoded, payload);
}
#[test]
fn test_encode_decode_with_tlv() {
let payload = NegotiationPayload::new(0, 1, 0)
.with_tlv(1, vec![0xAA, 0xBB])
.with_tlv(256, vec![0x01, 0x02, 0x03, 0x04]);
let encoded = payload.encode();
// 10 header + (2+2+2) + (2+2+4) = 10 + 6 + 8 = 24
assert_eq!(encoded.len(), 24);
let decoded = NegotiationPayload::decode(&encoded).unwrap();
assert_eq!(decoded, payload);
assert_eq!(decoded.tlv_entries.len(), 2);
assert_eq!(decoded.tlv_entries[0].field_num, 1);
assert_eq!(decoded.tlv_entries[0].value, vec![0xAA, 0xBB]);
assert_eq!(decoded.tlv_entries[1].field_num, 256);
assert_eq!(decoded.tlv_entries[1].value, vec![0x01, 0x02, 0x03, 0x04]);
}
#[test]
fn test_version_agreement_basic() {
let ours = NegotiationPayload::new(1, 3, 0);
let theirs = NegotiationPayload::new(1, 2, 0);
assert_eq!(ours.agree_version(&theirs).unwrap(), 2);
}
#[test]
fn test_version_agreement_mismatch() {
let ours = NegotiationPayload::new(3, 5, 0);
let theirs = NegotiationPayload::new(1, 2, 0);
assert!(ours.agree_version(&theirs).is_err());
}
#[test]
fn test_version_agreement_asymmetric() {
// Ours: [2,5], theirs: [1,4] → agreed = min(5,4) = 4, 4 >= 2 and 4 >= 1 → ok
let ours = NegotiationPayload::new(2, 5, 0);
let theirs = NegotiationPayload::new(1, 4, 0);
assert_eq!(ours.agree_version(&theirs).unwrap(), 4);
// Ours: [1,4], theirs: [2,5] → agreed = min(4,5) = 4, 4 >= 1 and 4 >= 2 → ok
assert_eq!(theirs.agree_version(&ours).unwrap(), 4);
}
#[test]
fn test_unknown_format_rejected() {
let mut data = NegotiationPayload::new(0, 0, 0).encode();
data[0] = 1; // Set format to 1
assert!(NegotiationPayload::decode(&data).is_err());
}
#[test]
fn test_invalid_version_range() {
let mut data = NegotiationPayload::new(0, 0, 0).encode();
// Set version_min=5, version_max=3 (invalid: min > max)
data[1] = (5 << 4) | 3;
assert!(NegotiationPayload::decode(&data).is_err());
}
#[test]
fn test_unknown_tlv_forward_compat() {
// Unknown field_nums should be preserved through encode/decode
let payload = NegotiationPayload::new(0, 1, 0)
.with_tlv(9999, vec![0xFF, 0xFE, 0xFD]);
let encoded = payload.encode();
let decoded = NegotiationPayload::decode(&encoded).unwrap();
assert_eq!(decoded.tlv_entries.len(), 1);
assert_eq!(decoded.tlv_entries[0].field_num, 9999);
assert_eq!(decoded.tlv_entries[0].value, vec![0xFF, 0xFE, 0xFD]);
}
#[test]
fn test_empty_payload() {
let payload = NegotiationPayload::new(0, 0, 0);
let encoded = payload.encode();
assert_eq!(encoded.len(), NEGOTIATION_HEADER_SIZE);
let decoded = NegotiationPayload::decode(&encoded).unwrap();
assert_eq!(decoded.version_min, 0);
assert_eq!(decoded.version_max, 0);
assert_eq!(decoded.features, 0);
assert!(decoded.tlv_entries.is_empty());
}
#[test]
fn test_truncated_payload() {
// Less than header size
assert!(NegotiationPayload::decode(&[0u8; 5]).is_err());
assert!(NegotiationPayload::decode(&[]).is_err());
}
#[test]
fn test_truncated_tlv() {
let payload = NegotiationPayload::new(0, 1, 0)
.with_tlv(1, vec![0xAA, 0xBB, 0xCC]);
let mut encoded = payload.encode();
// Truncate the TLV value (remove last byte)
encoded.pop();
assert!(NegotiationPayload::decode(&encoded).is_err());
// Truncate to just partial TLV header (only 2 of 4 header bytes)
let mut partial = NegotiationPayload::new(0, 1, 0).encode();
partial.extend_from_slice(&[0x01, 0x00]); // Only field_num, no length
assert!(NegotiationPayload::decode(&partial).is_err());
}
}