FLP wire format revision and MMP link-layer measurement protocol

## FLP Wire Format Revision

Replace the 1-byte discriminator with a structured wire format:

- 4-byte common prefix (ver+phase, flags, payload_len) and 16-byte
  established frame header with AEAD AAD binding
- 5-byte encrypted inner header (4-byte session-relative timestamp +
  1-byte message type) on all link messages
- Phase-based packet dispatch replacing discriminator-based dispatch
- SessionDatagram reassigned from type 0x40 to 0x00; add SenderReport
  (0x01) and ReceiverReport (0x02) message types for MMP
- SessionDatagram: rename hop_limit to ttl, add path_mtu field (u16 LE)
  with min(datagram.path_mtu, transport.mtu()) at forwarding
- Updated handshake packets (msg1: 87->90 bytes, msg2: 42->45 bytes)
- FIPS_OVERHEAD updated from 135 to 144 bytes

## MMP Link-Layer Measurement Protocol

Add the Metrics Measurement Protocol for link quality measurement
between FIPS peers. Measures RTT, loss, jitter, throughput, OWD trend,
and ETX from periodic sender/receiver reports exchanged over established
links.

Module layout:
- mmp/algorithms.rs: JitterEstimator, SrttEstimator, DualEwma, OwdTrend,
  SpinBit, ETX computation
- mmp/report.rs: SenderReport (48B) and ReceiverReport (68B) wire format
- mmp/sender.rs: per-peer TX counters and interval tracking
- mmp/receiver.rs: per-peer RX counters, jitter, loss, gap tracking
- mmp/metrics.rs: derived metrics from report processing (SRTT, goodput_bps)
- mmp/mod.rs: MmpMode (Full/Lightweight/Minimal), MmpConfig, MmpPeerState
- node/handlers/mmp.rs: report dispatch, timer-driven generation, operator
  logging (periodic + teardown)

Integration: per-frame TX/RX hooks in encrypted message handling, report
dispatch from link message router, timer-driven generation from tick
handler, and periodic operator logging with throughput formatting.

Three operating modes: Full (sender + receiver reports, spin bit, CE echo),
Lightweight (receiver reports only), Minimal (spin bit + CE echo only).

## Design Documentation

Updated FLP sections across all design documents to match the implemented
wire format, including revised overhead calculations and numeric values.

