FSP wire format revision and session-layer MMP implementation

FSP wire format revision (TASK-2026-0007):

Introduce the FIPS Session Protocol (FSP) wire format with a 4-byte
common prefix [ver_phase:1][flags:1][payload_len:2 LE] replacing the
old 1-byte msg_type dispatch. All session messages share this prefix
with phase-based dispatch (Established, Setup, Ack, Unencrypted).

- New session_wire.rs: FSP constants, header types, parse/build helpers
- SessionMessageType enum: DataPacket (0x10), SenderReport (0x11),
  ReceiverReport (0x12), PathMtuNotification (0x13)
- FspFlags (CP/K/U) and FspInnerFlags (SP) for flag management
- SessionSenderReport, SessionReceiverReport, PathMtuNotification
  message structs with encode/decode
- FSP send pipeline: 12-byte header as AAD, 6-byte inner header
  (timestamp + msg_type + inner_flags), encrypt_with_aad()
- FSP receive pipeline: parse header, extract cleartext coords (CP),
  AEAD decrypt with AAD, strip inner header, msg_type dispatch
- Forwarding: transit nodes parse cleartext coords without decryption
- Removed DataPacket struct and associated types
- SessionEntry: session_start_ms, mark_established(), session_timestamp()
- FIPS_OVERHEAD: 144 → 150 bytes (+6 for FSP inner header)
- Design docs updated for new wire format

Session-layer MMP implementation (TASK-2026-0008):

Implement complete session-layer MMP reusing the link-layer algorithm
modules (SenderState, ReceiverState, MmpMetrics, SpinBitState) with
independent configuration and higher report interval clamps.

- SessionMmpConfig: separate config section (node.session_mmp.*)
- MmpSessionState: session-specific wrapper with PathMtuState tracking
- Session-layer constants (500ms-10s report intervals, 1s cold start)
- Parameterized interval methods (new_with_cold_start,
  update_report_interval_with_bounds) on SenderState/ReceiverState
- Bidirectional From conversions between link/session report types
- SessionEntry: mmp and is_initiator fields, initialized on Established
- send_session_msg() for reports/notifications
- Per-message RX recording with spin bit state tracking
- Handlers for SenderReport, ReceiverReport, PathMtuNotification
- path_mtu threaded from SessionDatagram envelope through to handlers
- check_session_mmp_reports() tick handler with collect-then-send pattern
- Periodic and teardown operator logging for session metrics
- PathMtuState: destination observes incoming MTU on all session messages,
  source seeded from outbound transport MTU, decrease-immediate /
  increase-requires-3-consecutive rules

Link-layer MMP fix:

- Stop feeding spin bit RTT samples into SRTT estimator; inter-frame
  timing in the mesh is irregular, inflating spin-bit RTT by variable
  processing delays; timestamp-echo provides accurate RTT

