From 1c41f7393161a5e7786d89e9d05ce612c5b15af4 Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Fri, 10 Jul 2026 16:42:17 +0000 Subject: [PATCH 1/2] Relocate FMP link wire codec into proto/fmp Move the FMP mesh-layer wire format (common prefix, encrypted/msg1/msg2 headers, and the build_*/inner-header codec fns) out of node/wire.rs and into proto/fmp/wire.rs, so the whole FMP wire surface lives with its subsystem, matching the proto/fsp/wire.rs layout. The wire module becomes pub(crate) mod wire; callers reach it via crate::proto::fmp::wire. Behavior-neutral: pure relocation plus import-path rewrites across the node/peer consumers; no logic change. Full lib suite green at baseline. --- src/node/decrypt_worker.rs | 10 +- src/node/encrypt_worker.rs | 4 +- src/node/handlers/encrypted.rs | 4 +- src/node/handlers/handshake.rs | 2 +- src/node/handlers/rekey.rs | 2 +- src/node/handlers/rx_loop.rs | 4 +- src/node/handlers/session.rs | 8 +- src/node/lifecycle.rs | 2 +- src/node/mod.rs | 9 +- src/node/tests/acl.rs | 2 +- src/node/tests/bootstrap.rs | 2 +- src/node/tests/establish_chartests.rs | 4 +- src/node/tests/handshake.rs | 12 +- src/node/tests/spanning_tree.rs | 4 +- src/node/tests/unit.rs | 2 +- src/node/wire.rs | 626 ------------------------- src/peer/active.rs | 2 +- src/proto/fmp/mod.rs | 6 +- src/proto/fmp/wire.rs | 634 +++++++++++++++++++++++++- 19 files changed, 674 insertions(+), 665 deletions(-) delete mode 100644 src/node/wire.rs diff --git a/src/node/decrypt_worker.rs b/src/node/decrypt_worker.rs index c02f5e6..10fb7bb 100644 --- a/src/node/decrypt_worker.rs +++ b/src/node/decrypt_worker.rs @@ -561,7 +561,7 @@ mod tests { let open_cipher = LessSafeKey::new(unbound2); let counter: u64 = 7; - const HDR: usize = crate::node::wire::ESTABLISHED_HEADER_SIZE; + const HDR: usize = crate::proto::fmp::wire::ESTABLISHED_HEADER_SIZE; // Build a wire packet `[16-byte header][4-byte inner ts][1 byte link msg]` // with capacity for the trailing AEAD tag. Header bytes // double as AAD and as the on-wire prefix. @@ -569,7 +569,7 @@ mod tests { // Header: fill the flags byte (the second byte) with both // FLAG_CE and FLAG_SP set; the rest is uninterpreted by the // worker (it just AADs the whole 16 bytes). - let flags_byte = crate::node::wire::FLAG_CE | crate::node::wire::FLAG_SP; + let flags_byte = crate::proto::fmp::wire::FLAG_CE | crate::proto::fmp::wire::FLAG_SP; let mut header = [0u8; HDR]; header[1] = flags_byte; wire.extend_from_slice(&header); @@ -626,11 +626,11 @@ mod tests { "fmp_flags must round-trip from DecryptJob to DecryptFallback" ); assert!( - fallback.fmp_flags & crate::node::wire::FLAG_CE != 0, + fallback.fmp_flags & crate::proto::fmp::wire::FLAG_CE != 0, "FLAG_CE bit lost on worker path" ); assert!( - fallback.fmp_flags & crate::node::wire::FLAG_SP != 0, + fallback.fmp_flags & crate::proto::fmp::wire::FLAG_SP != 0, "FLAG_SP bit lost on worker path" ); } @@ -724,7 +724,7 @@ mod tests { let open_cipher = LessSafeKey::new(unbound); let counter: u64 = 11; - const HDR: usize = crate::node::wire::ESTABLISHED_HEADER_SIZE; + const HDR: usize = crate::proto::fmp::wire::ESTABLISHED_HEADER_SIZE; let header = [0u8; HDR]; let mut wire = Vec::with_capacity(HDR + 4 + 1 + 16); wire.extend_from_slice(&header); diff --git a/src/node/encrypt_worker.rs b/src/node/encrypt_worker.rs index dc0fe86..28b232b 100644 --- a/src/node/encrypt_worker.rs +++ b/src/node/encrypt_worker.rs @@ -50,7 +50,7 @@ // warnings rather than gate every function individually. #![cfg_attr(not(unix), allow(dead_code))] -use crate::node::wire::ESTABLISHED_HEADER_SIZE; +use crate::proto::fmp::wire::ESTABLISHED_HEADER_SIZE; use crate::proto::fsp::wire::FSP_HEADER_SIZE; use crate::transport::udp::socket::AsyncUdpSocket; #[cfg(not(target_os = "macos"))] @@ -1904,8 +1904,8 @@ mod unix_tests { #[test] fn pipelined_send_wire_layout_roundtrips_canonical_decoders() { use crate::NodeAddr; - use crate::node::wire::{EncryptedHeader, FLAG_KEY_EPOCH, build_established_header}; use crate::noise::TAG_SIZE; + use crate::proto::fmp::wire::{EncryptedHeader, FLAG_KEY_EPOCH, build_established_header}; use crate::proto::fsp::wire::build_fsp_header; use crate::proto::link::{ LinkMessageType, SESSION_DATAGRAM_HEADER_SIZE, SessionDatagramRef, diff --git a/src/node/handlers/encrypted.rs b/src/node/handlers/encrypted.rs index 7401f9b..4a5e544 100644 --- a/src/node/handlers/encrypted.rs +++ b/src/node/handlers/encrypted.rs @@ -1,8 +1,10 @@ //! Encrypted frame handling (hot path). use crate::node::Node; -use crate::node::wire::{EncryptedHeader, FLAG_CE, FLAG_KEY_EPOCH, FLAG_SP, strip_inner_header}; use crate::noise::NoiseError; +use crate::proto::fmp::wire::{ + EncryptedHeader, FLAG_CE, FLAG_KEY_EPOCH, FLAG_SP, strip_inner_header, +}; use crate::transport::ReceivedPacket; use tracing::{debug, trace, warn}; diff --git a/src/node/handlers/handshake.rs b/src/node/handlers/handshake.rs index 8f231b5..189dca1 100644 --- a/src/node/handlers/handshake.rs +++ b/src/node/handlers/handshake.rs @@ -4,9 +4,9 @@ use crate::NodeAddr; use crate::PeerIdentity; use crate::node::acl::PeerAclContext; use crate::node::reject::{HandshakeReject, RejectReason}; -use crate::node::wire::{Msg1Header, Msg2Header, build_msg2}; use crate::node::{Node, NodeError}; use crate::peer::{ActivePeer, PeerConnection, PromotionResult}; +use crate::proto::fmp::wire::{Msg1Header, Msg2Header, build_msg2}; use crate::proto::fmp::{ ConnAction, EstablishSnapshot, EstablishView, InboundDecision, InboundReject, OutboundDecision, OutboundSnapshot, WireOutcome, cross_connection_winner, diff --git a/src/node/handlers/rekey.rs b/src/node/handlers/rekey.rs index b3cb413..b8b309a 100644 --- a/src/node/handlers/rekey.rs +++ b/src/node/handlers/rekey.rs @@ -7,8 +7,8 @@ use crate::NodeAddr; use crate::node::Node; -use crate::node::wire::build_msg1; use crate::noise::HandshakeState; +use crate::proto::fmp::wire::build_msg1; use crate::proto::fmp::{ConnAction, LifecycleView, PeerSnapshot, RekeyCfg, RekeyResendSnapshot}; use crate::proto::fsp::{ FspAction, RekeyMsg3ResendSnapshot, SessionSetup, SessionSnapshot, cutover_timer_elapsed, diff --git a/src/node/handlers/rx_loop.rs b/src/node/handlers/rx_loop.rs index 756aa9a..0eca9d8 100644 --- a/src/node/handlers/rx_loop.rs +++ b/src/node/handlers/rx_loop.rs @@ -1,10 +1,10 @@ //! RX event loop and packet dispatch. use crate::control::{ControlSocket, commands}; -use crate::node::wire::{ +use crate::node::{Node, NodeError}; +use crate::proto::fmp::wire::{ COMMON_PREFIX_SIZE, CommonPrefix, FMP_VERSION, PHASE_ESTABLISHED, PHASE_MSG1, PHASE_MSG2, }; -use crate::node::{Node, NodeError}; use crate::transport::ReceivedPacket; use std::time::Duration; use tracing::{debug, info, warn}; diff --git a/src/node/handlers/session.rs b/src/node/handlers/session.rs index 3abc8e3..de447f6 100644 --- a/src/node/handlers/session.rs +++ b/src/node/handlers/session.rs @@ -9,14 +9,14 @@ use crate::NodeAddr; use crate::node::handlers::mmp::format_throughput; use crate::node::reject::{RejectReason, SessionReject}; use crate::node::session::{EndToEndState, EpochSlot, SessionEntry}; -#[cfg(unix)] -use crate::node::wire::{ - ESTABLISHED_HEADER_SIZE, FLAG_KEY_EPOCH, FLAG_SP, build_established_header, -}; use crate::node::{Node, NodeError}; use crate::noise::{ HandshakeState, XK_HANDSHAKE_MSG1_SIZE, XK_HANDSHAKE_MSG2_SIZE, XK_HANDSHAKE_MSG3_SIZE, }; +#[cfg(unix)] +use crate::proto::fmp::wire::{ + ESTABLISHED_HEADER_SIZE, FLAG_KEY_EPOCH, FLAG_SP, build_established_header, +}; use crate::proto::fsp::wire::{ FSP_COMMON_PREFIX_SIZE, FSP_FLAG_CP, FSP_FLAG_K, FSP_HEADER_SIZE, FSP_PHASE_ESTABLISHED, FSP_PHASE_MSG1, FSP_PHASE_MSG2, FSP_PHASE_MSG3, FSP_PORT_HEADER_SIZE, FSP_PORT_IPV6_SHIM, diff --git a/src/node/lifecycle.rs b/src/node/lifecycle.rs index 200ef4d..9547396 100644 --- a/src/node/lifecycle.rs +++ b/src/node/lifecycle.rs @@ -3,10 +3,10 @@ use super::{Node, NodeError, NodeState}; use crate::config::{ConnectPolicy, PeerAddress, PeerConfig}; use crate::node::acl::PeerAclContext; -use crate::node::wire::build_msg1; use crate::nostr::{BootstrapEvent, NostrRendezvous}; use crate::nostr::{BootstrapHandoffResult, EstablishedTraversal}; use crate::peer::PeerConnection; +use crate::proto::fmp::wire::build_msg1; use crate::proto::fmp::{Disconnect, DisconnectReason}; use crate::transport::{Link, LinkDirection, LinkId, TransportAddr, TransportId, packet_channel}; use crate::upper::tun::{TunDevice, TunState, run_tun_reader, shutdown_tun_interface}; diff --git a/src/node/mod.rs b/src/node/mod.rs index 2b699a8..86ea375 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -24,7 +24,6 @@ pub(crate) mod stats_history; #[cfg(test)] mod tests; mod tree; -pub(crate) mod wire; use self::rate_limit::HandshakeRateLimiter; use self::reloadable::Reloadable; @@ -35,15 +34,15 @@ use self::reloadable::Reloadable; /// dual-initiation in symmetric-start meshes; the configured /// `node.rekey.after_secs` remains the nominal interval (mean preserved). pub(crate) const REKEY_JITTER_SECS: i64 = 15; -use self::wire::{ - ESTABLISHED_HEADER_SIZE, FLAG_CE, FLAG_KEY_EPOCH, FLAG_SP, build_encrypted, - build_established_header, prepend_inner_header, -}; use crate::cache::CoordCache; use crate::node::session::SessionEntry; use crate::peer::{ActivePeer, PeerConnection}; use crate::proto::bloom::{BloomFilter, BloomState}; use crate::proto::fmp::Fmp; +use crate::proto::fmp::wire::{ + ESTABLISHED_HEADER_SIZE, FLAG_CE, FLAG_KEY_EPOCH, FLAG_SP, build_encrypted, + build_established_header, prepend_inner_header, +}; use crate::proto::fsp::Fsp; use crate::proto::lookup::{Lookup, LookupBackoff, LookupForwardRateLimiter}; use crate::proto::mmp::Mmp; diff --git a/src/node/tests/acl.rs b/src/node/tests/acl.rs index 5cf20cc..5670d0f 100644 --- a/src/node/tests/acl.rs +++ b/src/node/tests/acl.rs @@ -2,7 +2,7 @@ use super::*; use crate::ReceivedPacket; use crate::node::acl::PeerAclReloader; use crate::node::reloadable::HostMapReloadable; -use crate::node::wire::{build_msg1, build_msg2}; +use crate::proto::fmp::wire::{build_msg1, build_msg2}; use crate::upper::hosts::HostMap; use crate::utils::index::SessionIndex; use std::path::PathBuf; diff --git a/src/node/tests/bootstrap.rs b/src/node/tests/bootstrap.rs index c78665c..38ab60b 100644 --- a/src/node/tests/bootstrap.rs +++ b/src/node/tests/bootstrap.rs @@ -3,7 +3,7 @@ use super::*; use crate::EstablishedTraversal; use crate::config::{TransportInstances, UdpConfig}; -use crate::node::wire::{PHASE_MSG1, PHASE_MSG2}; +use crate::proto::fmp::wire::{PHASE_MSG1, PHASE_MSG2}; use crate::transport::udp::UdpTransport; use crate::utils::index::IndexAllocator; use std::collections::HashMap; diff --git a/src/node/tests/establish_chartests.rs b/src/node/tests/establish_chartests.rs index 21cba6b..fa8f149 100644 --- a/src/node/tests/establish_chartests.rs +++ b/src/node/tests/establish_chartests.rs @@ -46,7 +46,7 @@ fn craft_msg1_wire( sender_index: SessionIndex, ts: u64, ) -> Vec { - use crate::node::wire::build_msg1; + use crate::proto::fmp::wire::build_msg1; let peer_b_identity = PeerIdentity::from_pubkey_full(node.identity().pubkey_full()); let link_id = LinkId::new(0x0BAD_C0DE); let mut conn = PeerConnection::outbound(link_id, peer_b_identity, ts); @@ -453,7 +453,7 @@ async fn chartest_msg1_at_cap_with_pending_outbound_bypasses_early_gate() { /// index it assigned while promoting the peer's msg1. #[tokio::test] async fn chartest_cross_connection_tiebreak_winner_and_loser() { - use crate::node::wire::build_msg1; + use crate::proto::fmp::wire::build_msg1; let mut node_a = make_node(); let mut node_b = make_node(); diff --git a/src/node/tests/handshake.rs b/src/node/tests/handshake.rs index 0e49fe2..c7f0197 100644 --- a/src/node/tests/handshake.rs +++ b/src/node/tests/handshake.rs @@ -5,7 +5,7 @@ use super::*; #[tokio::test] async fn test_two_node_handshake_udp() { use crate::config::UdpConfig; - use crate::node::wire::{ + use crate::proto::fmp::wire::{ build_encrypted, build_established_header, build_msg1, prepend_inner_header, }; use crate::transport::udp::UdpTransport; @@ -243,7 +243,7 @@ async fn test_two_node_handshake_udp() { #[tokio::test] async fn test_run_rx_loop_handshake() { use crate::config::UdpConfig; - use crate::node::wire::build_msg1; + use crate::proto::fmp::wire::build_msg1; use crate::transport::udp::UdpTransport; use tokio::time::Duration; @@ -434,7 +434,7 @@ async fn test_run_rx_loop_handshake() { #[tokio::test] async fn test_cross_connection_both_initiate() { use crate::config::UdpConfig; - use crate::node::wire::build_msg1; + use crate::proto::fmp::wire::build_msg1; use crate::transport::udp::UdpTransport; use tokio::time::{Duration, timeout}; @@ -788,7 +788,7 @@ async fn test_failed_connection_cleanup() { /// Test that msg1 bytes are stored on connection for resend. #[tokio::test] async fn test_msg1_stored_for_resend() { - use crate::node::wire::build_msg1; + use crate::proto::fmp::wire::build_msg1; let mut node = make_node(); let transport_id = TransportId::new(1); @@ -846,7 +846,7 @@ async fn test_resend_scheduling() { conn.set_source_addr(remote_addr.clone()); // Store msg1 with first resend at now + 1000ms - let wire_msg1 = crate::node::wire::build_msg1(our_index, &noise_msg1); + let wire_msg1 = crate::proto::fmp::wire::build_msg1(our_index, &noise_msg1); conn.set_handshake_msg1(wire_msg1, now_ms + 1000); let link = Link::connectionless( @@ -924,7 +924,7 @@ fn test_resend_count_tracking() { /// Test that duplicate msg2 is silently dropped when pending_outbound is already cleared. #[tokio::test] async fn test_duplicate_msg2_dropped() { - use crate::node::wire::build_msg2; + use crate::proto::fmp::wire::build_msg2; use crate::transport::ReceivedPacket; let mut node = make_node(); diff --git a/src/node/tests/spanning_tree.rs b/src/node/tests/spanning_tree.rs index b21f0f1..471f630 100644 --- a/src/node/tests/spanning_tree.rs +++ b/src/node/tests/spanning_tree.rs @@ -105,7 +105,7 @@ pub(super) async fn make_test_node_with_mtu(mtu: u16) -> TestNode { /// Sends msg1 over UDP. The drain loop will handle msg1 processing, /// msg2 response, and subsequent TreeAnnounce exchange. pub(super) async fn initiate_handshake(nodes: &mut [TestNode], i: usize, j: usize) { - use crate::node::wire::build_msg1; + use crate::proto::fmp::wire::build_msg1; // Extract responder info before mutably borrowing initiator let responder_addr = nodes[j].addr.clone(); @@ -267,7 +267,7 @@ pub(super) fn print_tree_snapshot(label: &str, nodes: &[TestNode]) { /// /// Returns the number of packets processed. pub(super) async fn process_available_packets(nodes: &mut [TestNode]) -> usize { - use crate::node::wire::{ + use crate::proto::fmp::wire::{ COMMON_PREFIX_SIZE, CommonPrefix, FMP_VERSION, PHASE_ESTABLISHED, PHASE_MSG1, PHASE_MSG2, }; diff --git a/src/node/tests/unit.rs b/src/node/tests/unit.rs index c857094..36ca670 100644 --- a/src/node/tests/unit.rs +++ b/src/node/tests/unit.rs @@ -1788,7 +1788,7 @@ async fn craft_and_send_msg1( addr_b: std::net::SocketAddr, timestamp_ms: u64, ) -> NodeAddr { - use crate::node::wire::build_msg1; + use crate::proto::fmp::wire::build_msg1; use crate::utils::index::SessionIndex; let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity().pubkey_full()); diff --git a/src/node/wire.rs b/src/node/wire.rs deleted file mode 100644 index 21bc149..0000000 --- a/src/node/wire.rs +++ /dev/null @@ -1,626 +0,0 @@ -//! Wire Format Parsing and Serialization -//! -//! Defines the FIPS mesh-layer wire format (FMP) for packet dispatch. -//! All packets begin with a 4-byte common prefix followed by phase-specific fields. -//! -//! ## Common Prefix (4 bytes) -//! -//! ```text -//! [ver+phase:1][flags:1][payload_len:2 LE] -//! ``` -//! -//! ## Packet Types -//! -//! | Phase | Type | Size | Description | -//! |-------|-----------------|------------|--------------------------------| -//! | 0x0 | Encrypted frame | 32+ bytes | Post-handshake encrypted data | -//! | 0x1 | Noise IK msg1 | 114 bytes | Handshake initiation | -//! | 0x2 | Noise IK msg2 | 69 bytes | Handshake response | - -use crate::noise::{HANDSHAKE_MSG1_SIZE, HANDSHAKE_MSG2_SIZE, TAG_SIZE}; -use crate::utils::index::SessionIndex; - -// ============================================================================ -// Constants -// ============================================================================ - -/// FMP protocol version (4 high bits of byte 0). -pub const FMP_VERSION: u8 = 0; - -/// Phase value for established (encrypted) frames. -pub const PHASE_ESTABLISHED: u8 = 0x0; - -/// Phase value for Noise IK message 1 (handshake initiation). -pub const PHASE_MSG1: u8 = 0x1; - -/// Phase value for Noise IK message 2 (handshake response). -pub const PHASE_MSG2: u8 = 0x2; - -/// Size of the common packet prefix (all packet types). -pub const COMMON_PREFIX_SIZE: usize = 4; - -/// Size of the full established frame header (prefix + receiver_idx + counter). -pub const ESTABLISHED_HEADER_SIZE: usize = 16; - -/// Size of Noise IK message 1 wire packet: prefix + sender_idx + noise_msg1. -pub const MSG1_WIRE_SIZE: usize = COMMON_PREFIX_SIZE + 4 + HANDSHAKE_MSG1_SIZE; // 114 bytes - -/// Size of Noise IK message 2 wire packet: prefix + sender_idx + receiver_idx + noise_msg2. -pub const MSG2_WIRE_SIZE: usize = COMMON_PREFIX_SIZE + 4 + 4 + HANDSHAKE_MSG2_SIZE; // 69 bytes - -/// Minimum size for encrypted frame: header + tag (no plaintext). -pub const ENCRYPTED_MIN_SIZE: usize = ESTABLISHED_HEADER_SIZE + TAG_SIZE; // 32 bytes - -/// Size of the encrypted inner header (timestamp + message type). -pub const INNER_HEADER_SIZE: usize = 5; - -// Flag bit constants (byte 1 of common prefix, meaningful only for phase 0x0). -// Reserved for upcoming rekeying, congestion signaling, and RTT measurement. -#[allow(dead_code)] -/// Key epoch flag — selects active key during rekeying. -pub const FLAG_KEY_EPOCH: u8 = 0x01; -#[allow(dead_code)] -/// Congestion Experienced echo flag. -pub const FLAG_CE: u8 = 0x02; -#[allow(dead_code)] -/// Spin bit for RTT measurement. -pub const FLAG_SP: u8 = 0x04; - -// ============================================================================ -// Common Prefix -// ============================================================================ - -/// Parsed common packet prefix (first 4 bytes of every FMP packet). -/// -/// Wire format: -/// ```text -/// [ver(4bits)+phase(4bits)][flags:1][payload_len:2 LE] -/// ``` -#[derive(Clone, Debug)] -pub struct CommonPrefix { - /// Protocol version (high nibble of byte 0). - pub version: u8, - /// Session lifecycle phase (low nibble of byte 0). - pub phase: u8, - /// Per-packet signal flags (meaningful only for phase 0x0). - #[allow(dead_code)] - pub flags: u8, - /// Length of payload following the phase-specific header (excludes AEAD tag). - #[allow(dead_code)] - pub payload_len: u16, -} - -impl CommonPrefix { - /// Parse a common prefix from the first 4 bytes of packet data. - pub fn parse(data: &[u8]) -> Option { - if data.len() < COMMON_PREFIX_SIZE { - return None; - } - - let version = data[0] >> 4; - let phase = data[0] & 0x0F; - let flags = data[1]; - let payload_len = u16::from_le_bytes([data[2], data[3]]); - - Some(Self { - version, - phase, - flags, - payload_len, - }) - } - - /// Encode the ver+phase byte. - fn ver_phase_byte(version: u8, phase: u8) -> u8 { - (version << 4) | (phase & 0x0F) - } -} - -// ============================================================================ -// Encrypted Frame Header -// ============================================================================ - -/// Parsed established frame header (phase 0x0). -/// -/// Wire format (16 bytes): -/// ```text -/// [ver+phase:1][flags:1][payload_len:2 LE][receiver_idx:4 LE][counter:8 LE] -/// ``` -/// -/// The full 16-byte header is used as AAD for the AEAD construction. -#[derive(Clone, Debug)] -pub struct EncryptedHeader { - /// Per-packet flags (K, CE, SP). - #[allow(dead_code)] - pub flags: u8, - /// Length of encrypted payload (excluding AEAD tag). - #[allow(dead_code)] - pub payload_len: u16, - /// Session index chosen by the receiver (for O(1) lookup). - pub receiver_idx: SessionIndex, - /// Monotonic counter used as AEAD nonce. - pub counter: u64, - /// Raw 16-byte header for use as AEAD AAD. - pub header_bytes: [u8; ESTABLISHED_HEADER_SIZE], -} - -impl EncryptedHeader { - /// Parse an established frame header from packet data. - /// - /// Returns None if the packet is too short or has wrong version/phase. - pub fn parse(data: &[u8]) -> Option { - if data.len() < ENCRYPTED_MIN_SIZE { - return None; - } - - let version = data[0] >> 4; - let phase = data[0] & 0x0F; - - if version != FMP_VERSION || phase != PHASE_ESTABLISHED { - return None; - } - - let flags = data[1]; - let payload_len = u16::from_le_bytes([data[2], data[3]]); - let receiver_idx = SessionIndex::from_le_bytes([data[4], data[5], data[6], data[7]]); - let counter = u64::from_le_bytes([ - data[8], data[9], data[10], data[11], data[12], data[13], data[14], data[15], - ]); - - let mut header_bytes = [0u8; ESTABLISHED_HEADER_SIZE]; - header_bytes.copy_from_slice(&data[..ESTABLISHED_HEADER_SIZE]); - - Some(Self { - flags, - payload_len, - receiver_idx, - counter, - header_bytes, - }) - } - - /// Offset where ciphertext begins in the original packet. - pub fn ciphertext_offset(&self) -> usize { - ESTABLISHED_HEADER_SIZE - } - - /// Get the ciphertext slice from the original packet. - #[cfg(test)] - pub fn ciphertext<'a>(&self, data: &'a [u8]) -> &'a [u8] { - &data[ESTABLISHED_HEADER_SIZE..] - } -} - -// ============================================================================ -// Msg1 Header -// ============================================================================ - -/// Parsed Noise IK message 1 header (phase 0x1). -/// -/// Wire format (114 bytes): -/// ```text -/// [0x01][0x00][payload_len:2 LE][sender_idx:4 LE][noise_msg1:106] -/// ``` -#[derive(Clone, Debug)] -pub struct Msg1Header { - /// Session index chosen by the sender (becomes receiver_idx for responses). - pub sender_idx: SessionIndex, - /// Offset where Noise msg1 payload begins. - pub noise_msg1_offset: usize, -} - -impl Msg1Header { - /// Parse a msg1 header from packet data. - /// - /// Returns None if the packet has wrong size or version/phase. - pub fn parse(data: &[u8]) -> Option { - if data.len() != MSG1_WIRE_SIZE { - return None; - } - - let version = data[0] >> 4; - let phase = data[0] & 0x0F; - - if version != FMP_VERSION || phase != PHASE_MSG1 { - return None; - } - - // flags must be zero during handshake - if data[1] != 0 { - return None; - } - - let sender_idx = SessionIndex::from_le_bytes([data[4], data[5], data[6], data[7]]); - - Some(Self { - sender_idx, - noise_msg1_offset: COMMON_PREFIX_SIZE + 4, // 8 - }) - } - - /// Get the Noise msg1 payload from the original packet. - #[cfg(test)] - pub fn noise_msg1<'a>(&self, data: &'a [u8]) -> &'a [u8] { - &data[self.noise_msg1_offset..] - } -} - -// ============================================================================ -// Msg2 Header -// ============================================================================ - -/// Parsed Noise IK message 2 header (phase 0x2). -/// -/// Wire format (69 bytes): -/// ```text -/// [0x02][0x00][payload_len:2 LE][sender_idx:4 LE][receiver_idx:4 LE][noise_msg2:57] -/// ``` -#[derive(Clone, Debug)] -pub struct Msg2Header { - /// Session index chosen by the responder. - pub sender_idx: SessionIndex, - /// Echo of the initiator's sender_idx from msg1. - pub receiver_idx: SessionIndex, - /// Offset where Noise msg2 payload begins. - pub noise_msg2_offset: usize, -} - -impl Msg2Header { - /// Parse a msg2 header from packet data. - /// - /// Returns None if the packet has wrong size or version/phase. - pub fn parse(data: &[u8]) -> Option { - if data.len() != MSG2_WIRE_SIZE { - return None; - } - - let version = data[0] >> 4; - let phase = data[0] & 0x0F; - - if version != FMP_VERSION || phase != PHASE_MSG2 { - return None; - } - - // flags must be zero during handshake - if data[1] != 0 { - return None; - } - - let sender_idx = SessionIndex::from_le_bytes([data[4], data[5], data[6], data[7]]); - let receiver_idx = SessionIndex::from_le_bytes([data[8], data[9], data[10], data[11]]); - - Some(Self { - sender_idx, - receiver_idx, - noise_msg2_offset: COMMON_PREFIX_SIZE + 4 + 4, // 12 - }) - } - - /// Get the Noise msg2 payload from the original packet. - #[cfg(test)] - pub fn noise_msg2<'a>(&self, data: &'a [u8]) -> &'a [u8] { - &data[self.noise_msg2_offset..] - } -} - -// ============================================================================ -// Serialization Helpers -// ============================================================================ - -/// Build a wire-format msg1 packet. -/// -/// Format: `[0x01][0x00][payload_len:2 LE][sender_idx:4 LE][noise_msg1:106]` -pub fn build_msg1(sender_idx: SessionIndex, noise_msg1: &[u8]) -> Vec { - debug_assert_eq!(noise_msg1.len(), HANDSHAKE_MSG1_SIZE); - - let payload_len = (4 + noise_msg1.len()) as u16; // sender_idx + noise_msg1 - - let mut packet = Vec::with_capacity(MSG1_WIRE_SIZE); - packet.push(CommonPrefix::ver_phase_byte(FMP_VERSION, PHASE_MSG1)); - packet.push(0x00); // flags must be zero - packet.extend_from_slice(&payload_len.to_le_bytes()); - packet.extend_from_slice(&sender_idx.to_le_bytes()); - packet.extend_from_slice(noise_msg1); - packet -} - -/// Build a wire-format msg2 packet. -/// -/// Format: `[0x02][0x00][payload_len:2 LE][sender_idx:4 LE][receiver_idx:4 LE][noise_msg2:57]` -pub fn build_msg2( - sender_idx: SessionIndex, - receiver_idx: SessionIndex, - noise_msg2: &[u8], -) -> Vec { - debug_assert_eq!(noise_msg2.len(), HANDSHAKE_MSG2_SIZE); - - let payload_len = (4 + 4 + noise_msg2.len()) as u16; // sender + receiver + noise - - let mut packet = Vec::with_capacity(MSG2_WIRE_SIZE); - packet.push(CommonPrefix::ver_phase_byte(FMP_VERSION, PHASE_MSG2)); - packet.push(0x00); // flags must be zero - packet.extend_from_slice(&payload_len.to_le_bytes()); - packet.extend_from_slice(&sender_idx.to_le_bytes()); - packet.extend_from_slice(&receiver_idx.to_le_bytes()); - packet.extend_from_slice(noise_msg2); - packet -} - -/// Build the 16-byte outer header for an established frame. -/// -/// Returns the header bytes (for use as AAD) separately from the construction. -pub fn build_established_header( - receiver_idx: SessionIndex, - counter: u64, - flags: u8, - payload_len: u16, -) -> [u8; ESTABLISHED_HEADER_SIZE] { - let mut header = [0u8; ESTABLISHED_HEADER_SIZE]; - header[0] = CommonPrefix::ver_phase_byte(FMP_VERSION, PHASE_ESTABLISHED); - header[1] = flags; - header[2..4].copy_from_slice(&payload_len.to_le_bytes()); - header[4..8].copy_from_slice(&receiver_idx.to_le_bytes()); - header[8..16].copy_from_slice(&counter.to_le_bytes()); - header -} - -/// Build a wire-format encrypted frame. -/// -/// Format: `[header:16][ciphertext+tag]` -/// -/// The header is constructed from the parameters and used as AAD during -/// encryption. The caller should use `build_established_header` to construct -/// the header, encrypt with it as AAD, then call this to assemble the packet. -pub fn build_encrypted(header: &[u8; ESTABLISHED_HEADER_SIZE], ciphertext: &[u8]) -> Vec { - let mut packet = Vec::with_capacity(ESTABLISHED_HEADER_SIZE + ciphertext.len()); - packet.extend_from_slice(header); - packet.extend_from_slice(ciphertext); - packet -} - -// ============================================================================ -// Inner Header Helpers -// ============================================================================ - -/// Prepend the 5-byte inner header (timestamp + msg_type) to a link message. -/// -/// The caller provides the original plaintext starting with `[msg_type][payload...]`. -/// This prepends `[timestamp:4 LE]` before the msg_type byte. -pub fn prepend_inner_header(timestamp_ms: u32, plaintext: &[u8]) -> Vec { - let mut buf = Vec::with_capacity(4 + plaintext.len()); - buf.extend_from_slice(×tamp_ms.to_le_bytes()); - buf.extend_from_slice(plaintext); - buf -} - -/// Strip the 4-byte timestamp from a decrypted inner payload. -/// -/// Returns `(timestamp, &payload_starting_at_msg_type)` or None if too short. -pub fn strip_inner_header(plaintext: &[u8]) -> Option<(u32, &[u8])> { - if plaintext.len() < INNER_HEADER_SIZE { - return None; - } - let timestamp = u32::from_le_bytes([plaintext[0], plaintext[1], plaintext[2], plaintext[3]]); - Some((timestamp, &plaintext[4..])) -} - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_common_prefix_parse() { - let data = [0x00, 0x04, 0x20, 0x00]; // ver=0, phase=0, flags=SP, payload_len=32 - let prefix = CommonPrefix::parse(&data).unwrap(); - assert_eq!(prefix.version, 0); - assert_eq!(prefix.phase, 0); - assert_eq!(prefix.flags, FLAG_SP); - assert_eq!(prefix.payload_len, 32); - } - - #[test] - fn test_common_prefix_too_short() { - assert!(CommonPrefix::parse(&[0, 0, 0]).is_none()); - } - - #[test] - fn test_encrypted_header_parse() { - let receiver_idx = SessionIndex::new(0x12345678); - let counter = 42u64; - let flags = 0u8; - let payload_len = 32u16; // 16 plaintext + 16 tag - let ciphertext = vec![0xaa; 48]; // payload_len + TAG_SIZE - - let header = build_established_header(receiver_idx, counter, flags, payload_len); - let packet = build_encrypted(&header, &ciphertext); - - assert_eq!(packet.len(), ESTABLISHED_HEADER_SIZE + 48); - assert_eq!(packet[0], 0x00); // ver=0, phase=0 - - let parsed = EncryptedHeader::parse(&packet).expect("should parse"); - assert_eq!(parsed.receiver_idx, receiver_idx); - assert_eq!(parsed.counter, 42); - assert_eq!(parsed.flags, 0); - assert_eq!(parsed.payload_len, 32); - assert_eq!(parsed.header_bytes, header); - assert_eq!(parsed.ciphertext(&packet), &ciphertext[..]); - } - - #[test] - fn test_encrypted_header_too_short() { - let packet = vec![0x00; ENCRYPTED_MIN_SIZE - 1]; - assert!(EncryptedHeader::parse(&packet).is_none()); - } - - #[test] - fn test_encrypted_header_wrong_phase() { - let mut packet = vec![0x00; ENCRYPTED_MIN_SIZE]; - packet[0] = 0x01; // phase 1 (msg1), not established - assert!(EncryptedHeader::parse(&packet).is_none()); - } - - #[test] - fn test_encrypted_header_wrong_version() { - let mut packet = vec![0x00; ENCRYPTED_MIN_SIZE]; - packet[0] = 0x10; // version 1, phase 0 - assert!(EncryptedHeader::parse(&packet).is_none()); - } - - #[test] - fn test_msg1_header_parse() { - let sender_idx = SessionIndex::new(0xABCDEF01); - let noise_msg1 = vec![0xbb; HANDSHAKE_MSG1_SIZE]; - - let packet = build_msg1(sender_idx, &noise_msg1); - - assert_eq!(packet.len(), MSG1_WIRE_SIZE); - assert_eq!(packet[0], 0x01); // ver=0, phase=1 - - let header = Msg1Header::parse(&packet).expect("should parse"); - assert_eq!(header.sender_idx, sender_idx); - assert_eq!(header.noise_msg1_offset, 8); - assert_eq!(header.noise_msg1(&packet), &noise_msg1[..]); - } - - #[test] - fn test_msg1_header_wrong_size() { - let packet = vec![0x01; MSG1_WIRE_SIZE - 1]; - assert!(Msg1Header::parse(&packet).is_none()); - - let packet = vec![0x01; MSG1_WIRE_SIZE + 1]; - assert!(Msg1Header::parse(&packet).is_none()); - } - - #[test] - fn test_msg1_header_wrong_phase() { - let mut packet = vec![0x00; MSG1_WIRE_SIZE]; - packet[0] = 0x02; // phase 2, not phase 1 - assert!(Msg1Header::parse(&packet).is_none()); - } - - #[test] - fn test_msg1_header_nonzero_flags() { - let mut packet = build_msg1(SessionIndex::new(1), &[0u8; HANDSHAKE_MSG1_SIZE]); - packet[1] = 0x01; // flags must be zero during handshake - assert!(Msg1Header::parse(&packet).is_none()); - } - - #[test] - fn test_msg2_header_parse() { - let sender_idx = SessionIndex::new(0x11223344); - let receiver_idx = SessionIndex::new(0x55667788); - let noise_msg2 = vec![0xcc; HANDSHAKE_MSG2_SIZE]; - - let packet = build_msg2(sender_idx, receiver_idx, &noise_msg2); - - assert_eq!(packet.len(), MSG2_WIRE_SIZE); - assert_eq!(packet[0], 0x02); // ver=0, phase=2 - - let header = Msg2Header::parse(&packet).expect("should parse"); - assert_eq!(header.sender_idx, sender_idx); - assert_eq!(header.receiver_idx, receiver_idx); - assert_eq!(header.noise_msg2_offset, 12); - assert_eq!(header.noise_msg2(&packet), &noise_msg2[..]); - } - - #[test] - fn test_msg2_header_wrong_size() { - let packet = vec![0x02; MSG2_WIRE_SIZE - 1]; - assert!(Msg2Header::parse(&packet).is_none()); - - let packet = vec![0x02; MSG2_WIRE_SIZE + 1]; - assert!(Msg2Header::parse(&packet).is_none()); - } - - #[test] - fn test_msg2_header_wrong_phase() { - let mut packet = vec![0x00; MSG2_WIRE_SIZE]; - packet[0] = 0x00; // phase 0, not phase 2 - assert!(Msg2Header::parse(&packet).is_none()); - } - - #[test] - fn test_wire_sizes() { - assert_eq!(MSG1_WIRE_SIZE, 114); // 4 + 4 + 106 - assert_eq!(MSG2_WIRE_SIZE, 69); // 4 + 4 + 4 + 57 - assert_eq!(ENCRYPTED_MIN_SIZE, 32); // 16 + 16 - assert_eq!(COMMON_PREFIX_SIZE, 4); - assert_eq!(ESTABLISHED_HEADER_SIZE, 16); - assert_eq!(INNER_HEADER_SIZE, 5); - } - - #[test] - fn test_roundtrip_indices() { - let idx = SessionIndex::new(0xDEADBEEF); - - let msg1 = build_msg1(idx, &[0u8; HANDSHAKE_MSG1_SIZE]); - let parsed = Msg1Header::parse(&msg1).unwrap(); - assert_eq!(parsed.sender_idx.as_u32(), 0xDEADBEEF); - - // Verify little-endian encoding (sender_idx starts at offset 4) - assert_eq!(msg1[4], 0xEF); - assert_eq!(msg1[5], 0xBE); - assert_eq!(msg1[6], 0xAD); - assert_eq!(msg1[7], 0xDE); - } - - #[test] - fn test_inner_header_prepend_strip() { - let timestamp: u32 = 12345; - let original = vec![0x10, 0xAA, 0xBB]; // msg_type + payload - - let with_header = prepend_inner_header(timestamp, &original); - assert_eq!(with_header.len(), 4 + 3); // timestamp + original - - let (ts, rest) = strip_inner_header(&with_header).unwrap(); - assert_eq!(ts, 12345); - assert_eq!(rest, &original[..]); - } - - #[test] - fn test_inner_header_too_short() { - assert!(strip_inner_header(&[0, 0, 0, 0]).is_none()); // needs 5 bytes minimum - } - - #[test] - fn test_flags_byte() { - let header = - build_established_header(SessionIndex::new(1), 0, FLAG_KEY_EPOCH | FLAG_SP, 100); - assert_eq!(header[1], 0x05); // bits 0 and 2 set - - let parsed = EncryptedHeader::parse(&[ - header[0], header[1], header[2], header[3], header[4], header[5], header[6], header[7], - header[8], header[9], header[10], header[11], header[12], header[13], header[14], - header[15], // minimum: TAG_SIZE bytes of ciphertext - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - ]) - .unwrap(); - assert_eq!(parsed.flags & FLAG_KEY_EPOCH, FLAG_KEY_EPOCH); - assert_eq!(parsed.flags & FLAG_CE, 0); - assert_eq!(parsed.flags & FLAG_SP, FLAG_SP); - } - - #[test] - fn test_payload_len_in_msg1() { - let packet = build_msg1(SessionIndex::new(1), &[0u8; HANDSHAKE_MSG1_SIZE]); - let prefix = CommonPrefix::parse(&packet).unwrap(); - // payload_len = sender_idx(4) + noise_msg1(106) = 110 - assert_eq!(prefix.payload_len, 110); - } - - #[test] - fn test_payload_len_in_msg2() { - let packet = build_msg2( - SessionIndex::new(1), - SessionIndex::new(2), - &[0u8; HANDSHAKE_MSG2_SIZE], - ); - let prefix = CommonPrefix::parse(&packet).unwrap(); - // payload_len = sender_idx(4) + receiver_idx(4) + noise_msg2(57) = 65 - assert_eq!(prefix.payload_len, 65); - } -} diff --git a/src/peer/active.rs b/src/peer/active.rs index c88e95a..d727f72 100644 --- a/src/peer/active.rs +++ b/src/peer/active.rs @@ -1567,7 +1567,7 @@ mod tests { plaintext: &[u8], k_bit: bool, ) -> (Vec, u64, [u8; 16]) { - use crate::node::wire::{FLAG_KEY_EPOCH, build_established_header}; + use crate::proto::fmp::wire::{FLAG_KEY_EPOCH, build_established_header}; let counter = sender.current_send_counter(); let flags = if k_bit { FLAG_KEY_EPOCH } else { 0 }; let header = build_established_header(receiver_idx, counter, flags, plaintext.len() as u16); diff --git a/src/proto/fmp/mod.rs b/src/proto/fmp/mod.rs index b59c070..dc58f0f 100644 --- a/src/proto/fmp/mod.rs +++ b/src/proto/fmp/mod.rs @@ -21,12 +21,14 @@ //! handles) and its [`HandshakeState`] phase enum, plus [`Fmp`], the //! (stateless) lifecycle anchor owned by `Node`. //! - `wire.rs` — the FMP link-framing codec: handshake message types, -//! disconnect reasons, and the orderly disconnect message. +//! disconnect reasons, and the orderly disconnect message. Also carries the +//! FMP link wire framing relocated from `node/wire.rs` — the common prefix, +//! encrypted/msg1/msg2 headers, and the `build_*`/inner-header codec fns. mod core; mod limits; mod state; -mod wire; +pub(crate) mod wire; #[cfg(test)] mod tests; diff --git a/src/proto/fmp/wire.rs b/src/proto/fmp/wire.rs index 715b1f4..fc7cc61 100644 --- a/src/proto/fmp/wire.rs +++ b/src/proto/fmp/wire.rs @@ -1,14 +1,22 @@ -//! FMP link-framing messages: handshake message types and orderly disconnect. +//! FMP link-framing messages and link-layer wire codec. //! //! The Noise IK handshake message-type discriminants and the orderly //! disconnect codec, per the wire-migrates-with-subsystem policy. //! `Disconnect::encode` reads the shared `LinkMessageType::Disconnect` catalog //! variant (a downward `proto -> proto` dependency); the catalog itself lives //! in `crate::proto::link`. +//! +//! This module also carries the FMP mesh-layer packet-dispatch wire format +//! (the common prefix, encrypted/msg1/msg2 headers, and the `build_*`/ +//! inner-header codec functions), relocated from `node/wire.rs` so all FMP +//! wire lives with its subsystem. See the "FMP link wire framing" section +//! banner below for the packet-type layout. +use crate::noise::{HANDSHAKE_MSG1_SIZE, HANDSHAKE_MSG2_SIZE, TAG_SIZE}; use crate::proto::Error; use crate::proto::codec::Reader; use crate::proto::link::LinkMessageType; +use crate::utils::index::SessionIndex; use ::core::fmt; /// Handshake message type identifiers. @@ -160,3 +168,627 @@ impl Disconnect { Ok(Self { reason }) } } + +// ============================================================================ +// FMP link wire framing (relocated from node/wire.rs) +// ============================================================================ +// +// The FIPS mesh-layer wire format (FMP) for packet dispatch. All packets begin +// with a 4-byte common prefix followed by phase-specific fields. +// +// Common Prefix (4 bytes): +// +// [ver+phase:1][flags:1][payload_len:2 LE] +// +// Packet Types: +// +// | Phase | Type | Size | Description | +// |-------|-----------------|------------|--------------------------------| +// | 0x0 | Encrypted frame | 32+ bytes | Post-handshake encrypted data | +// | 0x1 | Noise IK msg1 | 114 bytes | Handshake initiation | +// | 0x2 | Noise IK msg2 | 69 bytes | Handshake response | + +// ============================================================================ +// Constants +// ============================================================================ + +/// FMP protocol version (4 high bits of byte 0). +pub const FMP_VERSION: u8 = 0; + +/// Phase value for established (encrypted) frames. +pub const PHASE_ESTABLISHED: u8 = 0x0; + +/// Phase value for Noise IK message 1 (handshake initiation). +pub const PHASE_MSG1: u8 = 0x1; + +/// Phase value for Noise IK message 2 (handshake response). +pub const PHASE_MSG2: u8 = 0x2; + +/// Size of the common packet prefix (all packet types). +pub const COMMON_PREFIX_SIZE: usize = 4; + +/// Size of the full established frame header (prefix + receiver_idx + counter). +pub const ESTABLISHED_HEADER_SIZE: usize = 16; + +/// Size of Noise IK message 1 wire packet: prefix + sender_idx + noise_msg1. +pub const MSG1_WIRE_SIZE: usize = COMMON_PREFIX_SIZE + 4 + HANDSHAKE_MSG1_SIZE; // 114 bytes + +/// Size of Noise IK message 2 wire packet: prefix + sender_idx + receiver_idx + noise_msg2. +pub const MSG2_WIRE_SIZE: usize = COMMON_PREFIX_SIZE + 4 + 4 + HANDSHAKE_MSG2_SIZE; // 69 bytes + +/// Minimum size for encrypted frame: header + tag (no plaintext). +pub const ENCRYPTED_MIN_SIZE: usize = ESTABLISHED_HEADER_SIZE + TAG_SIZE; // 32 bytes + +/// Size of the encrypted inner header (timestamp + message type). +pub const INNER_HEADER_SIZE: usize = 5; + +// Flag bit constants (byte 1 of common prefix, meaningful only for phase 0x0). +// Reserved for upcoming rekeying, congestion signaling, and RTT measurement. +#[allow(dead_code)] +/// Key epoch flag — selects active key during rekeying. +pub const FLAG_KEY_EPOCH: u8 = 0x01; +#[allow(dead_code)] +/// Congestion Experienced echo flag. +pub const FLAG_CE: u8 = 0x02; +#[allow(dead_code)] +/// Spin bit for RTT measurement. +pub const FLAG_SP: u8 = 0x04; + +// ============================================================================ +// Common Prefix +// ============================================================================ + +/// Parsed common packet prefix (first 4 bytes of every FMP packet). +/// +/// Wire format: +/// ```text +/// [ver(4bits)+phase(4bits)][flags:1][payload_len:2 LE] +/// ``` +#[derive(Clone, Debug)] +pub struct CommonPrefix { + /// Protocol version (high nibble of byte 0). + pub version: u8, + /// Session lifecycle phase (low nibble of byte 0). + pub phase: u8, + /// Per-packet signal flags (meaningful only for phase 0x0). + #[allow(dead_code)] + pub flags: u8, + /// Length of payload following the phase-specific header (excludes AEAD tag). + #[allow(dead_code)] + pub payload_len: u16, +} + +impl CommonPrefix { + /// Parse a common prefix from the first 4 bytes of packet data. + pub fn parse(data: &[u8]) -> Option { + if data.len() < COMMON_PREFIX_SIZE { + return None; + } + + let version = data[0] >> 4; + let phase = data[0] & 0x0F; + let flags = data[1]; + let payload_len = u16::from_le_bytes([data[2], data[3]]); + + Some(Self { + version, + phase, + flags, + payload_len, + }) + } + + /// Encode the ver+phase byte. + fn ver_phase_byte(version: u8, phase: u8) -> u8 { + (version << 4) | (phase & 0x0F) + } +} + +// ============================================================================ +// Encrypted Frame Header +// ============================================================================ + +/// Parsed established frame header (phase 0x0). +/// +/// Wire format (16 bytes): +/// ```text +/// [ver+phase:1][flags:1][payload_len:2 LE][receiver_idx:4 LE][counter:8 LE] +/// ``` +/// +/// The full 16-byte header is used as AAD for the AEAD construction. +#[derive(Clone, Debug)] +pub struct EncryptedHeader { + /// Per-packet flags (K, CE, SP). + #[allow(dead_code)] + pub flags: u8, + /// Length of encrypted payload (excluding AEAD tag). + #[allow(dead_code)] + pub payload_len: u16, + /// Session index chosen by the receiver (for O(1) lookup). + pub receiver_idx: SessionIndex, + /// Monotonic counter used as AEAD nonce. + pub counter: u64, + /// Raw 16-byte header for use as AEAD AAD. + pub header_bytes: [u8; ESTABLISHED_HEADER_SIZE], +} + +impl EncryptedHeader { + /// Parse an established frame header from packet data. + /// + /// Returns None if the packet is too short or has wrong version/phase. + pub fn parse(data: &[u8]) -> Option { + if data.len() < ENCRYPTED_MIN_SIZE { + return None; + } + + let version = data[0] >> 4; + let phase = data[0] & 0x0F; + + if version != FMP_VERSION || phase != PHASE_ESTABLISHED { + return None; + } + + let flags = data[1]; + let payload_len = u16::from_le_bytes([data[2], data[3]]); + let receiver_idx = SessionIndex::from_le_bytes([data[4], data[5], data[6], data[7]]); + let counter = u64::from_le_bytes([ + data[8], data[9], data[10], data[11], data[12], data[13], data[14], data[15], + ]); + + let mut header_bytes = [0u8; ESTABLISHED_HEADER_SIZE]; + header_bytes.copy_from_slice(&data[..ESTABLISHED_HEADER_SIZE]); + + Some(Self { + flags, + payload_len, + receiver_idx, + counter, + header_bytes, + }) + } + + /// Offset where ciphertext begins in the original packet. + pub fn ciphertext_offset(&self) -> usize { + ESTABLISHED_HEADER_SIZE + } + + /// Get the ciphertext slice from the original packet. + #[cfg(test)] + pub fn ciphertext<'a>(&self, data: &'a [u8]) -> &'a [u8] { + &data[ESTABLISHED_HEADER_SIZE..] + } +} + +// ============================================================================ +// Msg1 Header +// ============================================================================ + +/// Parsed Noise IK message 1 header (phase 0x1). +/// +/// Wire format (114 bytes): +/// ```text +/// [0x01][0x00][payload_len:2 LE][sender_idx:4 LE][noise_msg1:106] +/// ``` +#[derive(Clone, Debug)] +pub struct Msg1Header { + /// Session index chosen by the sender (becomes receiver_idx for responses). + pub sender_idx: SessionIndex, + /// Offset where Noise msg1 payload begins. + pub noise_msg1_offset: usize, +} + +impl Msg1Header { + /// Parse a msg1 header from packet data. + /// + /// Returns None if the packet has wrong size or version/phase. + pub fn parse(data: &[u8]) -> Option { + if data.len() != MSG1_WIRE_SIZE { + return None; + } + + let version = data[0] >> 4; + let phase = data[0] & 0x0F; + + if version != FMP_VERSION || phase != PHASE_MSG1 { + return None; + } + + // flags must be zero during handshake + if data[1] != 0 { + return None; + } + + let sender_idx = SessionIndex::from_le_bytes([data[4], data[5], data[6], data[7]]); + + Some(Self { + sender_idx, + noise_msg1_offset: COMMON_PREFIX_SIZE + 4, // 8 + }) + } + + /// Get the Noise msg1 payload from the original packet. + #[cfg(test)] + pub fn noise_msg1<'a>(&self, data: &'a [u8]) -> &'a [u8] { + &data[self.noise_msg1_offset..] + } +} + +// ============================================================================ +// Msg2 Header +// ============================================================================ + +/// Parsed Noise IK message 2 header (phase 0x2). +/// +/// Wire format (69 bytes): +/// ```text +/// [0x02][0x00][payload_len:2 LE][sender_idx:4 LE][receiver_idx:4 LE][noise_msg2:57] +/// ``` +#[derive(Clone, Debug)] +pub struct Msg2Header { + /// Session index chosen by the responder. + pub sender_idx: SessionIndex, + /// Echo of the initiator's sender_idx from msg1. + pub receiver_idx: SessionIndex, + /// Offset where Noise msg2 payload begins. + pub noise_msg2_offset: usize, +} + +impl Msg2Header { + /// Parse a msg2 header from packet data. + /// + /// Returns None if the packet has wrong size or version/phase. + pub fn parse(data: &[u8]) -> Option { + if data.len() != MSG2_WIRE_SIZE { + return None; + } + + let version = data[0] >> 4; + let phase = data[0] & 0x0F; + + if version != FMP_VERSION || phase != PHASE_MSG2 { + return None; + } + + // flags must be zero during handshake + if data[1] != 0 { + return None; + } + + let sender_idx = SessionIndex::from_le_bytes([data[4], data[5], data[6], data[7]]); + let receiver_idx = SessionIndex::from_le_bytes([data[8], data[9], data[10], data[11]]); + + Some(Self { + sender_idx, + receiver_idx, + noise_msg2_offset: COMMON_PREFIX_SIZE + 4 + 4, // 12 + }) + } + + /// Get the Noise msg2 payload from the original packet. + #[cfg(test)] + pub fn noise_msg2<'a>(&self, data: &'a [u8]) -> &'a [u8] { + &data[self.noise_msg2_offset..] + } +} + +// ============================================================================ +// Serialization Helpers +// ============================================================================ + +/// Build a wire-format msg1 packet. +/// +/// Format: `[0x01][0x00][payload_len:2 LE][sender_idx:4 LE][noise_msg1:106]` +pub fn build_msg1(sender_idx: SessionIndex, noise_msg1: &[u8]) -> Vec { + debug_assert_eq!(noise_msg1.len(), HANDSHAKE_MSG1_SIZE); + + let payload_len = (4 + noise_msg1.len()) as u16; // sender_idx + noise_msg1 + + let mut packet = Vec::with_capacity(MSG1_WIRE_SIZE); + packet.push(CommonPrefix::ver_phase_byte(FMP_VERSION, PHASE_MSG1)); + packet.push(0x00); // flags must be zero + packet.extend_from_slice(&payload_len.to_le_bytes()); + packet.extend_from_slice(&sender_idx.to_le_bytes()); + packet.extend_from_slice(noise_msg1); + packet +} + +/// Build a wire-format msg2 packet. +/// +/// Format: `[0x02][0x00][payload_len:2 LE][sender_idx:4 LE][receiver_idx:4 LE][noise_msg2:57]` +pub fn build_msg2( + sender_idx: SessionIndex, + receiver_idx: SessionIndex, + noise_msg2: &[u8], +) -> Vec { + debug_assert_eq!(noise_msg2.len(), HANDSHAKE_MSG2_SIZE); + + let payload_len = (4 + 4 + noise_msg2.len()) as u16; // sender + receiver + noise + + let mut packet = Vec::with_capacity(MSG2_WIRE_SIZE); + packet.push(CommonPrefix::ver_phase_byte(FMP_VERSION, PHASE_MSG2)); + packet.push(0x00); // flags must be zero + packet.extend_from_slice(&payload_len.to_le_bytes()); + packet.extend_from_slice(&sender_idx.to_le_bytes()); + packet.extend_from_slice(&receiver_idx.to_le_bytes()); + packet.extend_from_slice(noise_msg2); + packet +} + +/// Build the 16-byte outer header for an established frame. +/// +/// Returns the header bytes (for use as AAD) separately from the construction. +pub fn build_established_header( + receiver_idx: SessionIndex, + counter: u64, + flags: u8, + payload_len: u16, +) -> [u8; ESTABLISHED_HEADER_SIZE] { + let mut header = [0u8; ESTABLISHED_HEADER_SIZE]; + header[0] = CommonPrefix::ver_phase_byte(FMP_VERSION, PHASE_ESTABLISHED); + header[1] = flags; + header[2..4].copy_from_slice(&payload_len.to_le_bytes()); + header[4..8].copy_from_slice(&receiver_idx.to_le_bytes()); + header[8..16].copy_from_slice(&counter.to_le_bytes()); + header +} + +/// Build a wire-format encrypted frame. +/// +/// Format: `[header:16][ciphertext+tag]` +/// +/// The header is constructed from the parameters and used as AAD during +/// encryption. The caller should use `build_established_header` to construct +/// the header, encrypt with it as AAD, then call this to assemble the packet. +pub fn build_encrypted(header: &[u8; ESTABLISHED_HEADER_SIZE], ciphertext: &[u8]) -> Vec { + let mut packet = Vec::with_capacity(ESTABLISHED_HEADER_SIZE + ciphertext.len()); + packet.extend_from_slice(header); + packet.extend_from_slice(ciphertext); + packet +} + +// ============================================================================ +// Inner Header Helpers +// ============================================================================ + +/// Prepend the 5-byte inner header (timestamp + msg_type) to a link message. +/// +/// The caller provides the original plaintext starting with `[msg_type][payload...]`. +/// This prepends `[timestamp:4 LE]` before the msg_type byte. +pub fn prepend_inner_header(timestamp_ms: u32, plaintext: &[u8]) -> Vec { + let mut buf = Vec::with_capacity(4 + plaintext.len()); + buf.extend_from_slice(×tamp_ms.to_le_bytes()); + buf.extend_from_slice(plaintext); + buf +} + +/// Strip the 4-byte timestamp from a decrypted inner payload. +/// +/// Returns `(timestamp, &payload_starting_at_msg_type)` or None if too short. +pub fn strip_inner_header(plaintext: &[u8]) -> Option<(u32, &[u8])> { + if plaintext.len() < INNER_HEADER_SIZE { + return None; + } + let timestamp = u32::from_le_bytes([plaintext[0], plaintext[1], plaintext[2], plaintext[3]]); + Some((timestamp, &plaintext[4..])) +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_common_prefix_parse() { + let data = [0x00, 0x04, 0x20, 0x00]; // ver=0, phase=0, flags=SP, payload_len=32 + let prefix = CommonPrefix::parse(&data).unwrap(); + assert_eq!(prefix.version, 0); + assert_eq!(prefix.phase, 0); + assert_eq!(prefix.flags, FLAG_SP); + assert_eq!(prefix.payload_len, 32); + } + + #[test] + fn test_common_prefix_too_short() { + assert!(CommonPrefix::parse(&[0, 0, 0]).is_none()); + } + + #[test] + fn test_encrypted_header_parse() { + let receiver_idx = SessionIndex::new(0x12345678); + let counter = 42u64; + let flags = 0u8; + let payload_len = 32u16; // 16 plaintext + 16 tag + let ciphertext = vec![0xaa; 48]; // payload_len + TAG_SIZE + + let header = build_established_header(receiver_idx, counter, flags, payload_len); + let packet = build_encrypted(&header, &ciphertext); + + assert_eq!(packet.len(), ESTABLISHED_HEADER_SIZE + 48); + assert_eq!(packet[0], 0x00); // ver=0, phase=0 + + let parsed = EncryptedHeader::parse(&packet).expect("should parse"); + assert_eq!(parsed.receiver_idx, receiver_idx); + assert_eq!(parsed.counter, 42); + assert_eq!(parsed.flags, 0); + assert_eq!(parsed.payload_len, 32); + assert_eq!(parsed.header_bytes, header); + assert_eq!(parsed.ciphertext(&packet), &ciphertext[..]); + } + + #[test] + fn test_encrypted_header_too_short() { + let packet = vec![0x00; ENCRYPTED_MIN_SIZE - 1]; + assert!(EncryptedHeader::parse(&packet).is_none()); + } + + #[test] + fn test_encrypted_header_wrong_phase() { + let mut packet = vec![0x00; ENCRYPTED_MIN_SIZE]; + packet[0] = 0x01; // phase 1 (msg1), not established + assert!(EncryptedHeader::parse(&packet).is_none()); + } + + #[test] + fn test_encrypted_header_wrong_version() { + let mut packet = vec![0x00; ENCRYPTED_MIN_SIZE]; + packet[0] = 0x10; // version 1, phase 0 + assert!(EncryptedHeader::parse(&packet).is_none()); + } + + #[test] + fn test_msg1_header_parse() { + let sender_idx = SessionIndex::new(0xABCDEF01); + let noise_msg1 = vec![0xbb; HANDSHAKE_MSG1_SIZE]; + + let packet = build_msg1(sender_idx, &noise_msg1); + + assert_eq!(packet.len(), MSG1_WIRE_SIZE); + assert_eq!(packet[0], 0x01); // ver=0, phase=1 + + let header = Msg1Header::parse(&packet).expect("should parse"); + assert_eq!(header.sender_idx, sender_idx); + assert_eq!(header.noise_msg1_offset, 8); + assert_eq!(header.noise_msg1(&packet), &noise_msg1[..]); + } + + #[test] + fn test_msg1_header_wrong_size() { + let packet = vec![0x01; MSG1_WIRE_SIZE - 1]; + assert!(Msg1Header::parse(&packet).is_none()); + + let packet = vec![0x01; MSG1_WIRE_SIZE + 1]; + assert!(Msg1Header::parse(&packet).is_none()); + } + + #[test] + fn test_msg1_header_wrong_phase() { + let mut packet = vec![0x00; MSG1_WIRE_SIZE]; + packet[0] = 0x02; // phase 2, not phase 1 + assert!(Msg1Header::parse(&packet).is_none()); + } + + #[test] + fn test_msg1_header_nonzero_flags() { + let mut packet = build_msg1(SessionIndex::new(1), &[0u8; HANDSHAKE_MSG1_SIZE]); + packet[1] = 0x01; // flags must be zero during handshake + assert!(Msg1Header::parse(&packet).is_none()); + } + + #[test] + fn test_msg2_header_parse() { + let sender_idx = SessionIndex::new(0x11223344); + let receiver_idx = SessionIndex::new(0x55667788); + let noise_msg2 = vec![0xcc; HANDSHAKE_MSG2_SIZE]; + + let packet = build_msg2(sender_idx, receiver_idx, &noise_msg2); + + assert_eq!(packet.len(), MSG2_WIRE_SIZE); + assert_eq!(packet[0], 0x02); // ver=0, phase=2 + + let header = Msg2Header::parse(&packet).expect("should parse"); + assert_eq!(header.sender_idx, sender_idx); + assert_eq!(header.receiver_idx, receiver_idx); + assert_eq!(header.noise_msg2_offset, 12); + assert_eq!(header.noise_msg2(&packet), &noise_msg2[..]); + } + + #[test] + fn test_msg2_header_wrong_size() { + let packet = vec![0x02; MSG2_WIRE_SIZE - 1]; + assert!(Msg2Header::parse(&packet).is_none()); + + let packet = vec![0x02; MSG2_WIRE_SIZE + 1]; + assert!(Msg2Header::parse(&packet).is_none()); + } + + #[test] + fn test_msg2_header_wrong_phase() { + let mut packet = vec![0x00; MSG2_WIRE_SIZE]; + packet[0] = 0x00; // phase 0, not phase 2 + assert!(Msg2Header::parse(&packet).is_none()); + } + + #[test] + fn test_wire_sizes() { + assert_eq!(MSG1_WIRE_SIZE, 114); // 4 + 4 + 106 + assert_eq!(MSG2_WIRE_SIZE, 69); // 4 + 4 + 4 + 57 + assert_eq!(ENCRYPTED_MIN_SIZE, 32); // 16 + 16 + assert_eq!(COMMON_PREFIX_SIZE, 4); + assert_eq!(ESTABLISHED_HEADER_SIZE, 16); + assert_eq!(INNER_HEADER_SIZE, 5); + } + + #[test] + fn test_roundtrip_indices() { + let idx = SessionIndex::new(0xDEADBEEF); + + let msg1 = build_msg1(idx, &[0u8; HANDSHAKE_MSG1_SIZE]); + let parsed = Msg1Header::parse(&msg1).unwrap(); + assert_eq!(parsed.sender_idx.as_u32(), 0xDEADBEEF); + + // Verify little-endian encoding (sender_idx starts at offset 4) + assert_eq!(msg1[4], 0xEF); + assert_eq!(msg1[5], 0xBE); + assert_eq!(msg1[6], 0xAD); + assert_eq!(msg1[7], 0xDE); + } + + #[test] + fn test_inner_header_prepend_strip() { + let timestamp: u32 = 12345; + let original = vec![0x10, 0xAA, 0xBB]; // msg_type + payload + + let with_header = prepend_inner_header(timestamp, &original); + assert_eq!(with_header.len(), 4 + 3); // timestamp + original + + let (ts, rest) = strip_inner_header(&with_header).unwrap(); + assert_eq!(ts, 12345); + assert_eq!(rest, &original[..]); + } + + #[test] + fn test_inner_header_too_short() { + assert!(strip_inner_header(&[0, 0, 0, 0]).is_none()); // needs 5 bytes minimum + } + + #[test] + fn test_flags_byte() { + let header = + build_established_header(SessionIndex::new(1), 0, FLAG_KEY_EPOCH | FLAG_SP, 100); + assert_eq!(header[1], 0x05); // bits 0 and 2 set + + let parsed = EncryptedHeader::parse(&[ + header[0], header[1], header[2], header[3], header[4], header[5], header[6], header[7], + header[8], header[9], header[10], header[11], header[12], header[13], header[14], + header[15], // minimum: TAG_SIZE bytes of ciphertext + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ]) + .unwrap(); + assert_eq!(parsed.flags & FLAG_KEY_EPOCH, FLAG_KEY_EPOCH); + assert_eq!(parsed.flags & FLAG_CE, 0); + assert_eq!(parsed.flags & FLAG_SP, FLAG_SP); + } + + #[test] + fn test_payload_len_in_msg1() { + let packet = build_msg1(SessionIndex::new(1), &[0u8; HANDSHAKE_MSG1_SIZE]); + let prefix = CommonPrefix::parse(&packet).unwrap(); + // payload_len = sender_idx(4) + noise_msg1(106) = 110 + assert_eq!(prefix.payload_len, 110); + } + + #[test] + fn test_payload_len_in_msg2() { + let packet = build_msg2( + SessionIndex::new(1), + SessionIndex::new(2), + &[0u8; HANDSHAKE_MSG2_SIZE], + ); + let prefix = CommonPrefix::parse(&packet).unwrap(); + // payload_len = sender_idx(4) + receiver_idx(4) + noise_msg2(57) = 65 + assert_eq!(prefix.payload_len, 65); + } +} From 1c1ed0d93918a4b5edd9ee2c9c7bfddb5619ce13 Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Fri, 10 Jul 2026 16:52:18 +0000 Subject: [PATCH 2/2] Relocate PromotionResult into proto/fmp Move the PromotionResult enum (and its impl) out of peer::mod and into proto/fmp/core.rs, alongside the cross_connection_winner tie-break helper that was relocated the same way. This is FMP connection-lifecycle result vocabulary, so it belongs in the FMP subsystem home rather than the peer module. Behavior-neutral pure type relocation: consumers import it from crate::proto::fmp, and the crate-root public path crate::PromotionResult is preserved via a re-export in lib.rs (mirroring cross_connection_winner). Full lib suite green at baseline. --- src/lib.rs | 5 ++- src/node/handlers/handshake.rs | 4 +-- src/node/tests/session.rs | 2 +- src/node/tests/unit.rs | 2 +- src/peer/mod.rs | 61 +--------------------------------- src/proto/fmp/core.rs | 56 +++++++++++++++++++++++++++++++ src/proto/fmp/mod.rs | 2 +- 7 files changed, 64 insertions(+), 68 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 83ed616..47210f8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -88,13 +88,12 @@ pub use proto::fmp::HandshakeMessageType; // Re-export cache types pub use cache::{CacheEntry, CacheError, CacheStats, CoordCache}; -// Re-export FMP tie-break helper (relocated from peer:: to proto::fmp) -pub use proto::fmp::cross_connection_winner; +// Re-export FMP tie-break helper and promotion result (relocated from peer:: to proto::fmp) +pub use proto::fmp::{PromotionResult, cross_connection_winner}; // Re-export peer types pub use peer::{ ActivePeer, ConnectivityState, HandshakeState, PeerConnection, PeerError, PeerSlot, - PromotionResult, }; // Re-export node types diff --git a/src/node/handlers/handshake.rs b/src/node/handlers/handshake.rs index 189dca1..da8a40b 100644 --- a/src/node/handlers/handshake.rs +++ b/src/node/handlers/handshake.rs @@ -5,11 +5,11 @@ use crate::PeerIdentity; use crate::node::acl::PeerAclContext; use crate::node::reject::{HandshakeReject, RejectReason}; use crate::node::{Node, NodeError}; -use crate::peer::{ActivePeer, PeerConnection, PromotionResult}; +use crate::peer::{ActivePeer, PeerConnection}; use crate::proto::fmp::wire::{Msg1Header, Msg2Header, build_msg2}; use crate::proto::fmp::{ ConnAction, EstablishSnapshot, EstablishView, InboundDecision, InboundReject, OutboundDecision, - OutboundSnapshot, WireOutcome, cross_connection_winner, + OutboundSnapshot, PromotionResult, WireOutcome, cross_connection_winner, }; use crate::transport::{Link, LinkDirection, LinkId, ReceivedPacket}; use std::time::Duration; diff --git a/src/node/tests/session.rs b/src/node/tests/session.rs index b45297c..3915ea2 100644 --- a/src/node/tests/session.rs +++ b/src/node/tests/session.rs @@ -1002,7 +1002,7 @@ fn build_ipv6_packet( #[test] fn test_identity_cache_populated_on_promote() { - use crate::peer::PromotionResult; + use crate::proto::fmp::PromotionResult; let mut node = make_node(); let transport_id = TransportId::new(1); diff --git a/src/node/tests/unit.rs b/src/node/tests/unit.rs index 36ca670..9fe75ec 100644 --- a/src/node/tests/unit.rs +++ b/src/node/tests/unit.rs @@ -1,6 +1,6 @@ use super::*; use crate::nostr::{BootstrapEvent, NostrRendezvous}; -use crate::peer::PromotionResult; +use crate::proto::fmp::PromotionResult; use crate::transport::udp::UdpTransport; use crate::transport::{TransportHandle, packet_channel}; use std::sync::Arc; diff --git a/src/peer/mod.rs b/src/peer/mod.rs index 77dd871..07bcea1 100644 --- a/src/peer/mod.rs +++ b/src/peer/mod.rs @@ -59,66 +59,6 @@ pub enum PeerError { MaxPeersExceeded { max: usize }, } -// ============================================================================ -// Cross-Connection Handling -// ============================================================================ - -/// Result of attempting to promote a connection to active peer. -/// -/// When a handshake completes, we may discover that we already have a -/// connection to this peer (cross-connection). The tie-breaker rule -/// determines which connection survives. -/// -/// Note: Returns NodeAddr instead of ActivePeer because ActivePeer cannot -/// be cloned (it contains NoiseSession which has cryptographic state). -/// Callers can look up the peer from the peers map using the NodeAddr. -#[derive(Debug, Clone, Copy)] -pub enum PromotionResult { - /// New peer created successfully. - Promoted(NodeAddr), - - /// Cross-connection detected. This connection lost the tie-breaker - /// and should be closed. - CrossConnectionLost { - /// The link that won (existing connection). - winner_link_id: LinkId, - }, - - /// Cross-connection detected. This connection won the tie-breaker. - /// The existing connection was replaced. - CrossConnectionWon { - /// The link that lost (previous connection, now closed). - loser_link_id: LinkId, - /// The node ID of the peer. - node_addr: NodeAddr, - }, -} - -impl PromotionResult { - /// Get the node ID if promotion succeeded. - pub fn node_addr(&self) -> Option { - match self { - PromotionResult::Promoted(node_addr) => Some(*node_addr), - PromotionResult::CrossConnectionWon { node_addr, .. } => Some(*node_addr), - PromotionResult::CrossConnectionLost { .. } => None, - } - } - - /// Check if this connection should be closed. - pub fn should_close_this_connection(&self) -> bool { - matches!(self, PromotionResult::CrossConnectionLost { .. }) - } - - /// Get the link that should be closed, if any. - pub fn link_to_close(&self) -> Option { - match self { - PromotionResult::CrossConnectionLost { .. } => None, // Caller's link - PromotionResult::CrossConnectionWon { loser_link_id, .. } => Some(*loser_link_id), - PromotionResult::Promoted(_) => None, - } - } -} - // ============================================================================ // PeerSlot // ============================================================================ @@ -240,6 +180,7 @@ impl fmt::Display for PeerSlot { #[cfg(test)] mod tests { use super::*; + use crate::proto::fmp::PromotionResult; use crate::transport::LinkId; use crate::{Identity, PeerIdentity}; diff --git a/src/proto/fmp/core.rs b/src/proto/fmp/core.rs index dd7af64..5fa27d9 100644 --- a/src/proto/fmp/core.rs +++ b/src/proto/fmp/core.rs @@ -51,6 +51,62 @@ pub fn cross_connection_winner( } } +/// Result of attempting to promote a connection to active peer. +/// +/// When a handshake completes, we may discover that we already have a +/// connection to this peer (cross-connection). The tie-breaker rule +/// determines which connection survives. +/// +/// Note: Returns NodeAddr instead of ActivePeer because ActivePeer cannot +/// be cloned (it contains NoiseSession which has cryptographic state). +/// Callers can look up the peer from the peers map using the NodeAddr. +#[derive(Debug, Clone, Copy)] +pub enum PromotionResult { + /// New peer created successfully. + Promoted(NodeAddr), + + /// Cross-connection detected. This connection lost the tie-breaker + /// and should be closed. + CrossConnectionLost { + /// The link that won (existing connection). + winner_link_id: LinkId, + }, + + /// Cross-connection detected. This connection won the tie-breaker. + /// The existing connection was replaced. + CrossConnectionWon { + /// The link that lost (previous connection, now closed). + loser_link_id: LinkId, + /// The node ID of the peer. + node_addr: NodeAddr, + }, +} + +impl PromotionResult { + /// Get the node ID if promotion succeeded. + pub fn node_addr(&self) -> Option { + match self { + PromotionResult::Promoted(node_addr) => Some(*node_addr), + PromotionResult::CrossConnectionWon { node_addr, .. } => Some(*node_addr), + PromotionResult::CrossConnectionLost { .. } => None, + } + } + + /// Check if this connection should be closed. + pub fn should_close_this_connection(&self) -> bool { + matches!(self, PromotionResult::CrossConnectionLost { .. }) + } + + /// Get the link that should be closed, if any. + pub fn link_to_close(&self) -> Option { + match self { + PromotionResult::CrossConnectionLost { .. } => None, // Caller's link + PromotionResult::CrossConnectionWon { loser_link_id, .. } => Some(*loser_link_id), + PromotionResult::Promoted(_) => None, + } + } +} + /// A snapshot of one handshake connection's lifecycle-relevant state, taken by /// the shell so the core decides without touching live `Node` state or reading /// a clock. diff --git a/src/proto/fmp/mod.rs b/src/proto/fmp/mod.rs index dc58f0f..c719921 100644 --- a/src/proto/fmp/mod.rs +++ b/src/proto/fmp/mod.rs @@ -33,12 +33,12 @@ pub(crate) mod wire; #[cfg(test)] mod tests; -pub use core::cross_connection_winner; pub(crate) use core::{ ConnAction, ConnSnapshot, EstablishSnapshot, EstablishView, InboundDecision, InboundReject, LifecycleView, OutboundDecision, OutboundSnapshot, PeerSnapshot, RekeyCfg, RekeyResendSnapshot, WireOutcome, }; +pub use core::{PromotionResult, cross_connection_winner}; pub(crate) use limits::backoff_ms; pub use state::HandshakeState; pub(crate) use state::{ConnectionState, Fmp};