mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
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:
+8
-6
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(×tamp_ms.to_le_bytes());
|
|
||||||
buf.push(msg_type);
|
|
||||||
buf.push(inner_flags);
|
|
||||||
buf.extend_from_slice(payload);
|
|
||||||
buf
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Strip the 6-byte FSP inner header from a decrypted payload.
|
|
||||||
///
|
|
||||||
/// Returns `(timestamp, msg_type, inner_flags, &rest)` or None if too short.
|
|
||||||
pub fn fsp_strip_inner_header(plaintext: &[u8]) -> Option<(u32, u8, u8, &[u8])> {
|
|
||||||
if plaintext.len() < FSP_INNER_HEADER_SIZE {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let timestamp = u32::from_le_bytes([plaintext[0], plaintext[1], plaintext[2], plaintext[3]]);
|
|
||||||
let msg_type = plaintext[4];
|
|
||||||
let inner_flags = plaintext[5];
|
|
||||||
Some((
|
|
||||||
timestamp,
|
|
||||||
msg_type,
|
|
||||||
inner_flags,
|
|
||||||
&plaintext[FSP_INNER_HEADER_SIZE..],
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Coordinate Parsing (for transit nodes and receive path)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Parse source and destination coordinates from the cleartext section
|
|
||||||
/// of an encrypted FSP message when the CP flag is set.
|
|
||||||
///
|
|
||||||
/// Coordinates appear between the 12-byte header and the ciphertext:
|
|
||||||
/// `[src_coords_count:2 LE][src_coords:16×n][dest_coords_count:2 LE][dest_coords:16×m]`
|
|
||||||
///
|
|
||||||
/// Returns `(src_coords, dest_coords, bytes_consumed)`.
|
|
||||||
pub fn parse_encrypted_coords(
|
|
||||||
data: &[u8],
|
|
||||||
) -> Result<(Option<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,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.
|
||||||
|
|||||||
@@ -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.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mod core;
|
||||||
|
mod wire;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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(×tamp_ms.to_le_bytes());
|
||||||
|
buf.push(msg_type);
|
||||||
|
buf.push(inner_flags);
|
||||||
|
buf.extend_from_slice(payload);
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip the 6-byte FSP inner header from a decrypted payload.
|
||||||
|
///
|
||||||
|
/// Returns `(timestamp, msg_type, inner_flags, &rest)` or None if too short.
|
||||||
|
pub fn fsp_strip_inner_header(plaintext: &[u8]) -> Option<(u32, u8, u8, &[u8])> {
|
||||||
|
if plaintext.len() < FSP_INNER_HEADER_SIZE {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let timestamp = u32::from_le_bytes([plaintext[0], plaintext[1], plaintext[2], plaintext[3]]);
|
||||||
|
let msg_type = plaintext[4];
|
||||||
|
let inner_flags = plaintext[5];
|
||||||
|
Some((
|
||||||
|
timestamp,
|
||||||
|
msg_type,
|
||||||
|
inner_flags,
|
||||||
|
&plaintext[FSP_INNER_HEADER_SIZE..],
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Coordinate Parsing (for transit nodes and receive path)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Parse source and destination coordinates from the cleartext section
|
||||||
|
/// of an encrypted FSP message when the CP flag is set.
|
||||||
|
///
|
||||||
|
/// Coordinates appear between the 12-byte header and the ciphertext:
|
||||||
|
/// `[src_coords_count:2 LE][src_coords:16×n][dest_coords_count:2 LE][dest_coords:16×m]`
|
||||||
|
///
|
||||||
|
/// Returns `(src_coords, dest_coords, bytes_consumed)`.
|
||||||
|
pub fn parse_encrypted_coords(
|
||||||
|
data: &[u8],
|
||||||
|
) -> Result<(Option<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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,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};
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,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
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
|
||||||
Reference in New Issue
Block a user