proto/fsp: extract FSP session protocol into sans-IO layout, retire src/protocol

Migrate the FSP end-to-end session subsystem into src/proto/fsp/ following the
established sans-IO shape, and retire the src/protocol grab-bag now that FSP was
its last occupant.

Relocate the FSP session wire (node/session_wire.rs plus the FSP message types
from protocol/session.rs) into proto/fsp/wire.rs. Hoist the pure decision logic
into proto/fsp/core.rs over plain-data SessionSnapshots returning an ordered
FspAction list the shell drives: session-rekey policy, msg3-resend
classification, post-decrypt epoch reaction, setup/dual-init tie-break,
coords/path-MTU emit-policy, bounded pending-queue, and IPv6 ECN. The
crypto-owning SessionEntry stays shell-side in node/session.rs (matching the FMP
ActivePeer pattern); proto/fsp is wire + core + limits only, with no proto->noise
dependency and no crypto.

Move the coords helpers to proto/stp/ (they serialize TreeCoordinate), and split
SessionMessageType: the encrypted-inner 0x10-0x1F variants stay in proto/fsp/wire.rs
while the 0x20-0x2F routing signals become a new RoutingSignalType in
proto/routing/wire.rs. Migrate the session-MMP shell adapter, which continues to
drive proto/mmp/.

Retire src/protocol: LinkMessageType and SessionDatagram move to a new shared
proto/link.rs, ProtocolError becomes proto::Error (relocated verbatim), the
deprecated MessageType alias and the unimported PROTOCOL_VERSION are dropped, and
src/protocol/ is deleted along with its lib.rs module declaration.

