Implement FSP port multiplexing and IPv6 header compression

Breaking wire format change: DataPacket payloads inside the AEAD envelope
now carry a 4-byte port header [src_port:2 LE][dst_port:2 LE] before the
service payload. The receiver dispatches by destination port.

Port multiplexing:
- send_session_data() takes src_port/dst_port params, prepends port header
- New send_ipv6_packet() compresses IPv6 header and sends on port 256
- Receive path dispatches DataPackets by port: port 256 decompresses IPv6
  header from session context and delivers to TUN, unknown ports dropped
- Port constants: FSP_PORT_HEADER_SIZE (4 bytes), FSP_PORT_IPV6_SHIM (256)

IPv6 header compression:
- New ipv6_shim module with compress_ipv6()/decompress_ipv6() pure functions
- Strips src/dst addresses (32 bytes) and payload length (2 bytes) from each
  packet, preserving traffic class, flow label, next header, and hop limit
  as 6-byte residual fields
- Addresses reconstructed from session context on receive side
- Net savings: 29 bytes per packet (overhead 106 → 77 bytes)
- FIPS_IPV6_OVERHEAD constant (77 bytes), effective_ipv6_mtu() updated
- 16 unit tests for round-trip fidelity, field preservation, error cases

Documentation:
- fips-wire-formats: DataPacket port header, port registry, IPv6 shim
  format tables, updated encapsulation walkthrough and overhead budget
- fips-ipv6-adapter: FIPS_IPV6_OVERHEAD (77 bytes), updated MTU numbers,
  TUN reader/writer flow with compression steps, impl status
- fips-session-layer: port-based service dispatch section, data transfer
  description, impl status
- fips-intro: IPv6 adapter as port 256 service, node architecture updated
- fips-mesh-operation: packet size summary with compressed overhead
- DataPacket doc updated with port header and dispatch model
- session_wire.rs module doc: DataPacket Port Multiplexing section
This commit is contained in:
Johnathan Corgan
2026-03-11 12:53:32 +00:00
parent f37eb4b846
commit 6ab8b35755
12 changed files with 644 additions and 77 deletions

View File

@@ -155,18 +155,21 @@ demultiplexing. FSP provides a datagram service to applications above.
See [fips-session-layer.md](fips-session-layer.md) for the FSP specification.
**IPv6 adaptation layer**: Sits above FSP and adapts the FIPS datagram
service for unmodified IPv6 applications. Provides DNS resolution (npub →
fd00::/8 address), identity cache management, MTU enforcement, and a TUN
interface. This is the primary way existing applications use the FIPS mesh.
**IPv6 adaptation layer**: Sits above FSP as a service on port 256, adapting
the FIPS datagram service for unmodified IPv6 applications. Provides DNS
resolution (npub → fd00::/8 address), identity cache management, IPv6 header
compression, MTU enforcement, and a TUN interface. This is the primary way
existing applications use the FIPS mesh.
See [fips-ipv6-adapter.md](fips-ipv6-adapter.md) for the IPv6 adapter.
### Node Architecture
Two application interfaces sit at the top of the stack: a native datagram
API addressed by npub, and an IPv6 TUN adapter that maps npubs to `fd00::/8`
addresses so unmodified IP applications can use the network transparently.
Application services sit at the top of the stack, dispatched by FSP port
number: the IPv6 TUN adapter (port 256) maps npubs to `fd00::/8` addresses
with header compression so unmodified IP applications can use the network
transparently, while the native datagram API addresses destinations directly
by npub.
![Node Architecture](diagrams/fips-node-architecture.svg)

View File

@@ -146,35 +146,41 @@ wrapping.
| FSP header | 12 bytes | 4-byte prefix + 8-byte counter (used as AEAD AAD) |
| FSP inner header | 6 bytes | 4-byte timestamp + 1-byte msg_type + 1-byte inner_flags (inside AEAD) |
| Session AEAD tag | 16 bytes | ChaCha20-Poly1305 tag on session-encrypted payload |
| **Data path total** | **106 bytes** | `FIPS_OVERHEAD` constant |
| **Protocol envelope** | **106 bytes** | `FIPS_OVERHEAD` constant |
| Port header | 4 bytes | src_port + dst_port (DataPacket service dispatch) |
| IPv6 compression | 33 bytes | 40-byte IPv6 header → 7-byte format + residual |
| **IPv6 data path total** | **77 bytes** | `FIPS_IPV6_OVERHEAD` constant |
Coordinate piggybacking (CP flag) adds variable overhead: `2 + entries × 16`
per coordinate, with both src and dst coords sent. The send path skips the
CP flag if adding coords would exceed the transport MTU.
The `FIPS_OVERHEAD` constant (106 bytes) represents the fixed data path
overhead and is used for MTU calculations.
The `FIPS_OVERHEAD` constant (106 bytes) represents the base protocol
envelope overhead (link encryption + routing + session encryption). For IPv6
traffic, FSP port multiplexing adds 4 bytes (port header) while IPv6 header
compression saves 33 bytes (40-byte header → 7-byte format + residual),
yielding a net `FIPS_IPV6_OVERHEAD` of 77 bytes.
### Effective IPv6 MTU
The effective IPv6 MTU visible to applications is:
```text
effective_ipv6_mtu = transport_mtu - FIPS_OVERHEAD
effective_ipv6_mtu = transport_mtu - FIPS_IPV6_OVERHEAD
```
For typical deployments:
| Transport MTU | Effective IPv6 MTU | Notes |
| ------------- | ------------------ | ----- |
| 1472 (UDP/Ethernet) | 1366 | Standard deployment |
| 1280 (UDP minimum) | 1174 | Below IPv6 minimum |
| 1472 (UDP/Ethernet) | 1395 | Standard deployment |
| 1280 (UDP minimum) | 1203 | Below IPv6 minimum |
IPv6 mandates that every link support at least 1280 bytes. The minimum
transport path MTU for the IPv6 adapter is therefore:
```text
1280 + 106 = 1386 bytes
1280 + 77 = 1357 bytes
```
Transports with smaller MTUs (radio at ~250 bytes, serial at 256 bytes) cannot
@@ -249,15 +255,18 @@ The TUN reader receives raw IPv6 packets from applications and processes them:
3. Look up identity cache — miss returns ICMPv6 Destination Unreachable
4. Retrieve NodeAddr and PublicKey from cache
5. Look up or establish FSP session
6. Encrypt payload with session keys
7. Route through FMP toward destination
6. Compress IPv6 header: strip addresses and payload length, build format 0x00
payload with residual fields (traffic class, flow label, next header, hop limit)
7. Prepend port header (src_port=256, dst_port=256)
8. Encrypt with session keys
9. Route through FMP toward destination
### Writer Thread
A single writer thread services an mpsc queue of outbound packets:
- Inbound mesh traffic (decrypted session payloads destined for local
applications)
- Inbound mesh traffic on port 256 (IPv6 header reconstructed from session
context + residual fields, then delivered as complete IPv6 packets)
- ICMPv6 error responses (Packet Too Big, Destination Unreachable)
- TCP MSS-clamped SYN-ACK packets
@@ -305,6 +314,8 @@ TUN device creation requires `CAP_NET_ADMIN`. Options:
| ICMP rate limiting (per-source) | **Implemented** |
| TCP MSS clamping (SYN + SYN-ACK) | **Implemented** |
| DNS service (.fips domain) | **Implemented** |
| Port-based service multiplexing (port 256) | **Implemented** |
| IPv6 header compression (format 0x00) | **Implemented** |
| Per-destination route MTU (netlink) | Planned |
| Transit MTU error signal | **Implemented** |
| Path MTU tracking (SessionDatagram field) | **Implemented** |

