mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-10 00:26:59 +00:00
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:
+11
-9
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user