diff --git a/docs/design/fips-configuration.md b/docs/design/fips-configuration.md index 91e8ae0..4b4cfec 100644 --- a/docs/design/fips-configuration.md +++ b/docs/design/fips-configuration.md @@ -148,7 +148,7 @@ Controls end-to-end session behavior and packet queuing. | `node.session.pending_packets_per_dest` | usize | `16` | Queue depth per destination during session establishment | | `node.session.pending_max_destinations` | usize | `256` | Max destinations with pending packets | | `node.session.idle_timeout_secs` | u64 | `90` | Idle session timeout; established sessions with no activity for this duration are removed | -| `node.session.coords_warmup_packets` | u8 | `5` | Number of initial DataPackets per session that include COORDS_PRESENT for transit cache warmup; also the reset count on CoordsRequired receipt | +| `node.session.coords_warmup_packets` | u8 | `5` | Number of initial data packets per session that include the CP flag for transit cache warmup; also the reset count on CoordsRequired receipt | The anti-replay window size (2048 packets) is a compile-time constant and not configurable. diff --git a/docs/design/fips-ipv6-adapter.md b/docs/design/fips-ipv6-adapter.md index 10f3687..e4af3a4 100644 --- a/docs/design/fips-ipv6-adapter.md +++ b/docs/design/fips-ipv6-adapter.md @@ -121,13 +121,14 @@ after all layers of wrapping. | ----- | -------- | ------- | | Link encryption | 37 bytes | 16-byte outer header + 5-byte inner header + 16-byte AEAD tag | | SessionDatagram envelope | 36 bytes | type + ttl + path_mtu + src_addr + dest_addr | -| DataPacket header | 12 bytes | type + flags + counter + payload_len | -| Session encryption | 16 bytes | ChaCha20-Poly1305 AEAD tag | -| **Minimal total** | **101 bytes** | | +| 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 | +| **Minimal total** | **107 bytes** | | | Coordinates (if present) | ~43 bytes | Depth-dependent, first few packets only | -| **Worst case total** | **144 bytes** | With COORDS_PRESENT for depth-3 paths | +| **Worst case total** | **150 bytes** | With CP flag set for depth-3 paths | -The `FIPS_OVERHEAD` constant (144 bytes) is used for conservative MTU +The `FIPS_OVERHEAD` constant (150 bytes) is used for conservative MTU calculations. ### Effective IPv6 MTU @@ -142,14 +143,14 @@ For typical deployments: | Transport MTU | Effective IPv6 MTU | Notes | | ------------- | ------------------ | ----- | -| 1472 (UDP/Ethernet) | 1328 | Standard deployment | -| 1280 (UDP minimum) | 1136 | Below IPv6 minimum | +| 1472 (UDP/Ethernet) | 1322 | Standard deployment | +| 1280 (UDP minimum) | 1130 | 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 + 144 = 1424 bytes +1280 + 150 = 1430 bytes ``` Transports with smaller MTUs (LoRa at ~250 bytes, serial at 256 bytes) cannot @@ -315,13 +316,13 @@ decryption (opens attack surface). Endpoint-only fragmentation (fragment before session encryption, reassemble after decryption) is a future direction that avoids these objections. Each -fragment would be independently encrypted and look like a normal DataPacket +fragment would be independently encrypted and look like a normal data packet to transit nodes. ## References - [fips-intro.md](fips-intro.md) — Protocol overview and architecture - [fips-session-layer.md](fips-session-layer.md) — FSP (below the adapter) -- [fips-wire-formats.md](fips-wire-formats.md) — DataPacket and SessionDatagram +- [fips-wire-formats.md](fips-wire-formats.md) — FSP and SessionDatagram wire formats - [fips-configuration.md](fips-configuration.md) — TUN configuration parameters diff --git a/docs/design/fips-mesh-operation.md b/docs/design/fips-mesh-operation.md index beeaaa5..c9a36b0 100644 --- a/docs/design/fips-mesh-operation.md +++ b/docs/design/fips-mesh-operation.md @@ -212,8 +212,7 @@ be made. ### Unified Cache The coordinate cache is a single unified cache. All sources — SessionSetup -transit, COORDS_PRESENT DataPackets, LookupResponse — write to the same -cache. +transit, CP-flagged data packets, LookupResponse — write to the same cache. ### Population Sources @@ -221,7 +220,7 @@ cache. | ------ | ---- | ---- | | SessionSetup transit | Session establishment | Both src and dest coordinates | | SessionAck transit | Session establishment | Responder's coordinates | -| DataPacket with COORDS_PRESENT | Warmup or recovery | Both src and dest coordinates | +| CP-flagged data packet | Warmup or recovery | Both src and dest coordinates (cleartext) | | LookupResponse | Discovery | Target's coordinates | ### Eviction @@ -312,7 +311,7 @@ Destination Unreachable. ## SessionSetup Self-Bootstrapping SessionSetup is the mechanism that warms transit node coordinate caches -along a path, enabling subsequent DataPackets to route efficiently. +along a path, enabling subsequent data packets to route efficiently. ### How It Works @@ -335,18 +334,19 @@ coordinates and warming caches in the other direction. ### Result After the handshake completes, the entire forward and reverse paths have -cached coordinates for both endpoints. Subsequent DataPackets use minimal +cached coordinates for both endpoints. Subsequent data packets use minimal headers (no coordinates) and route efficiently through the warmed caches. -## COORDS_PRESENT Warmup +## CP (Coords Present) Warmup -The COORDS_PRESENT flag on DataPackets provides a secondary cache-warming +The CP flag in the FSP common prefix provides a secondary cache-warming mechanism that complements SessionSetup. See [fips-session-layer.md](fips-session-layer.md) for the warmup-then-reactive strategy. -Transit nodes process DataPackets with COORDS_PRESENT by extracting and -caching both source and destination coordinates — the same operation +Transit nodes parse the CP flag from the FSP header and extract source and +destination coordinates from the cleartext section between the header and +ciphertext — no decryption needed. This is the same caching operation performed for SessionSetup coordinates. ## Error Recovery @@ -367,8 +367,7 @@ coordinates for the destination. It cannot make a forwarding decision. **Source recovery**: 1. Initiate discovery (LookupRequest flood) for the destination -2. Reset COORDS_PRESENT warmup counter — subsequent DataPackets include - coordinates +2. Reset CP warmup counter — subsequent data packets include coordinates 3. When discovery completes, warmup counter resets again (covers timing gap) The crypto session remains active throughout — only routing state is @@ -386,7 +385,7 @@ source. **Source recovery**: 1. Remove stale coordinates from cache 2. Initiate discovery for the destination -3. Reset COORDS_PRESENT warmup counter +3. Reset CP warmup counter ### Error Signal Rate Limiting @@ -397,11 +396,11 @@ packets to the same destination hit the same routing failure simultaneously. ### Error Routing Limitation Error signals route back to the source using `find_next_hop(src_addr)`. For -steady-state DataPackets (after the COORDS_PRESENT warmup window), the +steady-state data packets (after the CP warmup window), the transit node may lack cached coordinates for the source. If so, the error is silently dropped. -This blind spot is partially addressed by COORDS_PRESENT warmup: transit +This blind spot is partially addressed by CP warmup: transit nodes receive source coordinates during the warmup phase. But after warmup expires and transit caches for the source expire, errors may be lost. The session idle timeout (90s) limits the window — if traffic stops long enough @@ -423,8 +422,8 @@ sequence: returns the destination's coordinates 4. **Session establishment**: SessionSetup carries coordinates, warming transit caches along the path -5. **Warmup**: First N DataPackets include COORDS_PRESENT, reinforcing - transit caches +5. **Warmup**: First N data packets include CP flag, reinforcing transit + caches The first packet to a new destination always triggers this sequence. The packet is queued (bounded) until the session is established. @@ -435,7 +434,7 @@ After session establishment and warmup: - Transit nodes have cached coordinates for both endpoints - Bloom filters have converged for the destination -- DataPackets use minimal headers (no coordinates) +- Data packets use minimal headers (no coordinates) - Routing decisions are fast: bloom candidate selection + distance ranking ### Steady State @@ -445,7 +444,7 @@ In steady state, the mesh is mostly self-maintaining: - TreeAnnounce gossip keeps the spanning tree current - FilterAnnounce gossip keeps bloom filters current - Coordinate caches are refreshed by active routing traffic -- Occasional cache misses trigger COORDS_PRESENT or discovery, but these +- Occasional cache misses trigger CP warmup or discovery, but these are rare when traffic is flowing ### Cache Expiry and Recovery @@ -511,8 +510,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 | ~122 bytes | Session confirmation | Yes (routed) | -| SessionDatagram + DataPacket (minimal) | 40 bytes + payload | Bulk traffic | Yes (routed) | -| SessionDatagram + DataPacket (with coords) | ~172 bytes + payload | Warmup/recovery | Yes (routed) | +| SessionDatagram + Data (minimal) | ~107 bytes + payload | Bulk traffic | Yes (routed) | +| SessionDatagram + Data (with CP) | ~150 bytes + payload | Warmup/recovery | 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) | @@ -550,7 +549,7 @@ recovery). | Flush coord cache on parent change | **Implemented** | | LookupRequest/LookupResponse discovery | **Implemented** | | SessionSetup self-bootstrapping | **Implemented** | -| COORDS_PRESENT warmup-then-reactive | **Implemented** | +| CP warmup-then-reactive | **Implemented** | | CoordsRequired recovery | **Implemented** | | PathBroken recovery | **Implemented** | | Error signal rate limiting | **Implemented** | diff --git a/docs/design/fips-session-layer.md b/docs/design/fips-session-layer.md index b57eecb..642ea0d 100644 --- a/docs/design/fips-session-layer.md +++ b/docs/design/fips-session-layer.md @@ -125,7 +125,7 @@ and transmitted after the session is established. SessionSetup is self-bootstrapping for routing. It carries the source's and destination's tree coordinates in the clear (not inside the Noise payload). As the message transits intermediate nodes, each node caches these coordinates, -warming the path for subsequent DataPackets that carry only addresses (no +warming the path for subsequent data packets that carry only addresses (no coordinates). SessionAck carries the responder's coordinates back along the reverse path, @@ -145,12 +145,14 @@ This ensures exactly one handshake completes. ### Data Transfer -Once established, sessions carry DataPacket messages containing encrypted -application data. Each DataPacket includes: +Once established, sessions carry encrypted data using the FSP pipeline. Each +encrypted message includes: -- An explicit 8-byte counter for replay protection (used as the AEAD nonce) -- A flags byte (including COORDS_PRESENT for cache warming) -- The encrypted payload +- A 12-byte cleartext header (used as AEAD AAD) containing the counter and + 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 ### Session Idle Timeout @@ -239,9 +241,9 @@ discarded after the handshake. ## Replay Protection FSP uses explicit 8-byte counters on the wire for replay protection. Each side -maintains a monotonically increasing send counter, transmitted with every -DataPacket. The receiver maintains a sliding window (2048-entry bitmap) -tracking which counters have been seen. +maintains a monotonically increasing send counter, included in the 12-byte +cleartext header of every encrypted message. The receiver maintains a sliding +window (2048-entry bitmap) tracking which counters have been seen. This design is critical for operation over unreliable transports. Under UDP packet loss or reordering, implicit nonce counters (where the receiver @@ -253,34 +255,34 @@ regardless of what packets were lost or reordered. The same `ReplayWindow` and `decrypt_with_replay_check()` implementation is used at both the link and session layers. -## COORDS_PRESENT Warmup Strategy +## CP (Coords Present) Warmup Strategy Session establishment (SessionSetup/SessionAck) warms transit node coordinate caches along the path. But coordinate caches have a finite TTL (default 300s), and entries may be evicted under memory pressure. When a transit node's cache -entry expires, it cannot forward DataPackets (which carry only addresses, not +entry expires, it cannot forward data packets (which carry only addresses, not coordinates) and sends a CoordsRequired error. FSP uses a warmup-then-reactive strategy to keep transit caches populated: ### Warmup Phase -After session establishment, the first N DataPackets (configurable, default 5) -include both source and destination coordinates via the COORDS_PRESENT flag. -Transit nodes cache these coordinates as packets pass through, reinforcing the -path established by SessionSetup. +After session establishment, the first N data packets (configurable, default 5) +include both source and destination coordinates via the CP flag in the FSP +common prefix. The coordinates appear in cleartext between the 12-byte header +and the ciphertext, allowing transit nodes to cache them without decryption. ### Steady State -After the warmup count is reached, FSP clears the COORDS_PRESENT flag and -sends minimal DataPackets (4-byte header instead of ~136 bytes with -coordinates). Transit nodes serve from their coordinate caches. +After the warmup count is reached, FSP clears the CP flag and sends minimal +data packets (12-byte header + ciphertext). Transit nodes serve from their +coordinate caches. ### Reactive Recovery When FSP receives a CoordsRequired signal: -1. The warmup counter resets — subsequent DataPackets include coordinates again +1. The warmup counter resets — subsequent data packets include coordinates again 2. A new LookupRequest may be initiated to rediscover the destination's current coordinates 3. When the LookupResponse arrives for an established session, the warmup @@ -343,7 +345,7 @@ either fall back to bloom-filter-only routing or signal CoordsRequired. The coordinate cache is a single unified cache (merged from previously separate coord_cache and route_cache). All coordinate sources — SessionSetup -transit, COORDS_PRESENT DataPackets, LookupResponse — write to the same cache. +transit, CP-flagged data packets, LookupResponse — write to the same cache. ### Eviction Policy @@ -380,7 +382,9 @@ node caches (still within their 300s TTL) are re-warmed. | Session establishment (Noise IK) | **Implemented** | | End-to-end encryption (ChaCha20-Poly1305) | **Implemented** | | Explicit counter replay protection | **Implemented** | -| COORDS_PRESENT warmup-then-reactive | **Implemented** | +| CP warmup-then-reactive | **Implemented** | +| FSP wire format (prefix, AAD, inner header) | **Implemented** | +| Session-layer MMP report types | **Implemented** (wire format) | | Identity cache (LRU-only) | **Implemented** | | Coordinate cache (unified, TTL + refresh) | **Implemented** | | Session idle timeout | **Implemented** | diff --git a/docs/design/fips-wire-formats.md b/docs/design/fips-wire-formats.md index e489e03..05ed4f9 100644 --- a/docs/design/fips-wire-formats.md +++ b/docs/design/fips-wire-formats.md @@ -396,103 +396,166 @@ Orderly link teardown with reason code. | 0x07 | Timeout | Keepalive or stale detection timeout | | 0xFF | Other | Unspecified reason | -## Session-Layer Message Types +## Session-Layer Message Formats -These messages are carried as the payload of a SessionDatagram (0x00). +Session-layer messages are carried as the payload of a SessionDatagram (0x00). +All FSP messages begin with a **4-byte common prefix** that identifies the +protocol version, session lifecycle phase, per-packet flags, and payload length. -### SessionSetup (0x00) +### FSP Common Prefix (4 bytes) + +| Field | Size | Description | +| ----- | ---- | ----------- | +| version | 4 bits (high) | Protocol version. Currently 0x0 | +| phase | 4 bits (low) | Session lifecycle phase (see table) | +| flags | 1 byte | Per-packet signal flags (zero during handshake) | +| payload_len | 2 bytes LE | Length of payload after phase-specific header | + +### FSP Phase Table + +| Phase | Type | Description | +| ----- | ---- | ----------- | +| 0x0 | Established | Post-handshake encrypted traffic or plaintext error signals | +| 0x1 | Handshake msg1 | SessionSetup (Noise IK msg1) | +| 0x2 | Handshake msg2 | SessionAck (Noise IK msg2) | + +### FSP Flags (Established Phase Only) + +| Bit | Name | Description | +| --- | ---- | ----------- | +| 0 | CP (coords present) | Source and destination coordinates follow the header in cleartext | +| 1 | K (key epoch) | Selects active key during rekeying | +| 2 | U (unencrypted) | Payload is plaintext (error signals) | +| 3-7 | — | Reserved (must be zero) | + +Flags must be zero in handshake packets (phase 0x1 and 0x2). + +### FSP Encrypted Message (phase 0x0, U flag clear) + +Post-handshake encrypted data. The 12-byte cleartext header is used as AEAD +AAD. Coordinates may appear in cleartext between the header and ciphertext +when the CP flag is set. + +**Cleartext header** (12 bytes, used as AEAD AAD): + +| Field | Size | Description | +| ----- | ---- | ----------- | +| common prefix | 4 bytes | ver=0, phase=0, flags, payload_len | +| counter | 8 bytes LE | Monotonic nonce, used as AEAD nonce and for replay detection | + +**Optional cleartext coordinates** (when CP flag is set): + +| Field | Size | Description | +| ----- | ---- | ----------- | +| src_coords_count | 2 bytes LE | Number of source coordinate entries | +| src_coords | 16 x n bytes | Source's ancestry (NodeAddr, self -> root) | +| dest_coords_count | 2 bytes LE | Number of dest coordinate entries | +| dest_coords | 16 x m bytes | Destination's ancestry | + +Transit nodes parse the CP flag and extract coordinates without decryption. + +**Encrypted inner header** (6 bytes, first bytes of AEAD plaintext): + +| Field | Size | Description | +| ----- | ---- | ----------- | +| timestamp | 4 bytes LE | Session-relative milliseconds (u32) | +| msg_type | 1 byte | Session-layer message type | +| inner_flags | 1 byte | Bit 0: SP (spin bit for RTT measurement) | + +After the inner header, the remaining plaintext is the message-type-specific +body. + +**Complete encrypted message**: + +```text +┌─────────────────────────────────┬─────────────────┬───────────────────────────┐ +│ header (12 bytes, used as AAD) │ [coords if CP] │ ciphertext + AEAD tag │ +│ │ │ (inner_hdr + body) + 16 │ +└─────────────────────────────────┴─────────────────┴───────────────────────────┘ +``` + +### FSP Session Message Types + +| Type | Message | Description | +| ---- | ------- | ----------- | +| 0x10 | Data | Application data (IPv6 payload via TUN) | +| 0x11 | SenderReport | MMP sender-side metrics report | +| 0x12 | ReceiverReport | MMP receiver-side metrics report | +| 0x13 | PathMtuNotification | End-to-end path MTU echo | +| 0x20 | CoordsRequired | Error: transit node lacks destination coordinates | +| 0x21 | PathBroken | Error: greedy routing reached dead end | + +Message types 0x10-0x13 are carried inside the AEAD ciphertext (dispatched +by the `msg_type` field in the encrypted inner header). Types 0x20-0x21 are +plaintext error signals (U flag set, no encryption). + +### SessionSetup (phase 0x1) Establishes a session and warms transit coordinate caches. +Encoded with FSP prefix: ver=0, phase=0x1, flags=0, payload_len. + +**Body** (after 4-byte FSP prefix): | Offset | Field | Size | Description | | ------ | ----- | ---- | ----------- | -| 0 | msg_type | 1 byte | 0x00 | -| 1 | flags | 1 byte | Bit 0: REQUEST_ACK, Bit 1: BIDIRECTIONAL | -| 2 | src_coords_count | 2 bytes LE | Number of source coordinate entries | -| 4 | src_coords | 16 x n bytes | Source's ancestry (NodeAddr, self -> root) | +| 0 | flags | 1 byte | Bit 0: REQUEST_ACK, Bit 1: BIDIRECTIONAL | +| 1 | src_coords_count | 2 bytes LE | Number of source coordinate entries | +| 3 | src_coords | 16 x n bytes | Source's ancestry (NodeAddr, self -> root) | | ... | dest_coords_count | 2 bytes LE | Number of dest coordinate entries | | ... | dest_coords | 16 x m bytes | Destination's ancestry | | ... | handshake_len | 2 bytes LE | Noise payload length | | ... | handshake_payload | variable | Noise IK msg1 (82 bytes typical) | -**Example** (depth-3 source, depth-4 destination): - -```text -SessionDatagram header: 36 bytes -SessionSetup payload: 1 + 1 + 2 + 48 + 2 + 64 + 2 + 82 = 202 bytes -Total: 238 bytes -``` - -### SessionAck (0x01) +### SessionAck (phase 0x2) Confirms session establishment, completes the Noise handshake. +Encoded with FSP prefix: ver=0, phase=0x2, flags=0, payload_len. + +**Body** (after 4-byte FSP prefix): | Offset | Field | Size | Description | | ------ | ----- | ---- | ----------- | -| 0 | msg_type | 1 byte | 0x01 | -| 1 | flags | 1 byte | Reserved | -| 2 | src_coords_count | 2 bytes LE | Number of coordinate entries | -| 4 | src_coords | 16 x n bytes | Acknowledger's ancestry (for cache warming) | +| 0 | flags | 1 byte | Reserved | +| 1 | src_coords_count | 2 bytes LE | Number of coordinate entries | +| 3 | src_coords | 16 x n bytes | Acknowledger's ancestry (for cache warming) | | ... | handshake_len | 2 bytes LE | Noise payload length | | ... | handshake_payload | variable | Noise IK msg2 (33 bytes typical) | -### DataPacket (0x10) +### Data (0x10) -Encrypted application data with explicit replay protection counter. - -**Minimal header** (COORDS_PRESENT = 0): - -| Offset | Field | Size | Description | -| ------ | ----- | ---- | ----------- | -| 0 | msg_type | 1 byte | 0x10 | -| 1 | flags | 1 byte | Bit 0: COORDS_PRESENT | -| 2 | counter | 8 bytes LE | Session encryption counter / replay nonce | -| 10 | payload_length | 2 bytes LE | Length of encrypted payload | -| 12 | payload | variable | Encrypted application data + 16-byte AEAD tag | - -**Header size**: 12 bytes (`DATA_HEADER_SIZE`) - -**With coordinates** (COORDS_PRESENT = 1): - -| Offset | Field | Size | Description | -| ------ | ----- | ---- | ----------- | -| 0 | msg_type | 1 byte | 0x10 | -| 1 | flags | 1 byte | 0x01 (COORDS_PRESENT) | -| 2 | counter | 8 bytes LE | Session encryption counter | -| 10 | payload_length | 2 bytes LE | Length of encrypted payload | -| 12 | src_coords_count | 2 bytes LE | Source coordinate entries | -| 14 | src_coords | 16 x n bytes | Source's ancestry | -| ... | dest_coords_count | 2 bytes LE | Dest coordinate entries | -| ... | dest_coords | 16 x m bytes | Destination's ancestry | -| ... | payload | variable | Encrypted application data | +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. ### CoordsRequired (0x20) -Link-layer error signal — transit node lacks coordinates for destination. -Plaintext (not end-to-end encrypted), generated by transit nodes. +Plaintext error signal — transit node lacks coordinates for destination. +Encoded with FSP prefix: ver=0, phase=0x0, U flag set, payload_len. + +**Body** (after 4-byte FSP prefix + 1-byte msg_type): | Offset | Field | Size | Description | | ------ | ----- | ---- | ----------- | -| 0 | msg_type | 1 byte | 0x20 | -| 1 | flags | 1 byte | Reserved | -| 2 | dest_addr | 16 bytes | NodeAddr we couldn't route to | -| 18 | reporter | 16 bytes | NodeAddr of reporting router | +| 0 | flags | 1 byte | Reserved | +| 1 | dest_addr | 16 bytes | NodeAddr we couldn't route to | +| 17 | reporter | 16 bytes | NodeAddr of reporting router | -**Payload**: 34 bytes. Wrapped in SessionDatagram: 70 bytes total. +**Body size**: 33 bytes. Total with prefix + msg_type: 38 bytes. ### PathBroken (0x21) -Link-layer error signal — greedy routing reached a dead end. Plaintext, -generated by transit nodes. +Plaintext error signal — greedy routing reached a dead end. +Encoded with FSP prefix: ver=0, phase=0x0, U flag set, payload_len. + +**Body** (after 4-byte FSP prefix + 1-byte msg_type): | Offset | Field | Size | Description | | ------ | ----- | ---- | ----------- | -| 0 | msg_type | 1 byte | 0x21 | -| 1 | flags | 1 byte | Reserved | -| 2 | dest_addr | 16 bytes | Unreachable NodeAddr | -| 18 | reporter | 16 bytes | NodeAddr of reporting router | -| 34 | last_coords_count | 2 bytes LE | Number of stale coordinate entries | -| 36 | last_known_coords | 16 x n bytes | Stale coordinates that failed | +| 0 | flags | 1 byte | Reserved | +| 1 | dest_addr | 16 bytes | Unreachable NodeAddr | +| 17 | reporter | 16 bytes | NodeAddr of reporting router | +| 33 | last_coords_count | 2 bytes LE | Number of stale coordinate entries | +| 35 | last_known_coords | 16 x n bytes | Stale coordinates that failed | ## Encapsulation Walkthrough @@ -507,19 +570,19 @@ Layer 4: Application data 1024 bytes Layer 3: Session encryption (FSP) - DataPacket header (12 bytes) + encrypted payload (1024) + AEAD tag (16) - = 1052 bytes + FSP header (12 bytes) + AEAD(inner_hdr (6) + payload (1024)) + AEAD tag (16) + = 1058 bytes Layer 2: SessionDatagram envelope (FLP routing) - msg_type (1) + ttl (1) + path_mtu (2) + src_addr (16) + dest_addr (16) + payload (1052) - = 1088 bytes + msg_type (1) + ttl (1) + path_mtu (2) + src_addr (16) + dest_addr (16) + payload (1058) + = 1094 bytes Layer 1: Link encryption (FLP per-hop) - outer header (16) + encrypted(inner_hdr (5) + datagram (1088)) + AEAD tag (16) - = 1125 bytes + outer header (16) + encrypted(inner_hdr (5) + datagram (1094)) + AEAD tag (16) + = 1131 bytes Layer 0: Transport - UDP datagram containing 1125 bytes + UDP datagram containing 1131 bytes ``` ### Overhead Budget @@ -528,11 +591,12 @@ Layer 0: Transport | ----- | -------- | --------- | | Link encryption | 37 bytes | 16 outer header (AAD) + 5 inner header + 16 AEAD tag | | SessionDatagram | 36 bytes | 1 type + 1 ttl + 2 path_mtu + 16 src + 16 dest | -| DataPacket header | 12 bytes | 1 type + 1 flags + 8 counter + 2 length | +| 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 | -| **Minimal total** | **101 bytes** | | +| **Minimal total** | **107 bytes** | | | Coordinates (if present) | ~43 bytes | Varies with tree depth | -| **Worst case** | **144 bytes** | `FIPS_OVERHEAD` constant | +| **Worst case** | **150 bytes** | `FIPS_OVERHEAD` constant | ### At Each Transit Node @@ -581,20 +645,23 @@ endpoint session keys). | ------- | ------------ | ----- | | SessionSetup | ~200 bytes | Depth-dependent | | SessionAck | ~80 bytes | Depth-dependent | -| DataPacket (minimal) | 12 + payload bytes | Steady state | -| DataPacket (with coords) | 12 + ~130 + payload bytes | Warmup/recovery | -| CoordsRequired | 34 bytes | Fixed | -| PathBroken | 36 + 16n bytes | Includes stale coords | +| Data (minimal) | 12 + 6 + payload + 16 bytes | Steady state | +| Data (with coords) | 12 + ~130 + 6 + payload + 16 bytes | Warmup/recovery | +| SenderReport | 12 + 6 + 46 + 16 bytes | MMP metrics | +| ReceiverReport | 12 + 6 + 66 + 16 bytes | MMP metrics | +| PathMtuNotification | 12 + 6 + 2 + 16 bytes | MTU signal | +| CoordsRequired | 38 bytes | Fixed (prefix + msg_type + body) | +| PathBroken | 35 + 16n bytes | Includes stale coords | ### Complete Packet Sizes (link + session) | Scenario | Wire Size | Notes | | -------- | --------- | ----- | | Encrypted frame minimum | 37 bytes | Empty body | -| SessionDatagram + DataPacket (minimal) | 37 + 36 + 12 + payload + 16 | 101 + payload | -| SessionDatagram + DataPacket (with coords) | ~144 + payload | Worst case | +| SessionDatagram + Data (minimal) | 37 + 36 + 12 + 6 + payload + 16 | 107 + payload | +| SessionDatagram + Data (with coords) | ~150 + payload | Worst case | | SessionDatagram + SessionSetup | ~275 bytes | Depth-3, both dirs | -| SessionDatagram + CoordsRequired | 37 + 36 + 34 = 107 bytes | Including link overhead | +| SessionDatagram + CoordsRequired | 37 + 36 + 38 = 111 bytes | Including link overhead | ## References diff --git a/src/config/mod.rs b/src/config/mod.rs index 54b1a02..bc2fd0d 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -30,7 +30,7 @@ use thiserror::Error; pub use node::{ BloomConfig, BuffersConfig, CacheConfig, DiscoveryConfig, LimitsConfig, NodeConfig, - RateLimitConfig, RetryConfig, SessionConfig, TreeConfig, + RateLimitConfig, RetryConfig, SessionConfig, SessionMmpConfig, TreeConfig, }; pub use peer::{ConnectPolicy, PeerAddress, PeerConfig}; pub use transport::{TransportInstances, TransportsConfig, UdpConfig}; diff --git a/src/config/node.rs b/src/config/node.rs index 116cd10..2c549a4 100644 --- a/src/config/node.rs +++ b/src/config/node.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use super::IdentityConfig; -use crate::mmp::MmpConfig; +use crate::mmp::{MmpConfig, MmpMode, DEFAULT_LOG_INTERVAL_SECS, DEFAULT_OWD_WINDOW_SIZE}; // ============================================================================ // Node Configuration Subsections @@ -227,7 +227,7 @@ pub struct SessionConfig { /// Established sessions with no activity for this duration are removed. #[serde(default = "SessionConfig::default_idle_timeout_secs")] pub idle_timeout_secs: u64, - /// Number of initial DataPackets per session that include COORDS_PRESENT + /// Number of initial data packets per session that include COORDS_PRESENT /// for transit cache warmup (`node.session.coords_warmup_packets`). /// Also used as the reset count on CoordsRequired receipt. #[serde(default = "SessionConfig::default_coords_warmup_packets")] @@ -254,6 +254,42 @@ impl SessionConfig { fn default_coords_warmup_packets() -> u8 { 5 } } +/// Session-layer Metrics Measurement Protocol (`node.session_mmp.*`). +/// +/// Separate from link-layer `node.mmp.*` to allow independent mode/interval +/// configuration per layer. Session reports consume bandwidth on every transit +/// link, so operators may want a lighter mode (e.g., Lightweight) for sessions +/// while running Full mode on links. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionMmpConfig { + /// Operating mode (`node.session_mmp.mode`). + #[serde(default)] + pub mode: MmpMode, + + /// Periodic operator log interval in seconds (`node.session_mmp.log_interval_secs`). + #[serde(default = "SessionMmpConfig::default_log_interval_secs")] + pub log_interval_secs: u64, + + /// OWD trend ring buffer size (`node.session_mmp.owd_window_size`). + #[serde(default = "SessionMmpConfig::default_owd_window_size")] + pub owd_window_size: usize, +} + +impl Default for SessionMmpConfig { + fn default() -> Self { + Self { + mode: MmpMode::default(), + log_interval_secs: DEFAULT_LOG_INTERVAL_SECS, + owd_window_size: DEFAULT_OWD_WINDOW_SIZE, + } + } +} + +impl SessionMmpConfig { + fn default_log_interval_secs() -> u64 { DEFAULT_LOG_INTERVAL_SECS } + fn default_owd_window_size() -> usize { DEFAULT_OWD_WINDOW_SIZE } +} + /// Internal buffers (`node.buffers.*`). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BuffersConfig { @@ -343,9 +379,13 @@ pub struct NodeConfig { #[serde(default)] pub buffers: BuffersConfig, - /// Metrics Measurement Protocol (`node.mmp.*`). + /// Metrics Measurement Protocol — link layer (`node.mmp.*`). #[serde(default)] pub mmp: MmpConfig, + + /// Metrics Measurement Protocol — session layer (`node.session_mmp.*`). + #[serde(default)] + pub session_mmp: SessionMmpConfig, } impl Default for NodeConfig { @@ -365,6 +405,7 @@ impl Default for NodeConfig { session: SessionConfig::default(), buffers: BuffersConfig::default(), mmp: MmpConfig::default(), + session_mmp: SessionMmpConfig::default(), } } } diff --git a/src/lib.rs b/src/lib.rs index d34dd64..b74a0c0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,7 +43,7 @@ pub use transport::udp::UdpTransport; // Re-export protocol types pub use protocol::{ - CoordsRequired, DataFlags, DataPacket, FilterAnnounce, HandshakeMessageType, LinkMessageType, + CoordsRequired, FilterAnnounce, HandshakeMessageType, LinkMessageType, LookupRequest, LookupResponse, PathBroken, ProtocolError, SessionAck, SessionDatagram, SessionFlags, SessionMessageType, SessionSetup, TreeAnnounce, }; diff --git a/src/mmp/metrics.rs b/src/mmp/metrics.rs index 263bb44..d6f41d7 100644 --- a/src/mmp/metrics.rs +++ b/src/mmp/metrics.rs @@ -8,6 +8,7 @@ use crate::mmp::algorithms::{DualEwma, SrttEstimator, compute_etx}; use crate::mmp::report::ReceiverReport; use std::time::Instant; +use tracing::debug; /// Derived MMP metrics, updated from incoming ReceiverReports. /// @@ -80,6 +81,14 @@ impl MmpMetrics { if our_timestamp_ms > echo_ms + dwell_ms { let rtt_ms = our_timestamp_ms - echo_ms - dwell_ms; let rtt_us = (rtt_ms as i64) * 1000; + debug!( + our_ts = our_timestamp_ms, + echo = echo_ms, + dwell = dwell_ms, + rtt_ms = rtt_ms, + srtt_ms = self.srtt.srtt_us() as f64 / 1000.0, + "RTT sample from timestamp echo" + ); self.srtt.update(rtt_us); self.rtt_trend.update(rtt_us as f64); } diff --git a/src/mmp/mod.rs b/src/mmp/mod.rs index bfe808d..50071bf 100644 --- a/src/mmp/mod.rs +++ b/src/mmp/mod.rs @@ -30,6 +30,9 @@ pub use receiver::ReceiverState; pub use report::{ReceiverReport, SenderReport}; pub use sender::SenderState; +// Session-layer re-exports +// MmpSessionState and PathMtuState are defined in this file + // ============================================================================ // Constants // ============================================================================ @@ -80,15 +83,29 @@ pub const DEFAULT_OWD_WINDOW_SIZE: usize = 32; /// Default operator log interval in seconds. pub const DEFAULT_LOG_INTERVAL_SECS: u64 = 30; +// --- Session-layer timing defaults --- +// Session reports are routed end-to-end (bandwidth cost on every transit link), +// so intervals are higher than link-layer. + +/// Session-layer minimum report interval. +pub const MIN_SESSION_REPORT_INTERVAL_MS: u64 = 500; + +/// Session-layer maximum report interval. +pub const MAX_SESSION_REPORT_INTERVAL_MS: u64 = 10_000; + +/// Session-layer cold-start report interval (before SRTT is available). +pub const SESSION_COLD_START_INTERVAL_MS: u64 = 1_000; + // ============================================================================ // Operating Mode // ============================================================================ /// MMP operating mode. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum MmpMode { /// Sender + receiver reports at RTT-adaptive intervals. Maximum fidelity. + #[default] Full, /// Receiver reports only. Loss inferred from counter gaps. Lightweight, @@ -96,12 +113,6 @@ pub enum MmpMode { Minimal, } -impl Default for MmpMode { - fn default() -> Self { - MmpMode::Full - } -} - impl fmt::Display for MmpMode { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -205,6 +216,246 @@ impl MmpPeerState { } } +// ============================================================================ +// Per-Session MMP State (session-layer instantiation) +// ============================================================================ + +/// Combined MMP state for a single end-to-end session. +/// +/// Wraps sender, receiver, metrics, spin bit, and path MTU state. +/// One instance per established `SessionEntry`. +pub struct MmpSessionState { + pub sender: SenderState, + pub receiver: ReceiverState, + pub metrics: MmpMetrics, + pub spin_bit: SpinBitState, + mode: MmpMode, + log_interval: Duration, + last_log_time: Option, + pub path_mtu: PathMtuState, +} + +impl MmpSessionState { + /// Create MMP state for a new session. + /// + /// `is_initiator`: true if this node initiated the Noise handshake + /// (determines spin bit role). + pub fn new(config: &crate::config::SessionMmpConfig, is_initiator: bool) -> Self { + Self { + sender: SenderState::new_with_cold_start(SESSION_COLD_START_INTERVAL_MS), + receiver: ReceiverState::new_with_cold_start( + config.owd_window_size, + SESSION_COLD_START_INTERVAL_MS, + ), + metrics: MmpMetrics::new(), + spin_bit: SpinBitState::new(is_initiator), + mode: config.mode, + log_interval: Duration::from_secs(config.log_interval_secs), + last_log_time: None, + path_mtu: PathMtuState::new(), + } + } + + /// Current operating mode. + pub fn mode(&self) -> MmpMode { + self.mode + } + + /// Check if it's time to emit a periodic metrics log. + pub fn should_log(&self, now: Instant) -> bool { + match self.last_log_time { + None => true, + Some(last) => now.duration_since(last) >= self.log_interval, + } + } + + /// Mark that a periodic log was emitted. + pub fn mark_logged(&mut self, now: Instant) { + self.last_log_time = Some(now); + } +} + +impl Debug for MmpSessionState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MmpSessionState") + .field("mode", &self.mode) + .field("path_mtu", &self.path_mtu.current_mtu()) + .finish_non_exhaustive() + } +} + +// ============================================================================ +// Path MTU State (session-layer only) +// ============================================================================ + +/// Path MTU tracking for a single session. +/// +/// Destination side: observes `path_mtu` from incoming SessionDatagram envelopes +/// and generates PathMtuNotification messages back to the source. +/// +/// Source side: applies received PathMtuNotification to limit outbound datagram +/// size. Decrease is immediate; increase requires 3 consecutive notifications. +pub struct PathMtuState { + /// Current effective path MTU (what we use for sending). + current_mtu: u16, + /// Last observed path MTU from incoming datagrams (destination-side). + last_observed_mtu: u16, + /// Whether the observed MTU has changed since the last notification. + observed_changed: bool, + /// Last time a PathMtuNotification was sent. + last_notification_time: Option, + /// Notification interval: max(10s, 5 * SRTT). Default 10s. + notification_interval: Duration, + /// For source-side increase tracking: consecutive higher-value notifications. + consecutive_increase_count: u8, + /// Time of the first notification in the current increase sequence. + first_increase_time: Option, + /// The MTU value being proposed for increase. + pending_increase_mtu: u16, +} + +impl PathMtuState { + /// Create path MTU state with no initial measurement. + pub fn new() -> Self { + Self { + current_mtu: u16::MAX, + last_observed_mtu: u16::MAX, + observed_changed: false, + last_notification_time: None, + notification_interval: Duration::from_secs(10), + consecutive_increase_count: 0, + first_increase_time: None, + pending_increase_mtu: 0, + } + } + + /// Current effective path MTU (source-side, for sending). + pub fn current_mtu(&self) -> u16 { + self.current_mtu + } + + /// Last observed incoming path MTU (destination-side). + pub fn last_observed_mtu(&self) -> u16 { + self.last_observed_mtu + } + + /// Update notification interval from SRTT: max(10s, 5 * SRTT). + pub fn update_interval_from_srtt(&mut self, srtt_ms: f64) { + let five_srtt = Duration::from_millis((srtt_ms * 5.0) as u64); + self.notification_interval = five_srtt.max(Duration::from_secs(10)); + } + + /// Seed source-side current_mtu from outbound transport MTU. + /// + /// Called on each send. Only decreases (never increases) the current_mtu + /// so the destination's PathMtuNotification can still raise it later. + /// Ensures current_mtu doesn't stay at u16::MAX before any notification + /// arrives from the destination. + pub fn seed_source_mtu(&mut self, outbound_mtu: u16) { + if outbound_mtu < self.current_mtu { + self.current_mtu = outbound_mtu; + } + } + + // --- Destination side --- + + /// Observe the path_mtu from an incoming SessionDatagram envelope. + /// + /// Called on the destination (receiver) side for every session message. + pub fn observe_incoming_mtu(&mut self, path_mtu: u16) { + if path_mtu != self.last_observed_mtu { + self.observed_changed = true; + self.last_observed_mtu = path_mtu; + } + } + + /// Check if a PathMtuNotification should be sent. + /// + /// Send on first measurement, on decrease (immediate), or periodic + /// confirmation at the notification interval. + pub fn should_send_notification(&self, now: Instant) -> bool { + if self.last_observed_mtu == u16::MAX { + return false; // No measurement yet + } + match self.last_notification_time { + None => true, // First measurement + Some(last) => { + // Immediate on decrease + if self.observed_changed && self.last_observed_mtu < self.current_mtu { + return true; + } + // Periodic confirmation + now.duration_since(last) >= self.notification_interval + } + } + } + + /// Build a PathMtuNotification from current state. + /// + /// Returns the path_mtu value to send. Caller handles encoding. + pub fn build_notification(&mut self, now: Instant) -> Option { + if self.last_observed_mtu == u16::MAX { + return None; + } + self.last_notification_time = Some(now); + self.observed_changed = false; + Some(self.last_observed_mtu) + } + + // --- Source side --- + + /// Apply a received PathMtuNotification. + /// + /// - Decrease: immediate (take the lower value). + /// - Increase: require 3 consecutive notifications with the same higher + /// value, spanning at least 2 * notification_interval. + /// + /// Returns `true` if the effective MTU changed. + pub fn apply_notification(&mut self, reported_mtu: u16, now: Instant) -> bool { + if reported_mtu < self.current_mtu { + // Decrease: immediate + self.current_mtu = reported_mtu; + self.consecutive_increase_count = 0; + self.first_increase_time = None; + return true; + } + + if reported_mtu > self.current_mtu { + // Increase: track consecutive notifications + if reported_mtu == self.pending_increase_mtu { + self.consecutive_increase_count += 1; + } else { + // Different value: reset sequence + self.pending_increase_mtu = reported_mtu; + self.consecutive_increase_count = 1; + self.first_increase_time = Some(now); + } + + // Accept increase after 3 consecutive spanning 2 * interval + if self.consecutive_increase_count >= 3 + && let Some(first_time) = self.first_increase_time + { + let required = self.notification_interval * 2; + if now.duration_since(first_time) >= required { + self.current_mtu = reported_mtu; + self.consecutive_increase_count = 0; + self.first_increase_time = None; + return true; + } + } + } + + // No change (equal or increase not yet confirmed) + false + } +} + +impl Default for PathMtuState { + fn default() -> Self { + Self::new() + } +} + impl Debug for MmpPeerState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("MmpPeerState") diff --git a/src/mmp/receiver.rs b/src/mmp/receiver.rs index 753aa7c..e0873f0 100644 --- a/src/mmp/receiver.rs +++ b/src/mmp/receiver.rs @@ -170,6 +170,14 @@ pub struct ReceiverState { impl ReceiverState { pub fn new(owd_window_size: usize) -> Self { + Self::new_with_cold_start(owd_window_size, DEFAULT_COLD_START_INTERVAL_MS) + } + + /// Create with a custom cold-start interval (ms). + /// + /// Used by session-layer MMP which needs a longer initial interval + /// since reports consume bandwidth on every transit link. + pub fn new_with_cold_start(owd_window_size: usize, cold_start_ms: u64) -> Self { Self { cumulative_packets_recv: 0, cumulative_bytes_recv: 0, @@ -185,7 +193,7 @@ impl ReceiverState { last_sender_timestamp: 0, last_recv_time: None, last_report_time: None, - report_interval: Duration::from_millis(DEFAULT_COLD_START_INTERVAL_MS), + report_interval: Duration::from_millis(cold_start_ms), interval_has_data: false, } } @@ -308,15 +316,22 @@ impl ReceiverState { } } - /// Update the report interval based on SRTT. + /// Update the report interval based on SRTT (link-layer defaults). /// /// Receiver reports at 1× SRTT, clamped to [MIN, MAX]. pub fn update_report_interval_from_srtt(&mut self, srtt_us: i64) { + self.update_report_interval_with_bounds(srtt_us, MIN_REPORT_INTERVAL_MS, MAX_REPORT_INTERVAL_MS); + } + + /// Update the report interval based on SRTT with custom bounds. + /// + /// Used by session-layer MMP which needs higher clamp values since + /// each report consumes bandwidth on every transit link. + pub fn update_report_interval_with_bounds(&mut self, srtt_us: i64, min_ms: u64, max_ms: u64) { if srtt_us <= 0 { return; } - let interval_ms = ((srtt_us as u64) / 1000) - .clamp(MIN_REPORT_INTERVAL_MS, MAX_REPORT_INTERVAL_MS); + let interval_ms = ((srtt_us as u64) / 1000).clamp(min_ms, max_ms); self.report_interval = Duration::from_millis(interval_ms); } diff --git a/src/mmp/report.rs b/src/mmp/report.rs index cc9a2a4..8d0289f 100644 --- a/src/mmp/report.rs +++ b/src/mmp/report.rs @@ -172,6 +172,82 @@ impl ReceiverReport { } } +// ============================================================================ +// Conversions between link-layer and session-layer report types +// ============================================================================ + +use crate::protocol::{SessionReceiverReport, SessionSenderReport}; + +impl From<&SenderReport> for SessionSenderReport { + fn from(r: &SenderReport) -> Self { + Self { + interval_start_counter: r.interval_start_counter, + interval_end_counter: r.interval_end_counter, + interval_start_timestamp: r.interval_start_timestamp, + interval_end_timestamp: r.interval_end_timestamp, + interval_bytes_sent: r.interval_bytes_sent, + cumulative_packets_sent: r.cumulative_packets_sent, + cumulative_bytes_sent: r.cumulative_bytes_sent, + } + } +} + +impl From<&SessionSenderReport> for SenderReport { + fn from(r: &SessionSenderReport) -> Self { + Self { + interval_start_counter: r.interval_start_counter, + interval_end_counter: r.interval_end_counter, + interval_start_timestamp: r.interval_start_timestamp, + interval_end_timestamp: r.interval_end_timestamp, + interval_bytes_sent: r.interval_bytes_sent, + cumulative_packets_sent: r.cumulative_packets_sent, + cumulative_bytes_sent: r.cumulative_bytes_sent, + } + } +} + +impl From<&ReceiverReport> for SessionReceiverReport { + fn from(r: &ReceiverReport) -> Self { + Self { + highest_counter: r.highest_counter, + cumulative_packets_recv: r.cumulative_packets_recv, + cumulative_bytes_recv: r.cumulative_bytes_recv, + timestamp_echo: r.timestamp_echo, + dwell_time: r.dwell_time, + max_burst_loss: r.max_burst_loss, + mean_burst_loss: r.mean_burst_loss, + jitter: r.jitter, + ecn_ce_count: r.ecn_ce_count, + owd_trend: r.owd_trend, + burst_loss_count: r.burst_loss_count, + cumulative_reorder_count: r.cumulative_reorder_count, + interval_packets_recv: r.interval_packets_recv, + interval_bytes_recv: r.interval_bytes_recv, + } + } +} + +impl From<&SessionReceiverReport> for ReceiverReport { + fn from(r: &SessionReceiverReport) -> Self { + Self { + highest_counter: r.highest_counter, + cumulative_packets_recv: r.cumulative_packets_recv, + cumulative_bytes_recv: r.cumulative_bytes_recv, + timestamp_echo: r.timestamp_echo, + dwell_time: r.dwell_time, + max_burst_loss: r.max_burst_loss, + mean_burst_loss: r.mean_burst_loss, + jitter: r.jitter, + ecn_ce_count: r.ecn_ce_count, + owd_trend: r.owd_trend, + burst_loss_count: r.burst_loss_count, + cumulative_reorder_count: r.cumulative_reorder_count, + interval_packets_recv: r.interval_packets_recv, + interval_bytes_recv: r.interval_bytes_recv, + } + } +} + // ============================================================================ // Tests // ============================================================================ diff --git a/src/mmp/sender.rs b/src/mmp/sender.rs index 3b6e536..c3a4fa2 100644 --- a/src/mmp/sender.rs +++ b/src/mmp/sender.rs @@ -35,6 +35,14 @@ pub struct SenderState { impl SenderState { pub fn new() -> Self { + Self::new_with_cold_start(DEFAULT_COLD_START_INTERVAL_MS) + } + + /// Create with a custom cold-start interval (ms). + /// + /// Used by session-layer MMP which needs a longer initial interval + /// since reports consume bandwidth on every transit link. + pub fn new_with_cold_start(cold_start_ms: u64) -> Self { Self { cumulative_packets_sent: 0, cumulative_bytes_sent: 0, @@ -45,7 +53,7 @@ impl SenderState { last_timestamp: 0, interval_has_data: false, last_report_time: None, - report_interval: Duration::from_millis(DEFAULT_COLD_START_INTERVAL_MS), + report_interval: Duration::from_millis(cold_start_ms), } } @@ -104,17 +112,23 @@ impl SenderState { } } - /// Update the report interval based on SRTT. + /// Update the report interval based on SRTT (link-layer defaults). /// - /// Sender reports at 2-5× the receiver report interval. For simplicity, - /// we use 2× SRTT clamped to [MIN, MAX]. + /// Sender reports at 2× SRTT clamped to [MIN, MAX]. pub fn update_report_interval_from_srtt(&mut self, srtt_us: i64) { + self.update_report_interval_with_bounds(srtt_us, MIN_REPORT_INTERVAL_MS, MAX_REPORT_INTERVAL_MS); + } + + /// Update the report interval based on SRTT with custom bounds. + /// + /// Used by session-layer MMP which needs higher clamp values since + /// each report consumes bandwidth on every transit link. + pub fn update_report_interval_with_bounds(&mut self, srtt_us: i64, min_ms: u64, max_ms: u64) { if srtt_us <= 0 { return; } let interval_us = (srtt_us * 2) as u64; - let interval_ms = (interval_us / 1000) - .clamp(MIN_REPORT_INTERVAL_MS, MAX_REPORT_INTERVAL_MS); + let interval_ms = (interval_us / 1000).clamp(min_ms, max_ms); self.report_interval = Duration::from_millis(interval_ms); } diff --git a/src/node/handlers/discovery.rs b/src/node/handlers/discovery.rs index f4a613e..5e1ec78 100644 --- a/src/node/handlers/discovery.rs +++ b/src/node/handlers/discovery.rs @@ -151,7 +151,7 @@ impl Node { // If an established session exists, reset the warmup counter. // Discovery has completed and transit nodes along the response // path now have fresh coords. Reset warmup so the next N - // DataPackets include COORDS_PRESENT to re-warm the forward path. + // data packets include COORDS_PRESENT to re-warm the forward path. if let Some(entry) = self.sessions.get_mut(&target) && entry.is_established() { diff --git a/src/node/handlers/encrypted.rs b/src/node/handlers/encrypted.rs index 9b2eb63..ec762ac 100644 --- a/src/node/handlers/encrypted.rs +++ b/src/node/handlers/encrypted.rs @@ -100,10 +100,12 @@ impl Node { 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); - } + // Spin bit: advance state machine for correct TX reflection. + // RTT samples from spin bit are not used for SRTT because + // inter-frame timing in the mesh is irregular, inflating + // spin-bit RTT by variable processing delays on both sides. + // Timestamp-echo in ReceiverReport provides accurate RTT. + let _spin_rtt = mmp.spin_bit.rx_observe(sp_flag, header.counter, now); } // Update address for roaming support diff --git a/src/node/handlers/forwarding.rs b/src/node/handlers/forwarding.rs index 86cc5be..d56f16c 100644 --- a/src/node/handlers/forwarding.rs +++ b/src/node/handlers/forwarding.rs @@ -6,9 +6,12 @@ //! locally, and generates error signals on routing failure. use crate::node::Node; +use crate::node::session_wire::{ + parse_encrypted_coords, FspCommonPrefix, FSP_COMMON_PREFIX_SIZE, FSP_HEADER_SIZE, + FSP_PHASE_ESTABLISHED, FSP_PHASE_MSG1, FSP_PHASE_MSG2, +}; use crate::protocol::{ - CoordsRequired, DataPacket, PathBroken, SessionAck, SessionDatagram, SessionMessageType, - SessionSetup, + CoordsRequired, PathBroken, SessionAck, SessionDatagram, SessionSetup, }; use crate::NodeAddr; use tracing::debug; @@ -42,7 +45,7 @@ impl Node { // Local delivery: dispatch to session layer handlers if datagram.dest_addr == *self.node_addr() { - self.handle_session_payload(&datagram.src_addr, &datagram.payload) + self.handle_session_payload(&datagram.src_addr, &datagram.payload, datagram.path_mtu) .await; return; } @@ -81,28 +84,29 @@ impl Node { /// Attempt to warm the coordinate cache from session-layer payload headers. /// - /// Transit routers can read the session message type byte and, for - /// SessionSetup and SessionAck, extract plaintext coordinate fields. - /// DataPacket with COORDS_PRESENT has plaintext coords before the - /// encrypted payload. Other types are ignored. + /// Transit routers parse the 4-byte FSP common prefix to identify message + /// type, then extract plaintext coordinate fields from: + /// - SessionSetup (phase 0x1): src_coords + dest_coords + /// - SessionAck (phase 0x2): src_coords + /// - Encrypted with CP flag (phase 0x0): cleartext coords between header and ciphertext /// /// Decode failures are logged and silently ignored — they don't block /// forwarding. fn try_warm_coord_cache(&mut self, datagram: &SessionDatagram) { - if datagram.payload.is_empty() { - return; - } + let prefix = match FspCommonPrefix::parse(&datagram.payload) { + Some(p) => p, + None => return, + }; - let msg_type = datagram.payload[0]; - let inner = &datagram.payload[1..]; + let inner = &datagram.payload[FSP_COMMON_PREFIX_SIZE..]; let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); - match SessionMessageType::from_byte(msg_type) { - Some(SessionMessageType::SessionSetup) => { + match prefix.phase { + FSP_PHASE_MSG1 => { match SessionSetup::decode(inner) { Ok(setup) => { self.coord_cache_mut().insert( @@ -126,7 +130,7 @@ impl Node { } } } - Some(SessionMessageType::SessionAck) => { + FSP_PHASE_MSG2 => { match SessionAck::decode(inner) { Ok(ack) => { self.coord_cache_mut().insert( @@ -144,38 +148,41 @@ impl Node { } } } - Some(SessionMessageType::DataPacket) => { - match DataPacket::decode(inner) { - Ok(data) => { - if data.flags.coords_present { - if let Some(src_coords) = data.src_coords { - self.coord_cache_mut().insert( - datagram.src_addr, - src_coords, - now_ms, - ); - } - if let Some(dest_coords) = data.dest_coords { - self.coord_cache_mut().insert( - datagram.dest_addr, - dest_coords, - now_ms, - ); - } - debug!( - src = %datagram.src_addr, - dest = %datagram.dest_addr, - "Cached coords from DataPacket" + FSP_PHASE_ESTABLISHED if prefix.has_coords() => { + // CP flag set: coords in cleartext between header and ciphertext. + // Parse coords from the cleartext section after the 12-byte header. + // inner starts after the 4-byte prefix, so we need 8 more bytes + // for the counter (header is 12 total = 4 prefix + 8 counter). + let coord_data = &datagram.payload[FSP_HEADER_SIZE..]; + match parse_encrypted_coords(coord_data) { + Ok((src_coords, dest_coords, _bytes_consumed)) => { + if let Some(coords) = src_coords { + self.coord_cache_mut().insert( + datagram.src_addr, + coords, + now_ms, ); } + if let Some(coords) = dest_coords { + self.coord_cache_mut().insert( + datagram.dest_addr, + coords, + now_ms, + ); + } + debug!( + src = %datagram.src_addr, + dest = %datagram.dest_addr, + "Cached coords from encrypted message" + ); } Err(e) => { - debug!(error = %e, "Failed to decode DataPacket for cache warming"); + debug!(error = %e, "Failed to parse coords for cache warming"); } } } _ => { - // CoordsRequired, PathBroken, unknown: no coords to cache + // Phase 0x0 without CP, error signals, unknown: no coords to cache } } } diff --git a/src/node/handlers/mmp.rs b/src/node/handlers/mmp.rs index 434f02a..72a8e91 100644 --- a/src/node/handlers/mmp.rs +++ b/src/node/handlers/mmp.rs @@ -5,8 +5,12 @@ //! and teardown metric logs. use crate::mmp::MmpMode; +use crate::mmp::MmpSessionState; use crate::mmp::report::{ReceiverReport, SenderReport}; use crate::node::Node; +use crate::protocol::{ + PathMtuNotification, SessionMessageType, SessionReceiverReport, SessionSenderReport, +}; use crate::NodeAddr; use std::time::Instant; use tracing::{debug, info, warn}; @@ -139,17 +143,19 @@ impl Node { 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())); - } + if mode == MmpMode::Full + && mmp.sender.should_send_report(now) + && 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())); - } + if mode != MmpMode::Minimal + && mmp.receiver.should_send_report(now) + && let Some(rr) = mmp.receiver.build_report(now) + { + receiver_reports.push((*node_addr, rr.encode())); } // Periodic operator logging @@ -243,4 +249,153 @@ impl Node { "MMP link teardown" ); } + + // === Session-layer MMP === + + /// Check all sessions for pending MMP reports and send them. + /// + /// Called from the tick handler. Also emits periodic session MMP logs. + /// Uses the collect-then-send pattern to avoid borrowing conflicts. + pub(in crate::node) async fn check_session_mmp_reports(&mut self) { + let now = Instant::now(); + + // Collect reports to send: (dest_addr, msg_type, encoded_body) + let mut reports: Vec<(NodeAddr, u8, Vec)> = Vec::new(); + + for (dest_addr, entry) in self.sessions.iter_mut() { + let Some(mmp) = entry.mmp_mut() else { + continue; + }; + + let mode = mmp.mode(); + + // Sender reports: Full mode only + if mode == MmpMode::Full + && mmp.sender.should_send_report(now) + && let Some(sr) = mmp.sender.build_report(now) + { + let session_sr: SessionSenderReport = SessionSenderReport::from(&sr); + reports.push(( + *dest_addr, + SessionMessageType::SenderReport.to_byte(), + session_sr.encode(), + )); + } + + // Receiver reports: Full and Lightweight modes + if mode != MmpMode::Minimal + && mmp.receiver.should_send_report(now) + && let Some(rr) = mmp.receiver.build_report(now) + { + let session_rr: SessionReceiverReport = SessionReceiverReport::from(&rr); + reports.push(( + *dest_addr, + SessionMessageType::ReceiverReport.to_byte(), + session_rr.encode(), + )); + } + + // PathMtu notifications (all modes) + if mmp.path_mtu.should_send_notification(now) + && let Some(mtu_value) = mmp.path_mtu.build_notification(now) + { + let notif = PathMtuNotification::new(mtu_value); + reports.push(( + *dest_addr, + SessionMessageType::PathMtuNotification.to_byte(), + notif.encode(), + )); + } + + // Periodic operator logging + if mmp.should_log(now) { + Self::log_session_mmp_metrics(dest_addr, mmp); + mmp.mark_logged(now); + } + } + + // Send collected reports via session-layer encryption + for (dest_addr, msg_type, body) in reports { + if let Err(e) = self.send_session_msg(&dest_addr, msg_type, &body).await { + debug!( + dest = %dest_addr, + msg_type, + error = %e, + "Failed to send session MMP report" + ); + } + } + } + + /// Emit periodic session MMP metrics at info and debug levels. + fn log_session_mmp_metrics(dest_addr: &NodeAddr, mmp: &MmpSessionState) { + 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_str = format_throughput(m.goodput_bps()); + let send_mtu = mmp.path_mtu.current_mtu(); + let observed_mtu = mmp.path_mtu.last_observed_mtu(); + + info!( + session = %dest_addr, + rtt = %rtt_str, + loss = format_args!("{:.1}%", loss_pct), + goodput = %goodput_str, + send_mtu, + observed_mtu, + tx_pkts, + rx_pkts, + "MMP session metrics" + ); + + debug!( + session = %dest_addr, + jitter_us = mmp.receiver.jitter_us(), + 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 session metrics (detail)" + ); + } + + /// Emit a teardown log summarizing lifetime session MMP metrics. + pub(in crate::node) fn log_session_mmp_teardown(dest_addr: &NodeAddr, mmp: &MmpSessionState) { + let m = &mmp.metrics; + + let rtt_str = match m.srtt_ms() { + Some(rtt) => format!("{:.1}ms", rtt), + None => "n/a".to_string(), + }; + + info!( + session = %dest_addr, + rtt = %rtt_str, + loss = format_args!("{:.1}%", m.loss_rate() * 100.0), + etx = format_args!("{:.2}", m.etx), + send_mtu = mmp.path_mtu.current_mtu(), + observed_mtu = mmp.path_mtu.last_observed_mtu(), + tx_pkts = mmp.sender.cumulative_packets_sent(), + tx_bytes = mmp.sender.cumulative_bytes_sent(), + rx_pkts = mmp.receiver.cumulative_packets_recv(), + rx_bytes = mmp.receiver.cumulative_bytes_recv(), + jitter_us = mmp.receiver.jitter_us(), + "MMP session teardown" + ); + } } diff --git a/src/node/handlers/rx_loop.rs b/src/node/handlers/rx_loop.rs index 66ebae9..7ff22a0 100644 --- a/src/node/handlers/rx_loop.rs +++ b/src/node/handlers/rx_loop.rs @@ -84,6 +84,7 @@ impl Node { self.check_tree_state().await; self.check_bloom_state().await; self.check_mmp_reports().await; + self.check_session_mmp_reports().await; self.purge_stale_lookups(now_ms); } } diff --git a/src/node/handlers/session.rs b/src/node/handlers/session.rs index e3fb83b..0bc9f8a 100644 --- a/src/node/handlers/session.rs +++ b/src/node/handlers/session.rs @@ -1,16 +1,24 @@ //! End-to-end session message handlers. //! //! Handles locally-delivered session payloads from SessionDatagram envelopes. -//! Dispatches based on session message type to specific handlers for -//! SessionSetup (Noise IK msg1), SessionAck (msg2), DataPacket, and -//! error signals (CoordsRequired, PathBroken). +//! Dispatches based on FSP common prefix phase to specific handlers for +//! SessionSetup (Noise IK msg1), SessionAck (msg2), encrypted data, +//! and error signals (CoordsRequired, PathBroken). use crate::node::session::{EndToEndState, SessionEntry}; +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_HEADER_SIZE, FSP_PHASE_ESTABLISHED, FSP_PHASE_MSG1, FSP_PHASE_MSG2, +}; +use crate::protocol::encode_coords; use crate::node::{Node, NodeError}; use crate::noise::{HandshakeState, HANDSHAKE_MSG1_SIZE, HANDSHAKE_MSG2_SIZE}; +use crate::mmp::report::ReceiverReport; +use crate::mmp::{MAX_SESSION_REPORT_INTERVAL_MS, MIN_SESSION_REPORT_INTERVAL_MS}; use crate::protocol::{ - CoordsRequired, DataPacket, PathBroken, SessionAck, SessionDatagram, SessionMessageType, - SessionSetup, + CoordsRequired, FspInnerFlags, PathBroken, PathMtuNotification, SessionAck, SessionDatagram, + SessionMessageType, SessionReceiverReport, SessionSenderReport, SessionSetup, }; use crate::NodeAddr; use secp256k1::PublicKey; @@ -20,42 +28,239 @@ impl Node { /// Handle a locally-delivered session datagram payload. /// /// Called from `handle_session_datagram()` when `dest_addr == self.node_addr()`. - /// Dispatches to the appropriate handler based on the session message type byte. + /// Dispatches based on the 4-byte FSP common prefix: + /// + /// - Phase 0x1 → SessionSetup (handshake msg1) + /// - Phase 0x2 → SessionAck (handshake msg2) + /// - Phase 0x0 + U flag → plaintext error signal (CoordsRequired/PathBroken) + /// - Phase 0x0 + !U → encrypted session message (data, reports, etc.) pub(in crate::node) async fn handle_session_payload( &mut self, src_addr: &NodeAddr, payload: &[u8], + path_mtu: u16, ) { - if payload.is_empty() { - debug!("Empty session payload"); - return; - } + let prefix = match FspCommonPrefix::parse(payload) { + Some(p) => p, + None => { + debug!(len = payload.len(), "Session payload too short for FSP prefix"); + return; + } + }; - let msg_type = payload[0]; - let inner = &payload[1..]; + let inner = &payload[FSP_COMMON_PREFIX_SIZE..]; - match SessionMessageType::from_byte(msg_type) { - Some(SessionMessageType::SessionSetup) => { + match prefix.phase { + FSP_PHASE_MSG1 => { self.handle_session_setup(src_addr, inner).await; } - Some(SessionMessageType::SessionAck) => { + FSP_PHASE_MSG2 => { self.handle_session_ack(src_addr, inner).await; } - Some(SessionMessageType::DataPacket) => { - self.handle_data_packet(src_addr, inner).await; + FSP_PHASE_ESTABLISHED if prefix.is_unencrypted() => { + // Plaintext error signals: read msg_type from first byte after prefix + if inner.is_empty() { + debug!("Empty plaintext error signal"); + return; + } + let error_type = inner[0]; + let error_body = &inner[1..]; + match SessionMessageType::from_byte(error_type) { + Some(SessionMessageType::CoordsRequired) => { + self.handle_coords_required(error_body).await; + } + Some(SessionMessageType::PathBroken) => { + self.handle_path_broken(error_body).await; + } + _ => { + debug!(error_type, "Unknown plaintext error signal type"); + } + } } - Some(SessionMessageType::CoordsRequired) => { - self.handle_coords_required(inner).await; + FSP_PHASE_ESTABLISHED => { + self.handle_encrypted_session_msg(src_addr, payload, path_mtu).await; } - Some(SessionMessageType::PathBroken) => { - self.handle_path_broken(inner).await; - } - None => { - debug!(msg_type, "Unknown session message type"); + _ => { + debug!(phase = prefix.phase, "Unknown FSP phase"); } } } + /// Handle an encrypted session message (phase 0x0, U flag clear). + /// + /// Full FSP receive pipeline: + /// 1. Parse FspEncryptedHeader (12 bytes) → counter, flags, header_bytes + /// 2. If CP flag: parse cleartext coords, cache them + /// 3. Session lookup with Responding→Established transition + /// 4. AEAD decrypt with AAD = header_bytes + /// 5. Strip FSP inner header → timestamp, msg_type, inner_flags + /// 6. Dispatch by msg_type + async fn handle_encrypted_session_msg(&mut self, src_addr: &NodeAddr, payload: &[u8], path_mtu: u16) { + // Parse the 12-byte encrypted header (includes the 4-byte prefix) + let header = match FspEncryptedHeader::parse(payload) { + Some(h) => h, + None => { + debug!(len = payload.len(), "Encrypted session message too short for FSP header"); + return; + } + }; + + // Determine where ciphertext starts (after header, optionally after coords) + let mut ciphertext_offset = FSP_HEADER_SIZE; + + // If CP flag set, parse cleartext coords between header and ciphertext + if header.has_coords() { + let coord_data = &payload[FSP_HEADER_SIZE..]; + match parse_encrypted_coords(coord_data) { + Ok((src_coords, dest_coords, bytes_consumed)) => { + let now_ms = Self::now_ms(); + if let Some(coords) = src_coords { + self.coord_cache.insert(*src_addr, coords, now_ms); + } + if let Some(coords) = dest_coords { + self.coord_cache.insert(*self.node_addr(), coords, now_ms); + } + ciphertext_offset += bytes_consumed; + } + Err(e) => { + debug!(error = %e, "Failed to parse coords from encrypted session message"); + return; + } + } + } + + let ciphertext = &payload[ciphertext_offset..]; + + // Look up session entry, handle Responding→Established transition + let mut entry = match self.sessions.remove(src_addr) { + Some(e) => e, + None => { + debug!(src = %src_addr, "Encrypted session message for unknown session"); + return; + } + }; + + if entry.state().is_responding() { + let old_state = entry.take_state(); + let handshake = match old_state { + Some(EndToEndState::Responding(hs)) => hs, + _ => { + debug!(src = %src_addr, "Unexpected state during Responding transition"); + return; + } + }; + let noise_session = match handshake.into_session() { + Ok(s) => s, + Err(e) => { + debug!(error = %e, "Failed to create session from responding handshake"); + return; + } + }; + entry.set_state(EndToEndState::Established(noise_session)); + entry.set_coords_warmup_remaining(self.config.node.session.coords_warmup_packets); + entry.mark_established(Self::now_ms()); + entry.init_mmp(&self.config.node.session_mmp); + debug!(src = %src_addr, "Session established (responder, on first encrypted message)"); + } + + // Decrypt with AAD = the 12-byte header + let session = match entry.state_mut() { + EndToEndState::Established(s) => s, + _ => { + debug!(src = %src_addr, "Encrypted message but session not established"); + self.sessions.insert(*src_addr, entry); + return; + } + }; + + let plaintext = match session.decrypt_with_replay_check_and_aad( + ciphertext, + header.counter, + &header.header_bytes, + ) { + Ok(pt) => pt, + Err(e) => { + debug!( + error = %e, src = %src_addr, counter = header.counter, + "Session AEAD decryption failed" + ); + self.sessions.insert(*src_addr, entry); + return; + } + }; + + entry.touch(Self::now_ms()); + self.sessions.insert(*src_addr, entry); + + // Strip FSP inner header (6 bytes) + let (timestamp, msg_type, inner_flags_byte, rest) = match fsp_strip_inner_header(&plaintext) { + Some(parts) => parts, + None => { + debug!(src = %src_addr, "Decrypted payload too short for FSP inner header"); + return; + } + }; + + // MMP per-message recording on RX path + if let Some(entry) = self.sessions.get_mut(src_addr) + && let Some(mmp) = entry.mmp_mut() + { + let now = std::time::Instant::now(); + mmp.receiver.record_recv( + header.counter, timestamp, plaintext.len(), false, now, + ); + // Spin bit: advance state machine for correct TX reflection. + // RTT samples not fed into SRTT — timestamp-echo provides + // accurate RTT; spin bit includes variable inter-frame delays. + let inner_flags = FspInnerFlags::from_byte(inner_flags_byte); + let _spin_rtt = mmp.spin_bit.rx_observe( + inner_flags.spin_bit, header.counter, now, + ); + } + + // Feed path_mtu from datagram envelope to MMP path MTU tracking. + // Done for ALL session messages, not just DataPackets, so the + // destination learns the path MTU even when only reports flow. + if let Some(entry) = self.sessions.get_mut(src_addr) + && let Some(mmp) = entry.mmp_mut() + { + mmp.path_mtu.observe_incoming_mtu(path_mtu); + } + + // Dispatch by msg_type + match SessionMessageType::from_byte(msg_type) { + Some(SessionMessageType::DataPacket) => { + // msg_type 0x10: deliver rest (IPv6 payload) to TUN + if let Some(tun_tx) = &self.tun_tx { + if let Err(e) = tun_tx.send(rest.to_vec()) { + debug!(error = %e, "Failed to deliver decrypted packet to TUN"); + } + } else { + debug!( + src = %src_addr, + "DataPacket decrypted (no TUN interface, plaintext dropped)" + ); + } + } + Some(SessionMessageType::SenderReport) => { + self.handle_session_sender_report(src_addr, rest); + } + Some(SessionMessageType::ReceiverReport) => { + self.handle_session_receiver_report(src_addr, rest); + } + Some(SessionMessageType::PathMtuNotification) => { + self.handle_session_path_mtu_notification(src_addr, rest); + } + _ => { + debug!(src = %src_addr, msg_type, "Unknown session message type, dropping"); + } + } + + // Flush any pending outbound packets (e.g., simultaneous initiation + // where responder also had queued outbound packets) + self.flush_pending_packets(src_addr).await; + } + /// Handle an incoming SessionSetup (Noise IK msg1). /// /// The remote node wants to establish an end-to-end session with us. @@ -143,18 +348,18 @@ impl Node { let our_coords = self.tree_state.my_coords().clone(); 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()) + let mut datagram = SessionDatagram::new(my_addr, *src_addr, ack.encode()) .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 { + if let Err(e) = self.send_session_datagram(&mut datagram).await { debug!(error = %e, dest = %src_addr, "Failed to send SessionAck"); return; } // Store session entry in Responding state let now_ms = Self::now_ms(); - let entry = SessionEntry::new(*src_addr, remote_pubkey, EndToEndState::Responding(handshake), now_ms); + let entry = SessionEntry::new(*src_addr, remote_pubkey, EndToEndState::Responding(handshake), now_ms, false); self.sessions.insert(*src_addr, entry); debug!(src = %src_addr, "SessionSetup processed, SessionAck sent"); @@ -210,13 +415,13 @@ impl Node { } }; + let now_ms = Self::now_ms(); entry.set_state(EndToEndState::Established(session)); entry.set_coords_warmup_remaining(self.config.node.session.coords_warmup_packets); - entry.touch(Self::now_ms()); + entry.mark_established(now_ms); + entry.init_mmp(&self.config.node.session_mmp); + entry.touch(now_ms); self.sessions.insert(*src_addr, entry); - - // Cache the responder's coordinates - let now_ms = Self::now_ms(); self.coord_cache.insert(*src_addr, ack.src_coords, now_ms); // Flush any queued outbound packets for this destination @@ -225,6 +430,136 @@ impl Node { debug!(src = %src_addr, "Session established (initiator)"); } + // === Session-layer MMP report handlers === + + /// Handle an incoming session-layer SenderReport (msg_type 0x11). + /// + /// Informational only — the peer is telling us about what they sent. + /// Logged but not used for metrics (same pattern as link-layer). + fn handle_session_sender_report(&mut self, src_addr: &NodeAddr, body: &[u8]) { + let sr = match SessionSenderReport::decode(body) { + Ok(sr) => sr, + Err(e) => { + debug!(src = %src_addr, error = %e, "Malformed SessionSenderReport"); + return; + } + }; + + debug!( + src = %src_addr, + cum_pkts = sr.cumulative_packets_sent, + interval_bytes = sr.interval_bytes_sent, + "Received SessionSenderReport" + ); + } + + /// Handle an incoming session-layer ReceiverReport (msg_type 0x12). + /// + /// 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. + fn handle_session_receiver_report(&mut self, src_addr: &NodeAddr, body: &[u8]) { + let session_rr = match SessionReceiverReport::decode(body) { + Ok(rr) => rr, + Err(e) => { + debug!(src = %src_addr, error = %e, "Malformed SessionReceiverReport"); + return; + } + }; + + // Convert to link-layer ReceiverReport for MmpMetrics processing + let rr: ReceiverReport = ReceiverReport::from(&session_rr); + + let now_ms = Self::now_ms(); + let entry = match self.sessions.get_mut(src_addr) { + Some(e) => e, + None => { + debug!(src = %src_addr, "SessionReceiverReport for unknown session"); + return; + } + }; + + let our_timestamp_ms = entry.session_timestamp(now_ms); + + let Some(mmp) = entry.mmp_mut() else { + return; + }; + + let now = std::time::Instant::now(); + mmp.metrics.process_receiver_report(&rr, our_timestamp_ms, now); + + // Feed SRTT back to sender/receiver report interval tuning (session-layer bounds) + if let Some(srtt_ms) = mmp.metrics.srtt_ms() { + let srtt_us = (srtt_ms * 1000.0) as i64; + mmp.sender.update_report_interval_with_bounds( + srtt_us, + MIN_SESSION_REPORT_INTERVAL_MS, + MAX_SESSION_REPORT_INTERVAL_MS, + ); + mmp.receiver.update_report_interval_with_bounds( + srtt_us, + MIN_SESSION_REPORT_INTERVAL_MS, + MAX_SESSION_REPORT_INTERVAL_MS, + ); + // Also update PathMtu notification interval from SRTT + mmp.path_mtu.update_interval_from_srtt(srtt_ms); + } + + // Update reverse delivery ratio from our own receiver state + 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!( + src = %src_addr, + rtt_ms = ?mmp.metrics.srtt_ms(), + loss = format_args!("{:.1}%", mmp.metrics.loss_rate() * 100.0), + "Processed SessionReceiverReport" + ); + } + + /// Handle an incoming PathMtuNotification (msg_type 0x13). + /// + /// The destination is telling us the path MTU has changed. + /// Apply source-side rules (decrease immediate, increase validated). + fn handle_session_path_mtu_notification(&mut self, src_addr: &NodeAddr, body: &[u8]) { + let notif = match PathMtuNotification::decode(body) { + Ok(n) => n, + Err(e) => { + debug!(src = %src_addr, error = %e, "Malformed PathMtuNotification"); + return; + } + }; + + let entry = match self.sessions.get_mut(src_addr) { + Some(e) => e, + None => { + debug!(src = %src_addr, "PathMtuNotification for unknown session"); + return; + } + }; + + let Some(mmp) = entry.mmp_mut() else { + return; + }; + + let old_mtu = mmp.path_mtu.current_mtu(); + let now = std::time::Instant::now(); + mmp.path_mtu.apply_notification(notif.path_mtu, now); + let new_mtu = mmp.path_mtu.current_mtu(); + + if new_mtu != old_mtu { + debug!( + src = %src_addr, + old_mtu, + new_mtu, + "Path MTU changed via notification" + ); + } + } + /// Complete an initiator-side Noise IK handshake given msg2. fn complete_initiator_handshake( mut handshake: HandshakeState, @@ -238,90 +573,6 @@ impl Node { .map_err(|e| format!("into_session failed: {}", e)) } - /// Handle an incoming DataPacket. - /// - /// Decrypts the payload using the established session key and delivers - /// to the TUN interface. - async fn handle_data_packet(&mut self, src_addr: &NodeAddr, inner: &[u8]) { - let packet = match DataPacket::decode(inner) { - Ok(p) => p, - Err(e) => { - debug!(error = %e, "Malformed DataPacket"); - return; - } - }; - - // Remove entry to take ownership for potential state transition - let mut entry = match self.sessions.remove(src_addr) { - Some(e) => e, - None => { - debug!(src = %src_addr, "DataPacket for unknown session"); - return; - } - }; - - // If in Responding state, transition to Established first - // (responder wrote msg2, handshake is complete from our side) - if entry.state().is_responding() { - let old_state = entry.take_state(); - let handshake = match old_state { - Some(EndToEndState::Responding(hs)) => hs, - _ => { - debug!(src = %src_addr, "Unexpected state in DataPacket handler"); - return; - } - }; - let noise_session = match handshake.into_session() { - Ok(s) => s, - Err(e) => { - debug!(error = %e, "Failed to create session from responding handshake"); - return; - } - }; - entry.set_state(EndToEndState::Established(noise_session)); - entry.set_coords_warmup_remaining(self.config.node.session.coords_warmup_packets); - debug!(src = %src_addr, "Session established (responder, on first data)"); - } - - // Decrypt - let session = match entry.state_mut() { - EndToEndState::Established(s) => s, - _ => { - debug!(src = %src_addr, "DataPacket but session not established"); - self.sessions.insert(*src_addr, entry); - return; - } - }; - - let plaintext = match session.decrypt_with_replay_check(&packet.payload, packet.counter) { - Ok(pt) => pt, - Err(e) => { - debug!(error = %e, src = %src_addr, counter = packet.counter, "Session decryption failed"); - self.sessions.insert(*src_addr, entry); - return; - } - }; - - entry.touch(Self::now_ms()); - self.sessions.insert(*src_addr, entry); - - // Deliver to TUN - if let Some(tun_tx) = &self.tun_tx { - if let Err(e) = tun_tx.send(plaintext) { - debug!(error = %e, "Failed to deliver decrypted packet to TUN"); - } - } else { - debug!( - src = %src_addr, - "DataPacket decrypted (no TUN interface, plaintext dropped)" - ); - } - - // Flush any pending outbound packets (e.g., simultaneous initiation - // where responder also had queued outbound packets) - self.flush_pending_packets(src_addr).await; - } - /// Handle a CoordsRequired error signal from a transit router. /// /// The router couldn't route our packet because it lacks cached @@ -431,18 +682,18 @@ impl Node { // Wrap in SessionDatagram let my_addr = *self.node_addr(); - let datagram = SessionDatagram::new(my_addr, dest_addr, setup.encode()) + let mut datagram = SessionDatagram::new(my_addr, dest_addr, setup.encode()) .with_ttl(self.config.node.session.default_ttl); // Route toward destination - self.send_session_datagram(&datagram).await?; + self.send_session_datagram(&mut datagram).await?; // Register destination identity for TUN → session routing self.register_identity(dest_addr, dest_pubkey); // Store session entry let now_ms = Self::now_ms(); - let entry = SessionEntry::new(dest_addr, dest_pubkey, EndToEndState::Initiating(handshake), now_ms); + let entry = SessionEntry::new(dest_addr, dest_pubkey, EndToEndState::Initiating(handshake), now_ms, true); self.sessions.insert(dest_addr, entry); debug!(dest = %dest_addr, "Session initiation started"); @@ -451,18 +702,28 @@ impl Node { /// Send application data over an established session. /// - /// Encrypts the payload with the session key, wraps in DataPacket - /// and SessionDatagram, routes toward destination. + /// 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. pub(in crate::node) async fn send_session_data( &mut self, dest_addr: &NodeAddr, plaintext: &[u8], ) -> Result<(), NodeError> { + let now_ms = Self::now_ms(); let entry = self.sessions.get_mut(dest_addr).ok_or_else(|| NodeError::SendFailed { node_addr: *dest_addr, reason: "no session".into(), })?; + // Check warmup counter and get session timestamp + let include_coords = entry.coords_warmup_remaining() > 0; + if include_coords { + entry.set_coords_warmup_remaining(entry.coords_warmup_remaining() - 1); + } + let timestamp = entry.session_timestamp(now_ms); + let spin_bit = entry.mmp().is_some_and(|m| m.spin_bit.tx_bit()); + let session = match entry.state_mut() { EndToEndState::Established(s) => s, _ => { @@ -476,35 +737,129 @@ impl Node { // Get counter before encrypting (encrypt will increment it) let counter = session.current_send_counter(); - // Encrypt with session key - let ciphertext = session.encrypt(plaintext).map_err(|e| NodeError::SendFailed { - node_addr: *dest_addr, - reason: format!("session encrypt failed: {}", e), + // FSP inner header: [timestamp:4 LE][msg_type:1][inner_flags:1] + plaintext + 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); + + // Build FSP flags + let flags = if include_coords { FSP_FLAG_CP } else { 0 }; + + // Build 12-byte FSP header (used as AAD for AEAD) + let payload_len = inner_plaintext.len() as u16; + let header = build_fsp_header(counter, flags, payload_len); + + // Encrypt with AAD binding to the FSP header + let ciphertext = session.encrypt_with_aad(&inner_plaintext, &header).map_err(|e| { + NodeError::SendFailed { + node_addr: *dest_addr, + reason: format!("session encrypt failed: {}", e), + } })?; - // Check warmup counter and decrement (while entry is still borrowed) - let include_coords = entry.coords_warmup_remaining() > 0; - if include_coords { - entry.set_coords_warmup_remaining(entry.coords_warmup_remaining() - 1); - } - - // Build DataPacket with explicit counter for replay protection - let mut data_packet = DataPacket::new(counter, ciphertext); + // Assemble: header(12) + [coords] + ciphertext + let mut fsp_payload = Vec::with_capacity(FSP_HEADER_SIZE + ciphertext.len() + 200); + fsp_payload.extend_from_slice(&header); if include_coords { let my_coords = self.tree_state.my_coords().clone(); let dest_coords = self.get_dest_coords(dest_addr); - data_packet = data_packet.with_coords(my_coords, dest_coords); + encode_coords(&my_coords, &mut fsp_payload); + encode_coords(&dest_coords, &mut fsp_payload); } + fsp_payload.extend_from_slice(&ciphertext); let my_addr = *self.node_addr(); - let datagram = SessionDatagram::new(my_addr, *dest_addr, data_packet.encode()) + let mut datagram = SessionDatagram::new(my_addr, *dest_addr, fsp_payload) .with_ttl(self.config.node.session.default_ttl); - self.send_session_datagram(&datagram).await?; + self.send_session_datagram(&mut datagram).await?; // Re-borrow after send (which borrowed &mut self) if let Some(entry) = self.sessions.get_mut(dest_addr) { - entry.touch(Self::now_ms()); + if let Some(mmp) = entry.mmp_mut() { + mmp.sender.record_sent(counter, timestamp, ciphertext.len()); + } + entry.touch(now_ms); + } + + Ok(()) + } + + /// Send a non-data session message (reports, notifications) over an established session. + /// + /// Similar to `send_session_data()` but: + /// - Takes an explicit `msg_type` byte (0x11, 0x12, 0x13, etc.) + /// - Never includes COORDS_PRESENT (reports are lightweight) + /// - Reads spin bit from MMP state for the inner header + /// - Records the send in MMP sender state + pub(in crate::node) async fn send_session_msg( + &mut self, + dest_addr: &NodeAddr, + msg_type: u8, + payload: &[u8], + ) -> Result<(), NodeError> { + let now_ms = Self::now_ms(); + + // Read spin bit and session timestamp from entry + let entry = self.sessions.get(dest_addr).ok_or_else(|| NodeError::SendFailed { + node_addr: *dest_addr, + reason: "no session".into(), + })?; + let timestamp = entry.session_timestamp(now_ms); + let spin_bit = entry.mmp().is_some_and(|m| m.spin_bit.tx_bit()); + + // Build inner flags with spin bit + let inner_flags = FspInnerFlags { spin_bit }.to_byte(); + + // Get mutable access for encryption + let entry = self.sessions.get_mut(dest_addr).ok_or_else(|| NodeError::SendFailed { + node_addr: *dest_addr, + reason: "no session".into(), + })?; + let session = match entry.state_mut() { + EndToEndState::Established(s) => s, + _ => { + return Err(NodeError::SendFailed { + node_addr: *dest_addr, + reason: "session not established".into(), + }); + } + }; + + let counter = session.current_send_counter(); + + // FSP inner header + plaintext + let inner_plaintext = fsp_prepend_inner_header(timestamp, msg_type, inner_flags, payload); + + // Build 12-byte FSP header (no flags — no CP for reports) + let payload_len = inner_plaintext.len() as u16; + let header = build_fsp_header(counter, 0, payload_len); + + // Encrypt with AAD + let ciphertext = session.encrypt_with_aad(&inner_plaintext, &header).map_err(|e| { + NodeError::SendFailed { + node_addr: *dest_addr, + reason: format!("session encrypt failed: {}", e), + } + })?; + + // Assemble: header(12) + ciphertext (no coords) + let mut fsp_payload = Vec::with_capacity(FSP_HEADER_SIZE + ciphertext.len()); + fsp_payload.extend_from_slice(&header); + fsp_payload.extend_from_slice(&ciphertext); + + let my_addr = *self.node_addr(); + let mut datagram = SessionDatagram::new(my_addr, *dest_addr, fsp_payload) + .with_ttl(self.config.node.session.default_ttl); + + self.send_session_datagram(&mut datagram).await?; + + // Record in MMP sender state and touch + if let Some(entry) = self.sessions.get_mut(dest_addr) { + if let Some(mmp) = entry.mmp_mut() { + mmp.sender.record_sent(counter, timestamp, ciphertext.len()); + } + entry.touch(now_ms); } Ok(()) @@ -512,11 +867,11 @@ impl Node { /// Route and send a SessionDatagram through the mesh. /// - /// Finds the next hop for the destination and sends the datagram - /// as an encrypted link message. + /// Finds the next hop for the destination, seeds path_mtu from the + /// first-hop transport MTU, and sends as an encrypted link message. async fn send_session_datagram( &mut self, - datagram: &SessionDatagram, + datagram: &mut SessionDatagram, ) -> Result<(), NodeError> { let next_hop_addr = match self.find_next_hop(&datagram.dest_addr) { Some(peer) => *peer.node_addr(), @@ -528,6 +883,23 @@ impl Node { } }; + // Seed path_mtu from the first-hop transport MTU (same as forwarding path) + 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()); + } + + // Source-side: seed our PathMtuState.current_mtu from the outbound + // transport MTU so it doesn't stay at u16::MAX until the destination + // sends a PathMtuNotification back. + if let Some(entry) = self.sessions.get_mut(&datagram.dest_addr) + && let Some(mmp) = entry.mmp_mut() + { + mmp.path_mtu.seed_source_mtu(datagram.path_mtu); + } + let encoded = datagram.encode(); self.send_encrypted_link_message(&next_hop_addr, &encoded).await } diff --git a/src/node/handlers/timeout.rs b/src/node/handlers/timeout.rs index e6cbdd4..0acc57d 100644 --- a/src/node/handlers/timeout.rs +++ b/src/node/handlers/timeout.rs @@ -98,6 +98,12 @@ impl Node { .collect(); for addr in idle { + // Log MMP teardown metrics before removing the session + if let Some(entry) = self.sessions.get(&addr) + && let Some(mmp) = entry.mmp() + { + Self::log_session_mmp_teardown(&addr, mmp); + } self.sessions.remove(&addr); self.pending_tun_packets.remove(&addr); debug!( diff --git a/src/node/mod.rs b/src/node/mod.rs index 4bb7634..0b194ab 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -11,6 +11,7 @@ mod retry; mod rate_limit; mod routing_error_rate_limit; pub(crate) mod session; +pub(crate) mod session_wire; pub(crate) mod wire; mod tree; #[cfg(test)] diff --git a/src/node/session.rs b/src/node/session.rs index 903d7ec..d744bd1 100644 --- a/src/node/session.rs +++ b/src/node/session.rs @@ -4,6 +4,8 @@ //! Sessions are established via SessionSetup/SessionAck handshake //! messages carried inside SessionDatagram envelopes through the mesh. +use crate::config::SessionMmpConfig; +use crate::mmp::MmpSessionState; use crate::noise::{HandshakeState, NoiseSession}; use crate::NodeAddr; use secp256k1::PublicKey; @@ -54,10 +56,19 @@ pub(crate) struct SessionEntry { created_at: u64, /// Last activity timestamp (Unix milliseconds). last_activity: u64, - /// Remaining DataPackets that should include COORDS_PRESENT. + /// When the session transitioned to Established (Unix milliseconds). + /// Used to compute session-relative timestamps for the FSP inner header. + /// Set to 0 until the session is established. + session_start_ms: u64, + /// Remaining data packets that should include COORDS_PRESENT. /// Initialized from config when session becomes Established; /// reset on CoordsRequired receipt. coords_warmup_remaining: u8, + /// Whether this node initiated the Noise IK handshake. + /// Used for spin bit role assignment in session-layer MMP. + is_initiator: bool, + /// Session-layer MMP state. Initialized on Established transition. + mmp: Option, } impl SessionEntry { @@ -67,6 +78,7 @@ impl SessionEntry { remote_pubkey: PublicKey, state: EndToEndState, now_ms: u64, + is_initiator: bool, ) -> Self { Self { remote_addr, @@ -74,7 +86,10 @@ impl SessionEntry { state: Some(state), created_at: now_ms, last_activity: now_ms, + session_start_ms: 0, coords_warmup_remaining: 0, + is_initiator, + mmp: None, } } @@ -132,4 +147,41 @@ impl SessionEntry { pub(crate) fn set_coords_warmup_remaining(&mut self, value: u8) { self.coords_warmup_remaining = value; } + + /// Mark the session as started (transition to Established). + /// + /// Records the current time as the session start for computing + /// session-relative timestamps in the FSP inner header. + pub(crate) fn mark_established(&mut self, now_ms: u64) { + self.session_start_ms = now_ms; + } + + /// Compute a session-relative timestamp for the FSP inner header. + /// + /// Returns `(now_ms - session_start_ms)` truncated to u32. + /// Wraps naturally at ~49.7 days, which is fine for relative timing. + pub(crate) fn session_timestamp(&self, now_ms: u64) -> u32 { + now_ms.wrapping_sub(self.session_start_ms) as u32 + } + + /// Whether this node initiated the Noise IK handshake. + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn is_initiator(&self) -> bool { + self.is_initiator + } + + /// Get a reference to the session-layer MMP state, if initialized. + pub(crate) fn mmp(&self) -> Option<&MmpSessionState> { + self.mmp.as_ref() + } + + /// Get a mutable reference to the session-layer MMP state, if initialized. + pub(crate) fn mmp_mut(&mut self) -> Option<&mut MmpSessionState> { + self.mmp.as_mut() + } + + /// Initialize session-layer MMP state (called on Established transition). + pub(crate) fn init_mmp(&mut self, config: &SessionMmpConfig) { + self.mmp = Some(MmpSessionState::new(config, self.is_initiator)); + } } diff --git a/src/node/session_wire.rs b/src/node/session_wire.rs new file mode 100644 index 0000000..b9c063b --- /dev/null +++ b/src/node/session_wire.rs @@ -0,0 +1,582 @@ +//! FSP Wire Format Parsing and Serialization +//! +//! Defines the FIPS session-layer wire format (FSP) for packet dispatch. +//! All FSP messages begin with a 4-byte common prefix followed by phase-specific +//! fields. Encrypted messages use a 12-byte cleartext header as AAD for AEAD, +//! and a 6-byte encrypted inner header containing timestamps and message type. +//! +//! ## Common Prefix (4 bytes) +//! +//! ```text +//! [ver+phase:1][flags:1][payload_len:2 LE] +//! ``` +//! +//! ## Message Classes +//! +//! | Phase | U Flag | Type | Description | +//! |-------|--------|------------------|-----------------------------------| +//! | 0x0 | 0 | Encrypted | Post-handshake encrypted data | +//! | 0x0 | 1 | Plaintext error | CoordsRequired, PathBroken | +//! | 0x1 | - | Handshake msg1 | SessionSetup (Noise IK msg1) | +//! | 0x2 | - | Handshake msg2 | SessionAck (Noise IK msg2) | + +use crate::protocol::{ProtocolError, decode_optional_coords}; +use crate::tree::TreeCoordinate; + +// ============================================================================ +// Constants +// ============================================================================ + +/// FSP protocol version (4 high bits of byte 0). +pub const FSP_VERSION: u8 = 0; + +/// Phase value for established (encrypted or plaintext error) messages. +pub const FSP_PHASE_ESTABLISHED: u8 = 0x0; + +/// Phase value for SessionSetup (Noise IK message 1). +pub const FSP_PHASE_MSG1: u8 = 0x1; + +/// Phase value for SessionAck (Noise IK message 2). +pub const FSP_PHASE_MSG2: u8 = 0x2; + +/// Size of the common packet prefix (all FSP message types). +pub const FSP_COMMON_PREFIX_SIZE: usize = 4; + +/// Size of the full encrypted message header (prefix + counter). +pub const FSP_HEADER_SIZE: usize = 12; + +/// Size of the encrypted inner header (timestamp + msg_type + inner_flags). +pub const FSP_INNER_HEADER_SIZE: usize = 6; + +/// AEAD authentication tag size (ChaCha20-Poly1305). +const TAG_SIZE: usize = 16; + +/// Minimum size for an encrypted FSP message: header + tag (no plaintext). +pub const FSP_ENCRYPTED_MIN_SIZE: usize = FSP_HEADER_SIZE + TAG_SIZE; // 28 bytes + +// Cleartext flag bit constants (byte 1 of common prefix, phase 0x0 only). + +/// Coords Present — source and destination coordinates follow the header. +pub const FSP_FLAG_CP: u8 = 0x01; + +/// Key Epoch — selects active key during rekeying. +#[allow(dead_code)] +pub const FSP_FLAG_K: u8 = 0x02; + +/// Unencrypted — payload is plaintext (error signals). +pub const FSP_FLAG_U: u8 = 0x04; + +// Inner flag bit constants (byte 5 of decrypted inner header). + +/// Spin bit for end-to-end RTT measurement (inside AEAD). +#[allow(dead_code)] +pub const FSP_INNER_FLAG_SP: u8 = 0x01; + +// ============================================================================ +// Common Prefix +// ============================================================================ + +/// Parsed FSP common packet prefix (first 4 bytes of every FSP message). +/// +/// Wire format: +/// ```text +/// [ver(4bits)+phase(4bits)][flags:1][payload_len:2 LE] +/// ``` +#[derive(Clone, Debug)] +pub struct FspCommonPrefix { + /// Protocol version (high nibble of byte 0). + #[cfg_attr(not(test), allow(dead_code))] + pub version: u8, + /// Session lifecycle phase (low nibble of byte 0). + pub phase: u8, + /// Per-message signal flags. + pub flags: u8, + /// Length of payload following the phase-specific header. + #[cfg_attr(not(test), allow(dead_code))] + pub payload_len: u16, +} + +impl FspCommonPrefix { + /// Parse a common prefix from the first 4 bytes of FSP message data. + pub fn parse(data: &[u8]) -> Option { + if data.len() < FSP_COMMON_PREFIX_SIZE { + return None; + } + + let version = data[0] >> 4; + let phase = data[0] & 0x0F; + let flags = data[1]; + let payload_len = u16::from_le_bytes([data[2], data[3]]); + + Some(Self { + version, + phase, + flags, + payload_len, + }) + } + + /// Check if the Unencrypted flag is set. + pub fn is_unencrypted(&self) -> bool { + self.flags & FSP_FLAG_U != 0 + } + + /// Check if the Coords Present flag is set. + pub fn has_coords(&self) -> bool { + self.flags & FSP_FLAG_CP != 0 + } + + /// Encode the ver+phase byte. + fn ver_phase_byte(version: u8, phase: u8) -> u8 { + (version << 4) | (phase & 0x0F) + } +} + +// ============================================================================ +// Encrypted Message Header +// ============================================================================ + +/// Parsed FSP encrypted message header (phase 0x0, U flag clear). +/// +/// Wire format (12 bytes): +/// ```text +/// [ver+phase:1][flags:1][payload_len:2 LE][counter:8 LE] +/// ``` +/// +/// The full 12-byte header is used as AAD for the AEAD construction. +/// No receiver_idx — unlike FLP, FSP is end-to-end (dispatched by src_addr +/// from the SessionDatagram envelope, not by index). +#[derive(Clone, Debug)] +pub struct FspEncryptedHeader { + /// Per-message flags (CP, K). + pub flags: u8, + /// Length of encrypted payload (excluding AEAD tag). + #[cfg_attr(not(test), allow(dead_code))] + pub payload_len: u16, + /// Monotonic counter used as AEAD nonce. + pub counter: u64, + /// Raw 12-byte header for use as AEAD AAD. + pub header_bytes: [u8; FSP_HEADER_SIZE], +} + +impl FspEncryptedHeader { + /// Parse an encrypted message header from FSP message data. + /// + /// Returns None if the data is too short or has wrong version/phase, + /// or if the U flag is set (plaintext messages use a different path). + pub fn parse(data: &[u8]) -> Option { + if data.len() < FSP_ENCRYPTED_MIN_SIZE { + return None; + } + + let version = data[0] >> 4; + let phase = data[0] & 0x0F; + + if version != FSP_VERSION || phase != FSP_PHASE_ESTABLISHED { + return None; + } + + let flags = data[1]; + + // U flag means plaintext — not an encrypted message + if flags & FSP_FLAG_U != 0 { + return None; + } + + let payload_len = u16::from_le_bytes([data[2], data[3]]); + let counter = u64::from_le_bytes([ + data[4], data[5], data[6], data[7], + data[8], data[9], data[10], data[11], + ]); + + let mut header_bytes = [0u8; FSP_HEADER_SIZE]; + header_bytes.copy_from_slice(&data[..FSP_HEADER_SIZE]); + + Some(Self { + flags, + payload_len, + counter, + header_bytes, + }) + } + + /// Check if the Coords Present flag is set. + pub fn has_coords(&self) -> bool { + self.flags & FSP_FLAG_CP != 0 + } + + /// Offset where ciphertext (or coords if CP) begins in the original data. + #[cfg_attr(not(test), allow(dead_code))] + pub fn data_offset(&self) -> usize { + FSP_HEADER_SIZE + } +} + +// ============================================================================ +// Serialization Helpers +// ============================================================================ + +/// Build the 12-byte cleartext header for an encrypted FSP message. +/// +/// Returns the header bytes for use as AEAD AAD. +pub fn build_fsp_header( + counter: u64, + flags: u8, + payload_len: u16, +) -> [u8; FSP_HEADER_SIZE] { + let mut header = [0u8; FSP_HEADER_SIZE]; + header[0] = FspCommonPrefix::ver_phase_byte(FSP_VERSION, FSP_PHASE_ESTABLISHED); + header[1] = flags; + header[2..4].copy_from_slice(&payload_len.to_le_bytes()); + header[4..12].copy_from_slice(&counter.to_le_bytes()); + header +} + +/// Assemble a wire-format encrypted FSP message. +/// +/// Format: `[header:12][ciphertext+tag]` +#[cfg_attr(not(test), allow(dead_code))] +pub fn build_fsp_encrypted(header: &[u8; FSP_HEADER_SIZE], ciphertext: &[u8]) -> Vec { + let mut packet = Vec::with_capacity(FSP_HEADER_SIZE + ciphertext.len()); + packet.extend_from_slice(header); + packet.extend_from_slice(ciphertext); + packet +} + +/// Build a 4-byte common prefix for a handshake message. +/// +/// `phase` should be `FSP_PHASE_MSG1` or `FSP_PHASE_MSG2`. +/// Flags are zero during handshake. +#[cfg_attr(not(test), allow(dead_code))] +pub fn build_fsp_handshake_prefix(phase: u8, payload_len: u16) -> [u8; FSP_COMMON_PREFIX_SIZE] { + let mut prefix = [0u8; FSP_COMMON_PREFIX_SIZE]; + prefix[0] = FspCommonPrefix::ver_phase_byte(FSP_VERSION, phase); + prefix[1] = 0x00; // flags must be zero during handshake + prefix[2..4].copy_from_slice(&payload_len.to_le_bytes()); + prefix +} + +/// Build a 4-byte common prefix for a plaintext error signal. +/// +/// Sets phase 0x0 and U flag. +#[cfg_attr(not(test), allow(dead_code))] +pub fn build_fsp_error_prefix(payload_len: u16) -> [u8; FSP_COMMON_PREFIX_SIZE] { + let mut prefix = [0u8; FSP_COMMON_PREFIX_SIZE]; + prefix[0] = FspCommonPrefix::ver_phase_byte(FSP_VERSION, FSP_PHASE_ESTABLISHED); + prefix[1] = FSP_FLAG_U; + prefix[2..4].copy_from_slice(&payload_len.to_le_bytes()); + prefix +} + +// ============================================================================ +// Inner Header Helpers +// ============================================================================ + +/// Prepend the 6-byte FSP inner header to a message payload. +/// +/// Inner header: `[timestamp:4 LE][msg_type:1][inner_flags:1]` +/// +/// The caller provides the message-type-specific payload (e.g., application +/// data for msg_type 0x10, report fields for SenderReport). This function +/// prepends the inner header. +pub fn fsp_prepend_inner_header( + timestamp_ms: u32, + msg_type: u8, + inner_flags: u8, + payload: &[u8], +) -> Vec { + let mut buf = Vec::with_capacity(FSP_INNER_HEADER_SIZE + payload.len()); + buf.extend_from_slice(×tamp_ms.to_le_bytes()); + buf.push(msg_type); + buf.push(inner_flags); + buf.extend_from_slice(payload); + buf +} + +/// Strip the 6-byte FSP inner header from a decrypted payload. +/// +/// Returns `(timestamp, msg_type, inner_flags, &rest)` or None if too short. +pub fn fsp_strip_inner_header(plaintext: &[u8]) -> Option<(u32, u8, u8, &[u8])> { + if plaintext.len() < FSP_INNER_HEADER_SIZE { + return None; + } + let timestamp = u32::from_le_bytes([ + plaintext[0], plaintext[1], plaintext[2], plaintext[3], + ]); + let msg_type = plaintext[4]; + let inner_flags = plaintext[5]; + Some((timestamp, msg_type, inner_flags, &plaintext[FSP_INNER_HEADER_SIZE..])) +} + +// ============================================================================ +// Coordinate Parsing (for transit nodes and receive path) +// ============================================================================ + +/// Parse source and destination coordinates from the cleartext section +/// of an encrypted FSP message when the CP flag is set. +/// +/// Coordinates appear between the 12-byte header and the ciphertext: +/// `[src_coords_count:2 LE][src_coords:16×n][dest_coords_count:2 LE][dest_coords:16×m]` +/// +/// Returns `(src_coords, dest_coords, bytes_consumed)`. +pub fn parse_encrypted_coords( + data: &[u8], +) -> Result<(Option, Option, usize), ProtocolError> { + let (src_coords, src_consumed) = decode_optional_coords(data)?; + let (dest_coords, dest_consumed) = decode_optional_coords(&data[src_consumed..])?; + Ok((src_coords, dest_coords, src_consumed + dest_consumed)) +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + // ===== Size Constant Tests ===== + + #[test] + fn test_wire_sizes() { + assert_eq!(FSP_COMMON_PREFIX_SIZE, 4); + assert_eq!(FSP_HEADER_SIZE, 12); + assert_eq!(FSP_INNER_HEADER_SIZE, 6); + assert_eq!(FSP_ENCRYPTED_MIN_SIZE, 28); // 12 + 16 + } + + // ===== Common Prefix Tests ===== + + #[test] + fn test_common_prefix_parse_established() { + let data = [0x00, 0x01, 0x40, 0x00]; // ver=0, phase=0, flags=CP, payload_len=64 + let prefix = FspCommonPrefix::parse(&data).unwrap(); + assert_eq!(prefix.version, 0); + assert_eq!(prefix.phase, FSP_PHASE_ESTABLISHED); + assert_eq!(prefix.flags, FSP_FLAG_CP); + assert_eq!(prefix.payload_len, 64); + assert!(prefix.has_coords()); + assert!(!prefix.is_unencrypted()); + } + + #[test] + fn test_common_prefix_parse_handshake() { + let data = [0x01, 0x00, 0x50, 0x00]; // ver=0, phase=1, flags=0, payload_len=80 + let prefix = FspCommonPrefix::parse(&data).unwrap(); + assert_eq!(prefix.version, 0); + assert_eq!(prefix.phase, FSP_PHASE_MSG1); + assert_eq!(prefix.flags, 0); + assert_eq!(prefix.payload_len, 80); + } + + #[test] + fn test_common_prefix_parse_error_signal() { + let data = [0x00, FSP_FLAG_U, 0x22, 0x00]; // ver=0, phase=0, U flag, payload_len=34 + let prefix = FspCommonPrefix::parse(&data).unwrap(); + assert_eq!(prefix.phase, FSP_PHASE_ESTABLISHED); + assert!(prefix.is_unencrypted()); + assert_eq!(prefix.payload_len, 34); + } + + #[test] + fn test_common_prefix_too_short() { + assert!(FspCommonPrefix::parse(&[0, 0, 0]).is_none()); + } + + // ===== Encrypted Header Tests ===== + + #[test] + fn test_encrypted_header_parse() { + let counter = 42u64; + let flags = FSP_FLAG_CP; + let payload_len = 100u16; + let header = build_fsp_header(counter, flags, payload_len); + + // Build a minimal packet: header + 16 bytes of fake ciphertext (tag) + let mut packet = Vec::from(header); + packet.extend_from_slice(&[0xaa; TAG_SIZE]); + + let parsed = FspEncryptedHeader::parse(&packet).unwrap(); + assert_eq!(parsed.counter, 42); + assert_eq!(parsed.flags, FSP_FLAG_CP); + assert_eq!(parsed.payload_len, 100); + assert!(parsed.has_coords()); + assert_eq!(parsed.header_bytes, header); + assert_eq!(parsed.data_offset(), FSP_HEADER_SIZE); + } + + #[test] + fn test_encrypted_header_too_short() { + let packet = vec![0x00; FSP_ENCRYPTED_MIN_SIZE - 1]; + assert!(FspEncryptedHeader::parse(&packet).is_none()); + } + + #[test] + fn test_encrypted_header_wrong_phase() { + let mut packet = vec![0x00; FSP_ENCRYPTED_MIN_SIZE]; + packet[0] = 0x01; // phase 1 (msg1), not established + assert!(FspEncryptedHeader::parse(&packet).is_none()); + } + + #[test] + fn test_encrypted_header_wrong_version() { + let mut packet = vec![0x00; FSP_ENCRYPTED_MIN_SIZE]; + packet[0] = 0x10; // version 1, phase 0 + assert!(FspEncryptedHeader::parse(&packet).is_none()); + } + + #[test] + fn test_encrypted_header_u_flag_rejected() { + let mut packet = vec![0x00; FSP_ENCRYPTED_MIN_SIZE]; + packet[1] = FSP_FLAG_U; // U flag set → not encrypted + assert!(FspEncryptedHeader::parse(&packet).is_none()); + } + + // ===== Build Header Tests ===== + + #[test] + fn test_build_fsp_header() { + let header = build_fsp_header(1000, FSP_FLAG_CP, 200); + assert_eq!(header[0], 0x00); // ver=0, phase=0 + assert_eq!(header[1], FSP_FLAG_CP); + assert_eq!(u16::from_le_bytes([header[2], header[3]]), 200); + assert_eq!( + u64::from_le_bytes([ + header[4], header[5], header[6], header[7], + header[8], header[9], header[10], header[11], + ]), + 1000 + ); + } + + #[test] + fn test_build_fsp_encrypted() { + let header = build_fsp_header(0, 0, 10); + let ciphertext = vec![0xCC; 26]; // 10 payload + 16 tag + let packet = build_fsp_encrypted(&header, &ciphertext); + assert_eq!(packet.len(), FSP_HEADER_SIZE + 26); + assert_eq!(&packet[..FSP_HEADER_SIZE], &header); + assert_eq!(&packet[FSP_HEADER_SIZE..], &ciphertext[..]); + } + + // ===== Handshake Prefix Tests ===== + + #[test] + fn test_build_fsp_handshake_prefix_msg1() { + let prefix = build_fsp_handshake_prefix(FSP_PHASE_MSG1, 100); + assert_eq!(prefix[0], 0x01); // ver=0, phase=1 + assert_eq!(prefix[1], 0x00); // flags zero + assert_eq!(u16::from_le_bytes([prefix[2], prefix[3]]), 100); + + let parsed = FspCommonPrefix::parse(&prefix).unwrap(); + assert_eq!(parsed.phase, FSP_PHASE_MSG1); + } + + #[test] + fn test_build_fsp_handshake_prefix_msg2() { + let prefix = build_fsp_handshake_prefix(FSP_PHASE_MSG2, 50); + assert_eq!(prefix[0], 0x02); // ver=0, phase=2 + assert_eq!(prefix[1], 0x00); + assert_eq!(u16::from_le_bytes([prefix[2], prefix[3]]), 50); + } + + // ===== Error Prefix Tests ===== + + #[test] + fn test_build_fsp_error_prefix() { + let prefix = build_fsp_error_prefix(34); + assert_eq!(prefix[0], 0x00); // ver=0, phase=0 + assert_eq!(prefix[1], FSP_FLAG_U); + assert_eq!(u16::from_le_bytes([prefix[2], prefix[3]]), 34); + + let parsed = FspCommonPrefix::parse(&prefix).unwrap(); + assert!(parsed.is_unencrypted()); + assert_eq!(parsed.phase, FSP_PHASE_ESTABLISHED); + } + + // ===== Inner Header Tests ===== + + #[test] + fn test_inner_header_prepend_strip() { + let timestamp: u32 = 12345; + let msg_type: u8 = 0x10; + let inner_flags: u8 = 0x01; // SP bit + let payload = vec![0xAA, 0xBB, 0xCC]; + + let with_header = fsp_prepend_inner_header(timestamp, msg_type, inner_flags, &payload); + assert_eq!(with_header.len(), FSP_INNER_HEADER_SIZE + 3); + + let (ts, mt, flags, rest) = fsp_strip_inner_header(&with_header).unwrap(); + assert_eq!(ts, 12345); + assert_eq!(mt, 0x10); + assert_eq!(flags, 0x01); + assert_eq!(rest, &payload[..]); + } + + #[test] + fn test_inner_header_empty_payload() { + let with_header = fsp_prepend_inner_header(0, 0x13, 0, &[]); + assert_eq!(with_header.len(), FSP_INNER_HEADER_SIZE); + + let (ts, mt, flags, rest) = fsp_strip_inner_header(&with_header).unwrap(); + assert_eq!(ts, 0); + assert_eq!(mt, 0x13); + assert_eq!(flags, 0); + assert!(rest.is_empty()); + } + + #[test] + fn test_inner_header_too_short() { + assert!(fsp_strip_inner_header(&[0, 0, 0, 0, 0]).is_none()); // needs 6 bytes + assert!(fsp_strip_inner_header(&[]).is_none()); + } + + // ===== Flag Constants Tests ===== + + #[test] + fn test_flag_bits_distinct() { + // Cleartext flags don't overlap + assert_eq!(FSP_FLAG_CP & FSP_FLAG_K, 0); + assert_eq!(FSP_FLAG_CP & FSP_FLAG_U, 0); + assert_eq!(FSP_FLAG_K & FSP_FLAG_U, 0); + } + + #[test] + fn test_header_roundtrip() { + let counter = 0xDEADBEEF_12345678u64; + let flags = FSP_FLAG_CP | FSP_FLAG_K; + let payload_len = 1234u16; + + let header = build_fsp_header(counter, flags, payload_len); + let ciphertext = vec![0xFF; payload_len as usize + TAG_SIZE]; + let packet = build_fsp_encrypted(&header, &ciphertext); + + let parsed = FspEncryptedHeader::parse(&packet).unwrap(); + assert_eq!(parsed.counter, counter); + assert_eq!(parsed.flags, flags); + assert_eq!(parsed.payload_len, payload_len); + assert!(parsed.has_coords()); + assert_eq!(parsed.header_bytes, header); + } + + #[test] + fn test_all_message_types_through_prefix() { + // Encrypted (phase 0, no U) + let prefix = FspCommonPrefix::parse(&[0x00, 0x00, 0x10, 0x00]).unwrap(); + assert_eq!(prefix.phase, 0); + assert!(!prefix.is_unencrypted()); + + // Error signal (phase 0, U set) + let prefix = FspCommonPrefix::parse(&[0x00, FSP_FLAG_U, 0x22, 0x00]).unwrap(); + assert_eq!(prefix.phase, 0); + assert!(prefix.is_unencrypted()); + + // SessionSetup (phase 1) + let prefix = FspCommonPrefix::parse(&[0x01, 0x00, 0x50, 0x00]).unwrap(); + assert_eq!(prefix.phase, 1); + + // SessionAck (phase 2) + let prefix = FspCommonPrefix::parse(&[0x02, 0x00, 0x21, 0x00]).unwrap(); + assert_eq!(prefix.phase, 2); + } +} diff --git a/src/node/tests/forwarding.rs b/src/node/tests/forwarding.rs index ffd4435..1cdb2c6 100644 --- a/src/node/tests/forwarding.rs +++ b/src/node/tests/forwarding.rs @@ -5,7 +5,8 @@ //! multi-hop forwarding through live node topologies. use super::*; -use crate::protocol::{DataPacket, SessionAck, SessionDatagram, SessionSetup}; +use crate::node::session_wire::{build_fsp_header, FSP_FLAG_CP}; +use crate::protocol::{SessionAck, SessionDatagram, SessionSetup, encode_coords}; use crate::tree::TreeCoordinate; use spanning_tree::{ cleanup_nodes, process_available_packets, run_tree_test, verify_tree_convergence, @@ -184,7 +185,7 @@ async fn test_coord_cache_warming_session_ack() { } #[tokio::test] -async fn test_coord_cache_warming_data_packet_with_coords() { +async fn test_coord_cache_warming_encrypted_msg_with_coords() { let mut node = make_node(); let from = make_node_addr(0xAA); let src_addr = make_node_addr(0x01); @@ -194,9 +195,13 @@ async fn test_coord_cache_warming_data_packet_with_coords() { let src_coords = TreeCoordinate::from_addrs(vec![src_addr, root_addr]).unwrap(); let dest_coords = TreeCoordinate::from_addrs(vec![dest_addr, root_addr]).unwrap(); - let data = DataPacket::new(0, vec![1, 2, 3, 4]) - .with_coords(src_coords.clone(), dest_coords.clone()); - let data_payload = data.encode(); + // Build FSP encrypted message with CP flag: header(12) + coords + fake_ciphertext + let header = build_fsp_header(0, FSP_FLAG_CP, 20); + let mut data_payload = Vec::new(); + data_payload.extend_from_slice(&header); + encode_coords(&src_coords, &mut data_payload); + encode_coords(&dest_coords, &mut data_payload); + data_payload.extend_from_slice(&[0xCC; 36]); // fake ciphertext (20 payload + 16 tag) let dg = SessionDatagram::new(src_addr, dest_addr, data_payload); let encoded = dg.encode(); @@ -213,24 +218,26 @@ async fn test_coord_cache_warming_data_packet_with_coords() { assert!( node.coord_cache().get(&src_addr, now_ms).is_some(), - "src coords not cached from DataPacket" + "src coords not cached from encrypted message" ); assert!( node.coord_cache().get(&dest_addr, now_ms).is_some(), - "dest coords not cached from DataPacket" + "dest coords not cached from encrypted message" ); } #[tokio::test] -async fn test_coord_cache_warming_opaque_data_packet() { +async fn test_coord_cache_warming_encrypted_msg_no_coords() { let mut node = make_node(); let from = make_node_addr(0xAA); let src_addr = make_node_addr(0x01); let dest_addr = make_node_addr(0x02); - // DataPacket without COORDS_PRESENT — no coords to cache - let data = DataPacket::new(0, vec![1, 2, 3, 4]); - let data_payload = data.encode(); + // Build FSP encrypted message without CP flag: header(12) + fake_ciphertext + let header = build_fsp_header(0, 0, 20); + let mut data_payload = Vec::new(); + data_payload.extend_from_slice(&header); + data_payload.extend_from_slice(&[0xCC; 36]); // fake ciphertext (20 payload + 16 tag) let dg = SessionDatagram::new(src_addr, dest_addr, data_payload); let encoded = dg.encode(); @@ -244,11 +251,11 @@ async fn test_coord_cache_warming_opaque_data_packet() { assert!( node.coord_cache().get(&src_addr, now_ms).is_none(), - "Should not cache coords from opaque DataPacket" + "Should not cache coords from message without CP flag" ); assert!( node.coord_cache().get(&dest_addr, now_ms).is_none(), - "Should not cache coords from opaque DataPacket" + "Should not cache coords from message without CP flag" ); } diff --git a/src/node/tests/session.rs b/src/node/tests/session.rs index df1020d..f33a09f 100644 --- a/src/node/tests/session.rs +++ b/src/node/tests/session.rs @@ -60,6 +60,7 @@ fn test_session_entry_new_initiating() { identity_b.pubkey_full(), EndToEndState::Initiating(handshake), 1000, + true, ); assert!(entry.state().is_initiating()); @@ -86,6 +87,7 @@ fn test_session_entry_touch() { identity_b.pubkey_full(), EndToEndState::Initiating(handshake), 1000, + true, ); entry.touch(2000); @@ -111,6 +113,7 @@ fn test_session_table_operations() { identity_b.pubkey_full(), EndToEndState::Initiating(handshake), 1000, + true, ); node.sessions.insert(dest_addr, entry); @@ -223,10 +226,10 @@ async fn test_session_direct_peer_data_transfer() { .await .expect("send_session_data failed"); - // Process packets: DataPacket arrives at Node 1 + // Process packets: encrypted data arrives at Node 1 tokio::time::sleep(Duration::from_millis(20)).await; let count = process_available_packets(&mut nodes).await; - assert!(count > 0, "Expected DataPacket to arrive"); + assert!(count > 0, "Expected encrypted data to arrive"); // Node 1's session should now be Established (was Responding, transitions on first data) assert!(nodes[1] @@ -958,7 +961,7 @@ async fn test_tun_outbound_established_session() { nodes[0].node.handle_tun_outbound(ipv6_packet.clone()).await; - // Process packets: encrypted DataPacket → Node 1 + // Process packets: encrypted data → Node 1 tokio::time::sleep(Duration::from_millis(20)).await; process_available_packets(&mut nodes).await; @@ -1176,6 +1179,7 @@ fn test_purge_idle_sessions_removes_expired() { remote.pubkey_full(), EndToEndState::Established(session), 1000, // created at t=1000ms + true, ); node.sessions.insert(remote_addr, entry); @@ -1201,6 +1205,7 @@ fn test_purge_idle_sessions_keeps_active() { remote.pubkey_full(), EndToEndState::Established(session), 1000, + true, ); // Touch at t=80s — recent activity @@ -1232,6 +1237,7 @@ fn test_purge_idle_sessions_ignores_initiating() { remote.pubkey_full(), EndToEndState::Initiating(handshake), 1000, + true, ); node.sessions.insert(remote_addr, entry); @@ -1255,6 +1261,7 @@ fn test_purge_idle_sessions_cleans_pending_packets() { remote.pubkey_full(), EndToEndState::Established(session), 1000, + true, ); node.sessions.insert(remote_addr, entry); @@ -1288,6 +1295,7 @@ fn test_purge_idle_sessions_disabled_when_zero() { remote.pubkey_full(), EndToEndState::Established(session), 1000, + true, ); node.sessions.insert(remote_addr, entry); @@ -1320,6 +1328,7 @@ fn test_coords_warmup_counter_default_zero_on_new() { identity_b.pubkey_full(), EndToEndState::Initiating(handshake), 1000, + true, ); assert_eq!(entry.coords_warmup_remaining(), 0, @@ -1338,6 +1347,7 @@ fn test_coords_warmup_counter_set_and_get() { remote.pubkey_full(), EndToEndState::Established(session), 1000, + true, ); assert_eq!(entry.coords_warmup_remaining(), 0); @@ -1361,6 +1371,7 @@ fn test_coords_warmup_counter_decrement() { remote.pubkey_full(), EndToEndState::Established(session), 1000, + true, ); entry.set_coords_warmup_remaining(3); diff --git a/src/protocol/link.rs b/src/protocol/link.rs index 58e8a42..49fd09c 100644 --- a/src/protocol/link.rs +++ b/src/protocol/link.rs @@ -272,9 +272,9 @@ impl Disconnect { /// | 20 | dest_addr | 16 bytes | Destination node_addr | /// | 36 | payload | variable | Session-layer message | /// -/// The payload is either end-to-end encrypted (SessionSetup, SessionAck, -/// DataPacket) or plaintext link-layer error signals (CoordsRequired, -/// PathBroken) generated by transit routers. +/// The payload is either end-to-end encrypted (handshake messages, data, +/// reports) or plaintext error signals (CoordsRequired, PathBroken) +/// generated by transit routers. #[derive(Clone, Debug)] pub struct SessionDatagram { /// Source node address (originator of this datagram). @@ -526,7 +526,7 @@ mod tests { fn test_session_datagram_encode_decode() { let src = make_node_addr(0xAA); let dest = make_node_addr(0xBB); - let payload = vec![0x10, 0x00, 0x05, 0x00, 1, 2, 3, 4, 5]; // DataPacket payload + let payload = vec![0x10, 0x00, 0x05, 0x00, 1, 2, 3, 4, 5]; // session payload let dg = SessionDatagram::new(src, dest, payload.clone()) .with_ttl(32); diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 9f560fb..309e87d 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -37,10 +37,12 @@ pub use tree::TreeAnnounce; pub use filter::FilterAnnounce; pub use discovery::{LookupRequest, LookupResponse}; pub use session::{ - CoordsRequired, DataFlags, DataPacket, PathBroken, SessionAck, SessionFlags, - SessionMessageType, SessionSetup, COORDS_REQUIRED_SIZE, DATA_FLAG_COORDS_PRESENT, - DATA_HEADER_SIZE, + CoordsRequired, FspFlags, FspInnerFlags, PathBroken, PathMtuNotification, SessionAck, + SessionFlags, SessionMessageType, SessionReceiverReport, SessionSenderReport, SessionSetup, + COORDS_REQUIRED_SIZE, PATH_MTU_NOTIFICATION_SIZE, SESSION_RECEIVER_REPORT_SIZE, + SESSION_SENDER_REPORT_SIZE, }; +pub(crate) use session::{decode_optional_coords, encode_coords}; /// Protocol version for message compatibility. pub const PROTOCOL_VERSION: u8 = 1; diff --git a/src/protocol/session.rs b/src/protocol/session.rs index 67d55cb..a41b9f4 100644 --- a/src/protocol/session.rs +++ b/src/protocol/session.rs @@ -12,10 +12,10 @@ use std::fmt; /// SessionDatagram payload message type identifiers. /// /// These messages are carried as payloads inside `SessionDatagram` (link -/// message type 0x00). Session-layer messages (SessionSetup, SessionAck, -/// DataPacket) are end-to-end encrypted with session keys. Error signals -/// (CoordsRequired, PathBroken) are plaintext link-layer messages generated -/// by transit routers that cannot establish e2e sessions with the source. +/// message type 0x00). Post-handshake messages (data, reports) are end-to-end +/// encrypted with session keys via the FSP pipeline. Error signals +/// (CoordsRequired, PathBroken) are plaintext messages generated by transit +/// routers that cannot establish e2e sessions with the source. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(u8)] pub enum SessionMessageType { @@ -25,9 +25,15 @@ pub enum SessionMessageType { /// Session acknowledgement. SessionAck = 0x01, - // Data (0x10-0x1F) - /// Encrypted IPv6 datagram payload. + // Data and metrics (0x10-0x1F) — encrypted, inner header msg_type + /// Encrypted IPv6 datagram payload (TUN delivery). DataPacket = 0x10, + /// MMP sender report (metrics from sender to receiver). + SenderReport = 0x11, + /// MMP receiver report (metrics from receiver to sender). + ReceiverReport = 0x12, + /// Path MTU notification (discovered path MTU). + PathMtuNotification = 0x13, // Link-layer error signals (0x20-0x2F) — plaintext, from transit routers /// Router cache miss — needs coordinates (link-layer error signal). @@ -43,6 +49,9 @@ impl SessionMessageType { 0x00 => Some(SessionMessageType::SessionSetup), 0x01 => Some(SessionMessageType::SessionAck), 0x10 => Some(SessionMessageType::DataPacket), + 0x11 => Some(SessionMessageType::SenderReport), + 0x12 => Some(SessionMessageType::ReceiverReport), + 0x13 => Some(SessionMessageType::PathMtuNotification), 0x20 => Some(SessionMessageType::CoordsRequired), 0x21 => Some(SessionMessageType::PathBroken), _ => None, @@ -61,6 +70,9 @@ impl fmt::Display for SessionMessageType { SessionMessageType::SessionSetup => "SessionSetup", SessionMessageType::SessionAck => "SessionAck", SessionMessageType::DataPacket => "DataPacket", + SessionMessageType::SenderReport => "SenderReport", + SessionMessageType::ReceiverReport => "ReceiverReport", + SessionMessageType::PathMtuNotification => "PathMtuNotification", SessionMessageType::CoordsRequired => "CoordsRequired", SessionMessageType::PathBroken => "PathBroken", }; @@ -123,7 +135,7 @@ pub(crate) fn decode_coords(data: &[u8]) -> Result<(TreeCoordinate, usize), Prot /// Decode an optional coordinate field (count may be 0). /// /// Returns None if count is 0, Some(coord) otherwise, plus bytes consumed. -fn decode_optional_coords(data: &[u8]) -> Result<(Option, usize), ProtocolError> { +pub(crate) fn decode_optional_coords(data: &[u8]) -> Result<(Option, usize), ProtocolError> { if data.len() < 2 { return Err(ProtocolError::MessageTooShort { expected: 2, @@ -210,6 +222,90 @@ impl SessionFlags { } } +// ============================================================================ +// FSP Packet Flags +// ============================================================================ + +/// FSP common prefix flags (cleartext, in outer header). +/// +/// | Bit | Name | Description | +/// |-----|------|------------------------------------------------| +/// | 0 | CP | Coords present between header and ciphertext | +/// | 1 | K | Key epoch (for rekeying) | +/// | 2 | U | Unencrypted payload (error signals) | +/// | 3-7 | | Reserved | +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct FspFlags { + /// Coordinates present between header and ciphertext. + pub coords_present: bool, + /// Key epoch bit for rekeying. + pub key_epoch: bool, + /// Unencrypted payload (plaintext error signals from transit routers). + pub unencrypted: bool, +} + +impl FspFlags { + /// Create default flags (all clear). + pub fn new() -> Self { + Self::default() + } + + /// Convert to a byte. + pub fn to_byte(&self) -> u8 { + let mut flags = 0u8; + if self.coords_present { + flags |= 0x01; + } + if self.key_epoch { + flags |= 0x02; + } + if self.unencrypted { + flags |= 0x04; + } + flags + } + + /// Convert from a byte. + pub fn from_byte(byte: u8) -> Self { + Self { + coords_present: byte & 0x01 != 0, + key_epoch: byte & 0x02 != 0, + unencrypted: byte & 0x04 != 0, + } + } +} + +/// FSP inner header flags (encrypted, inside AEAD envelope). +/// +/// | Bit | Name | Description | +/// |-----|------|---------------------------------| +/// | 0 | SP | Spin bit for RTT measurement | +/// | 1-7 | | Reserved | +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct FspInnerFlags { + /// Spin bit for passive RTT measurement. + pub spin_bit: bool, +} + +impl FspInnerFlags { + /// Create default inner flags (all clear). + pub fn new() -> Self { + Self::default() + } + + /// Convert to a byte. + pub fn to_byte(&self) -> u8 { + if self.spin_bit { 0x01 } else { 0x00 } + } + + /// Convert from a byte. + pub fn from_byte(byte: u8) -> Self { + Self { + spin_bit: byte & 0x01 != 0, + } + } +} + // ============================================================================ // Session Setup // ============================================================================ @@ -267,20 +363,31 @@ impl SessionSetup { self } - /// Encode as wire format (msg_type + flags + coords + handshake). + /// Encode as wire format (4-byte FSP prefix + flags + coords + handshake). + /// + /// The 4-byte prefix: `[ver_phase:1][flags:1][payload_len:2 LE]` + /// where ver_phase = 0x01 (version 0, phase MSG1). pub fn encode(&self) -> Vec { - let mut buf = Vec::new(); - buf.push(SessionMessageType::SessionSetup.to_byte()); - buf.push(self.flags.to_byte()); - encode_coords(&self.src_coords, &mut buf); - encode_coords(&self.dest_coords, &mut buf); + // Build body first to compute payload_len + let mut body = Vec::new(); + body.push(self.flags.to_byte()); + encode_coords(&self.src_coords, &mut body); + encode_coords(&self.dest_coords, &mut body); let hs_len = self.handshake_payload.len() as u16; - buf.extend_from_slice(&hs_len.to_le_bytes()); - buf.extend_from_slice(&self.handshake_payload); + body.extend_from_slice(&hs_len.to_le_bytes()); + body.extend_from_slice(&self.handshake_payload); + + // Prepend 4-byte FSP common prefix + let payload_len = body.len() as u16; + let mut buf = Vec::with_capacity(4 + body.len()); + buf.push(0x01); // version 0, phase 0x1 (MSG1) + buf.push(0x00); // flags (must be zero for handshake) + buf.extend_from_slice(&payload_len.to_le_bytes()); + buf.extend_from_slice(&body); buf } - /// Decode from wire format (after msg_type byte has been consumed). + /// Decode from wire format (after 4-byte FSP prefix has been consumed). pub fn decode(payload: &[u8]) -> Result { if payload.is_empty() { return Err(ProtocolError::MessageTooShort { @@ -369,19 +476,30 @@ impl SessionAck { self } - /// Encode as wire format (msg_type + flags + coords + handshake). + /// Encode as wire format (4-byte FSP prefix + flags + coords + handshake). + /// + /// The 4-byte prefix: `[ver_phase:1][flags:1][payload_len:2 LE]` + /// where ver_phase = 0x02 (version 0, phase MSG2). pub fn encode(&self) -> Vec { - let mut buf = Vec::new(); - buf.push(SessionMessageType::SessionAck.to_byte()); - buf.push(self.flags); - encode_coords(&self.src_coords, &mut buf); + // Build body first to compute payload_len + let mut body = Vec::new(); + body.push(self.flags); + encode_coords(&self.src_coords, &mut body); let hs_len = self.handshake_payload.len() as u16; - buf.extend_from_slice(&hs_len.to_le_bytes()); - buf.extend_from_slice(&self.handshake_payload); + body.extend_from_slice(&hs_len.to_le_bytes()); + body.extend_from_slice(&self.handshake_payload); + + // Prepend 4-byte FSP common prefix + let payload_len = body.len() as u16; + let mut buf = Vec::with_capacity(4 + body.len()); + buf.push(0x02); // version 0, phase 0x2 (MSG2) + buf.push(0x00); // flags (must be zero for handshake) + buf.extend_from_slice(&payload_len.to_le_bytes()); + buf.extend_from_slice(&body); buf } - /// Decode from wire format (after msg_type byte has been consumed). + /// Decode from wire format (after 4-byte FSP prefix has been consumed). pub fn decode(payload: &[u8]) -> Result { if payload.is_empty() { return Err(ProtocolError::MessageTooShort { @@ -421,227 +539,218 @@ impl SessionAck { } // ============================================================================ -// Data Messages +// Session-Layer MMP Reports // ============================================================================ -/// Data packet flags. +/// Session-layer sender report (msg_type 0x11). /// -/// ## Flag Bits +/// Mirrors the FLP `SenderReport` fields but carried as an FSP session +/// message inside the AEAD envelope. The msg_type is in the FSP inner +/// header, so the body starts with reserved bytes. /// -/// | Bit | Name | Description | -/// |-----|----------------|------------------------------------------| -/// | 0 | COORDS_PRESENT | Coordinates follow the fixed header | -/// | 1-7 | reserved | Reserved for future use | -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct DataFlags { - /// When set, source and destination coordinates follow the header. - /// Used to warm router caches after receiving CoordsRequired. - pub coords_present: bool, - /// Reserved bits (preserved for forward compatibility). - reserved: u8, +/// ## Wire Format (46 bytes body, after inner header stripped) +/// +/// ```text +/// [0-1] reserved (zero) +/// [2-9] interval_start_counter: u64 LE +/// [10-17] interval_end_counter: u64 LE +/// [18-21] interval_start_timestamp: u32 LE +/// [22-25] interval_end_timestamp: u32 LE +/// [26-29] interval_bytes_sent: u32 LE +/// [30-37] cumulative_packets_sent: u64 LE +/// [38-45] cumulative_bytes_sent: u64 LE +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionSenderReport { + pub interval_start_counter: u64, + pub interval_end_counter: u64, + pub interval_start_timestamp: u32, + pub interval_end_timestamp: u32, + pub interval_bytes_sent: u32, + pub cumulative_packets_sent: u64, + pub cumulative_bytes_sent: u64, } -/// Bit 0: coordinates follow the header. -pub const DATA_FLAG_COORDS_PRESENT: u8 = 0x01; +/// Body size for SessionSenderReport: 2 reserved + 44 fields. +pub const SESSION_SENDER_REPORT_SIZE: usize = 46; -impl DataFlags { - /// Create default flags (no coordinates). - pub fn new() -> Self { - Self::default() - } - - /// Create flags with COORDS_PRESENT set. - pub fn with_coords() -> Self { - Self { - coords_present: true, - reserved: 0, - } - } - - /// Set the coords_present flag. - pub fn set_coords_present(&mut self, value: bool) { - self.coords_present = value; - } - - /// Convert to a byte. - pub fn to_byte(&self) -> u8 { - let mut flags = self.reserved & !DATA_FLAG_COORDS_PRESENT; - if self.coords_present { - flags |= DATA_FLAG_COORDS_PRESENT; - } - flags - } - - /// Convert from a byte. - pub fn from_byte(byte: u8) -> Self { - Self { - coords_present: byte & DATA_FLAG_COORDS_PRESENT != 0, - reserved: byte & !DATA_FLAG_COORDS_PRESENT, - } - } -} - -/// DataPacket header size in bytes (excluding payload). -/// msg_type(1) + flags(1) + counter(8) + payload_length(2) = 12 -/// (Addressing and TTL are in the SessionDatagram envelope.) -pub const DATA_HEADER_SIZE: usize = 12; - -/// Encrypted application data carried inside a SessionDatagram. -/// -/// ## Wire Format (minimal, no coordinates) -/// -/// | Offset | Field | Size | Description | -/// |--------|----------------|---------|-------------------------------| -/// | 0 | msg_type | 1 byte | 0x10 | -/// | 1 | flags | 1 byte | Bit 0: COORDS_PRESENT | -/// | 2 | counter | 8 bytes | u64 LE, encryption nonce | -/// | 10 | payload_length | 2 bytes | u16 LE | -/// | 12 | payload | variable| Encrypted application data | -/// -/// ## Wire Format (with coordinates, when COORDS_PRESENT is set) -/// -/// | Offset | Field | Size | Description | -/// |--------|------------------|---------|----------------------------| -/// | 0 | msg_type | 1 byte | 0x10 | -/// | 1 | flags | 1 byte | 0x01 (COORDS_PRESENT) | -/// | 2 | counter | 8 bytes | u64 LE, encryption nonce | -/// | 10 | payload_length | 2 bytes | u16 LE | -/// | 12 | src_coords_count | 2 bytes | u16 LE | -/// | 14 | src_coords | 16 × n | Source coordinates | -/// | ... | dest_coords_count| 2 bytes | u16 LE | -/// | ... | dest_coords | 16 × m | Destination coordinates | -/// | ... | payload | variable| Encrypted application data | -#[derive(Clone, Debug)] -pub struct DataPacket { - /// Packet flags. - pub flags: DataFlags, - /// Encryption counter (used as nonce for ChaCha20Poly1305). - /// Transmitted on the wire so the receiver can decrypt out-of-order packets. - pub counter: u64, - /// Payload data (end-to-end encrypted application data). - pub payload: Vec, - /// Source coordinates (present when COORDS_PRESENT flag is set). - pub src_coords: Option, - /// Destination coordinates (present when COORDS_PRESENT flag is set). - pub dest_coords: Option, -} - -impl DataPacket { - /// Create a new data packet with the given counter and payload. - pub fn new(counter: u64, payload: Vec) -> Self { - Self { - flags: DataFlags::new(), - counter, - payload, - src_coords: None, - dest_coords: None, - } - } - - /// Set the flags. - pub fn with_flags(mut self, flags: DataFlags) -> Self { - self.flags = flags; - self - } - - /// Set coordinates for route cache warming. - pub fn with_coords(mut self, src: TreeCoordinate, dest: TreeCoordinate) -> Self { - self.src_coords = Some(src); - self.dest_coords = Some(dest); - self.flags.coords_present = true; - self - } - - /// Get the payload length. - pub fn payload_len(&self) -> usize { - self.payload.len() - } - - /// Total packet size (header + optional coords + payload). - pub fn total_size(&self) -> usize { - DATA_HEADER_SIZE + self.coords_wire_size() + self.payload.len() - } - - /// Header size in bytes. - pub fn header_size(&self) -> usize { - DATA_HEADER_SIZE - } - - /// Wire size of the optional coordinate fields. - fn coords_wire_size(&self) -> usize { - if !self.flags.coords_present { - return 0; - } - let src_count = self.src_coords.as_ref().map_or(0, |c| c.depth() + 1); - let dest_count = self.dest_coords.as_ref().map_or(0, |c| c.depth() + 1); - 2 + src_count * 16 + 2 + dest_count * 16 - } - - /// Encode as wire format. +impl SessionSenderReport { + /// Encode to wire format (46 bytes body). pub fn encode(&self) -> Vec { - let mut buf = Vec::new(); - buf.push(SessionMessageType::DataPacket.to_byte()); - buf.push(self.flags.to_byte()); - buf.extend_from_slice(&self.counter.to_le_bytes()); - let payload_len = self.payload.len() as u16; - buf.extend_from_slice(&payload_len.to_le_bytes()); - if self.flags.coords_present { - if let Some(ref src) = self.src_coords { - encode_coords(src, &mut buf); - } else { - encode_empty_coords(&mut buf); - } - if let Some(ref dest) = self.dest_coords { - encode_coords(dest, &mut buf); - } else { - encode_empty_coords(&mut buf); - } - } - buf.extend_from_slice(&self.payload); + let mut buf = Vec::with_capacity(SESSION_SENDER_REPORT_SIZE); + buf.extend_from_slice(&[0u8; 2]); // reserved + buf.extend_from_slice(&self.interval_start_counter.to_le_bytes()); + buf.extend_from_slice(&self.interval_end_counter.to_le_bytes()); + buf.extend_from_slice(&self.interval_start_timestamp.to_le_bytes()); + buf.extend_from_slice(&self.interval_end_timestamp.to_le_bytes()); + buf.extend_from_slice(&self.interval_bytes_sent.to_le_bytes()); + buf.extend_from_slice(&self.cumulative_packets_sent.to_le_bytes()); + buf.extend_from_slice(&self.cumulative_bytes_sent.to_le_bytes()); buf } - /// Decode from wire format (after msg_type byte has been consumed). - pub fn decode(payload: &[u8]) -> Result { - // flags(1) + counter(8) + payload_len(2) = 11 - if payload.len() < 11 { + /// Decode from body (after FSP inner header has been stripped). + pub fn decode(body: &[u8]) -> Result { + if body.len() < SESSION_SENDER_REPORT_SIZE { return Err(ProtocolError::MessageTooShort { - expected: 11, - got: payload.len(), + expected: SESSION_SENDER_REPORT_SIZE, + got: body.len(), }); } - let flags = DataFlags::from_byte(payload[0]); - let counter = u64::from_le_bytes([ - payload[1], payload[2], payload[3], payload[4], - payload[5], payload[6], payload[7], payload[8], - ]); - let payload_len = u16::from_le_bytes([payload[9], payload[10]]) as usize; - let mut offset = 11; - - let (src_coords, dest_coords) = if flags.coords_present { - let (src, consumed) = decode_optional_coords(&payload[offset..])?; - offset += consumed; - let (dest, consumed) = decode_optional_coords(&payload[offset..])?; - offset += consumed; - (src, dest) - } else { - (None, None) - }; - - if payload.len() < offset + payload_len { - return Err(ProtocolError::MessageTooShort { - expected: offset + payload_len, - got: payload.len(), - }); - } - let data = payload[offset..offset + payload_len].to_vec(); - + // Skip 2 reserved bytes + let p = &body[2..]; Ok(Self { - flags, - counter, - payload: data, - src_coords, - dest_coords, + interval_start_counter: u64::from_le_bytes(p[0..8].try_into().unwrap()), + interval_end_counter: u64::from_le_bytes(p[8..16].try_into().unwrap()), + interval_start_timestamp: u32::from_le_bytes(p[16..20].try_into().unwrap()), + interval_end_timestamp: u32::from_le_bytes(p[20..24].try_into().unwrap()), + interval_bytes_sent: u32::from_le_bytes(p[24..28].try_into().unwrap()), + cumulative_packets_sent: u64::from_le_bytes(p[28..36].try_into().unwrap()), + cumulative_bytes_sent: u64::from_le_bytes(p[36..44].try_into().unwrap()), + }) + } +} + +/// Session-layer receiver report (msg_type 0x12). +/// +/// Mirrors the FLP `ReceiverReport` fields but carried as an FSP session +/// message inside the AEAD envelope. +/// +/// ## Wire Format (66 bytes body, after inner header stripped) +/// +/// ```text +/// [0-1] reserved (zero) +/// [2-9] highest_counter: u64 LE +/// [10-17] cumulative_packets_recv: u64 LE +/// [18-25] cumulative_bytes_recv: u64 LE +/// [26-29] timestamp_echo: u32 LE +/// [30-31] dwell_time: u16 LE +/// [32-33] max_burst_loss: u16 LE +/// [34-35] mean_burst_loss: u16 LE (u8.8 fixed-point) +/// [36-37] reserved: u16 LE +/// [38-41] jitter: u32 LE (microseconds) +/// [42-45] ecn_ce_count: u32 LE +/// [46-49] owd_trend: i32 LE (µs/s) +/// [50-53] burst_loss_count: u32 LE +/// [54-57] cumulative_reorder_count: u32 LE +/// [58-61] interval_packets_recv: u32 LE +/// [62-65] interval_bytes_recv: u32 LE +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionReceiverReport { + pub highest_counter: u64, + pub cumulative_packets_recv: u64, + pub cumulative_bytes_recv: u64, + pub timestamp_echo: u32, + pub dwell_time: u16, + pub max_burst_loss: u16, + pub mean_burst_loss: u16, + pub jitter: u32, + pub ecn_ce_count: u32, + pub owd_trend: i32, + pub burst_loss_count: u32, + pub cumulative_reorder_count: u32, + pub interval_packets_recv: u32, + pub interval_bytes_recv: u32, +} + +/// Body size for SessionReceiverReport: 2 reserved + 64 fields. +pub const SESSION_RECEIVER_REPORT_SIZE: usize = 66; + +impl SessionReceiverReport { + /// Encode to wire format (66 bytes body). + pub fn encode(&self) -> Vec { + let mut buf = Vec::with_capacity(SESSION_RECEIVER_REPORT_SIZE); + buf.extend_from_slice(&[0u8; 2]); // reserved + buf.extend_from_slice(&self.highest_counter.to_le_bytes()); + buf.extend_from_slice(&self.cumulative_packets_recv.to_le_bytes()); + buf.extend_from_slice(&self.cumulative_bytes_recv.to_le_bytes()); + buf.extend_from_slice(&self.timestamp_echo.to_le_bytes()); + buf.extend_from_slice(&self.dwell_time.to_le_bytes()); + buf.extend_from_slice(&self.max_burst_loss.to_le_bytes()); + buf.extend_from_slice(&self.mean_burst_loss.to_le_bytes()); + buf.extend_from_slice(&[0u8; 2]); // reserved + buf.extend_from_slice(&self.jitter.to_le_bytes()); + buf.extend_from_slice(&self.ecn_ce_count.to_le_bytes()); + buf.extend_from_slice(&self.owd_trend.to_le_bytes()); + buf.extend_from_slice(&self.burst_loss_count.to_le_bytes()); + buf.extend_from_slice(&self.cumulative_reorder_count.to_le_bytes()); + buf.extend_from_slice(&self.interval_packets_recv.to_le_bytes()); + buf.extend_from_slice(&self.interval_bytes_recv.to_le_bytes()); + buf + } + + /// Decode from body (after FSP inner header has been stripped). + pub fn decode(body: &[u8]) -> Result { + if body.len() < SESSION_RECEIVER_REPORT_SIZE { + return Err(ProtocolError::MessageTooShort { + expected: SESSION_RECEIVER_REPORT_SIZE, + got: body.len(), + }); + } + // Skip 2 reserved bytes + let p = &body[2..]; + Ok(Self { + highest_counter: u64::from_le_bytes(p[0..8].try_into().unwrap()), + cumulative_packets_recv: u64::from_le_bytes(p[8..16].try_into().unwrap()), + cumulative_bytes_recv: u64::from_le_bytes(p[16..24].try_into().unwrap()), + timestamp_echo: u32::from_le_bytes(p[24..28].try_into().unwrap()), + dwell_time: u16::from_le_bytes(p[28..30].try_into().unwrap()), + max_burst_loss: u16::from_le_bytes(p[30..32].try_into().unwrap()), + mean_burst_loss: u16::from_le_bytes(p[32..34].try_into().unwrap()), + // skip 2 reserved bytes at p[34..36] + jitter: u32::from_le_bytes(p[36..40].try_into().unwrap()), + ecn_ce_count: u32::from_le_bytes(p[40..44].try_into().unwrap()), + owd_trend: i32::from_le_bytes(p[44..48].try_into().unwrap()), + burst_loss_count: u32::from_le_bytes(p[48..52].try_into().unwrap()), + cumulative_reorder_count: u32::from_le_bytes(p[52..56].try_into().unwrap()), + interval_packets_recv: u32::from_le_bytes(p[56..60].try_into().unwrap()), + interval_bytes_recv: u32::from_le_bytes(p[60..64].try_into().unwrap()), + }) + } +} + +/// Path MTU notification (msg_type 0x13). +/// +/// Sent by a node that discovers a path MTU value (from transit router +/// feedback or ICMP Packet Too Big). Allows the remote endpoint to +/// adjust its sending MTU. +/// +/// ## Wire Format (2 bytes body, after inner header stripped) +/// +/// ```text +/// [0-1] path_mtu: u16 LE +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PathMtuNotification { + /// Discovered path MTU in bytes. + pub path_mtu: u16, +} + +/// Body size for PathMtuNotification. +pub const PATH_MTU_NOTIFICATION_SIZE: usize = 2; + +impl PathMtuNotification { + /// Create a new path MTU notification. + pub fn new(path_mtu: u16) -> Self { + Self { path_mtu } + } + + /// Encode to wire format (2 bytes body). + pub fn encode(&self) -> Vec { + self.path_mtu.to_le_bytes().to_vec() + } + + /// Decode from body (after FSP inner header has been stripped). + pub fn decode(body: &[u8]) -> Result { + if body.len() < PATH_MTU_NOTIFICATION_SIZE { + return Err(ProtocolError::MessageTooShort { + expected: PATH_MTU_NOTIFICATION_SIZE, + got: body.len(), + }); + } + Ok(Self { + path_mtu: u16::from_le_bytes([body[0], body[1]]), }) } } @@ -686,9 +795,19 @@ impl CoordsRequired { Self { dest_addr, reporter } } - /// Encode as wire format. + /// Encode as wire format (4-byte FSP prefix + msg_type + body). + /// + /// Error signals use phase=0x0 with U flag set. pub fn encode(&self) -> Vec { - let mut buf = Vec::with_capacity(COORDS_REQUIRED_SIZE); + // Body: msg_type + flags(reserved) + dest_addr + reporter + let body_len = 1 + 1 + 16 + 16; // 34 bytes + let mut buf = Vec::with_capacity(4 + body_len); + // FSP prefix: version 0, phase 0x0, U flag set + buf.push(0x00); // version 0, phase 0x0 + buf.push(0x04); // U flag + let payload_len = body_len as u16; + buf.extend_from_slice(&payload_len.to_le_bytes()); + // msg_type byte (after prefix, before body) buf.push(SessionMessageType::CoordsRequired.to_byte()); buf.push(0x00); // reserved flags buf.extend_from_slice(self.dest_addr.as_bytes()); @@ -696,7 +815,7 @@ impl CoordsRequired { buf } - /// Decode from wire format (after msg_type byte has been consumed). + /// Decode from wire format (after FSP prefix and msg_type byte consumed). pub fn decode(payload: &[u8]) -> Result { // flags(1) + dest_addr(16) + reporter(16) = 33 if payload.len() < 33 { @@ -761,22 +880,33 @@ impl PathBroken { self } - /// Encode as wire format. + /// Encode as wire format (4-byte FSP prefix + msg_type + body). + /// + /// Error signals use phase=0x0 with U flag set. pub fn encode(&self) -> Vec { - let mut buf = Vec::new(); - buf.push(SessionMessageType::PathBroken.to_byte()); - buf.push(0x00); // reserved flags - buf.extend_from_slice(self.dest_addr.as_bytes()); - buf.extend_from_slice(self.reporter.as_bytes()); + // Build body first to compute length + let mut body = Vec::new(); + body.push(SessionMessageType::PathBroken.to_byte()); + body.push(0x00); // reserved flags + body.extend_from_slice(self.dest_addr.as_bytes()); + body.extend_from_slice(self.reporter.as_bytes()); if let Some(ref coords) = self.last_known_coords { - encode_coords(coords, &mut buf); + encode_coords(coords, &mut body); } else { - encode_empty_coords(&mut buf); + encode_empty_coords(&mut body); } + + // Prepend FSP prefix: version 0, phase 0x0, U flag set + let payload_len = body.len() as u16; + let mut buf = Vec::with_capacity(4 + body.len()); + buf.push(0x00); // version 0, phase 0x0 + buf.push(0x04); // U flag + buf.extend_from_slice(&payload_len.to_le_bytes()); + buf.extend_from_slice(&body); buf } - /// Decode from wire format (after msg_type byte has been consumed). + /// Decode from wire format (after FSP prefix and msg_type byte consumed). pub fn decode(payload: &[u8]) -> Result { // flags(1) + dest_addr(16) + reporter(16) + coords_count(2) = 35 minimum if payload.len() < 35 { @@ -823,6 +953,9 @@ mod tests { SessionMessageType::SessionSetup, SessionMessageType::SessionAck, SessionMessageType::DataPacket, + SessionMessageType::SenderReport, + SessionMessageType::ReceiverReport, + SessionMessageType::PathMtuNotification, SessionMessageType::CoordsRequired, SessionMessageType::PathBroken, ]; @@ -863,54 +996,6 @@ mod tests { assert_eq!(flags.to_byte(), 0); } - // ===== DataPacket Tests ===== - - #[test] - fn test_data_packet_size() { - let packet = DataPacket::new(0, vec![0u8; 100]); - - // 12 byte header + 100 byte payload - assert_eq!(packet.total_size(), 112); - assert_eq!(packet.header_size(), 12); - assert_eq!(packet.payload_len(), 100); - } - - #[test] - fn test_data_packet_builder() { - let packet = DataPacket::new(0, vec![1, 2, 3]) - .with_flags(DataFlags::from_byte(0x80)); - - assert_eq!(packet.flags.to_byte(), 0x80); - } - - #[test] - fn test_data_flags_coords_present() { - // Default: no coords - let flags = DataFlags::new(); - assert!(!flags.coords_present); - assert_eq!(flags.to_byte(), 0x00); - - // With coords - let flags = DataFlags::with_coords(); - assert!(flags.coords_present); - assert_eq!(flags.to_byte(), 0x01); - - // Round-trip preserves flag - let flags = DataFlags::from_byte(0x01); - assert!(flags.coords_present); - assert_eq!(flags.to_byte(), 0x01); - - // Reserved bits preserved - let flags = DataFlags::from_byte(0x81); // coords + reserved bit 7 - assert!(flags.coords_present); - assert_eq!(flags.to_byte(), 0x81); - - // Coords bit toggles independently - let flags = DataFlags::from_byte(0x80); // only reserved bit 7 - assert!(!flags.coords_present); - assert_eq!(flags.to_byte(), 0x80); - } - // ===== SessionSetup Tests ===== #[test] @@ -955,11 +1040,14 @@ mod tests { let encoded = setup.encode(); - // Verify msg_type byte - assert_eq!(encoded[0], 0x00); + // Verify FSP prefix: ver_phase=0x01 (version 0, phase MSG1) + assert_eq!(encoded[0], 0x01); + assert_eq!(encoded[1], 0x00); // flags = 0 for handshake + let payload_len = u16::from_le_bytes([encoded[2], encoded[3]]); + assert_eq!(payload_len as usize, encoded.len() - 4); - // Decode (skip msg_type byte) - let decoded = SessionSetup::decode(&encoded[1..]).unwrap(); + // Decode (skip 4-byte FSP prefix) + let decoded = SessionSetup::decode(&encoded[4..]).unwrap(); assert_eq!(decoded.flags, setup.flags); assert_eq!(decoded.src_coords, setup.src_coords); @@ -972,7 +1060,7 @@ mod tests { let setup = SessionSetup::new(make_coords(&[5, 0]), make_coords(&[6, 0])); let encoded = setup.encode(); - let decoded = SessionSetup::decode(&encoded[1..]).unwrap(); + let decoded = SessionSetup::decode(&encoded[4..]).unwrap(); assert!(decoded.handshake_payload.is_empty()); assert_eq!(decoded.src_coords, setup.src_coords); @@ -986,59 +1074,30 @@ mod tests { .with_handshake(handshake.clone()); let encoded = ack.encode(); - assert_eq!(encoded[0], 0x01); + // Verify FSP prefix: ver_phase=0x02 (version 0, phase MSG2) + assert_eq!(encoded[0], 0x02); + assert_eq!(encoded[1], 0x00); // flags = 0 for handshake - let decoded = SessionAck::decode(&encoded[1..]).unwrap(); + let decoded = SessionAck::decode(&encoded[4..]).unwrap(); assert_eq!(decoded.src_coords, ack.src_coords); assert_eq!(decoded.handshake_payload, handshake); } - #[test] - fn test_data_packet_encode_decode_minimal() { - let data = vec![1, 2, 3, 4, 5]; - let packet = DataPacket::new(42, data.clone()); - - let encoded = packet.encode(); - assert_eq!(encoded[0], 0x10); // msg_type - assert_eq!(encoded[1], 0x00); // flags (no coords) - - let decoded = DataPacket::decode(&encoded[1..]).unwrap(); - assert_eq!(decoded.payload, data); - assert_eq!(decoded.counter, 42); - assert!(!decoded.flags.coords_present); - assert!(decoded.src_coords.is_none()); - assert!(decoded.dest_coords.is_none()); - } - - #[test] - fn test_data_packet_encode_decode_with_coords() { - let data = vec![0xFF; 100]; - let src = make_coords(&[1, 2, 0]); - let dest = make_coords(&[3, 4, 0]); - let packet = DataPacket::new(1000, data.clone()) - .with_coords(src.clone(), dest.clone()); - - let encoded = packet.encode(); - assert_eq!(encoded[0], 0x10); - assert_eq!(encoded[1], 0x01); // COORDS_PRESENT - - let decoded = DataPacket::decode(&encoded[1..]).unwrap(); - assert_eq!(decoded.payload, data); - assert_eq!(decoded.counter, 1000); - assert!(decoded.flags.coords_present); - assert_eq!(decoded.src_coords.unwrap(), src); - assert_eq!(decoded.dest_coords.unwrap(), dest); - } - #[test] fn test_coords_required_encode_decode() { let err = CoordsRequired::new(make_node_addr(0xAA), make_node_addr(0xBB)); let encoded = err.encode(); - assert_eq!(encoded.len(), COORDS_REQUIRED_SIZE); - assert_eq!(encoded[0], 0x20); + // 4 prefix + 1 msg_type + 1 flags + 16 dest + 16 reporter = 38 + assert_eq!(encoded.len(), 4 + COORDS_REQUIRED_SIZE); + // Check FSP prefix: phase 0x0, U flag + assert_eq!(encoded[0], 0x00); + assert_eq!(encoded[1], 0x04); // U flag + // msg_type after prefix + assert_eq!(encoded[4], 0x20); - let decoded = CoordsRequired::decode(&encoded[1..]).unwrap(); + // decode after prefix + msg_type consumed + let decoded = CoordsRequired::decode(&encoded[5..]).unwrap(); assert_eq!(decoded.dest_addr, err.dest_addr); assert_eq!(decoded.reporter, err.reporter); } @@ -1048,9 +1107,12 @@ mod tests { let err = PathBroken::new(make_node_addr(0xCC), make_node_addr(0xDD)); let encoded = err.encode(); - assert_eq!(encoded[0], 0x21); + // Check FSP prefix + assert_eq!(encoded[0], 0x00); + assert_eq!(encoded[1], 0x04); // U flag + assert_eq!(encoded[4], 0x21); // msg_type - let decoded = PathBroken::decode(&encoded[1..]).unwrap(); + let decoded = PathBroken::decode(&encoded[5..]).unwrap(); assert_eq!(decoded.dest_addr, err.dest_addr); assert_eq!(decoded.reporter, err.reporter); assert!(decoded.last_known_coords.is_none()); @@ -1063,7 +1125,7 @@ mod tests { .with_last_coords(coords.clone()); let encoded = err.encode(); - let decoded = PathBroken::decode(&encoded[1..]).unwrap(); + let decoded = PathBroken::decode(&encoded[5..]).unwrap(); assert_eq!(decoded.dest_addr, err.dest_addr); assert_eq!(decoded.reporter, err.reporter); @@ -1080,12 +1142,6 @@ mod tests { assert!(SessionAck::decode(&[]).is_err()); } - #[test] - fn test_data_packet_decode_too_short() { - assert!(DataPacket::decode(&[]).is_err()); - assert!(DataPacket::decode(&[0x00]).is_err()); - } - #[test] fn test_coords_required_decode_too_short() { assert!(CoordsRequired::decode(&[]).is_err()); @@ -1098,17 +1154,6 @@ mod tests { assert!(PathBroken::decode(&[0x00; 20]).is_err()); } - #[test] - fn test_data_packet_large_payload() { - let data = vec![0x42; 65000]; - let packet = DataPacket::new(0, data.clone()); - - let encoded = packet.encode(); - let decoded = DataPacket::decode(&encoded[1..]).unwrap(); - assert_eq!(decoded.payload.len(), 65000); - assert_eq!(decoded.payload, data); - } - #[test] fn test_session_setup_deep_coords() { // Depth-10 coordinate (11 entries: self + 10 ancestors) @@ -1119,9 +1164,223 @@ mod tests { .with_handshake(vec![0x55; 82]); let encoded = setup.encode(); - let decoded = SessionSetup::decode(&encoded[1..]).unwrap(); + let decoded = SessionSetup::decode(&encoded[4..]).unwrap(); assert_eq!(decoded.src_coords, src); assert_eq!(decoded.dest_coords, dest); } + + // ===== FspFlags Tests ===== + + #[test] + fn test_fsp_flags_default() { + let flags = FspFlags::new(); + assert!(!flags.coords_present); + assert!(!flags.key_epoch); + assert!(!flags.unencrypted); + assert_eq!(flags.to_byte(), 0x00); + } + + #[test] + fn test_fsp_flags_roundtrip() { + // All combinations of 3 bits + for byte in 0u8..=0x07 { + let flags = FspFlags::from_byte(byte); + assert_eq!(flags.to_byte(), byte); + } + } + + #[test] + fn test_fsp_flags_individual_bits() { + let cp = FspFlags::from_byte(0x01); + assert!(cp.coords_present); + assert!(!cp.key_epoch); + assert!(!cp.unencrypted); + + let k = FspFlags::from_byte(0x02); + assert!(!k.coords_present); + assert!(k.key_epoch); + assert!(!k.unencrypted); + + let u = FspFlags::from_byte(0x04); + assert!(!u.coords_present); + assert!(!u.key_epoch); + assert!(u.unencrypted); + } + + #[test] + fn test_fsp_flags_ignores_reserved_bits() { + // Reserved bits in upper 5 bits are not preserved + let flags = FspFlags::from_byte(0xFF); + assert!(flags.coords_present); + assert!(flags.key_epoch); + assert!(flags.unencrypted); + assert_eq!(flags.to_byte(), 0x07); // only lower 3 bits + } + + // ===== FspInnerFlags Tests ===== + + #[test] + fn test_fsp_inner_flags_default() { + let flags = FspInnerFlags::new(); + assert!(!flags.spin_bit); + assert_eq!(flags.to_byte(), 0x00); + } + + #[test] + fn test_fsp_inner_flags_roundtrip() { + let flags = FspInnerFlags::from_byte(0x01); + assert!(flags.spin_bit); + assert_eq!(flags.to_byte(), 0x01); + + let flags = FspInnerFlags::from_byte(0x00); + assert!(!flags.spin_bit); + assert_eq!(flags.to_byte(), 0x00); + } + + #[test] + fn test_fsp_inner_flags_ignores_reserved() { + let flags = FspInnerFlags::from_byte(0xFE); + assert!(!flags.spin_bit); + assert_eq!(flags.to_byte(), 0x00); + + let flags = FspInnerFlags::from_byte(0xFF); + assert!(flags.spin_bit); + assert_eq!(flags.to_byte(), 0x01); + } + + // ===== New SessionMessageType Values ===== + + #[test] + fn test_session_message_type_new_values() { + assert_eq!(SessionMessageType::SenderReport.to_byte(), 0x11); + assert_eq!(SessionMessageType::ReceiverReport.to_byte(), 0x12); + assert_eq!(SessionMessageType::PathMtuNotification.to_byte(), 0x13); + } + + #[test] + fn test_session_message_type_display() { + assert_eq!(format!("{}", SessionMessageType::SenderReport), "SenderReport"); + assert_eq!(format!("{}", SessionMessageType::ReceiverReport), "ReceiverReport"); + assert_eq!(format!("{}", SessionMessageType::PathMtuNotification), "PathMtuNotification"); + } + + // ===== SessionSenderReport Tests ===== + + fn sample_session_sender_report() -> SessionSenderReport { + SessionSenderReport { + interval_start_counter: 100, + interval_end_counter: 200, + interval_start_timestamp: 5000, + interval_end_timestamp: 6000, + interval_bytes_sent: 50_000, + cumulative_packets_sent: 10_000, + cumulative_bytes_sent: 5_000_000, + } + } + + #[test] + fn test_session_sender_report_encode_size() { + let sr = sample_session_sender_report(); + let encoded = sr.encode(); + assert_eq!(encoded.len(), SESSION_SENDER_REPORT_SIZE); + } + + #[test] + fn test_session_sender_report_roundtrip() { + let sr = sample_session_sender_report(); + let encoded = sr.encode(); + let decoded = SessionSenderReport::decode(&encoded).unwrap(); + assert_eq!(sr, decoded); + } + + #[test] + fn test_session_sender_report_too_short() { + assert!(SessionSenderReport::decode(&[0u8; 10]).is_err()); + } + + // ===== SessionReceiverReport Tests ===== + + fn sample_session_receiver_report() -> SessionReceiverReport { + SessionReceiverReport { + highest_counter: 195, + cumulative_packets_recv: 9_500, + cumulative_bytes_recv: 4_750_000, + timestamp_echo: 5900, + dwell_time: 5, + max_burst_loss: 3, + mean_burst_loss: 384, + jitter: 1200, + ecn_ce_count: 0, + owd_trend: -50, + burst_loss_count: 2, + cumulative_reorder_count: 10, + interval_packets_recv: 95, + interval_bytes_recv: 47_500, + } + } + + #[test] + fn test_session_receiver_report_encode_size() { + let rr = sample_session_receiver_report(); + let encoded = rr.encode(); + assert_eq!(encoded.len(), SESSION_RECEIVER_REPORT_SIZE); + } + + #[test] + fn test_session_receiver_report_roundtrip() { + let rr = sample_session_receiver_report(); + let encoded = rr.encode(); + let decoded = SessionReceiverReport::decode(&encoded).unwrap(); + assert_eq!(rr, decoded); + } + + #[test] + fn test_session_receiver_report_too_short() { + assert!(SessionReceiverReport::decode(&[0u8; 10]).is_err()); + } + + #[test] + fn test_session_receiver_report_negative_owd_trend() { + let rr = SessionReceiverReport { + owd_trend: -12345, + ..sample_session_receiver_report() + }; + let encoded = rr.encode(); + let decoded = SessionReceiverReport::decode(&encoded).unwrap(); + assert_eq!(decoded.owd_trend, -12345); + } + + // ===== PathMtuNotification Tests ===== + + #[test] + fn test_path_mtu_notification_encode_size() { + let n = PathMtuNotification::new(1400); + let encoded = n.encode(); + assert_eq!(encoded.len(), PATH_MTU_NOTIFICATION_SIZE); + } + + #[test] + fn test_path_mtu_notification_roundtrip() { + let n = PathMtuNotification::new(1400); + let encoded = n.encode(); + let decoded = PathMtuNotification::decode(&encoded).unwrap(); + assert_eq!(decoded.path_mtu, 1400); + } + + #[test] + fn test_path_mtu_notification_too_short() { + assert!(PathMtuNotification::decode(&[]).is_err()); + assert!(PathMtuNotification::decode(&[0x00]).is_err()); + } + + #[test] + fn test_path_mtu_notification_boundary_values() { + for mtu in [0u16, 1280, 1500, u16::MAX] { + let n = PathMtuNotification::new(mtu); + let encoded = n.encode(); + let decoded = PathMtuNotification::decode(&encoded).unwrap(); + assert_eq!(decoded.path_mtu, mtu); + } + } } diff --git a/src/upper/icmp.rs b/src/upper/icmp.rs index 6310351..cea0495 100644 --- a/src/upper/icmp.rs +++ b/src/upper/icmp.rs @@ -62,13 +62,21 @@ const MAX_ORIGINAL_PACKET: usize = MIN_IPV6_MTU - IPV6_HEADER_LEN - ICMPV6_HEADE /// Total FIPS encapsulation overhead (worst case). /// /// Breakdown: -/// - Noise encryption tag: 16 bytes -/// - Established frame header: 16 bytes (common prefix + receiver_idx + counter) -/// - Inner header: 5 bytes (4-byte timestamp + 1-byte msg_type) +/// - Noise encryption tag: 16 bytes (FLP link-layer AEAD) +/// - FLP established frame header: 16 bytes (common prefix + receiver_idx + counter) +/// - FLP inner header: 5 bytes (4-byte timestamp + 1-byte msg_type) /// - SessionDatagram fields: 35 bytes (ttl + path_mtu + src_addr + dest_addr) -/// - DataPacket header: 12 bytes (msg_type + flags + counter + payload_len) +/// - FSP header: 12 bytes (4-byte prefix + 8-byte counter) +/// - FSP inner header: 6 bytes (4-byte timestamp + 1-byte msg_type + 1-byte inner_flags) +/// - Session AEAD tag: 16 bytes (FSP session-layer AEAD) /// - Coordinates (worst case): ~60 bytes (2 coords with depth 3 each) -pub const FIPS_OVERHEAD: u16 = 16 + 16 + 5 + 35 + 12 + 60; // 144 bytes +/// +/// The old 12-byte session header is replaced by FSP header (12) + FSP inner +/// header (6) + session AEAD tag (16) = 34 bytes, an increase of 22 bytes. +/// However, the old overhead already accounted for the session AEAD tag in +/// the "Noise encryption tag" line, and the old header included the counter. +/// Net change: +6 bytes for FSP inner header. +pub const FIPS_OVERHEAD: u16 = 16 + 16 + 5 + 35 + 12 + 6 + 60; // 150 bytes /// Calculate the effective IPv6 MTU for FIPS-encapsulated traffic. ///