View File

@@ -647,8 +647,8 @@ routing decisions but retains its own end-to-end encryption and identity.
| LookupResponse | ~400 bytes | Response to discovery | Yes (greedy routed) |
| SessionDatagram + SessionSetup | ~232402 bytes | Session establishment | Yes (routed) |
| SessionDatagram + SessionAck | ~170 bytes | Session confirmation | Yes (routed) |
| SessionDatagram + Data (minimal) | 106 bytes + payload | Bulk traffic | Yes (routed) |
| SessionDatagram + Data (with CP) | 106 + coords + payload | Warmup/recovery | Yes (routed) |
| SessionDatagram + Data (minimal) | 77 bytes + IPv6 payload | Bulk IPv6 traffic (compressed) | Yes (routed) |
| SessionDatagram + Data (with CP) | 77 + coords + IPv6 payload | Warmup/recovery (compressed) | Yes (routed) |
| SessionDatagram + CoordsRequired | 70 bytes | Cache miss error | Yes (routed) |
| SessionDatagram + PathBroken | 70+ bytes | Dead-end error | Yes (routed) |
| Disconnect | 2 bytes | Link teardown | No (peer-to-peer) |

View File

@@ -45,6 +45,15 @@ arriving at the TUN are translated to FIPS datagrams and routed through FSP.
See [fips-ipv6-adapter.md](fips-ipv6-adapter.md) for the IPv6 adaptation
layer.
### Port-Based Service Dispatch
FSP DataPackets carry a 4-byte port header (source and destination port) inside
the AEAD envelope, enabling multiple services to share a single session. The
IPv6 adapter runs on port 256; the native FIPS API and future services
(gateways, application protocols) register on other ports. Port dispatch is
internal to the FSP layer — services see only their payload, not the port
header.
### What Applications Get
- **Authenticated datagram delivery**: Each datagram is encrypted and
@@ -174,7 +183,9 @@ encrypted message includes:
flags (including the CP flag for coordinate cache warming)
- Optional cleartext coordinates when the CP flag is set
- An AEAD-encrypted payload containing a 6-byte inner header (session-relative
timestamp, message type, inner flags) followed by the application data
timestamp, message type, inner flags) followed by DataPacket content: a 4-byte
port header (src_port, dst_port) and the service payload. The receiver
dispatches by destination port to the registered service handler.
### Session Idle Timeout
@@ -539,6 +550,8 @@ MMP session metrics session=npub1tdwa...84le rtt=4.3ms loss=0.6% jitter=0.2ms go
| Simultaneous initiation tie-breaker | **Implemented** |
| Flush coord cache on parent change | **Implemented** |
| Rekey | **Implemented** |
| Port-based service multiplexing | **Implemented** |
| IPv6 header compression (shim on port 256) | **Implemented** |
| Path MTU tracking (FMP SessionDatagram field) | **Implemented** |
| Path MTU notification (end-to-end echo) | **Implemented** |

View File