568 tests pass, clippy clean.
This commit is contained in:
Johnathan Corgan
2026-02-18 21:54:21 +00:00
parent 2964a71ea7
commit d8cb4d407e
34 changed files with 3419 additions and 386 deletions
+17 -4
View File
@@ -17,6 +17,18 @@ impl Node {
let payload = &plaintext[1..];
match msg_type {
0x00 => {
// SessionDatagram
self.handle_session_datagram(from, payload).await;
}
0x01 => {
// SenderReport
self.handle_sender_report(from, payload);
}
0x02 => {
// ReceiverReport
self.handle_receiver_report(from, payload);
}
0x10 => {
// TreeAnnounce
self.handle_tree_announce(from, payload).await;
@@ -33,10 +45,6 @@ impl Node {
// LookupResponse
self.handle_lookup_response(from, payload).await;
}
0x40 => {
// SessionDatagram
self.handle_session_datagram(from, payload).await;
}
0x50 => {
// Disconnect
self.handle_disconnect(from, payload);
@@ -86,6 +94,11 @@ impl Node {
}
};
// MMP teardown log (before we drop the peer)
if let Some(mmp) = peer.mmp() {
Self::log_mmp_teardown(node_addr, mmp);
}
let link_id = peer.link_id();
// Free session index
+43 -7
View File
@@ -1,12 +1,13 @@
//! Encrypted frame handling (hot path).
use crate::node::Node;
use crate::node::wire::{EncryptedHeader, strip_inner_header, FLAG_CE, FLAG_SP};
use crate::transport::ReceivedPacket;
use crate::node::wire::EncryptedHeader;
use std::time::Instant;
use tracing::{debug, warn};
impl Node {
/// Handle an encrypted frame (discriminator 0x00).
/// Handle an encrypted frame (phase 0x0).
///
/// This is the hot path for established sessions. We use O(1)
/// index-based lookup to find the session, then decrypt.
@@ -53,9 +54,13 @@ impl Node {
}
};
// Decrypt with replay check (this is the expensive part)
let ciphertext = &packet.data[header.ciphertext_offset..];
let plaintext = match session.decrypt_with_replay_check(ciphertext, header.counter) {
// Decrypt with replay check and AAD (this is the expensive part)
let ciphertext = &packet.data[header.ciphertext_offset()..];
let plaintext = match session.decrypt_with_replay_check_and_aad(
ciphertext,
header.counter,
&header.header_bytes,
) {
Ok(p) => p,
Err(e) => {
debug!(
@@ -70,6 +75,37 @@ impl Node {
// === PACKET IS AUTHENTIC ===
// Strip inner header (4-byte timestamp + msg_type)
let (timestamp, link_message) = match strip_inner_header(&plaintext) {
Some(parts) => parts,
None => {
debug!(
node_addr = %node_addr,
len = plaintext.len(),
"Decrypted payload too short for inner header"
);
return;
}
};
// MMP per-frame processing: feed counter, timestamp, flags to receiver state
let now = Instant::now();
let ce_flag = header.flags & FLAG_CE != 0;
let sp_flag = header.flags & FLAG_SP != 0;
if let Some(mmp) = peer.mmp_mut() {
mmp.receiver.record_recv(
header.counter,
timestamp,
packet.data.len(),
ce_flag,
now,
);
// Spin bit: feed to spin state, get optional RTT sample
if let Some(rtt) = mmp.spin_bit.rx_observe(sp_flag, header.counter, now) {
mmp.metrics.srtt.update(rtt.as_micros() as i64);
}
}
// Update address for roaming support
peer.set_current_addr(packet.transport_id, packet.remote_addr.clone());
@@ -77,7 +113,7 @@ impl Node {
peer.link_stats_mut().record_recv(packet.data.len(), packet.timestamp_ms);
peer.touch(packet.timestamp_ms);
// Dispatch to link message handler
self.dispatch_link_message(&node_addr, &plaintext).await;
// Dispatch to link message handler (msg_type + payload, inner header stripped)
self.dispatch_link_message(&node_addr, link_message).await;
}
}
+15 -7
View File
@@ -1,6 +1,6 @@
//! SessionDatagram forwarding handler.
//!
//! Handles incoming SessionDatagram (0x40) link messages: decodes the
//! Handles incoming SessionDatagram (0x00) link messages: decodes the
//! envelope, enforces hop limits, performs coordinate cache warming from
//! plaintext session-layer headers, routes to the next hop or delivers
//! locally, and generates error signals on routing failure.
@@ -16,7 +16,7 @@ use tracing::debug;
impl Node {
/// Handle an incoming SessionDatagram from a peer.
///
/// Called by `dispatch_link_message` for msg_type 0x40. The payload
/// Called by `dispatch_link_message` for msg_type 0x00. The payload
/// has already had its msg_type byte stripped by dispatch.
pub(in crate::node) async fn handle_session_datagram(&mut self, _from: &NodeAddr, payload: &[u8]) {
let mut datagram = match SessionDatagram::decode(payload) {
@@ -27,12 +27,12 @@ impl Node {
}
};
// Hop limit enforcement: decrement and drop if exhausted
if !datagram.decrement_hop_limit() {
// TTL enforcement: decrement and drop if exhausted
if !datagram.decrement_ttl() {
debug!(
src = %datagram.src_addr,
dest = %datagram.dest_addr,
"SessionDatagram hop limit exhausted, dropping"
"SessionDatagram TTL exhausted, dropping"
);
return;
}
@@ -56,7 +56,15 @@ impl Node {
}
};
// Forward: re-encode (includes 0x40 type byte) and send
// Apply path_mtu min() from the outgoing link's transport MTU
if let Some(peer) = self.peers.get(&next_hop_addr)
&& let Some(tid) = peer.transport_id()
&& let Some(transport) = self.transports.get(&tid)
{
datagram.path_mtu = datagram.path_mtu.min(transport.mtu());
}
// Forward: re-encode (includes 0x00 type byte) and send
let encoded = datagram.encode();
if let Err(e) = self
.send_encrypted_link_message(&next_hop_addr, &encoded)
@@ -204,7 +212,7 @@ impl Node {
};
let error_dg = SessionDatagram::new(my_addr, original.src_addr, error_payload)
.with_hop_limit(self.config.node.session.default_hop_limit);
.with_ttl(self.config.node.session.default_ttl);
let next_hop_addr = match self.find_next_hop(&original.src_addr) {
Some(peer) => *peer.node_addr(),
+6 -2
View File
@@ -11,7 +11,7 @@ use std::time::Duration;
use tracing::{debug, info, warn};
impl Node {
/// Handle handshake message 1 (discriminator 0x01).
/// Handle handshake message 1 (phase 0x1).
///
/// This creates a new inbound connection. Rate limiting is applied
/// before any expensive crypto operations.
@@ -222,7 +222,7 @@ impl Node {
self.msg1_rate_limiter.complete_handshake();
}
/// Handle handshake message 2 (discriminator 0x02).
/// Handle handshake message 2 (phase 0x2).
///
/// This completes an outbound handshake we initiated.
pub(in crate::node) async fn handle_msg2(&mut self, packet: ReceivedPacket) {
@@ -549,6 +549,8 @@ impl Node {
transport_id,
current_addr,
link_stats,
is_outbound,
&self.config.node.mmp,
);
new_peer.set_tree_announce_min_interval_ms(self.config.node.tree.announce_min_interval_ms);
@@ -629,6 +631,8 @@ impl Node {
transport_id,
current_addr,
link_stats,
is_outbound,
&self.config.node.mmp,
);
new_peer.set_tree_announce_min_interval_ms(self.config.node.tree.announce_min_interval_ms);
+246
View File
@@ -0,0 +1,246 @@
//! MMP report dispatch, periodic report generation, and operator logging.
//!
//! Handles incoming SenderReport / ReceiverReport messages, drives
//! periodic report generation on the tick timer, and emits periodic
//! and teardown metric logs.
use crate::mmp::MmpMode;
use crate::mmp::report::{ReceiverReport, SenderReport};
use crate::node::Node;
use crate::NodeAddr;
use std::time::Instant;
use tracing::{debug, info, warn};
/// Format bytes/sec as human-readable throughput.
fn format_throughput(bps: f64) -> String {
if bps == 0.0 {
"n/a".to_string()
} else if bps >= 1_000_000.0 {
format!("{:.1}MB/s", bps / 1_000_000.0)
} else if bps >= 1_000.0 {
format!("{:.1}KB/s", bps / 1_000.0)
} else {
format!("{:.0}B/s", bps)
}
}
impl Node {
/// Handle an incoming SenderReport from a peer.
///
/// The peer is telling us about what they sent. We feed this to our
/// receiver state for cross-reference (not currently used for metrics,
/// but stored for future use).
pub(in crate::node) fn handle_sender_report(&mut self, from: &NodeAddr, payload: &[u8]) {
let sr = match SenderReport::decode(payload) {
Ok(sr) => sr,
Err(e) => {
debug!(from = %from, error = %e, "Malformed SenderReport");
return;
}
};
let peer = match self.peers.get_mut(from) {
Some(p) => p,
None => {
debug!(from = %from, "SenderReport from unknown peer");
return;
}
};
if peer.mmp().is_none() {
return;
}
debug!(
from = %from,
cum_pkts = sr.cumulative_packets_sent,
interval_bytes = sr.interval_bytes_sent,
"Received SenderReport"
);
// Store sender's report in receiver state for cross-reference.
// Currently informational; the receiver already tracks its own
// counters and echoes timestamps from data frames.
}
/// Handle an incoming ReceiverReport from a peer.
///
/// The peer is telling us about what they received from us. We feed
/// this to our metrics to compute RTT, loss rate, and trend indicators.
pub(in crate::node) fn handle_receiver_report(&mut self, from: &NodeAddr, payload: &[u8]) {
let rr = match ReceiverReport::decode(payload) {
Ok(rr) => rr,
Err(e) => {
debug!(from = %from, error = %e, "Malformed ReceiverReport");
return;
}
};
let peer = match self.peers.get_mut(from) {
Some(p) => p,
None => {
debug!(from = %from, "ReceiverReport from unknown peer");
return;
}
};
// Get session timestamp before taking mutable borrow on MMP
let our_timestamp_ms = peer.session_elapsed_ms();
let Some(mmp) = peer.mmp_mut() else {
return;
};
// Process the report: computes RTT from timestamp echo, updates
// loss rate, goodput rate, jitter trend, and ETX.
let now = Instant::now();
mmp.metrics.process_receiver_report(&rr, our_timestamp_ms, now);
// Feed SRTT back to sender/receiver report interval tuning
if let Some(srtt_ms) = mmp.metrics.srtt_ms() {
let srtt_us = (srtt_ms * 1000.0) as i64;
mmp.sender.update_report_interval_from_srtt(srtt_us);
mmp.receiver.update_report_interval_from_srtt(srtt_us);
}
// Update reverse delivery ratio from our own receiver state
// (what fraction of peer's frames we received).
let our_recv_packets = mmp.receiver.cumulative_packets_recv();
let peer_highest = mmp.receiver.highest_counter();
if peer_highest > 0 {
let reverse_ratio = (our_recv_packets as f64) / (peer_highest as f64);
mmp.metrics.set_delivery_ratio_reverse(reverse_ratio);
}
debug!(
from = %from,
rtt_ms = ?mmp.metrics.srtt_ms(),
loss = format_args!("{:.1}%", mmp.metrics.loss_rate() * 100.0),
etx = format_args!("{:.2}", mmp.metrics.etx),
"Processed ReceiverReport"
);
}
/// Check all peers for pending MMP reports and send them.
///
/// Called from the tick handler. Also emits periodic operator logs.
pub(in crate::node) async fn check_mmp_reports(&mut self) {
let now = Instant::now();
// Collect peers that need reports (can't borrow self mutably while iterating)
let mut sender_reports: Vec<(NodeAddr, Vec<u8>)> = Vec::new();
let mut receiver_reports: Vec<(NodeAddr, Vec<u8>)> = Vec::new();
for (node_addr, peer) in self.peers.iter_mut() {
let Some(mmp) = peer.mmp_mut() else {
continue;
};
let mode = mmp.mode();
// Sender reports: Full mode only
if mode == MmpMode::Full && mmp.sender.should_send_report(now) {
if let Some(sr) = mmp.sender.build_report(now) {
sender_reports.push((*node_addr, sr.encode()));
}
}
// Receiver reports: Full and Lightweight modes
if mode != MmpMode::Minimal && mmp.receiver.should_send_report(now) {
if let Some(rr) = mmp.receiver.build_report(now) {
receiver_reports.push((*node_addr, rr.encode()));
}
}
// Periodic operator logging
if mmp.should_log(now) {
Self::log_mmp_metrics(node_addr, mmp);
mmp.mark_logged(now);
}
}
// Send collected reports
for (node_addr, encoded) in sender_reports {
if let Err(e) = self.send_encrypted_link_message(&node_addr, &encoded).await {
warn!(peer = %node_addr, error = %e, "Failed to send SenderReport");
}
}
for (node_addr, encoded) in receiver_reports {
if let Err(e) = self.send_encrypted_link_message(&node_addr, &encoded).await {
warn!(peer = %node_addr, error = %e, "Failed to send ReceiverReport");
}
}
}
/// Emit periodic MMP metrics for a peer at info and debug levels.
fn log_mmp_metrics(node_addr: &NodeAddr, mmp: &crate::mmp::MmpPeerState) {
let m = &mmp.metrics;
let rtt_str = match m.srtt_ms() {
Some(rtt) => format!("{:.1}ms", rtt),
None => "n/a".to_string(),
};
let loss_pct = m.loss_rate() * 100.0;
let tx_pkts = mmp.sender.cumulative_packets_sent();
let rx_pkts = mmp.receiver.cumulative_packets_recv();
let goodput_bps = m.goodput_bps();
let goodput_str = format_throughput(goodput_bps);
// Info-level: concise summary
info!(
peer = %node_addr,
rtt = %rtt_str,
loss = format_args!("{:.1}%", loss_pct),
goodput = %goodput_str,
tx_pkts = tx_pkts,
rx_pkts = rx_pkts,
"MMP link metrics"
);
// Debug-level: extended details
debug!(
peer = %node_addr,
jitter_us = mmp.receiver.jitter_us(),
reorder = mmp.receiver.cumulative_packets_recv(),
rtt_trend = format_args!("{}", if m.rtt_trend.initialized() {
format!("short={:.1} long={:.1}", m.rtt_trend.short(), m.rtt_trend.long())
} else {
"n/a".to_string()
}),
loss_trend = format_args!("{}", if m.loss_trend.initialized() {
format!("short={:.4} long={:.4}", m.loss_trend.short(), m.loss_trend.long())
} else {
"n/a".to_string()
}),
delivery_fwd = format_args!("{:.3}", m.delivery_ratio_forward),
delivery_rev = format_args!("{:.3}", m.delivery_ratio_reverse),
mode = %mmp.mode(),
"MMP link metrics (detail)"
);
}
/// Emit a teardown log summarizing lifetime MMP metrics for a removed peer.
pub(in crate::node) fn log_mmp_teardown(node_addr: &NodeAddr, mmp: &crate::mmp::MmpPeerState) {
let m = &mmp.metrics;
let rtt_str = match m.srtt_ms() {
Some(rtt) => format!("{:.1}ms", rtt),
None => "n/a".to_string(),
};
info!(
peer = %node_addr,
rtt = %rtt_str,
loss = format_args!("{:.1}%", m.loss_rate() * 100.0),
etx = format_args!("{:.2}", m.etx),
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(),
jitter_us = mmp.receiver.jitter_us(),
"MMP link teardown"
);
}
}
+1
View File
@@ -5,6 +5,7 @@ mod dispatch;
mod encrypted;
mod forwarding;
mod handshake;
mod mmp;
mod rx_loop;
mod session;
mod timeout;
+29 -16
View File
@@ -2,7 +2,7 @@
use crate::node::{Node, NodeError};
use crate::transport::ReceivedPacket;
use crate::node::wire::{DISCRIMINATOR_ENCRYPTED, DISCRIMINATOR_MSG1, DISCRIMINATOR_MSG2};
use crate::node::wire::{CommonPrefix, PHASE_ESTABLISHED, PHASE_MSG1, PHASE_MSG2, FLP_VERSION, COMMON_PREFIX_SIZE};
use std::time::Duration;
use tracing::{debug, info};
@@ -10,10 +10,10 @@ impl Node {
/// Run the receive event loop.
///
/// Processes packets from all transports, dispatching based on
/// the discriminator byte in the wire protocol:
/// - 0x00: Encrypted frame (session data)
/// - 0x01: Handshake message 1 (initiator -> responder)
/// - 0x02: Handshake message 2 (responder -> initiator)
/// the phase field in the 4-byte common prefix:
/// - Phase 0x0: Encrypted frame (session data)
/// - Phase 0x1: Handshake message 1 (initiator -> responder)
/// - Phase 0x2: Handshake message 2 (responder -> initiator)
///
/// Also processes outbound IPv6 packets from the TUN reader for session
/// encapsulation and routing through the mesh.
@@ -83,6 +83,7 @@ impl Node {
self.process_pending_retries(now_ms).await;
self.check_tree_state().await;
self.check_bloom_state().await;
self.check_mmp_reports().await;
self.purge_stale_lookups(now_ms);
}
}
@@ -94,29 +95,41 @@ impl Node {
/// Process a single received packet.
///
/// Dispatches based on the discriminator byte.
/// Dispatches based on the phase field in the 4-byte common prefix.
async fn process_packet(&mut self, packet: ReceivedPacket) {
if packet.data.is_empty() {
return; // Drop empty packets
if packet.data.len() < COMMON_PREFIX_SIZE {
return; // Drop packets too short for common prefix
}
let discriminator = packet.data[0];
match discriminator {
DISCRIMINATOR_ENCRYPTED => {
let prefix = match CommonPrefix::parse(&packet.data) {
Some(p) => p,
None => return, // Malformed prefix
};
if prefix.version != FLP_VERSION {
debug!(
version = prefix.version,
transport_id = %packet.transport_id,
"Unknown FLP version, dropping"
);
return;
}
match prefix.phase {
PHASE_ESTABLISHED => {
self.handle_encrypted_frame(packet).await;
}
DISCRIMINATOR_MSG1 => {
PHASE_MSG1 => {
self.handle_msg1(packet).await;
}
DISCRIMINATOR_MSG2 => {
PHASE_MSG2 => {
self.handle_msg2(packet).await;
}
_ => {
// Unknown discriminator, drop silently
debug!(
discriminator = discriminator,
phase = prefix.phase,
transport_id = %packet.transport_id,
"Unknown packet discriminator, dropping"
"Unknown FLP phase, dropping"
);
}
}
+3 -3
View File
@@ -144,7 +144,7 @@ impl Node {
let ack = SessionAck::new(our_coords).with_handshake(msg2);
let my_addr = *self.node_addr();
let datagram = SessionDatagram::new(my_addr, *src_addr, ack.encode())
.with_hop_limit(self.config.node.session.default_hop_limit);
.with_ttl(self.config.node.session.default_ttl);
// Route the ack back to the initiator
if let Err(e) = self.send_session_datagram(&datagram).await {
@@ -432,7 +432,7 @@ impl Node {
// Wrap in SessionDatagram
let my_addr = *self.node_addr();
let datagram = SessionDatagram::new(my_addr, dest_addr, setup.encode())
.with_hop_limit(self.config.node.session.default_hop_limit);
.with_ttl(self.config.node.session.default_ttl);
// Route toward destination
self.send_session_datagram(&datagram).await?;
@@ -498,7 +498,7 @@ impl Node {
let my_addr = *self.node_addr();
let datagram = SessionDatagram::new(my_addr, *dest_addr, data_packet.encode())
.with_hop_limit(self.config.node.session.default_hop_limit);
.with_ttl(self.config.node.session.default_ttl);
self.send_session_datagram(&datagram).await?;
+28 -4
View File
@@ -30,7 +30,7 @@ use crate::transport::udp::UdpTransport;
use crate::tree::TreeState;
use crate::upper::icmp_rate_limit::IcmpRateLimiter;
use crate::upper::tun::{TunError, TunOutboundRx, TunState, TunTx};
use self::wire::build_encrypted;
use self::wire::{build_encrypted, build_established_header, prepend_inner_header, FLAG_SP};
use crate::{Config, ConfigError, Identity, IdentityError, NodeAddr};
use std::collections::{HashMap, VecDeque};
use std::fmt;
@@ -1026,6 +1026,10 @@ impl Node {
/// The plaintext should include the message type byte followed by the
/// message-specific payload (e.g., `[0x50, reason]` for Disconnect).
///
/// The send path prepends a 4-byte session-relative timestamp (inner
/// header) before encryption. The full 16-byte outer header is used
/// as AAD for the AEAD construction.
///
/// This is the standard path for sending any link-layer control message
/// to a peer over their encrypted Noise session.
pub(super) async fn send_encrypted_link_message(
@@ -1049,19 +1053,35 @@ impl Node {
reason: "no current_addr".into(),
})?;
// Prepend 4-byte session-relative timestamp (inner header)
let timestamp_ms = peer.session_elapsed_ms();
// MMP: read spin bit value before entering session borrow
let sp_flag = peer.mmp()
.map(|mmp| mmp.spin_bit.tx_bit())
.unwrap_or(false);
let flags = if sp_flag { FLAG_SP } else { 0 };
let session = peer.noise_session_mut().ok_or_else(|| NodeError::SendFailed {
node_addr: *node_addr,
reason: "no noise session".into(),
})?;
// Get counter before encrypt (encrypt increments it)
// Inner plaintext: [timestamp:4 LE][msg_type][payload...]
let inner_plaintext = prepend_inner_header(timestamp_ms, plaintext);
// Build 16-byte outer header (used as AAD for AEAD)
let counter = session.current_send_counter();
let ciphertext = session.encrypt(plaintext).map_err(|e| NodeError::SendFailed {
let payload_len = inner_plaintext.len() as u16;
let header = build_established_header(their_index, counter, flags, payload_len);
// Encrypt with AAD binding to the outer header
let ciphertext = session.encrypt_with_aad(&inner_plaintext, &header).map_err(|e| NodeError::SendFailed {
node_addr: *node_addr,
reason: format!("encryption failed: {}", e),
})?;
let wire_packet = build_encrypted(their_index, counter, &ciphertext);
let wire_packet = build_encrypted(&header, &ciphertext);
// Re-borrow peer for stats update after sending
let transport = self.transports.get(&transport_id)
@@ -1076,6 +1096,10 @@ impl Node {
// Update send statistics
if let Some(peer) = self.peers.get_mut(node_addr) {
peer.link_stats_mut().record_sent(bytes_sent);
// MMP: record sent frame for sender report generation
if let Some(mmp) = peer.mmp_mut() {
mmp.sender.record_sent(counter, timestamp_ms, bytes_sent);
}
}
Ok(())
+11 -11
View File
@@ -1,7 +1,7 @@
//! SessionDatagram forwarding tests.
//!
//! Tests for the handle_session_datagram handler including decode errors,
//! hop limit enforcement, local delivery, coordinate cache warming, and
//! TTL enforcement, local delivery, coordinate cache warming, and
//! multi-hop forwarding through live node topologies.
use super::*;
@@ -26,7 +26,7 @@ async fn test_forwarding_decode_error() {
node.handle_session_datagram(&from, &[0x00; 5]).await;
}
// --- Hop limit ---
// --- TTL ---
#[tokio::test]
async fn test_forwarding_hop_limit_exhausted() {
@@ -35,7 +35,7 @@ async fn test_forwarding_hop_limit_exhausted() {
let src = make_node_addr(0x01);
let dest = make_node_addr(0x02);
let dg = SessionDatagram::new(src, dest, vec![0x10, 0x00, 0x00, 0x00])
.with_hop_limit(0);
.with_ttl(0);
let encoded = dg.encode();
// Dispatch with payload after msg_type byte
node.handle_session_datagram(&from, &encoded[1..]).await;
@@ -44,17 +44,17 @@ async fn test_forwarding_hop_limit_exhausted() {
#[tokio::test]
async fn test_forwarding_hop_limit_one_drops_at_transit() {
// hop_limit=1 means after decrement it becomes 0 — the datagram can
// ttl=1 means after decrement it becomes 0 — the datagram can
// still be delivered this hop but would be dropped at the next.
// decrement_hop_limit returns true (1 > 0), so the handler proceeds.
// decrement_ttl returns true (1 > 0), so the handler proceeds.
let mut node = make_node();
let from = make_node_addr(0xAA);
let my_addr = *node.node_addr();
let src = make_node_addr(0x01);
let dg = SessionDatagram::new(src, my_addr, vec![0x10, 0x00, 0x00, 0x00])
.with_hop_limit(1);
.with_ttl(1);
let encoded = dg.encode();
// Should succeed — hop_limit=1 decrements to 0 but packet is still processed
// Should succeed — ttl=1 decrements to 0 but packet is still processed
node.handle_session_datagram(&from, &encoded[1..]).await;
}
@@ -343,7 +343,7 @@ async fn test_forwarding_multi_hop() {
let node1_addr = *nodes[1].node.node_addr();
let node4_addr = *nodes[4].node.node_addr();
// Build a SessionDatagram with enough hop_limit for 4 hops
// Build a SessionDatagram with enough TTL for 4 hops
let dg = SessionDatagram::new(
node0_addr,
node4_addr,
@@ -372,9 +372,9 @@ async fn test_forwarding_multi_hop() {
#[tokio::test]
async fn test_forwarding_hop_limit_prevents_infinite_loops() {
// 3-node chain: 0 -- 1 -- 2
// Send a datagram with hop_limit=1. It should be forwarded by node 1
// Send a datagram with ttl=1. It should be forwarded by node 1
// (decrement to 0) and delivered at node 2 (local delivery). If node 2
// tried to forward further, the 0 hop_limit would prevent it.
// tried to forward further, the 0 ttl would prevent it.
let edges = vec![(0, 1), (1, 2)];
let mut nodes = run_tree_test(3, &edges, false).await;
verify_tree_convergence(&nodes);
@@ -389,7 +389,7 @@ async fn test_forwarding_hop_limit_prevents_infinite_loops() {
node2_addr,
vec![0x10, 0x00, 0x04, 0x00, 1, 2, 3, 4],
)
.with_hop_limit(2); // Enough for 01 (decrement to 1) and 12 (decrement to 0, local delivery)
.with_ttl(2); // Enough for 0->1 (decrement to 1) and 1->2 (decrement to 0, local delivery)
let encoded = dg.encode();
+16 -8
View File
@@ -6,7 +6,7 @@ use super::*;
async fn test_two_node_handshake_udp() {
use crate::config::UdpConfig;
use crate::transport::udp::UdpTransport;
use crate::node::wire::{build_encrypted, build_msg1};
use crate::node::wire::{build_encrypted, build_established_header, build_msg1, prepend_inner_header};
use tokio::time::{timeout, Duration};
// === Setup: Two nodes with UDP transports on localhost ===
@@ -156,13 +156,17 @@ async fn test_two_node_handshake_udp() {
// === Phase 4: Encrypted frame A → B ===
// A encrypts a test message and sends to B
let plaintext_a = b"hello from A";
// Prepend inner header (timestamp + msg_type) as the real send path does
let msg_a = b"\x10test from A"; // msg_type 0x10 (TreeAnnounce) + dummy payload
let inner_a = prepend_inner_header(0, msg_a);
let peer_b = node_a.get_peer_mut(&peer_b_node_addr).unwrap();
let their_index_b = peer_b.their_index().expect("A should know B's index");
let session_a = peer_b.noise_session_mut().unwrap();
let ciphertext_a = session_a.encrypt(plaintext_a).unwrap();
let counter_a = session_a.current_send_counter();
let header_a = build_established_header(their_index_b, counter_a, 0, inner_a.len() as u16);
let ciphertext_a = session_a.encrypt_with_aad(&inner_a, &header_a).unwrap();
let wire_encrypted = build_encrypted(their_index_b, 0, &ciphertext_a);
let wire_encrypted = build_encrypted(&header_a, &ciphertext_a);
let transport = node_a.transports.get(&transport_id_a).unwrap();
transport
.send(&remote_addr_b, &wire_encrypted)
@@ -186,13 +190,17 @@ async fn test_two_node_handshake_udp() {
// === Phase 5: Encrypted frame B → A ===
let plaintext_b = b"hello from B";
// Prepend inner header (timestamp + msg_type) as the real send path does
let msg_b = b"\x10test from B"; // msg_type 0x10 (TreeAnnounce) + dummy payload
let inner_b = prepend_inner_header(0, msg_b);
let peer_a = node_b.get_peer_mut(&peer_a_node_addr).unwrap();
let their_index_a = peer_a.their_index().expect("B should know A's index");
let session_b = peer_a.noise_session_mut().unwrap();
let ciphertext_b = session_b.encrypt(plaintext_b).unwrap();
let counter_b = session_b.current_send_counter();
let header_b = build_established_header(their_index_a, counter_b, 0, inner_b.len() as u16);
let ciphertext_b = session_b.encrypt_with_aad(&inner_b, &header_b).unwrap();
let wire_encrypted_b = build_encrypted(their_index_a, 0, &ciphertext_b);
let wire_encrypted_b = build_encrypted(&header_b, &ciphertext_b);
let transport = node_b.transports.get(&transport_id_b).unwrap();
transport
.send(&remote_addr_a, &wire_encrypted_b)
@@ -328,7 +336,7 @@ async fn test_run_rx_loop_handshake() {
//
// This is the key difference from test_two_node_handshake_udp:
// instead of calling handle_msg1() directly, we run the full rx loop
// which dispatches based on the discriminator byte.
// which dispatches based on the common prefix phase field.
tokio::select! {
result = node_b.run_rx_loop() => {
+14 -9
View File
@@ -203,23 +203,28 @@ pub(super) fn print_tree_snapshot(label: &str, nodes: &[TestNode]) {
///
/// Returns the number of packets processed.
pub(super) async fn process_available_packets(nodes: &mut [TestNode]) -> usize {
use crate::node::wire::{DISCRIMINATOR_ENCRYPTED, DISCRIMINATOR_MSG1, DISCRIMINATOR_MSG2};
use crate::node::wire::{CommonPrefix, FLP_VERSION, PHASE_ESTABLISHED, PHASE_MSG1, PHASE_MSG2, COMMON_PREFIX_SIZE};
let mut count = 0;
for node in nodes.iter_mut() {
while let Ok(packet) = node.packet_rx.try_recv() {
if packet.data.is_empty() {
if packet.data.len() < COMMON_PREFIX_SIZE {
continue;
}
match packet.data[0] {
DISCRIMINATOR_MSG1 => node.node.handle_msg1(packet).await,
DISCRIMINATOR_MSG2 => node.node.handle_msg2(packet).await,
DISCRIMINATOR_ENCRYPTED => {
node.node.handle_encrypted_frame(packet).await
if let Some(prefix) = CommonPrefix::parse(&packet.data) {
if prefix.version != FLP_VERSION {
continue;
}
_ => {}
match prefix.phase {
PHASE_MSG1 => node.node.handle_msg1(packet).await,
PHASE_MSG2 => node.node.handle_msg2(packet).await,
PHASE_ESTABLISHED => {
node.node.handle_encrypted_frame(packet).await
}
_ => {}
}
count += 1;
}
count += 1;
}
}
count
+361 -93
View File
@@ -1,15 +1,21 @@
//! Wire Format Parsing and Serialization
//!
//! Defines the FIPS link-layer wire format for packet dispatch.
//! All packets begin with a discriminator byte followed by type-specific payload.
//! Defines the FIPS link-layer wire format (FLP) for packet dispatch.
//! All packets begin with a 4-byte common prefix followed by phase-specific fields.
//!
//! ## Common Prefix (4 bytes)
//!
//! ```text
//! [ver+phase:1][flags:1][payload_len:2 LE]
//! ```
//!
//! ## Packet Types
//!
//! | Byte | Type | Size | Description |
//! |------|-----------------|-----------|--------------------------------|
//! | 0x00 | Encrypted frame | 29+ bytes | Post-handshake encrypted data |
//! | 0x01 | Noise IK msg1 | 87 bytes | Handshake initiation |
//! | 0x02 | Noise IK msg2 | 42 bytes | Handshake response |
//! | Phase | Type | Size | Description |
//! |-------|-----------------|-----------|--------------------------------|
//! | 0x0 | Encrypted frame | 32+ bytes | Post-handshake encrypted data |
//! | 0x1 | Noise IK msg1 | 90 bytes | Handshake initiation |
//! | 0x2 | Noise IK msg2 | 45 bytes | Handshake response |
use crate::utils::index::SessionIndex;
use crate::noise::{HANDSHAKE_MSG1_SIZE, HANDSHAKE_MSG2_SIZE, TAG_SIZE};
@@ -18,73 +24,171 @@ use crate::noise::{HANDSHAKE_MSG1_SIZE, HANDSHAKE_MSG2_SIZE, TAG_SIZE};
// Constants
// ============================================================================
/// Discriminator for encrypted frames (post-handshake data).
pub const DISCRIMINATOR_ENCRYPTED: u8 = 0x00;
/// FLP protocol version (4 high bits of byte 0).
pub const FLP_VERSION: u8 = 0;
/// Discriminator for Noise IK message 1 (handshake initiation).
pub const DISCRIMINATOR_MSG1: u8 = 0x01;
/// Phase value for established (encrypted) frames.
pub const PHASE_ESTABLISHED: u8 = 0x0;
/// Discriminator for Noise IK message 2 (handshake response).
pub const DISCRIMINATOR_MSG2: u8 = 0x02;
/// Phase value for Noise IK message 1 (handshake initiation).
pub const PHASE_MSG1: u8 = 0x1;
/// Size of Noise IK message 1 wire packet: discriminator + sender_idx + noise_msg1.
pub const MSG1_WIRE_SIZE: usize = 1 + 4 + HANDSHAKE_MSG1_SIZE; // 87 bytes
/// Phase value for Noise IK message 2 (handshake response).
pub const PHASE_MSG2: u8 = 0x2;
/// Size of Noise IK message 2 wire packet: discriminator + sender_idx + receiver_idx + noise_msg2.
pub const MSG2_WIRE_SIZE: usize = 1 + 4 + 4 + HANDSHAKE_MSG2_SIZE; // 42 bytes
/// Size of the common packet prefix (all packet types).
pub const COMMON_PREFIX_SIZE: usize = 4;
/// Minimum size for encrypted frame: discriminator + receiver_idx + counter + tag.
pub const ENCRYPTED_MIN_SIZE: usize = 1 + 4 + 8 + TAG_SIZE; // 29 bytes
/// Size of the full established frame header (prefix + receiver_idx + counter).
pub const ESTABLISHED_HEADER_SIZE: usize = 16;
/// Size of Noise IK message 1 wire packet: prefix + sender_idx + noise_msg1.
pub const MSG1_WIRE_SIZE: usize = COMMON_PREFIX_SIZE + 4 + HANDSHAKE_MSG1_SIZE; // 90 bytes
/// Size of Noise IK message 2 wire packet: prefix + sender_idx + receiver_idx + noise_msg2.
pub const MSG2_WIRE_SIZE: usize = COMMON_PREFIX_SIZE + 4 + 4 + HANDSHAKE_MSG2_SIZE; // 45 bytes
/// Minimum size for encrypted frame: header + tag (no plaintext).
pub const ENCRYPTED_MIN_SIZE: usize = ESTABLISHED_HEADER_SIZE + TAG_SIZE; // 32 bytes
/// Size of the encrypted inner header (timestamp + message type).
pub const INNER_HEADER_SIZE: usize = 5;
// Flag bit constants (byte 1 of common prefix, meaningful only for phase 0x0).
// Reserved for upcoming rekeying, congestion signaling, and RTT measurement.
#[allow(dead_code)]
/// Key epoch flag — selects active key during rekeying.
pub const FLAG_KEY_EPOCH: u8 = 0x01;
#[allow(dead_code)]
/// Congestion Experienced echo flag.
pub const FLAG_CE: u8 = 0x02;
#[allow(dead_code)]
/// Spin bit for RTT measurement.
pub const FLAG_SP: u8 = 0x04;
// ============================================================================
// Common Prefix
// ============================================================================
/// Parsed common packet prefix (first 4 bytes of every FLP packet).
///
/// Wire format:
/// ```text
/// [ver(4bits)+phase(4bits)][flags:1][payload_len:2 LE]
/// ```
#[derive(Clone, Debug)]
pub struct CommonPrefix {
/// Protocol version (high nibble of byte 0).
pub version: u8,
/// Session lifecycle phase (low nibble of byte 0).
pub phase: u8,
/// Per-packet signal flags (meaningful only for phase 0x0).
#[allow(dead_code)]
pub flags: u8,
/// Length of payload following the phase-specific header (excludes AEAD tag).
#[allow(dead_code)]
pub payload_len: u16,
}
impl CommonPrefix {
/// Parse a common prefix from the first 4 bytes of packet data.
pub fn parse(data: &[u8]) -> Option<Self> {
if data.len() < COMMON_PREFIX_SIZE {
return None;
}
let version = data[0] >> 4;
let phase = data[0] & 0x0F;
let flags = data[1];
let payload_len = u16::from_le_bytes([data[2], data[3]]);
Some(Self {
version,
phase,
flags,
payload_len,
})
}
/// Encode the ver+phase byte.
fn ver_phase_byte(version: u8, phase: u8) -> u8 {
(version << 4) | (phase & 0x0F)
}
}
// ============================================================================
// Encrypted Frame Header
// ============================================================================
/// Parsed encrypted frame header.
/// Parsed established frame header (phase 0x0).
///
/// Wire format:
/// Wire format (16 bytes):
/// ```text
/// [0x00][receiver_idx:4 LE][counter:8 LE][ciphertext+tag]
/// [ver+phase:1][flags:1][payload_len:2 LE][receiver_idx:4 LE][counter:8 LE]
/// ```
///
/// The full 16-byte header is used as AAD for the AEAD construction.
#[derive(Clone, Debug)]
pub struct EncryptedHeader {
/// Per-packet flags (K, CE, SP).
#[allow(dead_code)]
pub flags: u8,
/// Length of encrypted payload (excluding AEAD tag).
#[allow(dead_code)]
pub payload_len: u16,
/// Session index chosen by the receiver (for O(1) lookup).
pub receiver_idx: SessionIndex,
/// Monotonic counter used as AEAD nonce.
pub counter: u64,
/// Offset where ciphertext begins in the original packet.
pub ciphertext_offset: usize,
/// Raw 16-byte header for use as AEAD AAD.
pub header_bytes: [u8; ESTABLISHED_HEADER_SIZE],
}
impl EncryptedHeader {
/// Parse an encrypted frame header from packet data.
/// Parse an established frame header from packet data.
///
/// Returns None if the packet is too short or has wrong discriminator.
/// Returns None if the packet is too short or has wrong version/phase.
pub fn parse(data: &[u8]) -> Option<Self> {
if data.len() < ENCRYPTED_MIN_SIZE {
return None;
}
if data[0] != DISCRIMINATOR_ENCRYPTED {
let version = data[0] >> 4;
let phase = data[0] & 0x0F;
if version != FLP_VERSION || phase != PHASE_ESTABLISHED {
return None;
}
let receiver_idx = SessionIndex::from_le_bytes([data[1], data[2], data[3], data[4]]);
let flags = data[1];
let payload_len = u16::from_le_bytes([data[2], data[3]]);
let receiver_idx = SessionIndex::from_le_bytes([data[4], data[5], data[6], data[7]]);
let counter = u64::from_le_bytes([
data[5], data[6], data[7], data[8], data[9], data[10], data[11], data[12],
data[8], data[9], data[10], data[11],
data[12], data[13], data[14], data[15],
]);
let mut header_bytes = [0u8; ESTABLISHED_HEADER_SIZE];
header_bytes.copy_from_slice(&data[..ESTABLISHED_HEADER_SIZE]);
Some(Self {
flags,
payload_len,
receiver_idx,
counter,
ciphertext_offset: 13,
header_bytes,
})
}
/// Offset where ciphertext begins in the original packet.
pub fn ciphertext_offset(&self) -> usize {
ESTABLISHED_HEADER_SIZE
}
/// Get the ciphertext slice from the original packet.
#[cfg(test)]
pub fn ciphertext<'a>(&self, data: &'a [u8]) -> &'a [u8] {
&data[self.ciphertext_offset..]
&data[ESTABLISHED_HEADER_SIZE..]
}
}
@@ -92,11 +196,11 @@ impl EncryptedHeader {
// Msg1 Header
// ============================================================================
/// Parsed Noise IK message 1 header.
/// Parsed Noise IK message 1 header (phase 0x1).
///
/// Wire format:
/// Wire format (90 bytes):
/// ```text
/// [0x01][sender_idx:4 LE][noise_msg1:82]
/// [0x01][0x00][payload_len:2 LE][sender_idx:4 LE][noise_msg1:82]
/// ```
#[derive(Clone, Debug)]
pub struct Msg1Header {
@@ -109,21 +213,29 @@ pub struct Msg1Header {
impl Msg1Header {
/// Parse a msg1 header from packet data.
///
/// Returns None if the packet has wrong size or discriminator.
/// Returns None if the packet has wrong size or version/phase.
pub fn parse(data: &[u8]) -> Option<Self> {
if data.len() != MSG1_WIRE_SIZE {
return None;
}
if data[0] != DISCRIMINATOR_MSG1 {
let version = data[0] >> 4;
let phase = data[0] & 0x0F;
if version != FLP_VERSION || phase != PHASE_MSG1 {
return None;
}
let sender_idx = SessionIndex::from_le_bytes([data[1], data[2], data[3], data[4]]);
// flags must be zero during handshake
if data[1] != 0 {
return None;
}
let sender_idx = SessionIndex::from_le_bytes([data[4], data[5], data[6], data[7]]);
Some(Self {
sender_idx,
noise_msg1_offset: 5,
noise_msg1_offset: COMMON_PREFIX_SIZE + 4, // 8
})
}
@@ -138,11 +250,11 @@ impl Msg1Header {
// Msg2 Header
// ============================================================================
/// Parsed Noise IK message 2 header.
/// Parsed Noise IK message 2 header (phase 0x2).
///
/// Wire format:
/// Wire format (45 bytes):
/// ```text
/// [0x02][sender_idx:4 LE][receiver_idx:4 LE][noise_msg2:33]
/// [0x02][0x00][payload_len:2 LE][sender_idx:4 LE][receiver_idx:4 LE][noise_msg2:33]
/// ```
#[derive(Clone, Debug)]
pub struct Msg2Header {
@@ -157,23 +269,31 @@ pub struct Msg2Header {
impl Msg2Header {
/// Parse a msg2 header from packet data.
///
/// Returns None if the packet has wrong size or discriminator.
/// Returns None if the packet has wrong size or version/phase.
pub fn parse(data: &[u8]) -> Option<Self> {
if data.len() != MSG2_WIRE_SIZE {
return None;
}
if data[0] != DISCRIMINATOR_MSG2 {
let version = data[0] >> 4;
let phase = data[0] & 0x0F;
if version != FLP_VERSION || phase != PHASE_MSG2 {
return None;
}
let sender_idx = SessionIndex::from_le_bytes([data[1], data[2], data[3], data[4]]);
let receiver_idx = SessionIndex::from_le_bytes([data[5], data[6], data[7], data[8]]);
// flags must be zero during handshake
if data[1] != 0 {
return None;
}
let sender_idx = SessionIndex::from_le_bytes([data[4], data[5], data[6], data[7]]);
let receiver_idx = SessionIndex::from_le_bytes([data[8], data[9], data[10], data[11]]);
Some(Self {
sender_idx,
receiver_idx,
noise_msg2_offset: 9,
noise_msg2_offset: COMMON_PREFIX_SIZE + 4 + 4, // 12
})
}
@@ -190,12 +310,16 @@ impl Msg2Header {
/// Build a wire-format msg1 packet.
///
/// Format: `[0x01][sender_idx:4 LE][noise_msg1:82]`
/// Format: `[0x01][0x00][payload_len:2 LE][sender_idx:4 LE][noise_msg1:82]`
pub fn build_msg1(sender_idx: SessionIndex, noise_msg1: &[u8]) -> Vec<u8> {
debug_assert_eq!(noise_msg1.len(), HANDSHAKE_MSG1_SIZE);
let payload_len = (4 + noise_msg1.len()) as u16; // sender_idx + noise_msg1
let mut packet = Vec::with_capacity(MSG1_WIRE_SIZE);
packet.push(DISCRIMINATOR_MSG1);
packet.push(CommonPrefix::ver_phase_byte(FLP_VERSION, PHASE_MSG1));
packet.push(0x00); // flags must be zero
packet.extend_from_slice(&payload_len.to_le_bytes());
packet.extend_from_slice(&sender_idx.to_le_bytes());
packet.extend_from_slice(noise_msg1);
packet
@@ -203,30 +327,80 @@ pub fn build_msg1(sender_idx: SessionIndex, noise_msg1: &[u8]) -> Vec<u8> {
/// Build a wire-format msg2 packet.
///
/// Format: `[0x02][sender_idx:4 LE][receiver_idx:4 LE][noise_msg2:33]`
/// Format: `[0x02][0x00][payload_len:2 LE][sender_idx:4 LE][receiver_idx:4 LE][noise_msg2:33]`
pub fn build_msg2(sender_idx: SessionIndex, receiver_idx: SessionIndex, noise_msg2: &[u8]) -> Vec<u8> {
debug_assert_eq!(noise_msg2.len(), HANDSHAKE_MSG2_SIZE);
let payload_len = (4 + 4 + noise_msg2.len()) as u16; // sender + receiver + noise
let mut packet = Vec::with_capacity(MSG2_WIRE_SIZE);
packet.push(DISCRIMINATOR_MSG2);
packet.push(CommonPrefix::ver_phase_byte(FLP_VERSION, PHASE_MSG2));
packet.push(0x00); // flags must be zero
packet.extend_from_slice(&payload_len.to_le_bytes());
packet.extend_from_slice(&sender_idx.to_le_bytes());
packet.extend_from_slice(&receiver_idx.to_le_bytes());
packet.extend_from_slice(noise_msg2);
packet
}
/// Build the 16-byte outer header for an established frame.
///
/// Returns the header bytes (for use as AAD) separately from the construction.
pub fn build_established_header(
receiver_idx: SessionIndex,
counter: u64,
flags: u8,
payload_len: u16,
) -> [u8; ESTABLISHED_HEADER_SIZE] {
let mut header = [0u8; ESTABLISHED_HEADER_SIZE];
header[0] = CommonPrefix::ver_phase_byte(FLP_VERSION, PHASE_ESTABLISHED);
header[1] = flags;
header[2..4].copy_from_slice(&payload_len.to_le_bytes());
header[4..8].copy_from_slice(&receiver_idx.to_le_bytes());
header[8..16].copy_from_slice(&counter.to_le_bytes());
header
}
/// Build a wire-format encrypted frame.
///
/// Format: `[0x00][receiver_idx:4 LE][counter:8 LE][ciphertext+tag]`
pub fn build_encrypted(receiver_idx: SessionIndex, counter: u64, ciphertext: &[u8]) -> Vec<u8> {
let mut packet = Vec::with_capacity(13 + ciphertext.len());
packet.push(DISCRIMINATOR_ENCRYPTED);
packet.extend_from_slice(&receiver_idx.to_le_bytes());
packet.extend_from_slice(&counter.to_le_bytes());
/// Format: `[header:16][ciphertext+tag]`
///
/// The header is constructed from the parameters and used as AAD during
/// encryption. The caller should use `build_established_header` to construct
/// the header, encrypt with it as AAD, then call this to assemble the packet.
pub fn build_encrypted(header: &[u8; ESTABLISHED_HEADER_SIZE], ciphertext: &[u8]) -> Vec<u8> {
let mut packet = Vec::with_capacity(ESTABLISHED_HEADER_SIZE + ciphertext.len());
packet.extend_from_slice(header);
packet.extend_from_slice(ciphertext);
packet
}
// ============================================================================
// Inner Header Helpers
// ============================================================================
/// Prepend the 5-byte inner header (timestamp + msg_type) to a link message.
///
/// The caller provides the original plaintext starting with `[msg_type][payload...]`.
/// This prepends `[timestamp:4 LE]` before the msg_type byte.
pub fn prepend_inner_header(timestamp_ms: u32, plaintext: &[u8]) -> Vec<u8> {
let mut buf = Vec::with_capacity(4 + plaintext.len());
buf.extend_from_slice(&timestamp_ms.to_le_bytes());
buf.extend_from_slice(plaintext);
buf
}
/// Strip the 4-byte timestamp from a decrypted inner payload.
///
/// Returns `(timestamp, &payload_starting_at_msg_type)` or None if too short.
pub fn strip_inner_header(plaintext: &[u8]) -> Option<(u32, &[u8])> {
if plaintext.len() < INNER_HEADER_SIZE {
return None;
}
let timestamp = u32::from_le_bytes([plaintext[0], plaintext[1], plaintext[2], plaintext[3]]);
Some((timestamp, &plaintext[4..]))
}
// ============================================================================
// Tests
// ============================================================================
@@ -235,36 +409,61 @@ pub fn build_encrypted(receiver_idx: SessionIndex, counter: u64, ciphertext: &[u
mod tests {
use super::*;
#[test]
fn test_common_prefix_parse() {
let data = [0x00, 0x04, 0x20, 0x00]; // ver=0, phase=0, flags=SP, payload_len=32
let prefix = CommonPrefix::parse(&data).unwrap();
assert_eq!(prefix.version, 0);
assert_eq!(prefix.phase, 0);
assert_eq!(prefix.flags, FLAG_SP);
assert_eq!(prefix.payload_len, 32);
}
#[test]
fn test_common_prefix_too_short() {
assert!(CommonPrefix::parse(&[0, 0, 0]).is_none());
}
#[test]
fn test_encrypted_header_parse() {
// Build a valid encrypted frame
let receiver_idx = SessionIndex::new(0x12345678);
let counter = 42u64;
let ciphertext = vec![0xaa; 32]; // 16 plaintext + 16 tag
let flags = 0u8;
let payload_len = 32u16; // 16 plaintext + 16 tag
let ciphertext = vec![0xaa; 48]; // payload_len + TAG_SIZE
let packet = build_encrypted(receiver_idx, counter, &ciphertext);
let header = build_established_header(receiver_idx, counter, flags, payload_len);
let packet = build_encrypted(&header, &ciphertext);
assert_eq!(packet.len(), 13 + 32);
assert_eq!(packet[0], DISCRIMINATOR_ENCRYPTED);
assert_eq!(packet.len(), ESTABLISHED_HEADER_SIZE + 48);
assert_eq!(packet[0], 0x00); // ver=0, phase=0
// Parse it back
let header = EncryptedHeader::parse(&packet).expect("should parse");
assert_eq!(header.receiver_idx, receiver_idx);
assert_eq!(header.counter, 42);
assert_eq!(header.ciphertext_offset, 13);
assert_eq!(header.ciphertext(&packet), &ciphertext[..]);
let parsed = EncryptedHeader::parse(&packet).expect("should parse");
assert_eq!(parsed.receiver_idx, receiver_idx);
assert_eq!(parsed.counter, 42);
assert_eq!(parsed.flags, 0);
assert_eq!(parsed.payload_len, 32);
assert_eq!(parsed.header_bytes, header);
assert_eq!(parsed.ciphertext(&packet), &ciphertext[..]);
}
#[test]
fn test_encrypted_header_too_short() {
let packet = vec![0x00; 28]; // One byte too short
let packet = vec![0x00; ENCRYPTED_MIN_SIZE - 1];
assert!(EncryptedHeader::parse(&packet).is_none());
}
#[test]
fn test_encrypted_header_wrong_discriminator() {
let mut packet = vec![0x00; 30];
packet[0] = 0x01; // Wrong discriminator
fn test_encrypted_header_wrong_phase() {
let mut packet = vec![0x00; ENCRYPTED_MIN_SIZE];
packet[0] = 0x01; // phase 1 (msg1), not established
assert!(EncryptedHeader::parse(&packet).is_none());
}
#[test]
fn test_encrypted_header_wrong_version() {
let mut packet = vec![0x00; ENCRYPTED_MIN_SIZE];
packet[0] = 0x10; // version 1, phase 0
assert!(EncryptedHeader::parse(&packet).is_none());
}
@@ -276,27 +475,34 @@ mod tests {
let packet = build_msg1(sender_idx, &noise_msg1);
assert_eq!(packet.len(), MSG1_WIRE_SIZE);
assert_eq!(packet[0], DISCRIMINATOR_MSG1);
assert_eq!(packet[0], 0x01); // ver=0, phase=1
let header = Msg1Header::parse(&packet).expect("should parse");
assert_eq!(header.sender_idx, sender_idx);
assert_eq!(header.noise_msg1_offset, 5);
assert_eq!(header.noise_msg1_offset, 8);
assert_eq!(header.noise_msg1(&packet), &noise_msg1[..]);
}
#[test]
fn test_msg1_header_wrong_size() {
let packet = vec![0x01; 86]; // One byte too short
let packet = vec![0x01; MSG1_WIRE_SIZE - 1];
assert!(Msg1Header::parse(&packet).is_none());
let packet = vec![0x01; 88]; // One byte too long
let packet = vec![0x01; MSG1_WIRE_SIZE + 1];
assert!(Msg1Header::parse(&packet).is_none());
}
#[test]
fn test_msg1_header_wrong_discriminator() {
fn test_msg1_header_wrong_phase() {
let mut packet = vec![0x00; MSG1_WIRE_SIZE];
packet[0] = 0x02; // Wrong discriminator
packet[0] = 0x02; // phase 2, not phase 1
assert!(Msg1Header::parse(&packet).is_none());
}
#[test]
fn test_msg1_header_nonzero_flags() {
let mut packet = build_msg1(SessionIndex::new(1), &[0u8; HANDSHAKE_MSG1_SIZE]);
packet[1] = 0x01; // flags must be zero during handshake
assert!(Msg1Header::parse(&packet).is_none());
}
@@ -309,52 +515,114 @@ mod tests {
let packet = build_msg2(sender_idx, receiver_idx, &noise_msg2);
assert_eq!(packet.len(), MSG2_WIRE_SIZE);
assert_eq!(packet[0], DISCRIMINATOR_MSG2);
assert_eq!(packet[0], 0x02); // ver=0, phase=2
let header = Msg2Header::parse(&packet).expect("should parse");
assert_eq!(header.sender_idx, sender_idx);
assert_eq!(header.receiver_idx, receiver_idx);
assert_eq!(header.noise_msg2_offset, 9);
assert_eq!(header.noise_msg2_offset, 12);
assert_eq!(header.noise_msg2(&packet), &noise_msg2[..]);
}
#[test]
fn test_msg2_header_wrong_size() {
let packet = vec![0x02; 41]; // One byte too short
let packet = vec![0x02; MSG2_WIRE_SIZE - 1];
assert!(Msg2Header::parse(&packet).is_none());
let packet = vec![0x02; 43]; // One byte too long
let packet = vec![0x02; MSG2_WIRE_SIZE + 1];
assert!(Msg2Header::parse(&packet).is_none());
}
#[test]
fn test_msg2_header_wrong_discriminator() {
fn test_msg2_header_wrong_phase() {
let mut packet = vec![0x00; MSG2_WIRE_SIZE];
packet[0] = 0x00; // Wrong discriminator
packet[0] = 0x00; // phase 0, not phase 2
assert!(Msg2Header::parse(&packet).is_none());
}
#[test]
fn test_wire_sizes() {
// Verify constants match spec
assert_eq!(MSG1_WIRE_SIZE, 87); // 1 + 4 + 82
assert_eq!(MSG2_WIRE_SIZE, 42); // 1 + 4 + 4 + 33
assert_eq!(ENCRYPTED_MIN_SIZE, 29); // 1 + 4 + 8 + 16
assert_eq!(MSG1_WIRE_SIZE, 90); // 4 + 4 + 82
assert_eq!(MSG2_WIRE_SIZE, 45); // 4 + 4 + 4 + 33
assert_eq!(ENCRYPTED_MIN_SIZE, 32); // 16 + 16
assert_eq!(COMMON_PREFIX_SIZE, 4);
assert_eq!(ESTABLISHED_HEADER_SIZE, 16);
assert_eq!(INNER_HEADER_SIZE, 5);
}
#[test]
fn test_roundtrip_indices() {
// Test that indices survive the roundtrip correctly (endianness)
let idx = SessionIndex::new(0xDEADBEEF);
let msg1 = build_msg1(idx, &[0u8; HANDSHAKE_MSG1_SIZE]);
let parsed = Msg1Header::parse(&msg1).unwrap();
assert_eq!(parsed.sender_idx.as_u32(), 0xDEADBEEF);
// Verify little-endian encoding
assert_eq!(msg1[1], 0xEF);
assert_eq!(msg1[2], 0xBE);
assert_eq!(msg1[3], 0xAD);
assert_eq!(msg1[4], 0xDE);
// Verify little-endian encoding (sender_idx starts at offset 4)
assert_eq!(msg1[4], 0xEF);
assert_eq!(msg1[5], 0xBE);
assert_eq!(msg1[6], 0xAD);
assert_eq!(msg1[7], 0xDE);
}
#[test]
fn test_inner_header_prepend_strip() {
let timestamp: u32 = 12345;
let original = vec![0x10, 0xAA, 0xBB]; // msg_type + payload
let with_header = prepend_inner_header(timestamp, &original);
assert_eq!(with_header.len(), 4 + 3); // timestamp + original
let (ts, rest) = strip_inner_header(&with_header).unwrap();
assert_eq!(ts, 12345);
assert_eq!(rest, &original[..]);
}
#[test]
fn test_inner_header_too_short() {
assert!(strip_inner_header(&[0, 0, 0, 0]).is_none()); // needs 5 bytes minimum
}
#[test]
fn test_flags_byte() {
let header = build_established_header(
SessionIndex::new(1),
0,
FLAG_KEY_EPOCH | FLAG_SP,
100,
);
assert_eq!(header[1], 0x05); // bits 0 and 2 set
let parsed = EncryptedHeader::parse(&[
header[0], header[1], header[2], header[3],
header[4], header[5], header[6], header[7],
header[8], header[9], header[10], header[11],
header[12], header[13], header[14], header[15],
// minimum: TAG_SIZE bytes of ciphertext
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
]).unwrap();
assert_eq!(parsed.flags & FLAG_KEY_EPOCH, FLAG_KEY_EPOCH);
assert_eq!(parsed.flags & FLAG_CE, 0);
assert_eq!(parsed.flags & FLAG_SP, FLAG_SP);
}
#[test]
fn test_payload_len_in_msg1() {
let packet = build_msg1(SessionIndex::new(1), &[0u8; HANDSHAKE_MSG1_SIZE]);
let prefix = CommonPrefix::parse(&packet).unwrap();
// payload_len = sender_idx(4) + noise_msg1(82) = 86
assert_eq!(prefix.payload_len, 86);
}
#[test]
fn test_payload_len_in_msg2() {
let packet = build_msg2(
SessionIndex::new(1),
SessionIndex::new(2),
&[0u8; HANDSHAKE_MSG2_SIZE],
);
let prefix = CommonPrefix::parse(&packet).unwrap();
// payload_len = sender_idx(4) + receiver_idx(4) + noise_msg2(33) = 41
assert_eq!(prefix.payload_len, 41);
}
}