29 files changed, 602 tests pass, 0 clippy warnings.
This commit is contained in:
Johnathan Corgan
2026-02-19 03:15:05 +00:00
parent d8cb4d407e
commit 04d9fd625d
29 changed files with 2675 additions and 733 deletions
+1 -1
View File
@@ -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_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.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.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 The anti-replay window size (2048 packets) is a compile-time constant and not
configurable. configurable.
+11 -10
View File
@@ -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 | | 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 | | SessionDatagram envelope | 36 bytes | type + ttl + path_mtu + src_addr + dest_addr |
| DataPacket header | 12 bytes | type + flags + counter + payload_len | | FSP header | 12 bytes | 4-byte prefix + 8-byte counter (used as AEAD AAD) |
| Session encryption | 16 bytes | ChaCha20-Poly1305 AEAD tag | | FSP inner header | 6 bytes | 4-byte timestamp + 1-byte msg_type + 1-byte inner_flags (inside AEAD) |
| **Minimal total** | **101 bytes** | | | 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 | | 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. calculations.
### Effective IPv6 MTU ### Effective IPv6 MTU
@@ -142,14 +143,14 @@ For typical deployments:
| Transport MTU | Effective IPv6 MTU | Notes | | Transport MTU | Effective IPv6 MTU | Notes |
| ------------- | ------------------ | ----- | | ------------- | ------------------ | ----- |
| 1472 (UDP/Ethernet) | 1328 | Standard deployment | | 1472 (UDP/Ethernet) | 1322 | Standard deployment |
| 1280 (UDP minimum) | 1136 | Below IPv6 minimum | | 1280 (UDP minimum) | 1130 | Below IPv6 minimum |
IPv6 mandates that every link support at least 1280 bytes. The minimum IPv6 mandates that every link support at least 1280 bytes. The minimum
transport path MTU for the IPv6 adapter is therefore: transport path MTU for the IPv6 adapter is therefore:
```text ```text
1280 + 144 = 1424 bytes 1280 + 150 = 1430 bytes
``` ```
Transports with smaller MTUs (LoRa at ~250 bytes, serial at 256 bytes) cannot 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 Endpoint-only fragmentation (fragment before session encryption, reassemble
after decryption) is a future direction that avoids these objections. Each 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. to transit nodes.
## References ## References
- [fips-intro.md](fips-intro.md) — Protocol overview and architecture - [fips-intro.md](fips-intro.md) — Protocol overview and architecture
- [fips-session-layer.md](fips-session-layer.md) — FSP (below the adapter) - [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 formats
- [fips-configuration.md](fips-configuration.md) — TUN configuration parameters - [fips-configuration.md](fips-configuration.md) — TUN configuration parameters
+20 -21
View File
@@ -212,8 +212,7 @@ be made.
### Unified Cache ### Unified Cache
The coordinate cache is a single unified cache. All sources — SessionSetup The coordinate cache is a single unified cache. All sources — SessionSetup
transit, COORDS_PRESENT DataPackets, LookupResponse — write to the same transit, CP-flagged data packets, LookupResponse — write to the same cache.
cache.
### Population Sources ### Population Sources
@@ -221,7 +220,7 @@ cache.
| ------ | ---- | ---- | | ------ | ---- | ---- |
| SessionSetup transit | Session establishment | Both src and dest coordinates | | SessionSetup transit | Session establishment | Both src and dest coordinates |
| SessionAck transit | Session establishment | Responder's 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 | | LookupResponse | Discovery | Target's coordinates |
### Eviction ### Eviction
@@ -312,7 +311,7 @@ Destination Unreachable.
## SessionSetup Self-Bootstrapping ## SessionSetup Self-Bootstrapping
SessionSetup is the mechanism that warms transit node coordinate caches 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 ### How It Works
@@ -335,18 +334,19 @@ coordinates and warming caches in the other direction.
### Result ### Result
After the handshake completes, the entire forward and reverse paths have 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. 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 mechanism that complements SessionSetup. See
[fips-session-layer.md](fips-session-layer.md) for the warmup-then-reactive [fips-session-layer.md](fips-session-layer.md) for the warmup-then-reactive
strategy. strategy.
Transit nodes process DataPackets with COORDS_PRESENT by extracting and Transit nodes parse the CP flag from the FSP header and extract source and
caching both source and destination coordinates the same operation destination coordinates from the cleartext section between the header and
ciphertext — no decryption needed. This is the same caching operation
performed for SessionSetup coordinates. performed for SessionSetup coordinates.
## Error Recovery ## Error Recovery
@@ -367,8 +367,7 @@ coordinates for the destination. It cannot make a forwarding decision.
**Source recovery**: **Source recovery**:
1. Initiate discovery (LookupRequest flood) for the destination 1. Initiate discovery (LookupRequest flood) for the destination
2. Reset COORDS_PRESENT warmup counter — subsequent DataPackets include 2. Reset CP warmup counter — subsequent data packets include coordinates
coordinates
3. When discovery completes, warmup counter resets again (covers timing gap) 3. When discovery completes, warmup counter resets again (covers timing gap)
The crypto session remains active throughout — only routing state is The crypto session remains active throughout — only routing state is
@@ -386,7 +385,7 @@ source.
**Source recovery**: **Source recovery**:
1. Remove stale coordinates from cache 1. Remove stale coordinates from cache
2. Initiate discovery for the destination 2. Initiate discovery for the destination
3. Reset COORDS_PRESENT warmup counter 3. Reset CP warmup counter
### Error Signal Rate Limiting ### Error Signal Rate Limiting
@@ -397,11 +396,11 @@ packets to the same destination hit the same routing failure simultaneously.
### Error Routing Limitation ### Error Routing Limitation
Error signals route back to the source using `find_next_hop(src_addr)`. For 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 transit node may lack cached coordinates for the source. If so, the error is
silently dropped. 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 nodes receive source coordinates during the warmup phase. But after warmup
expires and transit caches for the source expire, errors may be lost. The 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 session idle timeout (90s) limits the window — if traffic stops long enough
@@ -423,8 +422,8 @@ sequence:
returns the destination's coordinates returns the destination's coordinates
4. **Session establishment**: SessionSetup carries coordinates, warming 4. **Session establishment**: SessionSetup carries coordinates, warming
transit caches along the path transit caches along the path
5. **Warmup**: First N DataPackets include COORDS_PRESENT, reinforcing 5. **Warmup**: First N data packets include CP flag, reinforcing transit
transit caches caches
The first packet to a new destination always triggers this sequence. The The first packet to a new destination always triggers this sequence. The
packet is queued (bounded) until the session is established. 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 - Transit nodes have cached coordinates for both endpoints
- Bloom filters have converged for the destination - 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 - Routing decisions are fast: bloom candidate selection + distance ranking
### Steady State ### Steady State
@@ -445,7 +444,7 @@ In steady state, the mesh is mostly self-maintaining:
- TreeAnnounce gossip keeps the spanning tree current - TreeAnnounce gossip keeps the spanning tree current
- FilterAnnounce gossip keeps bloom filters current - FilterAnnounce gossip keeps bloom filters current
- Coordinate caches are refreshed by active routing traffic - 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 are rare when traffic is flowing
### Cache Expiry and Recovery ### 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) | | LookupResponse | ~400 bytes | Response to discovery | Yes (greedy routed) |
| SessionDatagram + SessionSetup | ~232402 bytes | Session establishment | Yes (routed) | | SessionDatagram + SessionSetup | ~232402 bytes | Session establishment | Yes (routed) |
| SessionDatagram + SessionAck | ~122 bytes | Session confirmation | Yes (routed) | | SessionDatagram + SessionAck | ~122 bytes | Session confirmation | Yes (routed) |
| SessionDatagram + DataPacket (minimal) | 40 bytes + payload | Bulk traffic | Yes (routed) | | SessionDatagram + Data (minimal) | ~107 bytes + payload | Bulk traffic | Yes (routed) |
| SessionDatagram + DataPacket (with coords) | ~172 bytes + payload | Warmup/recovery | Yes (routed) | | SessionDatagram + Data (with CP) | ~150 bytes + payload | Warmup/recovery | Yes (routed) |
| SessionDatagram + CoordsRequired | 70 bytes | Cache miss error | Yes (routed) | | SessionDatagram + CoordsRequired | 70 bytes | Cache miss error | Yes (routed) |
| SessionDatagram + PathBroken | 70+ bytes | Dead-end error | Yes (routed) | | SessionDatagram + PathBroken | 70+ bytes | Dead-end error | Yes (routed) |
| Disconnect | 2 bytes | Link teardown | No (peer-to-peer) | | Disconnect | 2 bytes | Link teardown | No (peer-to-peer) |
@@ -550,7 +549,7 @@ recovery).
| Flush coord cache on parent change | **Implemented** | | Flush coord cache on parent change | **Implemented** |
| LookupRequest/LookupResponse discovery | **Implemented** | | LookupRequest/LookupResponse discovery | **Implemented** |
| SessionSetup self-bootstrapping | **Implemented** | | SessionSetup self-bootstrapping | **Implemented** |
| COORDS_PRESENT warmup-then-reactive | **Implemented** | | CP warmup-then-reactive | **Implemented** |
| CoordsRequired recovery | **Implemented** | | CoordsRequired recovery | **Implemented** |
| PathBroken recovery | **Implemented** | | PathBroken recovery | **Implemented** |
| Error signal rate limiting | **Implemented** | | Error signal rate limiting | **Implemented** |
+25 -21
View File
@@ -125,7 +125,7 @@ and transmitted after the session is established.
SessionSetup is self-bootstrapping for routing. It carries the source's and SessionSetup is self-bootstrapping for routing. It carries the source's and
destination's tree coordinates in the clear (not inside the Noise payload). destination's tree coordinates in the clear (not inside the Noise payload).
As the message transits intermediate nodes, each node caches these coordinates, 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). coordinates).
SessionAck carries the responder's coordinates back along the reverse path, SessionAck carries the responder's coordinates back along the reverse path,
@@ -145,12 +145,14 @@ This ensures exactly one handshake completes.
### Data Transfer ### Data Transfer
Once established, sessions carry DataPacket messages containing encrypted Once established, sessions carry encrypted data using the FSP pipeline. Each
application data. Each DataPacket includes: encrypted message includes:
- An explicit 8-byte counter for replay protection (used as the AEAD nonce) - A 12-byte cleartext header (used as AEAD AAD) containing the counter and
- A flags byte (including COORDS_PRESENT for cache warming) flags (including the CP flag for coordinate cache warming)
- The encrypted payload - 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 ### Session Idle Timeout
@@ -239,9 +241,9 @@ discarded after the handshake.
## Replay Protection ## Replay Protection
FSP uses explicit 8-byte counters on the wire for replay protection. Each side FSP uses explicit 8-byte counters on the wire for replay protection. Each side
maintains a monotonically increasing send counter, transmitted with every maintains a monotonically increasing send counter, included in the 12-byte
DataPacket. The receiver maintains a sliding window (2048-entry bitmap) cleartext header of every encrypted message. The receiver maintains a sliding
tracking which counters have been seen. window (2048-entry bitmap) tracking which counters have been seen.
This design is critical for operation over unreliable transports. Under UDP This design is critical for operation over unreliable transports. Under UDP
packet loss or reordering, implicit nonce counters (where the receiver 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 The same `ReplayWindow` and `decrypt_with_replay_check()` implementation is
used at both the link and session layers. 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 Session establishment (SessionSetup/SessionAck) warms transit node coordinate
caches along the path. But coordinate caches have a finite TTL (default 300s), 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 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. coordinates) and sends a CoordsRequired error.
FSP uses a warmup-then-reactive strategy to keep transit caches populated: FSP uses a warmup-then-reactive strategy to keep transit caches populated:
### Warmup Phase ### Warmup Phase
After session establishment, the first N DataPackets (configurable, default 5) After session establishment, the first N data packets (configurable, default 5)
include both source and destination coordinates via the COORDS_PRESENT flag. include both source and destination coordinates via the CP flag in the FSP
Transit nodes cache these coordinates as packets pass through, reinforcing the common prefix. The coordinates appear in cleartext between the 12-byte header
path established by SessionSetup. and the ciphertext, allowing transit nodes to cache them without decryption.
### Steady State ### Steady State
After the warmup count is reached, FSP clears the COORDS_PRESENT flag and After the warmup count is reached, FSP clears the CP flag and sends minimal
sends minimal DataPackets (4-byte header instead of ~136 bytes with data packets (12-byte header + ciphertext). Transit nodes serve from their
coordinates). Transit nodes serve from their coordinate caches. coordinate caches.
### Reactive Recovery ### Reactive Recovery
When FSP receives a CoordsRequired signal: 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 2. A new LookupRequest may be initiated to rediscover the destination's
current coordinates current coordinates
3. When the LookupResponse arrives for an established session, the warmup 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 The coordinate cache is a single unified cache (merged from previously
separate coord_cache and route_cache). All coordinate sources — SessionSetup 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 ### Eviction Policy
@@ -380,7 +382,9 @@ node caches (still within their 300s TTL) are re-warmed.
| Session establishment (Noise IK) | **Implemented** | | Session establishment (Noise IK) | **Implemented** |
| End-to-end encryption (ChaCha20-Poly1305) | **Implemented** | | End-to-end encryption (ChaCha20-Poly1305) | **Implemented** |
| Explicit counter replay protection | **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** | | Identity cache (LRU-only) | **Implemented** |
| Coordinate cache (unified, TTL + refresh) | **Implemented** | | Coordinate cache (unified, TTL + refresh) | **Implemented** |
| Session idle timeout | **Implemented** | | Session idle timeout | **Implemented** |
+147 -80
View File
@@ -396,103 +396,166 @@ Orderly link teardown with reason code.
| 0x07 | Timeout | Keepalive or stale detection timeout | | 0x07 | Timeout | Keepalive or stale detection timeout |
| 0xFF | Other | Unspecified reason | | 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. 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 | | Offset | Field | Size | Description |
| ------ | ----- | ---- | ----------- | | ------ | ----- | ---- | ----------- |
| 0 | msg_type | 1 byte | 0x00 | | 0 | flags | 1 byte | Bit 0: REQUEST_ACK, Bit 1: BIDIRECTIONAL |
| 1 | flags | 1 byte | Bit 0: REQUEST_ACK, Bit 1: BIDIRECTIONAL | | 1 | src_coords_count | 2 bytes LE | Number of source coordinate entries |
| 2 | src_coords_count | 2 bytes LE | Number of source coordinate entries | | 3 | src_coords | 16 x n bytes | Source's ancestry (NodeAddr, self -> root) |
| 4 | 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_count | 2 bytes LE | Number of dest coordinate entries |
| ... | dest_coords | 16 x m bytes | Destination's ancestry | | ... | dest_coords | 16 x m bytes | Destination's ancestry |
| ... | handshake_len | 2 bytes LE | Noise payload length | | ... | handshake_len | 2 bytes LE | Noise payload length |
| ... | handshake_payload | variable | Noise IK msg1 (82 bytes typical) | | ... | handshake_payload | variable | Noise IK msg1 (82 bytes typical) |
**Example** (depth-3 source, depth-4 destination): ### SessionAck (phase 0x2)
```text
SessionDatagram header: 36 bytes
SessionSetup payload: 1 + 1 + 2 + 48 + 2 + 64 + 2 + 82 = 202 bytes
Total: 238 bytes
```
### SessionAck (0x01)
Confirms session establishment, completes the Noise handshake. 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 | | Offset | Field | Size | Description |
| ------ | ----- | ---- | ----------- | | ------ | ----- | ---- | ----------- |
| 0 | msg_type | 1 byte | 0x01 | | 0 | flags | 1 byte | Reserved |
| 1 | flags | 1 byte | Reserved | | 1 | src_coords_count | 2 bytes LE | Number of coordinate entries |
| 2 | src_coords_count | 2 bytes LE | Number of coordinate entries | | 3 | src_coords | 16 x n bytes | Acknowledger's ancestry (for cache warming) |
| 4 | src_coords | 16 x n bytes | Acknowledger's ancestry (for cache warming) |
| ... | handshake_len | 2 bytes LE | Noise payload length | | ... | handshake_len | 2 bytes LE | Noise payload length |
| ... | handshake_payload | variable | Noise IK msg2 (33 bytes typical) | | ... | handshake_payload | variable | Noise IK msg2 (33 bytes typical) |
### DataPacket (0x10) ### Data (0x10)
Encrypted application data with explicit replay protection counter. Application data (typically IPv6 payload). This is the `msg_type` byte
inside the encrypted inner header — there is no separate DataPacket struct.
**Minimal header** (COORDS_PRESENT = 0): The body after the inner header is delivered directly to the TUN interface.
| 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 |
### CoordsRequired (0x20) ### CoordsRequired (0x20)
Link-layer error signal — transit node lacks coordinates for destination. Plaintext error signal — transit node lacks coordinates for destination.
Plaintext (not end-to-end encrypted), generated by transit nodes. 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 | | Offset | Field | Size | Description |
| ------ | ----- | ---- | ----------- | | ------ | ----- | ---- | ----------- |
| 0 | msg_type | 1 byte | 0x20 | | 0 | flags | 1 byte | Reserved |
| 1 | flags | 1 byte | Reserved | | 1 | dest_addr | 16 bytes | NodeAddr we couldn't route to |
| 2 | dest_addr | 16 bytes | NodeAddr we couldn't route to | | 17 | reporter | 16 bytes | NodeAddr of reporting router |
| 18 | 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) ### PathBroken (0x21)
Link-layer error signal — greedy routing reached a dead end. Plaintext, Plaintext error signal — greedy routing reached a dead end.
generated by transit nodes. 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 | | Offset | Field | Size | Description |
| ------ | ----- | ---- | ----------- | | ------ | ----- | ---- | ----------- |
| 0 | msg_type | 1 byte | 0x21 | | 0 | flags | 1 byte | Reserved |
| 1 | flags | 1 byte | Reserved | | 1 | dest_addr | 16 bytes | Unreachable NodeAddr |
| 2 | dest_addr | 16 bytes | Unreachable NodeAddr | | 17 | reporter | 16 bytes | NodeAddr of reporting router |
| 18 | reporter | 16 bytes | NodeAddr of reporting router | | 33 | last_coords_count | 2 bytes LE | Number of stale coordinate entries |
| 34 | last_coords_count | 2 bytes LE | Number of stale coordinate entries | | 35 | last_known_coords | 16 x n bytes | Stale coordinates that failed |
| 36 | last_known_coords | 16 x n bytes | Stale coordinates that failed |
## Encapsulation Walkthrough ## Encapsulation Walkthrough
@@ -507,19 +570,19 @@ Layer 4: Application data
1024 bytes 1024 bytes
Layer 3: Session encryption (FSP) Layer 3: Session encryption (FSP)
DataPacket header (12 bytes) + encrypted payload (1024) + AEAD tag (16) FSP header (12 bytes) + AEAD(inner_hdr (6) + payload (1024)) + AEAD tag (16)
= 1052 bytes = 1058 bytes
Layer 2: SessionDatagram envelope (FLP routing) Layer 2: SessionDatagram envelope (FLP routing)
msg_type (1) + ttl (1) + path_mtu (2) + src_addr (16) + dest_addr (16) + payload (1052) msg_type (1) + ttl (1) + path_mtu (2) + src_addr (16) + dest_addr (16) + payload (1058)
= 1088 bytes = 1094 bytes
Layer 1: Link encryption (FLP per-hop) Layer 1: Link encryption (FLP per-hop)
outer header (16) + encrypted(inner_hdr (5) + datagram (1088)) + AEAD tag (16) outer header (16) + encrypted(inner_hdr (5) + datagram (1094)) + AEAD tag (16)
= 1125 bytes = 1131 bytes
Layer 0: Transport Layer 0: Transport
UDP datagram containing 1125 bytes UDP datagram containing 1131 bytes
``` ```
### Overhead Budget ### Overhead Budget
@@ -528,11 +591,12 @@ Layer 0: Transport
| ----- | -------- | --------- | | ----- | -------- | --------- |
| Link encryption | 37 bytes | 16 outer header (AAD) + 5 inner header + 16 AEAD tag | | 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 | | 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 | | 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 | | 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 ### At Each Transit Node
@@ -581,20 +645,23 @@ endpoint session keys).
| ------- | ------------ | ----- | | ------- | ------------ | ----- |
| SessionSetup | ~200 bytes | Depth-dependent | | SessionSetup | ~200 bytes | Depth-dependent |
| SessionAck | ~80 bytes | Depth-dependent | | SessionAck | ~80 bytes | Depth-dependent |
| DataPacket (minimal) | 12 + payload bytes | Steady state | | Data (minimal) | 12 + 6 + payload + 16 bytes | Steady state |
| DataPacket (with coords) | 12 + ~130 + payload bytes | Warmup/recovery | | Data (with coords) | 12 + ~130 + 6 + payload + 16 bytes | Warmup/recovery |
| CoordsRequired | 34 bytes | Fixed | | SenderReport | 12 + 6 + 46 + 16 bytes | MMP metrics |
| PathBroken | 36 + 16n bytes | Includes stale coords | | 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) ### Complete Packet Sizes (link + session)
| Scenario | Wire Size | Notes | | Scenario | Wire Size | Notes |
| -------- | --------- | ----- | | -------- | --------- | ----- |
| Encrypted frame minimum | 37 bytes | Empty body | | Encrypted frame minimum | 37 bytes | Empty body |
| SessionDatagram + DataPacket (minimal) | 37 + 36 + 12 + payload + 16 | 101 + payload | | SessionDatagram + Data (minimal) | 37 + 36 + 12 + 6 + payload + 16 | 107 + payload |
| SessionDatagram + DataPacket (with coords) | ~144 + payload | Worst case | | SessionDatagram + Data (with coords) | ~150 + payload | Worst case |
| SessionDatagram + SessionSetup | ~275 bytes | Depth-3, both dirs | | 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 ## References
+1 -1
View File
@@ -30,7 +30,7 @@ use thiserror::Error;
pub use node::{ pub use node::{
BloomConfig, BuffersConfig, CacheConfig, DiscoveryConfig, LimitsConfig, NodeConfig, BloomConfig, BuffersConfig, CacheConfig, DiscoveryConfig, LimitsConfig, NodeConfig,
RateLimitConfig, RetryConfig, SessionConfig, TreeConfig, RateLimitConfig, RetryConfig, SessionConfig, SessionMmpConfig, TreeConfig,
}; };
pub use peer::{ConnectPolicy, PeerAddress, PeerConfig}; pub use peer::{ConnectPolicy, PeerAddress, PeerConfig};
pub use transport::{TransportInstances, TransportsConfig, UdpConfig}; pub use transport::{TransportInstances, TransportsConfig, UdpConfig};
+44 -3
View File
@@ -7,7 +7,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::IdentityConfig; use super::IdentityConfig;
use crate::mmp::MmpConfig; use crate::mmp::{MmpConfig, MmpMode, DEFAULT_LOG_INTERVAL_SECS, DEFAULT_OWD_WINDOW_SIZE};
// ============================================================================ // ============================================================================
// Node Configuration Subsections // Node Configuration Subsections
@@ -227,7 +227,7 @@ pub struct SessionConfig {
/// Established sessions with no activity for this duration are removed. /// Established sessions with no activity for this duration are removed.
#[serde(default = "SessionConfig::default_idle_timeout_secs")] #[serde(default = "SessionConfig::default_idle_timeout_secs")]
pub idle_timeout_secs: u64, 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`). /// for transit cache warmup (`node.session.coords_warmup_packets`).
/// Also used as the reset count on CoordsRequired receipt. /// Also used as the reset count on CoordsRequired receipt.
#[serde(default = "SessionConfig::default_coords_warmup_packets")] #[serde(default = "SessionConfig::default_coords_warmup_packets")]
@@ -254,6 +254,42 @@ impl SessionConfig {
fn default_coords_warmup_packets() -> u8 { 5 } 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.*`). /// Internal buffers (`node.buffers.*`).
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuffersConfig { pub struct BuffersConfig {
@@ -343,9 +379,13 @@ pub struct NodeConfig {
#[serde(default)] #[serde(default)]
pub buffers: BuffersConfig, pub buffers: BuffersConfig,
/// Metrics Measurement Protocol (`node.mmp.*`). /// Metrics Measurement Protocol — link layer (`node.mmp.*`).
#[serde(default)] #[serde(default)]
pub mmp: MmpConfig, pub mmp: MmpConfig,
/// Metrics Measurement Protocol — session layer (`node.session_mmp.*`).
#[serde(default)]
pub session_mmp: SessionMmpConfig,
} }
impl Default for NodeConfig { impl Default for NodeConfig {
@@ -365,6 +405,7 @@ impl Default for NodeConfig {
session: SessionConfig::default(), session: SessionConfig::default(),
buffers: BuffersConfig::default(), buffers: BuffersConfig::default(),
mmp: MmpConfig::default(), mmp: MmpConfig::default(),
session_mmp: SessionMmpConfig::default(),
} }
} }
} }
+1 -1
View File
@@ -43,7 +43,7 @@ pub use transport::udp::UdpTransport;
// Re-export protocol types // Re-export protocol types
pub use protocol::{ pub use protocol::{
CoordsRequired, DataFlags, DataPacket, FilterAnnounce, HandshakeMessageType, LinkMessageType, CoordsRequired, FilterAnnounce, HandshakeMessageType, LinkMessageType,
LookupRequest, LookupResponse, PathBroken, ProtocolError, SessionAck, SessionDatagram, LookupRequest, LookupResponse, PathBroken, ProtocolError, SessionAck, SessionDatagram,
SessionFlags, SessionMessageType, SessionSetup, TreeAnnounce, SessionFlags, SessionMessageType, SessionSetup, TreeAnnounce,
}; };
+9
View File
@@ -8,6 +8,7 @@
use crate::mmp::algorithms::{DualEwma, SrttEstimator, compute_etx}; use crate::mmp::algorithms::{DualEwma, SrttEstimator, compute_etx};
use crate::mmp::report::ReceiverReport; use crate::mmp::report::ReceiverReport;
use std::time::Instant; use std::time::Instant;
use tracing::debug;
/// Derived MMP metrics, updated from incoming ReceiverReports. /// Derived MMP metrics, updated from incoming ReceiverReports.
/// ///
@@ -80,6 +81,14 @@ impl MmpMetrics {
if our_timestamp_ms > echo_ms + dwell_ms { if our_timestamp_ms > echo_ms + dwell_ms {
let rtt_ms = 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; 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.srtt.update(rtt_us);
self.rtt_trend.update(rtt_us as f64); self.rtt_trend.update(rtt_us as f64);
} }
+258 -7
View File
@@ -30,6 +30,9 @@ pub use receiver::ReceiverState;
pub use report::{ReceiverReport, SenderReport}; pub use report::{ReceiverReport, SenderReport};
pub use sender::SenderState; pub use sender::SenderState;
// Session-layer re-exports
// MmpSessionState and PathMtuState are defined in this file
// ============================================================================ // ============================================================================
// Constants // Constants
// ============================================================================ // ============================================================================
@@ -80,15 +83,29 @@ pub const DEFAULT_OWD_WINDOW_SIZE: usize = 32;
/// Default operator log interval in seconds. /// Default operator log interval in seconds.
pub const DEFAULT_LOG_INTERVAL_SECS: u64 = 30; 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 // Operating Mode
// ============================================================================ // ============================================================================
/// MMP 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")] #[serde(rename_all = "lowercase")]
pub enum MmpMode { pub enum MmpMode {
/// Sender + receiver reports at RTT-adaptive intervals. Maximum fidelity. /// Sender + receiver reports at RTT-adaptive intervals. Maximum fidelity.
#[default]
Full, Full,
/// Receiver reports only. Loss inferred from counter gaps. /// Receiver reports only. Loss inferred from counter gaps.
Lightweight, Lightweight,
@@ -96,12 +113,6 @@ pub enum MmpMode {
Minimal, Minimal,
} }
impl Default for MmpMode {
fn default() -> Self {
MmpMode::Full
}
}
impl fmt::Display for MmpMode { impl fmt::Display for MmpMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { 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<Instant>,
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<Instant>,
/// 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<Instant>,
/// 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<u16> {
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 { impl Debug for MmpPeerState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("MmpPeerState") f.debug_struct("MmpPeerState")
+19 -4
View File
@@ -170,6 +170,14 @@ pub struct ReceiverState {
impl ReceiverState { impl ReceiverState {
pub fn new(owd_window_size: usize) -> Self { 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 { Self {
cumulative_packets_recv: 0, cumulative_packets_recv: 0,
cumulative_bytes_recv: 0, cumulative_bytes_recv: 0,
@@ -185,7 +193,7 @@ impl ReceiverState {
last_sender_timestamp: 0, last_sender_timestamp: 0,
last_recv_time: None, last_recv_time: None,
last_report_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, 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]. /// Receiver reports at 1× SRTT, clamped to [MIN, MAX].
pub fn update_report_interval_from_srtt(&mut self, srtt_us: i64) { 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 { if srtt_us <= 0 {
return; return;
} }
let interval_ms = ((srtt_us as u64) / 1000) let interval_ms = ((srtt_us as u64) / 1000).clamp(min_ms, max_ms);
.clamp(MIN_REPORT_INTERVAL_MS, MAX_REPORT_INTERVAL_MS);
self.report_interval = Duration::from_millis(interval_ms); self.report_interval = Duration::from_millis(interval_ms);
} }
+76
View File
@@ -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 // Tests
// ============================================================================ // ============================================================================
+20 -6
View File
@@ -35,6 +35,14 @@ pub struct SenderState {
impl SenderState { impl SenderState {
pub fn new() -> Self { 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 { Self {
cumulative_packets_sent: 0, cumulative_packets_sent: 0,
cumulative_bytes_sent: 0, cumulative_bytes_sent: 0,
@@ -45,7 +53,7 @@ impl SenderState {
last_timestamp: 0, last_timestamp: 0,
interval_has_data: false, interval_has_data: false,
last_report_time: None, 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, /// Sender reports at 2× SRTT clamped to [MIN, MAX].
/// we use 2× SRTT clamped to [MIN, MAX].
pub fn update_report_interval_from_srtt(&mut self, srtt_us: i64) { 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 { if srtt_us <= 0 {
return; return;
} }
let interval_us = (srtt_us * 2) as u64; let interval_us = (srtt_us * 2) as u64;
let interval_ms = (interval_us / 1000) let interval_ms = (interval_us / 1000).clamp(min_ms, max_ms);
.clamp(MIN_REPORT_INTERVAL_MS, MAX_REPORT_INTERVAL_MS);
self.report_interval = Duration::from_millis(interval_ms); self.report_interval = Duration::from_millis(interval_ms);
} }
+1 -1
View File
@@ -151,7 +151,7 @@ impl Node {
// If an established session exists, reset the warmup counter. // If an established session exists, reset the warmup counter.
// Discovery has completed and transit nodes along the response // Discovery has completed and transit nodes along the response
// path now have fresh coords. Reset warmup so the next N // 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) if let Some(entry) = self.sessions.get_mut(&target)
&& entry.is_established() && entry.is_established()
{ {
+6 -4
View File
@@ -100,10 +100,12 @@ impl Node {
ce_flag, ce_flag,
now, now,
); );
// Spin bit: feed to spin state, get optional RTT sample // Spin bit: advance state machine for correct TX reflection.
if let Some(rtt) = mmp.spin_bit.rx_observe(sp_flag, header.counter, now) { // RTT samples from spin bit are not used for SRTT because
mmp.metrics.srtt.update(rtt.as_micros() as i64); // 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 // Update address for roaming support
+46 -39
View File
@@ -6,9 +6,12 @@
//! locally, and generates error signals on routing failure. //! locally, and generates error signals on routing failure.
use crate::node::Node; 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::{ use crate::protocol::{
CoordsRequired, DataPacket, PathBroken, SessionAck, SessionDatagram, SessionMessageType, CoordsRequired, PathBroken, SessionAck, SessionDatagram, SessionSetup,
SessionSetup,
}; };
use crate::NodeAddr; use crate::NodeAddr;
use tracing::debug; use tracing::debug;
@@ -42,7 +45,7 @@ impl Node {
// Local delivery: dispatch to session layer handlers // Local delivery: dispatch to session layer handlers
if datagram.dest_addr == *self.node_addr() { 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; .await;
return; return;
} }
@@ -81,28 +84,29 @@ impl Node {
/// Attempt to warm the coordinate cache from session-layer payload headers. /// Attempt to warm the coordinate cache from session-layer payload headers.
/// ///
/// Transit routers can read the session message type byte and, for /// Transit routers parse the 4-byte FSP common prefix to identify message
/// SessionSetup and SessionAck, extract plaintext coordinate fields. /// type, then extract plaintext coordinate fields from:
/// DataPacket with COORDS_PRESENT has plaintext coords before the /// - SessionSetup (phase 0x1): src_coords + dest_coords
/// encrypted payload. Other types are ignored. /// - 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 /// Decode failures are logged and silently ignored — they don't block
/// forwarding. /// forwarding.
fn try_warm_coord_cache(&mut self, datagram: &SessionDatagram) { fn try_warm_coord_cache(&mut self, datagram: &SessionDatagram) {
if datagram.payload.is_empty() { let prefix = match FspCommonPrefix::parse(&datagram.payload) {
return; Some(p) => p,
} None => return,
};
let msg_type = datagram.payload[0]; let inner = &datagram.payload[FSP_COMMON_PREFIX_SIZE..];
let inner = &datagram.payload[1..];
let now_ms = std::time::SystemTime::now() let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64) .map(|d| d.as_millis() as u64)
.unwrap_or(0); .unwrap_or(0);
match SessionMessageType::from_byte(msg_type) { match prefix.phase {
Some(SessionMessageType::SessionSetup) => { FSP_PHASE_MSG1 => {
match SessionSetup::decode(inner) { match SessionSetup::decode(inner) {
Ok(setup) => { Ok(setup) => {
self.coord_cache_mut().insert( self.coord_cache_mut().insert(
@@ -126,7 +130,7 @@ impl Node {
} }
} }
} }
Some(SessionMessageType::SessionAck) => { FSP_PHASE_MSG2 => {
match SessionAck::decode(inner) { match SessionAck::decode(inner) {
Ok(ack) => { Ok(ack) => {
self.coord_cache_mut().insert( self.coord_cache_mut().insert(
@@ -144,38 +148,41 @@ impl Node {
} }
} }
} }
Some(SessionMessageType::DataPacket) => { FSP_PHASE_ESTABLISHED if prefix.has_coords() => {
match DataPacket::decode(inner) { // CP flag set: coords in cleartext between header and ciphertext.
Ok(data) => { // Parse coords from the cleartext section after the 12-byte header.
if data.flags.coords_present { // inner starts after the 4-byte prefix, so we need 8 more bytes
if let Some(src_coords) = data.src_coords { // for the counter (header is 12 total = 4 prefix + 8 counter).
self.coord_cache_mut().insert( let coord_data = &datagram.payload[FSP_HEADER_SIZE..];
datagram.src_addr, match parse_encrypted_coords(coord_data) {
src_coords, Ok((src_coords, dest_coords, _bytes_consumed)) => {
now_ms, if let Some(coords) = src_coords {
); self.coord_cache_mut().insert(
} datagram.src_addr,
if let Some(dest_coords) = data.dest_coords { coords,
self.coord_cache_mut().insert( now_ms,
datagram.dest_addr,
dest_coords,
now_ms,
);
}
debug!(
src = %datagram.src_addr,
dest = %datagram.dest_addr,
"Cached coords from DataPacket"
); );
} }
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) => { 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
} }
} }
} }
+163 -8
View File
@@ -5,8 +5,12 @@
//! and teardown metric logs. //! and teardown metric logs.
use crate::mmp::MmpMode; use crate::mmp::MmpMode;
use crate::mmp::MmpSessionState;
use crate::mmp::report::{ReceiverReport, SenderReport}; use crate::mmp::report::{ReceiverReport, SenderReport};
use crate::node::Node; use crate::node::Node;
use crate::protocol::{
PathMtuNotification, SessionMessageType, SessionReceiverReport, SessionSenderReport,
};
use crate::NodeAddr; use crate::NodeAddr;
use std::time::Instant; use std::time::Instant;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
@@ -139,17 +143,19 @@ impl Node {
let mode = mmp.mode(); let mode = mmp.mode();
// Sender reports: Full mode only // Sender reports: Full mode only
if mode == MmpMode::Full && mmp.sender.should_send_report(now) { if mode == MmpMode::Full
if let Some(sr) = mmp.sender.build_report(now) { && mmp.sender.should_send_report(now)
sender_reports.push((*node_addr, sr.encode())); && let Some(sr) = mmp.sender.build_report(now)
} {
sender_reports.push((*node_addr, sr.encode()));
} }
// Receiver reports: Full and Lightweight modes // Receiver reports: Full and Lightweight modes
if mode != MmpMode::Minimal && mmp.receiver.should_send_report(now) { if mode != MmpMode::Minimal
if let Some(rr) = mmp.receiver.build_report(now) { && mmp.receiver.should_send_report(now)
receiver_reports.push((*node_addr, rr.encode())); && let Some(rr) = mmp.receiver.build_report(now)
} {
receiver_reports.push((*node_addr, rr.encode()));
} }
// Periodic operator logging // Periodic operator logging
@@ -243,4 +249,153 @@ impl Node {
"MMP link teardown" "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<u8>)> = 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"
);
}
} }
+1
View File
@@ -84,6 +84,7 @@ impl Node {
self.check_tree_state().await; self.check_tree_state().await;
self.check_bloom_state().await; self.check_bloom_state().await;
self.check_mmp_reports().await; self.check_mmp_reports().await;
self.check_session_mmp_reports().await;
self.purge_stale_lookups(now_ms); self.purge_stale_lookups(now_ms);
} }
} }
+511 -139
View File
@@ -1,16 +1,24 @@
//! End-to-end session message handlers. //! End-to-end session message handlers.
//! //!
//! Handles locally-delivered session payloads from SessionDatagram envelopes. //! Handles locally-delivered session payloads from SessionDatagram envelopes.
//! Dispatches based on session message type to specific handlers for //! Dispatches based on FSP common prefix phase to specific handlers for
//! SessionSetup (Noise IK msg1), SessionAck (msg2), DataPacket, and //! SessionSetup (Noise IK msg1), SessionAck (msg2), encrypted data,
//! error signals (CoordsRequired, PathBroken). //! and error signals (CoordsRequired, PathBroken).
use crate::node::session::{EndToEndState, SessionEntry}; 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::node::{Node, NodeError};
use crate::noise::{HandshakeState, HANDSHAKE_MSG1_SIZE, HANDSHAKE_MSG2_SIZE}; 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::{ use crate::protocol::{
CoordsRequired, DataPacket, PathBroken, SessionAck, SessionDatagram, SessionMessageType, CoordsRequired, FspInnerFlags, PathBroken, PathMtuNotification, SessionAck, SessionDatagram,
SessionSetup, SessionMessageType, SessionReceiverReport, SessionSenderReport, SessionSetup,
}; };
use crate::NodeAddr; use crate::NodeAddr;
use secp256k1::PublicKey; use secp256k1::PublicKey;
@@ -20,42 +28,239 @@ impl Node {
/// Handle a locally-delivered session datagram payload. /// Handle a locally-delivered session datagram payload.
/// ///
/// Called from `handle_session_datagram()` when `dest_addr == self.node_addr()`. /// 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( pub(in crate::node) async fn handle_session_payload(
&mut self, &mut self,
src_addr: &NodeAddr, src_addr: &NodeAddr,
payload: &[u8], payload: &[u8],
path_mtu: u16,
) { ) {
if payload.is_empty() { let prefix = match FspCommonPrefix::parse(payload) {
debug!("Empty session payload"); Some(p) => p,
return; None => {
} debug!(len = payload.len(), "Session payload too short for FSP prefix");
return;
}
};
let msg_type = payload[0]; let inner = &payload[FSP_COMMON_PREFIX_SIZE..];
let inner = &payload[1..];
match SessionMessageType::from_byte(msg_type) { match prefix.phase {
Some(SessionMessageType::SessionSetup) => { FSP_PHASE_MSG1 => {
self.handle_session_setup(src_addr, inner).await; self.handle_session_setup(src_addr, inner).await;
} }
Some(SessionMessageType::SessionAck) => { FSP_PHASE_MSG2 => {
self.handle_session_ack(src_addr, inner).await; self.handle_session_ack(src_addr, inner).await;
} }
Some(SessionMessageType::DataPacket) => { FSP_PHASE_ESTABLISHED if prefix.is_unencrypted() => {
self.handle_data_packet(src_addr, inner).await; // 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) => { FSP_PHASE_ESTABLISHED => {
self.handle_coords_required(inner).await; self.handle_encrypted_session_msg(src_addr, payload, path_mtu).await;
} }
Some(SessionMessageType::PathBroken) => { _ => {
self.handle_path_broken(inner).await; debug!(phase = prefix.phase, "Unknown FSP phase");
}
None => {
debug!(msg_type, "Unknown session message type");
} }
} }
} }
/// 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). /// Handle an incoming SessionSetup (Noise IK msg1).
/// ///
/// The remote node wants to establish an end-to-end session with us. /// 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 our_coords = self.tree_state.my_coords().clone();
let ack = SessionAck::new(our_coords).with_handshake(msg2); let ack = SessionAck::new(our_coords).with_handshake(msg2);
let my_addr = *self.node_addr(); 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); .with_ttl(self.config.node.session.default_ttl);
// Route the ack back to the initiator // 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"); debug!(error = %e, dest = %src_addr, "Failed to send SessionAck");
return; return;
} }
// Store session entry in Responding state // Store session entry in Responding state
let now_ms = Self::now_ms(); 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); self.sessions.insert(*src_addr, entry);
debug!(src = %src_addr, "SessionSetup processed, SessionAck sent"); 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_state(EndToEndState::Established(session));
entry.set_coords_warmup_remaining(self.config.node.session.coords_warmup_packets); 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); 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); self.coord_cache.insert(*src_addr, ack.src_coords, now_ms);
// Flush any queued outbound packets for this destination // Flush any queued outbound packets for this destination
@@ -225,6 +430,136 @@ impl Node {
debug!(src = %src_addr, "Session established (initiator)"); 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. /// Complete an initiator-side Noise IK handshake given msg2.
fn complete_initiator_handshake( fn complete_initiator_handshake(
mut handshake: HandshakeState, mut handshake: HandshakeState,
@@ -238,90 +573,6 @@ impl Node {
.map_err(|e| format!("into_session failed: {}", e)) .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. /// Handle a CoordsRequired error signal from a transit router.
/// ///
/// The router couldn't route our packet because it lacks cached /// The router couldn't route our packet because it lacks cached
@@ -431,18 +682,18 @@ impl Node {
// Wrap in SessionDatagram // Wrap in SessionDatagram
let my_addr = *self.node_addr(); 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); .with_ttl(self.config.node.session.default_ttl);
// Route toward destination // Route toward destination
self.send_session_datagram(&datagram).await?; self.send_session_datagram(&mut datagram).await?;
// Register destination identity for TUN → session routing // Register destination identity for TUN → session routing
self.register_identity(dest_addr, dest_pubkey); self.register_identity(dest_addr, dest_pubkey);
// Store session entry // Store session entry
let now_ms = Self::now_ms(); 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); self.sessions.insert(dest_addr, entry);
debug!(dest = %dest_addr, "Session initiation started"); debug!(dest = %dest_addr, "Session initiation started");
@@ -451,18 +702,28 @@ impl Node {
/// Send application data over an established session. /// Send application data over an established session.
/// ///
/// Encrypts the payload with the session key, wraps in DataPacket /// Uses the FSP pipeline: builds a 12-byte cleartext header (used as AAD),
/// and SessionDatagram, routes toward destination. /// 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( pub(in crate::node) async fn send_session_data(
&mut self, &mut self,
dest_addr: &NodeAddr, dest_addr: &NodeAddr,
plaintext: &[u8], plaintext: &[u8],
) -> Result<(), NodeError> { ) -> Result<(), NodeError> {
let now_ms = Self::now_ms();
let entry = self.sessions.get_mut(dest_addr).ok_or_else(|| NodeError::SendFailed { let entry = self.sessions.get_mut(dest_addr).ok_or_else(|| NodeError::SendFailed {
node_addr: *dest_addr, node_addr: *dest_addr,
reason: "no session".into(), 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() { let session = match entry.state_mut() {
EndToEndState::Established(s) => s, EndToEndState::Established(s) => s,
_ => { _ => {
@@ -476,35 +737,129 @@ impl Node {
// Get counter before encrypting (encrypt will increment it) // Get counter before encrypting (encrypt will increment it)
let counter = session.current_send_counter(); let counter = session.current_send_counter();
// Encrypt with session key // FSP inner header: [timestamp:4 LE][msg_type:1][inner_flags:1] + plaintext
let ciphertext = session.encrypt(plaintext).map_err(|e| NodeError::SendFailed { let msg_type = SessionMessageType::DataPacket.to_byte(); // 0x10
node_addr: *dest_addr, let inner_flags = FspInnerFlags { spin_bit }.to_byte();
reason: format!("session encrypt failed: {}", e), 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) // Assemble: header(12) + [coords] + ciphertext
let include_coords = entry.coords_warmup_remaining() > 0; let mut fsp_payload = Vec::with_capacity(FSP_HEADER_SIZE + ciphertext.len() + 200);
if include_coords { fsp_payload.extend_from_slice(&header);
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);
if include_coords { if include_coords {
let my_coords = self.tree_state.my_coords().clone(); let my_coords = self.tree_state.my_coords().clone();
let dest_coords = self.get_dest_coords(dest_addr); 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 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); .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) // Re-borrow after send (which borrowed &mut self)
if let Some(entry) = self.sessions.get_mut(dest_addr) { 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(()) Ok(())
@@ -512,11 +867,11 @@ impl Node {
/// Route and send a SessionDatagram through the mesh. /// Route and send a SessionDatagram through the mesh.
/// ///
/// Finds the next hop for the destination and sends the datagram /// Finds the next hop for the destination, seeds path_mtu from the
/// as an encrypted link message. /// first-hop transport MTU, and sends as an encrypted link message.
async fn send_session_datagram( async fn send_session_datagram(
&mut self, &mut self,
datagram: &SessionDatagram, datagram: &mut SessionDatagram,
) -> Result<(), NodeError> { ) -> Result<(), NodeError> {
let next_hop_addr = match self.find_next_hop(&datagram.dest_addr) { let next_hop_addr = match self.find_next_hop(&datagram.dest_addr) {
Some(peer) => *peer.node_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(); let encoded = datagram.encode();
self.send_encrypted_link_message(&next_hop_addr, &encoded).await self.send_encrypted_link_message(&next_hop_addr, &encoded).await
} }
+6
View File
@@ -98,6 +98,12 @@ impl Node {
.collect(); .collect();
for addr in idle { 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.sessions.remove(&addr);
self.pending_tun_packets.remove(&addr); self.pending_tun_packets.remove(&addr);
debug!( debug!(
+1
View File
@@ -11,6 +11,7 @@ mod retry;
mod rate_limit; mod rate_limit;
mod routing_error_rate_limit; mod routing_error_rate_limit;
pub(crate) mod session; pub(crate) mod session;
pub(crate) mod session_wire;
pub(crate) mod wire; pub(crate) mod wire;
mod tree; mod tree;
#[cfg(test)] #[cfg(test)]
+53 -1
View File
@@ -4,6 +4,8 @@
//! Sessions are established via SessionSetup/SessionAck handshake //! Sessions are established via SessionSetup/SessionAck handshake
//! messages carried inside SessionDatagram envelopes through the mesh. //! messages carried inside SessionDatagram envelopes through the mesh.
use crate::config::SessionMmpConfig;
use crate::mmp::MmpSessionState;
use crate::noise::{HandshakeState, NoiseSession}; use crate::noise::{HandshakeState, NoiseSession};
use crate::NodeAddr; use crate::NodeAddr;
use secp256k1::PublicKey; use secp256k1::PublicKey;
@@ -54,10 +56,19 @@ pub(crate) struct SessionEntry {
created_at: u64, created_at: u64,
/// Last activity timestamp (Unix milliseconds). /// Last activity timestamp (Unix milliseconds).
last_activity: u64, 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; /// Initialized from config when session becomes Established;
/// reset on CoordsRequired receipt. /// reset on CoordsRequired receipt.
coords_warmup_remaining: u8, 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<MmpSessionState>,
} }
impl SessionEntry { impl SessionEntry {
@@ -67,6 +78,7 @@ impl SessionEntry {
remote_pubkey: PublicKey, remote_pubkey: PublicKey,
state: EndToEndState, state: EndToEndState,
now_ms: u64, now_ms: u64,
is_initiator: bool,
) -> Self { ) -> Self {
Self { Self {
remote_addr, remote_addr,
@@ -74,7 +86,10 @@ impl SessionEntry {
state: Some(state), state: Some(state),
created_at: now_ms, created_at: now_ms,
last_activity: now_ms, last_activity: now_ms,
session_start_ms: 0,
coords_warmup_remaining: 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) { pub(crate) fn set_coords_warmup_remaining(&mut self, value: u8) {
self.coords_warmup_remaining = value; 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));
}
} }
+582
View File
@@ -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<Self> {
if data.len() < FSP_COMMON_PREFIX_SIZE {
return None;
}
let version = data[0] >> 4;
let phase = data[0] & 0x0F;
let flags = data[1];
let payload_len = u16::from_le_bytes([data[2], data[3]]);
Some(Self {
version,
phase,
flags,
payload_len,
})
}
/// Check if the Unencrypted flag is set.
pub fn is_unencrypted(&self) -> bool {
self.flags & FSP_FLAG_U != 0
}
/// Check if the Coords Present flag is set.
pub fn has_coords(&self) -> bool {
self.flags & FSP_FLAG_CP != 0
}
/// Encode the ver+phase byte.
fn ver_phase_byte(version: u8, phase: u8) -> u8 {
(version << 4) | (phase & 0x0F)
}
}
// ============================================================================
// Encrypted Message Header
// ============================================================================
/// Parsed FSP encrypted message header (phase 0x0, U flag clear).
///
/// Wire format (12 bytes):
/// ```text
/// [ver+phase:1][flags:1][payload_len:2 LE][counter:8 LE]
/// ```
///
/// The full 12-byte header is used as AAD for the AEAD construction.
/// No receiver_idx — unlike 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<Self> {
if data.len() < FSP_ENCRYPTED_MIN_SIZE {
return None;
}
let version = data[0] >> 4;
let phase = data[0] & 0x0F;
if version != FSP_VERSION || phase != FSP_PHASE_ESTABLISHED {
return None;
}
let flags = data[1];
// U flag means plaintext — not an encrypted message
if flags & FSP_FLAG_U != 0 {
return None;
}
let payload_len = u16::from_le_bytes([data[2], data[3]]);
let counter = u64::from_le_bytes([
data[4], data[5], data[6], data[7],
data[8], data[9], data[10], data[11],
]);
let mut header_bytes = [0u8; FSP_HEADER_SIZE];
header_bytes.copy_from_slice(&data[..FSP_HEADER_SIZE]);
Some(Self {
flags,
payload_len,
counter,
header_bytes,
})
}
/// Check if the Coords Present flag is set.
pub fn has_coords(&self) -> bool {
self.flags & FSP_FLAG_CP != 0
}
/// Offset where ciphertext (or coords if CP) begins in the original data.
#[cfg_attr(not(test), allow(dead_code))]
pub fn data_offset(&self) -> usize {
FSP_HEADER_SIZE
}
}
// ============================================================================
// Serialization Helpers
// ============================================================================
/// Build the 12-byte cleartext header for an encrypted FSP message.
///
/// Returns the header bytes for use as AEAD AAD.
pub fn build_fsp_header(
counter: u64,
flags: u8,
payload_len: u16,
) -> [u8; FSP_HEADER_SIZE] {
let mut header = [0u8; FSP_HEADER_SIZE];
header[0] = FspCommonPrefix::ver_phase_byte(FSP_VERSION, FSP_PHASE_ESTABLISHED);
header[1] = flags;
header[2..4].copy_from_slice(&payload_len.to_le_bytes());
header[4..12].copy_from_slice(&counter.to_le_bytes());
header
}
/// Assemble a wire-format encrypted FSP message.
///
/// Format: `[header:12][ciphertext+tag]`
#[cfg_attr(not(test), allow(dead_code))]
pub fn build_fsp_encrypted(header: &[u8; FSP_HEADER_SIZE], ciphertext: &[u8]) -> Vec<u8> {
let mut packet = Vec::with_capacity(FSP_HEADER_SIZE + ciphertext.len());
packet.extend_from_slice(header);
packet.extend_from_slice(ciphertext);
packet
}
/// Build a 4-byte common prefix for a handshake message.
///
/// `phase` should be `FSP_PHASE_MSG1` 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<u8> {
let mut buf = Vec::with_capacity(FSP_INNER_HEADER_SIZE + payload.len());
buf.extend_from_slice(&timestamp_ms.to_le_bytes());
buf.push(msg_type);
buf.push(inner_flags);
buf.extend_from_slice(payload);
buf
}
/// Strip the 6-byte FSP inner header from a decrypted payload.
///
/// Returns `(timestamp, msg_type, inner_flags, &rest)` or None if too short.
pub fn fsp_strip_inner_header(plaintext: &[u8]) -> Option<(u32, u8, u8, &[u8])> {
if plaintext.len() < FSP_INNER_HEADER_SIZE {
return None;
}
let timestamp = u32::from_le_bytes([
plaintext[0], plaintext[1], plaintext[2], plaintext[3],
]);
let msg_type = plaintext[4];
let inner_flags = plaintext[5];
Some((timestamp, msg_type, inner_flags, &plaintext[FSP_INNER_HEADER_SIZE..]))
}
// ============================================================================
// Coordinate Parsing (for transit nodes and receive path)
// ============================================================================
/// Parse source and destination coordinates from the cleartext section
/// of an encrypted FSP message when the CP flag is set.
///
/// Coordinates appear between the 12-byte header and the ciphertext:
/// `[src_coords_count:2 LE][src_coords:16×n][dest_coords_count:2 LE][dest_coords:16×m]`
///
/// Returns `(src_coords, dest_coords, bytes_consumed)`.
pub fn parse_encrypted_coords(
data: &[u8],
) -> Result<(Option<TreeCoordinate>, Option<TreeCoordinate>, usize), ProtocolError> {
let (src_coords, src_consumed) = decode_optional_coords(data)?;
let (dest_coords, dest_consumed) = decode_optional_coords(&data[src_consumed..])?;
Ok((src_coords, dest_coords, src_consumed + dest_consumed))
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
// ===== Size Constant Tests =====
#[test]
fn test_wire_sizes() {
assert_eq!(FSP_COMMON_PREFIX_SIZE, 4);
assert_eq!(FSP_HEADER_SIZE, 12);
assert_eq!(FSP_INNER_HEADER_SIZE, 6);
assert_eq!(FSP_ENCRYPTED_MIN_SIZE, 28); // 12 + 16
}
// ===== Common Prefix Tests =====
#[test]
fn test_common_prefix_parse_established() {
let data = [0x00, 0x01, 0x40, 0x00]; // ver=0, phase=0, flags=CP, payload_len=64
let prefix = FspCommonPrefix::parse(&data).unwrap();
assert_eq!(prefix.version, 0);
assert_eq!(prefix.phase, FSP_PHASE_ESTABLISHED);
assert_eq!(prefix.flags, FSP_FLAG_CP);
assert_eq!(prefix.payload_len, 64);
assert!(prefix.has_coords());
assert!(!prefix.is_unencrypted());
}
#[test]
fn test_common_prefix_parse_handshake() {
let data = [0x01, 0x00, 0x50, 0x00]; // ver=0, phase=1, flags=0, payload_len=80
let prefix = FspCommonPrefix::parse(&data).unwrap();
assert_eq!(prefix.version, 0);
assert_eq!(prefix.phase, FSP_PHASE_MSG1);
assert_eq!(prefix.flags, 0);
assert_eq!(prefix.payload_len, 80);
}
#[test]
fn test_common_prefix_parse_error_signal() {
let data = [0x00, FSP_FLAG_U, 0x22, 0x00]; // ver=0, phase=0, U flag, payload_len=34
let prefix = FspCommonPrefix::parse(&data).unwrap();
assert_eq!(prefix.phase, FSP_PHASE_ESTABLISHED);
assert!(prefix.is_unencrypted());
assert_eq!(prefix.payload_len, 34);
}
#[test]
fn test_common_prefix_too_short() {
assert!(FspCommonPrefix::parse(&[0, 0, 0]).is_none());
}
// ===== Encrypted Header Tests =====
#[test]
fn test_encrypted_header_parse() {
let counter = 42u64;
let flags = FSP_FLAG_CP;
let payload_len = 100u16;
let header = build_fsp_header(counter, flags, payload_len);
// Build a minimal packet: header + 16 bytes of fake ciphertext (tag)
let mut packet = Vec::from(header);
packet.extend_from_slice(&[0xaa; TAG_SIZE]);
let parsed = FspEncryptedHeader::parse(&packet).unwrap();
assert_eq!(parsed.counter, 42);
assert_eq!(parsed.flags, FSP_FLAG_CP);
assert_eq!(parsed.payload_len, 100);
assert!(parsed.has_coords());
assert_eq!(parsed.header_bytes, header);
assert_eq!(parsed.data_offset(), FSP_HEADER_SIZE);
}
#[test]
fn test_encrypted_header_too_short() {
let packet = vec![0x00; FSP_ENCRYPTED_MIN_SIZE - 1];
assert!(FspEncryptedHeader::parse(&packet).is_none());
}
#[test]
fn test_encrypted_header_wrong_phase() {
let mut packet = vec![0x00; FSP_ENCRYPTED_MIN_SIZE];
packet[0] = 0x01; // phase 1 (msg1), not established
assert!(FspEncryptedHeader::parse(&packet).is_none());
}
#[test]
fn test_encrypted_header_wrong_version() {
let mut packet = vec![0x00; FSP_ENCRYPTED_MIN_SIZE];
packet[0] = 0x10; // version 1, phase 0
assert!(FspEncryptedHeader::parse(&packet).is_none());
}
#[test]
fn test_encrypted_header_u_flag_rejected() {
let mut packet = vec![0x00; FSP_ENCRYPTED_MIN_SIZE];
packet[1] = FSP_FLAG_U; // U flag set → not encrypted
assert!(FspEncryptedHeader::parse(&packet).is_none());
}
// ===== Build Header Tests =====
#[test]
fn test_build_fsp_header() {
let header = build_fsp_header(1000, FSP_FLAG_CP, 200);
assert_eq!(header[0], 0x00); // ver=0, phase=0
assert_eq!(header[1], FSP_FLAG_CP);
assert_eq!(u16::from_le_bytes([header[2], header[3]]), 200);
assert_eq!(
u64::from_le_bytes([
header[4], header[5], header[6], header[7],
header[8], header[9], header[10], header[11],
]),
1000
);
}
#[test]
fn test_build_fsp_encrypted() {
let header = build_fsp_header(0, 0, 10);
let ciphertext = vec![0xCC; 26]; // 10 payload + 16 tag
let packet = build_fsp_encrypted(&header, &ciphertext);
assert_eq!(packet.len(), FSP_HEADER_SIZE + 26);
assert_eq!(&packet[..FSP_HEADER_SIZE], &header);
assert_eq!(&packet[FSP_HEADER_SIZE..], &ciphertext[..]);
}
// ===== Handshake Prefix Tests =====
#[test]
fn test_build_fsp_handshake_prefix_msg1() {
let prefix = build_fsp_handshake_prefix(FSP_PHASE_MSG1, 100);
assert_eq!(prefix[0], 0x01); // ver=0, phase=1
assert_eq!(prefix[1], 0x00); // flags zero
assert_eq!(u16::from_le_bytes([prefix[2], prefix[3]]), 100);
let parsed = FspCommonPrefix::parse(&prefix).unwrap();
assert_eq!(parsed.phase, FSP_PHASE_MSG1);
}
#[test]
fn test_build_fsp_handshake_prefix_msg2() {
let prefix = build_fsp_handshake_prefix(FSP_PHASE_MSG2, 50);
assert_eq!(prefix[0], 0x02); // ver=0, phase=2
assert_eq!(prefix[1], 0x00);
assert_eq!(u16::from_le_bytes([prefix[2], prefix[3]]), 50);
}
// ===== 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);
}
}
+20 -13
View File
@@ -5,7 +5,8 @@
//! multi-hop forwarding through live node topologies. //! multi-hop forwarding through live node topologies.
use super::*; 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 crate::tree::TreeCoordinate;
use spanning_tree::{ use spanning_tree::{
cleanup_nodes, process_available_packets, run_tree_test, verify_tree_convergence, 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] #[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 mut node = make_node();
let from = make_node_addr(0xAA); let from = make_node_addr(0xAA);
let src_addr = make_node_addr(0x01); 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 src_coords = TreeCoordinate::from_addrs(vec![src_addr, root_addr]).unwrap();
let dest_coords = TreeCoordinate::from_addrs(vec![dest_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]) // Build FSP encrypted message with CP flag: header(12) + coords + fake_ciphertext
.with_coords(src_coords.clone(), dest_coords.clone()); let header = build_fsp_header(0, FSP_FLAG_CP, 20);
let data_payload = data.encode(); 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 dg = SessionDatagram::new(src_addr, dest_addr, data_payload);
let encoded = dg.encode(); let encoded = dg.encode();
@@ -213,24 +218,26 @@ async fn test_coord_cache_warming_data_packet_with_coords() {
assert!( assert!(
node.coord_cache().get(&src_addr, now_ms).is_some(), node.coord_cache().get(&src_addr, now_ms).is_some(),
"src coords not cached from DataPacket" "src coords not cached from encrypted message"
); );
assert!( assert!(
node.coord_cache().get(&dest_addr, now_ms).is_some(), 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] #[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 mut node = make_node();
let from = make_node_addr(0xAA); let from = make_node_addr(0xAA);
let src_addr = make_node_addr(0x01); let src_addr = make_node_addr(0x01);
let dest_addr = make_node_addr(0x02); let dest_addr = make_node_addr(0x02);
// DataPacket without COORDS_PRESENT — no coords to cache // Build FSP encrypted message without CP flag: header(12) + fake_ciphertext
let data = DataPacket::new(0, vec![1, 2, 3, 4]); let header = build_fsp_header(0, 0, 20);
let data_payload = data.encode(); 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 dg = SessionDatagram::new(src_addr, dest_addr, data_payload);
let encoded = dg.encode(); let encoded = dg.encode();
@@ -244,11 +251,11 @@ async fn test_coord_cache_warming_opaque_data_packet() {
assert!( assert!(
node.coord_cache().get(&src_addr, now_ms).is_none(), 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!( assert!(
node.coord_cache().get(&dest_addr, now_ms).is_none(), 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"
); );
} }
+14 -3
View File
@@ -60,6 +60,7 @@ fn test_session_entry_new_initiating() {
identity_b.pubkey_full(), identity_b.pubkey_full(),
EndToEndState::Initiating(handshake), EndToEndState::Initiating(handshake),
1000, 1000,
true,
); );
assert!(entry.state().is_initiating()); assert!(entry.state().is_initiating());
@@ -86,6 +87,7 @@ fn test_session_entry_touch() {
identity_b.pubkey_full(), identity_b.pubkey_full(),
EndToEndState::Initiating(handshake), EndToEndState::Initiating(handshake),
1000, 1000,
true,
); );
entry.touch(2000); entry.touch(2000);
@@ -111,6 +113,7 @@ fn test_session_table_operations() {
identity_b.pubkey_full(), identity_b.pubkey_full(),
EndToEndState::Initiating(handshake), EndToEndState::Initiating(handshake),
1000, 1000,
true,
); );
node.sessions.insert(dest_addr, entry); node.sessions.insert(dest_addr, entry);
@@ -223,10 +226,10 @@ async fn test_session_direct_peer_data_transfer() {
.await .await
.expect("send_session_data failed"); .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; tokio::time::sleep(Duration::from_millis(20)).await;
let count = process_available_packets(&mut nodes).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) // Node 1's session should now be Established (was Responding, transitions on first data)
assert!(nodes[1] assert!(nodes[1]
@@ -958,7 +961,7 @@ async fn test_tun_outbound_established_session() {
nodes[0].node.handle_tun_outbound(ipv6_packet.clone()).await; 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; tokio::time::sleep(Duration::from_millis(20)).await;
process_available_packets(&mut nodes).await; process_available_packets(&mut nodes).await;
@@ -1176,6 +1179,7 @@ fn test_purge_idle_sessions_removes_expired() {
remote.pubkey_full(), remote.pubkey_full(),
EndToEndState::Established(session), EndToEndState::Established(session),
1000, // created at t=1000ms 1000, // created at t=1000ms
true,
); );
node.sessions.insert(remote_addr, entry); node.sessions.insert(remote_addr, entry);
@@ -1201,6 +1205,7 @@ fn test_purge_idle_sessions_keeps_active() {
remote.pubkey_full(), remote.pubkey_full(),
EndToEndState::Established(session), EndToEndState::Established(session),
1000, 1000,
true,
); );
// Touch at t=80s — recent activity // Touch at t=80s — recent activity
@@ -1232,6 +1237,7 @@ fn test_purge_idle_sessions_ignores_initiating() {
remote.pubkey_full(), remote.pubkey_full(),
EndToEndState::Initiating(handshake), EndToEndState::Initiating(handshake),
1000, 1000,
true,
); );
node.sessions.insert(remote_addr, entry); node.sessions.insert(remote_addr, entry);
@@ -1255,6 +1261,7 @@ fn test_purge_idle_sessions_cleans_pending_packets() {
remote.pubkey_full(), remote.pubkey_full(),
EndToEndState::Established(session), EndToEndState::Established(session),
1000, 1000,
true,
); );
node.sessions.insert(remote_addr, entry); node.sessions.insert(remote_addr, entry);
@@ -1288,6 +1295,7 @@ fn test_purge_idle_sessions_disabled_when_zero() {
remote.pubkey_full(), remote.pubkey_full(),
EndToEndState::Established(session), EndToEndState::Established(session),
1000, 1000,
true,
); );
node.sessions.insert(remote_addr, entry); node.sessions.insert(remote_addr, entry);
@@ -1320,6 +1328,7 @@ fn test_coords_warmup_counter_default_zero_on_new() {
identity_b.pubkey_full(), identity_b.pubkey_full(),
EndToEndState::Initiating(handshake), EndToEndState::Initiating(handshake),
1000, 1000,
true,
); );
assert_eq!(entry.coords_warmup_remaining(), 0, assert_eq!(entry.coords_warmup_remaining(), 0,
@@ -1338,6 +1347,7 @@ fn test_coords_warmup_counter_set_and_get() {
remote.pubkey_full(), remote.pubkey_full(),
EndToEndState::Established(session), EndToEndState::Established(session),
1000, 1000,
true,
); );
assert_eq!(entry.coords_warmup_remaining(), 0); assert_eq!(entry.coords_warmup_remaining(), 0);
@@ -1361,6 +1371,7 @@ fn test_coords_warmup_counter_decrement() {
remote.pubkey_full(), remote.pubkey_full(),
EndToEndState::Established(session), EndToEndState::Established(session),
1000, 1000,
true,
); );
entry.set_coords_warmup_remaining(3); entry.set_coords_warmup_remaining(3);
+4 -4
View File
@@ -272,9 +272,9 @@ impl Disconnect {
/// | 20 | dest_addr | 16 bytes | Destination node_addr | /// | 20 | dest_addr | 16 bytes | Destination node_addr |
/// | 36 | payload | variable | Session-layer message | /// | 36 | payload | variable | Session-layer message |
/// ///
/// The payload is either end-to-end encrypted (SessionSetup, SessionAck, /// The payload is either end-to-end encrypted (handshake messages, data,
/// DataPacket) or plaintext link-layer error signals (CoordsRequired, /// reports) or plaintext error signals (CoordsRequired, PathBroken)
/// PathBroken) generated by transit routers. /// generated by transit routers.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct SessionDatagram { pub struct SessionDatagram {
/// Source node address (originator of this datagram). /// Source node address (originator of this datagram).
@@ -526,7 +526,7 @@ mod tests {
fn test_session_datagram_encode_decode() { fn test_session_datagram_encode_decode() {
let src = make_node_addr(0xAA); let src = make_node_addr(0xAA);
let dest = make_node_addr(0xBB); 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()) let dg = SessionDatagram::new(src, dest, payload.clone())
.with_ttl(32); .with_ttl(32);
+5 -3
View File
@@ -37,10 +37,12 @@ pub use tree::TreeAnnounce;
pub use filter::FilterAnnounce; pub use filter::FilterAnnounce;
pub use discovery::{LookupRequest, LookupResponse}; pub use discovery::{LookupRequest, LookupResponse};
pub use session::{ pub use session::{
CoordsRequired, DataFlags, DataPacket, PathBroken, SessionAck, SessionFlags, CoordsRequired, FspFlags, FspInnerFlags, PathBroken, PathMtuNotification, SessionAck,
SessionMessageType, SessionSetup, COORDS_REQUIRED_SIZE, DATA_FLAG_COORDS_PRESENT, SessionFlags, SessionMessageType, SessionReceiverReport, SessionSenderReport, SessionSetup,
DATA_HEADER_SIZE, 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. /// Protocol version for message compatibility.
pub const PROTOCOL_VERSION: u8 = 1; pub const PROTOCOL_VERSION: u8 = 1;
+617 -358
View File
File diff suppressed because it is too large Load Diff
+13 -5
View File
@@ -62,13 +62,21 @@ const MAX_ORIGINAL_PACKET: usize = MIN_IPV6_MTU - IPV6_HEADER_LEN - ICMPV6_HEADE
/// Total FIPS encapsulation overhead (worst case). /// Total FIPS encapsulation overhead (worst case).
/// ///
/// Breakdown: /// Breakdown:
/// - Noise encryption tag: 16 bytes /// - Noise encryption tag: 16 bytes (FLP link-layer AEAD)
/// - Established frame header: 16 bytes (common prefix + receiver_idx + counter) /// - FLP established frame header: 16 bytes (common prefix + receiver_idx + counter)
/// - Inner header: 5 bytes (4-byte timestamp + 1-byte msg_type) /// - FLP inner header: 5 bytes (4-byte timestamp + 1-byte msg_type)
/// - SessionDatagram fields: 35 bytes (ttl + path_mtu + src_addr + dest_addr) /// - 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) /// - 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. /// Calculate the effective IPv6 MTU for FIPS-encapsulated traffic.
/// ///