mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
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:
@@ -148,7 +148,7 @@ Controls end-to-end session behavior and packet queuing.
|
||||
| `node.session.pending_packets_per_dest` | usize | `16` | Queue depth per destination during session establishment |
|
||||
| `node.session.pending_max_destinations` | usize | `256` | Max destinations with pending packets |
|
||||
| `node.session.idle_timeout_secs` | u64 | `90` | Idle session timeout; established sessions with no activity for this duration are removed |
|
||||
| `node.session.coords_warmup_packets` | u8 | `5` | Number of initial DataPackets per session that include COORDS_PRESENT for transit cache warmup; also the reset count on CoordsRequired receipt |
|
||||
| `node.session.coords_warmup_packets` | u8 | `5` | Number of initial data packets per session that include the CP flag for transit cache warmup; also the reset count on CoordsRequired receipt |
|
||||
|
||||
The anti-replay window size (2048 packets) is a compile-time constant and not
|
||||
configurable.
|
||||
|
||||
@@ -121,13 +121,14 @@ after all layers of wrapping.
|
||||
| ----- | -------- | ------- |
|
||||
| Link encryption | 37 bytes | 16-byte outer header + 5-byte inner header + 16-byte AEAD tag |
|
||||
| SessionDatagram envelope | 36 bytes | type + ttl + path_mtu + src_addr + dest_addr |
|
||||
| DataPacket header | 12 bytes | type + flags + counter + payload_len |
|
||||
| Session encryption | 16 bytes | ChaCha20-Poly1305 AEAD tag |
|
||||
| **Minimal total** | **101 bytes** | |
|
||||
| FSP header | 12 bytes | 4-byte prefix + 8-byte counter (used as AEAD AAD) |
|
||||
| FSP inner header | 6 bytes | 4-byte timestamp + 1-byte msg_type + 1-byte inner_flags (inside AEAD) |
|
||||
| Session AEAD tag | 16 bytes | ChaCha20-Poly1305 tag on session-encrypted payload |
|
||||
| **Minimal total** | **107 bytes** | |
|
||||
| Coordinates (if present) | ~43 bytes | Depth-dependent, first few packets only |
|
||||
| **Worst case total** | **144 bytes** | With COORDS_PRESENT for depth-3 paths |
|
||||
| **Worst case total** | **150 bytes** | With CP flag set for depth-3 paths |
|
||||
|
||||
The `FIPS_OVERHEAD` constant (144 bytes) is used for conservative MTU
|
||||
The `FIPS_OVERHEAD` constant (150 bytes) is used for conservative MTU
|
||||
calculations.
|
||||
|
||||
### Effective IPv6 MTU
|
||||
@@ -142,14 +143,14 @@ For typical deployments:
|
||||
|
||||
| Transport MTU | Effective IPv6 MTU | Notes |
|
||||
| ------------- | ------------------ | ----- |
|
||||
| 1472 (UDP/Ethernet) | 1328 | Standard deployment |
|
||||
| 1280 (UDP minimum) | 1136 | Below IPv6 minimum |
|
||||
| 1472 (UDP/Ethernet) | 1322 | Standard deployment |
|
||||
| 1280 (UDP minimum) | 1130 | Below IPv6 minimum |
|
||||
|
||||
IPv6 mandates that every link support at least 1280 bytes. The minimum
|
||||
transport path MTU for the IPv6 adapter is therefore:
|
||||
|
||||
```text
|
||||
1280 + 144 = 1424 bytes
|
||||
1280 + 150 = 1430 bytes
|
||||
```
|
||||
|
||||
Transports with smaller MTUs (LoRa at ~250 bytes, serial at 256 bytes) cannot
|
||||
@@ -315,13 +316,13 @@ decryption (opens attack surface).
|
||||
|
||||
Endpoint-only fragmentation (fragment before session encryption, reassemble
|
||||
after decryption) is a future direction that avoids these objections. Each
|
||||
fragment would be independently encrypted and look like a normal DataPacket
|
||||
fragment would be independently encrypted and look like a normal data packet
|
||||
to transit nodes.
|
||||
|
||||
## References
|
||||
|
||||
- [fips-intro.md](fips-intro.md) — Protocol overview and architecture
|
||||
- [fips-session-layer.md](fips-session-layer.md) — FSP (below the adapter)
|
||||
- [fips-wire-formats.md](fips-wire-formats.md) — DataPacket and SessionDatagram
|
||||
- [fips-wire-formats.md](fips-wire-formats.md) — FSP and SessionDatagram wire
|
||||
formats
|
||||
- [fips-configuration.md](fips-configuration.md) — TUN configuration parameters
|
||||
|
||||
@@ -212,8 +212,7 @@ be made.
|
||||
### Unified Cache
|
||||
|
||||
The coordinate cache is a single unified cache. All sources — SessionSetup
|
||||
transit, COORDS_PRESENT DataPackets, LookupResponse — write to the same
|
||||
cache.
|
||||
transit, CP-flagged data packets, LookupResponse — write to the same cache.
|
||||
|
||||
### Population Sources
|
||||
|
||||
@@ -221,7 +220,7 @@ cache.
|
||||
| ------ | ---- | ---- |
|
||||
| SessionSetup transit | Session establishment | Both src and dest coordinates |
|
||||
| SessionAck transit | Session establishment | Responder's coordinates |
|
||||
| DataPacket with COORDS_PRESENT | Warmup or recovery | Both src and dest coordinates |
|
||||
| CP-flagged data packet | Warmup or recovery | Both src and dest coordinates (cleartext) |
|
||||
| LookupResponse | Discovery | Target's coordinates |
|
||||
|
||||
### Eviction
|
||||
@@ -312,7 +311,7 @@ Destination Unreachable.
|
||||
## SessionSetup Self-Bootstrapping
|
||||
|
||||
SessionSetup is the mechanism that warms transit node coordinate caches
|
||||
along a path, enabling subsequent DataPackets to route efficiently.
|
||||
along a path, enabling subsequent data packets to route efficiently.
|
||||
|
||||
### How It Works
|
||||
|
||||
@@ -335,18 +334,19 @@ coordinates and warming caches in the other direction.
|
||||
### Result
|
||||
|
||||
After the handshake completes, the entire forward and reverse paths have
|
||||
cached coordinates for both endpoints. Subsequent DataPackets use minimal
|
||||
cached coordinates for both endpoints. Subsequent data packets use minimal
|
||||
headers (no coordinates) and route efficiently through the warmed caches.
|
||||
|
||||
## COORDS_PRESENT Warmup
|
||||
## CP (Coords Present) Warmup
|
||||
|
||||
The COORDS_PRESENT flag on DataPackets provides a secondary cache-warming
|
||||
The CP flag in the FSP common prefix provides a secondary cache-warming
|
||||
mechanism that complements SessionSetup. See
|
||||
[fips-session-layer.md](fips-session-layer.md) for the warmup-then-reactive
|
||||
strategy.
|
||||
|
||||
Transit nodes process DataPackets with COORDS_PRESENT by extracting and
|
||||
caching both source and destination coordinates — the same operation
|
||||
Transit nodes parse the CP flag from the FSP header and extract source and
|
||||
destination coordinates from the cleartext section between the header and
|
||||
ciphertext — no decryption needed. This is the same caching operation
|
||||
performed for SessionSetup coordinates.
|
||||
|
||||
## Error Recovery
|
||||
@@ -367,8 +367,7 @@ coordinates for the destination. It cannot make a forwarding decision.
|
||||
|
||||
**Source recovery**:
|
||||
1. Initiate discovery (LookupRequest flood) for the destination
|
||||
2. Reset COORDS_PRESENT warmup counter — subsequent DataPackets include
|
||||
coordinates
|
||||
2. Reset CP warmup counter — subsequent data packets include coordinates
|
||||
3. When discovery completes, warmup counter resets again (covers timing gap)
|
||||
|
||||
The crypto session remains active throughout — only routing state is
|
||||
@@ -386,7 +385,7 @@ source.
|
||||
**Source recovery**:
|
||||
1. Remove stale coordinates from cache
|
||||
2. Initiate discovery for the destination
|
||||
3. Reset COORDS_PRESENT warmup counter
|
||||
3. Reset CP warmup counter
|
||||
|
||||
### Error Signal Rate Limiting
|
||||
|
||||
@@ -397,11 +396,11 @@ packets to the same destination hit the same routing failure simultaneously.
|
||||
### Error Routing Limitation
|
||||
|
||||
Error signals route back to the source using `find_next_hop(src_addr)`. For
|
||||
steady-state DataPackets (after the COORDS_PRESENT warmup window), the
|
||||
steady-state data packets (after the CP warmup window), the
|
||||
transit node may lack cached coordinates for the source. If so, the error is
|
||||
silently dropped.
|
||||
|
||||
This blind spot is partially addressed by COORDS_PRESENT warmup: transit
|
||||
This blind spot is partially addressed by CP warmup: transit
|
||||
nodes receive source coordinates during the warmup phase. But after warmup
|
||||
expires and transit caches for the source expire, errors may be lost. The
|
||||
session idle timeout (90s) limits the window — if traffic stops long enough
|
||||
@@ -423,8 +422,8 @@ sequence:
|
||||
returns the destination's coordinates
|
||||
4. **Session establishment**: SessionSetup carries coordinates, warming
|
||||
transit caches along the path
|
||||
5. **Warmup**: First N DataPackets include COORDS_PRESENT, reinforcing
|
||||
transit caches
|
||||
5. **Warmup**: First N data packets include CP flag, reinforcing transit
|
||||
caches
|
||||
|
||||
The first packet to a new destination always triggers this sequence. The
|
||||
packet is queued (bounded) until the session is established.
|
||||
@@ -435,7 +434,7 @@ After session establishment and warmup:
|
||||
|
||||
- Transit nodes have cached coordinates for both endpoints
|
||||
- Bloom filters have converged for the destination
|
||||
- DataPackets use minimal headers (no coordinates)
|
||||
- Data packets use minimal headers (no coordinates)
|
||||
- Routing decisions are fast: bloom candidate selection + distance ranking
|
||||
|
||||
### Steady State
|
||||
@@ -445,7 +444,7 @@ In steady state, the mesh is mostly self-maintaining:
|
||||
- TreeAnnounce gossip keeps the spanning tree current
|
||||
- FilterAnnounce gossip keeps bloom filters current
|
||||
- Coordinate caches are refreshed by active routing traffic
|
||||
- Occasional cache misses trigger COORDS_PRESENT or discovery, but these
|
||||
- Occasional cache misses trigger CP warmup or discovery, but these
|
||||
are rare when traffic is flowing
|
||||
|
||||
### Cache Expiry and Recovery
|
||||
@@ -511,8 +510,8 @@ routing decisions but retains its own end-to-end encryption and identity.
|
||||
| LookupResponse | ~400 bytes | Response to discovery | Yes (greedy routed) |
|
||||
| SessionDatagram + SessionSetup | ~232–402 bytes | Session establishment | Yes (routed) |
|
||||
| SessionDatagram + SessionAck | ~122 bytes | Session confirmation | Yes (routed) |
|
||||
| SessionDatagram + DataPacket (minimal) | 40 bytes + payload | Bulk traffic | Yes (routed) |
|
||||
| SessionDatagram + DataPacket (with coords) | ~172 bytes + payload | Warmup/recovery | Yes (routed) |
|
||||
| SessionDatagram + Data (minimal) | ~107 bytes + payload | Bulk traffic | Yes (routed) |
|
||||
| SessionDatagram + Data (with CP) | ~150 bytes + payload | Warmup/recovery | Yes (routed) |
|
||||
| SessionDatagram + CoordsRequired | 70 bytes | Cache miss error | Yes (routed) |
|
||||
| SessionDatagram + PathBroken | 70+ bytes | Dead-end error | Yes (routed) |
|
||||
| Disconnect | 2 bytes | Link teardown | No (peer-to-peer) |
|
||||
@@ -550,7 +549,7 @@ recovery).
|
||||
| Flush coord cache on parent change | **Implemented** |
|
||||
| LookupRequest/LookupResponse discovery | **Implemented** |
|
||||
| SessionSetup self-bootstrapping | **Implemented** |
|
||||
| COORDS_PRESENT warmup-then-reactive | **Implemented** |
|
||||
| CP warmup-then-reactive | **Implemented** |
|
||||
| CoordsRequired recovery | **Implemented** |
|
||||
| PathBroken recovery | **Implemented** |
|
||||
| Error signal rate limiting | **Implemented** |
|
||||
|
||||
@@ -125,7 +125,7 @@ and transmitted after the session is established.
|
||||
SessionSetup is self-bootstrapping for routing. It carries the source's and
|
||||
destination's tree coordinates in the clear (not inside the Noise payload).
|
||||
As the message transits intermediate nodes, each node caches these coordinates,
|
||||
warming the path for subsequent DataPackets that carry only addresses (no
|
||||
warming the path for subsequent data packets that carry only addresses (no
|
||||
coordinates).
|
||||
|
||||
SessionAck carries the responder's coordinates back along the reverse path,
|
||||
@@ -145,12 +145,14 @@ This ensures exactly one handshake completes.
|
||||
|
||||
### Data Transfer
|
||||
|
||||
Once established, sessions carry DataPacket messages containing encrypted
|
||||
application data. Each DataPacket includes:
|
||||
Once established, sessions carry encrypted data using the FSP pipeline. Each
|
||||
encrypted message includes:
|
||||
|
||||
- An explicit 8-byte counter for replay protection (used as the AEAD nonce)
|
||||
- A flags byte (including COORDS_PRESENT for cache warming)
|
||||
- The encrypted payload
|
||||
- A 12-byte cleartext header (used as AEAD AAD) containing the counter and
|
||||
flags (including the CP flag for coordinate cache warming)
|
||||
- Optional cleartext coordinates when the CP flag is set
|
||||
- An AEAD-encrypted payload containing a 6-byte inner header (session-relative
|
||||
timestamp, message type, inner flags) followed by the application data
|
||||
|
||||
### Session Idle Timeout
|
||||
|
||||
@@ -239,9 +241,9 @@ discarded after the handshake.
|
||||
## Replay Protection
|
||||
|
||||
FSP uses explicit 8-byte counters on the wire for replay protection. Each side
|
||||
maintains a monotonically increasing send counter, transmitted with every
|
||||
DataPacket. The receiver maintains a sliding window (2048-entry bitmap)
|
||||
tracking which counters have been seen.
|
||||
maintains a monotonically increasing send counter, included in the 12-byte
|
||||
cleartext header of every encrypted message. The receiver maintains a sliding
|
||||
window (2048-entry bitmap) tracking which counters have been seen.
|
||||
|
||||
This design is critical for operation over unreliable transports. Under UDP
|
||||
packet loss or reordering, implicit nonce counters (where the receiver
|
||||
@@ -253,34 +255,34 @@ regardless of what packets were lost or reordered.
|
||||
The same `ReplayWindow` and `decrypt_with_replay_check()` implementation is
|
||||
used at both the link and session layers.
|
||||
|
||||
## COORDS_PRESENT Warmup Strategy
|
||||
## CP (Coords Present) Warmup Strategy
|
||||
|
||||
Session establishment (SessionSetup/SessionAck) warms transit node coordinate
|
||||
caches along the path. But coordinate caches have a finite TTL (default 300s),
|
||||
and entries may be evicted under memory pressure. When a transit node's cache
|
||||
entry expires, it cannot forward DataPackets (which carry only addresses, not
|
||||
entry expires, it cannot forward data packets (which carry only addresses, not
|
||||
coordinates) and sends a CoordsRequired error.
|
||||
|
||||
FSP uses a warmup-then-reactive strategy to keep transit caches populated:
|
||||
|
||||
### Warmup Phase
|
||||
|
||||
After session establishment, the first N DataPackets (configurable, default 5)
|
||||
include both source and destination coordinates via the COORDS_PRESENT flag.
|
||||
Transit nodes cache these coordinates as packets pass through, reinforcing the
|
||||
path established by SessionSetup.
|
||||
After session establishment, the first N data packets (configurable, default 5)
|
||||
include both source and destination coordinates via the CP flag in the FSP
|
||||
common prefix. The coordinates appear in cleartext between the 12-byte header
|
||||
and the ciphertext, allowing transit nodes to cache them without decryption.
|
||||
|
||||
### Steady State
|
||||
|
||||
After the warmup count is reached, FSP clears the COORDS_PRESENT flag and
|
||||
sends minimal DataPackets (4-byte header instead of ~136 bytes with
|
||||
coordinates). Transit nodes serve from their coordinate caches.
|
||||
After the warmup count is reached, FSP clears the CP flag and sends minimal
|
||||
data packets (12-byte header + ciphertext). Transit nodes serve from their
|
||||
coordinate caches.
|
||||
|
||||
### Reactive Recovery
|
||||
|
||||
When FSP receives a CoordsRequired signal:
|
||||
|
||||
1. The warmup counter resets — subsequent DataPackets include coordinates again
|
||||
1. The warmup counter resets — subsequent data packets include coordinates again
|
||||
2. A new LookupRequest may be initiated to rediscover the destination's
|
||||
current coordinates
|
||||
3. When the LookupResponse arrives for an established session, the warmup
|
||||
@@ -343,7 +345,7 @@ either fall back to bloom-filter-only routing or signal CoordsRequired.
|
||||
|
||||
The coordinate cache is a single unified cache (merged from previously
|
||||
separate coord_cache and route_cache). All coordinate sources — SessionSetup
|
||||
transit, COORDS_PRESENT DataPackets, LookupResponse — write to the same cache.
|
||||
transit, CP-flagged data packets, LookupResponse — write to the same cache.
|
||||
|
||||
### Eviction Policy
|
||||
|
||||
@@ -380,7 +382,9 @@ node caches (still within their 300s TTL) are re-warmed.
|
||||
| Session establishment (Noise IK) | **Implemented** |
|
||||
| End-to-end encryption (ChaCha20-Poly1305) | **Implemented** |
|
||||
| Explicit counter replay protection | **Implemented** |
|
||||
| COORDS_PRESENT warmup-then-reactive | **Implemented** |
|
||||
| CP warmup-then-reactive | **Implemented** |
|
||||
| FSP wire format (prefix, AAD, inner header) | **Implemented** |
|
||||
| Session-layer MMP report types | **Implemented** (wire format) |
|
||||
| Identity cache (LRU-only) | **Implemented** |
|
||||
| Coordinate cache (unified, TTL + refresh) | **Implemented** |
|
||||
| Session idle timeout | **Implemented** |
|
||||
|
||||
@@ -396,103 +396,166 @@ Orderly link teardown with reason code.
|
||||
| 0x07 | Timeout | Keepalive or stale detection timeout |
|
||||
| 0xFF | Other | Unspecified reason |
|
||||
|
||||
## Session-Layer Message Types
|
||||
## Session-Layer Message Formats
|
||||
|
||||
These messages are carried as the payload of a SessionDatagram (0x00).
|
||||
Session-layer messages are carried as the payload of a SessionDatagram (0x00).
|
||||
All FSP messages begin with a **4-byte common prefix** that identifies the
|
||||
protocol version, session lifecycle phase, per-packet flags, and payload length.
|
||||
|
||||
### SessionSetup (0x00)
|
||||
### FSP Common Prefix (4 bytes)
|
||||
|
||||
| Field | Size | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| version | 4 bits (high) | Protocol version. Currently 0x0 |
|
||||
| phase | 4 bits (low) | Session lifecycle phase (see table) |
|
||||
| flags | 1 byte | Per-packet signal flags (zero during handshake) |
|
||||
| payload_len | 2 bytes LE | Length of payload after phase-specific header |
|
||||
|
||||
### FSP Phase Table
|
||||
|
||||
| Phase | Type | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| 0x0 | Established | Post-handshake encrypted traffic or plaintext error signals |
|
||||
| 0x1 | Handshake msg1 | SessionSetup (Noise IK msg1) |
|
||||
| 0x2 | Handshake msg2 | SessionAck (Noise IK msg2) |
|
||||
|
||||
### FSP Flags (Established Phase Only)
|
||||
|
||||
| Bit | Name | Description |
|
||||
| --- | ---- | ----------- |
|
||||
| 0 | CP (coords present) | Source and destination coordinates follow the header in cleartext |
|
||||
| 1 | K (key epoch) | Selects active key during rekeying |
|
||||
| 2 | U (unencrypted) | Payload is plaintext (error signals) |
|
||||
| 3-7 | — | Reserved (must be zero) |
|
||||
|
||||
Flags must be zero in handshake packets (phase 0x1 and 0x2).
|
||||
|
||||
### FSP Encrypted Message (phase 0x0, U flag clear)
|
||||
|
||||
Post-handshake encrypted data. The 12-byte cleartext header is used as AEAD
|
||||
AAD. Coordinates may appear in cleartext between the header and ciphertext
|
||||
when the CP flag is set.
|
||||
|
||||
**Cleartext header** (12 bytes, used as AEAD AAD):
|
||||
|
||||
| Field | Size | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| common prefix | 4 bytes | ver=0, phase=0, flags, payload_len |
|
||||
| counter | 8 bytes LE | Monotonic nonce, used as AEAD nonce and for replay detection |
|
||||
|
||||
**Optional cleartext coordinates** (when CP flag is set):
|
||||
|
||||
| Field | Size | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| src_coords_count | 2 bytes LE | Number of source coordinate entries |
|
||||
| src_coords | 16 x n bytes | Source's ancestry (NodeAddr, self -> root) |
|
||||
| dest_coords_count | 2 bytes LE | Number of dest coordinate entries |
|
||||
| dest_coords | 16 x m bytes | Destination's ancestry |
|
||||
|
||||
Transit nodes parse the CP flag and extract coordinates without decryption.
|
||||
|
||||
**Encrypted inner header** (6 bytes, first bytes of AEAD plaintext):
|
||||
|
||||
| Field | Size | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| timestamp | 4 bytes LE | Session-relative milliseconds (u32) |
|
||||
| msg_type | 1 byte | Session-layer message type |
|
||||
| inner_flags | 1 byte | Bit 0: SP (spin bit for RTT measurement) |
|
||||
|
||||
After the inner header, the remaining plaintext is the message-type-specific
|
||||
body.
|
||||
|
||||
**Complete encrypted message**:
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────┬─────────────────┬───────────────────────────┐
|
||||
│ header (12 bytes, used as AAD) │ [coords if CP] │ ciphertext + AEAD tag │
|
||||
│ │ │ (inner_hdr + body) + 16 │
|
||||
└─────────────────────────────────┴─────────────────┴───────────────────────────┘
|
||||
```
|
||||
|
||||
### FSP Session Message Types
|
||||
|
||||
| Type | Message | Description |
|
||||
| ---- | ------- | ----------- |
|
||||
| 0x10 | Data | Application data (IPv6 payload via TUN) |
|
||||
| 0x11 | SenderReport | MMP sender-side metrics report |
|
||||
| 0x12 | ReceiverReport | MMP receiver-side metrics report |
|
||||
| 0x13 | PathMtuNotification | End-to-end path MTU echo |
|
||||
| 0x20 | CoordsRequired | Error: transit node lacks destination coordinates |
|
||||
| 0x21 | PathBroken | Error: greedy routing reached dead end |
|
||||
|
||||
Message types 0x10-0x13 are carried inside the AEAD ciphertext (dispatched
|
||||
by the `msg_type` field in the encrypted inner header). Types 0x20-0x21 are
|
||||
plaintext error signals (U flag set, no encryption).
|
||||
|
||||
### SessionSetup (phase 0x1)
|
||||
|
||||
Establishes a session and warms transit coordinate caches.
|
||||
Encoded with FSP prefix: ver=0, phase=0x1, flags=0, payload_len.
|
||||
|
||||
**Body** (after 4-byte FSP prefix):
|
||||
|
||||
| Offset | Field | Size | Description |
|
||||
| ------ | ----- | ---- | ----------- |
|
||||
| 0 | msg_type | 1 byte | 0x00 |
|
||||
| 1 | flags | 1 byte | Bit 0: REQUEST_ACK, Bit 1: BIDIRECTIONAL |
|
||||
| 2 | src_coords_count | 2 bytes LE | Number of source coordinate entries |
|
||||
| 4 | src_coords | 16 x n bytes | Source's ancestry (NodeAddr, self -> root) |
|
||||
| 0 | flags | 1 byte | Bit 0: REQUEST_ACK, Bit 1: BIDIRECTIONAL |
|
||||
| 1 | src_coords_count | 2 bytes LE | Number of source coordinate entries |
|
||||
| 3 | src_coords | 16 x n bytes | Source's ancestry (NodeAddr, self -> root) |
|
||||
| ... | dest_coords_count | 2 bytes LE | Number of dest coordinate entries |
|
||||
| ... | dest_coords | 16 x m bytes | Destination's ancestry |
|
||||
| ... | handshake_len | 2 bytes LE | Noise payload length |
|
||||
| ... | handshake_payload | variable | Noise IK msg1 (82 bytes typical) |
|
||||
|
||||
**Example** (depth-3 source, depth-4 destination):
|
||||
|
||||
```text
|
||||
SessionDatagram header: 36 bytes
|
||||
SessionSetup payload: 1 + 1 + 2 + 48 + 2 + 64 + 2 + 82 = 202 bytes
|
||||
Total: 238 bytes
|
||||
```
|
||||
|
||||
### SessionAck (0x01)
|
||||
### SessionAck (phase 0x2)
|
||||
|
||||
Confirms session establishment, completes the Noise handshake.
|
||||
Encoded with FSP prefix: ver=0, phase=0x2, flags=0, payload_len.
|
||||
|
||||
**Body** (after 4-byte FSP prefix):
|
||||
|
||||
| Offset | Field | Size | Description |
|
||||
| ------ | ----- | ---- | ----------- |
|
||||
| 0 | msg_type | 1 byte | 0x01 |
|
||||
| 1 | flags | 1 byte | Reserved |
|
||||
| 2 | src_coords_count | 2 bytes LE | Number of coordinate entries |
|
||||
| 4 | src_coords | 16 x n bytes | Acknowledger's ancestry (for cache warming) |
|
||||
| 0 | flags | 1 byte | Reserved |
|
||||
| 1 | src_coords_count | 2 bytes LE | Number of coordinate entries |
|
||||
| 3 | src_coords | 16 x n bytes | Acknowledger's ancestry (for cache warming) |
|
||||
| ... | handshake_len | 2 bytes LE | Noise payload length |
|
||||
| ... | handshake_payload | variable | Noise IK msg2 (33 bytes typical) |
|
||||
|
||||
### DataPacket (0x10)
|
||||
### Data (0x10)
|
||||
|
||||
Encrypted application data with explicit replay protection counter.
|
||||
|
||||
**Minimal header** (COORDS_PRESENT = 0):
|
||||
|
||||
| Offset | Field | Size | Description |
|
||||
| ------ | ----- | ---- | ----------- |
|
||||
| 0 | msg_type | 1 byte | 0x10 |
|
||||
| 1 | flags | 1 byte | Bit 0: COORDS_PRESENT |
|
||||
| 2 | counter | 8 bytes LE | Session encryption counter / replay nonce |
|
||||
| 10 | payload_length | 2 bytes LE | Length of encrypted payload |
|
||||
| 12 | payload | variable | Encrypted application data + 16-byte AEAD tag |
|
||||
|
||||
**Header size**: 12 bytes (`DATA_HEADER_SIZE`)
|
||||
|
||||
**With coordinates** (COORDS_PRESENT = 1):
|
||||
|
||||
| Offset | Field | Size | Description |
|
||||
| ------ | ----- | ---- | ----------- |
|
||||
| 0 | msg_type | 1 byte | 0x10 |
|
||||
| 1 | flags | 1 byte | 0x01 (COORDS_PRESENT) |
|
||||
| 2 | counter | 8 bytes LE | Session encryption counter |
|
||||
| 10 | payload_length | 2 bytes LE | Length of encrypted payload |
|
||||
| 12 | src_coords_count | 2 bytes LE | Source coordinate entries |
|
||||
| 14 | src_coords | 16 x n bytes | Source's ancestry |
|
||||
| ... | dest_coords_count | 2 bytes LE | Dest coordinate entries |
|
||||
| ... | dest_coords | 16 x m bytes | Destination's ancestry |
|
||||
| ... | payload | variable | Encrypted application data |
|
||||
Application data (typically IPv6 payload). This is the `msg_type` byte
|
||||
inside the encrypted inner header — there is no separate DataPacket struct.
|
||||
The body after the inner header is delivered directly to the TUN interface.
|
||||
|
||||
### CoordsRequired (0x20)
|
||||
|
||||
Link-layer error signal — transit node lacks coordinates for destination.
|
||||
Plaintext (not end-to-end encrypted), generated by transit nodes.
|
||||
Plaintext error signal — transit node lacks coordinates for destination.
|
||||
Encoded with FSP prefix: ver=0, phase=0x0, U flag set, payload_len.
|
||||
|
||||
**Body** (after 4-byte FSP prefix + 1-byte msg_type):
|
||||
|
||||
| Offset | Field | Size | Description |
|
||||
| ------ | ----- | ---- | ----------- |
|
||||
| 0 | msg_type | 1 byte | 0x20 |
|
||||
| 1 | flags | 1 byte | Reserved |
|
||||
| 2 | dest_addr | 16 bytes | NodeAddr we couldn't route to |
|
||||
| 18 | reporter | 16 bytes | NodeAddr of reporting router |
|
||||
| 0 | flags | 1 byte | Reserved |
|
||||
| 1 | dest_addr | 16 bytes | NodeAddr we couldn't route to |
|
||||
| 17 | reporter | 16 bytes | NodeAddr of reporting router |
|
||||
|
||||
**Payload**: 34 bytes. Wrapped in SessionDatagram: 70 bytes total.
|
||||
**Body size**: 33 bytes. Total with prefix + msg_type: 38 bytes.
|
||||
|
||||
### PathBroken (0x21)
|
||||
|
||||
Link-layer error signal — greedy routing reached a dead end. Plaintext,
|
||||
generated by transit nodes.
|
||||
Plaintext error signal — greedy routing reached a dead end.
|
||||
Encoded with FSP prefix: ver=0, phase=0x0, U flag set, payload_len.
|
||||
|
||||
**Body** (after 4-byte FSP prefix + 1-byte msg_type):
|
||||
|
||||
| Offset | Field | Size | Description |
|
||||
| ------ | ----- | ---- | ----------- |
|
||||
| 0 | msg_type | 1 byte | 0x21 |
|
||||
| 1 | flags | 1 byte | Reserved |
|
||||
| 2 | dest_addr | 16 bytes | Unreachable NodeAddr |
|
||||
| 18 | reporter | 16 bytes | NodeAddr of reporting router |
|
||||
| 34 | last_coords_count | 2 bytes LE | Number of stale coordinate entries |
|
||||
| 36 | last_known_coords | 16 x n bytes | Stale coordinates that failed |
|
||||
| 0 | flags | 1 byte | Reserved |
|
||||
| 1 | dest_addr | 16 bytes | Unreachable NodeAddr |
|
||||
| 17 | reporter | 16 bytes | NodeAddr of reporting router |
|
||||
| 33 | last_coords_count | 2 bytes LE | Number of stale coordinate entries |
|
||||
| 35 | last_known_coords | 16 x n bytes | Stale coordinates that failed |
|
||||
|
||||
## Encapsulation Walkthrough
|
||||
|
||||
@@ -507,19 +570,19 @@ Layer 4: Application data
|
||||
1024 bytes
|
||||
|
||||
Layer 3: Session encryption (FSP)
|
||||
DataPacket header (12 bytes) + encrypted payload (1024) + AEAD tag (16)
|
||||
= 1052 bytes
|
||||
FSP header (12 bytes) + AEAD(inner_hdr (6) + payload (1024)) + AEAD tag (16)
|
||||
= 1058 bytes
|
||||
|
||||
Layer 2: SessionDatagram envelope (FLP routing)
|
||||
msg_type (1) + ttl (1) + path_mtu (2) + src_addr (16) + dest_addr (16) + payload (1052)
|
||||
= 1088 bytes
|
||||
msg_type (1) + ttl (1) + path_mtu (2) + src_addr (16) + dest_addr (16) + payload (1058)
|
||||
= 1094 bytes
|
||||
|
||||
Layer 1: Link encryption (FLP per-hop)
|
||||
outer header (16) + encrypted(inner_hdr (5) + datagram (1088)) + AEAD tag (16)
|
||||
= 1125 bytes
|
||||
outer header (16) + encrypted(inner_hdr (5) + datagram (1094)) + AEAD tag (16)
|
||||
= 1131 bytes
|
||||
|
||||
Layer 0: Transport
|
||||
UDP datagram containing 1125 bytes
|
||||
UDP datagram containing 1131 bytes
|
||||
```
|
||||
|
||||
### Overhead Budget
|
||||
@@ -528,11 +591,12 @@ Layer 0: Transport
|
||||
| ----- | -------- | --------- |
|
||||
| Link encryption | 37 bytes | 16 outer header (AAD) + 5 inner header + 16 AEAD tag |
|
||||
| SessionDatagram | 36 bytes | 1 type + 1 ttl + 2 path_mtu + 16 src + 16 dest |
|
||||
| DataPacket header | 12 bytes | 1 type + 1 flags + 8 counter + 2 length |
|
||||
| FSP header | 12 bytes | 4 prefix + 8 counter |
|
||||
| FSP inner header | 6 bytes | 4 timestamp + 1 msg_type + 1 inner_flags (inside AEAD) |
|
||||
| Session AEAD tag | 16 bytes | Poly1305 tag on session-encrypted payload |
|
||||
| **Minimal total** | **101 bytes** | |
|
||||
| **Minimal total** | **107 bytes** | |
|
||||
| Coordinates (if present) | ~43 bytes | Varies with tree depth |
|
||||
| **Worst case** | **144 bytes** | `FIPS_OVERHEAD` constant |
|
||||
| **Worst case** | **150 bytes** | `FIPS_OVERHEAD` constant |
|
||||
|
||||
### At Each Transit Node
|
||||
|
||||
@@ -581,20 +645,23 @@ endpoint session keys).
|
||||
| ------- | ------------ | ----- |
|
||||
| SessionSetup | ~200 bytes | Depth-dependent |
|
||||
| SessionAck | ~80 bytes | Depth-dependent |
|
||||
| DataPacket (minimal) | 12 + payload bytes | Steady state |
|
||||
| DataPacket (with coords) | 12 + ~130 + payload bytes | Warmup/recovery |
|
||||
| CoordsRequired | 34 bytes | Fixed |
|
||||
| PathBroken | 36 + 16n bytes | Includes stale coords |
|
||||
| Data (minimal) | 12 + 6 + payload + 16 bytes | Steady state |
|
||||
| Data (with coords) | 12 + ~130 + 6 + payload + 16 bytes | Warmup/recovery |
|
||||
| SenderReport | 12 + 6 + 46 + 16 bytes | MMP metrics |
|
||||
| ReceiverReport | 12 + 6 + 66 + 16 bytes | MMP metrics |
|
||||
| PathMtuNotification | 12 + 6 + 2 + 16 bytes | MTU signal |
|
||||
| CoordsRequired | 38 bytes | Fixed (prefix + msg_type + body) |
|
||||
| PathBroken | 35 + 16n bytes | Includes stale coords |
|
||||
|
||||
### Complete Packet Sizes (link + session)
|
||||
|
||||
| Scenario | Wire Size | Notes |
|
||||
| -------- | --------- | ----- |
|
||||
| Encrypted frame minimum | 37 bytes | Empty body |
|
||||
| SessionDatagram + DataPacket (minimal) | 37 + 36 + 12 + payload + 16 | 101 + payload |
|
||||
| SessionDatagram + DataPacket (with coords) | ~144 + payload | Worst case |
|
||||
| SessionDatagram + Data (minimal) | 37 + 36 + 12 + 6 + payload + 16 | 107 + payload |
|
||||
| SessionDatagram + Data (with coords) | ~150 + payload | Worst case |
|
||||
| SessionDatagram + SessionSetup | ~275 bytes | Depth-3, both dirs |
|
||||
| SessionDatagram + CoordsRequired | 37 + 36 + 34 = 107 bytes | Including link overhead |
|
||||
| SessionDatagram + CoordsRequired | 37 + 36 + 38 = 111 bytes | Including link overhead |
|
||||
|
||||
## References
|
||||
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ use thiserror::Error;
|
||||
|
||||
pub use node::{
|
||||
BloomConfig, BuffersConfig, CacheConfig, DiscoveryConfig, LimitsConfig, NodeConfig,
|
||||
RateLimitConfig, RetryConfig, SessionConfig, TreeConfig,
|
||||
RateLimitConfig, RetryConfig, SessionConfig, SessionMmpConfig, TreeConfig,
|
||||
};
|
||||
pub use peer::{ConnectPolicy, PeerAddress, PeerConfig};
|
||||
pub use transport::{TransportInstances, TransportsConfig, UdpConfig};
|
||||
|
||||
+44
-3
@@ -7,7 +7,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::IdentityConfig;
|
||||
use crate::mmp::MmpConfig;
|
||||
use crate::mmp::{MmpConfig, MmpMode, DEFAULT_LOG_INTERVAL_SECS, DEFAULT_OWD_WINDOW_SIZE};
|
||||
|
||||
// ============================================================================
|
||||
// Node Configuration Subsections
|
||||
@@ -227,7 +227,7 @@ pub struct SessionConfig {
|
||||
/// Established sessions with no activity for this duration are removed.
|
||||
#[serde(default = "SessionConfig::default_idle_timeout_secs")]
|
||||
pub idle_timeout_secs: u64,
|
||||
/// Number of initial DataPackets per session that include COORDS_PRESENT
|
||||
/// Number of initial data packets per session that include COORDS_PRESENT
|
||||
/// for transit cache warmup (`node.session.coords_warmup_packets`).
|
||||
/// Also used as the reset count on CoordsRequired receipt.
|
||||
#[serde(default = "SessionConfig::default_coords_warmup_packets")]
|
||||
@@ -254,6 +254,42 @@ impl SessionConfig {
|
||||
fn default_coords_warmup_packets() -> u8 { 5 }
|
||||
}
|
||||
|
||||
/// Session-layer Metrics Measurement Protocol (`node.session_mmp.*`).
|
||||
///
|
||||
/// Separate from link-layer `node.mmp.*` to allow independent mode/interval
|
||||
/// configuration per layer. Session reports consume bandwidth on every transit
|
||||
/// link, so operators may want a lighter mode (e.g., Lightweight) for sessions
|
||||
/// while running Full mode on links.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionMmpConfig {
|
||||
/// Operating mode (`node.session_mmp.mode`).
|
||||
#[serde(default)]
|
||||
pub mode: MmpMode,
|
||||
|
||||
/// Periodic operator log interval in seconds (`node.session_mmp.log_interval_secs`).
|
||||
#[serde(default = "SessionMmpConfig::default_log_interval_secs")]
|
||||
pub log_interval_secs: u64,
|
||||
|
||||
/// OWD trend ring buffer size (`node.session_mmp.owd_window_size`).
|
||||
#[serde(default = "SessionMmpConfig::default_owd_window_size")]
|
||||
pub owd_window_size: usize,
|
||||
}
|
||||
|
||||
impl Default for SessionMmpConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: MmpMode::default(),
|
||||
log_interval_secs: DEFAULT_LOG_INTERVAL_SECS,
|
||||
owd_window_size: DEFAULT_OWD_WINDOW_SIZE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionMmpConfig {
|
||||
fn default_log_interval_secs() -> u64 { DEFAULT_LOG_INTERVAL_SECS }
|
||||
fn default_owd_window_size() -> usize { DEFAULT_OWD_WINDOW_SIZE }
|
||||
}
|
||||
|
||||
/// Internal buffers (`node.buffers.*`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BuffersConfig {
|
||||
@@ -343,9 +379,13 @@ pub struct NodeConfig {
|
||||
#[serde(default)]
|
||||
pub buffers: BuffersConfig,
|
||||
|
||||
/// Metrics Measurement Protocol (`node.mmp.*`).
|
||||
/// Metrics Measurement Protocol — link layer (`node.mmp.*`).
|
||||
#[serde(default)]
|
||||
pub mmp: MmpConfig,
|
||||
|
||||
/// Metrics Measurement Protocol — session layer (`node.session_mmp.*`).
|
||||
#[serde(default)]
|
||||
pub session_mmp: SessionMmpConfig,
|
||||
}
|
||||
|
||||
impl Default for NodeConfig {
|
||||
@@ -365,6 +405,7 @@ impl Default for NodeConfig {
|
||||
session: SessionConfig::default(),
|
||||
buffers: BuffersConfig::default(),
|
||||
mmp: MmpConfig::default(),
|
||||
session_mmp: SessionMmpConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ pub use transport::udp::UdpTransport;
|
||||
|
||||
// Re-export protocol types
|
||||
pub use protocol::{
|
||||
CoordsRequired, DataFlags, DataPacket, FilterAnnounce, HandshakeMessageType, LinkMessageType,
|
||||
CoordsRequired, FilterAnnounce, HandshakeMessageType, LinkMessageType,
|
||||
LookupRequest, LookupResponse, PathBroken, ProtocolError, SessionAck, SessionDatagram,
|
||||
SessionFlags, SessionMessageType, SessionSetup, TreeAnnounce,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
use crate::mmp::algorithms::{DualEwma, SrttEstimator, compute_etx};
|
||||
use crate::mmp::report::ReceiverReport;
|
||||
use std::time::Instant;
|
||||
use tracing::debug;
|
||||
|
||||
/// Derived MMP metrics, updated from incoming ReceiverReports.
|
||||
///
|
||||
@@ -80,6 +81,14 @@ impl MmpMetrics {
|
||||
if our_timestamp_ms > echo_ms + dwell_ms {
|
||||
let rtt_ms = our_timestamp_ms - echo_ms - dwell_ms;
|
||||
let rtt_us = (rtt_ms as i64) * 1000;
|
||||
debug!(
|
||||
our_ts = our_timestamp_ms,
|
||||
echo = echo_ms,
|
||||
dwell = dwell_ms,
|
||||
rtt_ms = rtt_ms,
|
||||
srtt_ms = self.srtt.srtt_us() as f64 / 1000.0,
|
||||
"RTT sample from timestamp echo"
|
||||
);
|
||||
self.srtt.update(rtt_us);
|
||||
self.rtt_trend.update(rtt_us as f64);
|
||||
}
|
||||
|
||||
+258
-7
@@ -30,6 +30,9 @@ pub use receiver::ReceiverState;
|
||||
pub use report::{ReceiverReport, SenderReport};
|
||||
pub use sender::SenderState;
|
||||
|
||||
// Session-layer re-exports
|
||||
// MmpSessionState and PathMtuState are defined in this file
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
@@ -80,15 +83,29 @@ pub const DEFAULT_OWD_WINDOW_SIZE: usize = 32;
|
||||
/// Default operator log interval in seconds.
|
||||
pub const DEFAULT_LOG_INTERVAL_SECS: u64 = 30;
|
||||
|
||||
// --- Session-layer timing defaults ---
|
||||
// Session reports are routed end-to-end (bandwidth cost on every transit link),
|
||||
// so intervals are higher than link-layer.
|
||||
|
||||
/// Session-layer minimum report interval.
|
||||
pub const MIN_SESSION_REPORT_INTERVAL_MS: u64 = 500;
|
||||
|
||||
/// Session-layer maximum report interval.
|
||||
pub const MAX_SESSION_REPORT_INTERVAL_MS: u64 = 10_000;
|
||||
|
||||
/// Session-layer cold-start report interval (before SRTT is available).
|
||||
pub const SESSION_COLD_START_INTERVAL_MS: u64 = 1_000;
|
||||
|
||||
// ============================================================================
|
||||
// Operating Mode
|
||||
// ============================================================================
|
||||
|
||||
/// MMP operating mode.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MmpMode {
|
||||
/// Sender + receiver reports at RTT-adaptive intervals. Maximum fidelity.
|
||||
#[default]
|
||||
Full,
|
||||
/// Receiver reports only. Loss inferred from counter gaps.
|
||||
Lightweight,
|
||||
@@ -96,12 +113,6 @@ pub enum MmpMode {
|
||||
Minimal,
|
||||
}
|
||||
|
||||
impl Default for MmpMode {
|
||||
fn default() -> Self {
|
||||
MmpMode::Full
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MmpMode {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
@@ -205,6 +216,246 @@ impl MmpPeerState {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Per-Session MMP State (session-layer instantiation)
|
||||
// ============================================================================
|
||||
|
||||
/// Combined MMP state for a single end-to-end session.
|
||||
///
|
||||
/// Wraps sender, receiver, metrics, spin bit, and path MTU state.
|
||||
/// One instance per established `SessionEntry`.
|
||||
pub struct MmpSessionState {
|
||||
pub sender: SenderState,
|
||||
pub receiver: ReceiverState,
|
||||
pub metrics: MmpMetrics,
|
||||
pub spin_bit: SpinBitState,
|
||||
mode: MmpMode,
|
||||
log_interval: Duration,
|
||||
last_log_time: Option<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 {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("MmpPeerState")
|
||||
|
||||
+19
-4
@@ -170,6 +170,14 @@ pub struct ReceiverState {
|
||||
|
||||
impl ReceiverState {
|
||||
pub fn new(owd_window_size: usize) -> Self {
|
||||
Self::new_with_cold_start(owd_window_size, DEFAULT_COLD_START_INTERVAL_MS)
|
||||
}
|
||||
|
||||
/// Create with a custom cold-start interval (ms).
|
||||
///
|
||||
/// Used by session-layer MMP which needs a longer initial interval
|
||||
/// since reports consume bandwidth on every transit link.
|
||||
pub fn new_with_cold_start(owd_window_size: usize, cold_start_ms: u64) -> Self {
|
||||
Self {
|
||||
cumulative_packets_recv: 0,
|
||||
cumulative_bytes_recv: 0,
|
||||
@@ -185,7 +193,7 @@ impl ReceiverState {
|
||||
last_sender_timestamp: 0,
|
||||
last_recv_time: None,
|
||||
last_report_time: None,
|
||||
report_interval: Duration::from_millis(DEFAULT_COLD_START_INTERVAL_MS),
|
||||
report_interval: Duration::from_millis(cold_start_ms),
|
||||
interval_has_data: false,
|
||||
}
|
||||
}
|
||||
@@ -308,15 +316,22 @@ impl ReceiverState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the report interval based on SRTT.
|
||||
/// Update the report interval based on SRTT (link-layer defaults).
|
||||
///
|
||||
/// Receiver reports at 1× SRTT, clamped to [MIN, MAX].
|
||||
pub fn update_report_interval_from_srtt(&mut self, srtt_us: i64) {
|
||||
self.update_report_interval_with_bounds(srtt_us, MIN_REPORT_INTERVAL_MS, MAX_REPORT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/// Update the report interval based on SRTT with custom bounds.
|
||||
///
|
||||
/// Used by session-layer MMP which needs higher clamp values since
|
||||
/// each report consumes bandwidth on every transit link.
|
||||
pub fn update_report_interval_with_bounds(&mut self, srtt_us: i64, min_ms: u64, max_ms: u64) {
|
||||
if srtt_us <= 0 {
|
||||
return;
|
||||
}
|
||||
let interval_ms = ((srtt_us as u64) / 1000)
|
||||
.clamp(MIN_REPORT_INTERVAL_MS, MAX_REPORT_INTERVAL_MS);
|
||||
let interval_ms = ((srtt_us as u64) / 1000).clamp(min_ms, max_ms);
|
||||
self.report_interval = Duration::from_millis(interval_ms);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
+20
-6
@@ -35,6 +35,14 @@ pub struct SenderState {
|
||||
|
||||
impl SenderState {
|
||||
pub fn new() -> Self {
|
||||
Self::new_with_cold_start(DEFAULT_COLD_START_INTERVAL_MS)
|
||||
}
|
||||
|
||||
/// Create with a custom cold-start interval (ms).
|
||||
///
|
||||
/// Used by session-layer MMP which needs a longer initial interval
|
||||
/// since reports consume bandwidth on every transit link.
|
||||
pub fn new_with_cold_start(cold_start_ms: u64) -> Self {
|
||||
Self {
|
||||
cumulative_packets_sent: 0,
|
||||
cumulative_bytes_sent: 0,
|
||||
@@ -45,7 +53,7 @@ impl SenderState {
|
||||
last_timestamp: 0,
|
||||
interval_has_data: false,
|
||||
last_report_time: None,
|
||||
report_interval: Duration::from_millis(DEFAULT_COLD_START_INTERVAL_MS),
|
||||
report_interval: Duration::from_millis(cold_start_ms),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,17 +112,23 @@ impl SenderState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the report interval based on SRTT.
|
||||
/// Update the report interval based on SRTT (link-layer defaults).
|
||||
///
|
||||
/// Sender reports at 2-5× the receiver report interval. For simplicity,
|
||||
/// we use 2× SRTT clamped to [MIN, MAX].
|
||||
/// Sender reports at 2× SRTT clamped to [MIN, MAX].
|
||||
pub fn update_report_interval_from_srtt(&mut self, srtt_us: i64) {
|
||||
self.update_report_interval_with_bounds(srtt_us, MIN_REPORT_INTERVAL_MS, MAX_REPORT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/// Update the report interval based on SRTT with custom bounds.
|
||||
///
|
||||
/// Used by session-layer MMP which needs higher clamp values since
|
||||
/// each report consumes bandwidth on every transit link.
|
||||
pub fn update_report_interval_with_bounds(&mut self, srtt_us: i64, min_ms: u64, max_ms: u64) {
|
||||
if srtt_us <= 0 {
|
||||
return;
|
||||
}
|
||||
let interval_us = (srtt_us * 2) as u64;
|
||||
let interval_ms = (interval_us / 1000)
|
||||
.clamp(MIN_REPORT_INTERVAL_MS, MAX_REPORT_INTERVAL_MS);
|
||||
let interval_ms = (interval_us / 1000).clamp(min_ms, max_ms);
|
||||
self.report_interval = Duration::from_millis(interval_ms);
|
||||
}
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ impl Node {
|
||||
// If an established session exists, reset the warmup counter.
|
||||
// Discovery has completed and transit nodes along the response
|
||||
// path now have fresh coords. Reset warmup so the next N
|
||||
// DataPackets include COORDS_PRESENT to re-warm the forward path.
|
||||
// data packets include COORDS_PRESENT to re-warm the forward path.
|
||||
if let Some(entry) = self.sessions.get_mut(&target)
|
||||
&& entry.is_established()
|
||||
{
|
||||
|
||||
@@ -100,10 +100,12 @@ impl Node {
|
||||
ce_flag,
|
||||
now,
|
||||
);
|
||||
// Spin bit: feed to spin state, get optional RTT sample
|
||||
if let Some(rtt) = mmp.spin_bit.rx_observe(sp_flag, header.counter, now) {
|
||||
mmp.metrics.srtt.update(rtt.as_micros() as i64);
|
||||
}
|
||||
// Spin bit: advance state machine for correct TX reflection.
|
||||
// RTT samples from spin bit are not used for SRTT because
|
||||
// inter-frame timing in the mesh is irregular, inflating
|
||||
// spin-bit RTT by variable processing delays on both sides.
|
||||
// Timestamp-echo in ReceiverReport provides accurate RTT.
|
||||
let _spin_rtt = mmp.spin_bit.rx_observe(sp_flag, header.counter, now);
|
||||
}
|
||||
|
||||
// Update address for roaming support
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
//! locally, and generates error signals on routing failure.
|
||||
|
||||
use crate::node::Node;
|
||||
use crate::node::session_wire::{
|
||||
parse_encrypted_coords, FspCommonPrefix, FSP_COMMON_PREFIX_SIZE, FSP_HEADER_SIZE,
|
||||
FSP_PHASE_ESTABLISHED, FSP_PHASE_MSG1, FSP_PHASE_MSG2,
|
||||
};
|
||||
use crate::protocol::{
|
||||
CoordsRequired, DataPacket, PathBroken, SessionAck, SessionDatagram, SessionMessageType,
|
||||
SessionSetup,
|
||||
CoordsRequired, PathBroken, SessionAck, SessionDatagram, SessionSetup,
|
||||
};
|
||||
use crate::NodeAddr;
|
||||
use tracing::debug;
|
||||
@@ -42,7 +45,7 @@ impl Node {
|
||||
|
||||
// Local delivery: dispatch to session layer handlers
|
||||
if datagram.dest_addr == *self.node_addr() {
|
||||
self.handle_session_payload(&datagram.src_addr, &datagram.payload)
|
||||
self.handle_session_payload(&datagram.src_addr, &datagram.payload, datagram.path_mtu)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
@@ -81,28 +84,29 @@ impl Node {
|
||||
|
||||
/// Attempt to warm the coordinate cache from session-layer payload headers.
|
||||
///
|
||||
/// Transit routers can read the session message type byte and, for
|
||||
/// SessionSetup and SessionAck, extract plaintext coordinate fields.
|
||||
/// DataPacket with COORDS_PRESENT has plaintext coords before the
|
||||
/// encrypted payload. Other types are ignored.
|
||||
/// Transit routers parse the 4-byte FSP common prefix to identify message
|
||||
/// type, then extract plaintext coordinate fields from:
|
||||
/// - SessionSetup (phase 0x1): src_coords + dest_coords
|
||||
/// - SessionAck (phase 0x2): src_coords
|
||||
/// - Encrypted with CP flag (phase 0x0): cleartext coords between header and ciphertext
|
||||
///
|
||||
/// Decode failures are logged and silently ignored — they don't block
|
||||
/// forwarding.
|
||||
fn try_warm_coord_cache(&mut self, datagram: &SessionDatagram) {
|
||||
if datagram.payload.is_empty() {
|
||||
return;
|
||||
}
|
||||
let prefix = match FspCommonPrefix::parse(&datagram.payload) {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let msg_type = datagram.payload[0];
|
||||
let inner = &datagram.payload[1..];
|
||||
let inner = &datagram.payload[FSP_COMMON_PREFIX_SIZE..];
|
||||
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
|
||||
match SessionMessageType::from_byte(msg_type) {
|
||||
Some(SessionMessageType::SessionSetup) => {
|
||||
match prefix.phase {
|
||||
FSP_PHASE_MSG1 => {
|
||||
match SessionSetup::decode(inner) {
|
||||
Ok(setup) => {
|
||||
self.coord_cache_mut().insert(
|
||||
@@ -126,7 +130,7 @@ impl Node {
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(SessionMessageType::SessionAck) => {
|
||||
FSP_PHASE_MSG2 => {
|
||||
match SessionAck::decode(inner) {
|
||||
Ok(ack) => {
|
||||
self.coord_cache_mut().insert(
|
||||
@@ -144,38 +148,41 @@ impl Node {
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(SessionMessageType::DataPacket) => {
|
||||
match DataPacket::decode(inner) {
|
||||
Ok(data) => {
|
||||
if data.flags.coords_present {
|
||||
if let Some(src_coords) = data.src_coords {
|
||||
self.coord_cache_mut().insert(
|
||||
datagram.src_addr,
|
||||
src_coords,
|
||||
now_ms,
|
||||
);
|
||||
}
|
||||
if let Some(dest_coords) = data.dest_coords {
|
||||
self.coord_cache_mut().insert(
|
||||
datagram.dest_addr,
|
||||
dest_coords,
|
||||
now_ms,
|
||||
);
|
||||
}
|
||||
debug!(
|
||||
src = %datagram.src_addr,
|
||||
dest = %datagram.dest_addr,
|
||||
"Cached coords from DataPacket"
|
||||
FSP_PHASE_ESTABLISHED if prefix.has_coords() => {
|
||||
// CP flag set: coords in cleartext between header and ciphertext.
|
||||
// Parse coords from the cleartext section after the 12-byte header.
|
||||
// inner starts after the 4-byte prefix, so we need 8 more bytes
|
||||
// for the counter (header is 12 total = 4 prefix + 8 counter).
|
||||
let coord_data = &datagram.payload[FSP_HEADER_SIZE..];
|
||||
match parse_encrypted_coords(coord_data) {
|
||||
Ok((src_coords, dest_coords, _bytes_consumed)) => {
|
||||
if let Some(coords) = src_coords {
|
||||
self.coord_cache_mut().insert(
|
||||
datagram.src_addr,
|
||||
coords,
|
||||
now_ms,
|
||||
);
|
||||
}
|
||||
if let Some(coords) = dest_coords {
|
||||
self.coord_cache_mut().insert(
|
||||
datagram.dest_addr,
|
||||
coords,
|
||||
now_ms,
|
||||
);
|
||||
}
|
||||
debug!(
|
||||
src = %datagram.src_addr,
|
||||
dest = %datagram.dest_addr,
|
||||
"Cached coords from encrypted message"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(error = %e, "Failed to decode DataPacket for cache warming");
|
||||
debug!(error = %e, "Failed to parse coords for cache warming");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// CoordsRequired, PathBroken, unknown: no coords to cache
|
||||
// Phase 0x0 without CP, error signals, unknown: no coords to cache
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+163
-8
@@ -5,8 +5,12 @@
|
||||
//! and teardown metric logs.
|
||||
|
||||
use crate::mmp::MmpMode;
|
||||
use crate::mmp::MmpSessionState;
|
||||
use crate::mmp::report::{ReceiverReport, SenderReport};
|
||||
use crate::node::Node;
|
||||
use crate::protocol::{
|
||||
PathMtuNotification, SessionMessageType, SessionReceiverReport, SessionSenderReport,
|
||||
};
|
||||
use crate::NodeAddr;
|
||||
use std::time::Instant;
|
||||
use tracing::{debug, info, warn};
|
||||
@@ -139,17 +143,19 @@ impl Node {
|
||||
let mode = mmp.mode();
|
||||
|
||||
// Sender reports: Full mode only
|
||||
if mode == MmpMode::Full && mmp.sender.should_send_report(now) {
|
||||
if let Some(sr) = mmp.sender.build_report(now) {
|
||||
sender_reports.push((*node_addr, sr.encode()));
|
||||
}
|
||||
if mode == MmpMode::Full
|
||||
&& mmp.sender.should_send_report(now)
|
||||
&& let Some(sr) = mmp.sender.build_report(now)
|
||||
{
|
||||
sender_reports.push((*node_addr, sr.encode()));
|
||||
}
|
||||
|
||||
// Receiver reports: Full and Lightweight modes
|
||||
if mode != MmpMode::Minimal && mmp.receiver.should_send_report(now) {
|
||||
if let Some(rr) = mmp.receiver.build_report(now) {
|
||||
receiver_reports.push((*node_addr, rr.encode()));
|
||||
}
|
||||
if mode != MmpMode::Minimal
|
||||
&& mmp.receiver.should_send_report(now)
|
||||
&& let Some(rr) = mmp.receiver.build_report(now)
|
||||
{
|
||||
receiver_reports.push((*node_addr, rr.encode()));
|
||||
}
|
||||
|
||||
// Periodic operator logging
|
||||
@@ -243,4 +249,153 @@ impl Node {
|
||||
"MMP link teardown"
|
||||
);
|
||||
}
|
||||
|
||||
// === Session-layer MMP ===
|
||||
|
||||
/// Check all sessions for pending MMP reports and send them.
|
||||
///
|
||||
/// Called from the tick handler. Also emits periodic session MMP logs.
|
||||
/// Uses the collect-then-send pattern to avoid borrowing conflicts.
|
||||
pub(in crate::node) async fn check_session_mmp_reports(&mut self) {
|
||||
let now = Instant::now();
|
||||
|
||||
// Collect reports to send: (dest_addr, msg_type, encoded_body)
|
||||
let mut reports: Vec<(NodeAddr, u8, Vec<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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ impl Node {
|
||||
self.check_tree_state().await;
|
||||
self.check_bloom_state().await;
|
||||
self.check_mmp_reports().await;
|
||||
self.check_session_mmp_reports().await;
|
||||
self.purge_stale_lookups(now_ms);
|
||||
}
|
||||
}
|
||||
|
||||
+511
-139
@@ -1,16 +1,24 @@
|
||||
//! End-to-end session message handlers.
|
||||
//!
|
||||
//! Handles locally-delivered session payloads from SessionDatagram envelopes.
|
||||
//! Dispatches based on session message type to specific handlers for
|
||||
//! SessionSetup (Noise IK msg1), SessionAck (msg2), DataPacket, and
|
||||
//! error signals (CoordsRequired, PathBroken).
|
||||
//! Dispatches based on FSP common prefix phase to specific handlers for
|
||||
//! SessionSetup (Noise IK msg1), SessionAck (msg2), encrypted data,
|
||||
//! and error signals (CoordsRequired, PathBroken).
|
||||
|
||||
use crate::node::session::{EndToEndState, SessionEntry};
|
||||
use crate::node::session_wire::{
|
||||
build_fsp_header, fsp_prepend_inner_header, fsp_strip_inner_header,
|
||||
parse_encrypted_coords, FspCommonPrefix, FspEncryptedHeader, FSP_COMMON_PREFIX_SIZE,
|
||||
FSP_FLAG_CP, FSP_HEADER_SIZE, FSP_PHASE_ESTABLISHED, FSP_PHASE_MSG1, FSP_PHASE_MSG2,
|
||||
};
|
||||
use crate::protocol::encode_coords;
|
||||
use crate::node::{Node, NodeError};
|
||||
use crate::noise::{HandshakeState, HANDSHAKE_MSG1_SIZE, HANDSHAKE_MSG2_SIZE};
|
||||
use crate::mmp::report::ReceiverReport;
|
||||
use crate::mmp::{MAX_SESSION_REPORT_INTERVAL_MS, MIN_SESSION_REPORT_INTERVAL_MS};
|
||||
use crate::protocol::{
|
||||
CoordsRequired, DataPacket, PathBroken, SessionAck, SessionDatagram, SessionMessageType,
|
||||
SessionSetup,
|
||||
CoordsRequired, FspInnerFlags, PathBroken, PathMtuNotification, SessionAck, SessionDatagram,
|
||||
SessionMessageType, SessionReceiverReport, SessionSenderReport, SessionSetup,
|
||||
};
|
||||
use crate::NodeAddr;
|
||||
use secp256k1::PublicKey;
|
||||
@@ -20,42 +28,239 @@ impl Node {
|
||||
/// Handle a locally-delivered session datagram payload.
|
||||
///
|
||||
/// Called from `handle_session_datagram()` when `dest_addr == self.node_addr()`.
|
||||
/// Dispatches to the appropriate handler based on the session message type byte.
|
||||
/// Dispatches based on the 4-byte FSP common prefix:
|
||||
///
|
||||
/// - Phase 0x1 → SessionSetup (handshake msg1)
|
||||
/// - Phase 0x2 → SessionAck (handshake msg2)
|
||||
/// - Phase 0x0 + U flag → plaintext error signal (CoordsRequired/PathBroken)
|
||||
/// - Phase 0x0 + !U → encrypted session message (data, reports, etc.)
|
||||
pub(in crate::node) async fn handle_session_payload(
|
||||
&mut self,
|
||||
src_addr: &NodeAddr,
|
||||
payload: &[u8],
|
||||
path_mtu: u16,
|
||||
) {
|
||||
if payload.is_empty() {
|
||||
debug!("Empty session payload");
|
||||
return;
|
||||
}
|
||||
let prefix = match FspCommonPrefix::parse(payload) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
debug!(len = payload.len(), "Session payload too short for FSP prefix");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let msg_type = payload[0];
|
||||
let inner = &payload[1..];
|
||||
let inner = &payload[FSP_COMMON_PREFIX_SIZE..];
|
||||
|
||||
match SessionMessageType::from_byte(msg_type) {
|
||||
Some(SessionMessageType::SessionSetup) => {
|
||||
match prefix.phase {
|
||||
FSP_PHASE_MSG1 => {
|
||||
self.handle_session_setup(src_addr, inner).await;
|
||||
}
|
||||
Some(SessionMessageType::SessionAck) => {
|
||||
FSP_PHASE_MSG2 => {
|
||||
self.handle_session_ack(src_addr, inner).await;
|
||||
}
|
||||
Some(SessionMessageType::DataPacket) => {
|
||||
self.handle_data_packet(src_addr, inner).await;
|
||||
FSP_PHASE_ESTABLISHED if prefix.is_unencrypted() => {
|
||||
// Plaintext error signals: read msg_type from first byte after prefix
|
||||
if inner.is_empty() {
|
||||
debug!("Empty plaintext error signal");
|
||||
return;
|
||||
}
|
||||
let error_type = inner[0];
|
||||
let error_body = &inner[1..];
|
||||
match SessionMessageType::from_byte(error_type) {
|
||||
Some(SessionMessageType::CoordsRequired) => {
|
||||
self.handle_coords_required(error_body).await;
|
||||
}
|
||||
Some(SessionMessageType::PathBroken) => {
|
||||
self.handle_path_broken(error_body).await;
|
||||
}
|
||||
_ => {
|
||||
debug!(error_type, "Unknown plaintext error signal type");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(SessionMessageType::CoordsRequired) => {
|
||||
self.handle_coords_required(inner).await;
|
||||
FSP_PHASE_ESTABLISHED => {
|
||||
self.handle_encrypted_session_msg(src_addr, payload, path_mtu).await;
|
||||
}
|
||||
Some(SessionMessageType::PathBroken) => {
|
||||
self.handle_path_broken(inner).await;
|
||||
}
|
||||
None => {
|
||||
debug!(msg_type, "Unknown session message type");
|
||||
_ => {
|
||||
debug!(phase = prefix.phase, "Unknown FSP phase");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle an encrypted session message (phase 0x0, U flag clear).
|
||||
///
|
||||
/// Full FSP receive pipeline:
|
||||
/// 1. Parse FspEncryptedHeader (12 bytes) → counter, flags, header_bytes
|
||||
/// 2. If CP flag: parse cleartext coords, cache them
|
||||
/// 3. Session lookup with Responding→Established transition
|
||||
/// 4. AEAD decrypt with AAD = header_bytes
|
||||
/// 5. Strip FSP inner header → timestamp, msg_type, inner_flags
|
||||
/// 6. Dispatch by msg_type
|
||||
async fn handle_encrypted_session_msg(&mut self, src_addr: &NodeAddr, payload: &[u8], path_mtu: u16) {
|
||||
// Parse the 12-byte encrypted header (includes the 4-byte prefix)
|
||||
let header = match FspEncryptedHeader::parse(payload) {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
debug!(len = payload.len(), "Encrypted session message too short for FSP header");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Determine where ciphertext starts (after header, optionally after coords)
|
||||
let mut ciphertext_offset = FSP_HEADER_SIZE;
|
||||
|
||||
// If CP flag set, parse cleartext coords between header and ciphertext
|
||||
if header.has_coords() {
|
||||
let coord_data = &payload[FSP_HEADER_SIZE..];
|
||||
match parse_encrypted_coords(coord_data) {
|
||||
Ok((src_coords, dest_coords, bytes_consumed)) => {
|
||||
let now_ms = Self::now_ms();
|
||||
if let Some(coords) = src_coords {
|
||||
self.coord_cache.insert(*src_addr, coords, now_ms);
|
||||
}
|
||||
if let Some(coords) = dest_coords {
|
||||
self.coord_cache.insert(*self.node_addr(), coords, now_ms);
|
||||
}
|
||||
ciphertext_offset += bytes_consumed;
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(error = %e, "Failed to parse coords from encrypted session message");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ciphertext = &payload[ciphertext_offset..];
|
||||
|
||||
// Look up session entry, handle Responding→Established transition
|
||||
let mut entry = match self.sessions.remove(src_addr) {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
debug!(src = %src_addr, "Encrypted session message for unknown session");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if entry.state().is_responding() {
|
||||
let old_state = entry.take_state();
|
||||
let handshake = match old_state {
|
||||
Some(EndToEndState::Responding(hs)) => hs,
|
||||
_ => {
|
||||
debug!(src = %src_addr, "Unexpected state during Responding transition");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let noise_session = match handshake.into_session() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
debug!(error = %e, "Failed to create session from responding handshake");
|
||||
return;
|
||||
}
|
||||
};
|
||||
entry.set_state(EndToEndState::Established(noise_session));
|
||||
entry.set_coords_warmup_remaining(self.config.node.session.coords_warmup_packets);
|
||||
entry.mark_established(Self::now_ms());
|
||||
entry.init_mmp(&self.config.node.session_mmp);
|
||||
debug!(src = %src_addr, "Session established (responder, on first encrypted message)");
|
||||
}
|
||||
|
||||
// Decrypt with AAD = the 12-byte header
|
||||
let session = match entry.state_mut() {
|
||||
EndToEndState::Established(s) => s,
|
||||
_ => {
|
||||
debug!(src = %src_addr, "Encrypted message but session not established");
|
||||
self.sessions.insert(*src_addr, entry);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let plaintext = match session.decrypt_with_replay_check_and_aad(
|
||||
ciphertext,
|
||||
header.counter,
|
||||
&header.header_bytes,
|
||||
) {
|
||||
Ok(pt) => pt,
|
||||
Err(e) => {
|
||||
debug!(
|
||||
error = %e, src = %src_addr, counter = header.counter,
|
||||
"Session AEAD decryption failed"
|
||||
);
|
||||
self.sessions.insert(*src_addr, entry);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
entry.touch(Self::now_ms());
|
||||
self.sessions.insert(*src_addr, entry);
|
||||
|
||||
// Strip FSP inner header (6 bytes)
|
||||
let (timestamp, msg_type, inner_flags_byte, rest) = match fsp_strip_inner_header(&plaintext) {
|
||||
Some(parts) => parts,
|
||||
None => {
|
||||
debug!(src = %src_addr, "Decrypted payload too short for FSP inner header");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// MMP per-message recording on RX path
|
||||
if let Some(entry) = self.sessions.get_mut(src_addr)
|
||||
&& let Some(mmp) = entry.mmp_mut()
|
||||
{
|
||||
let now = std::time::Instant::now();
|
||||
mmp.receiver.record_recv(
|
||||
header.counter, timestamp, plaintext.len(), false, now,
|
||||
);
|
||||
// Spin bit: advance state machine for correct TX reflection.
|
||||
// RTT samples not fed into SRTT — timestamp-echo provides
|
||||
// accurate RTT; spin bit includes variable inter-frame delays.
|
||||
let inner_flags = FspInnerFlags::from_byte(inner_flags_byte);
|
||||
let _spin_rtt = mmp.spin_bit.rx_observe(
|
||||
inner_flags.spin_bit, header.counter, now,
|
||||
);
|
||||
}
|
||||
|
||||
// Feed path_mtu from datagram envelope to MMP path MTU tracking.
|
||||
// Done for ALL session messages, not just DataPackets, so the
|
||||
// destination learns the path MTU even when only reports flow.
|
||||
if let Some(entry) = self.sessions.get_mut(src_addr)
|
||||
&& let Some(mmp) = entry.mmp_mut()
|
||||
{
|
||||
mmp.path_mtu.observe_incoming_mtu(path_mtu);
|
||||
}
|
||||
|
||||
// Dispatch by msg_type
|
||||
match SessionMessageType::from_byte(msg_type) {
|
||||
Some(SessionMessageType::DataPacket) => {
|
||||
// msg_type 0x10: deliver rest (IPv6 payload) to TUN
|
||||
if let Some(tun_tx) = &self.tun_tx {
|
||||
if let Err(e) = tun_tx.send(rest.to_vec()) {
|
||||
debug!(error = %e, "Failed to deliver decrypted packet to TUN");
|
||||
}
|
||||
} else {
|
||||
debug!(
|
||||
src = %src_addr,
|
||||
"DataPacket decrypted (no TUN interface, plaintext dropped)"
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(SessionMessageType::SenderReport) => {
|
||||
self.handle_session_sender_report(src_addr, rest);
|
||||
}
|
||||
Some(SessionMessageType::ReceiverReport) => {
|
||||
self.handle_session_receiver_report(src_addr, rest);
|
||||
}
|
||||
Some(SessionMessageType::PathMtuNotification) => {
|
||||
self.handle_session_path_mtu_notification(src_addr, rest);
|
||||
}
|
||||
_ => {
|
||||
debug!(src = %src_addr, msg_type, "Unknown session message type, dropping");
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any pending outbound packets (e.g., simultaneous initiation
|
||||
// where responder also had queued outbound packets)
|
||||
self.flush_pending_packets(src_addr).await;
|
||||
}
|
||||
|
||||
/// Handle an incoming SessionSetup (Noise IK msg1).
|
||||
///
|
||||
/// The remote node wants to establish an end-to-end session with us.
|
||||
@@ -143,18 +348,18 @@ impl Node {
|
||||
let our_coords = self.tree_state.my_coords().clone();
|
||||
let ack = SessionAck::new(our_coords).with_handshake(msg2);
|
||||
let my_addr = *self.node_addr();
|
||||
let datagram = SessionDatagram::new(my_addr, *src_addr, ack.encode())
|
||||
let mut datagram = SessionDatagram::new(my_addr, *src_addr, ack.encode())
|
||||
.with_ttl(self.config.node.session.default_ttl);
|
||||
|
||||
// Route the ack back to the initiator
|
||||
if let Err(e) = self.send_session_datagram(&datagram).await {
|
||||
if let Err(e) = self.send_session_datagram(&mut datagram).await {
|
||||
debug!(error = %e, dest = %src_addr, "Failed to send SessionAck");
|
||||
return;
|
||||
}
|
||||
|
||||
// Store session entry in Responding state
|
||||
let now_ms = Self::now_ms();
|
||||
let entry = SessionEntry::new(*src_addr, remote_pubkey, EndToEndState::Responding(handshake), now_ms);
|
||||
let entry = SessionEntry::new(*src_addr, remote_pubkey, EndToEndState::Responding(handshake), now_ms, false);
|
||||
self.sessions.insert(*src_addr, entry);
|
||||
|
||||
debug!(src = %src_addr, "SessionSetup processed, SessionAck sent");
|
||||
@@ -210,13 +415,13 @@ impl Node {
|
||||
}
|
||||
};
|
||||
|
||||
let now_ms = Self::now_ms();
|
||||
entry.set_state(EndToEndState::Established(session));
|
||||
entry.set_coords_warmup_remaining(self.config.node.session.coords_warmup_packets);
|
||||
entry.touch(Self::now_ms());
|
||||
entry.mark_established(now_ms);
|
||||
entry.init_mmp(&self.config.node.session_mmp);
|
||||
entry.touch(now_ms);
|
||||
self.sessions.insert(*src_addr, entry);
|
||||
|
||||
// Cache the responder's coordinates
|
||||
let now_ms = Self::now_ms();
|
||||
self.coord_cache.insert(*src_addr, ack.src_coords, now_ms);
|
||||
|
||||
// Flush any queued outbound packets for this destination
|
||||
@@ -225,6 +430,136 @@ impl Node {
|
||||
debug!(src = %src_addr, "Session established (initiator)");
|
||||
}
|
||||
|
||||
// === Session-layer MMP report handlers ===
|
||||
|
||||
/// Handle an incoming session-layer SenderReport (msg_type 0x11).
|
||||
///
|
||||
/// Informational only — the peer is telling us about what they sent.
|
||||
/// Logged but not used for metrics (same pattern as link-layer).
|
||||
fn handle_session_sender_report(&mut self, src_addr: &NodeAddr, body: &[u8]) {
|
||||
let sr = match SessionSenderReport::decode(body) {
|
||||
Ok(sr) => sr,
|
||||
Err(e) => {
|
||||
debug!(src = %src_addr, error = %e, "Malformed SessionSenderReport");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
debug!(
|
||||
src = %src_addr,
|
||||
cum_pkts = sr.cumulative_packets_sent,
|
||||
interval_bytes = sr.interval_bytes_sent,
|
||||
"Received SessionSenderReport"
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle an incoming session-layer ReceiverReport (msg_type 0x12).
|
||||
///
|
||||
/// The peer is telling us about what they received from us. We feed
|
||||
/// this to our metrics to compute RTT, loss rate, and trend indicators.
|
||||
fn handle_session_receiver_report(&mut self, src_addr: &NodeAddr, body: &[u8]) {
|
||||
let session_rr = match SessionReceiverReport::decode(body) {
|
||||
Ok(rr) => rr,
|
||||
Err(e) => {
|
||||
debug!(src = %src_addr, error = %e, "Malformed SessionReceiverReport");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Convert to link-layer ReceiverReport for MmpMetrics processing
|
||||
let rr: ReceiverReport = ReceiverReport::from(&session_rr);
|
||||
|
||||
let now_ms = Self::now_ms();
|
||||
let entry = match self.sessions.get_mut(src_addr) {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
debug!(src = %src_addr, "SessionReceiverReport for unknown session");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let our_timestamp_ms = entry.session_timestamp(now_ms);
|
||||
|
||||
let Some(mmp) = entry.mmp_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let now = std::time::Instant::now();
|
||||
mmp.metrics.process_receiver_report(&rr, our_timestamp_ms, now);
|
||||
|
||||
// Feed SRTT back to sender/receiver report interval tuning (session-layer bounds)
|
||||
if let Some(srtt_ms) = mmp.metrics.srtt_ms() {
|
||||
let srtt_us = (srtt_ms * 1000.0) as i64;
|
||||
mmp.sender.update_report_interval_with_bounds(
|
||||
srtt_us,
|
||||
MIN_SESSION_REPORT_INTERVAL_MS,
|
||||
MAX_SESSION_REPORT_INTERVAL_MS,
|
||||
);
|
||||
mmp.receiver.update_report_interval_with_bounds(
|
||||
srtt_us,
|
||||
MIN_SESSION_REPORT_INTERVAL_MS,
|
||||
MAX_SESSION_REPORT_INTERVAL_MS,
|
||||
);
|
||||
// Also update PathMtu notification interval from SRTT
|
||||
mmp.path_mtu.update_interval_from_srtt(srtt_ms);
|
||||
}
|
||||
|
||||
// Update reverse delivery ratio from our own receiver state
|
||||
let our_recv_packets = mmp.receiver.cumulative_packets_recv();
|
||||
let peer_highest = mmp.receiver.highest_counter();
|
||||
if peer_highest > 0 {
|
||||
let reverse_ratio = (our_recv_packets as f64) / (peer_highest as f64);
|
||||
mmp.metrics.set_delivery_ratio_reverse(reverse_ratio);
|
||||
}
|
||||
|
||||
debug!(
|
||||
src = %src_addr,
|
||||
rtt_ms = ?mmp.metrics.srtt_ms(),
|
||||
loss = format_args!("{:.1}%", mmp.metrics.loss_rate() * 100.0),
|
||||
"Processed SessionReceiverReport"
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle an incoming PathMtuNotification (msg_type 0x13).
|
||||
///
|
||||
/// The destination is telling us the path MTU has changed.
|
||||
/// Apply source-side rules (decrease immediate, increase validated).
|
||||
fn handle_session_path_mtu_notification(&mut self, src_addr: &NodeAddr, body: &[u8]) {
|
||||
let notif = match PathMtuNotification::decode(body) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
debug!(src = %src_addr, error = %e, "Malformed PathMtuNotification");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let entry = match self.sessions.get_mut(src_addr) {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
debug!(src = %src_addr, "PathMtuNotification for unknown session");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(mmp) = entry.mmp_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let old_mtu = mmp.path_mtu.current_mtu();
|
||||
let now = std::time::Instant::now();
|
||||
mmp.path_mtu.apply_notification(notif.path_mtu, now);
|
||||
let new_mtu = mmp.path_mtu.current_mtu();
|
||||
|
||||
if new_mtu != old_mtu {
|
||||
debug!(
|
||||
src = %src_addr,
|
||||
old_mtu,
|
||||
new_mtu,
|
||||
"Path MTU changed via notification"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete an initiator-side Noise IK handshake given msg2.
|
||||
fn complete_initiator_handshake(
|
||||
mut handshake: HandshakeState,
|
||||
@@ -238,90 +573,6 @@ impl Node {
|
||||
.map_err(|e| format!("into_session failed: {}", e))
|
||||
}
|
||||
|
||||
/// Handle an incoming DataPacket.
|
||||
///
|
||||
/// Decrypts the payload using the established session key and delivers
|
||||
/// to the TUN interface.
|
||||
async fn handle_data_packet(&mut self, src_addr: &NodeAddr, inner: &[u8]) {
|
||||
let packet = match DataPacket::decode(inner) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
debug!(error = %e, "Malformed DataPacket");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Remove entry to take ownership for potential state transition
|
||||
let mut entry = match self.sessions.remove(src_addr) {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
debug!(src = %src_addr, "DataPacket for unknown session");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// If in Responding state, transition to Established first
|
||||
// (responder wrote msg2, handshake is complete from our side)
|
||||
if entry.state().is_responding() {
|
||||
let old_state = entry.take_state();
|
||||
let handshake = match old_state {
|
||||
Some(EndToEndState::Responding(hs)) => hs,
|
||||
_ => {
|
||||
debug!(src = %src_addr, "Unexpected state in DataPacket handler");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let noise_session = match handshake.into_session() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
debug!(error = %e, "Failed to create session from responding handshake");
|
||||
return;
|
||||
}
|
||||
};
|
||||
entry.set_state(EndToEndState::Established(noise_session));
|
||||
entry.set_coords_warmup_remaining(self.config.node.session.coords_warmup_packets);
|
||||
debug!(src = %src_addr, "Session established (responder, on first data)");
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
let session = match entry.state_mut() {
|
||||
EndToEndState::Established(s) => s,
|
||||
_ => {
|
||||
debug!(src = %src_addr, "DataPacket but session not established");
|
||||
self.sessions.insert(*src_addr, entry);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let plaintext = match session.decrypt_with_replay_check(&packet.payload, packet.counter) {
|
||||
Ok(pt) => pt,
|
||||
Err(e) => {
|
||||
debug!(error = %e, src = %src_addr, counter = packet.counter, "Session decryption failed");
|
||||
self.sessions.insert(*src_addr, entry);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
entry.touch(Self::now_ms());
|
||||
self.sessions.insert(*src_addr, entry);
|
||||
|
||||
// Deliver to TUN
|
||||
if let Some(tun_tx) = &self.tun_tx {
|
||||
if let Err(e) = tun_tx.send(plaintext) {
|
||||
debug!(error = %e, "Failed to deliver decrypted packet to TUN");
|
||||
}
|
||||
} else {
|
||||
debug!(
|
||||
src = %src_addr,
|
||||
"DataPacket decrypted (no TUN interface, plaintext dropped)"
|
||||
);
|
||||
}
|
||||
|
||||
// Flush any pending outbound packets (e.g., simultaneous initiation
|
||||
// where responder also had queued outbound packets)
|
||||
self.flush_pending_packets(src_addr).await;
|
||||
}
|
||||
|
||||
/// Handle a CoordsRequired error signal from a transit router.
|
||||
///
|
||||
/// The router couldn't route our packet because it lacks cached
|
||||
@@ -431,18 +682,18 @@ impl Node {
|
||||
|
||||
// Wrap in SessionDatagram
|
||||
let my_addr = *self.node_addr();
|
||||
let datagram = SessionDatagram::new(my_addr, dest_addr, setup.encode())
|
||||
let mut datagram = SessionDatagram::new(my_addr, dest_addr, setup.encode())
|
||||
.with_ttl(self.config.node.session.default_ttl);
|
||||
|
||||
// Route toward destination
|
||||
self.send_session_datagram(&datagram).await?;
|
||||
self.send_session_datagram(&mut datagram).await?;
|
||||
|
||||
// Register destination identity for TUN → session routing
|
||||
self.register_identity(dest_addr, dest_pubkey);
|
||||
|
||||
// Store session entry
|
||||
let now_ms = Self::now_ms();
|
||||
let entry = SessionEntry::new(dest_addr, dest_pubkey, EndToEndState::Initiating(handshake), now_ms);
|
||||
let entry = SessionEntry::new(dest_addr, dest_pubkey, EndToEndState::Initiating(handshake), now_ms, true);
|
||||
self.sessions.insert(dest_addr, entry);
|
||||
|
||||
debug!(dest = %dest_addr, "Session initiation started");
|
||||
@@ -451,18 +702,28 @@ impl Node {
|
||||
|
||||
/// Send application data over an established session.
|
||||
///
|
||||
/// Encrypts the payload with the session key, wraps in DataPacket
|
||||
/// and SessionDatagram, routes toward destination.
|
||||
/// Uses the FSP pipeline: builds a 12-byte cleartext header (used as AAD),
|
||||
/// prepends the 6-byte inner header to the plaintext, encrypts with AAD,
|
||||
/// optionally inserts cleartext coords, and wraps in a SessionDatagram.
|
||||
pub(in crate::node) async fn send_session_data(
|
||||
&mut self,
|
||||
dest_addr: &NodeAddr,
|
||||
plaintext: &[u8],
|
||||
) -> Result<(), NodeError> {
|
||||
let now_ms = Self::now_ms();
|
||||
let entry = self.sessions.get_mut(dest_addr).ok_or_else(|| NodeError::SendFailed {
|
||||
node_addr: *dest_addr,
|
||||
reason: "no session".into(),
|
||||
})?;
|
||||
|
||||
// Check warmup counter and get session timestamp
|
||||
let include_coords = entry.coords_warmup_remaining() > 0;
|
||||
if include_coords {
|
||||
entry.set_coords_warmup_remaining(entry.coords_warmup_remaining() - 1);
|
||||
}
|
||||
let timestamp = entry.session_timestamp(now_ms);
|
||||
let spin_bit = entry.mmp().is_some_and(|m| m.spin_bit.tx_bit());
|
||||
|
||||
let session = match entry.state_mut() {
|
||||
EndToEndState::Established(s) => s,
|
||||
_ => {
|
||||
@@ -476,35 +737,129 @@ impl Node {
|
||||
// Get counter before encrypting (encrypt will increment it)
|
||||
let counter = session.current_send_counter();
|
||||
|
||||
// Encrypt with session key
|
||||
let ciphertext = session.encrypt(plaintext).map_err(|e| NodeError::SendFailed {
|
||||
node_addr: *dest_addr,
|
||||
reason: format!("session encrypt failed: {}", e),
|
||||
// FSP inner header: [timestamp:4 LE][msg_type:1][inner_flags:1] + plaintext
|
||||
let msg_type = SessionMessageType::DataPacket.to_byte(); // 0x10
|
||||
let inner_flags = FspInnerFlags { spin_bit }.to_byte();
|
||||
let inner_plaintext = fsp_prepend_inner_header(timestamp, msg_type, inner_flags, plaintext);
|
||||
|
||||
// Build FSP flags
|
||||
let flags = if include_coords { FSP_FLAG_CP } else { 0 };
|
||||
|
||||
// Build 12-byte FSP header (used as AAD for AEAD)
|
||||
let payload_len = inner_plaintext.len() as u16;
|
||||
let header = build_fsp_header(counter, flags, payload_len);
|
||||
|
||||
// Encrypt with AAD binding to the FSP header
|
||||
let ciphertext = session.encrypt_with_aad(&inner_plaintext, &header).map_err(|e| {
|
||||
NodeError::SendFailed {
|
||||
node_addr: *dest_addr,
|
||||
reason: format!("session encrypt failed: {}", e),
|
||||
}
|
||||
})?;
|
||||
|
||||
// Check warmup counter and decrement (while entry is still borrowed)
|
||||
let include_coords = entry.coords_warmup_remaining() > 0;
|
||||
if include_coords {
|
||||
entry.set_coords_warmup_remaining(entry.coords_warmup_remaining() - 1);
|
||||
}
|
||||
|
||||
// Build DataPacket with explicit counter for replay protection
|
||||
let mut data_packet = DataPacket::new(counter, ciphertext);
|
||||
// Assemble: header(12) + [coords] + ciphertext
|
||||
let mut fsp_payload = Vec::with_capacity(FSP_HEADER_SIZE + ciphertext.len() + 200);
|
||||
fsp_payload.extend_from_slice(&header);
|
||||
if include_coords {
|
||||
let my_coords = self.tree_state.my_coords().clone();
|
||||
let dest_coords = self.get_dest_coords(dest_addr);
|
||||
data_packet = data_packet.with_coords(my_coords, dest_coords);
|
||||
encode_coords(&my_coords, &mut fsp_payload);
|
||||
encode_coords(&dest_coords, &mut fsp_payload);
|
||||
}
|
||||
fsp_payload.extend_from_slice(&ciphertext);
|
||||
|
||||
let my_addr = *self.node_addr();
|
||||
let datagram = SessionDatagram::new(my_addr, *dest_addr, data_packet.encode())
|
||||
let mut datagram = SessionDatagram::new(my_addr, *dest_addr, fsp_payload)
|
||||
.with_ttl(self.config.node.session.default_ttl);
|
||||
|
||||
self.send_session_datagram(&datagram).await?;
|
||||
self.send_session_datagram(&mut datagram).await?;
|
||||
|
||||
// Re-borrow after send (which borrowed &mut self)
|
||||
if let Some(entry) = self.sessions.get_mut(dest_addr) {
|
||||
entry.touch(Self::now_ms());
|
||||
if let Some(mmp) = entry.mmp_mut() {
|
||||
mmp.sender.record_sent(counter, timestamp, ciphertext.len());
|
||||
}
|
||||
entry.touch(now_ms);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a non-data session message (reports, notifications) over an established session.
|
||||
///
|
||||
/// Similar to `send_session_data()` but:
|
||||
/// - Takes an explicit `msg_type` byte (0x11, 0x12, 0x13, etc.)
|
||||
/// - Never includes COORDS_PRESENT (reports are lightweight)
|
||||
/// - Reads spin bit from MMP state for the inner header
|
||||
/// - Records the send in MMP sender state
|
||||
pub(in crate::node) async fn send_session_msg(
|
||||
&mut self,
|
||||
dest_addr: &NodeAddr,
|
||||
msg_type: u8,
|
||||
payload: &[u8],
|
||||
) -> Result<(), NodeError> {
|
||||
let now_ms = Self::now_ms();
|
||||
|
||||
// Read spin bit and session timestamp from entry
|
||||
let entry = self.sessions.get(dest_addr).ok_or_else(|| NodeError::SendFailed {
|
||||
node_addr: *dest_addr,
|
||||
reason: "no session".into(),
|
||||
})?;
|
||||
let timestamp = entry.session_timestamp(now_ms);
|
||||
let spin_bit = entry.mmp().is_some_and(|m| m.spin_bit.tx_bit());
|
||||
|
||||
// Build inner flags with spin bit
|
||||
let inner_flags = FspInnerFlags { spin_bit }.to_byte();
|
||||
|
||||
// Get mutable access for encryption
|
||||
let entry = self.sessions.get_mut(dest_addr).ok_or_else(|| NodeError::SendFailed {
|
||||
node_addr: *dest_addr,
|
||||
reason: "no session".into(),
|
||||
})?;
|
||||
let session = match entry.state_mut() {
|
||||
EndToEndState::Established(s) => s,
|
||||
_ => {
|
||||
return Err(NodeError::SendFailed {
|
||||
node_addr: *dest_addr,
|
||||
reason: "session not established".into(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let counter = session.current_send_counter();
|
||||
|
||||
// FSP inner header + plaintext
|
||||
let inner_plaintext = fsp_prepend_inner_header(timestamp, msg_type, inner_flags, payload);
|
||||
|
||||
// Build 12-byte FSP header (no flags — no CP for reports)
|
||||
let payload_len = inner_plaintext.len() as u16;
|
||||
let header = build_fsp_header(counter, 0, payload_len);
|
||||
|
||||
// Encrypt with AAD
|
||||
let ciphertext = session.encrypt_with_aad(&inner_plaintext, &header).map_err(|e| {
|
||||
NodeError::SendFailed {
|
||||
node_addr: *dest_addr,
|
||||
reason: format!("session encrypt failed: {}", e),
|
||||
}
|
||||
})?;
|
||||
|
||||
// Assemble: header(12) + ciphertext (no coords)
|
||||
let mut fsp_payload = Vec::with_capacity(FSP_HEADER_SIZE + ciphertext.len());
|
||||
fsp_payload.extend_from_slice(&header);
|
||||
fsp_payload.extend_from_slice(&ciphertext);
|
||||
|
||||
let my_addr = *self.node_addr();
|
||||
let mut datagram = SessionDatagram::new(my_addr, *dest_addr, fsp_payload)
|
||||
.with_ttl(self.config.node.session.default_ttl);
|
||||
|
||||
self.send_session_datagram(&mut datagram).await?;
|
||||
|
||||
// Record in MMP sender state and touch
|
||||
if let Some(entry) = self.sessions.get_mut(dest_addr) {
|
||||
if let Some(mmp) = entry.mmp_mut() {
|
||||
mmp.sender.record_sent(counter, timestamp, ciphertext.len());
|
||||
}
|
||||
entry.touch(now_ms);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -512,11 +867,11 @@ impl Node {
|
||||
|
||||
/// Route and send a SessionDatagram through the mesh.
|
||||
///
|
||||
/// Finds the next hop for the destination and sends the datagram
|
||||
/// as an encrypted link message.
|
||||
/// Finds the next hop for the destination, seeds path_mtu from the
|
||||
/// first-hop transport MTU, and sends as an encrypted link message.
|
||||
async fn send_session_datagram(
|
||||
&mut self,
|
||||
datagram: &SessionDatagram,
|
||||
datagram: &mut SessionDatagram,
|
||||
) -> Result<(), NodeError> {
|
||||
let next_hop_addr = match self.find_next_hop(&datagram.dest_addr) {
|
||||
Some(peer) => *peer.node_addr(),
|
||||
@@ -528,6 +883,23 @@ impl Node {
|
||||
}
|
||||
};
|
||||
|
||||
// Seed path_mtu from the first-hop transport MTU (same as forwarding path)
|
||||
if let Some(peer) = self.peers.get(&next_hop_addr)
|
||||
&& let Some(tid) = peer.transport_id()
|
||||
&& let Some(transport) = self.transports.get(&tid)
|
||||
{
|
||||
datagram.path_mtu = datagram.path_mtu.min(transport.mtu());
|
||||
}
|
||||
|
||||
// Source-side: seed our PathMtuState.current_mtu from the outbound
|
||||
// transport MTU so it doesn't stay at u16::MAX until the destination
|
||||
// sends a PathMtuNotification back.
|
||||
if let Some(entry) = self.sessions.get_mut(&datagram.dest_addr)
|
||||
&& let Some(mmp) = entry.mmp_mut()
|
||||
{
|
||||
mmp.path_mtu.seed_source_mtu(datagram.path_mtu);
|
||||
}
|
||||
|
||||
let encoded = datagram.encode();
|
||||
self.send_encrypted_link_message(&next_hop_addr, &encoded).await
|
||||
}
|
||||
|
||||
@@ -98,6 +98,12 @@ impl Node {
|
||||
.collect();
|
||||
|
||||
for addr in idle {
|
||||
// Log MMP teardown metrics before removing the session
|
||||
if let Some(entry) = self.sessions.get(&addr)
|
||||
&& let Some(mmp) = entry.mmp()
|
||||
{
|
||||
Self::log_session_mmp_teardown(&addr, mmp);
|
||||
}
|
||||
self.sessions.remove(&addr);
|
||||
self.pending_tun_packets.remove(&addr);
|
||||
debug!(
|
||||
|
||||
@@ -11,6 +11,7 @@ mod retry;
|
||||
mod rate_limit;
|
||||
mod routing_error_rate_limit;
|
||||
pub(crate) mod session;
|
||||
pub(crate) mod session_wire;
|
||||
pub(crate) mod wire;
|
||||
mod tree;
|
||||
#[cfg(test)]
|
||||
|
||||
+53
-1
@@ -4,6 +4,8 @@
|
||||
//! Sessions are established via SessionSetup/SessionAck handshake
|
||||
//! messages carried inside SessionDatagram envelopes through the mesh.
|
||||
|
||||
use crate::config::SessionMmpConfig;
|
||||
use crate::mmp::MmpSessionState;
|
||||
use crate::noise::{HandshakeState, NoiseSession};
|
||||
use crate::NodeAddr;
|
||||
use secp256k1::PublicKey;
|
||||
@@ -54,10 +56,19 @@ pub(crate) struct SessionEntry {
|
||||
created_at: u64,
|
||||
/// Last activity timestamp (Unix milliseconds).
|
||||
last_activity: u64,
|
||||
/// Remaining DataPackets that should include COORDS_PRESENT.
|
||||
/// When the session transitioned to Established (Unix milliseconds).
|
||||
/// Used to compute session-relative timestamps for the FSP inner header.
|
||||
/// Set to 0 until the session is established.
|
||||
session_start_ms: u64,
|
||||
/// Remaining data packets that should include COORDS_PRESENT.
|
||||
/// Initialized from config when session becomes Established;
|
||||
/// reset on CoordsRequired receipt.
|
||||
coords_warmup_remaining: u8,
|
||||
/// Whether this node initiated the Noise IK handshake.
|
||||
/// Used for spin bit role assignment in session-layer MMP.
|
||||
is_initiator: bool,
|
||||
/// Session-layer MMP state. Initialized on Established transition.
|
||||
mmp: Option<MmpSessionState>,
|
||||
}
|
||||
|
||||
impl SessionEntry {
|
||||
@@ -67,6 +78,7 @@ impl SessionEntry {
|
||||
remote_pubkey: PublicKey,
|
||||
state: EndToEndState,
|
||||
now_ms: u64,
|
||||
is_initiator: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
remote_addr,
|
||||
@@ -74,7 +86,10 @@ impl SessionEntry {
|
||||
state: Some(state),
|
||||
created_at: now_ms,
|
||||
last_activity: now_ms,
|
||||
session_start_ms: 0,
|
||||
coords_warmup_remaining: 0,
|
||||
is_initiator,
|
||||
mmp: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,4 +147,41 @@ impl SessionEntry {
|
||||
pub(crate) fn set_coords_warmup_remaining(&mut self, value: u8) {
|
||||
self.coords_warmup_remaining = value;
|
||||
}
|
||||
|
||||
/// Mark the session as started (transition to Established).
|
||||
///
|
||||
/// Records the current time as the session start for computing
|
||||
/// session-relative timestamps in the FSP inner header.
|
||||
pub(crate) fn mark_established(&mut self, now_ms: u64) {
|
||||
self.session_start_ms = now_ms;
|
||||
}
|
||||
|
||||
/// Compute a session-relative timestamp for the FSP inner header.
|
||||
///
|
||||
/// Returns `(now_ms - session_start_ms)` truncated to u32.
|
||||
/// Wraps naturally at ~49.7 days, which is fine for relative timing.
|
||||
pub(crate) fn session_timestamp(&self, now_ms: u64) -> u32 {
|
||||
now_ms.wrapping_sub(self.session_start_ms) as u32
|
||||
}
|
||||
|
||||
/// Whether this node initiated the Noise IK handshake.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(crate) fn is_initiator(&self) -> bool {
|
||||
self.is_initiator
|
||||
}
|
||||
|
||||
/// Get a reference to the session-layer MMP state, if initialized.
|
||||
pub(crate) fn mmp(&self) -> Option<&MmpSessionState> {
|
||||
self.mmp.as_ref()
|
||||
}
|
||||
|
||||
/// Get a mutable reference to the session-layer MMP state, if initialized.
|
||||
pub(crate) fn mmp_mut(&mut self) -> Option<&mut MmpSessionState> {
|
||||
self.mmp.as_mut()
|
||||
}
|
||||
|
||||
/// Initialize session-layer MMP state (called on Established transition).
|
||||
pub(crate) fn init_mmp(&mut self, config: &SessionMmpConfig) {
|
||||
self.mmp = Some(MmpSessionState::new(config, self.is_initiator));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(×tamp_ms.to_le_bytes());
|
||||
buf.push(msg_type);
|
||||
buf.push(inner_flags);
|
||||
buf.extend_from_slice(payload);
|
||||
buf
|
||||
}
|
||||
|
||||
/// Strip the 6-byte FSP inner header from a decrypted payload.
|
||||
///
|
||||
/// Returns `(timestamp, msg_type, inner_flags, &rest)` or None if too short.
|
||||
pub fn fsp_strip_inner_header(plaintext: &[u8]) -> Option<(u32, u8, u8, &[u8])> {
|
||||
if plaintext.len() < FSP_INNER_HEADER_SIZE {
|
||||
return None;
|
||||
}
|
||||
let timestamp = u32::from_le_bytes([
|
||||
plaintext[0], plaintext[1], plaintext[2], plaintext[3],
|
||||
]);
|
||||
let msg_type = plaintext[4];
|
||||
let inner_flags = plaintext[5];
|
||||
Some((timestamp, msg_type, inner_flags, &plaintext[FSP_INNER_HEADER_SIZE..]))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Coordinate Parsing (for transit nodes and receive path)
|
||||
// ============================================================================
|
||||
|
||||
/// Parse source and destination coordinates from the cleartext section
|
||||
/// of an encrypted FSP message when the CP flag is set.
|
||||
///
|
||||
/// Coordinates appear between the 12-byte header and the ciphertext:
|
||||
/// `[src_coords_count:2 LE][src_coords:16×n][dest_coords_count:2 LE][dest_coords:16×m]`
|
||||
///
|
||||
/// Returns `(src_coords, dest_coords, bytes_consumed)`.
|
||||
pub fn parse_encrypted_coords(
|
||||
data: &[u8],
|
||||
) -> Result<(Option<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);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
//! multi-hop forwarding through live node topologies.
|
||||
|
||||
use super::*;
|
||||
use crate::protocol::{DataPacket, SessionAck, SessionDatagram, SessionSetup};
|
||||
use crate::node::session_wire::{build_fsp_header, FSP_FLAG_CP};
|
||||
use crate::protocol::{SessionAck, SessionDatagram, SessionSetup, encode_coords};
|
||||
use crate::tree::TreeCoordinate;
|
||||
use spanning_tree::{
|
||||
cleanup_nodes, process_available_packets, run_tree_test, verify_tree_convergence,
|
||||
@@ -184,7 +185,7 @@ async fn test_coord_cache_warming_session_ack() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_coord_cache_warming_data_packet_with_coords() {
|
||||
async fn test_coord_cache_warming_encrypted_msg_with_coords() {
|
||||
let mut node = make_node();
|
||||
let from = make_node_addr(0xAA);
|
||||
let src_addr = make_node_addr(0x01);
|
||||
@@ -194,9 +195,13 @@ async fn test_coord_cache_warming_data_packet_with_coords() {
|
||||
let src_coords = TreeCoordinate::from_addrs(vec![src_addr, root_addr]).unwrap();
|
||||
let dest_coords = TreeCoordinate::from_addrs(vec![dest_addr, root_addr]).unwrap();
|
||||
|
||||
let data = DataPacket::new(0, vec![1, 2, 3, 4])
|
||||
.with_coords(src_coords.clone(), dest_coords.clone());
|
||||
let data_payload = data.encode();
|
||||
// Build FSP encrypted message with CP flag: header(12) + coords + fake_ciphertext
|
||||
let header = build_fsp_header(0, FSP_FLAG_CP, 20);
|
||||
let mut data_payload = Vec::new();
|
||||
data_payload.extend_from_slice(&header);
|
||||
encode_coords(&src_coords, &mut data_payload);
|
||||
encode_coords(&dest_coords, &mut data_payload);
|
||||
data_payload.extend_from_slice(&[0xCC; 36]); // fake ciphertext (20 payload + 16 tag)
|
||||
|
||||
let dg = SessionDatagram::new(src_addr, dest_addr, data_payload);
|
||||
let encoded = dg.encode();
|
||||
@@ -213,24 +218,26 @@ async fn test_coord_cache_warming_data_packet_with_coords() {
|
||||
|
||||
assert!(
|
||||
node.coord_cache().get(&src_addr, now_ms).is_some(),
|
||||
"src coords not cached from DataPacket"
|
||||
"src coords not cached from encrypted message"
|
||||
);
|
||||
assert!(
|
||||
node.coord_cache().get(&dest_addr, now_ms).is_some(),
|
||||
"dest coords not cached from DataPacket"
|
||||
"dest coords not cached from encrypted message"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_coord_cache_warming_opaque_data_packet() {
|
||||
async fn test_coord_cache_warming_encrypted_msg_no_coords() {
|
||||
let mut node = make_node();
|
||||
let from = make_node_addr(0xAA);
|
||||
let src_addr = make_node_addr(0x01);
|
||||
let dest_addr = make_node_addr(0x02);
|
||||
|
||||
// DataPacket without COORDS_PRESENT — no coords to cache
|
||||
let data = DataPacket::new(0, vec![1, 2, 3, 4]);
|
||||
let data_payload = data.encode();
|
||||
// Build FSP encrypted message without CP flag: header(12) + fake_ciphertext
|
||||
let header = build_fsp_header(0, 0, 20);
|
||||
let mut data_payload = Vec::new();
|
||||
data_payload.extend_from_slice(&header);
|
||||
data_payload.extend_from_slice(&[0xCC; 36]); // fake ciphertext (20 payload + 16 tag)
|
||||
|
||||
let dg = SessionDatagram::new(src_addr, dest_addr, data_payload);
|
||||
let encoded = dg.encode();
|
||||
@@ -244,11 +251,11 @@ async fn test_coord_cache_warming_opaque_data_packet() {
|
||||
|
||||
assert!(
|
||||
node.coord_cache().get(&src_addr, now_ms).is_none(),
|
||||
"Should not cache coords from opaque DataPacket"
|
||||
"Should not cache coords from message without CP flag"
|
||||
);
|
||||
assert!(
|
||||
node.coord_cache().get(&dest_addr, now_ms).is_none(),
|
||||
"Should not cache coords from opaque DataPacket"
|
||||
"Should not cache coords from message without CP flag"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ fn test_session_entry_new_initiating() {
|
||||
identity_b.pubkey_full(),
|
||||
EndToEndState::Initiating(handshake),
|
||||
1000,
|
||||
true,
|
||||
);
|
||||
|
||||
assert!(entry.state().is_initiating());
|
||||
@@ -86,6 +87,7 @@ fn test_session_entry_touch() {
|
||||
identity_b.pubkey_full(),
|
||||
EndToEndState::Initiating(handshake),
|
||||
1000,
|
||||
true,
|
||||
);
|
||||
|
||||
entry.touch(2000);
|
||||
@@ -111,6 +113,7 @@ fn test_session_table_operations() {
|
||||
identity_b.pubkey_full(),
|
||||
EndToEndState::Initiating(handshake),
|
||||
1000,
|
||||
true,
|
||||
);
|
||||
|
||||
node.sessions.insert(dest_addr, entry);
|
||||
@@ -223,10 +226,10 @@ async fn test_session_direct_peer_data_transfer() {
|
||||
.await
|
||||
.expect("send_session_data failed");
|
||||
|
||||
// Process packets: DataPacket arrives at Node 1
|
||||
// Process packets: encrypted data arrives at Node 1
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
let count = process_available_packets(&mut nodes).await;
|
||||
assert!(count > 0, "Expected DataPacket to arrive");
|
||||
assert!(count > 0, "Expected encrypted data to arrive");
|
||||
|
||||
// Node 1's session should now be Established (was Responding, transitions on first data)
|
||||
assert!(nodes[1]
|
||||
@@ -958,7 +961,7 @@ async fn test_tun_outbound_established_session() {
|
||||
|
||||
nodes[0].node.handle_tun_outbound(ipv6_packet.clone()).await;
|
||||
|
||||
// Process packets: encrypted DataPacket → Node 1
|
||||
// Process packets: encrypted data → Node 1
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
process_available_packets(&mut nodes).await;
|
||||
|
||||
@@ -1176,6 +1179,7 @@ fn test_purge_idle_sessions_removes_expired() {
|
||||
remote.pubkey_full(),
|
||||
EndToEndState::Established(session),
|
||||
1000, // created at t=1000ms
|
||||
true,
|
||||
);
|
||||
|
||||
node.sessions.insert(remote_addr, entry);
|
||||
@@ -1201,6 +1205,7 @@ fn test_purge_idle_sessions_keeps_active() {
|
||||
remote.pubkey_full(),
|
||||
EndToEndState::Established(session),
|
||||
1000,
|
||||
true,
|
||||
);
|
||||
|
||||
// Touch at t=80s — recent activity
|
||||
@@ -1232,6 +1237,7 @@ fn test_purge_idle_sessions_ignores_initiating() {
|
||||
remote.pubkey_full(),
|
||||
EndToEndState::Initiating(handshake),
|
||||
1000,
|
||||
true,
|
||||
);
|
||||
|
||||
node.sessions.insert(remote_addr, entry);
|
||||
@@ -1255,6 +1261,7 @@ fn test_purge_idle_sessions_cleans_pending_packets() {
|
||||
remote.pubkey_full(),
|
||||
EndToEndState::Established(session),
|
||||
1000,
|
||||
true,
|
||||
);
|
||||
|
||||
node.sessions.insert(remote_addr, entry);
|
||||
@@ -1288,6 +1295,7 @@ fn test_purge_idle_sessions_disabled_when_zero() {
|
||||
remote.pubkey_full(),
|
||||
EndToEndState::Established(session),
|
||||
1000,
|
||||
true,
|
||||
);
|
||||
|
||||
node.sessions.insert(remote_addr, entry);
|
||||
@@ -1320,6 +1328,7 @@ fn test_coords_warmup_counter_default_zero_on_new() {
|
||||
identity_b.pubkey_full(),
|
||||
EndToEndState::Initiating(handshake),
|
||||
1000,
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(entry.coords_warmup_remaining(), 0,
|
||||
@@ -1338,6 +1347,7 @@ fn test_coords_warmup_counter_set_and_get() {
|
||||
remote.pubkey_full(),
|
||||
EndToEndState::Established(session),
|
||||
1000,
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(entry.coords_warmup_remaining(), 0);
|
||||
@@ -1361,6 +1371,7 @@ fn test_coords_warmup_counter_decrement() {
|
||||
remote.pubkey_full(),
|
||||
EndToEndState::Established(session),
|
||||
1000,
|
||||
true,
|
||||
);
|
||||
|
||||
entry.set_coords_warmup_remaining(3);
|
||||
|
||||
@@ -272,9 +272,9 @@ impl Disconnect {
|
||||
/// | 20 | dest_addr | 16 bytes | Destination node_addr |
|
||||
/// | 36 | payload | variable | Session-layer message |
|
||||
///
|
||||
/// The payload is either end-to-end encrypted (SessionSetup, SessionAck,
|
||||
/// DataPacket) or plaintext link-layer error signals (CoordsRequired,
|
||||
/// PathBroken) generated by transit routers.
|
||||
/// The payload is either end-to-end encrypted (handshake messages, data,
|
||||
/// reports) or plaintext error signals (CoordsRequired, PathBroken)
|
||||
/// generated by transit routers.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SessionDatagram {
|
||||
/// Source node address (originator of this datagram).
|
||||
@@ -526,7 +526,7 @@ mod tests {
|
||||
fn test_session_datagram_encode_decode() {
|
||||
let src = make_node_addr(0xAA);
|
||||
let dest = make_node_addr(0xBB);
|
||||
let payload = vec![0x10, 0x00, 0x05, 0x00, 1, 2, 3, 4, 5]; // DataPacket payload
|
||||
let payload = vec![0x10, 0x00, 0x05, 0x00, 1, 2, 3, 4, 5]; // session payload
|
||||
let dg = SessionDatagram::new(src, dest, payload.clone())
|
||||
.with_ttl(32);
|
||||
|
||||
|
||||
+5
-3
@@ -37,10 +37,12 @@ pub use tree::TreeAnnounce;
|
||||
pub use filter::FilterAnnounce;
|
||||
pub use discovery::{LookupRequest, LookupResponse};
|
||||
pub use session::{
|
||||
CoordsRequired, DataFlags, DataPacket, PathBroken, SessionAck, SessionFlags,
|
||||
SessionMessageType, SessionSetup, COORDS_REQUIRED_SIZE, DATA_FLAG_COORDS_PRESENT,
|
||||
DATA_HEADER_SIZE,
|
||||
CoordsRequired, FspFlags, FspInnerFlags, PathBroken, PathMtuNotification, SessionAck,
|
||||
SessionFlags, SessionMessageType, SessionReceiverReport, SessionSenderReport, SessionSetup,
|
||||
COORDS_REQUIRED_SIZE, PATH_MTU_NOTIFICATION_SIZE, SESSION_RECEIVER_REPORT_SIZE,
|
||||
SESSION_SENDER_REPORT_SIZE,
|
||||
};
|
||||
pub(crate) use session::{decode_optional_coords, encode_coords};
|
||||
|
||||
/// Protocol version for message compatibility.
|
||||
pub const PROTOCOL_VERSION: u8 = 1;
|
||||
|
||||
+617
-358
File diff suppressed because it is too large
Load Diff
+13
-5
@@ -62,13 +62,21 @@ const MAX_ORIGINAL_PACKET: usize = MIN_IPV6_MTU - IPV6_HEADER_LEN - ICMPV6_HEADE
|
||||
/// Total FIPS encapsulation overhead (worst case).
|
||||
///
|
||||
/// Breakdown:
|
||||
/// - Noise encryption tag: 16 bytes
|
||||
/// - Established frame header: 16 bytes (common prefix + receiver_idx + counter)
|
||||
/// - Inner header: 5 bytes (4-byte timestamp + 1-byte msg_type)
|
||||
/// - Noise encryption tag: 16 bytes (FLP link-layer AEAD)
|
||||
/// - FLP established frame header: 16 bytes (common prefix + receiver_idx + counter)
|
||||
/// - FLP inner header: 5 bytes (4-byte timestamp + 1-byte msg_type)
|
||||
/// - SessionDatagram fields: 35 bytes (ttl + path_mtu + src_addr + dest_addr)
|
||||
/// - DataPacket header: 12 bytes (msg_type + flags + counter + payload_len)
|
||||
/// - FSP header: 12 bytes (4-byte prefix + 8-byte counter)
|
||||
/// - FSP inner header: 6 bytes (4-byte timestamp + 1-byte msg_type + 1-byte inner_flags)
|
||||
/// - Session AEAD tag: 16 bytes (FSP session-layer AEAD)
|
||||
/// - Coordinates (worst case): ~60 bytes (2 coords with depth 3 each)
|
||||
pub const FIPS_OVERHEAD: u16 = 16 + 16 + 5 + 35 + 12 + 60; // 144 bytes
|
||||
///
|
||||
/// The old 12-byte session header is replaced by FSP header (12) + FSP inner
|
||||
/// header (6) + session AEAD tag (16) = 34 bytes, an increase of 22 bytes.
|
||||
/// However, the old overhead already accounted for the session AEAD tag in
|
||||
/// the "Noise encryption tag" line, and the old header included the counter.
|
||||
/// Net change: +6 bytes for FSP inner header.
|
||||
pub const FIPS_OVERHEAD: u16 = 16 + 16 + 5 + 35 + 12 + 6 + 60; // 150 bytes
|
||||
|
||||
/// Calculate the effective IPv6 MTU for FIPS-encapsulated traffic.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user