@@ -488,7 +488,7 @@ body.
| Type | Message | Description |
| ---- | ------- | ----------- |
| 0x10 | Data | Application data (IPv6 payload via TUN) |
| 0x10 | Data | Port-multiplexed service payload (see DataPacket below) |
| 0x11 | SenderReport | MMP sender-side metrics report |
| 0x12 | ReceiverReport | MMP receiver-side metrics report |
| 0x13 | PathMtuNotification | End-to-end path MTU echo |
@@ -580,11 +580,64 @@ Encoded with FSP prefix: ver=0, phase=0x3, flags=0, payload_len.
SessionMsg3 does not carry coordinates — both endpoints already have each
other's coordinates from SessionSetup (msg1) and SessionAck (msg2).
### Data (0x10)
### Data (0x10) — DataPacket Port Multiplexing
Application data (typically IPv6 payload). This is the `msg_type` byte
inside the encrypted inner header — there is no separate DataPacket struct.
The body after the inner header is delivered directly to the TUN interface.
DataPacket is the primary application data carrier. The body after the
6-byte encrypted inner header contains a 4-byte port header followed by
the service payload:
| Offset | Field | Size | Description |
| ------ | ----- | ---- | ----------- |
| 0 | src_port | 2 bytes LE | Source service port |
| 2 | dst_port | 2 bytes LE | Destination service port |
| 4 | payload | variable | Service-specific payload |
The receiver dispatches by `dst_port` to the registered service handler.
**Port registry (three tiers):**
| Range | Purpose |
| ----- | ------- |
| 0255 (0x000xFF) | Reserved, protocol use |
| 2561023 (0x1000x3FF) | Reserved, FIPS standard services |
| 102465535 (0x4000xFFFF) | Application use |
**Initial assignment**: Port 256 (0x100) = IPv6 shim.
#### IPv6 Shim Payload Format (Port 256)
The IPv6 shim defines its own payload format with a leading format byte:
| Offset | Field | Size | Description |
| ------ | ----- | ---- | ----------- |
| 0 | format | 1 byte | Compression format (0x00 = mesh-internal compressed) |
| 1 | fields | variable | Format-specific residual fields |
**Format 0x00 — mesh-internal compressed (default):**
Strips source and destination IPv6 addresses (32 bytes) and payload length
(2 bytes) from each packet. Carries residual fields that cannot be derived
from session context:
| Offset | Field | Size | Description |
| ------ | ----- | ---- | ----------- |
| 0 | format | 1 byte | 0x00 |
| 1 | traffic_class | 1 byte | IPv6 Traffic Class (DSCP + ECN) |
| 2 | flow_label | 3 bytes | IPv6 Flow Label (20 bits, big-endian, zero-padded) |
| 5 | next_header | 1 byte | IPv6 Next Header (protocol identifier) |
| 6 | hop_limit | 1 byte | IPv6 Hop Limit |
| 7 | upper_payload | variable | Upper-layer payload (TCP, UDP, ICMPv6, etc.) |
The receiver reconstructs the full 40-byte IPv6 header from session context
(source and destination addresses derived from session npubs, version = 6,
payload length from outer packet length) plus the 6 bytes of residual fields,
then delivers the complete IPv6 packet to the TUN interface.
**Format 0x01+**: Reserved for future use (e.g., full-header gateway traffic).
**Compression savings**: 29 bytes per packet (34 bytes stripped, 7 bytes
format + residual added). Net overhead for IPv6 traffic: 77 bytes
(`FIPS_IPV6_OVERHEAD`), down from 110 bytes base DataPacket overhead.
### PathMtuNotification (0x13)
@@ -695,28 +748,39 @@ A complete picture of how application data is wrapped through each layer.
### Application Data -> Wire
Starting with an application sending a 1024-byte payload to a destination:
Starting with an IPv6 application sending a 1024-byte TCP payload to a
destination (the original IPv6 packet at the TUN is 1064 bytes: 40-byte
header + 1024-byte payload):
```text
Layer 4: Application data
1024 bytes
Layer 5: Application data
1024 bytes (TCP payload inside 1064-byte IPv6 packet)
Layer 4: IPv6 shim compression (port 256)
Strip IPv6 addresses (32) + payload length (2), keep residual fields
format (1) + residual (6) + upper payload (1024) = 1031 bytes
Layer 3: Session encryption (FSP)
FSP header (12 bytes) + AEAD(inner_hdr (6) + payload (1024)) + AEAD tag (16)
= 1058 bytes
FSP header (12) + AEAD(inner_hdr (6) + port_hdr (4) + shim (1031)) + tag (16)
= 12 + 1041 + 16 = 1069 bytes
Layer 2: SessionDatagram envelope (FMP routing)
msg_type (1) + ttl (1) + path_mtu (2) + src_addr (16) + dest_addr (16) + payload (1058)
= 1094 bytes
msg_type (1) + ttl (1) + path_mtu (2) + src_addr (16) + dest_addr (16) + payload (1069)
= 1105 bytes
Layer 1: Link encryption (FMP per-hop)
outer header (16) + encrypted(inner_hdr (5) + datagram (1094)) + AEAD tag (16)
= 1131 bytes
outer header (16) + encrypted(inner_hdr (5) + datagram (1105)) + AEAD tag (16)
= 1142 bytes
Layer 0: Transport
UDP datagram containing 1131 bytes
UDP datagram containing 1142 bytes
```
Total overhead for IPv6 traffic: 1142 1064 = 78 bytes per packet. The
difference from the `FIPS_IPV6_OVERHEAD` constant (77 bytes) is the 1-byte
FMP `msg_type` counted in the link inner header rather than the
SessionDatagram body.
### Overhead Budget
| Layer | Overhead | Component |
@@ -726,7 +790,11 @@ Layer 0: Transport
| FSP header | 12 bytes | 4 prefix + 8 counter |
| FSP inner header | 6 bytes | 4 timestamp + 1 msg_type + 1 inner_flags (inside AEAD) |
| Session AEAD tag | 16 bytes | Poly1305 tag on session-encrypted payload |
| **Data path total** | **106 bytes** | `FIPS_OVERHEAD` constant |
| **Protocol envelope** | **106 bytes** | `FIPS_OVERHEAD` constant |
| Port header | 4 bytes | src_port + dst_port (DataPacket only) |
| **DataPacket total** | **110 bytes** | Base overhead for any port-multiplexed service |
| IPv6 compression | 33 bytes | 40-byte IPv6 header → 7-byte format + residual |
| **IPv6 data path total** | **77 bytes** | `FIPS_IPV6_OVERHEAD` constant |
### At Each Transit Node
@@ -784,8 +852,8 @@ endpoint session keys).
| SessionSetup | ~170 bytes | Depth-dependent (XK msg1 = 33 bytes) |
| SessionAck | ~190 bytes | Depth-dependent, carries both endpoints' coords (XK msg2 = 57 bytes) |
| SessionMsg3 | ~80 bytes | Fixed (XK msg3 = 73 bytes, no coords) |
| Data (minimal) | 12 + 6 + payload + 16 bytes | Steady state |
| Data (with coords) | 12 + ~130 + 6 + payload + 16 bytes | Warmup/recovery |
| Data (minimal) | 12 + 6 + 4 + payload + 16 bytes | Steady state (port header included) |
| Data (with coords) | 12 + ~130 + 6 + 4 + payload + 16 bytes | Warmup/recovery (port header included) |
| SenderReport | 12 + 6 + 46 + 16 bytes | MMP metrics |
| ReceiverReport | 12 + 6 + 66 + 16 bytes | MMP metrics |
| PathMtuNotification | 12 + 6 + 2 + 16 bytes | MTU signal |
@@ -799,8 +867,9 @@ endpoint session keys).
| Scenario | Wire Size | Notes |
| -------- | --------- | ----- |
| Encrypted frame minimum | 37 bytes | Empty body |
| SessionDatagram + Data (minimal) | 37 + 35 + 12 + 6 + payload + 16 | 106 + payload |
| SessionDatagram + Data (with coords) | 106 + coords + payload | Coords vary with tree depth |
| SessionDatagram + Data (minimal) | 37 + 35 + 12 + 6 + 4 + payload + 16 | 110 + payload (any service) |
| SessionDatagram + IPv6 Data (minimal) | 110 + 7 + upper_payload 34 | 77 + IPv6 payload (compressed) |
| SessionDatagram + Data (with coords) | 110 + coords + payload | Coords vary with tree depth |
| SessionDatagram + SessionSetup | ~275 bytes | Depth-3, both dirs |
| SessionDatagram + CoordsRequired | 37 + 36 + 38 = 111 bytes | Including link overhead |

