diff --git a/src/lib.rs b/src/lib.rs index 54618a2..c8724b1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -91,13 +91,12 @@ pub use proto::fmp::{NegotiationPayload, NodeProfile, TlvEntry}; // 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/decrypt_worker.rs b/src/node/decrypt_worker.rs index b27c220..d1f62de 100644 --- a/src/node/decrypt_worker.rs +++ b/src/node/decrypt_worker.rs @@ -560,7 +560,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. @@ -568,7 +568,7 @@ mod tests { // Header: fill the flags byte (the second byte) with both // FLAG_CE and FLAG_KEY_EPOCH 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_KEY_EPOCH; + let flags_byte = crate::proto::fmp::wire::FLAG_CE | crate::proto::fmp::wire::FLAG_KEY_EPOCH; let mut header = [0u8; HDR]; header[1] = flags_byte; wire.extend_from_slice(&header); @@ -625,11 +625,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_KEY_EPOCH != 0, + fallback.fmp_flags & crate::proto::fmp::wire::FLAG_KEY_EPOCH != 0, "FLAG_KEY_EPOCH bit lost on worker path" ); } @@ -723,7 +723,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 ca19f35..b5e2826 100644 --- a/src/node/handlers/encrypted.rs +++ b/src/node/handlers/encrypted.rs @@ -1,8 +1,8 @@ //! Encrypted frame handling (hot path). use crate::node::Node; -use crate::node::wire::{EncryptedHeader, FLAG_CE, FLAG_KEY_EPOCH, strip_inner_header}; use crate::noise::NoiseError; +use crate::proto::fmp::wire::{EncryptedHeader, FLAG_CE, FLAG_KEY_EPOCH, 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 6592caf..46105ee 100644 --- a/src/node/handlers/handshake.rs +++ b/src/node/handlers/handshake.rs @@ -8,12 +8,13 @@ use crate::PeerIdentity; use crate::node::acl::PeerAclContext; use crate::node::reject::{HandshakeReject, RejectReason}; -use crate::node::wire::{Msg1Header, Msg2Header, Msg3Header, build_msg2, build_msg3}; use crate::node::{Node, NodeError}; -use crate::peer::{ActivePeer, PeerConnection, PromotionResult}; +use crate::peer::{ActivePeer, PeerConnection}; +use crate::proto::fmp::wire::{Msg1Header, Msg2Header, Msg3Header, build_msg2, build_msg3}; use crate::proto::fmp::{ Disconnect, DisconnectReason, EstablishSnapshot, InboundDecision, InboundReject, - NegotiationPayload, WireOutcome, cross_connection_winner, decide_fmp_negotiation, + NegotiationPayload, PromotionResult, WireOutcome, cross_connection_winner, + decide_fmp_negotiation, }; use crate::transport::{Link, LinkDirection, LinkId, ReceivedPacket}; use std::time::Duration; diff --git a/src/node/handlers/rekey.rs b/src/node/handlers/rekey.rs index c9196f2..43540a8 100644 --- a/src/node/handlers/rekey.rs +++ b/src/node/handlers/rekey.rs @@ -8,8 +8,8 @@ use crate::NodeAddr; use crate::node::Node; use crate::node::reject::{HandshakeReject, RejectReason}; -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 57a6c86..f03bc3c 100644 --- a/src/node/handlers/rx_loop.rs +++ b/src/node/handlers/rx_loop.rs @@ -1,11 +1,11 @@ //! 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, PHASE_MSG3, }; -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 c39204c..e388fbf 100644 --- a/src/node/handlers/session.rs +++ b/src/node/handlers/session.rs @@ -9,11 +9,11 @@ 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, build_established_header}; use crate::node::{Node, NodeError}; use crate::noise::{HANDSHAKE_MSG1_SIZE, HANDSHAKE_MSG2_SIZE, HANDSHAKE_MSG3_SIZE, HandshakeState}; use crate::proto::fmp::NegotiationPayload; +#[cfg(unix)] +use crate::proto::fmp::wire::{ESTABLISHED_HEADER_SIZE, FLAG_KEY_EPOCH, 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 2b7eaab..b6c4eb8 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 5a88be6..ead9dd0 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; @@ -48,16 +47,16 @@ use self::reloadable::Reloadable; /// bounding that cross-connection branch below the rekey age floor so a /// rekey-aged msg3 falls through to the rekey-responder path. See CHANGELOG. pub(crate) const REKEY_JITTER_SECS: i64 = 15; -use self::wire::{ - ESTABLISHED_HEADER_SIZE, FLAG_CE, FLAG_KEY_EPOCH, 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::NodeProfile; +use crate::proto::fmp::wire::{ + ESTABLISHED_HEADER_SIZE, FLAG_CE, FLAG_KEY_EPOCH, 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 8a019b3..dae2d52 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_msg2; +use crate::proto::fmp::wire::build_msg2; use crate::upper::hosts::HostMap; use crate::utils::index::SessionIndex; use std::path::PathBuf; @@ -123,7 +123,7 @@ async fn test_outbound_msg2_denied_after_acl_reload() { async fn test_inbound_msg3_denied_triggers_disconnect() { use crate::config::UdpConfig; use crate::node::acl::PeerAclReloader; - use crate::node::wire::build_msg1; + use crate::proto::fmp::wire::build_msg1; use crate::transport::udp::UdpTransport; use crate::transport::{TransportHandle, packet_channel}; use tokio::time::{Duration, timeout}; diff --git a/src/node/tests/bootstrap.rs b/src/node/tests/bootstrap.rs index 04fba2f..aae96bb 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, PHASE_MSG3}; +use crate::proto::fmp::wire::{PHASE_MSG1, PHASE_MSG2, PHASE_MSG3}; use crate::transport::udp::UdpTransport; use crate::utils::index::IndexAllocator; use std::collections::HashMap; diff --git a/src/node/tests/handshake.rs b/src/node/tests/handshake.rs index 9b53641..2cf1b71 100644 --- a/src/node/tests/handshake.rs +++ b/src/node/tests/handshake.rs @@ -8,7 +8,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; @@ -268,7 +268,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; @@ -456,7 +456,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}; @@ -829,7 +829,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); @@ -887,7 +887,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( @@ -965,7 +965,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(); @@ -1066,7 +1066,7 @@ async fn test_full_leaf_accepted() { #[tokio::test] async fn test_xx_duplicate_msg1_resends_msg2() { - use crate::node::wire::build_msg1; + use crate::proto::fmp::wire::build_msg1; use crate::transport::ReceivedPacket; // Node B with NO transport — msg2 send silently skips (if let Some check), diff --git a/src/node/tests/session.rs b/src/node/tests/session.rs index 03f57e1..1babd75 100644 --- a/src/node/tests/session.rs +++ b/src/node/tests/session.rs @@ -999,7 +999,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/spanning_tree.rs b/src/node/tests/spanning_tree.rs index 22e26fa..5ce36ac 100644 --- a/src/node/tests/spanning_tree.rs +++ b/src/node/tests/spanning_tree.rs @@ -126,7 +126,7 @@ async fn make_test_node_inner(config: Config, 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(); @@ -288,7 +288,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, PHASE_MSG3, }; diff --git a/src/node/tests/unit.rs b/src/node/tests/unit.rs index 205b338..2d0d256 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; @@ -1799,7 +1799,7 @@ async fn drive_xx_handshake( packet_rx_a: &mut crate::transport::PacketRx, packet_rx_b: &mut crate::transport::PacketRx, ) -> NodeAddr { - use crate::node::wire::build_msg1; + use crate::proto::fmp::wire::build_msg1; use tokio::time::{Duration, timeout}; let remote_addr_b = TransportAddr::from_string(&addr_b.to_string()); diff --git a/src/node/wire.rs b/src/node/wire.rs deleted file mode 100644 index 0fe533b..0000000 --- a/src/node/wire.rs +++ /dev/null @@ -1,797 +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 XX msg1 | 41 bytes | Handshake initiation | -//! | 0x2 | Noise XX msg2 | 118+ bytes | Handshake response | -//! | 0x3 | Noise XX msg3 | 85+ bytes | Handshake completion | - -use crate::noise::{HANDSHAKE_MSG1_SIZE, HANDSHAKE_MSG2_SIZE, HANDSHAKE_MSG3_SIZE, TAG_SIZE}; -use crate::utils::index::SessionIndex; - -// ============================================================================ -// Constants -// ============================================================================ - -/// FMP protocol version (4 high bits of byte 0). -pub const FMP_VERSION: u8 = 1; - -/// Phase value for established (encrypted) frames. -pub const PHASE_ESTABLISHED: u8 = 0x0; - -/// Phase value for handshake message 1 (initiation). -pub const PHASE_MSG1: u8 = 0x1; - -/// Phase value for handshake message 2 (response). -pub const PHASE_MSG2: u8 = 0x2; - -/// Phase value for handshake message 3 (completion, XX only). -pub const PHASE_MSG3: u8 = 0x3; - -/// 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 handshake msg1 wire packet: prefix + sender_idx + noise_msg1. -pub const MSG1_WIRE_SIZE: usize = COMMON_PREFIX_SIZE + 4 + HANDSHAKE_MSG1_SIZE; // 41 bytes - -/// Minimum size of handshake msg2 wire packet: prefix + sender_idx + receiver_idx + noise_msg2. -/// Actual size may be larger due to optional negotiation payload. -pub const MSG2_WIRE_SIZE: usize = COMMON_PREFIX_SIZE + 4 + 4 + HANDSHAKE_MSG2_SIZE; // 118 bytes - -/// Minimum size of handshake msg3 wire packet: prefix + sender_idx + receiver_idx + noise_msg3. -/// Actual size may be larger due to optional negotiation payload. -pub const MSG3_WIRE_SIZE: usize = COMMON_PREFIX_SIZE + 4 + 4 + HANDSHAKE_MSG3_SIZE; // 85 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; - -// ============================================================================ -// 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 handshake message 1 header (phase 0x1). -/// -/// Wire format (41 bytes, Noise XX): -/// ```text -/// [0x11][0x00][payload_len:2 LE][sender_idx:4 LE][noise_msg1:33] -/// ``` -#[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 handshake message 2 header (phase 0x2). -/// -/// Wire format (118+ bytes, Noise XX): -/// ```text -/// [0x12][0x00][payload_len:2 LE][sender_idx:4 LE][receiver_idx:4 LE][noise_msg2:106+] -/// ``` -/// Size is variable due to optional negotiation payload appended after base XX msg2. -#[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 is too short or has wrong version/phase. - /// Accepts variable size (base + optional negotiation payload). - 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 (variable length). - #[cfg(test)] - pub fn noise_msg2<'a>(&self, data: &'a [u8]) -> &'a [u8] { - &data[self.noise_msg2_offset..] - } - - /// Get the total noise payload length (base + optional negotiation). - #[allow(dead_code)] - pub fn noise_payload_len(&self, data: &[u8]) -> usize { - data.len() - self.noise_msg2_offset - } -} - -// ============================================================================ -// Msg3 Header -// ============================================================================ - -/// Parsed handshake message 3 header (phase 0x3, XX pattern). -/// -/// Wire format (85+ bytes, Noise XX): -/// ```text -/// [0x13][0x00][payload_len:2 LE][sender_idx:4 LE][receiver_idx:4 LE][noise_msg3:73+] -/// ``` -/// Size is variable due to optional negotiation payload appended after base XX msg3. -#[derive(Clone, Debug)] -pub struct Msg3Header { - /// Session index chosen by the initiator (echo of msg1 sender_idx). - pub sender_idx: SessionIndex, - /// Echo of the responder's sender_idx from msg2. - pub receiver_idx: SessionIndex, - /// Offset where Noise msg3 payload begins. - pub noise_msg3_offset: usize, -} - -impl Msg3Header { - /// Parse a msg3 header from packet data. - /// - /// Returns None if the packet is too short or has wrong version/phase. - /// Accepts variable size (base + optional negotiation payload). - pub fn parse(data: &[u8]) -> Option { - if data.len() < MSG3_WIRE_SIZE { - return None; - } - - let version = data[0] >> 4; - let phase = data[0] & 0x0F; - - if version != FMP_VERSION || phase != PHASE_MSG3 { - 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_msg3_offset: COMMON_PREFIX_SIZE + 4 + 4, // 12 - }) - } - - /// Get the Noise msg3 payload from the original packet (variable length). - #[cfg(test)] - pub fn noise_msg3<'a>(&self, data: &'a [u8]) -> &'a [u8] { - &data[self.noise_msg3_offset..] - } - - /// Get the total noise payload length (base + optional negotiation). - #[allow(dead_code)] - pub fn noise_payload_len(&self, data: &[u8]) -> usize { - data.len() - self.noise_msg3_offset - } -} - -// ============================================================================ -// Serialization Helpers -// ============================================================================ - -/// Build a wire-format msg1 packet. -/// -/// Format: `[0x11][0x00][payload_len:2 LE][sender_idx:4 LE][noise_msg1:33]` -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: `[0x12][0x00][payload_len:2 LE][sender_idx:4 LE][receiver_idx:4 LE][noise_msg2:106+]` -/// The noise_msg2 may include an optional negotiation payload beyond the base XX msg2. -pub fn build_msg2( - sender_idx: SessionIndex, - receiver_idx: SessionIndex, - noise_msg2: &[u8], -) -> Vec { - debug_assert!(noise_msg2.len() >= HANDSHAKE_MSG2_SIZE); - - let payload_len = (4 + 4 + noise_msg2.len()) as u16; // sender + receiver + noise - let total = COMMON_PREFIX_SIZE + 4 + 4 + noise_msg2.len(); - - let mut packet = Vec::with_capacity(total); - 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 a wire-format msg3 packet (XX handshake completion). -/// -/// Format: `[0x13][0x00][payload_len:2 LE][sender_idx:4 LE][receiver_idx:4 LE][noise_msg3:73+]` -/// The noise_msg3 may include an optional negotiation payload beyond the base XX msg3. -pub fn build_msg3( - sender_idx: SessionIndex, - receiver_idx: SessionIndex, - noise_msg3: &[u8], -) -> Vec { - debug_assert!(noise_msg3.len() >= HANDSHAKE_MSG3_SIZE); - - let payload_len = (4 + 4 + noise_msg3.len()) as u16; // sender + receiver + noise - let total = COMMON_PREFIX_SIZE + 4 + 4 + noise_msg3.len(); - - let mut packet = Vec::with_capacity(total); - packet.push(CommonPrefix::ver_phase_byte(FMP_VERSION, PHASE_MSG3)); - 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_msg3); - 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 = [0x10, 0x02, 0x20, 0x00]; // ver=1, phase=0, flags=CE, payload_len=32 - let prefix = CommonPrefix::parse(&data).unwrap(); - assert_eq!(prefix.version, 1); - assert_eq!(prefix.phase, 0); - assert_eq!(prefix.flags, FLAG_CE); - 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], 0x10); // ver=1, 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] = 0x11; // ver=1, 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] = 0x00; // version 0 (old), 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], 0x11); // ver=1, 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![0x11; MSG1_WIRE_SIZE - 1]; - assert!(Msg1Header::parse(&packet).is_none()); - - let packet = vec![0x11; 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] = 0x12; // ver=1, 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], 0x12); // ver=1, 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![0x12; MSG2_WIRE_SIZE - 1]; - assert!(Msg2Header::parse(&packet).is_none()); - - // Larger than minimum is now accepted (variable-length negotiation payload) - let mut packet = vec![0x12; MSG2_WIRE_SIZE + 10]; - packet[0] = 0x12; // ver=1, phase=2 - packet[1] = 0x00; - let header = Msg2Header::parse(&packet); - assert!(header.is_some()); - } - - #[test] - fn test_msg2_header_wrong_phase() { - let mut packet = vec![0x00; MSG2_WIRE_SIZE]; - packet[0] = 0x10; // ver=1, phase 0, not phase 2 - assert!(Msg2Header::parse(&packet).is_none()); - } - - #[test] - fn test_wire_sizes() { - assert_eq!(MSG1_WIRE_SIZE, 41); // 4 + 4 + 33 (XX msg1) - assert_eq!(MSG2_WIRE_SIZE, 118); // 4 + 4 + 4 + 106 (XX msg2 minimum) - assert_eq!(MSG3_WIRE_SIZE, 85); // 4 + 4 + 4 + 73 (XX msg3 minimum) - 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_CE, 100); - assert_eq!(header[1], 0x03); // bits 0 and 1 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, FLAG_CE); - } - - #[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(33) = 37 - assert_eq!(prefix.payload_len, 37); - } - - #[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(106) = 114 - assert_eq!(prefix.payload_len, 114); - } - - #[test] - fn test_msg3_header_parse() { - let sender_idx = SessionIndex::new(0xAABBCCDD); - let receiver_idx = SessionIndex::new(0x11223344); - let noise_msg3 = vec![0xdd; HANDSHAKE_MSG3_SIZE]; - - let packet = build_msg3(sender_idx, receiver_idx, &noise_msg3); - - assert_eq!(packet.len(), MSG3_WIRE_SIZE); - assert_eq!(packet[0], 0x13); // ver=1, phase=3 - - let header = Msg3Header::parse(&packet).expect("should parse"); - assert_eq!(header.sender_idx, sender_idx); - assert_eq!(header.receiver_idx, receiver_idx); - assert_eq!(header.noise_msg3_offset, 12); - assert_eq!(header.noise_msg3(&packet), &noise_msg3[..]); - } - - #[test] - fn test_msg3_header_wrong_size() { - let packet = vec![0x13; MSG3_WIRE_SIZE - 1]; - assert!(Msg3Header::parse(&packet).is_none()); - - // Larger than minimum is now accepted (variable-length negotiation payload) - let mut packet = vec![0x13; MSG3_WIRE_SIZE + 10]; - packet[0] = 0x13; // ver=1, phase=3 - packet[1] = 0x00; - let header = Msg3Header::parse(&packet); - assert!(header.is_some()); - } - - #[test] - fn test_msg3_header_wrong_phase() { - let mut packet = vec![0x00; MSG3_WIRE_SIZE]; - packet[0] = 0x12; // ver=1, phase 2, not phase 3 - assert!(Msg3Header::parse(&packet).is_none()); - } - - #[test] - fn test_msg3_header_nonzero_flags() { - let mut packet = build_msg3( - SessionIndex::new(1), - SessionIndex::new(2), - &[0u8; HANDSHAKE_MSG3_SIZE], - ); - packet[1] = 0x01; // flags must be zero during handshake - assert!(Msg3Header::parse(&packet).is_none()); - } - - #[test] - fn test_payload_len_in_msg3() { - let packet = build_msg3( - SessionIndex::new(1), - SessionIndex::new(2), - &[0u8; HANDSHAKE_MSG3_SIZE], - ); - let prefix = CommonPrefix::parse(&packet).unwrap(); - // payload_len = sender_idx(4) + receiver_idx(4) + noise_msg3(73) = 81 - assert_eq!(prefix.payload_len, 81); - } -} 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 26d6200..2628a3e 100644 --- a/src/proto/fmp/core.rs +++ b/src/proto/fmp/core.rs @@ -54,6 +54,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 f6514e6..f259a04 100644 --- a/src/proto/fmp/mod.rs +++ b/src/proto/fmp/mod.rs @@ -24,20 +24,23 @@ //! (stateless) lifecycle anchor owned by `Node`. //! - `wire.rs` — the FMP wire codec: XX handshake message types, disconnect //! reasons, the orderly disconnect message, and the negotiation payload. +//! Also carries the relocated FMP link wire framing (moved from +//! `node/wire.rs`): the common prefix, encrypted/established headers, and +//! the msg1/msg2/msg3 handshake framing. mod core; mod limits; mod state; -mod wire; +pub(crate) mod wire; #[cfg(test)] mod tests; -pub use core::cross_connection_winner; pub(crate) use core::{ ConnAction, ConnSnapshot, EstablishSnapshot, InboundDecision, InboundReject, LifecycleView, PeerSnapshot, RekeyCfg, RekeyResendSnapshot, WireOutcome, decide_fmp_negotiation, }; +pub use core::{PromotionResult, cross_connection_winner}; pub(crate) use limits::backoff_ms; pub use state::HandshakeState; pub(crate) use state::{ConnectionState, Fmp}; diff --git a/src/proto/fmp/wire.rs b/src/proto/fmp/wire.rs index 8c56131..0f0e9a6 100644 --- a/src/proto/fmp/wire.rs +++ b/src/proto/fmp/wire.rs @@ -10,9 +10,11 @@ //! negotiation *decision* logic (version agreement, profile validation, FMP //! feature helpers) lives in `core.rs`; only the payload codec is here. +use crate::noise::{HANDSHAKE_MSG1_SIZE, HANDSHAKE_MSG2_SIZE, HANDSHAKE_MSG3_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. @@ -368,3 +370,798 @@ impl NegotiationPayload { }) } } + +// ============================================================================ +// 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 XX msg1 | 41 bytes | Handshake initiation | +// | 0x2 | Noise XX msg2 | 118+ bytes | Handshake response | +// | 0x3 | Noise XX msg3 | 85+ bytes | Handshake completion | + +// ============================================================================ +// Constants +// ============================================================================ + +/// FMP protocol version (4 high bits of byte 0). +pub const FMP_VERSION: u8 = 1; + +/// Phase value for established (encrypted) frames. +pub const PHASE_ESTABLISHED: u8 = 0x0; + +/// Phase value for handshake message 1 (initiation). +pub const PHASE_MSG1: u8 = 0x1; + +/// Phase value for handshake message 2 (response). +pub const PHASE_MSG2: u8 = 0x2; + +/// Phase value for handshake message 3 (completion, XX only). +pub const PHASE_MSG3: u8 = 0x3; + +/// 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 handshake msg1 wire packet: prefix + sender_idx + noise_msg1. +pub const MSG1_WIRE_SIZE: usize = COMMON_PREFIX_SIZE + 4 + HANDSHAKE_MSG1_SIZE; // 41 bytes + +/// Minimum size of handshake msg2 wire packet: prefix + sender_idx + receiver_idx + noise_msg2. +/// Actual size may be larger due to optional negotiation payload. +pub const MSG2_WIRE_SIZE: usize = COMMON_PREFIX_SIZE + 4 + 4 + HANDSHAKE_MSG2_SIZE; // 118 bytes + +/// Minimum size of handshake msg3 wire packet: prefix + sender_idx + receiver_idx + noise_msg3. +/// Actual size may be larger due to optional negotiation payload. +pub const MSG3_WIRE_SIZE: usize = COMMON_PREFIX_SIZE + 4 + 4 + HANDSHAKE_MSG3_SIZE; // 85 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; + +// ============================================================================ +// 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 handshake message 1 header (phase 0x1). +/// +/// Wire format (41 bytes, Noise XX): +/// ```text +/// [0x11][0x00][payload_len:2 LE][sender_idx:4 LE][noise_msg1:33] +/// ``` +#[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 handshake message 2 header (phase 0x2). +/// +/// Wire format (118+ bytes, Noise XX): +/// ```text +/// [0x12][0x00][payload_len:2 LE][sender_idx:4 LE][receiver_idx:4 LE][noise_msg2:106+] +/// ``` +/// Size is variable due to optional negotiation payload appended after base XX msg2. +#[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 is too short or has wrong version/phase. + /// Accepts variable size (base + optional negotiation payload). + 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 (variable length). + #[cfg(test)] + pub fn noise_msg2<'a>(&self, data: &'a [u8]) -> &'a [u8] { + &data[self.noise_msg2_offset..] + } + + /// Get the total noise payload length (base + optional negotiation). + #[allow(dead_code)] + pub fn noise_payload_len(&self, data: &[u8]) -> usize { + data.len() - self.noise_msg2_offset + } +} + +// ============================================================================ +// Msg3 Header +// ============================================================================ + +/// Parsed handshake message 3 header (phase 0x3, XX pattern). +/// +/// Wire format (85+ bytes, Noise XX): +/// ```text +/// [0x13][0x00][payload_len:2 LE][sender_idx:4 LE][receiver_idx:4 LE][noise_msg3:73+] +/// ``` +/// Size is variable due to optional negotiation payload appended after base XX msg3. +#[derive(Clone, Debug)] +pub struct Msg3Header { + /// Session index chosen by the initiator (echo of msg1 sender_idx). + pub sender_idx: SessionIndex, + /// Echo of the responder's sender_idx from msg2. + pub receiver_idx: SessionIndex, + /// Offset where Noise msg3 payload begins. + pub noise_msg3_offset: usize, +} + +impl Msg3Header { + /// Parse a msg3 header from packet data. + /// + /// Returns None if the packet is too short or has wrong version/phase. + /// Accepts variable size (base + optional negotiation payload). + pub fn parse(data: &[u8]) -> Option { + if data.len() < MSG3_WIRE_SIZE { + return None; + } + + let version = data[0] >> 4; + let phase = data[0] & 0x0F; + + if version != FMP_VERSION || phase != PHASE_MSG3 { + 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_msg3_offset: COMMON_PREFIX_SIZE + 4 + 4, // 12 + }) + } + + /// Get the Noise msg3 payload from the original packet (variable length). + #[cfg(test)] + pub fn noise_msg3<'a>(&self, data: &'a [u8]) -> &'a [u8] { + &data[self.noise_msg3_offset..] + } + + /// Get the total noise payload length (base + optional negotiation). + #[allow(dead_code)] + pub fn noise_payload_len(&self, data: &[u8]) -> usize { + data.len() - self.noise_msg3_offset + } +} + +// ============================================================================ +// Serialization Helpers +// ============================================================================ + +/// Build a wire-format msg1 packet. +/// +/// Format: `[0x11][0x00][payload_len:2 LE][sender_idx:4 LE][noise_msg1:33]` +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: `[0x12][0x00][payload_len:2 LE][sender_idx:4 LE][receiver_idx:4 LE][noise_msg2:106+]` +/// The noise_msg2 may include an optional negotiation payload beyond the base XX msg2. +pub fn build_msg2( + sender_idx: SessionIndex, + receiver_idx: SessionIndex, + noise_msg2: &[u8], +) -> Vec { + debug_assert!(noise_msg2.len() >= HANDSHAKE_MSG2_SIZE); + + let payload_len = (4 + 4 + noise_msg2.len()) as u16; // sender + receiver + noise + let total = COMMON_PREFIX_SIZE + 4 + 4 + noise_msg2.len(); + + let mut packet = Vec::with_capacity(total); + 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 a wire-format msg3 packet (XX handshake completion). +/// +/// Format: `[0x13][0x00][payload_len:2 LE][sender_idx:4 LE][receiver_idx:4 LE][noise_msg3:73+]` +/// The noise_msg3 may include an optional negotiation payload beyond the base XX msg3. +pub fn build_msg3( + sender_idx: SessionIndex, + receiver_idx: SessionIndex, + noise_msg3: &[u8], +) -> Vec { + debug_assert!(noise_msg3.len() >= HANDSHAKE_MSG3_SIZE); + + let payload_len = (4 + 4 + noise_msg3.len()) as u16; // sender + receiver + noise + let total = COMMON_PREFIX_SIZE + 4 + 4 + noise_msg3.len(); + + let mut packet = Vec::with_capacity(total); + packet.push(CommonPrefix::ver_phase_byte(FMP_VERSION, PHASE_MSG3)); + 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_msg3); + 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 = [0x10, 0x02, 0x20, 0x00]; // ver=1, phase=0, flags=CE, payload_len=32 + let prefix = CommonPrefix::parse(&data).unwrap(); + assert_eq!(prefix.version, 1); + assert_eq!(prefix.phase, 0); + assert_eq!(prefix.flags, FLAG_CE); + 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], 0x10); // ver=1, 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] = 0x11; // ver=1, 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] = 0x00; // version 0 (old), 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], 0x11); // ver=1, 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![0x11; MSG1_WIRE_SIZE - 1]; + assert!(Msg1Header::parse(&packet).is_none()); + + let packet = vec![0x11; 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] = 0x12; // ver=1, 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], 0x12); // ver=1, 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![0x12; MSG2_WIRE_SIZE - 1]; + assert!(Msg2Header::parse(&packet).is_none()); + + // Larger than minimum is now accepted (variable-length negotiation payload) + let mut packet = vec![0x12; MSG2_WIRE_SIZE + 10]; + packet[0] = 0x12; // ver=1, phase=2 + packet[1] = 0x00; + let header = Msg2Header::parse(&packet); + assert!(header.is_some()); + } + + #[test] + fn test_msg2_header_wrong_phase() { + let mut packet = vec![0x00; MSG2_WIRE_SIZE]; + packet[0] = 0x10; // ver=1, phase 0, not phase 2 + assert!(Msg2Header::parse(&packet).is_none()); + } + + #[test] + fn test_wire_sizes() { + assert_eq!(MSG1_WIRE_SIZE, 41); // 4 + 4 + 33 (XX msg1) + assert_eq!(MSG2_WIRE_SIZE, 118); // 4 + 4 + 4 + 106 (XX msg2 minimum) + assert_eq!(MSG3_WIRE_SIZE, 85); // 4 + 4 + 4 + 73 (XX msg3 minimum) + 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_CE, 100); + assert_eq!(header[1], 0x03); // bits 0 and 1 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, FLAG_CE); + } + + #[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(33) = 37 + assert_eq!(prefix.payload_len, 37); + } + + #[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(106) = 114 + assert_eq!(prefix.payload_len, 114); + } + + #[test] + fn test_msg3_header_parse() { + let sender_idx = SessionIndex::new(0xAABBCCDD); + let receiver_idx = SessionIndex::new(0x11223344); + let noise_msg3 = vec![0xdd; HANDSHAKE_MSG3_SIZE]; + + let packet = build_msg3(sender_idx, receiver_idx, &noise_msg3); + + assert_eq!(packet.len(), MSG3_WIRE_SIZE); + assert_eq!(packet[0], 0x13); // ver=1, phase=3 + + let header = Msg3Header::parse(&packet).expect("should parse"); + assert_eq!(header.sender_idx, sender_idx); + assert_eq!(header.receiver_idx, receiver_idx); + assert_eq!(header.noise_msg3_offset, 12); + assert_eq!(header.noise_msg3(&packet), &noise_msg3[..]); + } + + #[test] + fn test_msg3_header_wrong_size() { + let packet = vec![0x13; MSG3_WIRE_SIZE - 1]; + assert!(Msg3Header::parse(&packet).is_none()); + + // Larger than minimum is now accepted (variable-length negotiation payload) + let mut packet = vec![0x13; MSG3_WIRE_SIZE + 10]; + packet[0] = 0x13; // ver=1, phase=3 + packet[1] = 0x00; + let header = Msg3Header::parse(&packet); + assert!(header.is_some()); + } + + #[test] + fn test_msg3_header_wrong_phase() { + let mut packet = vec![0x00; MSG3_WIRE_SIZE]; + packet[0] = 0x12; // ver=1, phase 2, not phase 3 + assert!(Msg3Header::parse(&packet).is_none()); + } + + #[test] + fn test_msg3_header_nonzero_flags() { + let mut packet = build_msg3( + SessionIndex::new(1), + SessionIndex::new(2), + &[0u8; HANDSHAKE_MSG3_SIZE], + ); + packet[1] = 0x01; // flags must be zero during handshake + assert!(Msg3Header::parse(&packet).is_none()); + } + + #[test] + fn test_payload_len_in_msg3() { + let packet = build_msg3( + SessionIndex::new(1), + SessionIndex::new(2), + &[0u8; HANDSHAKE_MSG3_SIZE], + ); + let prefix = CommonPrefix::parse(&packet).unwrap(); + // payload_len = sender_idx(4) + receiver_idx(4) + noise_msg3(73) = 81 + assert_eq!(prefix.payload_len, 81); + } +}