diff --git a/docs/design/fips-intro.md b/docs/design/fips-intro.md index b325ff1..86c68e4 100644 --- a/docs/design/fips-intro.md +++ b/docs/design/fips-intro.md @@ -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) diff --git a/docs/design/fips-ipv6-adapter.md b/docs/design/fips-ipv6-adapter.md index 66351d3..6c6b49e 100644 --- a/docs/design/fips-ipv6-adapter.md +++ b/docs/design/fips-ipv6-adapter.md @@ -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** | diff --git a/docs/design/fips-mesh-operation.md b/docs/design/fips-mesh-operation.md index 395d0d7..0be9b89 100644 --- a/docs/design/fips-mesh-operation.md +++ b/docs/design/fips-mesh-operation.md @@ -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 | ~232–402 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) | diff --git a/docs/design/fips-session-layer.md b/docs/design/fips-session-layer.md index ab9e015..7892032 100644 --- a/docs/design/fips-session-layer.md +++ b/docs/design/fips-session-layer.md @@ -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** | diff --git a/docs/design/fips-wire-formats.md b/docs/design/fips-wire-formats.md index 3e3c0d4..1aa39b0 100644 --- a/docs/design/fips-wire-formats.md +++ b/docs/design/fips-wire-formats.md @@ -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 | +| ----- | ------- | +| 0–255 (0x00–0xFF) | Reserved, protocol use | +| 256–1023 (0x100–0x3FF) | Reserved, FIPS standard services | +| 1024–65535 (0x400–0xFFFF) | 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 | diff --git a/src/node/handlers/session.rs b/src/node/handlers/session.rs index 41c5669..c2c6700 100644 --- a/src/node/handlers/session.rs +++ b/src/node/handlers/session.rs @@ -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,21 +271,53 @@ 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(); - if ce_flag { - mark_ipv6_ecn_ce(&mut packet); - self.stats_mut().congestion.record_ce_received(); + // 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; } - 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"); + 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 decompressed IPv6 packet to TUN"); + } + } else { + trace!( + src = %self.peer_display_name(src_addr), + "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" + ); } - } else { - trace!( - src = %self.peer_display_name(src_addr), - "DataPacket decrypted (no TUN interface, plaintext dropped)" - ); } } Some(SessionMessageType::SenderReport) => { @@ -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; } diff --git a/src/node/session_wire.rs b/src/node/session_wire.rs index 1ddf5ca..7ba66ac 100644 --- a/src/node/session_wire.rs +++ b/src/node/session_wire.rs @@ -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. diff --git a/src/node/tests/session.rs b/src/node/tests/session.rs index a2acf67..d3f3cf0 100644 --- a/src/node/tests/session.rs +++ b/src/node/tests/session.rs @@ -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 diff --git a/src/protocol/session.rs b/src/protocol/session.rs index fbfcfcf..d71a82a 100644 --- a/src/protocol/session.rs +++ b/src/protocol/session.rs @@ -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, diff --git a/src/upper/icmp.rs b/src/upper/icmp.rs index 99875cc..0c6909d 100644 --- a/src/upper/icmp.rs +++ b/src/upper/icmp.rs @@ -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. diff --git a/src/upper/ipv6_shim.rs b/src/upper/ipv6_shim.rs new file mode 100644 index 0000000..e892f73 --- /dev/null +++ b/src/upper/ipv6_shim.rs @@ -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> { + 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> { + 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 { + 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 + } +} diff --git a/src/upper/mod.rs b/src/upper/mod.rs index ba8814b..99488a6 100644 --- a/src/upper/mod.rs +++ b/src/upper/mod.rs @@ -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;