View File

@@ -10,7 +10,7 @@ use crate::node::session_wire::{
build_fsp_header, fsp_prepend_inner_header, fsp_strip_inner_header,
parse_encrypted_coords, FspCommonPrefix, FspEncryptedHeader, 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_PHASE_MSG2, FSP_PHASE_MSG3, FSP_PORT_HEADER_SIZE, FSP_PORT_IPV6_SHIM,
};
use crate::protocol::{coords_wire_size, encode_coords};
use crate::upper::icmp::FIPS_OVERHEAD;
@@ -271,23 +271,55 @@ impl Node {
// Dispatch by msg_type
match SessionMessageType::from_byte(msg_type) {
Some(SessionMessageType::DataPacket) => {
// msg_type 0x10: deliver rest (IPv6 payload) to TUN
let mut packet = rest.to_vec();
// msg_type 0x10: port-multiplexed service dispatch
if rest.len() < FSP_PORT_HEADER_SIZE {
debug!(len = rest.len(), "DataPacket too short for port header");
return;
}
let dst_port = u16::from_le_bytes([rest[2], rest[3]]);
let service_payload = &rest[FSP_PORT_HEADER_SIZE..];
match dst_port {
FSP_PORT_IPV6_SHIM => {
use crate::FipsAddress;
let src_ipv6 = FipsAddress::from_node_addr(src_addr).to_ipv6().octets();
let dst_ipv6 = FipsAddress::from_node_addr(self.node_addr()).to_ipv6().octets();
match crate::upper::ipv6_shim::decompress_ipv6(service_payload, src_ipv6, dst_ipv6) {
Some(mut packet) => {
if ce_flag {
mark_ipv6_ecn_ce(&mut packet);
self.stats_mut().congestion.record_ce_received();
}
if let Some(tun_tx) = &self.tun_tx {
if let Err(e) = tun_tx.send(packet) {
debug!(error = %e, "Failed to deliver decrypted packet to TUN");
debug!(error = %e, "Failed to deliver decompressed IPv6 packet to TUN");
}
} else {
trace!(
src = %self.peer_display_name(src_addr),
"DataPacket decrypted (no TUN interface, plaintext dropped)"
"IPv6 shim packet decompressed (no TUN interface)"
);
}
}
None => {
debug!(
src = %self.peer_display_name(src_addr),
len = service_payload.len(),
"IPv6 shim decompression failed"
);
}
}
}
_ => {
debug!(
src = %self.peer_display_name(src_addr),
dst_port,
"Unknown FSP service port, dropping DataPacket"
);
}
}
}
Some(SessionMessageType::SenderReport) => {
self.handle_session_sender_report(src_addr, rest);
}
@@ -1118,10 +1150,16 @@ impl Node {
/// Uses the FSP pipeline: builds a 12-byte cleartext header (used as AAD),
/// prepends the 6-byte inner header to the plaintext, encrypts with AAD,
/// optionally inserts cleartext coords, and wraps in a SessionDatagram.
///
/// The `src_port` and `dst_port` identify the service. A 4-byte port header
/// `[src_port:2 LE][dst_port:2 LE]` is prepended to `payload` inside the
/// AEAD envelope. The receiver dispatches by `dst_port`.
pub(in crate::node) async fn send_session_data(
&mut self,
dest_addr: &NodeAddr,
plaintext: &[u8],
src_port: u16,
dst_port: u16,
payload: &[u8],
) -> Result<(), NodeError> {
let now_ms = Self::now_ms();
@@ -1140,10 +1178,16 @@ impl Node {
});
}
// Build port-prefixed plaintext: [src_port:2 LE][dst_port:2 LE][payload...]
let mut port_payload = Vec::with_capacity(FSP_PORT_HEADER_SIZE + payload.len());
port_payload.extend_from_slice(&src_port.to_le_bytes());
port_payload.extend_from_slice(&dst_port.to_le_bytes());
port_payload.extend_from_slice(payload);
// Build inner plaintext (doesn't depend on counter)
let msg_type = SessionMessageType::DataPacket.to_byte(); // 0x10
let inner_flags = FspInnerFlags { spin_bit }.to_byte();
let inner_plaintext = fsp_prepend_inner_header(timestamp, msg_type, inner_flags, plaintext);
let inner_plaintext = fsp_prepend_inner_header(timestamp, msg_type, inner_flags, &port_payload);
// Determine whether coords fit within transport MTU.
// If not, send standalone CoordsWarmup before the data packet.
@@ -1151,7 +1195,7 @@ impl Node {
let src = self.tree_state.my_coords().clone();
let dst = self.get_dest_coords(dest_addr);
let coords_size = coords_wire_size(&src) + coords_wire_size(&dst);
let total_wire = FIPS_OVERHEAD as usize + coords_size + plaintext.len();
let total_wire = FIPS_OVERHEAD as usize + FSP_PORT_HEADER_SIZE + coords_size + payload.len();
if total_wire <= self.transport_mtu() as usize {
(true, Some(src), Some(dst))
} else {
@@ -1226,7 +1270,7 @@ impl Node {
// Re-borrow after send (which borrowed &mut self)
if let Some(entry) = self.sessions.get_mut(dest_addr) {
entry.record_sent(plaintext.len());
entry.record_sent(payload.len());
if let Some(mmp) = entry.mmp_mut() {
mmp.sender.record_sent(counter, timestamp, ciphertext.len());
}
@@ -1236,6 +1280,24 @@ impl Node {
Ok(())
}
/// Send an IPv6 packet through the IPv6 shim (port 256) with header compression.
///
/// Compresses the IPv6 header (format 0x00), then sends via `send_session_data`
/// with `src_port=256, dst_port=256`.
pub(in crate::node) async fn send_ipv6_packet(
&mut self,
dest_addr: &NodeAddr,
ipv6_packet: &[u8],
) -> Result<(), NodeError> {
let compressed = crate::upper::ipv6_shim::compress_ipv6(ipv6_packet)
.ok_or_else(|| NodeError::SendFailed {
node_addr: *dest_addr,
reason: "IPv6 header compression failed".into(),
})?;
self.send_session_data(dest_addr, FSP_PORT_IPV6_SHIM, FSP_PORT_IPV6_SHIM, &compressed)
.await
}
/// Send a non-data session message (reports, notifications) over an established session.
///
/// Similar to `send_session_data()` but:
@@ -1525,7 +1587,7 @@ impl Node {
return;
}
}
if let Err(e) = self.send_session_data(&dest_addr, &ipv6_packet).await {
if let Err(e) = self.send_ipv6_packet(&dest_addr, &ipv6_packet).await {
debug!(dest = %self.peer_display_name(&dest_addr), error = %e, "Failed to send TUN packet via session");
}
return;
@@ -1634,7 +1696,7 @@ impl Node {
None => return,
};
for packet in packets {
if let Err(e) = self.send_session_data(dest_addr, &packet).await {
if let Err(e) = self.send_ipv6_packet(dest_addr, &packet).await {
debug!(dest = %self.peer_display_name(dest_addr), error = %e, "Failed to send queued TUN packet");
break;
}

View File

@@ -11,6 +11,17 @@
//! [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 |
@@ -58,6 +69,14 @@ 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.

View File

@@ -243,7 +243,7 @@ async fn test_session_direct_peer_data_transfer() {
let test_data = b"Hello, FIPS session!";
nodes[0]
.node
.send_session_data(&node1_addr, test_data)
.send_session_data(&node1_addr, 0, 0, test_data)
.await
.expect("send_session_data failed");
@@ -378,7 +378,7 @@ async fn test_session_3node_forwarded_data() {
let test_data = b"End-to-end through transit node B";
nodes[0]
.node
.send_session_data(&node2_addr, test_data)
.send_session_data(&node2_addr, 0, 0, test_data)
.await
.expect("send_session_data failed");
@@ -438,7 +438,7 @@ async fn test_session_send_data_no_session_fails() {
let mut node = make_node();
let fake_addr = make_node_addr(0xAA);
let result = node.send_session_data(&fake_addr, b"test").await;
let result = node.send_session_data(&fake_addr, 0, 0, b"test").await;
assert!(result.is_err(), "Should fail with no session");
}
@@ -618,11 +618,16 @@ async fn test_session_100_nodes() {
let dest_addr = all_info[dst].0;
let src_addr = all_info[src].0;
// Build IPv6 packets with pair index as payload
let src_fips = crate::FipsAddress::from_node_addr(&src_addr);
let dst_fips = crate::FipsAddress::from_node_addr(&dest_addr);
// Forward: initiator → responder
let fwd_payload = format!("fwd-{}", pair_idx).into_bytes();
let fwd_ipv6 = build_ipv6_packet(&src_fips, &dst_fips, &fwd_payload);
match nodes[src]
.node
.send_session_data(&dest_addr, &fwd_payload)
.send_ipv6_packet(&dest_addr, &fwd_ipv6)
.await
{
Ok(()) => send_forward_ok += 1,
@@ -634,9 +639,10 @@ async fn test_session_100_nodes() {
// Reverse: responder → initiator
// (Responder should already be Established after XK msg3)
let rev_payload = format!("rev-{}", pair_idx).into_bytes();
let rev_ipv6 = build_ipv6_packet(&dst_fips, &src_fips, &rev_payload);
match nodes[dst]
.node
.send_session_data(&src_addr, &rev_payload)
.send_ipv6_packet(&src_addr, &rev_ipv6)
.await
{
Ok(()) => send_reverse_ok += 1,
@@ -671,13 +677,21 @@ async fn test_session_100_nodes() {
let fwd_payload = format!("fwd-{}", pair_idx).into_bytes();
let rev_payload = format!("rev-{}", pair_idx).into_bytes();
if delivered_per_node[dst].contains(&fwd_payload) {
// After decompression, TUN receives full IPv6 packets.
// Check that delivered packet's upper-layer payload matches.
let fwd_found = delivered_per_node[dst]
.iter()
.any(|pkt| pkt.len() >= 40 && pkt[40..] == fwd_payload);
if fwd_found {
fwd_delivered += 1;
} else if fwd_missing.len() < 20 {
fwd_missing.push((src, dst));
}
if delivered_per_node[src].contains(&rev_payload) {
let rev_found = delivered_per_node[src]
.iter()
.any(|pkt| pkt.len() >= 40 && pkt[40..] == rev_payload);
if rev_found {
rev_delivered += 1;
} else if rev_missing.len() < 20 {
rev_missing.push((src, dst));
@@ -1836,7 +1850,7 @@ async fn test_multihop_pmtud_heterogeneous_mtu() {
// With coords (~66 extra), the wire could exceed B's recv buffer.
for _ in 0..5 {
let small = build_ipv6_packet(&src_fips, &dst_fips, &[0u8; 10]);
nodes[0].node.send_session_data(&node2_addr, &small).await.unwrap();
nodes[0].node.send_ipv6_packet(&node2_addr, &small).await.unwrap();
}
drain_to_quiescence(&mut nodes).await;
@@ -1855,7 +1869,7 @@ async fn test_multihop_pmtud_heterogeneous_mtu() {
// Send the oversized packet — B should fail to forward and send
// MtuExceeded signal back.
nodes[0].node.send_session_data(&node2_addr, &ipv6_packet).await.unwrap();
nodes[0].node.send_ipv6_packet(&node2_addr, &ipv6_packet).await.unwrap();
drain_to_quiescence(&mut nodes).await;
// Verify PathMtuState was updated on A

View File

@@ -26,7 +26,8 @@ pub enum SessionMessageType {
SessionAck = 0x01,
// Data and metrics (0x10-0x1F) — encrypted, inner header msg_type
/// Encrypted IPv6 datagram payload (TUN delivery).
/// Port-multiplexed service payload: `[src_port:2 LE][dst_port:2 LE][service data...]`.
/// Port 256 = IPv6 shim (compressed header). Receiver dispatches by dst_port.
DataPacket = 0x10,
/// MMP sender report (metrics from sender to receiver).
SenderReport = 0x11,

View File

@@ -59,12 +59,13 @@ const ICMPV6_HEADER_LEN: usize = 8;
/// Maximum original packet bytes to include in ICMPv6 error.
const MAX_ORIGINAL_PACKET: usize = MIN_IPV6_MTU - IPV6_HEADER_LEN - ICMPV6_HEADER_LEN;
/// FIPS encapsulation overhead for data packets (no piggybacked coordinates).
/// FIPS base encapsulation overhead for DataPacket (excluding port payload).
///
/// This is the fixed overhead for a SessionDatagram carrying an FSP DataPacket,
/// used to compute `effective_ipv6_mtu()` and TCP MSS clamping. Coordinate
/// piggybacking (CP flag) adds variable overhead on top of this; the send path
/// guards against exceeding the transport MTU when coords are included.
/// used by the send path's CP-flag guard to check whether piggybacked coords
/// fit within the transport MTU. For IPv6 effective MTU calculations, use
/// [`FIPS_IPV6_OVERHEAD`] which accounts for port multiplexing and header
/// compression.
///
/// Breakdown (traced through the actual send path):
///
@@ -89,13 +90,26 @@ const MAX_ORIGINAL_PACKET: usize = MIN_IPV6_MTU - IPV6_HEADER_LEN - ICMPV6_HEADE
/// body after msg_type is consumed by the dispatch layer.
pub const FIPS_OVERHEAD: u16 = 16 + 16 + 5 + 35 + 12 + 6 + 16; // 106 bytes
/// FIPS encapsulation overhead for compressed IPv6 shim traffic (port 256).
///
/// With port multiplexing (4 bytes) and IPv6 header compression (format byte +
/// 6 residual bytes, stripping 34 bytes of addresses/version/payload length),
/// the net overhead for IPv6 packets is 77 bytes.
///
/// ```text
/// Wire size = FIPS_OVERHEAD(106) + port_header(4) + format(1) + residual(6) + upper_payload
/// = 117 + (ipv6_len - 40)
/// = ipv6_len + 77
/// ```
pub const FIPS_IPV6_OVERHEAD: u16 = 77;
/// Calculate the effective IPv6 MTU for FIPS-encapsulated traffic.
///
/// Given a transport MTU (e.g., UDP payload size), returns the maximum
/// IPv6 packet size (including IPv6 header) that can be transmitted
/// through the FIPS mesh after encapsulation overhead.
/// through the FIPS mesh after IPv6 header compression.
pub fn effective_ipv6_mtu(transport_mtu: u16) -> u16 {
transport_mtu.saturating_sub(FIPS_OVERHEAD)
transport_mtu.saturating_sub(FIPS_IPV6_OVERHEAD)
}
/// Check if we should send an ICMPv6 error for this packet.

360
src/upper/ipv6_shim.rs Normal file
View File

@@ -0,0 +1,360 @@
//! IPv6 Header Compression for the FIPS IPv6 Shim (FSP Port 256)
//!
//! Compresses and decompresses IPv6 headers for mesh-internal traffic.
//! Source and destination addresses are stripped (derivable from session
//! context), along with version and payload length. Residual fields
//! (traffic class, flow label, next header, hop limit) are preserved.
//!
//! ## Compressed Format (format 0x00)
//!
//! ```text
//! [format:1][ver_tc_flow:4][next_header:1][hop_limit:1][upper_layer_payload...]
//! ```
//!
//! The `ver_tc_flow` field stores the original IPv6 bytes 0-3 verbatim
//! (including the version nibble). On decompression, the version nibble
//! is forced to 6, payload length is computed from the remaining data,
//! and source/destination addresses are reconstructed from session context.
/// Compressed format byte for mesh-internal traffic.
pub const IPV6_SHIM_FORMAT_COMPRESSED: u8 = 0x00;
/// Size of the compressed residual fields (ver_tc_flow + next_header + hop_limit).
const IPV6_SHIM_RESIDUAL_SIZE: usize = 6;
/// IPv6 header size.
const IPV6_HEADER_SIZE: usize = 40;
/// Compress an IPv6 packet for the shim.
///
/// Strips source/destination addresses (32 bytes) and payload length (2 bytes).
/// Preserves traffic class, flow label, next header, and hop limit as residual
/// fields.
///
/// Returns `None` if the packet is not a valid IPv6 packet (too short or wrong
/// version).
pub fn compress_ipv6(ipv6_packet: &[u8]) -> Option<Vec<u8>> {
if ipv6_packet.len() < IPV6_HEADER_SIZE || ipv6_packet[0] >> 4 != 6 {
return None;
}
let upper_payload = &ipv6_packet[IPV6_HEADER_SIZE..];
let mut out = Vec::with_capacity(1 + IPV6_SHIM_RESIDUAL_SIZE + upper_payload.len());
// Format byte
out.push(IPV6_SHIM_FORMAT_COMPRESSED);
// Residual: bytes 0-3 of IPv6 header (version + TC + flow label)
out.extend_from_slice(&ipv6_packet[0..4]);
// Residual: next header and hop limit
out.push(ipv6_packet[6]); // next_header
out.push(ipv6_packet[7]); // hop_limit
// Upper-layer payload (everything after the 40-byte IPv6 header)
out.extend_from_slice(upper_payload);
Some(out)
}
/// Decompress a shim payload back to a full IPv6 packet.
///
/// Reconstructs the full 40-byte IPv6 header from the residual fields and
/// session context (source/destination addresses). The payload length field
/// is computed from the remaining data length.
///
/// Returns `None` if the format byte is unrecognized or the payload is too
/// short.
pub fn decompress_ipv6(
shim_payload: &[u8],
src_ipv6: [u8; 16],
dst_ipv6: [u8; 16],
) -> Option<Vec<u8>> {
if shim_payload.len() < 1 + IPV6_SHIM_RESIDUAL_SIZE {
return None;
}
let format = shim_payload[0];
if format != IPV6_SHIM_FORMAT_COMPRESSED {
return None;
}
let residual = &shim_payload[1..1 + IPV6_SHIM_RESIDUAL_SIZE];
let upper_payload = &shim_payload[1 + IPV6_SHIM_RESIDUAL_SIZE..];
let upper_len = upper_payload.len();
let mut ipv6 = Vec::with_capacity(IPV6_HEADER_SIZE + upper_len);
// Bytes 0-3: restore version nibble to 6
ipv6.push((residual[0] & 0x0F) | 0x60);
ipv6.extend_from_slice(&residual[1..4]);
// Bytes 4-5: payload length (big-endian)
ipv6.extend_from_slice(&(upper_len as u16).to_be_bytes());
// Byte 6: next header
ipv6.push(residual[4]);
// Byte 7: hop limit
ipv6.push(residual[5]);
// Bytes 8-23: source address
ipv6.extend_from_slice(&src_ipv6);
// Bytes 24-39: destination address
ipv6.extend_from_slice(&dst_ipv6);
// Upper-layer payload
ipv6.extend_from_slice(upper_payload);
Some(ipv6)
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a minimal valid IPv6 packet with the given fields and payload.
fn build_ipv6_packet(
traffic_class: u8,
flow_label: u32,
next_header: u8,
hop_limit: u8,
src: [u8; 16],
dst: [u8; 16],
payload: &[u8],
) -> Vec<u8> {
let mut pkt = Vec::with_capacity(IPV6_HEADER_SIZE + payload.len());
// Byte 0: version(4) | TC high nibble(4)
pkt.push(0x60 | (traffic_class >> 4));
// Byte 1: TC low nibble(4) | flow label high nibble(4)
pkt.push((traffic_class << 4) | ((flow_label >> 16) as u8 & 0x0F));
// Bytes 2-3: flow label low 16 bits
pkt.push((flow_label >> 8) as u8);
pkt.push(flow_label as u8);
// Bytes 4-5: payload length
pkt.extend_from_slice(&(payload.len() as u16).to_be_bytes());
// Byte 6: next header
pkt.push(next_header);
// Byte 7: hop limit
pkt.push(hop_limit);
// Bytes 8-23: source address
pkt.extend_from_slice(&src);
// Bytes 24-39: destination address
pkt.extend_from_slice(&dst);
// Payload
pkt.extend_from_slice(payload);
pkt
}
fn sample_src() -> [u8; 16] {
[0xfd, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f]
}
fn sample_dst() -> [u8; 16] {
[0xfd, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17,
0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f]
}
// ===== Round-trip fidelity =====
#[test]
fn test_compress_decompress_roundtrip() {
let payload = vec![0xAA; 100];
let pkt = build_ipv6_packet(0, 0, 17, 64, sample_src(), sample_dst(), &payload);
let compressed = compress_ipv6(&pkt).unwrap();
let decompressed = decompress_ipv6(&compressed, sample_src(), sample_dst()).unwrap();
assert_eq!(decompressed, pkt);
}
#[test]
fn test_roundtrip_empty_payload() {
let pkt = build_ipv6_packet(0, 0, 59, 1, sample_src(), sample_dst(), &[]);
let compressed = compress_ipv6(&pkt).unwrap();
assert_eq!(compressed.len(), 1 + IPV6_SHIM_RESIDUAL_SIZE); // format + residual only
let decompressed = decompress_ipv6(&compressed, sample_src(), sample_dst()).unwrap();
assert_eq!(decompressed, pkt);
}
#[test]
fn test_roundtrip_large_payload() {
let payload = vec![0x55; 1400];
let pkt = build_ipv6_packet(0, 0, 6, 128, sample_src(), sample_dst(), &payload);
let compressed = compress_ipv6(&pkt).unwrap();
let decompressed = decompress_ipv6(&compressed, sample_src(), sample_dst()).unwrap();
assert_eq!(decompressed, pkt);
}
// ===== Field preservation =====
#[test]
fn test_preserves_traffic_class() {
// TC = 0xAB (DSCP=0x2A, ECN=0x03)
let pkt = build_ipv6_packet(0xAB, 0, 17, 64, sample_src(), sample_dst(), &[1, 2, 3]);
let compressed = compress_ipv6(&pkt).unwrap();
let decompressed = decompress_ipv6(&compressed, sample_src(), sample_dst()).unwrap();
assert_eq!(decompressed, pkt);
// Verify TC is in the right position
let tc = ((decompressed[0] & 0x0F) << 4) | (decompressed[1] >> 4);
assert_eq!(tc, 0xAB);
}
#[test]
fn test_preserves_flow_label() {
let pkt = build_ipv6_packet(0, 0xFEDCB, 17, 64, sample_src(), sample_dst(), &[1]);
let compressed = compress_ipv6(&pkt).unwrap();
let decompressed = decompress_ipv6(&compressed, sample_src(), sample_dst()).unwrap();
assert_eq!(decompressed, pkt);
}
#[test]
fn test_preserves_tc_and_flow_label_combined() {
// TC=0xFF, flow_label=0xFFFFF (maximum values)
let pkt = build_ipv6_packet(0xFF, 0xFFFFF, 17, 64, sample_src(), sample_dst(), &[1]);
let compressed = compress_ipv6(&pkt).unwrap();
let decompressed = decompress_ipv6(&compressed, sample_src(), sample_dst()).unwrap();
assert_eq!(decompressed, pkt);
}
#[test]
fn test_preserves_next_header_tcp() {
let pkt = build_ipv6_packet(0, 0, 6, 64, sample_src(), sample_dst(), &[0; 20]);
let compressed = compress_ipv6(&pkt).unwrap();
let decompressed = decompress_ipv6(&compressed, sample_src(), sample_dst()).unwrap();
assert_eq!(decompressed[6], 6); // TCP
}
#[test]
fn test_preserves_next_header_icmpv6() {
let pkt = build_ipv6_packet(0, 0, 58, 255, sample_src(), sample_dst(), &[0; 8]);
let compressed = compress_ipv6(&pkt).unwrap();
let decompressed = decompress_ipv6(&compressed, sample_src(), sample_dst()).unwrap();
assert_eq!(decompressed[6], 58); // ICMPv6
assert_eq!(decompressed[7], 255); // hop limit
}
#[test]
fn test_preserves_hop_limit() {
for hop_limit in [0, 1, 64, 128, 255] {
let pkt = build_ipv6_packet(0, 0, 17, hop_limit, sample_src(), sample_dst(), &[1]);
let compressed = compress_ipv6(&pkt).unwrap();
let decompressed = decompress_ipv6(&compressed, sample_src(), sample_dst()).unwrap();
assert_eq!(decompressed[7], hop_limit);
}
}
// ===== Payload length reconstruction =====
#[test]
fn test_payload_length_reconstructed() {
let payload = vec![0xBB; 256];
let pkt = build_ipv6_packet(0, 0, 17, 64, sample_src(), sample_dst(), &payload);
let compressed = compress_ipv6(&pkt).unwrap();
let decompressed = decompress_ipv6(&compressed, sample_src(), sample_dst()).unwrap();
let payload_len = u16::from_be_bytes([decompressed[4], decompressed[5]]);
assert_eq!(payload_len, 256);
}
// ===== Compression size savings =====
#[test]
fn test_compression_saves_bytes() {
let payload = vec![0; 100];
let pkt = build_ipv6_packet(0, 0, 17, 64, sample_src(), sample_dst(), &payload);
let compressed = compress_ipv6(&pkt).unwrap();
// Original: 40 header + 100 payload = 140
// Compressed: 1 format + 6 residual + 100 payload = 107
// Savings: 33 bytes (version nibble kept in residual, so 34 - 1 = 33)
assert_eq!(pkt.len(), 140);
assert_eq!(compressed.len(), 107);
assert_eq!(pkt.len() - compressed.len(), 33);
}
// ===== Error cases =====
#[test]
fn test_compress_rejects_non_ipv6() {
let mut pkt = build_ipv6_packet(0, 0, 17, 64, sample_src(), sample_dst(), &[1]);
pkt[0] = 0x40; // version 4 (IPv4)
assert!(compress_ipv6(&pkt).is_none());
}
#[test]
fn test_compress_rejects_short_packet() {
assert!(compress_ipv6(&[0x60; 39]).is_none());
assert!(compress_ipv6(&[]).is_none());
}
#[test]
fn test_decompress_rejects_unknown_format() {
let mut compressed = vec![0x01]; // format 0x01 = unknown
compressed.extend_from_slice(&[0; IPV6_SHIM_RESIDUAL_SIZE]);
assert!(decompress_ipv6(&compressed, sample_src(), sample_dst()).is_none());
}
#[test]
fn test_decompress_rejects_short_payload() {
// Needs at least 1 (format) + 6 (residual) = 7 bytes
assert!(decompress_ipv6(&[0x00; 6], sample_src(), sample_dst()).is_none());
assert!(decompress_ipv6(&[], sample_src(), sample_dst()).is_none());
}
// ===== Address reconstruction =====
#[test]
fn test_addresses_from_context() {
let original_src = [0xfd, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA,
0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA];
let original_dst = [0xfd, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB,
0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB];
let pkt = build_ipv6_packet(0, 0, 17, 64, original_src, original_dst, &[1, 2]);
let compressed = compress_ipv6(&pkt).unwrap();
// Decompress with different addresses (simulating session context)
let context_src = sample_src();
let context_dst = sample_dst();
let decompressed = decompress_ipv6(&compressed, context_src, context_dst).unwrap();
// Addresses come from context, not original packet
assert_eq!(&decompressed[8..24], &context_src);
assert_eq!(&decompressed[24..40], &context_dst);
// But TC, flow label, next header, hop limit, payload match original
assert_eq!(&decompressed[0..4], &pkt[0..4]); // ver+TC+flow
assert_eq!(decompressed[6], pkt[6]); // next_header
assert_eq!(decompressed[7], pkt[7]); // hop_limit
assert_eq!(&decompressed[40..], &pkt[40..]); // payload
}
}

View File

@@ -10,5 +10,6 @@ pub mod dns;
pub mod hosts;
pub mod icmp;
pub mod icmp_rate_limit;
pub mod ipv6_shim;
pub mod tcp_mss;
pub mod tun;