diff --git a/src/lib.rs b/src/lib.rs index 61efc19..9c1ea15 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,7 +22,6 @@ pub mod noise; pub mod peer; pub mod perf_profile; pub(crate) mod proto; -pub mod protocol; #[cfg(test)] pub(crate) mod testutil; pub mod transport; @@ -57,11 +56,14 @@ pub use transport::{ TransportState, TransportType, packet_channel, }; -// Re-export protocol types -pub use protocol::{ - LinkMessageType, ProtocolError, SessionAck, SessionDatagram, SessionFlags, SessionMessageType, - SessionSetup, -}; +// Re-export link-layer types (relocated from protocol:: to proto::link) +pub use proto::link::{LinkMessageType, SessionDatagram}; + +// Re-export the shared protocol error (relocated from protocol:: to proto::Error) +pub use proto::Error; + +// Re-export FSP session wire types (relocated from protocol:: to proto::fsp) +pub use proto::fsp::{SessionAck, SessionFlags, SessionMessageType, SessionSetup}; // Re-export STP wire types (relocated from protocol:: to proto::stp) pub use proto::stp::TreeAnnounce; diff --git a/src/node/encrypt_worker.rs b/src/node/encrypt_worker.rs index d27ebfb..dc0fe86 100644 --- a/src/node/encrypt_worker.rs +++ b/src/node/encrypt_worker.rs @@ -50,8 +50,8 @@ // warnings rather than gate every function individually. #![cfg_attr(not(unix), allow(dead_code))] -use crate::node::session_wire::FSP_HEADER_SIZE; use crate::node::wire::ESTABLISHED_HEADER_SIZE; +use crate::proto::fsp::wire::FSP_HEADER_SIZE; use crate::transport::udp::socket::AsyncUdpSocket; #[cfg(not(target_os = "macos"))] use crossbeam_channel::{Receiver, SendError, Sender, TrySendError, bounded}; @@ -1904,10 +1904,12 @@ mod unix_tests { #[test] fn pipelined_send_wire_layout_roundtrips_canonical_decoders() { use crate::NodeAddr; - use crate::node::session_wire::build_fsp_header; use crate::node::wire::{EncryptedHeader, FLAG_KEY_EPOCH, build_established_header}; use crate::noise::TAG_SIZE; - use crate::protocol::{LinkMessageType, SESSION_DATAGRAM_HEADER_SIZE, SessionDatagramRef}; + use crate::proto::fsp::wire::build_fsp_header; + use crate::proto::link::{ + LinkMessageType, SESSION_DATAGRAM_HEADER_SIZE, SessionDatagramRef, + }; use crate::utils::index::SessionIndex; let rt = tokio::runtime::Builder::new_current_thread() diff --git a/src/node/handlers/forwarding.rs b/src/node/handlers/forwarding.rs index 7dd4e1c..859339a 100644 --- a/src/node/handlers/forwarding.rs +++ b/src/node/handlers/forwarding.rs @@ -7,13 +7,14 @@ use crate::NodeAddr; use crate::node::reject::ForwardingReject; -use crate::node::session_wire::{ +use crate::node::{Node, NodeError, NodeRoutingView}; +use crate::proto::fsp::wire::{ FSP_COMMON_PREFIX_SIZE, FSP_HEADER_SIZE, FSP_PHASE_ESTABLISHED, FSP_PHASE_MSG1, FSP_PHASE_MSG2, FspCommonPrefix, parse_encrypted_coords, }; -use crate::node::{Node, NodeError, NodeRoutingView}; +use crate::proto::fsp::{SessionAck, SessionSetup}; +use crate::proto::link::{SessionDatagram, SessionDatagramRef}; use crate::proto::routing::{DropReason, NextHop, RouteAction, RouteOutcome}; -use crate::protocol::{SessionAck, SessionDatagram, SessionDatagramRef, SessionSetup}; use std::time::{Duration, Instant}; use tracing::{debug, warn}; diff --git a/src/node/handlers/mmp.rs b/src/node/handlers/mmp.rs index 0d78d75..9362c6c 100644 --- a/src/node/handlers/mmp.rs +++ b/src/node/handlers/mmp.rs @@ -8,12 +8,11 @@ use crate::NodeAddr; use crate::node::Node; use crate::node::reject::{MmpReject, RejectReason, TreeReject}; use crate::node::tree::sign_declaration; +use crate::proto::link::LinkMessageType; use crate::proto::mmp::{ - BackoffUpdate, LinkReportKind, LinkReportSnapshot, MmpAction, MmpSessionState, - PathMtuNotification, PeerLivenessSnapshot, ReceiverReport, RrLog, SendResult, SenderReport, - SessionReceiverReport, SessionReportKind, SessionReportSnapshot, SessionSenderReport, + LinkReportKind, LinkReportSnapshot, MmpAction, PeerLivenessSnapshot, ReceiverReport, RrLog, + SenderReport, }; -use crate::protocol::{LinkMessageType, SessionMessageType}; use std::time::{Duration, Instant}; use tracing::{debug, info, trace, warn}; @@ -58,7 +57,7 @@ pub(super) fn log_rr_outcome(rr: &ReceiverReport, our_timestamp_ms: u32, log: Rr } /// Format bytes/sec as human-readable throughput. -fn format_throughput(bps: f64) -> String { +pub(in crate::node) fn format_throughput(bps: f64) -> String { if bps == 0.0 { "n/a".to_string() } else if bps >= 1_000_000.0 { @@ -418,223 +417,6 @@ impl Node { ); } - // === Session-layer MMP === - - /// Check all sessions for pending MMP reports and send them. - /// - /// Called from the tick handler. Also emits periodic session MMP logs. - /// Uses the collect-then-send pattern to avoid borrowing conflicts. - pub(in crate::node) async fn check_session_mmp_reports(&mut self) { - let now_ms = crate::mmp::mono_ms(); - - // Build one report-gating snapshot per session, resolving every timing - // read shell-side into a `bool`. The snapshots own only - // `NodeAddr`/`MmpMode`/`bool`, so the session-iteration borrow is released - // before the pure decision runs and the driving loop mutates the - // reporting state / performs the sends. - let snapshots: Vec = self - .sessions - .iter() - .filter_map(|(dest_addr, entry)| { - let mmp = entry.mmp()?; - Some(SessionReportSnapshot { - dest: *dest_addr, - mode: mmp.mode(), - sr_due: mmp.sender.should_send_report(now_ms), - rr_due: mmp.receiver.should_send_report(now_ms), - mtu_due: mmp.path_mtu.should_send_notification(now_ms), - log_due: mmp.should_log(now_ms), - }) - }) - .collect(); - - let actions = self.mmp.plan_session_reports(&snapshots); - - // Drive the planned actions in phase-grouped order (all logs, then the - // sends in per-session SR/RR/MTU order). Logs run first because the - // session operator log reads cumulative_packets_sent, which each send - // advances (send_session_msg -> sender.record_sent); the pre-refactor - // handler logged during its collect pass, before any send. Each build - // (`build_report`/`build_notification`, which advance interval/ - // notification state) runs only on its SendSessionReport action, exactly - // as the pre-refactor collect pass did. Per-destination success/failure - // is collected for the backoff dedup + failure-log suppression. - let mut send_results: Vec = Vec::new(); - for action in actions { - match action { - MmpAction::LogSession { dest } => { - // Resolve the display name exactly as the pre-refactor loop - // did (alias, else short_npub from the session's remote key). - let session_name = self.peer_aliases.get(&dest).cloned().unwrap_or_else(|| { - self.sessions - .get(&dest) - .map(|entry| { - let (xonly, _) = entry.remote_pubkey().x_only_public_key(); - crate::PeerIdentity::from_pubkey(xonly).short_npub() - }) - .unwrap_or_default() - }); - if let Some(mmp) = self.sessions.get_mut(&dest).and_then(|e| e.mmp_mut()) { - Self::log_session_mmp_metrics(&session_name, mmp); - mmp.mark_logged(now_ms); - } - } - MmpAction::SendSessionReport { dest, kind } => { - let built = self - .sessions - .get_mut(&dest) - .and_then(|entry| entry.mmp_mut()) - .and_then(|mmp| match kind { - SessionReportKind::Sender => { - mmp.sender.build_report(now_ms).map(|sr| { - ( - SessionMessageType::SenderReport.to_byte(), - SessionSenderReport::from(&sr).encode(), - ) - }) - } - SessionReportKind::Receiver => { - mmp.receiver.build_report(now_ms).map(|rr| { - ( - SessionMessageType::ReceiverReport.to_byte(), - SessionReceiverReport::from(&rr).encode(), - ) - }) - } - SessionReportKind::PathMtu => { - mmp.path_mtu.build_notification(now_ms).map(|mtu_value| { - ( - SessionMessageType::PathMtuNotification.to_byte(), - PathMtuNotification::new(mtu_value).encode(), - ) - }) - } - }); - - let Some((msg_type, body)) = built else { - continue; - }; - - match self.send_session_msg(&dest, msg_type, &body).await { - Ok(()) => send_results.push(SendResult { dest, ok: true }), - Err(e) => { - // Peek at current failure count for log suppression - // (unchanged by the backoff apply, which runs later). - let failures = self - .sessions - .get(&dest) - .and_then(|entry| entry.mmp()) - .map(|mmp| mmp.sender.consecutive_send_failures()) - .unwrap_or(0); - - if failures < 3 { - debug!( - dest = %self.peer_display_name(&dest), - msg_type, - error = %e, - "Failed to send session MMP report" - ); - } else if failures == 3 { - debug!( - dest = %self.peer_display_name(&dest), - "Suppressing further session MMP send failure logs" - ); - } - // failures > 3: silently suppressed - - send_results.push(SendResult { dest, ok: false }); - } - } - } - MmpAction::ReapPeer { .. } - | MmpAction::Heartbeat { .. } - | MmpAction::SendLinkReport { .. } - | MmpAction::LogLink { .. } => {} - } - } - - // Deduplicate send results per destination (any-ok -> success, all-fail - // -> failure) and apply the backoff state transition for each dest. - for update in self.mmp.plan_backoff(&send_results) { - match update { - BackoffUpdate::Success { dest } => { - if let Some(mmp) = self.sessions.get_mut(&dest).and_then(|e| e.mmp_mut()) { - let prev = mmp.sender.record_send_success(); - if prev > 3 { - debug!( - dest = %self.peer_display_name(&dest), - consecutive_failures = prev, - "Resumed session MMP reporting" - ); - } - } - } - BackoffUpdate::Failure { dest } => { - if let Some(mmp) = self.sessions.get_mut(&dest).and_then(|e| e.mmp_mut()) { - mmp.sender.record_send_failure(); - } - } - } - } - } - - /// Emit periodic session MMP metrics. - fn log_session_mmp_metrics(session_name: &str, mmp: &MmpSessionState) { - let m = &mmp.metrics; - - let rtt_str = if m.rtt_trend.initialized() { - format!("{:.1}ms", m.rtt_trend.long() / 1000.0) - } else { - "n/a".to_string() - }; - let loss_str = if m.loss_trend.initialized() { - format!("{:.1}%", m.loss_trend.long() * 100.0) - } else { - "n/a".to_string() - }; - let jitter_ms = mmp.receiver.jitter_us() as f64 / 1000.0; - - debug!( - session = %session_name, - rtt = %rtt_str, - loss = %loss_str, - jitter = format_args!("{:.1}ms", jitter_ms), - goodput = %format_throughput(m.goodput_bps()), - mtu = mmp.path_mtu.last_observed_mtu(), - tx_pkts = mmp.sender.cumulative_packets_sent(), - rx_pkts = mmp.receiver.cumulative_packets_recv(), - "MMP session metrics" - ); - } - - /// Emit a teardown log summarizing lifetime session MMP metrics. - pub(in crate::node) fn log_session_mmp_teardown(session_name: &str, mmp: &MmpSessionState) { - let m = &mmp.metrics; - let jitter_ms = mmp.receiver.jitter_us() as f64 / 1000.0; - - let rtt_str = match m.srtt_ms() { - Some(rtt) => format!("{:.1}ms", rtt), - None => "n/a".to_string(), - }; - let loss_str = format!("{:.1}%", m.loss_rate() * 100.0); - - debug!( - session = %session_name, - rtt = %rtt_str, - loss = %loss_str, - jitter = format_args!("{:.1}ms", jitter_ms), - etx = format_args!("{:.2}", m.etx), - goodput = %format_throughput(m.goodput_bps()), - send_mtu = mmp.path_mtu.current_mtu(), - observed_mtu = mmp.path_mtu.last_observed_mtu(), - tx_pkts = mmp.sender.cumulative_packets_sent(), - tx_bytes = mmp.sender.cumulative_bytes_sent(), - rx_pkts = mmp.receiver.cumulative_packets_recv(), - rx_bytes = mmp.receiver.cumulative_bytes_recv(), - "MMP session teardown" - ); - } - /// Send heartbeats and remove dead peers. /// /// Called from the tick handler. Sends a 1-byte heartbeat to each peer diff --git a/src/node/handlers/rekey.rs b/src/node/handlers/rekey.rs index 04f672c..b3cb413 100644 --- a/src/node/handlers/rekey.rs +++ b/src/node/handlers/rekey.rs @@ -10,26 +10,22 @@ use crate::node::Node; use crate::node::wire::build_msg1; use crate::noise::HandshakeState; use crate::proto::fmp::{ConnAction, LifecycleView, PeerSnapshot, RekeyCfg, RekeyResendSnapshot}; -use crate::protocol::{SessionDatagram, SessionSetup}; +use crate::proto::fsp::{ + FspAction, RekeyMsg3ResendSnapshot, SessionSetup, SessionSnapshot, cutover_timer_elapsed, +}; +use crate::proto::link::SessionDatagram; use tracing::{debug, trace, warn}; /// Keep previous session alive for this long after cutover. +/// +/// FMP-scoped copy for `check_rekey`; the FSP session-rekey timing bounds live +/// in `crate::proto::fsp::limits`. const DRAIN_WINDOW_SECS: u64 = 10; /// Suppress local rekey initiation for this long after receiving -/// a peer's rekey msg1. +/// a peer's rekey msg1. FMP-scoped copy for `check_rekey`. const REKEY_DAMPENING_SECS: u64 = 30; -/// Liveness bound on how long the FSP rekey initiator holds the -/// `current` + `pending` state before cutting over to the new epoch. -/// -/// This is NOT safety-critical: overlapping-epoch trial-decrypt covers -/// any skew between the two endpoints' cutovers. The timer only bounds -/// how long the initiator advertises the old K-bit. An opportunistic -/// early cutover also fires if the initiator authenticates a peer frame -/// against its own `pending` session (the responder cut over first). -const FSP_CUTOVER_DELAY_MS: u64 = 2000; - impl Node { /// Periodic rekey check. Called from the tick loop. /// @@ -358,66 +354,77 @@ impl Node { let ttl = self.config().node.session.default_ttl; let my_addr = *self.node_addr(); - // Collect rekey initiators whose msg3 retransmission is due. - let mut to_resend: Vec<(NodeAddr, Vec)> = Vec::new(); - let mut to_abandon: Vec = Vec::new(); - - for (node_addr, entry) in &self.sessions { - // Only the rekey initiator retains a msg3 payload. - let payload = match entry.rekey_msg3_payload() { - Some(p) => p, - None => continue, - }; - if entry.rekey_msg3_next_resend_ms() == 0 || now_ms < entry.rekey_msg3_next_resend_ms() - { - continue; - } - if entry.rekey_msg3_resend_count() >= max_resends { - to_abandon.push(*node_addr); - continue; - } - to_resend.push((*node_addr, payload.to_vec())); - } - - // Abandon rekey cycles that exhausted their retransmission budget. - for node_addr in to_abandon { - if let Some(entry) = self.sessions.get_mut(&node_addr) { - entry.abandon_rekey(); - } - debug!( - peer = %self.peer_display_name(&node_addr), - "FSP rekey aborted: msg3 unconfirmed after max retransmissions, abandoning cycle" - ); - } - - // Retransmit msg3 for cycles still within budget. - for (node_addr, payload) in to_resend { - let mut datagram = SessionDatagram::new(my_addr, node_addr, payload).with_ttl(ttl); - let sent = match self.send_session_datagram(&mut datagram).await { - Ok(_) => true, - Err(e) => { + // The shell snapshots each session retaining a msg3 payload (resend-due + // predicate resolved here); the core classifies abandon-vs-resend, + // abandons first. + let candidates = self.rekey_msg3_resend_snapshots(now_ms); + for action in self.fsp.poll_rekey_msg3_resends(candidates, max_resends) { + match action { + FspAction::AbandonRekey { addr } => { + if let Some(entry) = self.sessions.get_mut(&addr) { + entry.abandon_rekey(); + } debug!( - peer = %self.peer_display_name(&node_addr), - error = %e, - "FSP rekey msg3 retransmission failed" + peer = %self.peer_display_name(&addr), + "FSP rekey aborted: msg3 unconfirmed after max retransmissions, abandoning cycle" ); - false } - }; + FspAction::ResendSessionMsg3 { addr } => { + let payload = match self + .sessions + .get(&addr) + .and_then(|e| e.rekey_msg3_payload()) + { + Some(p) => p.to_vec(), + None => continue, + }; + let mut datagram = SessionDatagram::new(my_addr, addr, payload).with_ttl(ttl); + let sent = match self.send_session_datagram(&mut datagram).await { + Ok(_) => true, + Err(e) => { + debug!( + peer = %self.peer_display_name(&addr), + error = %e, + "FSP rekey msg3 retransmission failed" + ); + false + } + }; - if sent && let Some(entry) = self.sessions.get_mut(&node_addr) { - let count = entry.rekey_msg3_resend_count() + 1; - let next = now_ms + (interval_ms as f64 * backoff.powi(count as i32)) as u64; - entry.record_rekey_msg3_resend(next); - trace!( - peer = %self.peer_display_name(&node_addr), - resend = count, - "Resent FSP rekey msg3" - ); + if sent && let Some(entry) = self.sessions.get_mut(&addr) { + let count = entry.rekey_msg3_resend_count() + 1; + let next = + now_ms + (interval_ms as f64 * backoff.powi(count as i32)) as u64; + entry.record_rekey_msg3_resend(next); + trace!( + peer = %self.peer_display_name(&addr), + resend = count, + "Resent FSP rekey msg3" + ); + } + } + #[allow(unreachable_patterns)] + _ => {} } } } + /// Snapshot every session retaining a rekey-msg3 payload for the + /// retransmission decision, pre-evaluating the resend-due predicate against + /// `now_ms` so the core reads no clock. + fn rekey_msg3_resend_snapshots(&self, now_ms: u64) -> Vec { + self.sessions + .iter() + .filter(|(_, entry)| entry.rekey_msg3_payload().is_some()) + .map(|(node_addr, entry)| RekeyMsg3ResendSnapshot { + addr: *node_addr, + resend_count: entry.rekey_msg3_resend_count(), + resend_due: entry.rekey_msg3_next_resend_ms() != 0 + && now_ms >= entry.rekey_msg3_next_resend_ms(), + }) + .collect() + } + /// Periodic session (FSP) rekey check. Called from the tick loop. /// /// For each established session: @@ -435,100 +442,71 @@ impl Node { return; } - let rekey_after_secs = self.config().node.rekey.after_secs; - let rekey_after_messages = self.config().node.rekey.after_messages; + let cfg = crate::proto::fsp::RekeyCfg { + after_secs: self.config().node.rekey.after_secs, + after_messages: self.config().node.rekey.after_messages, + }; let now_ms = Self::now_ms(); - let drain_ms = DRAIN_WINDOW_SECS * 1000; - let dampening_ms = REKEY_DAMPENING_SECS * 1000; - let mut sessions_to_cutover: Vec = Vec::new(); - let mut sessions_to_drain: Vec = Vec::new(); - let mut sessions_to_rekey: Vec = Vec::new(); - - for (node_addr, entry) in &self.sessions { - if !entry.is_established() { - continue; - } - - // 1. Initiator-side cutover (option A): completed rekey, - // pending session ready, liveness timer elapsed. This is - // an unconditional timer, NOT gated on responder progress — - // overlapping-epoch trial-decrypt covers the cutover skew, - // so flipping the K-bit here is always safe. An - // opportunistic early cutover also happens in - // `handle_encrypted_session_msg` if the initiator - // authenticates a peer frame against its own `pending`. - if entry.pending_new_session().is_some() - && !entry.has_rekey_in_progress() - && entry.is_rekey_initiator() - && now_ms.saturating_sub(entry.rekey_completed_ms()) >= FSP_CUTOVER_DELAY_MS - { - sessions_to_cutover.push(*node_addr); - continue; - } - - // 2. Drain window expiry - if entry.is_draining() && entry.drain_expired(now_ms, drain_ms) { - sessions_to_drain.push(*node_addr); - } - - // 3. Rekey trigger - if entry.has_rekey_in_progress() { - continue; - } - if entry.pending_new_session().is_some() { - continue; // Pending session present, awaiting cutover - } - if entry.rekey_msg3_payload().is_some() { - // Initiator already cut over on its liveness timer but is - // still retransmitting msg3 to a responder not yet - // confirmed on the new epoch. Don't start another rekey - // until the current cycle's msg3 is delivered or abandoned. - continue; - } - if entry.is_rekey_dampened(now_ms, dampening_ms) { - continue; - } - - let elapsed_secs = now_ms.saturating_sub(entry.session_start_ms()) / 1000; - let counter = entry.send_counter(); - - // Apply per-session symmetric jitter to desynchronize - // dual-initiation in symmetric-start meshes. - let effective_after_secs = - rekey_after_secs.saturating_add_signed(entry.rekey_jitter_secs()); - if elapsed_secs >= effective_after_secs || counter >= rekey_after_messages { - sessions_to_rekey.push(*node_addr); + // The shell snapshots each established session's rekey ages/flags + // (every clock read resolved here); the core decides + // cutover/drain/trigger with no clock, phase-grouped to preserve the + // pre-refactor execution order. + let snapshots = self.session_rekey_snapshots(now_ms); + for action in self.fsp.poll_rekey(snapshots, &cfg) { + match action { + FspAction::CutOver { addr } => { + if let Some(entry) = self.sessions.get_mut(&addr) + && entry.cutover_to_new_session(now_ms) + { + debug!( + peer = %self.peer_display_name(&addr), + "FSP rekey cutover complete (initiator), K-bit flipped" + ); + } + } + FspAction::CompleteDrain { addr } => { + if let Some(entry) = self.sessions.get_mut(&addr) { + entry.complete_drain(); + trace!( + peer = %self.peer_display_name(&addr), + "FSP drain complete, previous session erased" + ); + } + } + FspAction::InitiateRekey { addr } => { + self.initiate_session_rekey(&addr).await; + } + #[allow(unreachable_patterns)] + _ => {} } } + } - // Execute cutover for initiator side - for node_addr in sessions_to_cutover { - if let Some(entry) = self.sessions.get_mut(&node_addr) - && entry.cutover_to_new_session(now_ms) - { - debug!( - peer = %self.peer_display_name(&node_addr), - "FSP rekey cutover complete (initiator), K-bit flipped" - ); - } - } - - // Execute drain completion - for node_addr in sessions_to_drain { - if let Some(entry) = self.sessions.get_mut(&node_addr) { - entry.complete_drain(); - trace!( - peer = %self.peer_display_name(&node_addr), - "FSP drain complete, previous session erased" - ); - } - } - - // Initiate new rekeys - for node_addr in sessions_to_rekey { - self.initiate_session_rekey(&node_addr).await; - } + /// Snapshot every established session for the FSP rekey decision, + /// pre-computing its monotonic age and timer predicates so the pure core + /// applies the thresholds without reading a clock (see [`SessionSnapshot`]). + fn session_rekey_snapshots(&self, now_ms: u64) -> Vec { + let drain_ms = crate::proto::fsp::limits::DRAIN_WINDOW_SECS * 1000; + let dampening_ms = crate::proto::fsp::limits::REKEY_DAMPENING_SECS * 1000; + self.sessions + .iter() + .filter(|(_, entry)| entry.is_established()) + .map(|(node_addr, entry)| SessionSnapshot { + addr: *node_addr, + has_pending: entry.pending_new_session().is_some(), + rekey_in_progress: entry.has_rekey_in_progress(), + is_rekey_initiator: entry.is_rekey_initiator(), + cutover_timer_elapsed: cutover_timer_elapsed(now_ms, entry.rekey_completed_ms()), + is_draining: entry.is_draining(), + drain_expired: entry.drain_expired(now_ms, drain_ms), + has_rekey_msg3_payload: entry.rekey_msg3_payload().is_some(), + is_dampened: entry.is_rekey_dampened(now_ms, dampening_ms), + elapsed_secs: now_ms.saturating_sub(entry.session_start_ms()) / 1000, + counter: entry.send_counter(), + jitter_secs: entry.rekey_jitter_secs(), + }) + .collect() } /// Initiate an FSP session rekey. diff --git a/src/node/handlers/session.rs b/src/node/handlers/session.rs index 9dc3962..952f5ef 100644 --- a/src/node/handlers/session.rs +++ b/src/node/handlers/session.rs @@ -6,14 +6,9 @@ //! encrypted data, and error signals (CoordsRequired, PathBroken). use crate::NodeAddr; +use crate::node::handlers::mmp::format_throughput; use crate::node::reject::{RejectReason, SessionReject}; use crate::node::session::{EndToEndState, EpochSlot, SessionEntry}; -use crate::node::session_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, - FspCommonPrefix, FspEncryptedHeader, build_fsp_header, fsp_prepend_inner_header, - fsp_strip_inner_header, parse_encrypted_coords, -}; #[cfg(unix)] use crate::node::wire::{ ESTABLISHED_HEADER_SIZE, FLAG_KEY_EPOCH, FLAG_SP, build_established_header, @@ -22,19 +17,28 @@ use crate::node::{Node, NodeError}; use crate::noise::{ HandshakeState, XK_HANDSHAKE_MSG1_SIZE, XK_HANDSHAKE_MSG2_SIZE, XK_HANDSHAKE_MSG3_SIZE, }; -use crate::proto::mmp::{MAX_SESSION_REPORT_INTERVAL_MS, MIN_SESSION_REPORT_INTERVAL_MS}; +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, + FspCommonPrefix, FspEncryptedHeader, build_fsp_header, fsp_prepend_inner_header, + fsp_strip_inner_header, parse_encrypted_coords, +}; +use crate::proto::fsp::{ + DecryptSlot, EpochReaction, FspAction, FspInnerFlags, SessionAck, SessionMessageType, + SessionMsg3, SessionSetup, mark_ipv6_ecn_ce, +}; +#[cfg(unix)] +use crate::proto::link::LinkMessageType; +#[cfg(unix)] +use crate::proto::link::SESSION_DATAGRAM_HEADER_SIZE; +use crate::proto::link::SessionDatagram; use crate::proto::mmp::{ - PathMtuNotification, ReceiverReport, SessionReceiverReport, SessionSenderReport, + BackoffUpdate, MmpAction, MmpSessionState, PathMtuNotification, ReceiverReport, SendResult, + SessionReceiverReport, SessionReportKind, SessionReportSnapshot, SessionSenderReport, }; -use crate::proto::routing::{CoordsRequired, MtuExceeded, PathBroken}; -#[cfg(unix)] -use crate::protocol::LinkMessageType; -#[cfg(unix)] -use crate::protocol::SESSION_DATAGRAM_HEADER_SIZE; -use crate::protocol::{ - FspInnerFlags, SessionAck, SessionDatagram, SessionMessageType, SessionMsg3, SessionSetup, -}; -use crate::protocol::{coords_wire_size, encode_coords}; +use crate::proto::mmp::{MAX_SESSION_REPORT_INTERVAL_MS, MIN_SESSION_REPORT_INTERVAL_MS}; +use crate::proto::routing::{CoordsRequired, MtuExceeded, PathBroken, RoutingSignalType}; +use crate::proto::stp::{coords_wire_size, encode_coords}; #[cfg(unix)] use crate::transport::TransportHandle; use crate::upper::icmp::FIPS_OVERHEAD; @@ -105,14 +109,14 @@ impl Node { } let error_type = inner[0]; let error_body = &inner[1..]; - match SessionMessageType::from_byte(error_type) { - Some(SessionMessageType::CoordsRequired) => { + match RoutingSignalType::from_byte(error_type) { + Some(RoutingSignalType::CoordsRequired) => { self.handle_coords_required(error_body).await; } - Some(SessionMessageType::PathBroken) => { + Some(RoutingSignalType::PathBroken) => { self.handle_path_broken(error_body).await; } - Some(SessionMessageType::MtuExceeded) => { + Some(RoutingSignalType::MtuExceeded) => { self.handle_mtu_exceeded(error_body).await; } _ => { @@ -167,11 +171,14 @@ impl Node { match parse_encrypted_coords(coord_data) { Ok((src_coords, dest_coords, bytes_consumed)) => { let now_ms = Self::now_ms(); - if let Some(coords) = src_coords { - self.coord_cache.insert(*src_addr, coords, now_ms); - } - if let Some(coords) = dest_coords { - self.coord_cache.insert(*self.node_addr(), coords, now_ms); + let my_addr = *self.node_addr(); + for action in + self.fsp + .plan_cache_coords(*src_addr, my_addr, src_coords, dest_coords) + { + if let FspAction::CacheCoords { addr, coords } = action { + self.coord_cache.insert(addr, coords, now_ms); + } } ciphertext_offset += bytes_consumed; } @@ -249,44 +256,55 @@ impl Node { } }; - // React to the epoch the frame decrypted against. - match slot { - EpochSlot::Pending => { - // A frame that authenticates against `pending` is itself - // the cutover signal — proof the peer derived the new - // session and moved to it. Promote now: current → - // previous, pending → current, flip the K-bit. The - // header K-bit is no longer the gating event; the - // authenticated decrypt is. + // React to the epoch the frame decrypted against. The shell opened + // the frame; the core classifies the post-decrypt reaction over the + // plain-data slot + session flags, and the shell applies the + // `SessionEntry` mutation. + let decrypt_slot = match slot { + EpochSlot::Current => DecryptSlot::Current, + EpochSlot::Pending => DecryptSlot::Pending, + EpochSlot::Previous => DecryptSlot::Previous, + }; + match self.fsp.classify_epoch( + decrypt_slot, + entry.rekey_msg3_payload().is_some(), + entry.pending_new_session().is_some(), + ) { + EpochReaction::PromoteConfirming => { + // A frame that authenticates against `pending` is itself the + // cutover signal — proof the peer derived the new session and + // moved to it. The peer received msg3, so confirm it on the new + // epoch (stop retransmitting) before `handle_peer_kbit_flip` + // consumes the pending session, then promote. info!( peer = %self.peer_display_name(src_addr), "Peer FSP new-epoch frame authenticated, FSP rekey cutover complete, promoting new session" ); - // The peer derived the new session, so it received msg3: - // confirm it on the new epoch and stop retransmitting. - // `handle_peer_kbit_flip` consumes the pending session, - // so confirm first. - if entry.rekey_msg3_payload().is_some() { - entry.confirm_peer_new_epoch(); - } + entry.confirm_peer_new_epoch(); entry.handle_peer_kbit_flip(now_ms); } - EpochSlot::Current => { - // If we still retain a msg3 retransmission payload but no - // longer hold a `pending` session, we are the rekey - // initiator that already cut over on its own timer: - // `current` is now the new epoch, so a frame decrypting - // against it confirms the responder reached the new - // epoch. Stop retransmitting msg3. - if entry.rekey_msg3_payload().is_some() && entry.pending_new_session().is_none() { - entry.confirm_peer_new_epoch(); - } + EpochReaction::Promote => { + // Promote now: current → previous, pending → current, flip the + // K-bit. The header K-bit is only a hint; the authenticated + // decrypt is the gating event. + info!( + peer = %self.peer_display_name(src_addr), + "Peer FSP new-epoch frame authenticated, FSP rekey cutover complete, promoting new session" + ); + entry.handle_peer_kbit_flip(now_ms); } - EpochSlot::Previous => { - // The peer is still on the old epoch. `fsp_trial_decrypt` - // already refreshed the drain deadline so the `previous` - // slot is not retired while the peer keeps using it — - // no further state change here, just deliver. + EpochReaction::ConfirmResponder => { + // We are the rekey initiator that already cut over on its own + // timer: `current` is now the new epoch, so a frame decrypting + // against it confirms the responder reached it. Stop + // retransmitting msg3. + entry.confirm_peer_new_epoch(); + } + EpochReaction::None => { + // Steady-state `current`, or an old-epoch `previous` straggler: + // `fsp_trial_decrypt` already refreshed the drain deadline so + // the `previous` slot is not retired while the peer keeps using + // it — no further state change, just deliver. } } @@ -445,7 +463,7 @@ impl Node { if let Some(existing) = self.sessions.get(src_addr) { if existing.is_initiating() { // Simultaneous initiation: smaller NodeAddr wins as initiator - if self.identity().node_addr() < src_addr { + if crate::proto::fsp::initiation_winner(self.identity().node_addr(), src_addr) { // We win — drop their setup, they'll process ours debug!( src = %self.peer_display_name(src_addr), @@ -483,7 +501,10 @@ impl Node { // simultaneously. Apply tie-breaker — smaller NodeAddr // wins as initiator (same as initial session setup). if rekey_in_progress { - if self.identity().node_addr() < src_addr { + if crate::proto::fsp::initiation_winner( + self.identity().node_addr(), + src_addr, + ) { // We win as initiator — drop their msg1. debug!( src = %self.peer_display_name(src_addr), @@ -920,6 +941,221 @@ impl Node { // === Session-layer MMP report handlers === + /// Check all sessions for pending MMP reports and send them. + /// + /// Called from the tick handler. Also emits periodic session MMP logs. + /// Uses the collect-then-send pattern to avoid borrowing conflicts. + pub(in crate::node) async fn check_session_mmp_reports(&mut self) { + let now_ms = crate::mmp::mono_ms(); + + // Build one report-gating snapshot per session, resolving every timing + // read shell-side into a `bool`. The snapshots own only + // `NodeAddr`/`MmpMode`/`bool`, so the session-iteration borrow is released + // before the pure decision runs and the driving loop mutates the + // reporting state / performs the sends. + let snapshots: Vec = self + .sessions + .iter() + .filter_map(|(dest_addr, entry)| { + let mmp = entry.mmp()?; + Some(SessionReportSnapshot { + dest: *dest_addr, + mode: mmp.mode(), + sr_due: mmp.sender.should_send_report(now_ms), + rr_due: mmp.receiver.should_send_report(now_ms), + mtu_due: mmp.path_mtu.should_send_notification(now_ms), + log_due: mmp.should_log(now_ms), + }) + }) + .collect(); + + let actions = self.mmp.plan_session_reports(&snapshots); + + // Drive the planned actions in phase-grouped order (all logs, then the + // sends in per-session SR/RR/MTU order). Logs run first because the + // session operator log reads cumulative_packets_sent, which each send + // advances (send_session_msg -> sender.record_sent); the pre-refactor + // handler logged during its collect pass, before any send. Each build + // (`build_report`/`build_notification`, which advance interval/ + // notification state) runs only on its SendSessionReport action, exactly + // as the pre-refactor collect pass did. Per-destination success/failure + // is collected for the backoff dedup + failure-log suppression. + let mut send_results: Vec = Vec::new(); + for action in actions { + match action { + MmpAction::LogSession { dest } => { + // Resolve the display name exactly as the pre-refactor loop + // did (alias, else short_npub from the session's remote key). + let session_name = self.peer_aliases.get(&dest).cloned().unwrap_or_else(|| { + self.sessions + .get(&dest) + .map(|entry| { + let (xonly, _) = entry.remote_pubkey().x_only_public_key(); + crate::PeerIdentity::from_pubkey(xonly).short_npub() + }) + .unwrap_or_default() + }); + if let Some(mmp) = self.sessions.get_mut(&dest).and_then(|e| e.mmp_mut()) { + Self::log_session_mmp_metrics(&session_name, mmp); + mmp.mark_logged(now_ms); + } + } + MmpAction::SendSessionReport { dest, kind } => { + let built = self + .sessions + .get_mut(&dest) + .and_then(|entry| entry.mmp_mut()) + .and_then(|mmp| match kind { + SessionReportKind::Sender => { + mmp.sender.build_report(now_ms).map(|sr| { + ( + SessionMessageType::SenderReport.to_byte(), + SessionSenderReport::from(&sr).encode(), + ) + }) + } + SessionReportKind::Receiver => { + mmp.receiver.build_report(now_ms).map(|rr| { + ( + SessionMessageType::ReceiverReport.to_byte(), + SessionReceiverReport::from(&rr).encode(), + ) + }) + } + SessionReportKind::PathMtu => { + mmp.path_mtu.build_notification(now_ms).map(|mtu_value| { + ( + SessionMessageType::PathMtuNotification.to_byte(), + PathMtuNotification::new(mtu_value).encode(), + ) + }) + } + }); + + let Some((msg_type, body)) = built else { + continue; + }; + + match self.send_session_msg(&dest, msg_type, &body).await { + Ok(()) => send_results.push(SendResult { dest, ok: true }), + Err(e) => { + // Peek at current failure count for log suppression + // (unchanged by the backoff apply, which runs later). + let failures = self + .sessions + .get(&dest) + .and_then(|entry| entry.mmp()) + .map(|mmp| mmp.sender.consecutive_send_failures()) + .unwrap_or(0); + + if failures < 3 { + debug!( + dest = %self.peer_display_name(&dest), + msg_type, + error = %e, + "Failed to send session MMP report" + ); + } else if failures == 3 { + debug!( + dest = %self.peer_display_name(&dest), + "Suppressing further session MMP send failure logs" + ); + } + // failures > 3: silently suppressed + + send_results.push(SendResult { dest, ok: false }); + } + } + } + MmpAction::ReapPeer { .. } + | MmpAction::Heartbeat { .. } + | MmpAction::SendLinkReport { .. } + | MmpAction::LogLink { .. } => {} + } + } + + // Deduplicate send results per destination (any-ok -> success, all-fail + // -> failure) and apply the backoff state transition for each dest. + for update in self.mmp.plan_backoff(&send_results) { + match update { + BackoffUpdate::Success { dest } => { + if let Some(mmp) = self.sessions.get_mut(&dest).and_then(|e| e.mmp_mut()) { + let prev = mmp.sender.record_send_success(); + if prev > 3 { + debug!( + dest = %self.peer_display_name(&dest), + consecutive_failures = prev, + "Resumed session MMP reporting" + ); + } + } + } + BackoffUpdate::Failure { dest } => { + if let Some(mmp) = self.sessions.get_mut(&dest).and_then(|e| e.mmp_mut()) { + mmp.sender.record_send_failure(); + } + } + } + } + } + + /// Emit periodic session MMP metrics. + fn log_session_mmp_metrics(session_name: &str, mmp: &MmpSessionState) { + let m = &mmp.metrics; + + let rtt_str = if m.rtt_trend.initialized() { + format!("{:.1}ms", m.rtt_trend.long() / 1000.0) + } else { + "n/a".to_string() + }; + let loss_str = if m.loss_trend.initialized() { + format!("{:.1}%", m.loss_trend.long() * 100.0) + } else { + "n/a".to_string() + }; + let jitter_ms = mmp.receiver.jitter_us() as f64 / 1000.0; + + debug!( + session = %session_name, + rtt = %rtt_str, + loss = %loss_str, + jitter = format_args!("{:.1}ms", jitter_ms), + goodput = %format_throughput(m.goodput_bps()), + mtu = mmp.path_mtu.last_observed_mtu(), + tx_pkts = mmp.sender.cumulative_packets_sent(), + rx_pkts = mmp.receiver.cumulative_packets_recv(), + "MMP session metrics" + ); + } + + /// Emit a teardown log summarizing lifetime session MMP metrics. + pub(in crate::node) fn log_session_mmp_teardown(session_name: &str, mmp: &MmpSessionState) { + let m = &mmp.metrics; + let jitter_ms = mmp.receiver.jitter_us() as f64 / 1000.0; + + let rtt_str = match m.srtt_ms() { + Some(rtt) => format!("{:.1}ms", rtt), + None => "n/a".to_string(), + }; + let loss_str = format!("{:.1}%", m.loss_rate() * 100.0); + + debug!( + session = %session_name, + rtt = %rtt_str, + loss = %loss_str, + jitter = format_args!("{:.1}ms", jitter_ms), + etx = format_args!("{:.2}", m.etx), + goodput = %format_throughput(m.goodput_bps()), + send_mtu = mmp.path_mtu.current_mtu(), + observed_mtu = mmp.path_mtu.last_observed_mtu(), + tx_pkts = mmp.sender.cumulative_packets_sent(), + tx_bytes = mmp.sender.cumulative_bytes_sent(), + rx_pkts = mmp.receiver.cumulative_packets_recv(), + rx_bytes = mmp.receiver.cumulative_bytes_recv(), + "MMP session teardown" + ); + } + /// Handle an incoming session-layer SenderReport (msg_type 0x11). /// /// Informational only — the peer is telling us about what they sent. @@ -1069,28 +1305,34 @@ impl Node { // tighter of existing-or-new — never loosen the clamp. let fips_addr = crate::FipsAddress::from_node_addr(src_addr); match self.path_mtu_lookup.write() { - Ok(mut map) => match map.get(&fips_addr).copied() { - Some(existing) if existing <= new_mtu => { + Ok(mut map) => { + // Read existing, decide, and apply the write under one guard so + // the keep-tighter update stays atomic. + let prior = map.get(&fips_addr).copied(); + let actions = self.fsp.plan_path_mtu_tighten(fips_addr, prior, new_mtu); + if actions.is_empty() { debug!( dest = %peer_name, fips_addr = %fips_addr, new_mtu, - existing, + existing = prior.unwrap_or(new_mtu), "PathMtuNotification: keeping tighter existing path_mtu_lookup value" ); } - other => { - map.insert(fips_addr, new_mtu); - debug!( - dest = %peer_name, - fips_addr = %fips_addr, - new_mtu, - prior = ?other, - map_len = map.len(), - "PathMtuNotification: tightened path_mtu_lookup" - ); + for action in actions { + if let FspAction::TightenPathMtuLookup { fips_addr, mtu } = action { + map.insert(fips_addr, mtu); + debug!( + dest = %peer_name, + fips_addr = %fips_addr, + new_mtu, + prior = ?prior, + map_len = map.len(), + "PathMtuNotification: tightened path_mtu_lookup" + ); + } } - }, + } Err(e) => { warn!( dest = %peer_name, @@ -1145,12 +1387,19 @@ impl Node { // Only trigger discovery if we have the target's identity cached — // otherwise we can't verify the LookupResponse proof. - if self.has_cached_identity(&msg.dest_addr) { - self.maybe_initiate_lookup(&msg.dest_addr).await; - } else { + let has_cached_identity = self.has_cached_identity(&msg.dest_addr); + let actions = self + .fsp + .plan_coords_required_lookup(msg.dest_addr, has_cached_identity); + if actions.is_empty() { debug!(dest = %msg.dest_addr, "Skipping discovery after CoordsRequired: no cached identity for target"); } + for action in actions { + if let FspAction::InitiateLookup { dest } = action { + self.maybe_initiate_lookup(&dest).await; + } + } // Reset coords warmup counter so the next N packets also include // COORDS_PRESENT, re-warming transit caches along the path. @@ -1204,16 +1453,26 @@ impl Node { "PathBroken response rate-limited, skipping standalone CoordsWarmup"); } - // Invalidate stale cached coordinates - self.coord_cache.remove(&msg.dest_addr); - - // Trigger re-discovery to get fresh coordinates, but only if we have - // the target's identity cached — otherwise we can't verify the - // LookupResponse proof. This avoids a race when the XK responder - // receives PathBroken before msg3 completes (identity unknown). - if self.has_cached_identity(&msg.dest_addr) { - self.maybe_initiate_lookup(&msg.dest_addr).await; - } else { + // Invalidate stale cached coordinates, then (only if the target's + // identity is cached — else the LookupResponse proof cannot be verified, + // e.g. when the XK responder receives PathBroken before msg3 completes) + // trigger re-discovery. The core emits invalidate-then-lookup in order. + let has_cached_identity = self.has_cached_identity(&msg.dest_addr); + let actions = self + .fsp + .plan_path_broken(msg.dest_addr, has_cached_identity); + for action in actions { + match action { + FspAction::InvalidateCoords { addr } => { + self.coord_cache.remove(&addr); + } + FspAction::InitiateLookup { dest } => { + self.maybe_initiate_lookup(&dest).await; + } + _ => {} + } + } + if !has_cached_identity { debug!(dest = %msg.dest_addr, "Skipping discovery after PathBroken: no cached identity for target"); } @@ -1283,28 +1542,34 @@ impl Node { // tighter of existing-or-new — never loosen the clamp. let fips_addr = crate::FipsAddress::from_node_addr(&msg.dest_addr); match self.path_mtu_lookup.write() { - Ok(mut map) => match map.get(&fips_addr).copied() { - Some(existing) if existing <= msg.mtu => { + Ok(mut map) => { + // Read existing, decide, and apply the write under one guard so + // the keep-tighter update stays atomic. + let prior = map.get(&fips_addr).copied(); + let actions = self.fsp.plan_path_mtu_tighten(fips_addr, prior, msg.mtu); + if actions.is_empty() { debug!( dest = %peer_name, fips_addr = %fips_addr, bottleneck_mtu = msg.mtu, - existing, + existing = prior.unwrap_or(msg.mtu), "Reactive MtuExceeded: keeping tighter existing path_mtu_lookup value" ); } - other => { - map.insert(fips_addr, msg.mtu); - debug!( - dest = %peer_name, - fips_addr = %fips_addr, - bottleneck_mtu = msg.mtu, - prior = ?other, - map_len = map.len(), - "Reactive MtuExceeded: tightened path_mtu_lookup" - ); + for action in actions { + if let FspAction::TightenPathMtuLookup { fips_addr, mtu } = action { + map.insert(fips_addr, mtu); + debug!( + dest = %peer_name, + fips_addr = %fips_addr, + bottleneck_mtu = msg.mtu, + prior = ?prior, + map_len = map.len(), + "Reactive MtuExceeded: tightened path_mtu_lookup" + ); + } } - }, + } Err(e) => { warn!( dest = %peer_name, @@ -2243,10 +2508,7 @@ impl Node { let per_dest = self.config().node.session.pending_packets_per_dest; let queue = self.pending_tun_packets.entry(dest_addr).or_default(); - if queue.len() >= per_dest { - queue.pop_front(); // Drop oldest - } - queue.push_back(packet); + crate::proto::fsp::push_bounded_pending(queue, packet, per_dest); } /// Flush pending packets for a destination whose session just reached Established. @@ -2297,31 +2559,3 @@ impl Node { } } } - -/// Mark ECN-CE in an IPv6 packet's Traffic Class field. -/// -/// IPv6 Traffic Class occupies bits across bytes 0 and 1: -/// byte[0] bits[3:0] = TC[7:4] -/// byte[1] bits[7:4] = TC[3:0] -/// ECN is TC[1:0]. Only marks CE (0b11) if the packet is ECN-capable -/// (ECT(0) or ECT(1)). Packets with ECN=0b00 (Not-ECT) are never marked -/// per RFC 3168. -/// -/// No checksum update needed: IPv6 has no header checksum, and the Traffic -/// Class field is not part of the TCP/UDP pseudo-header. -pub(in crate::node) fn mark_ipv6_ecn_ce(packet: &mut [u8]) { - if packet.len() < 2 { - return; - } - // Extract 8-bit Traffic Class from IPv6 header bytes 0-1 - let tc = ((packet[0] & 0x0F) << 4) | (packet[1] >> 4); - let ecn = tc & 0x03; - // Only mark CE on ECN-capable packets (ECT(0)=0b10 or ECT(1)=0b01) - if ecn == 0 { - return; - } - // Set both ECN bits to 1 (CE = 0b11) - let new_tc = tc | 0x03; - packet[0] = (packet[0] & 0xF0) | (new_tc >> 4); - packet[1] = (new_tc << 4) | (packet[1] & 0x0F); -} diff --git a/src/node/handlers/timeout.rs b/src/node/handlers/timeout.rs index add58bf..d61270e 100644 --- a/src/node/handlers/timeout.rs +++ b/src/node/handlers/timeout.rs @@ -248,7 +248,7 @@ impl Node { .collect(); for (dest_addr, payload) in candidates { - use crate::protocol::SessionDatagram; + use crate::proto::link::SessionDatagram; let mut datagram = SessionDatagram::new(my_addr, dest_addr, payload).with_ttl(ttl); let sent = match self.send_session_datagram(&mut datagram).await { diff --git a/src/node/mod.rs b/src/node/mod.rs index b786ae3..c5aa8e5 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -19,7 +19,6 @@ pub(crate) mod reject; mod reloadable; mod retry; pub(crate) mod session; -pub(crate) mod session_wire; pub(crate) mod stats; pub(crate) mod stats_history; #[cfg(test)] @@ -46,6 +45,7 @@ use crate::peer::{ActivePeer, PeerConnection}; use crate::proto::bloom::{BloomFilter, BloomState}; use crate::proto::discovery::{Discovery, DiscoveryBackoff, DiscoveryForwardRateLimiter}; use crate::proto::fmp::Fmp; +use crate::proto::fsp::Fsp; use crate::proto::mmp::Mmp; use crate::proto::routing::{self, Router, RoutingErrorRateLimiter}; use crate::proto::stp::TreeState; @@ -421,6 +421,9 @@ pub struct Node { /// FMP connection-lifecycle decision anchor (stateless; drives the /// tick-poll maintain/teardown decisions). fmp: Fmp, + /// FSP session-lifecycle decision anchor (stateless; drives the rekey / + /// epoch-reaction decisions). + fsp: Fsp, /// MMP reporting decision anchor (stateless; drives the report-fan-out / /// liveness / heartbeat decisions). mmp: Mmp, @@ -670,6 +673,7 @@ impl Node { icmp_rate_limiter: IcmpRateLimiter::new(), routing: Router::new(), fmp: Fmp::new(), + fsp: Fsp::new(), mmp: Mmp::new(), coords_response_rate_limiter: RoutingErrorRateLimiter::with_interval_ms( coords_response_interval_ms, @@ -834,6 +838,7 @@ impl Node { icmp_rate_limiter: IcmpRateLimiter::new(), routing: Router::new(), fmp: Fmp::new(), + fsp: Fsp::new(), mmp: Mmp::new(), coords_response_rate_limiter: RoutingErrorRateLimiter::with_interval_ms( coords_response_interval_ms, diff --git a/src/node/session.rs b/src/node/session.rs index b098e4f..694012e 100644 --- a/src/node/session.rs +++ b/src/node/session.rs @@ -801,8 +801,8 @@ impl SessionEntry { #[cfg(test)] mod overlapping_epoch_tests { use super::*; - use crate::node::session_wire::{FSP_FLAG_K, build_fsp_header}; use crate::noise::HandshakeState; + use crate::proto::fsp::wire::{FSP_FLAG_K, build_fsp_header}; use secp256k1::{Keypair, Secp256k1, SecretKey}; /// Deterministic keypair from a single seed byte. @@ -1265,4 +1265,155 @@ mod overlapping_epoch_tests { "window must expire on the plain wall-clock timer when peer is off the old epoch" ); } + + // ======================================================================== + // Rekey-policy characterization (pins `check_session_rekey`'s decision + // boundaries before the `Fsp::poll_rekey` hoist — these thresholds have no + // other test module; see plan §10). + // ======================================================================== + + /// The initiator liveness-cutover delay used by `check_session_rekey` + /// (`FSP_CUTOVER_DELAY_MS`). Mirrored here as the characterization anchor. + const CUTOVER_DELAY_MS: u64 = 2000; + + /// Build an established entry that has completed a rekey as initiator and + /// holds a pending session awaiting the K-bit cutover. + fn entry_pending_cutover(rekey_completed_ms: u64) -> SessionEntry { + let (_cur_send, cur_recv) = xk_pair(1, 2); + let (_new_send, new_recv) = xk_pair(3, 4); + let mut entry = entry_with_current(cur_recv); + // Mark ourselves the rekey initiator, then land the completed session + // as pending (clears rekey_state, so has_rekey_in_progress() == false). + entry.set_rekey_state(HandshakeState::new_xk_responder(keypair(7)), true); + entry.set_pending_session(new_recv); + entry.set_rekey_completed_ms(rekey_completed_ms); + entry + } + + // The initiator-side cutover predicate: pending session present, no rekey + // in progress, we are the initiator, and the liveness timer has elapsed. + #[test] + fn rekey_cutover_predicate_boundary() { + let completed = 1_000u64; + let entry = entry_pending_cutover(completed); + + assert!(entry.pending_new_session().is_some()); + assert!(!entry.has_rekey_in_progress()); + assert!(entry.is_rekey_initiator()); + + // Not yet eligible one ms before the delay elapses. + let just_before = completed + CUTOVER_DELAY_MS - 1; + assert!( + just_before.saturating_sub(entry.rekey_completed_ms()) < CUTOVER_DELAY_MS, + "cutover must not fire before the liveness delay" + ); + // Eligible exactly at the delay. + let at = completed + CUTOVER_DELAY_MS; + assert!( + at.saturating_sub(entry.rekey_completed_ms()) >= CUTOVER_DELAY_MS, + "cutover fires once the liveness delay has elapsed" + ); + } + + // Rekey-trigger threshold: elapsed time (with symmetric jitter applied) + // OR the send counter crossing its configured bound. + #[test] + fn rekey_trigger_threshold_arithmetic() { + let after_secs = 100u64; + let after_messages = 1_000u64; + + // Jitter is always within [-REKEY_JITTER_SECS, +REKEY_JITTER_SECS]. + let (_s, recv) = xk_pair(1, 2); + let entry = entry_with_current(recv); + let jitter = entry.rekey_jitter_secs(); + assert!( + jitter.abs() <= REKEY_JITTER_SECS, + "jitter within configured bound" + ); + + // Effective time threshold applies the symmetric jitter. + let effective_after = after_secs.saturating_add_signed(jitter); + + // Reproduce the policy's OR predicate directly. + let triggers = + |elapsed: u64, counter: u64| elapsed >= effective_after || counter >= after_messages; + + // Time arm: fires at/after the effective threshold, not below it. + assert!(!triggers(effective_after - 1, 0), "below time threshold"); + assert!(triggers(effective_after, 0), "at time threshold"); + // Counter arm: fires independently of elapsed time. + assert!(!triggers(0, after_messages - 1), "below counter threshold"); + assert!(triggers(0, after_messages), "at counter threshold"); + } + + // Dampening boundary: within `dampening_ms` of the peer's rekey msg1, local + // initiation is suppressed; at/after the window it is not. + #[test] + fn rekey_dampening_boundary() { + let (_s, recv) = xk_pair(1, 2); + let mut entry = entry_with_current(recv); + const DAMP_MS: u64 = 30_000; + + // No peer rekey recorded → never dampened. + assert!(!entry.is_rekey_dampened(50_000, DAMP_MS)); + + entry.record_peer_rekey(10_000); + assert!( + entry.is_rekey_dampened(10_000 + DAMP_MS - 1, DAMP_MS), + "dampened within the window" + ); + assert!( + !entry.is_rekey_dampened(10_000 + DAMP_MS, DAMP_MS), + "not dampened once the window has elapsed" + ); + } + + // Epoch-reaction: a frame authenticating against `pending` while a msg3 + // retransmission is retained confirms the peer on the new epoch (clears the + // msg3 payload) and then promotes. + #[test] + fn epoch_reaction_pending_confirms_then_promotes() { + let (mut p_send, p_recv) = xk_pair(3, 4); + let (_cur_send, cur_recv) = xk_pair(1, 2); + let mut entry = entry_with_current(cur_recv); + let k_before = entry.current_k_bit(); + entry.set_pending_session(p_recv); + entry.set_rekey_msg3_payload(vec![0xAB; 8], 5_000); + assert!(entry.rekey_msg3_payload().is_some()); + + let (ct, counter, hdr) = seal(&mut p_send, b"new-epoch", !k_before); + let (_pt, slot) = entry + .fsp_trial_decrypt(&ct, counter, &hdr, !k_before, 2_000) + .expect("pending frame decrypts"); + assert_eq!(slot, EpochSlot::Pending); + + // Reaction order: confirm (while pending still held) then promote. + entry.confirm_peer_new_epoch(); + assert!(entry.rekey_msg3_payload().is_none()); + entry.handle_peer_kbit_flip(2_000); + assert!(entry.pending_new_session().is_none()); + assert_ne!(entry.current_k_bit(), k_before); + } + + // Epoch-reaction: as the initiator that already cut over on its own timer + // (msg3 retained, no pending), a frame authenticating against `current` + // confirms the responder reached the new epoch. + #[test] + fn epoch_reaction_current_confirms_responder() { + let (mut cur_send, cur_recv) = xk_pair(1, 2); + let mut entry = entry_with_current(cur_recv); + entry.set_rekey_msg3_payload(vec![0xCD; 8], 5_000); + assert!(entry.pending_new_session().is_none()); + assert!(entry.rekey_msg3_payload().is_some()); + + let (ct, counter, hdr) = seal(&mut cur_send, b"steady", false); + let (_pt, slot) = entry + .fsp_trial_decrypt(&ct, counter, &hdr, false, 2_000) + .expect("current frame decrypts"); + assert_eq!(slot, EpochSlot::Current); + + // The Current-with-retained-msg3-and-no-pending arm confirms. + entry.confirm_peer_new_epoch(); + assert!(entry.rekey_msg3_payload().is_none()); + } } diff --git a/src/node/session_wire.rs b/src/node/session_wire.rs deleted file mode 100644 index 7a69e03..0000000 --- a/src/node/session_wire.rs +++ /dev/null @@ -1,618 +0,0 @@ -//! FSP Wire Format Parsing and Serialization -//! -//! Defines the FIPS session-layer wire format (FSP) for packet dispatch. -//! All FSP messages begin with a 4-byte common prefix followed by phase-specific -//! fields. Encrypted messages use a 12-byte cleartext header as AAD for AEAD, -//! and a 6-byte encrypted inner header containing timestamps and message type. -//! -//! ## Common Prefix (4 bytes) -//! -//! ```text -//! [ver+phase:1][flags:1][payload_len:2 LE] -//! ``` -//! -//! ## DataPacket Port Multiplexing -//! -//! DataPacket (msg_type 0x10) payloads inside the AEAD envelope carry a 4-byte -//! port header for service dispatch: -//! -//! ```text -//! [src_port:2 LE][dst_port:2 LE][service payload...] -//! ``` -//! -//! Port 256 (0x100) = IPv6 shim with header compression. -//! -//! ## Message Classes -//! -//! | Phase | U Flag | Type | Description | -//! |-------|--------|------------------|-----------------------------------| -//! | 0x0 | 0 | Encrypted | Post-handshake encrypted data | -//! | 0x0 | 1 | Plaintext error | CoordsRequired, PathBroken | -//! | 0x1 | - | Handshake msg1 | SessionSetup (Noise XK msg1) | -//! | 0x2 | - | Handshake msg2 | SessionAck (Noise XK msg2) | -//! | 0x3 | - | Handshake msg3 | SessionMsg3 (Noise XK msg3) | - -use crate::proto::stp::TreeCoordinate; -use crate::protocol::{ProtocolError, decode_optional_coords}; - -// ============================================================================ -// Constants -// ============================================================================ - -/// FSP protocol version (4 high bits of byte 0). -pub const FSP_VERSION: u8 = 0; - -/// Phase value for established (encrypted or plaintext error) messages. -pub const FSP_PHASE_ESTABLISHED: u8 = 0x0; - -/// Phase value for SessionSetup (Noise IK message 1). -pub const FSP_PHASE_MSG1: u8 = 0x1; - -/// Phase value for SessionAck (Noise handshake message 2). -pub const FSP_PHASE_MSG2: u8 = 0x2; - -/// Phase value for XK message 3 (initiator's encrypted static). -pub const FSP_PHASE_MSG3: u8 = 0x3; - -/// Size of the common packet prefix (all FSP message types). -pub const FSP_COMMON_PREFIX_SIZE: usize = 4; - -/// Size of the full encrypted message header (prefix + counter). -pub const FSP_HEADER_SIZE: usize = 12; - -/// Size of the encrypted inner header (timestamp + msg_type + inner_flags). -pub const FSP_INNER_HEADER_SIZE: usize = 6; - -/// AEAD authentication tag size (ChaCha20-Poly1305). -const TAG_SIZE: usize = 16; - -/// Minimum size for an encrypted FSP message: header + tag (no plaintext). -pub const FSP_ENCRYPTED_MIN_SIZE: usize = FSP_HEADER_SIZE + TAG_SIZE; // 28 bytes - -// FSP DataPacket port header constants. - -/// Size of the FSP DataPacket port header (src_port + dst_port). -pub const FSP_PORT_HEADER_SIZE: usize = 4; - -/// FSP port: IPv6 shim service. -pub const FSP_PORT_IPV6_SHIM: u16 = 256; - -// Cleartext flag bit constants (byte 1 of common prefix, phase 0x0 only). - -/// Coords Present — source and destination coordinates follow the header. -pub const FSP_FLAG_CP: u8 = 0x01; - -/// Key Epoch — selects active key during rekeying. -#[allow(dead_code)] -pub const FSP_FLAG_K: u8 = 0x02; - -/// Unencrypted — payload is plaintext (error signals). -pub const FSP_FLAG_U: u8 = 0x04; - -// Inner flag bit constants (byte 5 of decrypted inner header). - -/// Spin bit for end-to-end RTT measurement (inside AEAD). -#[allow(dead_code)] -pub const FSP_INNER_FLAG_SP: u8 = 0x01; - -// ============================================================================ -// Common Prefix -// ============================================================================ - -/// Parsed FSP common packet prefix (first 4 bytes of every FSP message). -/// -/// Wire format: -/// ```text -/// [ver(4bits)+phase(4bits)][flags:1][payload_len:2 LE] -/// ``` -#[derive(Clone, Debug)] -pub struct FspCommonPrefix { - /// Protocol version (high nibble of byte 0). - #[cfg_attr(not(test), allow(dead_code))] - pub version: u8, - /// Session lifecycle phase (low nibble of byte 0). - pub phase: u8, - /// Per-message signal flags. - pub flags: u8, - /// Length of payload following the phase-specific header. - #[cfg_attr(not(test), allow(dead_code))] - pub payload_len: u16, -} - -impl FspCommonPrefix { - /// Parse a common prefix from the first 4 bytes of FSP message data. - pub fn parse(data: &[u8]) -> Option { - if data.len() < FSP_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, - }) - } - - /// Check if the Unencrypted flag is set. - pub fn is_unencrypted(&self) -> bool { - self.flags & FSP_FLAG_U != 0 - } - - /// Check if the Coords Present flag is set. - pub fn has_coords(&self) -> bool { - self.flags & FSP_FLAG_CP != 0 - } - - /// Encode the ver+phase byte. - fn ver_phase_byte(version: u8, phase: u8) -> u8 { - (version << 4) | (phase & 0x0F) - } -} - -// ============================================================================ -// Encrypted Message Header -// ============================================================================ - -/// Parsed FSP encrypted message header (phase 0x0, U flag clear). -/// -/// Wire format (12 bytes): -/// ```text -/// [ver+phase:1][flags:1][payload_len:2 LE][counter:8 LE] -/// ``` -/// -/// The full 12-byte header is used as AAD for the AEAD construction. -/// No receiver_idx — unlike FMP, FSP is end-to-end (dispatched by src_addr -/// from the SessionDatagram envelope, not by index). -#[derive(Clone, Debug)] -pub struct FspEncryptedHeader { - /// Per-message flags (CP, K). - pub flags: u8, - /// Length of encrypted payload (excluding AEAD tag). - #[cfg_attr(not(test), allow(dead_code))] - pub payload_len: u16, - /// Monotonic counter used as AEAD nonce. - pub counter: u64, - /// Raw 12-byte header for use as AEAD AAD. - pub header_bytes: [u8; FSP_HEADER_SIZE], -} - -impl FspEncryptedHeader { - /// Parse an encrypted message header from FSP message data. - /// - /// Returns None if the data is too short or has wrong version/phase, - /// or if the U flag is set (plaintext messages use a different path). - pub fn parse(data: &[u8]) -> Option { - if data.len() < FSP_ENCRYPTED_MIN_SIZE { - return None; - } - - let version = data[0] >> 4; - let phase = data[0] & 0x0F; - - if version != FSP_VERSION || phase != FSP_PHASE_ESTABLISHED { - return None; - } - - let flags = data[1]; - - // U flag means plaintext — not an encrypted message - if flags & FSP_FLAG_U != 0 { - return None; - } - - let payload_len = u16::from_le_bytes([data[2], data[3]]); - let counter = u64::from_le_bytes([ - data[4], data[5], data[6], data[7], data[8], data[9], data[10], data[11], - ]); - - let mut header_bytes = [0u8; FSP_HEADER_SIZE]; - header_bytes.copy_from_slice(&data[..FSP_HEADER_SIZE]); - - Some(Self { - flags, - payload_len, - counter, - header_bytes, - }) - } - - /// Check if the Coords Present flag is set. - pub fn has_coords(&self) -> bool { - self.flags & FSP_FLAG_CP != 0 - } - - /// Offset where ciphertext (or coords if CP) begins in the original data. - #[cfg_attr(not(test), allow(dead_code))] - pub fn data_offset(&self) -> usize { - FSP_HEADER_SIZE - } -} - -// ============================================================================ -// Serialization Helpers -// ============================================================================ - -/// Build the 12-byte cleartext header for an encrypted FSP message. -/// -/// Returns the header bytes for use as AEAD AAD. -pub fn build_fsp_header(counter: u64, flags: u8, payload_len: u16) -> [u8; FSP_HEADER_SIZE] { - let mut header = [0u8; FSP_HEADER_SIZE]; - header[0] = FspCommonPrefix::ver_phase_byte(FSP_VERSION, FSP_PHASE_ESTABLISHED); - header[1] = flags; - header[2..4].copy_from_slice(&payload_len.to_le_bytes()); - header[4..12].copy_from_slice(&counter.to_le_bytes()); - header -} - -/// Assemble a wire-format encrypted FSP message. -/// -/// Format: `[header:12][ciphertext+tag]` -#[cfg_attr(not(test), allow(dead_code))] -pub fn build_fsp_encrypted(header: &[u8; FSP_HEADER_SIZE], ciphertext: &[u8]) -> Vec { - let mut packet = Vec::with_capacity(FSP_HEADER_SIZE + ciphertext.len()); - packet.extend_from_slice(header); - packet.extend_from_slice(ciphertext); - packet -} - -/// Build a 4-byte common prefix for a handshake message. -/// -/// `phase` should be `FSP_PHASE_MSG1`, `FSP_PHASE_MSG2`, or `FSP_PHASE_MSG3`. -/// Flags are zero during handshake. -#[cfg_attr(not(test), allow(dead_code))] -pub fn build_fsp_handshake_prefix(phase: u8, payload_len: u16) -> [u8; FSP_COMMON_PREFIX_SIZE] { - let mut prefix = [0u8; FSP_COMMON_PREFIX_SIZE]; - prefix[0] = FspCommonPrefix::ver_phase_byte(FSP_VERSION, phase); - prefix[1] = 0x00; // flags must be zero during handshake - prefix[2..4].copy_from_slice(&payload_len.to_le_bytes()); - prefix -} - -/// Build a 4-byte common prefix for a plaintext error signal. -/// -/// Sets phase 0x0 and U flag. -#[cfg_attr(not(test), allow(dead_code))] -pub fn build_fsp_error_prefix(payload_len: u16) -> [u8; FSP_COMMON_PREFIX_SIZE] { - let mut prefix = [0u8; FSP_COMMON_PREFIX_SIZE]; - prefix[0] = FspCommonPrefix::ver_phase_byte(FSP_VERSION, FSP_PHASE_ESTABLISHED); - prefix[1] = FSP_FLAG_U; - prefix[2..4].copy_from_slice(&payload_len.to_le_bytes()); - prefix -} - -// ============================================================================ -// Inner Header Helpers -// ============================================================================ - -/// Prepend the 6-byte FSP inner header to a message payload. -/// -/// Inner header: `[timestamp:4 LE][msg_type:1][inner_flags:1]` -/// -/// The caller provides the message-type-specific payload (e.g., application -/// data for msg_type 0x10, report fields for SenderReport). This function -/// prepends the inner header. -pub fn fsp_prepend_inner_header( - timestamp_ms: u32, - msg_type: u8, - inner_flags: u8, - payload: &[u8], -) -> Vec { - let mut buf = Vec::with_capacity(FSP_INNER_HEADER_SIZE + payload.len()); - buf.extend_from_slice(×tamp_ms.to_le_bytes()); - buf.push(msg_type); - buf.push(inner_flags); - buf.extend_from_slice(payload); - buf -} - -/// Strip the 6-byte FSP inner header from a decrypted payload. -/// -/// Returns `(timestamp, msg_type, inner_flags, &rest)` or None if too short. -pub fn fsp_strip_inner_header(plaintext: &[u8]) -> Option<(u32, u8, u8, &[u8])> { - if plaintext.len() < FSP_INNER_HEADER_SIZE { - return None; - } - let timestamp = u32::from_le_bytes([plaintext[0], plaintext[1], plaintext[2], plaintext[3]]); - let msg_type = plaintext[4]; - let inner_flags = plaintext[5]; - Some(( - timestamp, - msg_type, - inner_flags, - &plaintext[FSP_INNER_HEADER_SIZE..], - )) -} - -// ============================================================================ -// Coordinate Parsing (for transit nodes and receive path) -// ============================================================================ - -/// Parse source and destination coordinates from the cleartext section -/// of an encrypted FSP message when the CP flag is set. -/// -/// Coordinates appear between the 12-byte header and the ciphertext: -/// `[src_coords_count:2 LE][src_coords:16×n][dest_coords_count:2 LE][dest_coords:16×m]` -/// -/// Returns `(src_coords, dest_coords, bytes_consumed)`. -pub fn parse_encrypted_coords( - data: &[u8], -) -> Result<(Option, Option, usize), ProtocolError> { - let (src_coords, src_consumed) = decode_optional_coords(data)?; - let (dest_coords, dest_consumed) = decode_optional_coords(&data[src_consumed..])?; - Ok((src_coords, dest_coords, src_consumed + dest_consumed)) -} - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - - // ===== Size Constant Tests ===== - - #[test] - fn test_wire_sizes() { - assert_eq!(FSP_COMMON_PREFIX_SIZE, 4); - assert_eq!(FSP_HEADER_SIZE, 12); - assert_eq!(FSP_INNER_HEADER_SIZE, 6); - assert_eq!(FSP_ENCRYPTED_MIN_SIZE, 28); // 12 + 16 - } - - // ===== Common Prefix Tests ===== - - #[test] - fn test_common_prefix_parse_established() { - let data = [0x00, 0x01, 0x40, 0x00]; // ver=0, phase=0, flags=CP, payload_len=64 - let prefix = FspCommonPrefix::parse(&data).unwrap(); - assert_eq!(prefix.version, 0); - assert_eq!(prefix.phase, FSP_PHASE_ESTABLISHED); - assert_eq!(prefix.flags, FSP_FLAG_CP); - assert_eq!(prefix.payload_len, 64); - assert!(prefix.has_coords()); - assert!(!prefix.is_unencrypted()); - } - - #[test] - fn test_common_prefix_parse_handshake() { - let data = [0x01, 0x00, 0x50, 0x00]; // ver=0, phase=1, flags=0, payload_len=80 - let prefix = FspCommonPrefix::parse(&data).unwrap(); - assert_eq!(prefix.version, 0); - assert_eq!(prefix.phase, FSP_PHASE_MSG1); - assert_eq!(prefix.flags, 0); - assert_eq!(prefix.payload_len, 80); - } - - #[test] - fn test_common_prefix_parse_error_signal() { - let data = [0x00, FSP_FLAG_U, 0x22, 0x00]; // ver=0, phase=0, U flag, payload_len=34 - let prefix = FspCommonPrefix::parse(&data).unwrap(); - assert_eq!(prefix.phase, FSP_PHASE_ESTABLISHED); - assert!(prefix.is_unencrypted()); - assert_eq!(prefix.payload_len, 34); - } - - #[test] - fn test_common_prefix_too_short() { - assert!(FspCommonPrefix::parse(&[0, 0, 0]).is_none()); - } - - // ===== Encrypted Header Tests ===== - - #[test] - fn test_encrypted_header_parse() { - let counter = 42u64; - let flags = FSP_FLAG_CP; - let payload_len = 100u16; - let header = build_fsp_header(counter, flags, payload_len); - - // Build a minimal packet: header + 16 bytes of fake ciphertext (tag) - let mut packet = Vec::from(header); - packet.extend_from_slice(&[0xaa; TAG_SIZE]); - - let parsed = FspEncryptedHeader::parse(&packet).unwrap(); - assert_eq!(parsed.counter, 42); - assert_eq!(parsed.flags, FSP_FLAG_CP); - assert_eq!(parsed.payload_len, 100); - assert!(parsed.has_coords()); - assert_eq!(parsed.header_bytes, header); - assert_eq!(parsed.data_offset(), FSP_HEADER_SIZE); - } - - #[test] - fn test_encrypted_header_too_short() { - let packet = vec![0x00; FSP_ENCRYPTED_MIN_SIZE - 1]; - assert!(FspEncryptedHeader::parse(&packet).is_none()); - } - - #[test] - fn test_encrypted_header_wrong_phase() { - let mut packet = vec![0x00; FSP_ENCRYPTED_MIN_SIZE]; - packet[0] = 0x01; // phase 1 (msg1), not established - assert!(FspEncryptedHeader::parse(&packet).is_none()); - } - - #[test] - fn test_encrypted_header_wrong_version() { - let mut packet = vec![0x00; FSP_ENCRYPTED_MIN_SIZE]; - packet[0] = 0x10; // version 1, phase 0 - assert!(FspEncryptedHeader::parse(&packet).is_none()); - } - - #[test] - fn test_encrypted_header_u_flag_rejected() { - let mut packet = vec![0x00; FSP_ENCRYPTED_MIN_SIZE]; - packet[1] = FSP_FLAG_U; // U flag set → not encrypted - assert!(FspEncryptedHeader::parse(&packet).is_none()); - } - - // ===== Build Header Tests ===== - - #[test] - fn test_build_fsp_header() { - let header = build_fsp_header(1000, FSP_FLAG_CP, 200); - assert_eq!(header[0], 0x00); // ver=0, phase=0 - assert_eq!(header[1], FSP_FLAG_CP); - assert_eq!(u16::from_le_bytes([header[2], header[3]]), 200); - assert_eq!( - u64::from_le_bytes([ - header[4], header[5], header[6], header[7], header[8], header[9], header[10], - header[11], - ]), - 1000 - ); - } - - #[test] - fn test_build_fsp_encrypted() { - let header = build_fsp_header(0, 0, 10); - let ciphertext = vec![0xCC; 26]; // 10 payload + 16 tag - let packet = build_fsp_encrypted(&header, &ciphertext); - assert_eq!(packet.len(), FSP_HEADER_SIZE + 26); - assert_eq!(&packet[..FSP_HEADER_SIZE], &header); - assert_eq!(&packet[FSP_HEADER_SIZE..], &ciphertext[..]); - } - - // ===== Handshake Prefix Tests ===== - - #[test] - fn test_build_fsp_handshake_prefix_msg1() { - let prefix = build_fsp_handshake_prefix(FSP_PHASE_MSG1, 100); - assert_eq!(prefix[0], 0x01); // ver=0, phase=1 - assert_eq!(prefix[1], 0x00); // flags zero - assert_eq!(u16::from_le_bytes([prefix[2], prefix[3]]), 100); - - let parsed = FspCommonPrefix::parse(&prefix).unwrap(); - assert_eq!(parsed.phase, FSP_PHASE_MSG1); - } - - #[test] - fn test_build_fsp_handshake_prefix_msg2() { - let prefix = build_fsp_handshake_prefix(FSP_PHASE_MSG2, 50); - assert_eq!(prefix[0], 0x02); // ver=0, phase=2 - assert_eq!(prefix[1], 0x00); - assert_eq!(u16::from_le_bytes([prefix[2], prefix[3]]), 50); - } - - #[test] - fn test_build_fsp_handshake_prefix_msg3() { - let prefix = build_fsp_handshake_prefix(FSP_PHASE_MSG3, 73); - assert_eq!(prefix[0], 0x03); // ver=0, phase=3 - assert_eq!(prefix[1], 0x00); // flags zero - assert_eq!(u16::from_le_bytes([prefix[2], prefix[3]]), 73); - - let parsed = FspCommonPrefix::parse(&prefix).unwrap(); - assert_eq!(parsed.phase, FSP_PHASE_MSG3); - } - - // ===== Error Prefix Tests ===== - - #[test] - fn test_build_fsp_error_prefix() { - let prefix = build_fsp_error_prefix(34); - assert_eq!(prefix[0], 0x00); // ver=0, phase=0 - assert_eq!(prefix[1], FSP_FLAG_U); - assert_eq!(u16::from_le_bytes([prefix[2], prefix[3]]), 34); - - let parsed = FspCommonPrefix::parse(&prefix).unwrap(); - assert!(parsed.is_unencrypted()); - assert_eq!(parsed.phase, FSP_PHASE_ESTABLISHED); - } - - // ===== Inner Header Tests ===== - - #[test] - fn test_inner_header_prepend_strip() { - let timestamp: u32 = 12345; - let msg_type: u8 = 0x10; - let inner_flags: u8 = 0x01; // SP bit - let payload = vec![0xAA, 0xBB, 0xCC]; - - let with_header = fsp_prepend_inner_header(timestamp, msg_type, inner_flags, &payload); - assert_eq!(with_header.len(), FSP_INNER_HEADER_SIZE + 3); - - let (ts, mt, flags, rest) = fsp_strip_inner_header(&with_header).unwrap(); - assert_eq!(ts, 12345); - assert_eq!(mt, 0x10); - assert_eq!(flags, 0x01); - assert_eq!(rest, &payload[..]); - } - - #[test] - fn test_inner_header_empty_payload() { - let with_header = fsp_prepend_inner_header(0, 0x13, 0, &[]); - assert_eq!(with_header.len(), FSP_INNER_HEADER_SIZE); - - let (ts, mt, flags, rest) = fsp_strip_inner_header(&with_header).unwrap(); - assert_eq!(ts, 0); - assert_eq!(mt, 0x13); - assert_eq!(flags, 0); - assert!(rest.is_empty()); - } - - #[test] - fn test_inner_header_too_short() { - assert!(fsp_strip_inner_header(&[0, 0, 0, 0, 0]).is_none()); // needs 6 bytes - assert!(fsp_strip_inner_header(&[]).is_none()); - } - - // ===== Flag Constants Tests ===== - - #[test] - fn test_flag_bits_distinct() { - // Cleartext flags don't overlap - assert_eq!(FSP_FLAG_CP & FSP_FLAG_K, 0); - assert_eq!(FSP_FLAG_CP & FSP_FLAG_U, 0); - assert_eq!(FSP_FLAG_K & FSP_FLAG_U, 0); - } - - #[test] - fn test_header_roundtrip() { - let counter = 0xDEADBEEF_12345678u64; - let flags = FSP_FLAG_CP | FSP_FLAG_K; - let payload_len = 1234u16; - - let header = build_fsp_header(counter, flags, payload_len); - let ciphertext = vec![0xFF; payload_len as usize + TAG_SIZE]; - let packet = build_fsp_encrypted(&header, &ciphertext); - - let parsed = FspEncryptedHeader::parse(&packet).unwrap(); - assert_eq!(parsed.counter, counter); - assert_eq!(parsed.flags, flags); - assert_eq!(parsed.payload_len, payload_len); - assert!(parsed.has_coords()); - assert_eq!(parsed.header_bytes, header); - } - - #[test] - fn test_all_message_types_through_prefix() { - // Encrypted (phase 0, no U) - let prefix = FspCommonPrefix::parse(&[0x00, 0x00, 0x10, 0x00]).unwrap(); - assert_eq!(prefix.phase, 0); - assert!(!prefix.is_unencrypted()); - - // Error signal (phase 0, U set) - let prefix = FspCommonPrefix::parse(&[0x00, FSP_FLAG_U, 0x22, 0x00]).unwrap(); - assert_eq!(prefix.phase, 0); - assert!(prefix.is_unencrypted()); - - // SessionSetup (phase 1) - let prefix = FspCommonPrefix::parse(&[0x01, 0x00, 0x50, 0x00]).unwrap(); - assert_eq!(prefix.phase, 1); - - // SessionAck (phase 2) - let prefix = FspCommonPrefix::parse(&[0x02, 0x00, 0x21, 0x00]).unwrap(); - assert_eq!(prefix.phase, 2); - - // SessionMsg3 (phase 3) - let prefix = FspCommonPrefix::parse(&[0x03, 0x00, 0x49, 0x00]).unwrap(); - assert_eq!(prefix.phase, 3); - } -} diff --git a/src/node/tests/forwarding.rs b/src/node/tests/forwarding.rs index a88b9f4..a682a21 100644 --- a/src/node/tests/forwarding.rs +++ b/src/node/tests/forwarding.rs @@ -5,9 +5,11 @@ //! multi-hop forwarding through live node topologies. use super::*; -use crate::node::session_wire::{FSP_FLAG_CP, build_fsp_header}; +use crate::proto::fsp::wire::{FSP_FLAG_CP, build_fsp_header}; +use crate::proto::fsp::{SessionAck, SessionSetup}; +use crate::proto::link::SessionDatagram; use crate::proto::stp::TreeCoordinate; -use crate::protocol::{SessionAck, SessionDatagram, SessionSetup, encode_coords}; +use crate::proto::stp::encode_coords; use spanning_tree::{ TestNode, cleanup_nodes, process_available_packets, run_tree_test, verify_tree_convergence, }; @@ -591,7 +593,7 @@ async fn test_forwarding_with_cache_warming_enables_routing() { // ============================================================================ use crate::node::TransportDropState; -use crate::node::handlers::session::mark_ipv6_ecn_ce; +use crate::proto::fsp::mark_ipv6_ecn_ce; use crate::transport::TransportId; /// Build a minimal IPv6 header (40 bytes) with specified ECN bits. diff --git a/src/node/tests/session.rs b/src/node/tests/session.rs index d12583f..18dc267 100644 --- a/src/node/tests/session.rs +++ b/src/node/tests/session.rs @@ -6,7 +6,8 @@ use crate::node::tests::spanning_tree::{ TestNode, cleanup_nodes, generate_random_edges, lock_large_network_test, process_available_packets, run_tree_test, run_tree_test_with_mtus, verify_tree_convergence, }; -use crate::protocol::{SessionAck, SessionDatagram}; +use crate::proto::fsp::SessionAck; +use crate::proto::link::SessionDatagram; /// Populate all nodes' coordinate caches with each other's coords. /// diff --git a/src/proto/bloom/mod.rs b/src/proto/bloom/mod.rs index 0a00c9f..2c195ff 100644 --- a/src/proto/bloom/mod.rs +++ b/src/proto/bloom/mod.rs @@ -15,8 +15,8 @@ //! computation + the send-debounce decision). //! - `limits.rs` — the v1 sizing constants. //! - `wire.rs` — `FilterAnnounce` + `encode`/`decode` (the std-tethered file). -//! It imports the shared `ProtocolError` and `LinkMessageType` downward from -//! `crate::protocol`. +//! It imports the shared [`crate::proto::Error`] and +//! [`crate::proto::link::LinkMessageType`] downward. mod core; mod limits; diff --git a/src/proto/bloom/tests/wire.rs b/src/proto/bloom/tests/wire.rs index f6a5e58..8bf4f3d 100644 --- a/src/proto/bloom/tests/wire.rs +++ b/src/proto/bloom/tests/wire.rs @@ -2,7 +2,7 @@ use crate::proto::bloom::BloomFilter; use crate::proto::bloom::FilterAnnounce; -use crate::protocol::LinkMessageType; +use crate::proto::link::LinkMessageType; use crate::testutil::make_node_addr; #[test] diff --git a/src/proto/bloom/wire.rs b/src/proto/bloom/wire.rs index 5946a4c..e36136b 100644 --- a/src/proto/bloom/wire.rs +++ b/src/proto/bloom/wire.rs @@ -1,8 +1,8 @@ //! FilterAnnounce message: bloom filter reachability propagation. use super::BloomFilter; -use crate::protocol::LinkMessageType; -use crate::protocol::ProtocolError; +use crate::proto::Error; +use crate::proto::link::LinkMessageType; /// Bloom filter announcement for reachability propagation. /// @@ -79,9 +79,9 @@ impl FilterAnnounce { /// ```text /// [0x20][sequence:8 LE][hash_count:1][size_class:1][filter_bits:variable] /// ``` - pub fn encode(&self) -> Result, ProtocolError> { + pub fn encode(&self) -> Result, Error> { if !self.is_valid() { - return Err(ProtocolError::Malformed( + return Err(Error::Malformed( "filter size does not match size_class".into(), )); } @@ -107,9 +107,9 @@ impl FilterAnnounce { /// Decode from link-layer payload (after msg_type byte stripped by dispatcher). /// /// The payload starts with the sequence field. - pub fn decode(payload: &[u8]) -> Result { + pub fn decode(payload: &[u8]) -> Result { if payload.len() < Self::MIN_PAYLOAD_SIZE { - return Err(ProtocolError::MessageTooShort { + return Err(Error::MessageTooShort { expected: Self::MIN_PAYLOAD_SIZE, got: payload.len(), }); @@ -121,7 +121,7 @@ impl FilterAnnounce { let sequence = u64::from_le_bytes( payload[pos..pos + 8] .try_into() - .map_err(|_| ProtocolError::Malformed("bad sequence".into()))?, + .map_err(|_| Error::Malformed("bad sequence".into()))?, ); pos += 8; @@ -135,7 +135,7 @@ impl FilterAnnounce { // Validate size_class range if size_class > Self::MAX_SIZE_CLASS { - return Err(ProtocolError::Malformed(format!( + return Err(Error::Malformed(format!( "invalid size_class: {size_class} (max {})", Self::MAX_SIZE_CLASS ))); @@ -143,7 +143,7 @@ impl FilterAnnounce { // v1 compliance check if size_class != super::V1_SIZE_CLASS { - return Err(ProtocolError::Malformed(format!( + return Err(Error::Malformed(format!( "unsupported size_class: {size_class} (v1 requires {})", super::V1_SIZE_CLASS ))); @@ -153,7 +153,7 @@ impl FilterAnnounce { let expected_filter_bytes = 512usize << size_class; let remaining = payload.len() - pos; if remaining != expected_filter_bytes { - return Err(ProtocolError::MessageTooShort { + return Err(Error::MessageTooShort { expected: Self::MIN_PAYLOAD_SIZE + expected_filter_bytes, got: payload.len(), }); @@ -161,7 +161,7 @@ impl FilterAnnounce { // Construct BloomFilter from bytes let filter = BloomFilter::from_slice(&payload[pos..], hash_count) - .map_err(|e| ProtocolError::Malformed(format!("invalid bloom filter: {e}")))?; + .map_err(|e| Error::Malformed(format!("invalid bloom filter: {e}")))?; let announce = Self { filter, diff --git a/src/proto/discovery/wire.rs b/src/proto/discovery/wire.rs index c654b9c..de7a80a 100644 --- a/src/proto/discovery/wire.rs +++ b/src/proto/discovery/wire.rs @@ -1,9 +1,9 @@ //! Discovery messages: LookupRequest and LookupResponse. use crate::NodeAddr; +use crate::proto::Error; use crate::proto::stp::TreeCoordinate; -use crate::protocol::ProtocolError; -use crate::protocol::session::{decode_coords, encode_coords}; +use crate::proto::stp::{decode_coords, encode_coords}; use secp256k1::schnorr::Signature; /// Request to discover a node's coordinates. @@ -96,11 +96,11 @@ impl LookupRequest { } /// Decode from wire format (after msg_type byte has been consumed). - pub fn decode(payload: &[u8]) -> Result { + pub fn decode(payload: &[u8]) -> Result { // Minimum: request_id(8) + target(16) + origin(16) + ttl(1) + min_mtu(2) // + coords_count(2) = 45 bytes if payload.len() < 45 { - return Err(ProtocolError::MessageTooShort { + return Err(Error::MessageTooShort { expected: 45, got: payload.len(), }); @@ -111,7 +111,7 @@ impl LookupRequest { let request_id = u64::from_le_bytes( payload[pos..pos + 8] .try_into() - .map_err(|_| ProtocolError::Malformed("bad request_id".into()))?, + .map_err(|_| Error::Malformed("bad request_id".into()))?, ); pos += 8; @@ -131,7 +131,7 @@ impl LookupRequest { let min_mtu = u16::from_le_bytes( payload[pos..pos + 2] .try_into() - .map_err(|_| ProtocolError::Malformed("bad min_mtu".into()))?, + .map_err(|_| Error::Malformed("bad min_mtu".into()))?, ); pos += 2; @@ -222,10 +222,10 @@ impl LookupResponse { } /// Decode from wire format (after msg_type byte has been consumed). - pub fn decode(payload: &[u8]) -> Result { + pub fn decode(payload: &[u8]) -> Result { // Minimum: request_id(8) + target(16) + path_mtu(2) + coords_count(2) + proof(64) = 92 if payload.len() < 92 { - return Err(ProtocolError::MessageTooShort { + return Err(Error::MessageTooShort { expected: 92, got: payload.len(), }); @@ -236,7 +236,7 @@ impl LookupResponse { let request_id = u64::from_le_bytes( payload[pos..pos + 8] .try_into() - .map_err(|_| ProtocolError::Malformed("bad request_id".into()))?, + .map_err(|_| Error::Malformed("bad request_id".into()))?, ); pos += 8; @@ -248,7 +248,7 @@ impl LookupResponse { let path_mtu = u16::from_le_bytes( payload[pos..pos + 2] .try_into() - .map_err(|_| ProtocolError::Malformed("bad path_mtu".into()))?, + .map_err(|_| Error::Malformed("bad path_mtu".into()))?, ); pos += 2; @@ -256,13 +256,13 @@ impl LookupResponse { pos += consumed; if payload.len() < pos + 64 { - return Err(ProtocolError::MessageTooShort { + return Err(Error::MessageTooShort { expected: pos + 64, got: payload.len(), }); } let proof = Signature::from_slice(&payload[pos..pos + 64]) - .map_err(|_| ProtocolError::Malformed("bad proof signature".into()))?; + .map_err(|_| Error::Malformed("bad proof signature".into()))?; Ok(Self { request_id, diff --git a/src/protocol/error.rs b/src/proto/error.rs similarity index 96% rename from src/protocol/error.rs rename to src/proto/error.rs index f2b9223..04658cb 100644 --- a/src/protocol/error.rs +++ b/src/proto/error.rs @@ -4,7 +4,7 @@ use thiserror::Error; /// Errors related to protocol message handling. #[derive(Debug, Error)] -pub enum ProtocolError { +pub enum Error { #[error("invalid message type: 0x{0:02x}")] InvalidMessageType(u8), diff --git a/src/proto/fmp/wire.rs b/src/proto/fmp/wire.rs index 93b42f7..0da6fd3 100644 --- a/src/proto/fmp/wire.rs +++ b/src/proto/fmp/wire.rs @@ -1,12 +1,13 @@ //! FMP link-framing messages: handshake message types and orderly disconnect. //! //! The Noise IK handshake message-type discriminants and the orderly -//! disconnect codec, relocated from `protocol::link` per the -//! wire-migrates-with-subsystem policy. `Disconnect::encode` reads the shared -//! `LinkMessageType::Disconnect` catalog variant (a downward `proto -> -//! protocol` dependency); the catalog itself stays in `protocol::link`. +//! 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`. -use crate::protocol::{LinkMessageType, ProtocolError}; +use crate::proto::Error; +use crate::proto::link::LinkMessageType; use std::fmt; /// Handshake message type identifiers. @@ -151,9 +152,9 @@ impl Disconnect { } /// Decode from link-layer payload (after msg_type byte has been consumed). - pub fn decode(payload: &[u8]) -> Result { + pub fn decode(payload: &[u8]) -> Result { if payload.is_empty() { - return Err(ProtocolError::MessageTooShort { + return Err(Error::MessageTooShort { expected: 1, got: 0, }); diff --git a/src/proto/fsp/core.rs b/src/proto/fsp/core.rs new file mode 100644 index 0000000..c948813 --- /dev/null +++ b/src/proto/fsp/core.rs @@ -0,0 +1,431 @@ +//! Sans-IO FSP session-rekey + epoch-reaction decision core. +//! +//! Pure, runtime-agnostic decisions for the FSP end-to-end session lifecycle: +//! the per-tick rekey choreography (initiator cutover, drain completion, rekey +//! trigger), msg3 retransmission classification, and the post-decrypt epoch +//! reaction. The async I/O adapters in `node::handlers::{rekey,session}` build +//! the plain-data snapshots (pre-computing every clock read into `u64`/`bool`), +//! call these decisions, and drive the returned effects — the sends, the +//! `SessionEntry` mutations, metrics, and logging. No I/O, no clock, no crypto, +//! no metrics, no logging here. +//! +//! The crypto-owning `SessionEntry` stays shell-side (`node::session`), so this +//! core carries no `proto -> noise` and no `proto -> node` dependency: the +//! trial-decrypt AEAD open runs shell-side and the resulting epoch is mapped to +//! the plain-data [`DecryptSlot`] mirror before it reaches [`Fsp::classify_epoch`]. + +use super::limits::FSP_CUTOVER_DELAY_MS; +use crate::proto::stp::TreeCoordinate; +use crate::{FipsAddress, NodeAddr}; + +/// FSP session-lifecycle subsystem anchor owned by [`Node`](crate::node::Node). +/// +/// Like [`Fmp`](crate::proto::fmp::Fmp), the FSP core owns **no** mutable state: +/// every `SessionEntry`/coord-cache/path-MTU mutation stays shell-side, driven +/// by the [`FspAction`]s (and outcome enums) the pure decisions emit. `Fsp` is a +/// stateless namespace anchor so the decisions can hang off a `Node` field +/// (`self.fsp`) in the same shape the other migrated subsystems use. +pub(crate) struct Fsp; + +impl Fsp { + /// Create the (stateless) FSP lifecycle anchor. + pub(crate) fn new() -> Self { + Self + } +} + +/// A registry/session effect the async shell performs on the core's behalf. +/// +/// Only the variants the stage-2 rekey/epoch decisions actually emit are +/// defined; the send/decrypt-path effects are added when their emitting arms +/// migrate (an unconstructed variant would trip `clippy::dead_code`). +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum FspAction { + /// Perform the initiator-side K-bit cutover to `addr`'s pending session + /// (`SessionEntry::cutover_to_new_session`). + CutOver { addr: NodeAddr }, + /// Complete `addr`'s drain window (`SessionEntry::complete_drain`): erase + /// the previous session. + CompleteDrain { addr: NodeAddr }, + /// Initiate a fresh outbound rekey to `addr` (`initiate_session_rekey`). + /// The XK msg1 construction is the shell-side establish leaf; the action + /// carries only the target. + InitiateRekey { addr: NodeAddr }, + /// Abandon `addr`'s in-flight rekey cycle (`SessionEntry::abandon_rekey`): + /// its msg3 went unconfirmed past the retransmission budget. + AbandonRekey { addr: NodeAddr }, + /// Retransmit `addr`'s retained rekey msg3 (the shell re-reads the payload + /// from the entry, sends it, then records the retransmission on success). + ResendSessionMsg3 { addr: NodeAddr }, + /// Cache `coords` for `addr` in the shared coordinate cache + /// (`coord_cache.insert`). + CacheCoords { + addr: NodeAddr, + coords: TreeCoordinate, + }, + /// Invalidate the shared cached coordinates for `addr` + /// (`coord_cache.remove`). + InvalidateCoords { addr: NodeAddr }, + /// Write `mtu` into the shared `FipsAddress`-keyed path-MTU lookup, keeping + /// the tighter of existing-or-new (the shell applies the write under the + /// `path_mtu_lookup` guard). + TightenPathMtuLookup { fips_addr: FipsAddress, mtu: u16 }, + /// Trigger discovery toward `dest` (`maybe_initiate_lookup`); emitted only + /// when the target's identity is cached. + InitiateLookup { dest: NodeAddr }, +} + +/// The rekey trigger thresholds, read shell-side from node config. +pub(crate) struct RekeyCfg { + /// Rekey after this many seconds of session age (before jitter). + pub after_secs: u64, + /// Rekey after this many sent messages. + pub after_messages: u64, +} + +/// A snapshot of one established session's rekey-relevant state, taken by the +/// shell so the core decides without touching the live `SessionEntry` or reading +/// a clock. +/// +/// Every clock read is resolved shell-side into a plain `u64`/`bool`: +/// `cutover_timer_elapsed`, `drain_expired`, and `is_dampened` are the +/// pre-evaluated timer predicates; `elapsed_secs` is the monotonic session age. +/// The core applies the rekey thresholds and jitter with **no** clock read. +pub(crate) struct SessionSnapshot { + /// The session's remote node address (cutover/drain/rekey target). + pub addr: NodeAddr, + /// A completed rekey session is pending, awaiting the K-bit cutover. + pub has_pending: bool, + /// A rekey handshake is currently in flight. + pub rekey_in_progress: bool, + /// We initiated the current rekey (only the initiator cuts over on the + /// liveness timer). + pub is_rekey_initiator: bool, + /// The initiator liveness-cutover delay has elapsed (pre-evaluated: + /// `now - rekey_completed_ms >= FSP_CUTOVER_DELAY_MS`). + pub cutover_timer_elapsed: bool, + /// The session is in its post-cutover drain window. + pub is_draining: bool, + /// The drain window has expired (pre-evaluated against the drain timer). + pub drain_expired: bool, + /// The initiator still retains a msg3 retransmission payload (a rekey cycle + /// is mid-flight; do not start another). + pub has_rekey_msg3_payload: bool, + /// Local rekey initiation is dampened after a recently received peer rekey + /// msg1 (pre-evaluated against the dampening timer). + pub is_dampened: bool, + /// Monotonic session age in seconds (`(now - session_start_ms) / 1000`). + pub elapsed_secs: u64, + /// Current Noise send counter. + pub counter: u64, + /// Per-session symmetric rekey jitter, added to the time threshold. + pub jitter_secs: i64, +} + +/// A snapshot of one session with a retained rekey-msg3 payload, taken by the +/// shell for the msg3 retransmission decision. +pub(crate) struct RekeyMsg3ResendSnapshot { + /// The session's remote node address (abandon/resend target). + pub addr: NodeAddr, + /// How many msg3 retransmissions have already happened. Drives the + /// abandon-vs-resend classification. + pub resend_count: u32, + /// The retained msg3 is due for retransmission as of the shell's `now_ms` + /// (pre-evaluated: `next_resend_ms != 0 && now_ms >= next_resend_ms`). + pub resend_due: bool, +} + +/// Which key epoch a just-decrypted frame authenticated against — the shell-side +/// [`EpochSlot`](crate::node::session::EpochSlot) mapped to a proto-local +/// plain-data mirror so the core carries no `node` dependency. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DecryptSlot { + /// Decrypted against the current (active) session — steady state. + Current, + /// Decrypted against the pending (new) session — the peer cut over first. + Pending, + /// Decrypted against the previous (draining) session — old-epoch straggler. + Previous, +} + +/// The reaction the shell drives after a frame authenticates against a given +/// epoch. Post-decrypt classification (§3): the shell opens the frame, the core +/// classifies, the shell applies the `SessionEntry` mutation + observability. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum EpochReaction { + /// A `pending` hit while a msg3 retransmission is retained: confirm the peer + /// on the new epoch (stop retransmitting), then promote (cutover). + PromoteConfirming, + /// A `pending` hit with no retained msg3: promote (cutover). + Promote, + /// A `current` hit as the already-cut-over initiator (msg3 retained, no + /// pending): confirm the responder reached the new epoch. + ConfirmResponder, + /// No state change (a steady-state `current` hit, or an old-epoch + /// `previous` straggler — the drain refresh already happened shell-side). + None, +} + +impl Fsp { + /// Decide the per-tick rekey choreography for the established sessions the + /// shell snapshotted. Reproduces `check_session_rekey`'s priority and phase + /// grouping exactly: + /// + /// - **Cutover** takes precedence: an initiator with a pending session, no + /// in-flight rekey, and an elapsed liveness timer cuts over and is + /// considered for nothing else. + /// - Otherwise an expired drain window is completed, and — independently — + /// the rekey trigger fires when the session is neither mid-rekey, holding + /// a pending session, retaining a msg3 payload, nor dampened, and its + /// jittered time threshold or send counter is reached. + /// + /// Actions are returned phase-grouped (all cutovers, then all drains, then + /// all rekey initiations) to preserve the pre-refactor execution order. + pub(crate) fn poll_rekey( + &self, + sessions: Vec, + cfg: &RekeyCfg, + ) -> Vec { + let mut cutovers = Vec::new(); + let mut drains = Vec::new(); + let mut rekeys = Vec::new(); + for s in sessions { + // 1. Initiator-side cutover (unconditional liveness timer). + if s.has_pending + && !s.rekey_in_progress + && s.is_rekey_initiator + && s.cutover_timer_elapsed + { + cutovers.push(FspAction::CutOver { addr: s.addr }); + continue; + } + // 2. Drain window expiry (does not preclude a trigger below). + if s.is_draining && s.drain_expired { + drains.push(FspAction::CompleteDrain { addr: s.addr }); + } + // 3. Rekey trigger. + if s.rekey_in_progress || s.has_pending || s.has_rekey_msg3_payload || s.is_dampened { + continue; + } + let effective_after = cfg.after_secs.saturating_add_signed(s.jitter_secs); + if s.elapsed_secs >= effective_after || s.counter >= cfg.after_messages { + rekeys.push(FspAction::InitiateRekey { addr: s.addr }); + } + } + cutovers.extend(drains); + cutovers.extend(rekeys); + cutovers + } + + /// Decide the msg3 retransmission choreography for the sessions the shell + /// snapshotted as retaining a msg3 payload. A due candidate past + /// `max_resends` has its cycle abandoned; an in-budget due candidate is + /// retransmitted. Candidates not yet due are neither abandoned nor resent + /// this tick (matching the pre-refactor due-gate ordering). + /// + /// Actions are returned abandons-first (matching the pre-refactor two-pass + /// order); the shell commits a retransmission's count++ and reschedule only + /// on a successful send. + pub(crate) fn poll_rekey_msg3_resends( + &self, + candidates: Vec, + max_resends: u32, + ) -> Vec { + let mut abandons = Vec::new(); + let mut resends = Vec::new(); + for c in candidates { + if !c.resend_due { + continue; + } + if c.resend_count >= max_resends { + abandons.push(FspAction::AbandonRekey { addr: c.addr }); + continue; + } + resends.push(FspAction::ResendSessionMsg3 { addr: c.addr }); + } + abandons.extend(resends); + abandons + } + + /// Classify the reaction to a frame that authenticated against `slot`. Pure + /// over the slot and the two plain-data session flags; the shell applies the + /// resulting `SessionEntry` mutation and observability. + /// + /// Mirrors the pre-refactor `handle_encrypted_session_msg` epoch reaction + /// exactly: a `pending` hit always promotes (confirming first when a msg3 + /// payload is retained); a `current` hit confirms the responder only when + /// the initiator already cut over (msg3 retained, no pending); a `previous` + /// hit is a no-op (the drain refresh happened during the shell-side open). + pub(crate) fn classify_epoch( + &self, + slot: DecryptSlot, + has_rekey_msg3_payload: bool, + has_pending: bool, + ) -> EpochReaction { + match slot { + DecryptSlot::Pending => { + if has_rekey_msg3_payload { + EpochReaction::PromoteConfirming + } else { + EpochReaction::Promote + } + } + DecryptSlot::Current => { + if has_rekey_msg3_payload && !has_pending { + EpochReaction::ConfirmResponder + } else { + EpochReaction::None + } + } + DecryptSlot::Previous => EpochReaction::None, + } + } + + /// Decide which cleartext-coords (CP flag) present on an inbound encrypted + /// frame to cache. Emits an ordered `CacheCoords` per present coordinate: + /// the source's coords keyed by `src_addr`, then the destination's coords + /// keyed by `my_addr` (our own address). Mirrors the pre-refactor + /// `handle_encrypted_session_msg` CP-coords caching order. + pub(crate) fn plan_cache_coords( + &self, + src_addr: NodeAddr, + my_addr: NodeAddr, + src_coords: Option, + dest_coords: Option, + ) -> Vec { + let mut actions = Vec::new(); + if let Some(coords) = src_coords { + actions.push(FspAction::CacheCoords { + addr: src_addr, + coords, + }); + } + if let Some(coords) = dest_coords { + actions.push(FspAction::CacheCoords { + addr: my_addr, + coords, + }); + } + actions + } + + /// Decide the discovery reaction to a `CoordsRequired` signal: trigger a + /// lookup toward `dest` only when the target's identity is cached (else the + /// `LookupResponse` proof cannot be verified). The rate-limited warmup send + /// and warmup-counter reset stay shell-side. + pub(crate) fn plan_coords_required_lookup( + &self, + dest: NodeAddr, + has_cached_identity: bool, + ) -> Vec { + if has_cached_identity { + vec![FspAction::InitiateLookup { dest }] + } else { + Vec::new() + } + } + + /// Decide the reaction to a `PathBroken` signal: unconditionally invalidate + /// the stale cached coordinates for `dest`, then (only when the identity is + /// cached) trigger re-discovery. Order is invalidate-then-lookup, matching + /// the pre-refactor handler. The warmup send and counter reset stay shell. + pub(crate) fn plan_path_broken( + &self, + dest: NodeAddr, + has_cached_identity: bool, + ) -> Vec { + let mut actions = vec![FspAction::InvalidateCoords { addr: dest }]; + if has_cached_identity { + actions.push(FspAction::InitiateLookup { dest }); + } + actions + } + + /// Decide whether a path-MTU update should tighten the shared lookup: emit + /// `TightenPathMtuLookup` only when `candidate` is at least as tight as the + /// `existing` value (keep-tighter, never loosen). The `existing` read and + /// the applied write are performed shell-side under one `path_mtu_lookup` + /// write guard, so the decision stays atomic. + pub(crate) fn plan_path_mtu_tighten( + &self, + fips_addr: FipsAddress, + existing: Option, + candidate: u16, + ) -> Vec { + if should_apply_path_mtu(existing, candidate) { + vec![FspAction::TightenPathMtuLookup { + fips_addr, + mtu: candidate, + }] + } else { + Vec::new() + } + } +} + +/// Pre-evaluate the initiator liveness-cutover timer for a session: whether the +/// `FSP_CUTOVER_DELAY_MS` bound has elapsed since the rekey handshake completed. +/// Resolved shell-side into [`SessionSnapshot::cutover_timer_elapsed`]. +pub(crate) fn cutover_timer_elapsed(now_ms: u64, rekey_completed_ms: u64) -> bool { + now_ms.saturating_sub(rekey_completed_ms) >= FSP_CUTOVER_DELAY_MS +} + +/// Determine the winner of an FSP session-initiation tie-break: the node with +/// the smaller `NodeAddr` wins as initiator. Deterministic and symmetric — both +/// endpoints reach the same conclusion. Used for both simultaneous session +/// setup and dual rekey initiation. +/// +/// Returns `true` if *we* win (keep initiating, drop the peer's message). +pub(crate) fn initiation_winner(our_node_addr: &NodeAddr, their_node_addr: &NodeAddr) -> bool { + our_node_addr < their_node_addr +} + +/// Decide whether a path-MTU update should be applied to the shared +/// `FipsAddress`-keyed lookup: keep the tighter of existing-or-candidate, never +/// loosen. Returns `true` when `candidate` should be written (there is no +/// existing value, or the candidate is at least as tight). +pub(crate) fn should_apply_path_mtu(existing: Option, candidate: u16) -> bool { + !matches!(existing, Some(existing) if existing <= candidate) +} + +/// Push `packet` onto a bounded per-destination pending queue, dropping the +/// oldest entry first when the queue is at `per_dest` capacity. Pure transform +/// over a passed-in queue (the max-destinations cap is a shell-side map-level +/// check). +pub(crate) fn push_bounded_pending( + queue: &mut alloc::collections::VecDeque>, + packet: Vec, + per_dest: usize, +) { + if queue.len() >= per_dest { + queue.pop_front(); // Drop oldest + } + queue.push_back(packet); +} + +/// Mark ECN-CE in an IPv6 packet's Traffic Class field. +/// +/// IPv6 Traffic Class occupies bits across bytes 0 and 1: +/// byte[0] bits[3:0] = TC[7:4] +/// byte[1] bits[7:4] = TC[3:0] +/// ECN is TC[1:0]. Only marks CE (0b11) if the packet is ECN-capable (ECT(0) or +/// ECT(1)). Packets with ECN=0b00 (Not-ECT) are never marked per RFC 3168. +/// +/// No checksum update needed: IPv6 has no header checksum, and the Traffic Class +/// field is not part of the TCP/UDP pseudo-header. +pub(crate) fn mark_ipv6_ecn_ce(packet: &mut [u8]) { + if packet.len() < 2 { + return; + } + // Extract 8-bit Traffic Class from IPv6 header bytes 0-1 + let tc = ((packet[0] & 0x0F) << 4) | (packet[1] >> 4); + let ecn = tc & 0x03; + // Only mark CE on ECN-capable packets (ECT(0)=0b10 or ECT(1)=0b01) + if ecn == 0 { + return; + } + // Set both ECN bits to 1 (CE = 0b11) + let new_tc = tc | 0x03; + packet[0] = (packet[0] & 0xF0) | (new_tc >> 4); + packet[1] = (new_tc << 4) | (packet[1] & 0x0F); +} diff --git a/src/proto/fsp/limits.rs b/src/proto/fsp/limits.rs new file mode 100644 index 0000000..fd6a7ce --- /dev/null +++ b/src/proto/fsp/limits.rs @@ -0,0 +1,23 @@ +//! FSP session-rekey timing constants. +//! +//! Pure, runtime-agnostic bounds for the session-rekey lifecycle, relocated +//! from the async `node::handlers::rekey` shell. The shell resolves the clock +//! and pre-evaluates the timer predicates against these; the core reads only +//! the resulting plain-data snapshot fields. + +/// Keep the previous session alive for this long after cutover. +pub(crate) const DRAIN_WINDOW_SECS: u64 = 10; + +/// Suppress local rekey initiation for this long after receiving a peer's +/// rekey msg1. +pub(crate) const REKEY_DAMPENING_SECS: u64 = 30; + +/// Liveness bound on how long the FSP rekey initiator holds the `current` + +/// `pending` state before cutting over to the new epoch. +/// +/// This is NOT safety-critical: overlapping-epoch trial-decrypt covers any +/// skew between the two endpoints' cutovers. The timer only bounds how long the +/// initiator advertises the old K-bit. An opportunistic early cutover also +/// fires if the initiator authenticates a peer frame against its own `pending` +/// session (the responder cut over first). +pub(crate) const FSP_CUTOVER_DELAY_MS: u64 = 2000; diff --git a/src/proto/fsp/mod.rs b/src/proto/fsp/mod.rs new file mode 100644 index 0000000..50c73e7 --- /dev/null +++ b/src/proto/fsp/mod.rs @@ -0,0 +1,35 @@ +//! Sans-IO FSP (end-to-end session) protocol. +//! +//! The FSP session **wire** — the message types +//! (`SessionSetup`/`SessionAck`/`SessionMsg3`), the packet flags, the +//! `SessionMessageType` inner-message catalog, and the prefix/header/ +//! inner-header codec — migrated out of the async node shell (`protocol::session` +//! + `node::session_wire`) per the wire-migrates-with-subsystem policy. +//! +//! The crypto-owning `SessionEntry` session state machine stays shell-side in +//! `node::session`, so `proto::fsp` carries no `proto -> noise` dependency and +//! no crypto. It imports the shared [`crate::proto::Error`] and the +//! address-only coordinate helpers downward from `crate::proto::stp`. +//! +//! - `core.rs` — the stateless [`Fsp`] anchor + [`FspAction`]: the pure rekey +//! choreography (`poll_rekey`/`poll_rekey_msg3_resends`), the post-decrypt +//! `classify_epoch`, the initiation tie-break, and the pure MTU-clamp / +//! bounded-queue / ECN transforms. No clock/crypto/I/O/tracing. +//! - `limits.rs` — the session-rekey timing constants. +//! - `wire.rs` — the FSP session wire codec and message types. Clock-free, +//! crypto-free. + +pub(crate) mod core; +pub(crate) mod limits; +pub(crate) mod wire; + +#[cfg(test)] +mod tests; + +pub(crate) use core::{ + DecryptSlot, EpochReaction, Fsp, FspAction, RekeyCfg, RekeyMsg3ResendSnapshot, SessionSnapshot, + cutover_timer_elapsed, initiation_winner, mark_ipv6_ecn_ce, push_bounded_pending, +}; +pub use wire::{ + FspInnerFlags, SessionAck, SessionFlags, SessionMessageType, SessionMsg3, SessionSetup, +}; diff --git a/src/proto/fsp/tests/core.rs b/src/proto/fsp/tests/core.rs new file mode 100644 index 0000000..5c41b62 --- /dev/null +++ b/src/proto/fsp/tests/core.rs @@ -0,0 +1,457 @@ +//! Tests for the pure FSP session-rekey + epoch-reaction decision core. + +use crate::FipsAddress; +use crate::proto::fsp::core::{ + DecryptSlot, EpochReaction, Fsp, FspAction, RekeyCfg, RekeyMsg3ResendSnapshot, SessionSnapshot, + cutover_timer_elapsed, initiation_winner, mark_ipv6_ecn_ce, push_bounded_pending, + should_apply_path_mtu, +}; +use crate::proto::fsp::limits::FSP_CUTOVER_DELAY_MS; +use crate::proto::stp::TreeCoordinate; +use crate::testutil::make_node_addr; +use std::collections::VecDeque; + +fn coords(byte: u8) -> TreeCoordinate { + TreeCoordinate::from_addrs(vec![make_node_addr(byte)]).unwrap() +} + +/// A quiescent established-session snapshot: no pending cutover, no drain, no +/// dampening, zero ages/counter/jitter. Tests set only the fields they exercise. +fn session_snapshot(addr_byte: u8) -> SessionSnapshot { + SessionSnapshot { + addr: make_node_addr(addr_byte), + has_pending: false, + rekey_in_progress: false, + is_rekey_initiator: false, + cutover_timer_elapsed: false, + is_draining: false, + drain_expired: false, + has_rekey_msg3_payload: false, + is_dampened: false, + elapsed_secs: 0, + counter: 0, + jitter_secs: 0, + } +} + +fn cfg(after_secs: u64, after_messages: u64) -> RekeyCfg { + RekeyCfg { + after_secs, + after_messages, + } +} + +// ===== poll_rekey ===== + +#[test] +fn poll_rekey_quiescent_emits_nothing() { + let fsp = Fsp::new(); + let snaps = vec![session_snapshot(1), session_snapshot(2)]; + assert!(fsp.poll_rekey(snaps, &cfg(100, 1000)).is_empty()); +} + +#[test] +fn poll_rekey_initiator_cutover_when_timer_elapsed() { + let fsp = Fsp::new(); + let mut s = session_snapshot(1); + s.has_pending = true; + s.is_rekey_initiator = true; + s.cutover_timer_elapsed = true; + assert_eq!( + fsp.poll_rekey(vec![s], &cfg(100, 1000)), + vec![FspAction::CutOver { + addr: make_node_addr(1) + }] + ); +} + +#[test] +fn poll_rekey_no_cutover_before_timer_or_when_rekey_in_progress() { + let fsp = Fsp::new(); + // Timer not elapsed → no cutover. + let mut s = session_snapshot(1); + s.has_pending = true; + s.is_rekey_initiator = true; + s.cutover_timer_elapsed = false; + assert!(fsp.poll_rekey(vec![s], &cfg(100, 1000)).is_empty()); + + // Rekey still in progress → no cutover (and pending guards the trigger). + let mut s = session_snapshot(1); + s.has_pending = true; + s.is_rekey_initiator = true; + s.cutover_timer_elapsed = true; + s.rekey_in_progress = true; + assert!(fsp.poll_rekey(vec![s], &cfg(100, 1000)).is_empty()); + + // Responder side (not initiator) → no cutover. + let mut s = session_snapshot(1); + s.has_pending = true; + s.cutover_timer_elapsed = true; + assert!(fsp.poll_rekey(vec![s], &cfg(100, 1000)).is_empty()); +} + +#[test] +fn poll_rekey_completes_expired_drain() { + let fsp = Fsp::new(); + let mut s = session_snapshot(3); + s.is_draining = true; + s.drain_expired = true; + assert_eq!( + fsp.poll_rekey(vec![s], &cfg(100, 1000)), + vec![FspAction::CompleteDrain { + addr: make_node_addr(3) + }] + ); +} + +#[test] +fn poll_rekey_trigger_on_time_or_counter() { + let fsp = Fsp::new(); + // Time threshold reached. + let mut s = session_snapshot(4); + s.elapsed_secs = 100; + assert_eq!( + fsp.poll_rekey(vec![s], &cfg(100, 1000)), + vec![FspAction::InitiateRekey { + addr: make_node_addr(4) + }] + ); + // Counter threshold reached, time not. + let mut s = session_snapshot(4); + s.counter = 1000; + assert_eq!( + fsp.poll_rekey(vec![s], &cfg(100, 1000)), + vec![FspAction::InitiateRekey { + addr: make_node_addr(4) + }] + ); + // Neither reached. + let mut s = session_snapshot(4); + s.elapsed_secs = 99; + s.counter = 999; + assert!(fsp.poll_rekey(vec![s], &cfg(100, 1000)).is_empty()); +} + +#[test] +fn poll_rekey_jitter_shifts_time_threshold() { + let fsp = Fsp::new(); + // Positive jitter raises the effective threshold above the config value. + let mut s = session_snapshot(5); + s.elapsed_secs = 100; + s.jitter_secs = 5; + assert!(fsp.poll_rekey(vec![s], &cfg(100, 1000)).is_empty()); + // Negative jitter lowers it. + let mut s = session_snapshot(5); + s.elapsed_secs = 96; + s.jitter_secs = -5; + assert_eq!( + fsp.poll_rekey(vec![s], &cfg(100, 1000)), + vec![FspAction::InitiateRekey { + addr: make_node_addr(5) + }] + ); +} + +#[test] +fn poll_rekey_trigger_suppressed_by_guards() { + let fsp = Fsp::new(); + for set in [ + |s: &mut SessionSnapshot| s.rekey_in_progress = true, + |s: &mut SessionSnapshot| s.has_pending = true, + |s: &mut SessionSnapshot| s.has_rekey_msg3_payload = true, + |s: &mut SessionSnapshot| s.is_dampened = true, + ] { + let mut s = session_snapshot(6); + s.elapsed_secs = 1000; + set(&mut s); + assert!( + fsp.poll_rekey(vec![s], &cfg(100, 1000)).is_empty(), + "trigger must be suppressed by its guard" + ); + } +} + +#[test] +fn poll_rekey_cutover_precludes_drain_and_trigger_but_phase_grouped() { + let fsp = Fsp::new(); + // One session eligible for cutover, another draining, another triggering. + let mut a = session_snapshot(1); + a.has_pending = true; + a.is_rekey_initiator = true; + a.cutover_timer_elapsed = true; + let mut b = session_snapshot(2); + b.is_draining = true; + b.drain_expired = true; + let mut c = session_snapshot(3); + c.counter = 5000; + let actions = fsp.poll_rekey(vec![a, b, c], &cfg(100, 1000)); + assert_eq!( + actions, + vec![ + FspAction::CutOver { + addr: make_node_addr(1) + }, + FspAction::CompleteDrain { + addr: make_node_addr(2) + }, + FspAction::InitiateRekey { + addr: make_node_addr(3) + }, + ], + "phase grouped: all cutovers, then drains, then rekeys" + ); +} + +// ===== poll_rekey_msg3_resends ===== + +fn msg3_snapshot(addr_byte: u8, resend_count: u32, resend_due: bool) -> RekeyMsg3ResendSnapshot { + RekeyMsg3ResendSnapshot { + addr: make_node_addr(addr_byte), + resend_count, + resend_due, + } +} + +#[test] +fn poll_msg3_not_due_is_noop() { + let fsp = Fsp::new(); + assert!( + fsp.poll_rekey_msg3_resends(vec![msg3_snapshot(1, 0, false)], 3) + .is_empty() + ); + // Not due even past the budget: neither abandon nor resend this tick. + assert!( + fsp.poll_rekey_msg3_resends(vec![msg3_snapshot(1, 99, false)], 3) + .is_empty() + ); +} + +#[test] +fn poll_msg3_resend_when_due_in_budget() { + let fsp = Fsp::new(); + assert_eq!( + fsp.poll_rekey_msg3_resends(vec![msg3_snapshot(2, 1, true)], 3), + vec![FspAction::ResendSessionMsg3 { + addr: make_node_addr(2) + }] + ); +} + +#[test] +fn poll_msg3_abandon_at_budget() { + let fsp = Fsp::new(); + assert_eq!( + fsp.poll_rekey_msg3_resends(vec![msg3_snapshot(3, 3, true)], 3), + vec![FspAction::AbandonRekey { + addr: make_node_addr(3) + }] + ); +} + +#[test] +fn poll_msg3_abandons_first() { + let fsp = Fsp::new(); + let actions = fsp.poll_rekey_msg3_resends( + vec![msg3_snapshot(1, 0, true), msg3_snapshot(2, 5, true)], + 3, + ); + assert_eq!( + actions, + vec![ + FspAction::AbandonRekey { + addr: make_node_addr(2) + }, + FspAction::ResendSessionMsg3 { + addr: make_node_addr(1) + }, + ], + "abandons are grouped before resends" + ); +} + +// ===== classify_epoch ===== + +#[test] +fn classify_epoch_pending_promotes_confirming_with_retained_msg3() { + let fsp = Fsp::new(); + assert_eq!( + fsp.classify_epoch(DecryptSlot::Pending, true, true), + EpochReaction::PromoteConfirming + ); + assert_eq!( + fsp.classify_epoch(DecryptSlot::Pending, false, true), + EpochReaction::Promote + ); +} + +#[test] +fn classify_epoch_current_confirms_responder_only_when_cut_over() { + let fsp = Fsp::new(); + // Initiator cut over (msg3 retained, no pending) → confirm. + assert_eq!( + fsp.classify_epoch(DecryptSlot::Current, true, false), + EpochReaction::ConfirmResponder + ); + // msg3 retained but still holding pending → no confirm. + assert_eq!( + fsp.classify_epoch(DecryptSlot::Current, true, true), + EpochReaction::None + ); + // Steady state → no reaction. + assert_eq!( + fsp.classify_epoch(DecryptSlot::Current, false, false), + EpochReaction::None + ); +} + +#[test] +fn classify_epoch_previous_is_noop() { + let fsp = Fsp::new(); + assert_eq!( + fsp.classify_epoch(DecryptSlot::Previous, true, true), + EpochReaction::None + ); +} + +// ===== pure helpers ===== + +#[test] +fn initiation_winner_smaller_addr_wins() { + let small = make_node_addr(1); + let large = make_node_addr(2); + assert!(initiation_winner(&small, &large)); + assert!(!initiation_winner(&large, &small)); +} + +#[test] +fn cutover_timer_boundary() { + assert!(!cutover_timer_elapsed( + 1_000 + FSP_CUTOVER_DELAY_MS - 1, + 1_000 + )); + assert!(cutover_timer_elapsed(1_000 + FSP_CUTOVER_DELAY_MS, 1_000)); +} + +// ===== coords / MTU emit-policy ===== + +#[test] +fn plan_cache_coords_emits_present_in_order() { + let fsp = Fsp::new(); + let src = make_node_addr(1); + let me = make_node_addr(2); + // Both present: src keyed by src_addr, dest keyed by my_addr, in order. + assert_eq!( + fsp.plan_cache_coords(src, me, Some(coords(3)), Some(coords(4))), + vec![ + FspAction::CacheCoords { + addr: src, + coords: coords(3) + }, + FspAction::CacheCoords { + addr: me, + coords: coords(4) + }, + ] + ); + // Only source present. + assert_eq!( + fsp.plan_cache_coords(src, me, Some(coords(3)), None), + vec![FspAction::CacheCoords { + addr: src, + coords: coords(3) + }] + ); + // Neither present. + assert!(fsp.plan_cache_coords(src, me, None, None).is_empty()); +} + +#[test] +fn plan_coords_required_lookup_gated_on_identity() { + let fsp = Fsp::new(); + let dest = make_node_addr(5); + assert_eq!( + fsp.plan_coords_required_lookup(dest, true), + vec![FspAction::InitiateLookup { dest }] + ); + assert!(fsp.plan_coords_required_lookup(dest, false).is_empty()); +} + +#[test] +fn plan_path_broken_invalidates_then_lookups() { + let fsp = Fsp::new(); + let dest = make_node_addr(6); + // Cached identity: invalidate, then lookup — in that order. + assert_eq!( + fsp.plan_path_broken(dest, true), + vec![ + FspAction::InvalidateCoords { addr: dest }, + FspAction::InitiateLookup { dest }, + ] + ); + // No cached identity: invalidate only (still unconditional). + assert_eq!( + fsp.plan_path_broken(dest, false), + vec![FspAction::InvalidateCoords { addr: dest }] + ); +} + +#[test] +fn plan_path_mtu_tighten_emits_only_when_tighter() { + let fsp = Fsp::new(); + let fa = FipsAddress::from_node_addr(&make_node_addr(7)); + // No existing, or candidate tighter → emit. + assert_eq!( + fsp.plan_path_mtu_tighten(fa, None, 1400), + vec![FspAction::TightenPathMtuLookup { + fips_addr: fa, + mtu: 1400 + }] + ); + assert_eq!( + fsp.plan_path_mtu_tighten(fa, Some(1500), 1400), + vec![FspAction::TightenPathMtuLookup { + fips_addr: fa, + mtu: 1400 + }] + ); + // Existing already tighter or equal → keep, emit nothing. + assert!(fsp.plan_path_mtu_tighten(fa, Some(1300), 1400).is_empty()); + assert!(fsp.plan_path_mtu_tighten(fa, Some(1400), 1400).is_empty()); +} + +#[test] +fn should_apply_path_mtu_keeps_tighter() { + assert!(should_apply_path_mtu(None, 1400)); // no existing → apply + assert!(should_apply_path_mtu(Some(1500), 1400)); // candidate tighter → apply + assert!(!should_apply_path_mtu(Some(1300), 1400)); // existing tighter → keep + assert!(!should_apply_path_mtu(Some(1400), 1400)); // equal → keep +} + +#[test] +fn push_bounded_pending_drops_oldest_at_capacity() { + let mut q: VecDeque> = VecDeque::new(); + push_bounded_pending(&mut q, vec![1], 2); + push_bounded_pending(&mut q, vec![2], 2); + push_bounded_pending(&mut q, vec![3], 2); // drops [1] + assert_eq!(q.len(), 2); + assert_eq!(q.front(), Some(&vec![2])); + assert_eq!(q.back(), Some(&vec![3])); +} + +#[test] +fn mark_ipv6_ecn_ce_marks_only_ect() { + // ECT(0): TC low bits 0b10. Byte1 high nibble carries TC[3:0]. + let mut ect0 = [0x60, 0x20, 0, 0]; + mark_ipv6_ecn_ce(&mut ect0); + assert_eq!(ect0[1] & 0x30, 0x30, "ECN bits set to CE"); + + // Not-ECT (ECN 0b00) is never marked. + let mut not_ect = [0x60, 0x00, 0, 0]; + mark_ipv6_ecn_ce(&mut not_ect); + assert_eq!(not_ect[1] & 0x30, 0x00, "Not-ECT stays unmarked"); + + // Too short → no panic, no change. + let mut short = [0x60]; + mark_ipv6_ecn_ce(&mut short); + assert_eq!(short, [0x60]); +} diff --git a/src/proto/fsp/tests/mod.rs b/src/proto/fsp/tests/mod.rs new file mode 100644 index 0000000..e0f8f26 --- /dev/null +++ b/src/proto/fsp/tests/mod.rs @@ -0,0 +1,2 @@ +mod core; +mod wire; diff --git a/src/proto/fsp/tests/wire.rs b/src/proto/fsp/tests/wire.rs new file mode 100644 index 0000000..4833f99 --- /dev/null +++ b/src/proto/fsp/tests/wire.rs @@ -0,0 +1,558 @@ +//! Tests for the FSP session wire codec and message types. + +use crate::NodeAddr; +use crate::proto::fsp::wire::*; +use crate::proto::stp::TreeCoordinate; + +fn make_node_addr(val: u8) -> NodeAddr { + let mut bytes = [0u8; 16]; + bytes[0] = val; + NodeAddr::from_bytes(bytes) +} + +fn make_coords(ids: &[u8]) -> TreeCoordinate { + TreeCoordinate::from_addrs(ids.iter().map(|&v| make_node_addr(v)).collect()).unwrap() +} + +// ===== SessionMessageType Tests ===== + +#[test] +fn test_session_message_type_roundtrip() { + let types = [ + SessionMessageType::DataPacket, + SessionMessageType::SenderReport, + SessionMessageType::ReceiverReport, + SessionMessageType::PathMtuNotification, + SessionMessageType::CoordsWarmup, + ]; + + for ty in types { + let byte = ty.to_byte(); + let restored = SessionMessageType::from_byte(byte); + assert_eq!(restored, Some(ty)); + } +} + +#[test] +fn test_session_message_type_invalid() { + assert!(SessionMessageType::from_byte(0xFF).is_none()); + assert!(SessionMessageType::from_byte(0x99).is_none()); + // The 0x20-0x2F routing-signal range is no longer part of this enum. + assert!(SessionMessageType::from_byte(0x20).is_none()); + assert!(SessionMessageType::from_byte(0x22).is_none()); +} + +// ===== SessionFlags Tests ===== + +#[test] +fn test_session_flags() { + let flags = SessionFlags::new().with_ack().bidirectional(); + + assert!(flags.request_ack); + assert!(flags.bidirectional); + + let byte = flags.to_byte(); + let restored = SessionFlags::from_byte(byte); + + assert_eq!(flags, restored); +} + +#[test] +fn test_session_flags_default() { + let flags = SessionFlags::new(); + assert!(!flags.request_ack); + assert!(!flags.bidirectional); + assert_eq!(flags.to_byte(), 0); +} + +// ===== SessionSetup Tests ===== + +#[test] +fn test_session_setup() { + let setup = SessionSetup::new(make_coords(&[1, 0]), make_coords(&[2, 0])) + .with_flags(SessionFlags::new().with_ack()); + + assert!(setup.flags.request_ack); + assert!(!setup.flags.bidirectional); +} + +// ===== Encode/Decode Roundtrip Tests ===== + +#[test] +fn test_session_setup_encode_decode() { + let handshake = vec![0xAA; 82]; // typical Noise IK msg1 + let setup = SessionSetup::new(make_coords(&[1, 2, 0]), make_coords(&[3, 4, 0])) + .with_flags(SessionFlags::new().with_ack().bidirectional()) + .with_handshake(handshake.clone()); + + let encoded = setup.encode(); + + // Verify FSP prefix: ver_phase=0x01 (version 0, phase MSG1) + assert_eq!(encoded[0], 0x01); + assert_eq!(encoded[1], 0x00); // flags = 0 for handshake + let payload_len = u16::from_le_bytes([encoded[2], encoded[3]]); + assert_eq!(payload_len as usize, encoded.len() - 4); + + // Decode (skip 4-byte FSP prefix) + let decoded = SessionSetup::decode(&encoded[4..]).unwrap(); + + assert_eq!(decoded.flags, setup.flags); + assert_eq!(decoded.src_coords, setup.src_coords); + assert_eq!(decoded.dest_coords, setup.dest_coords); + assert_eq!(decoded.handshake_payload, handshake); +} + +#[test] +fn test_session_setup_no_handshake() { + let setup = SessionSetup::new(make_coords(&[5, 0]), make_coords(&[6, 0])); + + let encoded = setup.encode(); + let decoded = SessionSetup::decode(&encoded[4..]).unwrap(); + + assert!(decoded.handshake_payload.is_empty()); + assert_eq!(decoded.src_coords, setup.src_coords); + assert_eq!(decoded.dest_coords, setup.dest_coords); +} + +#[test] +fn test_session_ack_encode_decode() { + let handshake = vec![0xBB; 33]; // typical Noise IK msg2 + let ack = SessionAck::new(make_coords(&[7, 8, 0]), make_coords(&[3, 4, 0])) + .with_handshake(handshake.clone()); + + let encoded = ack.encode(); + // Verify FSP prefix: ver_phase=0x02 (version 0, phase MSG2) + assert_eq!(encoded[0], 0x02); + assert_eq!(encoded[1], 0x00); // flags = 0 for handshake + + let decoded = SessionAck::decode(&encoded[4..]).unwrap(); + assert_eq!(decoded.src_coords, ack.src_coords); + assert_eq!(decoded.dest_coords, ack.dest_coords); + assert_eq!(decoded.handshake_payload, handshake); +} + +#[test] +fn test_session_setup_decode_too_short() { + assert!(SessionSetup::decode(&[]).is_err()); +} + +#[test] +fn test_session_ack_decode_too_short() { + assert!(SessionAck::decode(&[]).is_err()); +} + +#[test] +fn test_session_setup_deep_coords() { + // Depth-10 coordinate (11 entries: self + 10 ancestors) + let addrs: Vec = (0..11).collect(); + let src = make_coords(&addrs); + let dest = make_coords(&[20, 21, 22, 23, 24]); + let setup = SessionSetup::new(src.clone(), dest.clone()).with_handshake(vec![0x55; 82]); + + let encoded = setup.encode(); + let decoded = SessionSetup::decode(&encoded[4..]).unwrap(); + + assert_eq!(decoded.src_coords, src); + assert_eq!(decoded.dest_coords, dest); +} + +// ===== FspFlags Tests ===== + +#[test] +fn test_fsp_flags_default() { + let flags = FspFlags::new(); + assert!(!flags.coords_present); + assert!(!flags.key_epoch); + assert!(!flags.unencrypted); + assert_eq!(flags.to_byte(), 0x00); +} + +#[test] +fn test_fsp_flags_roundtrip() { + // All combinations of 3 bits + for byte in 0u8..=0x07 { + let flags = FspFlags::from_byte(byte); + assert_eq!(flags.to_byte(), byte); + } +} + +#[test] +fn test_fsp_flags_individual_bits() { + let cp = FspFlags::from_byte(0x01); + assert!(cp.coords_present); + assert!(!cp.key_epoch); + assert!(!cp.unencrypted); + + let k = FspFlags::from_byte(0x02); + assert!(!k.coords_present); + assert!(k.key_epoch); + assert!(!k.unencrypted); + + let u = FspFlags::from_byte(0x04); + assert!(!u.coords_present); + assert!(!u.key_epoch); + assert!(u.unencrypted); +} + +#[test] +fn test_fsp_flags_ignores_reserved_bits() { + // Reserved bits in upper 5 bits are not preserved + let flags = FspFlags::from_byte(0xFF); + assert!(flags.coords_present); + assert!(flags.key_epoch); + assert!(flags.unencrypted); + assert_eq!(flags.to_byte(), 0x07); // only lower 3 bits +} + +// ===== FspInnerFlags Tests ===== + +#[test] +fn test_fsp_inner_flags_default() { + let flags = FspInnerFlags::new(); + assert!(!flags.spin_bit); + assert_eq!(flags.to_byte(), 0x00); +} + +#[test] +fn test_fsp_inner_flags_roundtrip() { + let flags = FspInnerFlags::from_byte(0x01); + assert!(flags.spin_bit); + assert_eq!(flags.to_byte(), 0x01); + + let flags = FspInnerFlags::from_byte(0x00); + assert!(!flags.spin_bit); + assert_eq!(flags.to_byte(), 0x00); +} + +#[test] +fn test_fsp_inner_flags_ignores_reserved() { + let flags = FspInnerFlags::from_byte(0xFE); + assert!(!flags.spin_bit); + assert_eq!(flags.to_byte(), 0x00); + + let flags = FspInnerFlags::from_byte(0xFF); + assert!(flags.spin_bit); + assert_eq!(flags.to_byte(), 0x01); +} + +// ===== New SessionMessageType Values ===== + +#[test] +fn test_session_message_type_new_values() { + assert_eq!(SessionMessageType::SenderReport.to_byte(), 0x11); + assert_eq!(SessionMessageType::ReceiverReport.to_byte(), 0x12); + assert_eq!(SessionMessageType::PathMtuNotification.to_byte(), 0x13); +} + +#[test] +fn test_session_message_type_display() { + assert_eq!( + format!("{}", SessionMessageType::SenderReport), + "SenderReport" + ); + assert_eq!( + format!("{}", SessionMessageType::ReceiverReport), + "ReceiverReport" + ); + assert_eq!( + format!("{}", SessionMessageType::PathMtuNotification), + "PathMtuNotification" + ); +} + +// (The 0x20-0x2F routing-signal types — CoordsRequired/PathBroken/MtuExceeded +// — moved to `RoutingSignalType`; their tests live in the routing subsystem.) + +// ===== SessionMsg3 Tests ===== + +#[test] +fn test_session_msg3_encode_decode() { + let handshake = vec![0xCC; 73]; // typical XK msg3 + let msg3 = SessionMsg3::new(handshake.clone()); + + let encoded = msg3.encode(); + // Verify FSP prefix: ver_phase=0x03 (version 0, phase MSG3) + assert_eq!(encoded[0], 0x03); + assert_eq!(encoded[1], 0x00); // flags = 0 for handshake + let payload_len = u16::from_le_bytes([encoded[2], encoded[3]]); + assert_eq!(payload_len as usize, encoded.len() - 4); + + // Decode (skip 4-byte FSP prefix) + let decoded = SessionMsg3::decode(&encoded[4..]).unwrap(); + assert_eq!(decoded.flags, 0); + assert_eq!(decoded.handshake_payload, handshake); +} + +#[test] +fn test_session_msg3_decode_too_short() { + assert!(SessionMsg3::decode(&[]).is_err()); + assert!(SessionMsg3::decode(&[0x00]).is_err()); // flags only, no hs_len +} + +#[test] +fn test_session_msg3_empty_handshake() { + let msg3 = SessionMsg3::new(vec![]); + let encoded = msg3.encode(); + let decoded = SessionMsg3::decode(&encoded[4..]).unwrap(); + assert!(decoded.handshake_payload.is_empty()); +} + +// ===== Size Constant Tests ===== + +#[test] +fn test_wire_sizes() { + assert_eq!(FSP_COMMON_PREFIX_SIZE, 4); + assert_eq!(FSP_HEADER_SIZE, 12); + assert_eq!(FSP_INNER_HEADER_SIZE, 6); + assert_eq!(FSP_ENCRYPTED_MIN_SIZE, 28); // 12 + 16 +} + +// ===== Common Prefix Tests ===== + +#[test] +fn test_common_prefix_parse_established() { + let data = [0x00, 0x01, 0x40, 0x00]; // ver=0, phase=0, flags=CP, payload_len=64 + let prefix = FspCommonPrefix::parse(&data).unwrap(); + assert_eq!(prefix.version, 0); + assert_eq!(prefix.phase, FSP_PHASE_ESTABLISHED); + assert_eq!(prefix.flags, FSP_FLAG_CP); + assert_eq!(prefix.payload_len, 64); + assert!(prefix.has_coords()); + assert!(!prefix.is_unencrypted()); +} + +#[test] +fn test_common_prefix_parse_handshake() { + let data = [0x01, 0x00, 0x50, 0x00]; // ver=0, phase=1, flags=0, payload_len=80 + let prefix = FspCommonPrefix::parse(&data).unwrap(); + assert_eq!(prefix.version, 0); + assert_eq!(prefix.phase, FSP_PHASE_MSG1); + assert_eq!(prefix.flags, 0); + assert_eq!(prefix.payload_len, 80); +} + +#[test] +fn test_common_prefix_parse_error_signal() { + let data = [0x00, FSP_FLAG_U, 0x22, 0x00]; // ver=0, phase=0, U flag, payload_len=34 + let prefix = FspCommonPrefix::parse(&data).unwrap(); + assert_eq!(prefix.phase, FSP_PHASE_ESTABLISHED); + assert!(prefix.is_unencrypted()); + assert_eq!(prefix.payload_len, 34); +} + +#[test] +fn test_common_prefix_too_short() { + assert!(FspCommonPrefix::parse(&[0, 0, 0]).is_none()); +} + +// ===== Encrypted Header Tests ===== + +#[test] +fn test_encrypted_header_parse() { + let counter = 42u64; + let flags = FSP_FLAG_CP; + let payload_len = 100u16; + let header = build_fsp_header(counter, flags, payload_len); + + // Build a minimal packet: header + 16 bytes of fake ciphertext (tag) + let mut packet = Vec::from(header); + packet.extend_from_slice(&[0xaa; TAG_SIZE]); + + let parsed = FspEncryptedHeader::parse(&packet).unwrap(); + assert_eq!(parsed.counter, 42); + assert_eq!(parsed.flags, FSP_FLAG_CP); + assert_eq!(parsed.payload_len, 100); + assert!(parsed.has_coords()); + assert_eq!(parsed.header_bytes, header); + assert_eq!(parsed.data_offset(), FSP_HEADER_SIZE); +} + +#[test] +fn test_encrypted_header_too_short() { + let packet = vec![0x00; FSP_ENCRYPTED_MIN_SIZE - 1]; + assert!(FspEncryptedHeader::parse(&packet).is_none()); +} + +#[test] +fn test_encrypted_header_wrong_phase() { + let mut packet = vec![0x00; FSP_ENCRYPTED_MIN_SIZE]; + packet[0] = 0x01; // phase 1 (msg1), not established + assert!(FspEncryptedHeader::parse(&packet).is_none()); +} + +#[test] +fn test_encrypted_header_wrong_version() { + let mut packet = vec![0x00; FSP_ENCRYPTED_MIN_SIZE]; + packet[0] = 0x10; // version 1, phase 0 + assert!(FspEncryptedHeader::parse(&packet).is_none()); +} + +#[test] +fn test_encrypted_header_u_flag_rejected() { + let mut packet = vec![0x00; FSP_ENCRYPTED_MIN_SIZE]; + packet[1] = FSP_FLAG_U; // U flag set → not encrypted + assert!(FspEncryptedHeader::parse(&packet).is_none()); +} + +// ===== Build Header Tests ===== + +#[test] +fn test_build_fsp_header() { + let header = build_fsp_header(1000, FSP_FLAG_CP, 200); + assert_eq!(header[0], 0x00); // ver=0, phase=0 + assert_eq!(header[1], FSP_FLAG_CP); + assert_eq!(u16::from_le_bytes([header[2], header[3]]), 200); + assert_eq!( + u64::from_le_bytes([ + header[4], header[5], header[6], header[7], header[8], header[9], header[10], + header[11], + ]), + 1000 + ); +} + +#[test] +fn test_build_fsp_encrypted() { + let header = build_fsp_header(0, 0, 10); + let ciphertext = vec![0xCC; 26]; // 10 payload + 16 tag + let packet = build_fsp_encrypted(&header, &ciphertext); + assert_eq!(packet.len(), FSP_HEADER_SIZE + 26); + assert_eq!(&packet[..FSP_HEADER_SIZE], &header); + assert_eq!(&packet[FSP_HEADER_SIZE..], &ciphertext[..]); +} + +// ===== Handshake Prefix Tests ===== + +#[test] +fn test_build_fsp_handshake_prefix_msg1() { + let prefix = build_fsp_handshake_prefix(FSP_PHASE_MSG1, 100); + assert_eq!(prefix[0], 0x01); // ver=0, phase=1 + assert_eq!(prefix[1], 0x00); // flags zero + assert_eq!(u16::from_le_bytes([prefix[2], prefix[3]]), 100); + + let parsed = FspCommonPrefix::parse(&prefix).unwrap(); + assert_eq!(parsed.phase, FSP_PHASE_MSG1); +} + +#[test] +fn test_build_fsp_handshake_prefix_msg2() { + let prefix = build_fsp_handshake_prefix(FSP_PHASE_MSG2, 50); + assert_eq!(prefix[0], 0x02); // ver=0, phase=2 + assert_eq!(prefix[1], 0x00); + assert_eq!(u16::from_le_bytes([prefix[2], prefix[3]]), 50); +} + +#[test] +fn test_build_fsp_handshake_prefix_msg3() { + let prefix = build_fsp_handshake_prefix(FSP_PHASE_MSG3, 73); + assert_eq!(prefix[0], 0x03); // ver=0, phase=3 + assert_eq!(prefix[1], 0x00); // flags zero + assert_eq!(u16::from_le_bytes([prefix[2], prefix[3]]), 73); + + let parsed = FspCommonPrefix::parse(&prefix).unwrap(); + assert_eq!(parsed.phase, FSP_PHASE_MSG3); +} + +// ===== Error Prefix Tests ===== + +#[test] +fn test_build_fsp_error_prefix() { + let prefix = build_fsp_error_prefix(34); + assert_eq!(prefix[0], 0x00); // ver=0, phase=0 + assert_eq!(prefix[1], FSP_FLAG_U); + assert_eq!(u16::from_le_bytes([prefix[2], prefix[3]]), 34); + + let parsed = FspCommonPrefix::parse(&prefix).unwrap(); + assert!(parsed.is_unencrypted()); + assert_eq!(parsed.phase, FSP_PHASE_ESTABLISHED); +} + +// ===== Inner Header Tests ===== + +#[test] +fn test_inner_header_prepend_strip() { + let timestamp: u32 = 12345; + let msg_type: u8 = 0x10; + let inner_flags: u8 = 0x01; // SP bit + let payload = vec![0xAA, 0xBB, 0xCC]; + + let with_header = fsp_prepend_inner_header(timestamp, msg_type, inner_flags, &payload); + assert_eq!(with_header.len(), FSP_INNER_HEADER_SIZE + 3); + + let (ts, mt, flags, rest) = fsp_strip_inner_header(&with_header).unwrap(); + assert_eq!(ts, 12345); + assert_eq!(mt, 0x10); + assert_eq!(flags, 0x01); + assert_eq!(rest, &payload[..]); +} + +#[test] +fn test_inner_header_empty_payload() { + let with_header = fsp_prepend_inner_header(0, 0x13, 0, &[]); + assert_eq!(with_header.len(), FSP_INNER_HEADER_SIZE); + + let (ts, mt, flags, rest) = fsp_strip_inner_header(&with_header).unwrap(); + assert_eq!(ts, 0); + assert_eq!(mt, 0x13); + assert_eq!(flags, 0); + assert!(rest.is_empty()); +} + +#[test] +fn test_inner_header_too_short() { + assert!(fsp_strip_inner_header(&[0, 0, 0, 0, 0]).is_none()); // needs 6 bytes + assert!(fsp_strip_inner_header(&[]).is_none()); +} + +// ===== Flag Constants Tests ===== + +#[test] +fn test_flag_bits_distinct() { + // Cleartext flags don't overlap + assert_eq!(FSP_FLAG_CP & FSP_FLAG_K, 0); + assert_eq!(FSP_FLAG_CP & FSP_FLAG_U, 0); + assert_eq!(FSP_FLAG_K & FSP_FLAG_U, 0); +} + +#[test] +fn test_header_roundtrip() { + let counter = 0xDEADBEEF_12345678u64; + let flags = FSP_FLAG_CP | FSP_FLAG_K; + let payload_len = 1234u16; + + let header = build_fsp_header(counter, flags, payload_len); + let ciphertext = vec![0xFF; payload_len as usize + TAG_SIZE]; + let packet = build_fsp_encrypted(&header, &ciphertext); + + let parsed = FspEncryptedHeader::parse(&packet).unwrap(); + assert_eq!(parsed.counter, counter); + assert_eq!(parsed.flags, flags); + assert_eq!(parsed.payload_len, payload_len); + assert!(parsed.has_coords()); + assert_eq!(parsed.header_bytes, header); +} + +#[test] +fn test_all_message_types_through_prefix() { + // Encrypted (phase 0, no U) + let prefix = FspCommonPrefix::parse(&[0x00, 0x00, 0x10, 0x00]).unwrap(); + assert_eq!(prefix.phase, 0); + assert!(!prefix.is_unencrypted()); + + // Error signal (phase 0, U set) + let prefix = FspCommonPrefix::parse(&[0x00, FSP_FLAG_U, 0x22, 0x00]).unwrap(); + assert_eq!(prefix.phase, 0); + assert!(prefix.is_unencrypted()); + + // SessionSetup (phase 1) + let prefix = FspCommonPrefix::parse(&[0x01, 0x00, 0x50, 0x00]).unwrap(); + assert_eq!(prefix.phase, 1); + + // SessionAck (phase 2) + let prefix = FspCommonPrefix::parse(&[0x02, 0x00, 0x21, 0x00]).unwrap(); + assert_eq!(prefix.phase, 2); + + // SessionMsg3 (phase 3) + let prefix = FspCommonPrefix::parse(&[0x03, 0x00, 0x49, 0x00]).unwrap(); + assert_eq!(prefix.phase, 3); +} diff --git a/src/protocol/session.rs b/src/proto/fsp/wire.rs similarity index 56% rename from src/protocol/session.rs rename to src/proto/fsp/wire.rs index b8b81a8..b2ec872 100644 --- a/src/protocol/session.rs +++ b/src/proto/fsp/wire.rs @@ -1,21 +1,365 @@ -//! Session-layer message types: setup, ack, data, and error messages. +//! FSP Wire Format Parsing and Serialization +//! +//! Defines the FIPS session-layer wire format (FSP) for packet dispatch. +//! All FSP messages begin with a 4-byte common prefix followed by phase-specific +//! fields. Encrypted messages use a 12-byte cleartext header as AAD for AEAD, +//! and a 6-byte encrypted inner header containing timestamps and message type. +//! +//! ## Common Prefix (4 bytes) +//! +//! ```text +//! [ver+phase:1][flags:1][payload_len:2 LE] +//! ``` +//! +//! ## DataPacket Port Multiplexing +//! +//! DataPacket (msg_type 0x10) payloads inside the AEAD envelope carry a 4-byte +//! port header for service dispatch: +//! +//! ```text +//! [src_port:2 LE][dst_port:2 LE][service payload...] +//! ``` +//! +//! Port 256 (0x100) = IPv6 shim with header compression. +//! +//! ## Message Classes +//! +//! | Phase | U Flag | Type | Description | +//! |-------|--------|------------------|-----------------------------------| +//! | 0x0 | 0 | Encrypted | Post-handshake encrypted data | +//! | 0x0 | 1 | Plaintext error | CoordsRequired, PathBroken | +//! | 0x1 | - | Handshake msg1 | SessionSetup (Noise XK msg1) | +//! | 0x2 | - | Handshake msg2 | SessionAck (Noise XK msg2) | +//! | 0x3 | - | Handshake msg3 | SessionMsg3 (Noise XK msg3) | -use super::ProtocolError; -use crate::NodeAddr; -use crate::proto::stp::TreeCoordinate; +use crate::proto::Error; +use crate::proto::stp::{TreeCoordinate, decode_coords, decode_optional_coords, encode_coords}; use std::fmt; +// ============================================================================ +// Constants +// ============================================================================ + +/// FSP protocol version (4 high bits of byte 0). +pub const FSP_VERSION: u8 = 0; + +/// Phase value for established (encrypted or plaintext error) messages. +pub const FSP_PHASE_ESTABLISHED: u8 = 0x0; + +/// Phase value for SessionSetup (Noise IK message 1). +pub const FSP_PHASE_MSG1: u8 = 0x1; + +/// Phase value for SessionAck (Noise handshake message 2). +pub const FSP_PHASE_MSG2: u8 = 0x2; + +/// Phase value for XK message 3 (initiator's encrypted static). +pub const FSP_PHASE_MSG3: u8 = 0x3; + +/// Size of the common packet prefix (all FSP message types). +pub const FSP_COMMON_PREFIX_SIZE: usize = 4; + +/// Size of the full encrypted message header (prefix + counter). +pub const FSP_HEADER_SIZE: usize = 12; + +/// Size of the encrypted inner header (timestamp + msg_type + inner_flags). +pub const FSP_INNER_HEADER_SIZE: usize = 6; + +/// AEAD authentication tag size (ChaCha20-Poly1305). +pub(crate) const TAG_SIZE: usize = 16; + +/// Minimum size for an encrypted FSP message: header + tag (no plaintext). +pub const FSP_ENCRYPTED_MIN_SIZE: usize = FSP_HEADER_SIZE + TAG_SIZE; // 28 bytes + +// FSP DataPacket port header constants. + +/// Size of the FSP DataPacket port header (src_port + dst_port). +pub const FSP_PORT_HEADER_SIZE: usize = 4; + +/// FSP port: IPv6 shim service. +pub const FSP_PORT_IPV6_SHIM: u16 = 256; + +// Cleartext flag bit constants (byte 1 of common prefix, phase 0x0 only). + +/// Coords Present — source and destination coordinates follow the header. +pub const FSP_FLAG_CP: u8 = 0x01; + +/// Key Epoch — selects active key during rekeying. +#[allow(dead_code)] +pub const FSP_FLAG_K: u8 = 0x02; + +/// Unencrypted — payload is plaintext (error signals). +pub const FSP_FLAG_U: u8 = 0x04; + +// Inner flag bit constants (byte 5 of decrypted inner header). + +/// Spin bit for end-to-end RTT measurement (inside AEAD). +#[allow(dead_code)] +pub const FSP_INNER_FLAG_SP: u8 = 0x01; + +// ============================================================================ +// Common Prefix +// ============================================================================ + +/// Parsed FSP common packet prefix (first 4 bytes of every FSP message). +/// +/// Wire format: +/// ```text +/// [ver(4bits)+phase(4bits)][flags:1][payload_len:2 LE] +/// ``` +#[derive(Clone, Debug)] +pub struct FspCommonPrefix { + /// Protocol version (high nibble of byte 0). + #[cfg_attr(not(test), allow(dead_code))] + pub version: u8, + /// Session lifecycle phase (low nibble of byte 0). + pub phase: u8, + /// Per-message signal flags. + pub flags: u8, + /// Length of payload following the phase-specific header. + #[cfg_attr(not(test), allow(dead_code))] + pub payload_len: u16, +} + +impl FspCommonPrefix { + /// Parse a common prefix from the first 4 bytes of FSP message data. + pub fn parse(data: &[u8]) -> Option { + if data.len() < FSP_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, + }) + } + + /// Check if the Unencrypted flag is set. + pub fn is_unencrypted(&self) -> bool { + self.flags & FSP_FLAG_U != 0 + } + + /// Check if the Coords Present flag is set. + pub fn has_coords(&self) -> bool { + self.flags & FSP_FLAG_CP != 0 + } + + /// Encode the ver+phase byte. + fn ver_phase_byte(version: u8, phase: u8) -> u8 { + (version << 4) | (phase & 0x0F) + } +} + +// ============================================================================ +// Encrypted Message Header +// ============================================================================ + +/// Parsed FSP encrypted message header (phase 0x0, U flag clear). +/// +/// Wire format (12 bytes): +/// ```text +/// [ver+phase:1][flags:1][payload_len:2 LE][counter:8 LE] +/// ``` +/// +/// The full 12-byte header is used as AAD for the AEAD construction. +/// No receiver_idx — unlike FMP, FSP is end-to-end (dispatched by src_addr +/// from the SessionDatagram envelope, not by index). +#[derive(Clone, Debug)] +pub struct FspEncryptedHeader { + /// Per-message flags (CP, K). + pub flags: u8, + /// Length of encrypted payload (excluding AEAD tag). + #[cfg_attr(not(test), allow(dead_code))] + pub payload_len: u16, + /// Monotonic counter used as AEAD nonce. + pub counter: u64, + /// Raw 12-byte header for use as AEAD AAD. + pub header_bytes: [u8; FSP_HEADER_SIZE], +} + +impl FspEncryptedHeader { + /// Parse an encrypted message header from FSP message data. + /// + /// Returns None if the data is too short or has wrong version/phase, + /// or if the U flag is set (plaintext messages use a different path). + pub fn parse(data: &[u8]) -> Option { + if data.len() < FSP_ENCRYPTED_MIN_SIZE { + return None; + } + + let version = data[0] >> 4; + let phase = data[0] & 0x0F; + + if version != FSP_VERSION || phase != FSP_PHASE_ESTABLISHED { + return None; + } + + let flags = data[1]; + + // U flag means plaintext — not an encrypted message + if flags & FSP_FLAG_U != 0 { + return None; + } + + let payload_len = u16::from_le_bytes([data[2], data[3]]); + let counter = u64::from_le_bytes([ + data[4], data[5], data[6], data[7], data[8], data[9], data[10], data[11], + ]); + + let mut header_bytes = [0u8; FSP_HEADER_SIZE]; + header_bytes.copy_from_slice(&data[..FSP_HEADER_SIZE]); + + Some(Self { + flags, + payload_len, + counter, + header_bytes, + }) + } + + /// Check if the Coords Present flag is set. + pub fn has_coords(&self) -> bool { + self.flags & FSP_FLAG_CP != 0 + } + + /// Offset where ciphertext (or coords if CP) begins in the original data. + #[cfg_attr(not(test), allow(dead_code))] + pub fn data_offset(&self) -> usize { + FSP_HEADER_SIZE + } +} + +// ============================================================================ +// Serialization Helpers +// ============================================================================ + +/// Build the 12-byte cleartext header for an encrypted FSP message. +/// +/// Returns the header bytes for use as AEAD AAD. +pub fn build_fsp_header(counter: u64, flags: u8, payload_len: u16) -> [u8; FSP_HEADER_SIZE] { + let mut header = [0u8; FSP_HEADER_SIZE]; + header[0] = FspCommonPrefix::ver_phase_byte(FSP_VERSION, FSP_PHASE_ESTABLISHED); + header[1] = flags; + header[2..4].copy_from_slice(&payload_len.to_le_bytes()); + header[4..12].copy_from_slice(&counter.to_le_bytes()); + header +} + +/// Assemble a wire-format encrypted FSP message. +/// +/// Format: `[header:12][ciphertext+tag]` +#[cfg_attr(not(test), allow(dead_code))] +pub fn build_fsp_encrypted(header: &[u8; FSP_HEADER_SIZE], ciphertext: &[u8]) -> Vec { + let mut packet = Vec::with_capacity(FSP_HEADER_SIZE + ciphertext.len()); + packet.extend_from_slice(header); + packet.extend_from_slice(ciphertext); + packet +} + +/// Build a 4-byte common prefix for a handshake message. +/// +/// `phase` should be `FSP_PHASE_MSG1`, `FSP_PHASE_MSG2`, or `FSP_PHASE_MSG3`. +/// Flags are zero during handshake. +#[cfg_attr(not(test), allow(dead_code))] +pub fn build_fsp_handshake_prefix(phase: u8, payload_len: u16) -> [u8; FSP_COMMON_PREFIX_SIZE] { + let mut prefix = [0u8; FSP_COMMON_PREFIX_SIZE]; + prefix[0] = FspCommonPrefix::ver_phase_byte(FSP_VERSION, phase); + prefix[1] = 0x00; // flags must be zero during handshake + prefix[2..4].copy_from_slice(&payload_len.to_le_bytes()); + prefix +} + +/// Build a 4-byte common prefix for a plaintext error signal. +/// +/// Sets phase 0x0 and U flag. +#[cfg_attr(not(test), allow(dead_code))] +pub fn build_fsp_error_prefix(payload_len: u16) -> [u8; FSP_COMMON_PREFIX_SIZE] { + let mut prefix = [0u8; FSP_COMMON_PREFIX_SIZE]; + prefix[0] = FspCommonPrefix::ver_phase_byte(FSP_VERSION, FSP_PHASE_ESTABLISHED); + prefix[1] = FSP_FLAG_U; + prefix[2..4].copy_from_slice(&payload_len.to_le_bytes()); + prefix +} + +// ============================================================================ +// Inner Header Helpers +// ============================================================================ + +/// Prepend the 6-byte FSP inner header to a message payload. +/// +/// Inner header: `[timestamp:4 LE][msg_type:1][inner_flags:1]` +/// +/// The caller provides the message-type-specific payload (e.g., application +/// data for msg_type 0x10, report fields for SenderReport). This function +/// prepends the inner header. +pub fn fsp_prepend_inner_header( + timestamp_ms: u32, + msg_type: u8, + inner_flags: u8, + payload: &[u8], +) -> Vec { + let mut buf = Vec::with_capacity(FSP_INNER_HEADER_SIZE + payload.len()); + buf.extend_from_slice(×tamp_ms.to_le_bytes()); + buf.push(msg_type); + buf.push(inner_flags); + buf.extend_from_slice(payload); + buf +} + +/// Strip the 6-byte FSP inner header from a decrypted payload. +/// +/// Returns `(timestamp, msg_type, inner_flags, &rest)` or None if too short. +pub fn fsp_strip_inner_header(plaintext: &[u8]) -> Option<(u32, u8, u8, &[u8])> { + if plaintext.len() < FSP_INNER_HEADER_SIZE { + return None; + } + let timestamp = u32::from_le_bytes([plaintext[0], plaintext[1], plaintext[2], plaintext[3]]); + let msg_type = plaintext[4]; + let inner_flags = plaintext[5]; + Some(( + timestamp, + msg_type, + inner_flags, + &plaintext[FSP_INNER_HEADER_SIZE..], + )) +} + +// ============================================================================ +// Coordinate Parsing (for transit nodes and receive path) +// ============================================================================ + +/// Parse source and destination coordinates from the cleartext section +/// of an encrypted FSP message when the CP flag is set. +/// +/// Coordinates appear between the 12-byte header and the ciphertext: +/// `[src_coords_count:2 LE][src_coords:16×n][dest_coords_count:2 LE][dest_coords:16×m]` +/// +/// Returns `(src_coords, dest_coords, bytes_consumed)`. +pub fn parse_encrypted_coords( + data: &[u8], +) -> Result<(Option, Option, usize), Error> { + let (src_coords, src_consumed) = decode_optional_coords(data)?; + let (dest_coords, dest_consumed) = decode_optional_coords(&data[src_consumed..])?; + Ok((src_coords, dest_coords, src_consumed + dest_consumed)) +} + // ============================================================================ // Session Layer Message Types // ============================================================================ -/// SessionDatagram payload message type identifiers. +/// FSP encrypted-inner message type identifiers (`0x10`–`0x1F`). /// -/// These messages are carried as payloads inside `SessionDatagram` (link -/// message type 0x00). Post-handshake messages (data, reports) are end-to-end -/// encrypted with session keys via the FSP pipeline. Error signals -/// (CoordsRequired, PathBroken) are plaintext messages generated by transit -/// routers that cannot establish e2e sessions with the source. +/// These messages are carried end-to-end encrypted inside the FSP AEAD +/// envelope; the type is the `msg_type` byte of the encrypted inner header. +/// The plaintext link-layer error signals (`0x20`–`0x2F`) are a separate +/// registry — [`RoutingSignalType`](crate::proto::routing::RoutingSignalType) +/// — since they are dispatched on the cleartext (U-flag) path with no session. /// /// Handshake messages (SessionSetup, SessionAck, SessionMsg3) are **not** /// identified by a message-type byte; they are dispatched by the FSP phase @@ -36,14 +380,6 @@ pub enum SessionMessageType { PathMtuNotification = 0x13, /// Standalone coordinate cache warming (empty body, coords in CP flag). CoordsWarmup = 0x14, - - // Link-layer error signals (0x20-0x2F) — plaintext, from transit routers - /// Router cache miss — needs coordinates (link-layer error signal). - CoordsRequired = 0x20, - /// Routing failure — local minimum or unreachable (link-layer error signal). - PathBroken = 0x21, - /// MTU exceeded — forwarded packet too large for next-hop transport (link-layer error signal). - MtuExceeded = 0x22, } impl SessionMessageType { @@ -55,9 +391,6 @@ impl SessionMessageType { 0x12 => Some(SessionMessageType::ReceiverReport), 0x13 => Some(SessionMessageType::PathMtuNotification), 0x14 => Some(SessionMessageType::CoordsWarmup), - 0x20 => Some(SessionMessageType::CoordsRequired), - 0x21 => Some(SessionMessageType::PathBroken), - 0x22 => Some(SessionMessageType::MtuExceeded), _ => None, } } @@ -76,111 +409,11 @@ impl fmt::Display for SessionMessageType { SessionMessageType::ReceiverReport => "ReceiverReport", SessionMessageType::PathMtuNotification => "PathMtuNotification", SessionMessageType::CoordsWarmup => "CoordsWarmup", - SessionMessageType::CoordsRequired => "CoordsRequired", - SessionMessageType::PathBroken => "PathBroken", - SessionMessageType::MtuExceeded => "MtuExceeded", }; write!(f, "{}", name) } } -// ============================================================================ -// Coordinate Wire Format Helpers -// ============================================================================ - -/// Wire size of a TreeCoordinate in address-only format: 2 + entries × 16. -pub(crate) fn coords_wire_size(coords: &TreeCoordinate) -> usize { - 2 + coords.entries().len() * 16 -} - -/// Encode a TreeCoordinate as address-only wire format: count(u16 LE) + addrs(16 × n). -/// -/// Session-layer messages serialize coordinates as NodeAddr arrays (16 bytes each), -/// without the sequence/timestamp metadata used by the tree gossip protocol. -pub(crate) fn encode_coords(coords: &TreeCoordinate, buf: &mut Vec) { - let addrs: Vec<&NodeAddr> = coords.node_addrs().collect(); - let count = addrs.len() as u16; - buf.extend_from_slice(&count.to_le_bytes()); - for addr in addrs { - buf.extend_from_slice(addr.as_bytes()); - } -} - -/// Decode a TreeCoordinate from address-only wire format. -/// -/// Returns the decoded coordinate and the number of bytes consumed. -pub(crate) fn decode_coords(data: &[u8]) -> Result<(TreeCoordinate, usize), ProtocolError> { - if data.len() < 2 { - return Err(ProtocolError::MessageTooShort { - expected: 2, - got: data.len(), - }); - } - let count = u16::from_le_bytes([data[0], data[1]]) as usize; - let needed = 2 + count * 16; - if data.len() < needed { - return Err(ProtocolError::MessageTooShort { - expected: needed, - got: data.len(), - }); - } - if count == 0 { - return Err(ProtocolError::Malformed( - "coordinate with zero entries".into(), - )); - } - let mut addrs = Vec::with_capacity(count); - for i in 0..count { - let offset = 2 + i * 16; - let mut bytes = [0u8; 16]; - bytes.copy_from_slice(&data[offset..offset + 16]); - addrs.push(NodeAddr::from_bytes(bytes)); - } - let coord = - TreeCoordinate::from_addrs(addrs).map_err(|e| ProtocolError::Malformed(e.to_string()))?; - Ok((coord, needed)) -} - -/// Decode an optional coordinate field (count may be 0). -/// -/// Returns None if count is 0, Some(coord) otherwise, plus bytes consumed. -pub(crate) fn decode_optional_coords( - data: &[u8], -) -> Result<(Option, usize), ProtocolError> { - if data.len() < 2 { - return Err(ProtocolError::MessageTooShort { - expected: 2, - got: data.len(), - }); - } - let count = u16::from_le_bytes([data[0], data[1]]) as usize; - let needed = 2 + count * 16; - if data.len() < needed { - return Err(ProtocolError::MessageTooShort { - expected: needed, - got: data.len(), - }); - } - if count == 0 { - return Ok((None, 2)); - } - let mut addrs = Vec::with_capacity(count); - for i in 0..count { - let offset = 2 + i * 16; - let mut bytes = [0u8; 16]; - bytes.copy_from_slice(&data[offset..offset + 16]); - addrs.push(NodeAddr::from_bytes(bytes)); - } - let coord = - TreeCoordinate::from_addrs(addrs).map_err(|e| ProtocolError::Malformed(e.to_string()))?; - Ok((Some(coord), needed)) -} - -/// Encode a count of zero (for empty/absent coordinate fields). -pub(crate) fn encode_empty_coords(buf: &mut Vec) { - buf.extend_from_slice(&0u16.to_le_bytes()); -} - // ============================================================================ // Session Flags // ============================================================================ @@ -245,6 +478,7 @@ impl SessionFlags { /// | 1 | K | Key epoch (for rekeying) | /// | 2 | U | Unencrypted payload (error signals) | /// | 3-7 | | Reserved | +#[cfg_attr(not(test), allow(dead_code))] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct FspFlags { /// Coordinates present between header and ciphertext. @@ -255,6 +489,8 @@ pub struct FspFlags { pub unencrypted: bool, } +#[cfg_attr(not(test), allow(dead_code))] +#[allow(clippy::wrong_self_convention)] impl FspFlags { /// Create default flags (all clear). pub fn new() -> Self { @@ -298,8 +534,10 @@ pub struct FspInnerFlags { pub spin_bit: bool, } +#[allow(clippy::wrong_self_convention)] impl FspInnerFlags { /// Create default inner flags (all clear). + #[cfg_attr(not(test), allow(dead_code))] pub fn new() -> Self { Self::default() } @@ -408,9 +646,9 @@ impl SessionSetup { } /// Decode from wire format (after 4-byte FSP prefix has been consumed). - pub fn decode(payload: &[u8]) -> Result { + pub fn decode(payload: &[u8]) -> Result { if payload.is_empty() { - return Err(ProtocolError::MessageTooShort { + return Err(Error::MessageTooShort { expected: 1, got: 0, }); @@ -425,7 +663,7 @@ impl SessionSetup { offset += consumed; if payload.len() < offset + 2 { - return Err(ProtocolError::MessageTooShort { + return Err(Error::MessageTooShort { expected: offset + 2, got: payload.len(), }); @@ -434,7 +672,7 @@ impl SessionSetup { offset += 2; if payload.len() < offset + hs_len { - return Err(ProtocolError::MessageTooShort { + return Err(Error::MessageTooShort { expected: offset + hs_len, got: payload.len(), }); @@ -535,9 +773,9 @@ impl SessionAck { } /// Decode from wire format (after 4-byte FSP prefix has been consumed). - pub fn decode(payload: &[u8]) -> Result { + pub fn decode(payload: &[u8]) -> Result { if payload.is_empty() { - return Err(ProtocolError::MessageTooShort { + return Err(Error::MessageTooShort { expected: 1, got: 0, }); @@ -552,7 +790,7 @@ impl SessionAck { offset += consumed; if payload.len() < offset + 2 { - return Err(ProtocolError::MessageTooShort { + return Err(Error::MessageTooShort { expected: offset + 2, got: payload.len(), }); @@ -561,7 +799,7 @@ impl SessionAck { offset += 2; if payload.len() < offset + hs_len { - return Err(ProtocolError::MessageTooShort { + return Err(Error::MessageTooShort { expected: offset + hs_len, got: payload.len(), }); @@ -634,9 +872,9 @@ impl SessionMsg3 { } /// Decode from wire format (after 4-byte FSP prefix has been consumed). - pub fn decode(payload: &[u8]) -> Result { + pub fn decode(payload: &[u8]) -> Result { if payload.is_empty() { - return Err(ProtocolError::MessageTooShort { + return Err(Error::MessageTooShort { expected: 1, got: 0, }); @@ -645,7 +883,7 @@ impl SessionMsg3 { let mut offset = 1; if payload.len() < offset + 2 { - return Err(ProtocolError::MessageTooShort { + return Err(Error::MessageTooShort { expected: offset + 2, got: payload.len(), }); @@ -654,7 +892,7 @@ impl SessionMsg3 { offset += 2; if payload.len() < offset + hs_len { - return Err(ProtocolError::MessageTooShort { + return Err(Error::MessageTooShort { expected: offset + hs_len, got: payload.len(), }); @@ -667,317 +905,3 @@ impl SessionMsg3 { }) } } - -#[cfg(test)] -mod tests { - use super::*; - - fn make_node_addr(val: u8) -> NodeAddr { - let mut bytes = [0u8; 16]; - bytes[0] = val; - NodeAddr::from_bytes(bytes) - } - - fn make_coords(ids: &[u8]) -> TreeCoordinate { - TreeCoordinate::from_addrs(ids.iter().map(|&v| make_node_addr(v)).collect()).unwrap() - } - - // ===== SessionMessageType Tests ===== - - #[test] - fn test_session_message_type_roundtrip() { - let types = [ - SessionMessageType::DataPacket, - SessionMessageType::SenderReport, - SessionMessageType::ReceiverReport, - SessionMessageType::PathMtuNotification, - SessionMessageType::CoordsWarmup, - SessionMessageType::CoordsRequired, - SessionMessageType::PathBroken, - SessionMessageType::MtuExceeded, - ]; - - for ty in types { - let byte = ty.to_byte(); - let restored = SessionMessageType::from_byte(byte); - assert_eq!(restored, Some(ty)); - } - } - - #[test] - fn test_session_message_type_invalid() { - assert!(SessionMessageType::from_byte(0xFF).is_none()); - assert!(SessionMessageType::from_byte(0x99).is_none()); - } - - // ===== SessionFlags Tests ===== - - #[test] - fn test_session_flags() { - let flags = SessionFlags::new().with_ack().bidirectional(); - - assert!(flags.request_ack); - assert!(flags.bidirectional); - - let byte = flags.to_byte(); - let restored = SessionFlags::from_byte(byte); - - assert_eq!(flags, restored); - } - - #[test] - fn test_session_flags_default() { - let flags = SessionFlags::new(); - assert!(!flags.request_ack); - assert!(!flags.bidirectional); - assert_eq!(flags.to_byte(), 0); - } - - // ===== SessionSetup Tests ===== - - #[test] - fn test_session_setup() { - let setup = SessionSetup::new(make_coords(&[1, 0]), make_coords(&[2, 0])) - .with_flags(SessionFlags::new().with_ack()); - - assert!(setup.flags.request_ack); - assert!(!setup.flags.bidirectional); - } - - // ===== Encode/Decode Roundtrip Tests ===== - - #[test] - fn test_session_setup_encode_decode() { - let handshake = vec![0xAA; 82]; // typical Noise IK msg1 - let setup = SessionSetup::new(make_coords(&[1, 2, 0]), make_coords(&[3, 4, 0])) - .with_flags(SessionFlags::new().with_ack().bidirectional()) - .with_handshake(handshake.clone()); - - let encoded = setup.encode(); - - // Verify FSP prefix: ver_phase=0x01 (version 0, phase MSG1) - assert_eq!(encoded[0], 0x01); - assert_eq!(encoded[1], 0x00); // flags = 0 for handshake - let payload_len = u16::from_le_bytes([encoded[2], encoded[3]]); - assert_eq!(payload_len as usize, encoded.len() - 4); - - // Decode (skip 4-byte FSP prefix) - let decoded = SessionSetup::decode(&encoded[4..]).unwrap(); - - assert_eq!(decoded.flags, setup.flags); - assert_eq!(decoded.src_coords, setup.src_coords); - assert_eq!(decoded.dest_coords, setup.dest_coords); - assert_eq!(decoded.handshake_payload, handshake); - } - - #[test] - fn test_session_setup_no_handshake() { - let setup = SessionSetup::new(make_coords(&[5, 0]), make_coords(&[6, 0])); - - let encoded = setup.encode(); - let decoded = SessionSetup::decode(&encoded[4..]).unwrap(); - - assert!(decoded.handshake_payload.is_empty()); - assert_eq!(decoded.src_coords, setup.src_coords); - assert_eq!(decoded.dest_coords, setup.dest_coords); - } - - #[test] - fn test_session_ack_encode_decode() { - let handshake = vec![0xBB; 33]; // typical Noise IK msg2 - let ack = SessionAck::new(make_coords(&[7, 8, 0]), make_coords(&[3, 4, 0])) - .with_handshake(handshake.clone()); - - let encoded = ack.encode(); - // Verify FSP prefix: ver_phase=0x02 (version 0, phase MSG2) - assert_eq!(encoded[0], 0x02); - assert_eq!(encoded[1], 0x00); // flags = 0 for handshake - - let decoded = SessionAck::decode(&encoded[4..]).unwrap(); - assert_eq!(decoded.src_coords, ack.src_coords); - assert_eq!(decoded.dest_coords, ack.dest_coords); - assert_eq!(decoded.handshake_payload, handshake); - } - - #[test] - fn test_session_setup_decode_too_short() { - assert!(SessionSetup::decode(&[]).is_err()); - } - - #[test] - fn test_session_ack_decode_too_short() { - assert!(SessionAck::decode(&[]).is_err()); - } - - #[test] - fn test_session_setup_deep_coords() { - // Depth-10 coordinate (11 entries: self + 10 ancestors) - let addrs: Vec = (0..11).collect(); - let src = make_coords(&addrs); - let dest = make_coords(&[20, 21, 22, 23, 24]); - let setup = SessionSetup::new(src.clone(), dest.clone()).with_handshake(vec![0x55; 82]); - - let encoded = setup.encode(); - let decoded = SessionSetup::decode(&encoded[4..]).unwrap(); - - assert_eq!(decoded.src_coords, src); - assert_eq!(decoded.dest_coords, dest); - } - - // ===== FspFlags Tests ===== - - #[test] - fn test_fsp_flags_default() { - let flags = FspFlags::new(); - assert!(!flags.coords_present); - assert!(!flags.key_epoch); - assert!(!flags.unencrypted); - assert_eq!(flags.to_byte(), 0x00); - } - - #[test] - fn test_fsp_flags_roundtrip() { - // All combinations of 3 bits - for byte in 0u8..=0x07 { - let flags = FspFlags::from_byte(byte); - assert_eq!(flags.to_byte(), byte); - } - } - - #[test] - fn test_fsp_flags_individual_bits() { - let cp = FspFlags::from_byte(0x01); - assert!(cp.coords_present); - assert!(!cp.key_epoch); - assert!(!cp.unencrypted); - - let k = FspFlags::from_byte(0x02); - assert!(!k.coords_present); - assert!(k.key_epoch); - assert!(!k.unencrypted); - - let u = FspFlags::from_byte(0x04); - assert!(!u.coords_present); - assert!(!u.key_epoch); - assert!(u.unencrypted); - } - - #[test] - fn test_fsp_flags_ignores_reserved_bits() { - // Reserved bits in upper 5 bits are not preserved - let flags = FspFlags::from_byte(0xFF); - assert!(flags.coords_present); - assert!(flags.key_epoch); - assert!(flags.unencrypted); - assert_eq!(flags.to_byte(), 0x07); // only lower 3 bits - } - - // ===== FspInnerFlags Tests ===== - - #[test] - fn test_fsp_inner_flags_default() { - let flags = FspInnerFlags::new(); - assert!(!flags.spin_bit); - assert_eq!(flags.to_byte(), 0x00); - } - - #[test] - fn test_fsp_inner_flags_roundtrip() { - let flags = FspInnerFlags::from_byte(0x01); - assert!(flags.spin_bit); - assert_eq!(flags.to_byte(), 0x01); - - let flags = FspInnerFlags::from_byte(0x00); - assert!(!flags.spin_bit); - assert_eq!(flags.to_byte(), 0x00); - } - - #[test] - fn test_fsp_inner_flags_ignores_reserved() { - let flags = FspInnerFlags::from_byte(0xFE); - assert!(!flags.spin_bit); - assert_eq!(flags.to_byte(), 0x00); - - let flags = FspInnerFlags::from_byte(0xFF); - assert!(flags.spin_bit); - assert_eq!(flags.to_byte(), 0x01); - } - - // ===== New SessionMessageType Values ===== - - #[test] - fn test_session_message_type_new_values() { - assert_eq!(SessionMessageType::SenderReport.to_byte(), 0x11); - assert_eq!(SessionMessageType::ReceiverReport.to_byte(), 0x12); - assert_eq!(SessionMessageType::PathMtuNotification.to_byte(), 0x13); - } - - #[test] - fn test_session_message_type_display() { - assert_eq!( - format!("{}", SessionMessageType::SenderReport), - "SenderReport" - ); - assert_eq!( - format!("{}", SessionMessageType::ReceiverReport), - "ReceiverReport" - ); - assert_eq!( - format!("{}", SessionMessageType::PathMtuNotification), - "PathMtuNotification" - ); - } - - // ===== MtuExceeded Tests ===== - - #[test] - fn test_mtu_exceeded_message_type_value() { - assert_eq!(SessionMessageType::MtuExceeded.to_byte(), 0x22); - assert_eq!( - SessionMessageType::from_byte(0x22), - Some(SessionMessageType::MtuExceeded) - ); - } - - #[test] - fn test_mtu_exceeded_display() { - assert_eq!( - format!("{}", SessionMessageType::MtuExceeded), - "MtuExceeded" - ); - } - - // ===== SessionMsg3 Tests ===== - - #[test] - fn test_session_msg3_encode_decode() { - let handshake = vec![0xCC; 73]; // typical XK msg3 - let msg3 = SessionMsg3::new(handshake.clone()); - - let encoded = msg3.encode(); - // Verify FSP prefix: ver_phase=0x03 (version 0, phase MSG3) - assert_eq!(encoded[0], 0x03); - assert_eq!(encoded[1], 0x00); // flags = 0 for handshake - let payload_len = u16::from_le_bytes([encoded[2], encoded[3]]); - assert_eq!(payload_len as usize, encoded.len() - 4); - - // Decode (skip 4-byte FSP prefix) - let decoded = SessionMsg3::decode(&encoded[4..]).unwrap(); - assert_eq!(decoded.flags, 0); - assert_eq!(decoded.handshake_payload, handshake); - } - - #[test] - fn test_session_msg3_decode_too_short() { - assert!(SessionMsg3::decode(&[]).is_err()); - assert!(SessionMsg3::decode(&[0x00]).is_err()); // flags only, no hs_len - } - - #[test] - fn test_session_msg3_empty_handshake() { - let msg3 = SessionMsg3::new(vec![]); - let encoded = msg3.encode(); - let decoded = SessionMsg3::decode(&encoded[4..]).unwrap(); - assert!(decoded.handshake_payload.is_empty()); - } -} diff --git a/src/protocol/link.rs b/src/proto/link.rs similarity index 97% rename from src/protocol/link.rs rename to src/proto/link.rs index 4920414..b04d02a 100644 --- a/src/protocol/link.rs +++ b/src/proto/link.rs @@ -1,7 +1,7 @@ //! Link-layer message types: the shared frame catalog and session datagram. -use super::ProtocolError; use crate::NodeAddr; +use crate::proto::Error; use std::fmt; // ============================================================================ @@ -199,7 +199,7 @@ impl SessionDatagram { } /// Decode from link-layer payload (after msg_type byte has been consumed). - pub fn decode(payload: &[u8]) -> Result { + pub fn decode(payload: &[u8]) -> Result { let view = SessionDatagramRef::decode(payload)?; Ok(view.into_owned()) } @@ -207,10 +207,10 @@ impl SessionDatagram { impl<'a> SessionDatagramRef<'a> { /// Decode a borrowed view from link-layer payload after the msg_type byte. - pub fn decode(payload: &'a [u8]) -> Result { + pub fn decode(payload: &'a [u8]) -> Result { // ttl(1) + path_mtu(2) + src_addr(16) + dest_addr(16) = 35 if payload.len() < 35 { - return Err(ProtocolError::MessageTooShort { + return Err(Error::MessageTooShort { expected: 35, got: payload.len(), }); @@ -243,10 +243,6 @@ impl<'a> SessionDatagramRef<'a> { } } -// Legacy type alias for compatibility during transition -#[deprecated(note = "Use LinkMessageType or SessionMessageType instead")] -pub type MessageType = LinkMessageType; - #[cfg(test)] mod tests { use super::*; diff --git a/src/proto/mmp/wire.rs b/src/proto/mmp/wire.rs index 8632ba8..342d407 100644 --- a/src/proto/mmp/wire.rs +++ b/src/proto/mmp/wire.rs @@ -6,7 +6,7 @@ //! [`SessionReceiverReport`]/[`PathMtuNotification`]), plus the conversions //! between the two layers. Wire format follows the MMP design doc. -use crate::protocol::ProtocolError; +use crate::proto::Error; // ============================================================================ // SenderReport (msg_type 0x01, 48-byte body including type byte) @@ -98,9 +98,9 @@ impl SenderReport { /// Decode from payload after msg_type byte has been consumed. /// /// `payload` starts at the reserved bytes (offset 1 in the wire format). - pub fn decode(payload: &[u8]) -> Result { + pub fn decode(payload: &[u8]) -> Result { if payload.len() < 47 { - return Err(ProtocolError::MessageTooShort { + return Err(Error::MessageTooShort { expected: 47, got: payload.len(), }); @@ -146,9 +146,9 @@ impl ReceiverReport { /// Decode from payload after msg_type byte has been consumed. /// /// `payload` starts at the reserved bytes (offset 1 in the wire format). - pub fn decode(payload: &[u8]) -> Result { + pub fn decode(payload: &[u8]) -> Result { if payload.len() < 67 { - return Err(ProtocolError::MessageTooShort { + return Err(Error::MessageTooShort { expected: 67, got: payload.len(), }); @@ -227,9 +227,9 @@ impl SessionSenderReport { } /// Decode from body (after FSP inner header has been stripped). - pub fn decode(body: &[u8]) -> Result { + pub fn decode(body: &[u8]) -> Result { if body.len() < SESSION_SENDER_REPORT_SIZE { - return Err(ProtocolError::MessageTooShort { + return Err(Error::MessageTooShort { expected: SESSION_SENDER_REPORT_SIZE, got: body.len(), }); @@ -318,9 +318,9 @@ impl SessionReceiverReport { } /// Decode from body (after FSP inner header has been stripped). - pub fn decode(body: &[u8]) -> Result { + pub fn decode(body: &[u8]) -> Result { if body.len() < SESSION_RECEIVER_REPORT_SIZE { - return Err(ProtocolError::MessageTooShort { + return Err(Error::MessageTooShort { expected: SESSION_RECEIVER_REPORT_SIZE, got: body.len(), }); @@ -379,9 +379,9 @@ impl PathMtuNotification { } /// Decode from body (after FSP inner header has been stripped). - pub fn decode(body: &[u8]) -> Result { + pub fn decode(body: &[u8]) -> Result { if body.len() < PATH_MTU_NOTIFICATION_SIZE { - return Err(ProtocolError::MessageTooShort { + return Err(Error::MessageTooShort { expected: PATH_MTU_NOTIFICATION_SIZE, got: body.len(), }); diff --git a/src/proto/mod.rs b/src/proto/mod.rs index 44c404b..7b0953b 100644 --- a/src/proto/mod.rs +++ b/src/proto/mod.rs @@ -3,9 +3,14 @@ //! A module here has been migrated out of the async node shell; the async //! I/O adapters remain in `node::handlers`. +mod error; +pub use error::Error; + pub(crate) mod bloom; pub(crate) mod discovery; pub(crate) mod fmp; +pub(crate) mod fsp; +pub(crate) mod link; pub(crate) mod mmp; pub(crate) mod routing; pub(crate) mod stp; diff --git a/src/proto/routing/core.rs b/src/proto/routing/core.rs index 2260169..68b7db1 100644 --- a/src/proto/routing/core.rs +++ b/src/proto/routing/core.rs @@ -18,7 +18,7 @@ use super::state::Router; use super::wire::{CoordsRequired, MtuExceeded, PathBroken}; -use crate::protocol::{SessionDatagram, SessionDatagramRef}; +use crate::proto::link::{SessionDatagram, SessionDatagramRef}; use crate::{NodeAddr, TreeCoordinate}; /// Read-only view of routing state the routing core needs. diff --git a/src/proto/routing/mod.rs b/src/proto/routing/mod.rs index 532f3bb..fd0df29 100644 --- a/src/proto/routing/mod.rs +++ b/src/proto/routing/mod.rs @@ -14,7 +14,8 @@ //! - `state.rs` — `Router`, the routing-subsystem state owned by `Node`. //! - `limits.rs` — the routing error-signal rate limiter. //! - `wire.rs` — the routing error-signal PDUs (`CoordsRequired`, -//! `PathBroken`, `MtuExceeded`), relocated from `protocol::session`. +//! `PathBroken`, `MtuExceeded`) and the `RoutingSignalType` (`0x20`–`0x2F`) +//! message-type registry split off from the FSP `SessionMessageType`. mod core; mod limits; @@ -30,4 +31,7 @@ pub(crate) use core::{ }; pub(crate) use limits::RoutingErrorRateLimiter; pub(crate) use state::Router; -pub use wire::{COORDS_REQUIRED_SIZE, CoordsRequired, MTU_EXCEEDED_SIZE, MtuExceeded, PathBroken}; +pub use wire::{ + COORDS_REQUIRED_SIZE, CoordsRequired, MTU_EXCEEDED_SIZE, MtuExceeded, PathBroken, + RoutingSignalType, +}; diff --git a/src/proto/routing/tests/core.rs b/src/proto/routing/tests/core.rs index 9dded93..1f6a0a3 100644 --- a/src/proto/routing/tests/core.rs +++ b/src/proto/routing/tests/core.rs @@ -2,10 +2,11 @@ use super::util::{MockPeer, MockRoutingView, make_coords, make_datagram_ref, make_next_hop}; use crate::TreeCoordinate; +use crate::proto::link::SessionDatagramRef; +use crate::proto::routing::RoutingSignalType; use crate::proto::routing::{ DropReason, RouteAction, RouteOutcome, Router, RoutingView, routing_candidates, }; -use crate::protocol::{SessionDatagramRef, SessionMessageType}; use crate::testutil::make_node_addr; /// Decode a forwarded byte buffer (which carries the leading msg_type byte) @@ -242,7 +243,7 @@ fn synth_uses_pathbroken_when_coords_cached() { ); assert_eq!( error_pdu_type(&action), - SessionMessageType::PathBroken.to_byte(), + RoutingSignalType::PathBroken.to_byte(), "cached coords select PathBroken", ); } @@ -259,7 +260,7 @@ fn synth_uses_coords_required_when_not_cached() { .expect("gate passes on first call"); assert_eq!( error_pdu_type(&action), - SessionMessageType::CoordsRequired.to_byte(), + RoutingSignalType::CoordsRequired.to_byte(), "absent coords select CoordsRequired", ); } @@ -320,7 +321,7 @@ fn synth_mtu_exceeded_carries_bottleneck_and_targets_source() { ); assert_eq!( error_pdu_type(&action), - SessionMessageType::MtuExceeded.to_byte(), + RoutingSignalType::MtuExceeded.to_byte(), "PDU is an MtuExceeded signal", ); assert_eq!( diff --git a/src/proto/routing/tests/util.rs b/src/proto/routing/tests/util.rs index 644a882..7f96818 100644 --- a/src/proto/routing/tests/util.rs +++ b/src/proto/routing/tests/util.rs @@ -1,7 +1,7 @@ //! Shared test helpers for the routing subsystem unit tests. +use crate::proto::link::SessionDatagramRef; use crate::proto::routing::{NextHop, RoutingView}; -use crate::protocol::SessionDatagramRef; use crate::testutil::make_node_addr; use crate::{NodeAddr, TreeCoordinate}; diff --git a/src/proto/routing/tests/wire.rs b/src/proto/routing/tests/wire.rs index c69cd96..87b4dea 100644 --- a/src/proto/routing/tests/wire.rs +++ b/src/proto/routing/tests/wire.rs @@ -4,9 +4,41 @@ use super::util::make_coords; use crate::proto::routing::{ COORDS_REQUIRED_SIZE, CoordsRequired, MTU_EXCEEDED_SIZE, MtuExceeded, PathBroken, + RoutingSignalType, }; use crate::testutil::make_node_addr; +#[test] +fn test_routing_signal_type_roundtrip() { + let types = [ + RoutingSignalType::CoordsRequired, + RoutingSignalType::PathBroken, + RoutingSignalType::MtuExceeded, + ]; + for ty in types { + assert_eq!(RoutingSignalType::from_byte(ty.to_byte()), Some(ty)); + } +} + +#[test] +fn test_routing_signal_type_values_and_invalid() { + assert_eq!(RoutingSignalType::CoordsRequired.to_byte(), 0x20); + assert_eq!(RoutingSignalType::PathBroken.to_byte(), 0x21); + assert_eq!(RoutingSignalType::MtuExceeded.to_byte(), 0x22); + // The 0x10-0x1F FSP encrypted-inner range is not part of this registry. + assert!(RoutingSignalType::from_byte(0x10).is_none()); + assert!(RoutingSignalType::from_byte(0xFF).is_none()); +} + +#[test] +fn test_routing_signal_type_display() { + assert_eq!(format!("{}", RoutingSignalType::MtuExceeded), "MtuExceeded"); + assert_eq!( + format!("{}", RoutingSignalType::CoordsRequired), + "CoordsRequired" + ); +} + #[test] fn test_coords_required() { let err = CoordsRequired::new(make_node_addr(1), make_node_addr(2)); diff --git a/src/proto/routing/wire.rs b/src/proto/routing/wire.rs index c12a913..803c90c 100644 --- a/src/proto/routing/wire.rs +++ b/src/proto/routing/wire.rs @@ -3,17 +3,66 @@ //! Plaintext link-layer error signals emitted by a transit router that //! cannot forward a `SessionDatagram`: `CoordsRequired` (coordinate cache //! miss), `PathBroken` (routing failure / local minimum), and `MtuExceeded` -//! (next-hop transport MTU too small). Relocated verbatim from -//! `protocol::session`; the shared coordinate helpers and the -//! `SessionMessageType` discriminant stay in `protocol` and are imported -//! downward here (a `proto -> protocol` dependency is allowed). +//! (next-hop transport MTU too small). Their `0x20`–`0x2F` discriminants form +//! the routing-owned [`RoutingSignalType`] registry, split off from the FSP +//! `SessionMessageType` (which keeps the encrypted-inner `0x10`–`0x1F` range). +//! The shared address-only coordinate helpers are imported downward from +//! `crate::proto::stp`, and [`Error`] from `crate::proto` (both +//! allowed `proto -> proto` dependencies). use crate::NodeAddr; -use crate::proto::stp::TreeCoordinate; -use crate::protocol::ProtocolError; -use crate::protocol::session::{ - SessionMessageType, decode_optional_coords, encode_coords, encode_empty_coords, +use crate::proto::Error; +use crate::proto::stp::{ + TreeCoordinate, decode_optional_coords, encode_coords, encode_empty_coords, }; +use core::fmt; + +/// Plaintext link-layer routing error-signal message types (`0x20`–`0x2F`). +/// +/// Emitted by transit routers that cannot forward a `SessionDatagram` and have +/// no end-to-end session with the source, so they are carried on the FSP +/// cleartext (U-flag) path rather than the AEAD-encrypted inner path. Split +/// from the FSP `SessionMessageType` (encrypted-inner `0x10`–`0x1F`): the two +/// decode sites are partitioned by byte-range and crypto context, so neither +/// registry references the other's range. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum RoutingSignalType { + /// Router cache miss — needs coordinates. + CoordsRequired = 0x20, + /// Routing failure — local minimum or unreachable. + PathBroken = 0x21, + /// MTU exceeded — forwarded packet too large for next-hop transport. + MtuExceeded = 0x22, +} + +impl RoutingSignalType { + /// Try to convert from a byte. + pub fn from_byte(b: u8) -> Option { + match b { + 0x20 => Some(RoutingSignalType::CoordsRequired), + 0x21 => Some(RoutingSignalType::PathBroken), + 0x22 => Some(RoutingSignalType::MtuExceeded), + _ => None, + } + } + + /// Convert to a byte. + pub fn to_byte(self) -> u8 { + self as u8 + } +} + +impl fmt::Display for RoutingSignalType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + RoutingSignalType::CoordsRequired => "CoordsRequired", + RoutingSignalType::PathBroken => "PathBroken", + RoutingSignalType::MtuExceeded => "MtuExceeded", + }; + write!(f, "{}", name) + } +} /// Link-layer error signal indicating router cache miss. /// @@ -67,7 +116,7 @@ impl CoordsRequired { let payload_len = body_len as u16; buf.extend_from_slice(&payload_len.to_le_bytes()); // msg_type byte (after prefix, before body) - buf.push(SessionMessageType::CoordsRequired.to_byte()); + buf.push(RoutingSignalType::CoordsRequired.to_byte()); buf.push(0x00); // reserved flags buf.extend_from_slice(self.dest_addr.as_bytes()); buf.extend_from_slice(self.reporter.as_bytes()); @@ -75,10 +124,10 @@ impl CoordsRequired { } /// Decode from wire format (after FSP prefix and msg_type byte consumed). - pub fn decode(payload: &[u8]) -> Result { + pub fn decode(payload: &[u8]) -> Result { // flags(1) + dest_addr(16) + reporter(16) = 33 if payload.len() < 33 { - return Err(ProtocolError::MessageTooShort { + return Err(Error::MessageTooShort { expected: 33, got: payload.len(), }); @@ -145,7 +194,7 @@ impl PathBroken { pub fn encode(&self) -> Vec { // Build body first to compute length let mut body = Vec::new(); - body.push(SessionMessageType::PathBroken.to_byte()); + body.push(RoutingSignalType::PathBroken.to_byte()); body.push(0x00); // reserved flags body.extend_from_slice(self.dest_addr.as_bytes()); body.extend_from_slice(self.reporter.as_bytes()); @@ -166,10 +215,10 @@ impl PathBroken { } /// Decode from wire format (after FSP prefix and msg_type byte consumed). - pub fn decode(payload: &[u8]) -> Result { + pub fn decode(payload: &[u8]) -> Result { // flags(1) + dest_addr(16) + reporter(16) + coords_count(2) = 35 minimum if payload.len() < 35 { - return Err(ProtocolError::MessageTooShort { + return Err(Error::MessageTooShort { expected: 35, got: payload.len(), }); @@ -242,7 +291,7 @@ impl MtuExceeded { let payload_len = body_len as u16; buf.extend_from_slice(&payload_len.to_le_bytes()); // msg_type byte - buf.push(SessionMessageType::MtuExceeded.to_byte()); + buf.push(RoutingSignalType::MtuExceeded.to_byte()); buf.push(0x00); // reserved flags buf.extend_from_slice(self.dest_addr.as_bytes()); buf.extend_from_slice(self.reporter.as_bytes()); @@ -251,10 +300,10 @@ impl MtuExceeded { } /// Decode from wire format (after FSP prefix and msg_type byte consumed). - pub fn decode(payload: &[u8]) -> Result { + pub fn decode(payload: &[u8]) -> Result { // flags(1) + dest_addr(16) + reporter(16) + mtu(2) = 35 if payload.len() < 35 { - return Err(ProtocolError::MessageTooShort { + return Err(Error::MessageTooShort { expected: 35, got: payload.len(), }); diff --git a/src/proto/stp/mod.rs b/src/proto/stp/mod.rs index 7ea582d..37f7c89 100644 --- a/src/proto/stp/mod.rs +++ b/src/proto/stp/mod.rs @@ -5,8 +5,8 @@ //! data, and the flap-dampening limiter all live here. The async I/O handlers //! remain in `node::tree`. The STP wire codec lives in `wire.rs` (the //! `TreeAnnounce` struct + `validate_semantics`), per the -//! wire-migrates-with-subsystem policy. It imports the shared `ProtocolError` -//! and `LinkMessageType` downward from `crate::protocol`. +//! wire-migrates-with-subsystem policy. It imports the shared +//! [`crate::proto::Error`] and [`crate::proto::link::LinkMessageType`] downward. //! //! - `core.rs` — the pure classify ladder (`Stp::classify_announce` / //! `classify_periodic` / `should_echo`) over an in-core `TreeState`. @@ -35,6 +35,9 @@ pub use coordinate::{CoordEntry, TreeCoordinate}; pub(crate) use core::{Stp, TreeDecision}; pub use state::{ParentDeclaration, TreeState}; pub use wire::TreeAnnounce; +pub(crate) use wire::{ + coords_wire_size, decode_coords, decode_optional_coords, encode_coords, encode_empty_coords, +}; /// Errors related to spanning tree operations. #[derive(Debug, Error)] diff --git a/src/proto/stp/wire.rs b/src/proto/stp/wire.rs index 9c572e9..984b7df 100644 --- a/src/proto/stp/wire.rs +++ b/src/proto/stp/wire.rs @@ -2,8 +2,8 @@ use super::{CoordEntry, ParentDeclaration, TreeCoordinate, TreeError}; use crate::NodeAddr; -use crate::protocol::LinkMessageType; -use crate::protocol::ProtocolError; +use crate::proto::Error; +use crate::proto::link::LinkMessageType; use secp256k1::schnorr::Signature; /// Spanning tree announcement carrying parent declaration and ancestry. @@ -94,11 +94,11 @@ impl TreeAnnounce { /// [0x10][version:1][sequence:8 LE][timestamp:8 LE][parent:16] /// [ancestry_count:2 LE][entries:32×n][signature:64] /// ``` - pub fn encode(&self) -> Result, ProtocolError> { + pub fn encode(&self) -> Result, Error> { let signature = self .declaration .signature() - .ok_or(ProtocolError::InvalidSignature)?; + .ok_or(Error::InvalidSignature)?; let entries = self.ancestry.entries(); let ancestry_count = entries.len() as u16; @@ -132,9 +132,9 @@ impl TreeAnnounce { /// Decode from link-layer payload (after msg_type byte stripped by dispatcher). /// /// The payload starts with the version byte. - pub fn decode(payload: &[u8]) -> Result { + pub fn decode(payload: &[u8]) -> Result { if payload.len() < Self::MIN_PAYLOAD_SIZE { - return Err(ProtocolError::MessageTooShort { + return Err(Error::MessageTooShort { expected: Self::MIN_PAYLOAD_SIZE, got: payload.len(), }); @@ -146,14 +146,14 @@ impl TreeAnnounce { let version = payload[pos]; pos += 1; if version != Self::VERSION_1 { - return Err(ProtocolError::UnsupportedVersion(version)); + return Err(Error::UnsupportedVersion(version)); } // sequence (8 LE) let sequence = u64::from_le_bytes( payload[pos..pos + 8] .try_into() - .map_err(|_| ProtocolError::Malformed("bad sequence".into()))?, + .map_err(|_| Error::Malformed("bad sequence".into()))?, ); pos += 8; @@ -161,7 +161,7 @@ impl TreeAnnounce { let timestamp = u64::from_le_bytes( payload[pos..pos + 8] .try_into() - .map_err(|_| ProtocolError::Malformed("bad timestamp".into()))?, + .map_err(|_| Error::Malformed("bad timestamp".into()))?, ); pos += 8; @@ -169,7 +169,7 @@ impl TreeAnnounce { let parent = NodeAddr::from_bytes( payload[pos..pos + 16] .try_into() - .map_err(|_| ProtocolError::Malformed("bad parent".into()))?, + .map_err(|_| Error::Malformed("bad parent".into()))?, ); pos += 16; @@ -177,14 +177,14 @@ impl TreeAnnounce { let ancestry_count = u16::from_le_bytes( payload[pos..pos + 2] .try_into() - .map_err(|_| ProtocolError::Malformed("bad ancestry count".into()))?, + .map_err(|_| Error::Malformed("bad ancestry count".into()))?, ) as usize; pos += 2; // Validate remaining length: entries + signature let expected_remaining = ancestry_count * CoordEntry::WIRE_SIZE + 64; if payload.len() - pos < expected_remaining { - return Err(ProtocolError::MessageTooShort { + return Err(Error::MessageTooShort { expected: pos + expected_remaining, got: payload.len(), }); @@ -196,19 +196,19 @@ impl TreeAnnounce { let node_addr = NodeAddr::from_bytes( payload[pos..pos + 16] .try_into() - .map_err(|_| ProtocolError::Malformed("bad entry node_addr".into()))?, + .map_err(|_| Error::Malformed("bad entry node_addr".into()))?, ); pos += 16; let entry_seq = u64::from_le_bytes( payload[pos..pos + 8] .try_into() - .map_err(|_| ProtocolError::Malformed("bad entry sequence".into()))?, + .map_err(|_| Error::Malformed("bad entry sequence".into()))?, ); pos += 8; let entry_ts = u64::from_le_bytes( payload[pos..pos + 8] .try_into() - .map_err(|_| ProtocolError::Malformed("bad entry timestamp".into()))?, + .map_err(|_| Error::Malformed("bad entry timestamp".into()))?, ); pos += 8; entries.push(CoordEntry::new(node_addr, entry_seq, entry_ts)); @@ -217,16 +217,16 @@ impl TreeAnnounce { // signature (64) let sig_bytes: [u8; 64] = payload[pos..pos + 64] .try_into() - .map_err(|_| ProtocolError::Malformed("bad signature".into()))?; + .map_err(|_| Error::Malformed("bad signature".into()))?; // Validate the signature parses as a well-formed schnorr signature (the // codec's only crypto touch, §11 w2); store the raw bytes so the in-core // declaration carries no `secp256k1` dependency. Actual verification is a // shell concern (§6). - Signature::from_slice(&sig_bytes).map_err(|_| ProtocolError::InvalidSignature)?; + Signature::from_slice(&sig_bytes).map_err(|_| Error::InvalidSignature)?; // The first entry's node_addr is the declaring node if entries.is_empty() { - return Err(ProtocolError::Malformed( + return Err(Error::Malformed( "ancestry must have at least one entry".into(), )); } @@ -236,7 +236,7 @@ impl TreeAnnounce { ParentDeclaration::with_signature(node_addr, parent, sequence, timestamp, sig_bytes); let ancestry = TreeCoordinate::new(entries) - .map_err(|e| ProtocolError::Malformed(format!("bad ancestry: {}", e)))?; + .map_err(|e| Error::Malformed(format!("bad ancestry: {}", e)))?; Ok(Self { declaration, @@ -244,6 +244,98 @@ impl TreeAnnounce { }) } } +// ============================================================================ +// Coordinate Wire Format Helpers +// ============================================================================ + +/// Wire size of a TreeCoordinate in address-only format: 2 + entries × 16. +pub(crate) fn coords_wire_size(coords: &TreeCoordinate) -> usize { + 2 + coords.entries().len() * 16 +} + +/// Encode a TreeCoordinate as address-only wire format: count(u16 LE) + addrs(16 × n). +/// +/// Session-layer messages serialize coordinates as NodeAddr arrays (16 bytes each), +/// without the sequence/timestamp metadata used by the tree gossip protocol. +pub(crate) fn encode_coords(coords: &TreeCoordinate, buf: &mut Vec) { + let addrs: Vec<&NodeAddr> = coords.node_addrs().collect(); + let count = addrs.len() as u16; + buf.extend_from_slice(&count.to_le_bytes()); + for addr in addrs { + buf.extend_from_slice(addr.as_bytes()); + } +} + +/// Decode a TreeCoordinate from address-only wire format. +/// +/// Returns the decoded coordinate and the number of bytes consumed. +pub(crate) fn decode_coords(data: &[u8]) -> Result<(TreeCoordinate, usize), Error> { + if data.len() < 2 { + return Err(Error::MessageTooShort { + expected: 2, + got: data.len(), + }); + } + let count = u16::from_le_bytes([data[0], data[1]]) as usize; + let needed = 2 + count * 16; + if data.len() < needed { + return Err(Error::MessageTooShort { + expected: needed, + got: data.len(), + }); + } + if count == 0 { + return Err(Error::Malformed("coordinate with zero entries".into())); + } + let mut addrs = Vec::with_capacity(count); + for i in 0..count { + let offset = 2 + i * 16; + let mut bytes = [0u8; 16]; + bytes.copy_from_slice(&data[offset..offset + 16]); + addrs.push(NodeAddr::from_bytes(bytes)); + } + let coord = TreeCoordinate::from_addrs(addrs).map_err(|e| Error::Malformed(e.to_string()))?; + Ok((coord, needed)) +} + +/// Decode an optional coordinate field (count may be 0). +/// +/// Returns None if count is 0, Some(coord) otherwise, plus bytes consumed. +pub(crate) fn decode_optional_coords( + data: &[u8], +) -> Result<(Option, usize), Error> { + if data.len() < 2 { + return Err(Error::MessageTooShort { + expected: 2, + got: data.len(), + }); + } + let count = u16::from_le_bytes([data[0], data[1]]) as usize; + let needed = 2 + count * 16; + if data.len() < needed { + return Err(Error::MessageTooShort { + expected: needed, + got: data.len(), + }); + } + if count == 0 { + return Ok((None, 2)); + } + let mut addrs = Vec::with_capacity(count); + for i in 0..count { + let offset = 2 + i * 16; + let mut bytes = [0u8; 16]; + bytes.copy_from_slice(&data[offset..offset + 16]); + addrs.push(NodeAddr::from_bytes(bytes)); + } + let coord = TreeCoordinate::from_addrs(addrs).map_err(|e| Error::Malformed(e.to_string()))?; + Ok((Some(coord), needed)) +} + +/// Encode a count of zero (for empty/absent coordinate fields). +pub(crate) fn encode_empty_coords(buf: &mut Vec) { + buf.extend_from_slice(&0u16.to_le_bytes()); +} #[cfg(test)] mod tests { @@ -388,10 +480,7 @@ mod tests { encoded[1] = 0xFF; let result = TreeAnnounce::decode(&encoded[1..]); - assert!(matches!( - result, - Err(ProtocolError::UnsupportedVersion(0xFF)) - )); + assert!(matches!(result, Err(Error::UnsupportedVersion(0xFF)))); } #[test] @@ -400,7 +489,7 @@ mod tests { let result = TreeAnnounce::decode(&[0x01]); assert!(matches!( result, - Err(ProtocolError::MessageTooShort { expected: 99, .. }) + Err(Error::MessageTooShort { expected: 99, .. }) )); // Just under minimum (98 bytes) @@ -408,7 +497,7 @@ mod tests { let result = TreeAnnounce::decode(&short); assert!(matches!( result, - Err(ProtocolError::MessageTooShort { expected: 99, .. }) + Err(Error::MessageTooShort { expected: 99, .. }) )); } @@ -432,7 +521,7 @@ mod tests { encoded[35] = 0; let result = TreeAnnounce::decode(&encoded[1..]); - assert!(matches!(result, Err(ProtocolError::MessageTooShort { .. }))); + assert!(matches!(result, Err(Error::MessageTooShort { .. }))); } #[test] @@ -443,7 +532,7 @@ mod tests { let announce = TreeAnnounce::new(decl, ancestry); let result = announce.encode(); - assert!(matches!(result, Err(ProtocolError::InvalidSignature))); + assert!(matches!(result, Err(Error::InvalidSignature))); } /// Tests that a well-formed non-root ancestry is accepted. diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs deleted file mode 100644 index 0998454..0000000 --- a/src/protocol/mod.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! FIPS Protocol Messages -//! -//! Wire format definitions for FIPS protocol communication across two layers: -//! -//! ## Link Layer (peer-to-peer, hop-by-hop) -//! -//! Messages exchanged between directly connected peers over Noise-encrypted -//! links. Includes spanning tree gossip, bloom filter propagation, discovery -//! protocol, and forwarding of session-layer datagrams. -//! -//! Link-layer peer authentication uses Noise IK (see `noise.rs`), which -//! establishes the encrypted channel before any of these messages are sent. -//! -//! ## Session Layer (end-to-end, between FIPS addresses) -//! -//! Messages exchanged between source and destination FIPS nodes, encrypted -//! with session keys that intermediate nodes cannot read. Includes session -//! establishment, IPv6 datagram encapsulation, and routing errors. -//! -//! Session-layer datagrams are carried as opaque payloads through the link -//! layer, encrypted end-to-end independently of per-hop link encryption. - -mod error; -mod link; -pub(crate) mod session; - -// Re-export all public types at protocol:: level -pub use error::ProtocolError; -pub use link::{ - LinkMessageType, SESSION_DATAGRAM_HEADER_SIZE, SessionDatagram, SessionDatagramRef, -}; -pub use session::{ - FspFlags, FspInnerFlags, SessionAck, SessionFlags, SessionMessageType, SessionMsg3, - SessionSetup, -}; -pub(crate) use session::{coords_wire_size, decode_optional_coords, encode_coords}; - -/// Protocol version for message compatibility. -pub const PROTOCOL_VERSION: u8 = 1; - -// Legacy type alias re-export -#[allow(deprecated)] -pub use link::MessageType;