Behavior-neutral: wire bytes unchanged, oracle tests pass unedited except
mod-path relocation; adds rekey/epoch characterization tests and pure
poll/emit-policy core tests.
This commit is contained in:
Johnathan Corgan
2026-07-08 04:57:00 +00:00
parent 4ed674ea8b
commit 4ad5940114
37 changed files with 2848 additions and 1741 deletions
+8 -6
View File
@@ -22,7 +22,6 @@ pub mod noise;
pub mod peer; pub mod peer;
pub mod perf_profile; pub mod perf_profile;
pub(crate) mod proto; pub(crate) mod proto;
pub mod protocol;
#[cfg(test)] #[cfg(test)]
pub(crate) mod testutil; pub(crate) mod testutil;
pub mod transport; pub mod transport;
@@ -57,11 +56,14 @@ pub use transport::{
TransportState, TransportType, packet_channel, TransportState, TransportType, packet_channel,
}; };
// Re-export protocol types // Re-export link-layer types (relocated from protocol:: to proto::link)
pub use protocol::{ pub use proto::link::{LinkMessageType, SessionDatagram};
LinkMessageType, ProtocolError, SessionAck, SessionDatagram, SessionFlags, SessionMessageType,
SessionSetup, // 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) // Re-export STP wire types (relocated from protocol:: to proto::stp)
pub use proto::stp::TreeAnnounce; pub use proto::stp::TreeAnnounce;
+5 -3
View File
@@ -50,8 +50,8 @@
// warnings rather than gate every function individually. // warnings rather than gate every function individually.
#![cfg_attr(not(unix), allow(dead_code))] #![cfg_attr(not(unix), allow(dead_code))]
use crate::node::session_wire::FSP_HEADER_SIZE;
use crate::node::wire::ESTABLISHED_HEADER_SIZE; use crate::node::wire::ESTABLISHED_HEADER_SIZE;
use crate::proto::fsp::wire::FSP_HEADER_SIZE;
use crate::transport::udp::socket::AsyncUdpSocket; use crate::transport::udp::socket::AsyncUdpSocket;
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
use crossbeam_channel::{Receiver, SendError, Sender, TrySendError, bounded}; use crossbeam_channel::{Receiver, SendError, Sender, TrySendError, bounded};
@@ -1904,10 +1904,12 @@ mod unix_tests {
#[test] #[test]
fn pipelined_send_wire_layout_roundtrips_canonical_decoders() { fn pipelined_send_wire_layout_roundtrips_canonical_decoders() {
use crate::NodeAddr; use crate::NodeAddr;
use crate::node::session_wire::build_fsp_header;
use crate::node::wire::{EncryptedHeader, FLAG_KEY_EPOCH, build_established_header}; use crate::node::wire::{EncryptedHeader, FLAG_KEY_EPOCH, build_established_header};
use crate::noise::TAG_SIZE; 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; use crate::utils::index::SessionIndex;
let rt = tokio::runtime::Builder::new_current_thread() let rt = tokio::runtime::Builder::new_current_thread()
+4 -3
View File
@@ -7,13 +7,14 @@
use crate::NodeAddr; use crate::NodeAddr;
use crate::node::reject::ForwardingReject; 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, FSP_COMMON_PREFIX_SIZE, FSP_HEADER_SIZE, FSP_PHASE_ESTABLISHED, FSP_PHASE_MSG1, FSP_PHASE_MSG2,
FspCommonPrefix, parse_encrypted_coords, 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::proto::routing::{DropReason, NextHop, RouteAction, RouteOutcome};
use crate::protocol::{SessionAck, SessionDatagram, SessionDatagramRef, SessionSetup};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tracing::{debug, warn}; use tracing::{debug, warn};
+4 -222
View File
@@ -8,12 +8,11 @@ use crate::NodeAddr;
use crate::node::Node; use crate::node::Node;
use crate::node::reject::{MmpReject, RejectReason, TreeReject}; use crate::node::reject::{MmpReject, RejectReason, TreeReject};
use crate::node::tree::sign_declaration; use crate::node::tree::sign_declaration;
use crate::proto::link::LinkMessageType;
use crate::proto::mmp::{ use crate::proto::mmp::{
BackoffUpdate, LinkReportKind, LinkReportSnapshot, MmpAction, MmpSessionState, LinkReportKind, LinkReportSnapshot, MmpAction, PeerLivenessSnapshot, ReceiverReport, RrLog,
PathMtuNotification, PeerLivenessSnapshot, ReceiverReport, RrLog, SendResult, SenderReport, SenderReport,
SessionReceiverReport, SessionReportKind, SessionReportSnapshot, SessionSenderReport,
}; };
use crate::protocol::{LinkMessageType, SessionMessageType};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tracing::{debug, info, trace, warn}; 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. /// 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 { if bps == 0.0 {
"n/a".to_string() "n/a".to_string()
} else if bps >= 1_000_000.0 { } 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<SessionReportSnapshot> = 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<SendResult> = 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. /// Send heartbeats and remove dead peers.
/// ///
/// Called from the tick handler. Sends a 1-byte heartbeat to each peer /// Called from the tick handler. Sends a 1-byte heartbeat to each peer
+131 -153
View File
@@ -10,26 +10,22 @@ use crate::node::Node;
use crate::node::wire::build_msg1; use crate::node::wire::build_msg1;
use crate::noise::HandshakeState; use crate::noise::HandshakeState;
use crate::proto::fmp::{ConnAction, LifecycleView, PeerSnapshot, RekeyCfg, RekeyResendSnapshot}; 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}; use tracing::{debug, trace, warn};
/// Keep previous session alive for this long after cutover. /// 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; const DRAIN_WINDOW_SECS: u64 = 10;
/// Suppress local rekey initiation for this long after receiving /// 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; 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 { impl Node {
/// Periodic rekey check. Called from the tick loop. /// Periodic rekey check. Called from the tick loop.
/// ///
@@ -358,66 +354,77 @@ impl Node {
let ttl = self.config().node.session.default_ttl; let ttl = self.config().node.session.default_ttl;
let my_addr = *self.node_addr(); let my_addr = *self.node_addr();
// Collect rekey initiators whose msg3 retransmission is due. // The shell snapshots each session retaining a msg3 payload (resend-due
let mut to_resend: Vec<(NodeAddr, Vec<u8>)> = Vec::new(); // predicate resolved here); the core classifies abandon-vs-resend,
let mut to_abandon: Vec<NodeAddr> = Vec::new(); // abandons first.
let candidates = self.rekey_msg3_resend_snapshots(now_ms);
for (node_addr, entry) in &self.sessions { for action in self.fsp.poll_rekey_msg3_resends(candidates, max_resends) {
// Only the rekey initiator retains a msg3 payload. match action {
let payload = match entry.rekey_msg3_payload() { FspAction::AbandonRekey { addr } => {
Some(p) => p, if let Some(entry) = self.sessions.get_mut(&addr) {
None => continue, entry.abandon_rekey();
}; }
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) => {
debug!( debug!(
peer = %self.peer_display_name(&node_addr), peer = %self.peer_display_name(&addr),
error = %e, "FSP rekey aborted: msg3 unconfirmed after max retransmissions, abandoning cycle"
"FSP rekey msg3 retransmission failed"
); );
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) { if sent && let Some(entry) = self.sessions.get_mut(&addr) {
let count = entry.rekey_msg3_resend_count() + 1; let count = entry.rekey_msg3_resend_count() + 1;
let next = now_ms + (interval_ms as f64 * backoff.powi(count as i32)) as u64; let next =
entry.record_rekey_msg3_resend(next); now_ms + (interval_ms as f64 * backoff.powi(count as i32)) as u64;
trace!( entry.record_rekey_msg3_resend(next);
peer = %self.peer_display_name(&node_addr), trace!(
resend = count, peer = %self.peer_display_name(&addr),
"Resent FSP rekey msg3" 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<RekeyMsg3ResendSnapshot> {
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. /// Periodic session (FSP) rekey check. Called from the tick loop.
/// ///
/// For each established session: /// For each established session:
@@ -435,100 +442,71 @@ impl Node {
return; return;
} }
let rekey_after_secs = self.config().node.rekey.after_secs; let cfg = crate::proto::fsp::RekeyCfg {
let rekey_after_messages = self.config().node.rekey.after_messages; after_secs: self.config().node.rekey.after_secs,
after_messages: self.config().node.rekey.after_messages,
};
let now_ms = Self::now_ms(); 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<NodeAddr> = Vec::new(); // The shell snapshots each established session's rekey ages/flags
let mut sessions_to_drain: Vec<NodeAddr> = Vec::new(); // (every clock read resolved here); the core decides
let mut sessions_to_rekey: Vec<NodeAddr> = Vec::new(); // cutover/drain/trigger with no clock, phase-grouped to preserve the
// pre-refactor execution order.
for (node_addr, entry) in &self.sessions { let snapshots = self.session_rekey_snapshots(now_ms);
if !entry.is_established() { for action in self.fsp.poll_rekey(snapshots, &cfg) {
continue; match action {
} FspAction::CutOver { addr } => {
if let Some(entry) = self.sessions.get_mut(&addr)
// 1. Initiator-side cutover (option A): completed rekey, && entry.cutover_to_new_session(now_ms)
// pending session ready, liveness timer elapsed. This is {
// an unconditional timer, NOT gated on responder progress — debug!(
// overlapping-epoch trial-decrypt covers the cutover skew, peer = %self.peer_display_name(&addr),
// so flipping the K-bit here is always safe. An "FSP rekey cutover complete (initiator), K-bit flipped"
// 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() FspAction::CompleteDrain { addr } => {
&& !entry.has_rekey_in_progress() if let Some(entry) = self.sessions.get_mut(&addr) {
&& entry.is_rekey_initiator() entry.complete_drain();
&& now_ms.saturating_sub(entry.rekey_completed_ms()) >= FSP_CUTOVER_DELAY_MS trace!(
{ peer = %self.peer_display_name(&addr),
sessions_to_cutover.push(*node_addr); "FSP drain complete, previous session erased"
continue; );
} }
}
// 2. Drain window expiry FspAction::InitiateRekey { addr } => {
if entry.is_draining() && entry.drain_expired(now_ms, drain_ms) { self.initiate_session_rekey(&addr).await;
sessions_to_drain.push(*node_addr); }
} #[allow(unreachable_patterns)]
_ => {}
// 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);
} }
} }
}
// Execute cutover for initiator side /// Snapshot every established session for the FSP rekey decision,
for node_addr in sessions_to_cutover { /// pre-computing its monotonic age and timer predicates so the pure core
if let Some(entry) = self.sessions.get_mut(&node_addr) /// applies the thresholds without reading a clock (see [`SessionSnapshot`]).
&& entry.cutover_to_new_session(now_ms) fn session_rekey_snapshots(&self, now_ms: u64) -> Vec<SessionSnapshot> {
{ let drain_ms = crate::proto::fsp::limits::DRAIN_WINDOW_SECS * 1000;
debug!( let dampening_ms = crate::proto::fsp::limits::REKEY_DAMPENING_SECS * 1000;
peer = %self.peer_display_name(&node_addr), self.sessions
"FSP rekey cutover complete (initiator), K-bit flipped" .iter()
); .filter(|(_, entry)| entry.is_established())
} .map(|(node_addr, entry)| SessionSnapshot {
} addr: *node_addr,
has_pending: entry.pending_new_session().is_some(),
// Execute drain completion rekey_in_progress: entry.has_rekey_in_progress(),
for node_addr in sessions_to_drain { is_rekey_initiator: entry.is_rekey_initiator(),
if let Some(entry) = self.sessions.get_mut(&node_addr) { cutover_timer_elapsed: cutover_timer_elapsed(now_ms, entry.rekey_completed_ms()),
entry.complete_drain(); is_draining: entry.is_draining(),
trace!( drain_expired: entry.drain_expired(now_ms, drain_ms),
peer = %self.peer_display_name(&node_addr), has_rekey_msg3_payload: entry.rekey_msg3_payload().is_some(),
"FSP drain complete, previous session erased" 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(),
})
// Initiate new rekeys .collect()
for node_addr in sessions_to_rekey {
self.initiate_session_rekey(&node_addr).await;
}
} }
/// Initiate an FSP session rekey. /// Initiate an FSP session rekey.
+366 -132
View File
@@ -6,14 +6,9 @@
//! encrypted data, and error signals (CoordsRequired, PathBroken). //! encrypted data, and error signals (CoordsRequired, PathBroken).
use crate::NodeAddr; use crate::NodeAddr;
use crate::node::handlers::mmp::format_throughput;
use crate::node::reject::{RejectReason, SessionReject}; use crate::node::reject::{RejectReason, SessionReject};
use crate::node::session::{EndToEndState, EpochSlot, SessionEntry}; 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)] #[cfg(unix)]
use crate::node::wire::{ use crate::node::wire::{
ESTABLISHED_HEADER_SIZE, FLAG_KEY_EPOCH, FLAG_SP, build_established_header, ESTABLISHED_HEADER_SIZE, FLAG_KEY_EPOCH, FLAG_SP, build_established_header,
@@ -22,19 +17,28 @@ use crate::node::{Node, NodeError};
use crate::noise::{ use crate::noise::{
HandshakeState, XK_HANDSHAKE_MSG1_SIZE, XK_HANDSHAKE_MSG2_SIZE, XK_HANDSHAKE_MSG3_SIZE, 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::{ 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}; use crate::proto::mmp::{MAX_SESSION_REPORT_INTERVAL_MS, MIN_SESSION_REPORT_INTERVAL_MS};
#[cfg(unix)] use crate::proto::routing::{CoordsRequired, MtuExceeded, PathBroken, RoutingSignalType};
use crate::protocol::LinkMessageType; use crate::proto::stp::{coords_wire_size, encode_coords};
#[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};
#[cfg(unix)] #[cfg(unix)]
use crate::transport::TransportHandle; use crate::transport::TransportHandle;
use crate::upper::icmp::FIPS_OVERHEAD; use crate::upper::icmp::FIPS_OVERHEAD;
@@ -105,14 +109,14 @@ impl Node {
} }
let error_type = inner[0]; let error_type = inner[0];
let error_body = &inner[1..]; let error_body = &inner[1..];
match SessionMessageType::from_byte(error_type) { match RoutingSignalType::from_byte(error_type) {
Some(SessionMessageType::CoordsRequired) => { Some(RoutingSignalType::CoordsRequired) => {
self.handle_coords_required(error_body).await; self.handle_coords_required(error_body).await;
} }
Some(SessionMessageType::PathBroken) => { Some(RoutingSignalType::PathBroken) => {
self.handle_path_broken(error_body).await; self.handle_path_broken(error_body).await;
} }
Some(SessionMessageType::MtuExceeded) => { Some(RoutingSignalType::MtuExceeded) => {
self.handle_mtu_exceeded(error_body).await; self.handle_mtu_exceeded(error_body).await;
} }
_ => { _ => {
@@ -167,11 +171,14 @@ impl Node {
match parse_encrypted_coords(coord_data) { match parse_encrypted_coords(coord_data) {
Ok((src_coords, dest_coords, bytes_consumed)) => { Ok((src_coords, dest_coords, bytes_consumed)) => {
let now_ms = Self::now_ms(); let now_ms = Self::now_ms();
if let Some(coords) = src_coords { let my_addr = *self.node_addr();
self.coord_cache.insert(*src_addr, coords, now_ms); for action in
} self.fsp
if let Some(coords) = dest_coords { .plan_cache_coords(*src_addr, my_addr, src_coords, dest_coords)
self.coord_cache.insert(*self.node_addr(), coords, now_ms); {
if let FspAction::CacheCoords { addr, coords } = action {
self.coord_cache.insert(addr, coords, now_ms);
}
} }
ciphertext_offset += bytes_consumed; ciphertext_offset += bytes_consumed;
} }
@@ -249,44 +256,55 @@ impl Node {
} }
}; };
// React to the epoch the frame decrypted against. // React to the epoch the frame decrypted against. The shell opened
match slot { // the frame; the core classifies the post-decrypt reaction over the
EpochSlot::Pending => { // plain-data slot + session flags, and the shell applies the
// A frame that authenticates against `pending` is itself // `SessionEntry` mutation.
// the cutover signal — proof the peer derived the new let decrypt_slot = match slot {
// session and moved to it. Promote now: current → EpochSlot::Current => DecryptSlot::Current,
// previous, pending → current, flip the K-bit. The EpochSlot::Pending => DecryptSlot::Pending,
// header K-bit is no longer the gating event; the EpochSlot::Previous => DecryptSlot::Previous,
// authenticated decrypt is. };
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!( info!(
peer = %self.peer_display_name(src_addr), peer = %self.peer_display_name(src_addr),
"Peer FSP new-epoch frame authenticated, FSP rekey cutover complete, promoting new session" "Peer FSP new-epoch frame authenticated, FSP rekey cutover complete, promoting new session"
); );
// The peer derived the new session, so it received msg3: entry.confirm_peer_new_epoch();
// 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.handle_peer_kbit_flip(now_ms); entry.handle_peer_kbit_flip(now_ms);
} }
EpochSlot::Current => { EpochReaction::Promote => {
// If we still retain a msg3 retransmission payload but no // Promote now: current → previous, pending → current, flip the
// longer hold a `pending` session, we are the rekey // K-bit. The header K-bit is only a hint; the authenticated
// initiator that already cut over on its own timer: // decrypt is the gating event.
// `current` is now the new epoch, so a frame decrypting info!(
// against it confirms the responder reached the new peer = %self.peer_display_name(src_addr),
// epoch. Stop retransmitting msg3. "Peer FSP new-epoch frame authenticated, FSP rekey cutover complete, promoting new session"
if entry.rekey_msg3_payload().is_some() && entry.pending_new_session().is_none() { );
entry.confirm_peer_new_epoch(); entry.handle_peer_kbit_flip(now_ms);
}
} }
EpochSlot::Previous => { EpochReaction::ConfirmResponder => {
// The peer is still on the old epoch. `fsp_trial_decrypt` // We are the rekey initiator that already cut over on its own
// already refreshed the drain deadline so the `previous` // timer: `current` is now the new epoch, so a frame decrypting
// slot is not retired while the peer keeps using it — // against it confirms the responder reached it. Stop
// no further state change here, just deliver. // 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 let Some(existing) = self.sessions.get(src_addr) {
if existing.is_initiating() { if existing.is_initiating() {
// Simultaneous initiation: smaller NodeAddr wins as initiator // 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 // We win — drop their setup, they'll process ours
debug!( debug!(
src = %self.peer_display_name(src_addr), src = %self.peer_display_name(src_addr),
@@ -483,7 +501,10 @@ impl Node {
// simultaneously. Apply tie-breaker — smaller NodeAddr // simultaneously. Apply tie-breaker — smaller NodeAddr
// wins as initiator (same as initial session setup). // wins as initiator (same as initial session setup).
if rekey_in_progress { 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. // We win as initiator — drop their msg1.
debug!( debug!(
src = %self.peer_display_name(src_addr), src = %self.peer_display_name(src_addr),
@@ -920,6 +941,221 @@ impl Node {
// === Session-layer MMP report handlers === // === 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<SessionReportSnapshot> = 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<SendResult> = 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). /// Handle an incoming session-layer SenderReport (msg_type 0x11).
/// ///
/// Informational only — the peer is telling us about what they sent. /// 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. // tighter of existing-or-new — never loosen the clamp.
let fips_addr = crate::FipsAddress::from_node_addr(src_addr); let fips_addr = crate::FipsAddress::from_node_addr(src_addr);
match self.path_mtu_lookup.write() { match self.path_mtu_lookup.write() {
Ok(mut map) => match map.get(&fips_addr).copied() { Ok(mut map) => {
Some(existing) if existing <= new_mtu => { // 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!( debug!(
dest = %peer_name, dest = %peer_name,
fips_addr = %fips_addr, fips_addr = %fips_addr,
new_mtu, new_mtu,
existing, existing = prior.unwrap_or(new_mtu),
"PathMtuNotification: keeping tighter existing path_mtu_lookup value" "PathMtuNotification: keeping tighter existing path_mtu_lookup value"
); );
} }
other => { for action in actions {
map.insert(fips_addr, new_mtu); if let FspAction::TightenPathMtuLookup { fips_addr, mtu } = action {
debug!( map.insert(fips_addr, mtu);
dest = %peer_name, debug!(
fips_addr = %fips_addr, dest = %peer_name,
new_mtu, fips_addr = %fips_addr,
prior = ?other, new_mtu,
map_len = map.len(), prior = ?prior,
"PathMtuNotification: tightened path_mtu_lookup" map_len = map.len(),
); "PathMtuNotification: tightened path_mtu_lookup"
);
}
} }
}, }
Err(e) => { Err(e) => {
warn!( warn!(
dest = %peer_name, dest = %peer_name,
@@ -1145,12 +1387,19 @@ impl Node {
// Only trigger discovery if we have the target's identity cached — // Only trigger discovery if we have the target's identity cached —
// otherwise we can't verify the LookupResponse proof. // otherwise we can't verify the LookupResponse proof.
if self.has_cached_identity(&msg.dest_addr) { let has_cached_identity = self.has_cached_identity(&msg.dest_addr);
self.maybe_initiate_lookup(&msg.dest_addr).await; let actions = self
} else { .fsp
.plan_coords_required_lookup(msg.dest_addr, has_cached_identity);
if actions.is_empty() {
debug!(dest = %msg.dest_addr, debug!(dest = %msg.dest_addr,
"Skipping discovery after CoordsRequired: no cached identity for target"); "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 // Reset coords warmup counter so the next N packets also include
// COORDS_PRESENT, re-warming transit caches along the path. // COORDS_PRESENT, re-warming transit caches along the path.
@@ -1204,16 +1453,26 @@ impl Node {
"PathBroken response rate-limited, skipping standalone CoordsWarmup"); "PathBroken response rate-limited, skipping standalone CoordsWarmup");
} }
// Invalidate stale cached coordinates // Invalidate stale cached coordinates, then (only if the target's
self.coord_cache.remove(&msg.dest_addr); // identity is cached — else the LookupResponse proof cannot be verified,
// e.g. when the XK responder receives PathBroken before msg3 completes)
// Trigger re-discovery to get fresh coordinates, but only if we have // trigger re-discovery. The core emits invalidate-then-lookup in order.
// the target's identity cached — otherwise we can't verify the let has_cached_identity = self.has_cached_identity(&msg.dest_addr);
// LookupResponse proof. This avoids a race when the XK responder let actions = self
// receives PathBroken before msg3 completes (identity unknown). .fsp
if self.has_cached_identity(&msg.dest_addr) { .plan_path_broken(msg.dest_addr, has_cached_identity);
self.maybe_initiate_lookup(&msg.dest_addr).await; for action in actions {
} else { 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, debug!(dest = %msg.dest_addr,
"Skipping discovery after PathBroken: no cached identity for target"); "Skipping discovery after PathBroken: no cached identity for target");
} }
@@ -1283,28 +1542,34 @@ impl Node {
// tighter of existing-or-new — never loosen the clamp. // tighter of existing-or-new — never loosen the clamp.
let fips_addr = crate::FipsAddress::from_node_addr(&msg.dest_addr); let fips_addr = crate::FipsAddress::from_node_addr(&msg.dest_addr);
match self.path_mtu_lookup.write() { match self.path_mtu_lookup.write() {
Ok(mut map) => match map.get(&fips_addr).copied() { Ok(mut map) => {
Some(existing) if existing <= msg.mtu => { // 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!( debug!(
dest = %peer_name, dest = %peer_name,
fips_addr = %fips_addr, fips_addr = %fips_addr,
bottleneck_mtu = msg.mtu, bottleneck_mtu = msg.mtu,
existing, existing = prior.unwrap_or(msg.mtu),
"Reactive MtuExceeded: keeping tighter existing path_mtu_lookup value" "Reactive MtuExceeded: keeping tighter existing path_mtu_lookup value"
); );
} }
other => { for action in actions {
map.insert(fips_addr, msg.mtu); if let FspAction::TightenPathMtuLookup { fips_addr, mtu } = action {
debug!( map.insert(fips_addr, mtu);
dest = %peer_name, debug!(
fips_addr = %fips_addr, dest = %peer_name,
bottleneck_mtu = msg.mtu, fips_addr = %fips_addr,
prior = ?other, bottleneck_mtu = msg.mtu,
map_len = map.len(), prior = ?prior,
"Reactive MtuExceeded: tightened path_mtu_lookup" map_len = map.len(),
); "Reactive MtuExceeded: tightened path_mtu_lookup"
);
}
} }
}, }
Err(e) => { Err(e) => {
warn!( warn!(
dest = %peer_name, dest = %peer_name,
@@ -2243,10 +2508,7 @@ impl Node {
let per_dest = self.config().node.session.pending_packets_per_dest; let per_dest = self.config().node.session.pending_packets_per_dest;
let queue = self.pending_tun_packets.entry(dest_addr).or_default(); let queue = self.pending_tun_packets.entry(dest_addr).or_default();
if queue.len() >= per_dest { crate::proto::fsp::push_bounded_pending(queue, packet, per_dest);
queue.pop_front(); // Drop oldest
}
queue.push_back(packet);
} }
/// Flush pending packets for a destination whose session just reached Established. /// 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);
}
+1 -1
View File
@@ -248,7 +248,7 @@ impl Node {
.collect(); .collect();
for (dest_addr, payload) in candidates { 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 mut datagram = SessionDatagram::new(my_addr, dest_addr, payload).with_ttl(ttl);
let sent = match self.send_session_datagram(&mut datagram).await { let sent = match self.send_session_datagram(&mut datagram).await {
+6 -1
View File
@@ -19,7 +19,6 @@ pub(crate) mod reject;
mod reloadable; mod reloadable;
mod retry; mod retry;
pub(crate) mod session; pub(crate) mod session;
pub(crate) mod session_wire;
pub(crate) mod stats; pub(crate) mod stats;
pub(crate) mod stats_history; pub(crate) mod stats_history;
#[cfg(test)] #[cfg(test)]
@@ -46,6 +45,7 @@ use crate::peer::{ActivePeer, PeerConnection};
use crate::proto::bloom::{BloomFilter, BloomState}; use crate::proto::bloom::{BloomFilter, BloomState};
use crate::proto::discovery::{Discovery, DiscoveryBackoff, DiscoveryForwardRateLimiter}; use crate::proto::discovery::{Discovery, DiscoveryBackoff, DiscoveryForwardRateLimiter};
use crate::proto::fmp::Fmp; use crate::proto::fmp::Fmp;
use crate::proto::fsp::Fsp;
use crate::proto::mmp::Mmp; use crate::proto::mmp::Mmp;
use crate::proto::routing::{self, Router, RoutingErrorRateLimiter}; use crate::proto::routing::{self, Router, RoutingErrorRateLimiter};
use crate::proto::stp::TreeState; use crate::proto::stp::TreeState;
@@ -421,6 +421,9 @@ pub struct Node {
/// FMP connection-lifecycle decision anchor (stateless; drives the /// FMP connection-lifecycle decision anchor (stateless; drives the
/// tick-poll maintain/teardown decisions). /// tick-poll maintain/teardown decisions).
fmp: Fmp, 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 / /// MMP reporting decision anchor (stateless; drives the report-fan-out /
/// liveness / heartbeat decisions). /// liveness / heartbeat decisions).
mmp: Mmp, mmp: Mmp,
@@ -670,6 +673,7 @@ impl Node {
icmp_rate_limiter: IcmpRateLimiter::new(), icmp_rate_limiter: IcmpRateLimiter::new(),
routing: Router::new(), routing: Router::new(),
fmp: Fmp::new(), fmp: Fmp::new(),
fsp: Fsp::new(),
mmp: Mmp::new(), mmp: Mmp::new(),
coords_response_rate_limiter: RoutingErrorRateLimiter::with_interval_ms( coords_response_rate_limiter: RoutingErrorRateLimiter::with_interval_ms(
coords_response_interval_ms, coords_response_interval_ms,
@@ -834,6 +838,7 @@ impl Node {
icmp_rate_limiter: IcmpRateLimiter::new(), icmp_rate_limiter: IcmpRateLimiter::new(),
routing: Router::new(), routing: Router::new(),
fmp: Fmp::new(), fmp: Fmp::new(),
fsp: Fsp::new(),
mmp: Mmp::new(), mmp: Mmp::new(),
coords_response_rate_limiter: RoutingErrorRateLimiter::with_interval_ms( coords_response_rate_limiter: RoutingErrorRateLimiter::with_interval_ms(
coords_response_interval_ms, coords_response_interval_ms,
+152 -1
View File
@@ -801,8 +801,8 @@ impl SessionEntry {
#[cfg(test)] #[cfg(test)]
mod overlapping_epoch_tests { mod overlapping_epoch_tests {
use super::*; use super::*;
use crate::node::session_wire::{FSP_FLAG_K, build_fsp_header};
use crate::noise::HandshakeState; use crate::noise::HandshakeState;
use crate::proto::fsp::wire::{FSP_FLAG_K, build_fsp_header};
use secp256k1::{Keypair, Secp256k1, SecretKey}; use secp256k1::{Keypair, Secp256k1, SecretKey};
/// Deterministic keypair from a single seed byte. /// 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" "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());
}
} }
-618
View File
@@ -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<Self> {
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<Self> {
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<u8> {
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<u8> {
let mut buf = Vec::with_capacity(FSP_INNER_HEADER_SIZE + payload.len());
buf.extend_from_slice(&timestamp_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<TreeCoordinate>, Option<TreeCoordinate>, 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);
}
}
+5 -3
View File
@@ -5,9 +5,11 @@
//! multi-hop forwarding through live node topologies. //! multi-hop forwarding through live node topologies.
use super::*; 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::proto::stp::TreeCoordinate;
use crate::protocol::{SessionAck, SessionDatagram, SessionSetup, encode_coords}; use crate::proto::stp::encode_coords;
use spanning_tree::{ use spanning_tree::{
TestNode, cleanup_nodes, process_available_packets, run_tree_test, verify_tree_convergence, 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::TransportDropState;
use crate::node::handlers::session::mark_ipv6_ecn_ce; use crate::proto::fsp::mark_ipv6_ecn_ce;
use crate::transport::TransportId; use crate::transport::TransportId;
/// Build a minimal IPv6 header (40 bytes) with specified ECN bits. /// Build a minimal IPv6 header (40 bytes) with specified ECN bits.
+2 -1
View File
@@ -6,7 +6,8 @@ use crate::node::tests::spanning_tree::{
TestNode, cleanup_nodes, generate_random_edges, lock_large_network_test, TestNode, cleanup_nodes, generate_random_edges, lock_large_network_test,
process_available_packets, run_tree_test, run_tree_test_with_mtus, verify_tree_convergence, 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. /// Populate all nodes' coordinate caches with each other's coords.
/// ///
+2 -2
View File
@@ -15,8 +15,8 @@
//! computation + the send-debounce decision). //! computation + the send-debounce decision).
//! - `limits.rs` — the v1 sizing constants. //! - `limits.rs` — the v1 sizing constants.
//! - `wire.rs` — `FilterAnnounce` + `encode`/`decode` (the std-tethered file). //! - `wire.rs` — `FilterAnnounce` + `encode`/`decode` (the std-tethered file).
//! It imports the shared `ProtocolError` and `LinkMessageType` downward from //! It imports the shared [`crate::proto::Error`] and
//! `crate::protocol`. //! [`crate::proto::link::LinkMessageType`] downward.
mod core; mod core;
mod limits; mod limits;
+1 -1
View File
@@ -2,7 +2,7 @@
use crate::proto::bloom::BloomFilter; use crate::proto::bloom::BloomFilter;
use crate::proto::bloom::FilterAnnounce; use crate::proto::bloom::FilterAnnounce;
use crate::protocol::LinkMessageType; use crate::proto::link::LinkMessageType;
use crate::testutil::make_node_addr; use crate::testutil::make_node_addr;
#[test] #[test]
+11 -11
View File
@@ -1,8 +1,8 @@
//! FilterAnnounce message: bloom filter reachability propagation. //! FilterAnnounce message: bloom filter reachability propagation.
use super::BloomFilter; use super::BloomFilter;
use crate::protocol::LinkMessageType; use crate::proto::Error;
use crate::protocol::ProtocolError; use crate::proto::link::LinkMessageType;
/// Bloom filter announcement for reachability propagation. /// Bloom filter announcement for reachability propagation.
/// ///
@@ -79,9 +79,9 @@ impl FilterAnnounce {
/// ```text /// ```text
/// [0x20][sequence:8 LE][hash_count:1][size_class:1][filter_bits:variable] /// [0x20][sequence:8 LE][hash_count:1][size_class:1][filter_bits:variable]
/// ``` /// ```
pub fn encode(&self) -> Result<Vec<u8>, ProtocolError> { pub fn encode(&self) -> Result<Vec<u8>, Error> {
if !self.is_valid() { if !self.is_valid() {
return Err(ProtocolError::Malformed( return Err(Error::Malformed(
"filter size does not match size_class".into(), "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). /// Decode from link-layer payload (after msg_type byte stripped by dispatcher).
/// ///
/// The payload starts with the sequence field. /// The payload starts with the sequence field.
pub fn decode(payload: &[u8]) -> Result<Self, ProtocolError> { pub fn decode(payload: &[u8]) -> Result<Self, Error> {
if payload.len() < Self::MIN_PAYLOAD_SIZE { if payload.len() < Self::MIN_PAYLOAD_SIZE {
return Err(ProtocolError::MessageTooShort { return Err(Error::MessageTooShort {
expected: Self::MIN_PAYLOAD_SIZE, expected: Self::MIN_PAYLOAD_SIZE,
got: payload.len(), got: payload.len(),
}); });
@@ -121,7 +121,7 @@ impl FilterAnnounce {
let sequence = u64::from_le_bytes( let sequence = u64::from_le_bytes(
payload[pos..pos + 8] payload[pos..pos + 8]
.try_into() .try_into()
.map_err(|_| ProtocolError::Malformed("bad sequence".into()))?, .map_err(|_| Error::Malformed("bad sequence".into()))?,
); );
pos += 8; pos += 8;
@@ -135,7 +135,7 @@ impl FilterAnnounce {
// Validate size_class range // Validate size_class range
if size_class > Self::MAX_SIZE_CLASS { if size_class > Self::MAX_SIZE_CLASS {
return Err(ProtocolError::Malformed(format!( return Err(Error::Malformed(format!(
"invalid size_class: {size_class} (max {})", "invalid size_class: {size_class} (max {})",
Self::MAX_SIZE_CLASS Self::MAX_SIZE_CLASS
))); )));
@@ -143,7 +143,7 @@ impl FilterAnnounce {
// v1 compliance check // v1 compliance check
if size_class != super::V1_SIZE_CLASS { if size_class != super::V1_SIZE_CLASS {
return Err(ProtocolError::Malformed(format!( return Err(Error::Malformed(format!(
"unsupported size_class: {size_class} (v1 requires {})", "unsupported size_class: {size_class} (v1 requires {})",
super::V1_SIZE_CLASS super::V1_SIZE_CLASS
))); )));
@@ -153,7 +153,7 @@ impl FilterAnnounce {
let expected_filter_bytes = 512usize << size_class; let expected_filter_bytes = 512usize << size_class;
let remaining = payload.len() - pos; let remaining = payload.len() - pos;
if remaining != expected_filter_bytes { if remaining != expected_filter_bytes {
return Err(ProtocolError::MessageTooShort { return Err(Error::MessageTooShort {
expected: Self::MIN_PAYLOAD_SIZE + expected_filter_bytes, expected: Self::MIN_PAYLOAD_SIZE + expected_filter_bytes,
got: payload.len(), got: payload.len(),
}); });
@@ -161,7 +161,7 @@ impl FilterAnnounce {
// Construct BloomFilter from bytes // Construct BloomFilter from bytes
let filter = BloomFilter::from_slice(&payload[pos..], hash_count) 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 { let announce = Self {
filter, filter,
+12 -12
View File
@@ -1,9 +1,9 @@
//! Discovery messages: LookupRequest and LookupResponse. //! Discovery messages: LookupRequest and LookupResponse.
use crate::NodeAddr; use crate::NodeAddr;
use crate::proto::Error;
use crate::proto::stp::TreeCoordinate; use crate::proto::stp::TreeCoordinate;
use crate::protocol::ProtocolError; use crate::proto::stp::{decode_coords, encode_coords};
use crate::protocol::session::{decode_coords, encode_coords};
use secp256k1::schnorr::Signature; use secp256k1::schnorr::Signature;
/// Request to discover a node's coordinates. /// Request to discover a node's coordinates.
@@ -96,11 +96,11 @@ impl LookupRequest {
} }
/// Decode from wire format (after msg_type byte has been consumed). /// Decode from wire format (after msg_type byte has been consumed).
pub fn decode(payload: &[u8]) -> Result<Self, ProtocolError> { pub fn decode(payload: &[u8]) -> Result<Self, Error> {
// Minimum: request_id(8) + target(16) + origin(16) + ttl(1) + min_mtu(2) // Minimum: request_id(8) + target(16) + origin(16) + ttl(1) + min_mtu(2)
// + coords_count(2) = 45 bytes // + coords_count(2) = 45 bytes
if payload.len() < 45 { if payload.len() < 45 {
return Err(ProtocolError::MessageTooShort { return Err(Error::MessageTooShort {
expected: 45, expected: 45,
got: payload.len(), got: payload.len(),
}); });
@@ -111,7 +111,7 @@ impl LookupRequest {
let request_id = u64::from_le_bytes( let request_id = u64::from_le_bytes(
payload[pos..pos + 8] payload[pos..pos + 8]
.try_into() .try_into()
.map_err(|_| ProtocolError::Malformed("bad request_id".into()))?, .map_err(|_| Error::Malformed("bad request_id".into()))?,
); );
pos += 8; pos += 8;
@@ -131,7 +131,7 @@ impl LookupRequest {
let min_mtu = u16::from_le_bytes( let min_mtu = u16::from_le_bytes(
payload[pos..pos + 2] payload[pos..pos + 2]
.try_into() .try_into()
.map_err(|_| ProtocolError::Malformed("bad min_mtu".into()))?, .map_err(|_| Error::Malformed("bad min_mtu".into()))?,
); );
pos += 2; pos += 2;
@@ -222,10 +222,10 @@ impl LookupResponse {
} }
/// Decode from wire format (after msg_type byte has been consumed). /// Decode from wire format (after msg_type byte has been consumed).
pub fn decode(payload: &[u8]) -> Result<Self, ProtocolError> { pub fn decode(payload: &[u8]) -> Result<Self, Error> {
// Minimum: request_id(8) + target(16) + path_mtu(2) + coords_count(2) + proof(64) = 92 // Minimum: request_id(8) + target(16) + path_mtu(2) + coords_count(2) + proof(64) = 92
if payload.len() < 92 { if payload.len() < 92 {
return Err(ProtocolError::MessageTooShort { return Err(Error::MessageTooShort {
expected: 92, expected: 92,
got: payload.len(), got: payload.len(),
}); });
@@ -236,7 +236,7 @@ impl LookupResponse {
let request_id = u64::from_le_bytes( let request_id = u64::from_le_bytes(
payload[pos..pos + 8] payload[pos..pos + 8]
.try_into() .try_into()
.map_err(|_| ProtocolError::Malformed("bad request_id".into()))?, .map_err(|_| Error::Malformed("bad request_id".into()))?,
); );
pos += 8; pos += 8;
@@ -248,7 +248,7 @@ impl LookupResponse {
let path_mtu = u16::from_le_bytes( let path_mtu = u16::from_le_bytes(
payload[pos..pos + 2] payload[pos..pos + 2]
.try_into() .try_into()
.map_err(|_| ProtocolError::Malformed("bad path_mtu".into()))?, .map_err(|_| Error::Malformed("bad path_mtu".into()))?,
); );
pos += 2; pos += 2;
@@ -256,13 +256,13 @@ impl LookupResponse {
pos += consumed; pos += consumed;
if payload.len() < pos + 64 { if payload.len() < pos + 64 {
return Err(ProtocolError::MessageTooShort { return Err(Error::MessageTooShort {
expected: pos + 64, expected: pos + 64,
got: payload.len(), got: payload.len(),
}); });
} }
let proof = Signature::from_slice(&payload[pos..pos + 64]) 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 { Ok(Self {
request_id, request_id,
+1 -1
View File
@@ -4,7 +4,7 @@ use thiserror::Error;
/// Errors related to protocol message handling. /// Errors related to protocol message handling.
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum ProtocolError { pub enum Error {
#[error("invalid message type: 0x{0:02x}")] #[error("invalid message type: 0x{0:02x}")]
InvalidMessageType(u8), InvalidMessageType(u8),
+8 -7
View File
@@ -1,12 +1,13 @@
//! FMP link-framing messages: handshake message types and orderly disconnect. //! FMP link-framing messages: handshake message types and orderly disconnect.
//! //!
//! The Noise IK handshake message-type discriminants and the orderly //! The Noise IK handshake message-type discriminants and the orderly
//! disconnect codec, relocated from `protocol::link` per the //! disconnect codec, per the wire-migrates-with-subsystem policy.
//! wire-migrates-with-subsystem policy. `Disconnect::encode` reads the shared //! `Disconnect::encode` reads the shared `LinkMessageType::Disconnect` catalog
//! `LinkMessageType::Disconnect` catalog variant (a downward `proto -> //! variant (a downward `proto -> proto` dependency); the catalog itself lives
//! protocol` dependency); the catalog itself stays in `protocol::link`. //! in `crate::proto::link`.
use crate::protocol::{LinkMessageType, ProtocolError}; use crate::proto::Error;
use crate::proto::link::LinkMessageType;
use std::fmt; use std::fmt;
/// Handshake message type identifiers. /// Handshake message type identifiers.
@@ -151,9 +152,9 @@ impl Disconnect {
} }
/// Decode from link-layer payload (after msg_type byte has been consumed). /// Decode from link-layer payload (after msg_type byte has been consumed).
pub fn decode(payload: &[u8]) -> Result<Self, ProtocolError> { pub fn decode(payload: &[u8]) -> Result<Self, Error> {
if payload.is_empty() { if payload.is_empty() {
return Err(ProtocolError::MessageTooShort { return Err(Error::MessageTooShort {
expected: 1, expected: 1,
got: 0, got: 0,
}); });
+431
View File
@@ -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<SessionSnapshot>,
cfg: &RekeyCfg,
) -> Vec<FspAction> {
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<RekeyMsg3ResendSnapshot>,
max_resends: u32,
) -> Vec<FspAction> {
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<TreeCoordinate>,
dest_coords: Option<TreeCoordinate>,
) -> Vec<FspAction> {
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<FspAction> {
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<FspAction> {
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<u16>,
candidate: u16,
) -> Vec<FspAction> {
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<u16>, 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<Vec<u8>>,
packet: Vec<u8>,
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);
}
+23
View File
@@ -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;
+35
View File
@@ -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,
};
+457
View File
@@ -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<Vec<u8>> = 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]);
}
+2
View File
@@ -0,0 +1,2 @@
mod core;
mod wire;
+558
View File
@@ -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<u8> = (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);
}
+371 -447
View File
@@ -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::proto::Error;
use crate::NodeAddr; use crate::proto::stp::{TreeCoordinate, decode_coords, decode_optional_coords, encode_coords};
use crate::proto::stp::TreeCoordinate;
use std::fmt; 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<Self> {
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<Self> {
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<u8> {
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<u8> {
let mut buf = Vec::with_capacity(FSP_INNER_HEADER_SIZE + payload.len());
buf.extend_from_slice(&timestamp_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<TreeCoordinate>, Option<TreeCoordinate>, 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 // 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 /// These messages are carried end-to-end encrypted inside the FSP AEAD
/// message type 0x00). Post-handshake messages (data, reports) are end-to-end /// envelope; the type is the `msg_type` byte of the encrypted inner header.
/// encrypted with session keys via the FSP pipeline. Error signals /// The plaintext link-layer error signals (`0x20``0x2F`) are a separate
/// (CoordsRequired, PathBroken) are plaintext messages generated by transit /// registry — [`RoutingSignalType`](crate::proto::routing::RoutingSignalType)
/// routers that cannot establish e2e sessions with the source. /// — since they are dispatched on the cleartext (U-flag) path with no session.
/// ///
/// Handshake messages (SessionSetup, SessionAck, SessionMsg3) are **not** /// Handshake messages (SessionSetup, SessionAck, SessionMsg3) are **not**
/// identified by a message-type byte; they are dispatched by the FSP phase /// identified by a message-type byte; they are dispatched by the FSP phase
@@ -36,14 +380,6 @@ pub enum SessionMessageType {
PathMtuNotification = 0x13, PathMtuNotification = 0x13,
/// Standalone coordinate cache warming (empty body, coords in CP flag). /// Standalone coordinate cache warming (empty body, coords in CP flag).
CoordsWarmup = 0x14, 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 { impl SessionMessageType {
@@ -55,9 +391,6 @@ impl SessionMessageType {
0x12 => Some(SessionMessageType::ReceiverReport), 0x12 => Some(SessionMessageType::ReceiverReport),
0x13 => Some(SessionMessageType::PathMtuNotification), 0x13 => Some(SessionMessageType::PathMtuNotification),
0x14 => Some(SessionMessageType::CoordsWarmup), 0x14 => Some(SessionMessageType::CoordsWarmup),
0x20 => Some(SessionMessageType::CoordsRequired),
0x21 => Some(SessionMessageType::PathBroken),
0x22 => Some(SessionMessageType::MtuExceeded),
_ => None, _ => None,
} }
} }
@@ -76,111 +409,11 @@ impl fmt::Display for SessionMessageType {
SessionMessageType::ReceiverReport => "ReceiverReport", SessionMessageType::ReceiverReport => "ReceiverReport",
SessionMessageType::PathMtuNotification => "PathMtuNotification", SessionMessageType::PathMtuNotification => "PathMtuNotification",
SessionMessageType::CoordsWarmup => "CoordsWarmup", SessionMessageType::CoordsWarmup => "CoordsWarmup",
SessionMessageType::CoordsRequired => "CoordsRequired",
SessionMessageType::PathBroken => "PathBroken",
SessionMessageType::MtuExceeded => "MtuExceeded",
}; };
write!(f, "{}", name) 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<u8>) {
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<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 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<u8>) {
buf.extend_from_slice(&0u16.to_le_bytes());
}
// ============================================================================ // ============================================================================
// Session Flags // Session Flags
// ============================================================================ // ============================================================================
@@ -245,6 +478,7 @@ impl SessionFlags {
/// | 1 | K | Key epoch (for rekeying) | /// | 1 | K | Key epoch (for rekeying) |
/// | 2 | U | Unencrypted payload (error signals) | /// | 2 | U | Unencrypted payload (error signals) |
/// | 3-7 | | Reserved | /// | 3-7 | | Reserved |
#[cfg_attr(not(test), allow(dead_code))]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct FspFlags { pub struct FspFlags {
/// Coordinates present between header and ciphertext. /// Coordinates present between header and ciphertext.
@@ -255,6 +489,8 @@ pub struct FspFlags {
pub unencrypted: bool, pub unencrypted: bool,
} }
#[cfg_attr(not(test), allow(dead_code))]
#[allow(clippy::wrong_self_convention)]
impl FspFlags { impl FspFlags {
/// Create default flags (all clear). /// Create default flags (all clear).
pub fn new() -> Self { pub fn new() -> Self {
@@ -298,8 +534,10 @@ pub struct FspInnerFlags {
pub spin_bit: bool, pub spin_bit: bool,
} }
#[allow(clippy::wrong_self_convention)]
impl FspInnerFlags { impl FspInnerFlags {
/// Create default inner flags (all clear). /// Create default inner flags (all clear).
#[cfg_attr(not(test), allow(dead_code))]
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self::default()
} }
@@ -408,9 +646,9 @@ impl SessionSetup {
} }
/// Decode from wire format (after 4-byte FSP prefix has been consumed). /// Decode from wire format (after 4-byte FSP prefix has been consumed).
pub fn decode(payload: &[u8]) -> Result<Self, ProtocolError> { pub fn decode(payload: &[u8]) -> Result<Self, Error> {
if payload.is_empty() { if payload.is_empty() {
return Err(ProtocolError::MessageTooShort { return Err(Error::MessageTooShort {
expected: 1, expected: 1,
got: 0, got: 0,
}); });
@@ -425,7 +663,7 @@ impl SessionSetup {
offset += consumed; offset += consumed;
if payload.len() < offset + 2 { if payload.len() < offset + 2 {
return Err(ProtocolError::MessageTooShort { return Err(Error::MessageTooShort {
expected: offset + 2, expected: offset + 2,
got: payload.len(), got: payload.len(),
}); });
@@ -434,7 +672,7 @@ impl SessionSetup {
offset += 2; offset += 2;
if payload.len() < offset + hs_len { if payload.len() < offset + hs_len {
return Err(ProtocolError::MessageTooShort { return Err(Error::MessageTooShort {
expected: offset + hs_len, expected: offset + hs_len,
got: payload.len(), got: payload.len(),
}); });
@@ -535,9 +773,9 @@ impl SessionAck {
} }
/// Decode from wire format (after 4-byte FSP prefix has been consumed). /// Decode from wire format (after 4-byte FSP prefix has been consumed).
pub fn decode(payload: &[u8]) -> Result<Self, ProtocolError> { pub fn decode(payload: &[u8]) -> Result<Self, Error> {
if payload.is_empty() { if payload.is_empty() {
return Err(ProtocolError::MessageTooShort { return Err(Error::MessageTooShort {
expected: 1, expected: 1,
got: 0, got: 0,
}); });
@@ -552,7 +790,7 @@ impl SessionAck {
offset += consumed; offset += consumed;
if payload.len() < offset + 2 { if payload.len() < offset + 2 {
return Err(ProtocolError::MessageTooShort { return Err(Error::MessageTooShort {
expected: offset + 2, expected: offset + 2,
got: payload.len(), got: payload.len(),
}); });
@@ -561,7 +799,7 @@ impl SessionAck {
offset += 2; offset += 2;
if payload.len() < offset + hs_len { if payload.len() < offset + hs_len {
return Err(ProtocolError::MessageTooShort { return Err(Error::MessageTooShort {
expected: offset + hs_len, expected: offset + hs_len,
got: payload.len(), got: payload.len(),
}); });
@@ -634,9 +872,9 @@ impl SessionMsg3 {
} }
/// Decode from wire format (after 4-byte FSP prefix has been consumed). /// Decode from wire format (after 4-byte FSP prefix has been consumed).
pub fn decode(payload: &[u8]) -> Result<Self, ProtocolError> { pub fn decode(payload: &[u8]) -> Result<Self, Error> {
if payload.is_empty() { if payload.is_empty() {
return Err(ProtocolError::MessageTooShort { return Err(Error::MessageTooShort {
expected: 1, expected: 1,
got: 0, got: 0,
}); });
@@ -645,7 +883,7 @@ impl SessionMsg3 {
let mut offset = 1; let mut offset = 1;
if payload.len() < offset + 2 { if payload.len() < offset + 2 {
return Err(ProtocolError::MessageTooShort { return Err(Error::MessageTooShort {
expected: offset + 2, expected: offset + 2,
got: payload.len(), got: payload.len(),
}); });
@@ -654,7 +892,7 @@ impl SessionMsg3 {
offset += 2; offset += 2;
if payload.len() < offset + hs_len { if payload.len() < offset + hs_len {
return Err(ProtocolError::MessageTooShort { return Err(Error::MessageTooShort {
expected: offset + hs_len, expected: offset + hs_len,
got: payload.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<u8> = (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());
}
}
+4 -8
View File
@@ -1,7 +1,7 @@
//! Link-layer message types: the shared frame catalog and session datagram. //! Link-layer message types: the shared frame catalog and session datagram.
use super::ProtocolError;
use crate::NodeAddr; use crate::NodeAddr;
use crate::proto::Error;
use std::fmt; use std::fmt;
// ============================================================================ // ============================================================================
@@ -199,7 +199,7 @@ impl SessionDatagram {
} }
/// Decode from link-layer payload (after msg_type byte has been consumed). /// Decode from link-layer payload (after msg_type byte has been consumed).
pub fn decode(payload: &[u8]) -> Result<Self, ProtocolError> { pub fn decode(payload: &[u8]) -> Result<Self, Error> {
let view = SessionDatagramRef::decode(payload)?; let view = SessionDatagramRef::decode(payload)?;
Ok(view.into_owned()) Ok(view.into_owned())
} }
@@ -207,10 +207,10 @@ impl SessionDatagram {
impl<'a> SessionDatagramRef<'a> { impl<'a> SessionDatagramRef<'a> {
/// Decode a borrowed view from link-layer payload after the msg_type byte. /// Decode a borrowed view from link-layer payload after the msg_type byte.
pub fn decode(payload: &'a [u8]) -> Result<Self, ProtocolError> { pub fn decode(payload: &'a [u8]) -> Result<Self, Error> {
// ttl(1) + path_mtu(2) + src_addr(16) + dest_addr(16) = 35 // ttl(1) + path_mtu(2) + src_addr(16) + dest_addr(16) = 35
if payload.len() < 35 { if payload.len() < 35 {
return Err(ProtocolError::MessageTooShort { return Err(Error::MessageTooShort {
expected: 35, expected: 35,
got: payload.len(), 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+11 -11
View File
@@ -6,7 +6,7 @@
//! [`SessionReceiverReport`]/[`PathMtuNotification`]), plus the conversions //! [`SessionReceiverReport`]/[`PathMtuNotification`]), plus the conversions
//! between the two layers. Wire format follows the MMP design doc. //! 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) // 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. /// Decode from payload after msg_type byte has been consumed.
/// ///
/// `payload` starts at the reserved bytes (offset 1 in the wire format). /// `payload` starts at the reserved bytes (offset 1 in the wire format).
pub fn decode(payload: &[u8]) -> Result<Self, ProtocolError> { pub fn decode(payload: &[u8]) -> Result<Self, Error> {
if payload.len() < 47 { if payload.len() < 47 {
return Err(ProtocolError::MessageTooShort { return Err(Error::MessageTooShort {
expected: 47, expected: 47,
got: payload.len(), got: payload.len(),
}); });
@@ -146,9 +146,9 @@ impl ReceiverReport {
/// Decode from payload after msg_type byte has been consumed. /// Decode from payload after msg_type byte has been consumed.
/// ///
/// `payload` starts at the reserved bytes (offset 1 in the wire format). /// `payload` starts at the reserved bytes (offset 1 in the wire format).
pub fn decode(payload: &[u8]) -> Result<Self, ProtocolError> { pub fn decode(payload: &[u8]) -> Result<Self, Error> {
if payload.len() < 67 { if payload.len() < 67 {
return Err(ProtocolError::MessageTooShort { return Err(Error::MessageTooShort {
expected: 67, expected: 67,
got: payload.len(), got: payload.len(),
}); });
@@ -227,9 +227,9 @@ impl SessionSenderReport {
} }
/// Decode from body (after FSP inner header has been stripped). /// Decode from body (after FSP inner header has been stripped).
pub fn decode(body: &[u8]) -> Result<Self, ProtocolError> { pub fn decode(body: &[u8]) -> Result<Self, Error> {
if body.len() < SESSION_SENDER_REPORT_SIZE { if body.len() < SESSION_SENDER_REPORT_SIZE {
return Err(ProtocolError::MessageTooShort { return Err(Error::MessageTooShort {
expected: SESSION_SENDER_REPORT_SIZE, expected: SESSION_SENDER_REPORT_SIZE,
got: body.len(), got: body.len(),
}); });
@@ -318,9 +318,9 @@ impl SessionReceiverReport {
} }
/// Decode from body (after FSP inner header has been stripped). /// Decode from body (after FSP inner header has been stripped).
pub fn decode(body: &[u8]) -> Result<Self, ProtocolError> { pub fn decode(body: &[u8]) -> Result<Self, Error> {
if body.len() < SESSION_RECEIVER_REPORT_SIZE { if body.len() < SESSION_RECEIVER_REPORT_SIZE {
return Err(ProtocolError::MessageTooShort { return Err(Error::MessageTooShort {
expected: SESSION_RECEIVER_REPORT_SIZE, expected: SESSION_RECEIVER_REPORT_SIZE,
got: body.len(), got: body.len(),
}); });
@@ -379,9 +379,9 @@ impl PathMtuNotification {
} }
/// Decode from body (after FSP inner header has been stripped). /// Decode from body (after FSP inner header has been stripped).
pub fn decode(body: &[u8]) -> Result<Self, ProtocolError> { pub fn decode(body: &[u8]) -> Result<Self, Error> {
if body.len() < PATH_MTU_NOTIFICATION_SIZE { if body.len() < PATH_MTU_NOTIFICATION_SIZE {
return Err(ProtocolError::MessageTooShort { return Err(Error::MessageTooShort {
expected: PATH_MTU_NOTIFICATION_SIZE, expected: PATH_MTU_NOTIFICATION_SIZE,
got: body.len(), got: body.len(),
}); });
+5
View File
@@ -3,9 +3,14 @@
//! A module here has been migrated out of the async node shell; the async //! A module here has been migrated out of the async node shell; the async
//! I/O adapters remain in `node::handlers`. //! I/O adapters remain in `node::handlers`.
mod error;
pub use error::Error;
pub(crate) mod bloom; pub(crate) mod bloom;
pub(crate) mod discovery; pub(crate) mod discovery;
pub(crate) mod fmp; pub(crate) mod fmp;
pub(crate) mod fsp;
pub(crate) mod link;
pub(crate) mod mmp; pub(crate) mod mmp;
pub(crate) mod routing; pub(crate) mod routing;
pub(crate) mod stp; pub(crate) mod stp;
+1 -1
View File
@@ -18,7 +18,7 @@
use super::state::Router; use super::state::Router;
use super::wire::{CoordsRequired, MtuExceeded, PathBroken}; use super::wire::{CoordsRequired, MtuExceeded, PathBroken};
use crate::protocol::{SessionDatagram, SessionDatagramRef}; use crate::proto::link::{SessionDatagram, SessionDatagramRef};
use crate::{NodeAddr, TreeCoordinate}; use crate::{NodeAddr, TreeCoordinate};
/// Read-only view of routing state the routing core needs. /// Read-only view of routing state the routing core needs.
+6 -2
View File
@@ -14,7 +14,8 @@
//! - `state.rs` — `Router`, the routing-subsystem state owned by `Node`. //! - `state.rs` — `Router`, the routing-subsystem state owned by `Node`.
//! - `limits.rs` — the routing error-signal rate limiter. //! - `limits.rs` — the routing error-signal rate limiter.
//! - `wire.rs` — the routing error-signal PDUs (`CoordsRequired`, //! - `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 core;
mod limits; mod limits;
@@ -30,4 +31,7 @@ pub(crate) use core::{
}; };
pub(crate) use limits::RoutingErrorRateLimiter; pub(crate) use limits::RoutingErrorRateLimiter;
pub(crate) use state::Router; 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,
};
+5 -4
View File
@@ -2,10 +2,11 @@
use super::util::{MockPeer, MockRoutingView, make_coords, make_datagram_ref, make_next_hop}; use super::util::{MockPeer, MockRoutingView, make_coords, make_datagram_ref, make_next_hop};
use crate::TreeCoordinate; use crate::TreeCoordinate;
use crate::proto::link::SessionDatagramRef;
use crate::proto::routing::RoutingSignalType;
use crate::proto::routing::{ use crate::proto::routing::{
DropReason, RouteAction, RouteOutcome, Router, RoutingView, routing_candidates, DropReason, RouteAction, RouteOutcome, Router, RoutingView, routing_candidates,
}; };
use crate::protocol::{SessionDatagramRef, SessionMessageType};
use crate::testutil::make_node_addr; use crate::testutil::make_node_addr;
/// Decode a forwarded byte buffer (which carries the leading msg_type byte) /// 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!( assert_eq!(
error_pdu_type(&action), error_pdu_type(&action),
SessionMessageType::PathBroken.to_byte(), RoutingSignalType::PathBroken.to_byte(),
"cached coords select PathBroken", "cached coords select PathBroken",
); );
} }
@@ -259,7 +260,7 @@ fn synth_uses_coords_required_when_not_cached() {
.expect("gate passes on first call"); .expect("gate passes on first call");
assert_eq!( assert_eq!(
error_pdu_type(&action), error_pdu_type(&action),
SessionMessageType::CoordsRequired.to_byte(), RoutingSignalType::CoordsRequired.to_byte(),
"absent coords select CoordsRequired", "absent coords select CoordsRequired",
); );
} }
@@ -320,7 +321,7 @@ fn synth_mtu_exceeded_carries_bottleneck_and_targets_source() {
); );
assert_eq!( assert_eq!(
error_pdu_type(&action), error_pdu_type(&action),
SessionMessageType::MtuExceeded.to_byte(), RoutingSignalType::MtuExceeded.to_byte(),
"PDU is an MtuExceeded signal", "PDU is an MtuExceeded signal",
); );
assert_eq!( assert_eq!(
+1 -1
View File
@@ -1,7 +1,7 @@
//! Shared test helpers for the routing subsystem unit tests. //! Shared test helpers for the routing subsystem unit tests.
use crate::proto::link::SessionDatagramRef;
use crate::proto::routing::{NextHop, RoutingView}; use crate::proto::routing::{NextHop, RoutingView};
use crate::protocol::SessionDatagramRef;
use crate::testutil::make_node_addr; use crate::testutil::make_node_addr;
use crate::{NodeAddr, TreeCoordinate}; use crate::{NodeAddr, TreeCoordinate};
+32
View File
@@ -4,9 +4,41 @@
use super::util::make_coords; use super::util::make_coords;
use crate::proto::routing::{ use crate::proto::routing::{
COORDS_REQUIRED_SIZE, CoordsRequired, MTU_EXCEEDED_SIZE, MtuExceeded, PathBroken, COORDS_REQUIRED_SIZE, CoordsRequired, MTU_EXCEEDED_SIZE, MtuExceeded, PathBroken,
RoutingSignalType,
}; };
use crate::testutil::make_node_addr; 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] #[test]
fn test_coords_required() { fn test_coords_required() {
let err = CoordsRequired::new(make_node_addr(1), make_node_addr(2)); let err = CoordsRequired::new(make_node_addr(1), make_node_addr(2));
+66 -17
View File
@@ -3,17 +3,66 @@
//! Plaintext link-layer error signals emitted by a transit router that //! Plaintext link-layer error signals emitted by a transit router that
//! cannot forward a `SessionDatagram`: `CoordsRequired` (coordinate cache //! cannot forward a `SessionDatagram`: `CoordsRequired` (coordinate cache
//! miss), `PathBroken` (routing failure / local minimum), and `MtuExceeded` //! miss), `PathBroken` (routing failure / local minimum), and `MtuExceeded`
//! (next-hop transport MTU too small). Relocated verbatim from //! (next-hop transport MTU too small). Their `0x20``0x2F` discriminants form
//! `protocol::session`; the shared coordinate helpers and the //! the routing-owned [`RoutingSignalType`] registry, split off from the FSP
//! `SessionMessageType` discriminant stay in `protocol` and are imported //! `SessionMessageType` (which keeps the encrypted-inner `0x10``0x1F` range).
//! downward here (a `proto -> protocol` dependency is allowed). //! 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::NodeAddr;
use crate::proto::stp::TreeCoordinate; use crate::proto::Error;
use crate::protocol::ProtocolError; use crate::proto::stp::{
use crate::protocol::session::{ TreeCoordinate, decode_optional_coords, encode_coords, encode_empty_coords,
SessionMessageType, 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<Self> {
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. /// Link-layer error signal indicating router cache miss.
/// ///
@@ -67,7 +116,7 @@ impl CoordsRequired {
let payload_len = body_len as u16; let payload_len = body_len as u16;
buf.extend_from_slice(&payload_len.to_le_bytes()); buf.extend_from_slice(&payload_len.to_le_bytes());
// msg_type byte (after prefix, before body) // 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.push(0x00); // reserved flags
buf.extend_from_slice(self.dest_addr.as_bytes()); buf.extend_from_slice(self.dest_addr.as_bytes());
buf.extend_from_slice(self.reporter.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). /// Decode from wire format (after FSP prefix and msg_type byte consumed).
pub fn decode(payload: &[u8]) -> Result<Self, ProtocolError> { pub fn decode(payload: &[u8]) -> Result<Self, Error> {
// flags(1) + dest_addr(16) + reporter(16) = 33 // flags(1) + dest_addr(16) + reporter(16) = 33
if payload.len() < 33 { if payload.len() < 33 {
return Err(ProtocolError::MessageTooShort { return Err(Error::MessageTooShort {
expected: 33, expected: 33,
got: payload.len(), got: payload.len(),
}); });
@@ -145,7 +194,7 @@ impl PathBroken {
pub fn encode(&self) -> Vec<u8> { pub fn encode(&self) -> Vec<u8> {
// Build body first to compute length // Build body first to compute length
let mut body = Vec::new(); let mut body = Vec::new();
body.push(SessionMessageType::PathBroken.to_byte()); body.push(RoutingSignalType::PathBroken.to_byte());
body.push(0x00); // reserved flags body.push(0x00); // reserved flags
body.extend_from_slice(self.dest_addr.as_bytes()); body.extend_from_slice(self.dest_addr.as_bytes());
body.extend_from_slice(self.reporter.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). /// Decode from wire format (after FSP prefix and msg_type byte consumed).
pub fn decode(payload: &[u8]) -> Result<Self, ProtocolError> { pub fn decode(payload: &[u8]) -> Result<Self, Error> {
// flags(1) + dest_addr(16) + reporter(16) + coords_count(2) = 35 minimum // flags(1) + dest_addr(16) + reporter(16) + coords_count(2) = 35 minimum
if payload.len() < 35 { if payload.len() < 35 {
return Err(ProtocolError::MessageTooShort { return Err(Error::MessageTooShort {
expected: 35, expected: 35,
got: payload.len(), got: payload.len(),
}); });
@@ -242,7 +291,7 @@ impl MtuExceeded {
let payload_len = body_len as u16; let payload_len = body_len as u16;
buf.extend_from_slice(&payload_len.to_le_bytes()); buf.extend_from_slice(&payload_len.to_le_bytes());
// msg_type byte // msg_type byte
buf.push(SessionMessageType::MtuExceeded.to_byte()); buf.push(RoutingSignalType::MtuExceeded.to_byte());
buf.push(0x00); // reserved flags buf.push(0x00); // reserved flags
buf.extend_from_slice(self.dest_addr.as_bytes()); buf.extend_from_slice(self.dest_addr.as_bytes());
buf.extend_from_slice(self.reporter.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). /// Decode from wire format (after FSP prefix and msg_type byte consumed).
pub fn decode(payload: &[u8]) -> Result<Self, ProtocolError> { pub fn decode(payload: &[u8]) -> Result<Self, Error> {
// flags(1) + dest_addr(16) + reporter(16) + mtu(2) = 35 // flags(1) + dest_addr(16) + reporter(16) + mtu(2) = 35
if payload.len() < 35 { if payload.len() < 35 {
return Err(ProtocolError::MessageTooShort { return Err(Error::MessageTooShort {
expected: 35, expected: 35,
got: payload.len(), got: payload.len(),
}); });
+5 -2
View File
@@ -5,8 +5,8 @@
//! data, and the flap-dampening limiter all live here. The async I/O handlers //! 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 //! remain in `node::tree`. The STP wire codec lives in `wire.rs` (the
//! `TreeAnnounce` struct + `validate_semantics`), per the //! `TreeAnnounce` struct + `validate_semantics`), per the
//! wire-migrates-with-subsystem policy. It imports the shared `ProtocolError` //! wire-migrates-with-subsystem policy. It imports the shared
//! and `LinkMessageType` downward from `crate::protocol`. //! [`crate::proto::Error`] and [`crate::proto::link::LinkMessageType`] downward.
//! //!
//! - `core.rs` — the pure classify ladder (`Stp::classify_announce` / //! - `core.rs` — the pure classify ladder (`Stp::classify_announce` /
//! `classify_periodic` / `should_echo`) over an in-core `TreeState`. //! `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(crate) use core::{Stp, TreeDecision};
pub use state::{ParentDeclaration, TreeState}; pub use state::{ParentDeclaration, TreeState};
pub use wire::TreeAnnounce; 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. /// Errors related to spanning tree operations.
#[derive(Debug, Error)] #[derive(Debug, Error)]
+116 -27
View File
@@ -2,8 +2,8 @@
use super::{CoordEntry, ParentDeclaration, TreeCoordinate, TreeError}; use super::{CoordEntry, ParentDeclaration, TreeCoordinate, TreeError};
use crate::NodeAddr; use crate::NodeAddr;
use crate::protocol::LinkMessageType; use crate::proto::Error;
use crate::protocol::ProtocolError; use crate::proto::link::LinkMessageType;
use secp256k1::schnorr::Signature; use secp256k1::schnorr::Signature;
/// Spanning tree announcement carrying parent declaration and ancestry. /// 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] /// [0x10][version:1][sequence:8 LE][timestamp:8 LE][parent:16]
/// [ancestry_count:2 LE][entries:32×n][signature:64] /// [ancestry_count:2 LE][entries:32×n][signature:64]
/// ``` /// ```
pub fn encode(&self) -> Result<Vec<u8>, ProtocolError> { pub fn encode(&self) -> Result<Vec<u8>, Error> {
let signature = self let signature = self
.declaration .declaration
.signature() .signature()
.ok_or(ProtocolError::InvalidSignature)?; .ok_or(Error::InvalidSignature)?;
let entries = self.ancestry.entries(); let entries = self.ancestry.entries();
let ancestry_count = entries.len() as u16; 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). /// Decode from link-layer payload (after msg_type byte stripped by dispatcher).
/// ///
/// The payload starts with the version byte. /// The payload starts with the version byte.
pub fn decode(payload: &[u8]) -> Result<Self, ProtocolError> { pub fn decode(payload: &[u8]) -> Result<Self, Error> {
if payload.len() < Self::MIN_PAYLOAD_SIZE { if payload.len() < Self::MIN_PAYLOAD_SIZE {
return Err(ProtocolError::MessageTooShort { return Err(Error::MessageTooShort {
expected: Self::MIN_PAYLOAD_SIZE, expected: Self::MIN_PAYLOAD_SIZE,
got: payload.len(), got: payload.len(),
}); });
@@ -146,14 +146,14 @@ impl TreeAnnounce {
let version = payload[pos]; let version = payload[pos];
pos += 1; pos += 1;
if version != Self::VERSION_1 { if version != Self::VERSION_1 {
return Err(ProtocolError::UnsupportedVersion(version)); return Err(Error::UnsupportedVersion(version));
} }
// sequence (8 LE) // sequence (8 LE)
let sequence = u64::from_le_bytes( let sequence = u64::from_le_bytes(
payload[pos..pos + 8] payload[pos..pos + 8]
.try_into() .try_into()
.map_err(|_| ProtocolError::Malformed("bad sequence".into()))?, .map_err(|_| Error::Malformed("bad sequence".into()))?,
); );
pos += 8; pos += 8;
@@ -161,7 +161,7 @@ impl TreeAnnounce {
let timestamp = u64::from_le_bytes( let timestamp = u64::from_le_bytes(
payload[pos..pos + 8] payload[pos..pos + 8]
.try_into() .try_into()
.map_err(|_| ProtocolError::Malformed("bad timestamp".into()))?, .map_err(|_| Error::Malformed("bad timestamp".into()))?,
); );
pos += 8; pos += 8;
@@ -169,7 +169,7 @@ impl TreeAnnounce {
let parent = NodeAddr::from_bytes( let parent = NodeAddr::from_bytes(
payload[pos..pos + 16] payload[pos..pos + 16]
.try_into() .try_into()
.map_err(|_| ProtocolError::Malformed("bad parent".into()))?, .map_err(|_| Error::Malformed("bad parent".into()))?,
); );
pos += 16; pos += 16;
@@ -177,14 +177,14 @@ impl TreeAnnounce {
let ancestry_count = u16::from_le_bytes( let ancestry_count = u16::from_le_bytes(
payload[pos..pos + 2] payload[pos..pos + 2]
.try_into() .try_into()
.map_err(|_| ProtocolError::Malformed("bad ancestry count".into()))?, .map_err(|_| Error::Malformed("bad ancestry count".into()))?,
) as usize; ) as usize;
pos += 2; pos += 2;
// Validate remaining length: entries + signature // Validate remaining length: entries + signature
let expected_remaining = ancestry_count * CoordEntry::WIRE_SIZE + 64; let expected_remaining = ancestry_count * CoordEntry::WIRE_SIZE + 64;
if payload.len() - pos < expected_remaining { if payload.len() - pos < expected_remaining {
return Err(ProtocolError::MessageTooShort { return Err(Error::MessageTooShort {
expected: pos + expected_remaining, expected: pos + expected_remaining,
got: payload.len(), got: payload.len(),
}); });
@@ -196,19 +196,19 @@ impl TreeAnnounce {
let node_addr = NodeAddr::from_bytes( let node_addr = NodeAddr::from_bytes(
payload[pos..pos + 16] payload[pos..pos + 16]
.try_into() .try_into()
.map_err(|_| ProtocolError::Malformed("bad entry node_addr".into()))?, .map_err(|_| Error::Malformed("bad entry node_addr".into()))?,
); );
pos += 16; pos += 16;
let entry_seq = u64::from_le_bytes( let entry_seq = u64::from_le_bytes(
payload[pos..pos + 8] payload[pos..pos + 8]
.try_into() .try_into()
.map_err(|_| ProtocolError::Malformed("bad entry sequence".into()))?, .map_err(|_| Error::Malformed("bad entry sequence".into()))?,
); );
pos += 8; pos += 8;
let entry_ts = u64::from_le_bytes( let entry_ts = u64::from_le_bytes(
payload[pos..pos + 8] payload[pos..pos + 8]
.try_into() .try_into()
.map_err(|_| ProtocolError::Malformed("bad entry timestamp".into()))?, .map_err(|_| Error::Malformed("bad entry timestamp".into()))?,
); );
pos += 8; pos += 8;
entries.push(CoordEntry::new(node_addr, entry_seq, entry_ts)); entries.push(CoordEntry::new(node_addr, entry_seq, entry_ts));
@@ -217,16 +217,16 @@ impl TreeAnnounce {
// signature (64) // signature (64)
let sig_bytes: [u8; 64] = payload[pos..pos + 64] let sig_bytes: [u8; 64] = payload[pos..pos + 64]
.try_into() .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 // 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 // codec's only crypto touch, §11 w2); store the raw bytes so the in-core
// declaration carries no `secp256k1` dependency. Actual verification is a // declaration carries no `secp256k1` dependency. Actual verification is a
// shell concern (§6). // 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 // The first entry's node_addr is the declaring node
if entries.is_empty() { if entries.is_empty() {
return Err(ProtocolError::Malformed( return Err(Error::Malformed(
"ancestry must have at least one entry".into(), "ancestry must have at least one entry".into(),
)); ));
} }
@@ -236,7 +236,7 @@ impl TreeAnnounce {
ParentDeclaration::with_signature(node_addr, parent, sequence, timestamp, sig_bytes); ParentDeclaration::with_signature(node_addr, parent, sequence, timestamp, sig_bytes);
let ancestry = TreeCoordinate::new(entries) 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 { Ok(Self {
declaration, 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<u8>) {
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<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 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<u8>) {
buf.extend_from_slice(&0u16.to_le_bytes());
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@@ -388,10 +480,7 @@ mod tests {
encoded[1] = 0xFF; encoded[1] = 0xFF;
let result = TreeAnnounce::decode(&encoded[1..]); let result = TreeAnnounce::decode(&encoded[1..]);
assert!(matches!( assert!(matches!(result, Err(Error::UnsupportedVersion(0xFF))));
result,
Err(ProtocolError::UnsupportedVersion(0xFF))
));
} }
#[test] #[test]
@@ -400,7 +489,7 @@ mod tests {
let result = TreeAnnounce::decode(&[0x01]); let result = TreeAnnounce::decode(&[0x01]);
assert!(matches!( assert!(matches!(
result, result,
Err(ProtocolError::MessageTooShort { expected: 99, .. }) Err(Error::MessageTooShort { expected: 99, .. })
)); ));
// Just under minimum (98 bytes) // Just under minimum (98 bytes)
@@ -408,7 +497,7 @@ mod tests {
let result = TreeAnnounce::decode(&short); let result = TreeAnnounce::decode(&short);
assert!(matches!( assert!(matches!(
result, result,
Err(ProtocolError::MessageTooShort { expected: 99, .. }) Err(Error::MessageTooShort { expected: 99, .. })
)); ));
} }
@@ -432,7 +521,7 @@ mod tests {
encoded[35] = 0; encoded[35] = 0;
let result = TreeAnnounce::decode(&encoded[1..]); let result = TreeAnnounce::decode(&encoded[1..]);
assert!(matches!(result, Err(ProtocolError::MessageTooShort { .. }))); assert!(matches!(result, Err(Error::MessageTooShort { .. })));
} }
#[test] #[test]
@@ -443,7 +532,7 @@ mod tests {
let announce = TreeAnnounce::new(decl, ancestry); let announce = TreeAnnounce::new(decl, ancestry);
let result = announce.encode(); 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. /// Tests that a well-formed non-root ancestry is accepted.
-43
View File
@@ -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;