diff --git a/docs/design/fips-gossip-protocol.md b/docs/design/fips-gossip-protocol.md index f9c6e2c..36092cd 100644 --- a/docs/design/fips-gossip-protocol.md +++ b/docs/design/fips-gossip-protocol.md @@ -417,12 +417,13 @@ Within the link-layer message framing: | Type Code | Message | |-----------|---------| | 0x10 | TreeAnnounce | -| 0x11 | FilterAnnounce | -| 0x12 | LookupRequest | -| 0x13 | LookupResponse | +| 0x20 | FilterAnnounce | +| 0x30 | LookupRequest | +| 0x31 | LookupResponse | These are carried inside the encrypted link-layer payload after Noise IK -handshake completion. +handshake completion. See [fips-wire-protocol.md](fips-wire-protocol.md) §2.6 +for the full link message type table. --- @@ -556,13 +557,13 @@ ANCESTRY[3] - Root: Total payload: 1 + 1 + 8 + 8 + 16 + 2 + (4 × 32) + 64 = 228 bytes ``` -### A.2 FilterAnnounce (0x11) +### A.2 FilterAnnounce (0x20) Propagates Bloom filter reachability information. ```text ┌─────────────────────────────────────────────────────────────────────────────┐ -│ FILTER ANNOUNCE (0x11) │ +│ FILTER ANNOUNCE (0x20) │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌───────────────────────────────────────────────────────────────────────┐ │ @@ -570,7 +571,7 @@ Propagates Bloom filter reachability information. │ ├────────┬──────────────────┬───────────┬───────────────────────────────┤ │ │ │ Offset │ Field │ Size │ Description │ │ │ ├────────┼──────────────────┼───────────┼───────────────────────────────┤ │ -│ │ 0 │ msg_type │ 1 byte │ 0x11 │ │ +│ │ 0 │ msg_type │ 1 byte │ 0x20 │ │ │ │ 1 │ sequence │ 8 bytes │ u64 LE, monotonic counter │ │ │ │ 9 │ hash_count │ 1 byte │ Number of hash functions (5) │ │ │ │ 10 │ size_class │ 1 byte │ Filter size: 512 << class │ │ @@ -615,7 +616,7 @@ Propagates Bloom filter reachability information. ```text PLAINTEXT BYTES: -11 ← msg_type = FilterAnnounce +20 ← msg_type = FilterAnnounce 2A 00 00 00 00 00 00 00 ← sequence = 42 05 ← hash_count = 5 01 ← size_class = 1 (1 KB filter) @@ -624,13 +625,13 @@ PLAINTEXT BYTES: Total: 1035 bytes ``` -### A.3 LookupRequest (0x12) +### A.3 LookupRequest (0x30) Discovers tree coordinates for distant destinations. ```text ┌─────────────────────────────────────────────────────────────────────────────┐ -│ LOOKUP REQUEST (0x12) │ +│ LOOKUP REQUEST (0x30) │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌───────────────────────────────────────────────────────────────────────┐ │ @@ -638,7 +639,7 @@ Discovers tree coordinates for distant destinations. │ ├────────┬──────────────────┬───────────┬───────────────────────────────┤ │ │ │ Offset │ Field │ Size │ Description │ │ │ ├────────┼──────────────────┼───────────┼───────────────────────────────┤ │ -│ │ 0 │ msg_type │ 1 byte │ 0x12 │ │ +│ │ 0 │ msg_type │ 1 byte │ 0x30 │ │ │ │ 1 │ request_id │ 8 bytes │ u64 LE, unique identifier │ │ │ │ 9 │ target │ 16 bytes │ NodeAddr being searched for │ │ │ │ 25 │ origin │ 16 bytes │ NodeAddr of requester │ │ @@ -664,7 +665,7 @@ Discovers tree coordinates for distant destinations. ```text PLAINTEXT BYTES: -12 ← msg_type = LookupRequest +30 ← msg_type = LookupRequest [8 bytes request_id] ← random unique ID [16 bytes target node_addr] ← who we're looking for [16 bytes origin node_addr] ← who's asking @@ -677,13 +678,13 @@ PLAINTEXT BYTES: Total: 1 + 8 + 16 + 16 + 1 + 2 + 64 + 1 + 256 = 365 bytes ``` -### A.4 LookupResponse (0x13) +### A.4 LookupResponse (0x31) Returns target's coordinates to the requester. ```text ┌─────────────────────────────────────────────────────────────────────────────┐ -│ LOOKUP RESPONSE (0x13) │ +│ LOOKUP RESPONSE (0x31) │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌───────────────────────────────────────────────────────────────────────┐ │ @@ -691,7 +692,7 @@ Returns target's coordinates to the requester. │ ├────────┬──────────────────┬───────────┬───────────────────────────────┤ │ │ │ Offset │ Field │ Size │ Description │ │ │ ├────────┼──────────────────┼───────────┼───────────────────────────────┤ │ -│ │ 0 │ msg_type │ 1 byte │ 0x13 │ │ +│ │ 0 │ msg_type │ 1 byte │ 0x31 │ │ │ │ 1 │ request_id │ 8 bytes │ u64 LE, echoes request │ │ │ │ 9 │ target │ 16 bytes │ NodeAddr that was found │ │ │ │ 25 │ target_coords_cnt│ 2 bytes │ u16 LE │ │ @@ -717,7 +718,7 @@ Returns target's coordinates to the requester. ```text PLAINTEXT BYTES: -13 ← msg_type = LookupResponse +31 ← msg_type = LookupResponse [8 bytes request_id] ← echoed from request [16 bytes target node_addr] ← confirms who was found 05 00 ← target_coords_count = 5 @@ -746,7 +747,7 @@ Source S wants to reach distant destination D (not in local filters) │ ┌───────────┘ │ │ ▼ │ │ ┌──────┬───────────────────────────────────┐ │ - │ │ 0x12 │ LookupRequest payload │ │ + │ │ 0x30 │ LookupRequest payload │ │ │ │ │ (target=D, origin=S, ttl=8, ...) │ │ │ └──────┴───────────────────────────────────┘ │ └──────────────────────────────────────────────────────────────┘ @@ -765,7 +766,7 @@ Source S wants to reach distant destination D (not in local filters) │ ┌───────────┘ │ │ ▼ │ │ ┌──────┬───────────────────────────────────┐ │ - │ │ 0x13 │ LookupResponse payload │ │ + │ │ 0x31 │ LookupResponse payload │ │ │ │ │ (target=D, coords=[D,P1,P2,Root]) │ │ │ └──────┴───────────────────────────────────┘ │ └──────────────────────────────────────────────────────────────┘ diff --git a/docs/design/fips-intro.md b/docs/design/fips-intro.md index 6d6ac02..1646c4a 100644 --- a/docs/design/fips-intro.md +++ b/docs/design/fips-intro.md @@ -462,28 +462,40 @@ FIPS uses a discriminator-based wire format for efficient message dispatch. Exchanged between directly connected peers, encrypted with link session keys: -| Type | Name | Purpose | -|------|----------------|--------------------------------------------| -| 0x10 | TreeAnnounce | Spanning tree state (parent, ancestry) | -| 0x20 | FilterAnnounce | Bloom filter reachability update | -| 0x30 | LookupRequest | Query for node's tree coordinates | -| 0x31 | LookupResponse | Response with coordinates and proof | -| 0x40 | SessionDatagram| Carries end-to-end encrypted payloads | -| 0x50 | Disconnect | Orderly disconnect notification | +| Type | Name | Purpose | +|------|----------------|------------------------------------------------------| +| 0x10 | TreeAnnounce | Spanning tree state (parent, ancestry) | +| 0x20 | FilterAnnounce | Bloom filter reachability update | +| 0x30 | LookupRequest | Query for node's tree coordinates | +| 0x31 | LookupResponse | Response with coordinates and proof | +| 0x40 | SessionDatagram| Carries session-layer payloads through the mesh | +| 0x50 | Disconnect | Orderly disconnect notification | -### Session Layer Messages +### SessionDatagram Payload Messages -Carried inside SessionDatagram, encrypted end-to-end between source and -destination: +Carried inside SessionDatagram (0x40), which provides src_addr, dest_addr, and +hop_limit for multi-hop forwarding. + +**Session-layer messages** (end-to-end encrypted between source and destination): | Type | Name | Purpose | |------|----------------|--------------------------------------------| | 0x00 | SessionSetup | Establish routing session with coordinates | | 0x01 | SessionAck | Acknowledge session establishment | | 0x10 | DataPacket | Encrypted application data (IPv6 payload) | + +**Link-layer error signals** (plaintext, generated by transit routers): + +| Type | Name | Purpose | +|------|----------------|--------------------------------------------| | 0x20 | CoordsRequired | Router cache miss—need fresh coordinates | | 0x21 | PathBroken | Greedy routing failed—need re-lookup | +Error signals are sent back to the original source using the `src_addr` from +the SessionDatagram that triggered the error. They are plaintext because the +transit router has no end-to-end session with the source; link-layer encryption +protects them hop-by-hop. + See [fips-wire-protocol.md](fips-wire-protocol.md) for wire format details and [fips-gossip-protocol.md](fips-gossip-protocol.md) for gossip message formats. diff --git a/docs/design/fips-routing.md b/docs/design/fips-routing.md index ca55b22..bcabdde 100644 --- a/docs/design/fips-routing.md +++ b/docs/design/fips-routing.md @@ -418,16 +418,20 @@ No global routing tables. Each node makes purely local decisions. ### Privacy Considerations -Intermediate routers can observe `src_addr` and `dest_addr` in transiting packets. -This enables traffic analysis (who is communicating with whom) but not content -inspection (the payload is end-to-end encrypted with session keys). +Intermediate routers can observe `src_addr` and `dest_addr` in the +SessionDatagram envelope of transiting packets. This enables traffic analysis +(who is communicating with whom) but not content inspection (the payload is +end-to-end encrypted with session keys). -**Why source address is visible**: The source address is required for routers to -send error messages (CoordsRequired, PathBroken) back to the sender. This is a -deliberate design choice: rather than silently dropping unroutable packets and -relying on application-layer timeouts to detect failures, FIPS provides explicit -feedback that allows rapid route recovery. The tradeoff favors responsiveness -over metadata privacy. +**Why source address is visible**: The `src_addr` field in the SessionDatagram +is required for transit routers to send error signals (CoordsRequired, +PathBroken) back to the sender. When a transit router R cannot forward a +SessionDatagram `{src: S, dest: D}`, it creates a new SessionDatagram +`{src: R, dest: S}` carrying the error signal, and routes it toward S using +`find_next_hop(S)`. This is a deliberate design choice: rather than silently +dropping unroutable packets and relying on application-layer timeouts to detect +failures, FIPS provides explicit feedback that allows rapid route recovery. +The tradeoff favors responsiveness over metadata privacy. **Partial mitigation**: FIPS addresses are derived from `SHA-256(pubkey)`, not the npub itself. An observer learns that `fd12:3456:...` is communicating with @@ -455,8 +459,9 @@ The coordinate cache serves two functions: 1. **Greedy routing fallback** — when bloom filters haven't converged, cached coordinates enable tree-distance-based forwarding. 2. **Reduced packet overhead** — once coordinates are cached at intermediate - routers, data packets can carry addresses only (38 bytes) rather than - full coordinates (~300 bytes). + routers, data packets can use minimal DataPacket headers (4 bytes inside + a 34-byte SessionDatagram = 38 bytes total) rather than including full + coordinates (~170 bytes total). ### Cache Lifecycle @@ -472,84 +477,101 @@ The coordinate cache serves two functions: ### Session Setup Flow +All messages are carried inside SessionDatagram envelopes that provide +`src_addr`, `dest_addr`, and `hop_limit` at the link layer. + ```text S R1 R2 D │ │ │ │ -│──SessionSetup─────────>│ │ │ -│ (src_coords, │──SessionSetup────────>│ │ -│ dest_coords) │ │──SessionSetup────────>│ -│ │ │ │ -│ │ cache: │ cache: │ +│──SessionDatagram──────>│ │ │ +│ {src:S, dest:D, │──SessionDatagram─────>│ │ +│ payload:SessionSetup │ │──SessionDatagram─────>│ +│ (src_coords, │ │ │ +│ dest_coords)} │ cache: │ cache: │ │ │ dest_addr→dest_coords│ dest_addr→dest_coords│ │ │ src_addr→src_coords │ src_addr→src_coords │ │ │ │ │ -│<─────────────────────────────────────────────────────────SessionAck───│ +│<──────────────────────────────────────────SessionDatagram{SessionAck}──│ │ │ │ │ -│══DataPacket═══════════>│══════════════════════>│══════════════════════>│ -│ (addresses only) │ (use cached coords) │ (use cached coords) │ +│══SessionDatagram══════>│══════════════════════>│══════════════════════>│ +│ {src:S, dest:D, │ (use cached coords) │ (use cached coords) │ +│ payload:DataPacket} │ │ │ ``` ### Router Behavior +Transit routers process SessionDatagram envelopes. The envelope provides +`src_addr` and `dest_addr` for routing decisions and error signaling. + ```rust impl Router { - fn handle_session_setup(&mut self, setup: SessionSetup, from: PeerId) { + /// Handle a SessionDatagram carrying a SessionSetup payload. + /// Cache coordinates from the setup message for both directions. + fn handle_session_setup(&mut self, dg: &SessionDatagram, setup: SessionSetup) { // Cache coordinates for both directions - self.coord_cache.insert(setup.dest_addr, CacheEntry { + self.coord_cache.insert(dg.dest_addr, CacheEntry { coords: setup.dest_coords.clone(), expires: now() + CACHE_TTL, }); - self.coord_cache.insert(setup.src_addr, CacheEntry { + self.coord_cache.insert(dg.src_addr, CacheEntry { coords: setup.src_coords.clone(), expires: now() + CACHE_TTL, }); - // Forward toward destination - let next = self.greedy_next_hop(&setup.dest_coords); - self.forward(next, setup); + // Forward toward destination using find_next_hop + if let Some(next) = self.find_next_hop(&dg.dest_addr) { + self.forward(next, dg); + } } - fn handle_data_packet(&mut self, packet: DataPacket, from: PeerId) { + /// Handle a SessionDatagram carrying a DataPacket payload. + /// Addresses come from the SessionDatagram envelope (dg.src_addr, dg.dest_addr). + fn handle_data_packet(&mut self, dg: &SessionDatagram, packet: DataPacket) { // If packet carries coordinates, cache them if packet.flags & COORDS_PRESENT != 0 { if let (Some(src_coords), Some(dest_coords)) = (&packet.src_coords, &packet.dest_coords) { - self.coord_cache.insert(packet.dest_addr, CacheEntry { + self.coord_cache.insert(dg.dest_addr, CacheEntry { coords: dest_coords.clone(), expires: now() + CACHE_TTL, }); - self.coord_cache.insert(packet.src_addr, CacheEntry { + self.coord_cache.insert(dg.src_addr, CacheEntry { coords: src_coords.clone(), expires: now() + CACHE_TTL, }); } } - // Primary: bloom filter routing - let candidates = self.destination_in_filters(&packet.dest_addr); - if !candidates.is_empty() { - let next = self.select_best_candidate(&candidates); - self.forward(next, packet); - return; - } - - // Fallback: greedy tree routing via cached coordinates - match self.coord_cache.get(&packet.dest_addr) { - Some(entry) => { - entry.last_used = now(); - let next = self.greedy_next_hop(&entry.coords); - self.forward(next, packet); - } + // Route using find_next_hop (bloom filter → greedy tree → None) + match self.find_next_hop(&dg.dest_addr) { + Some(next) => self.forward(next, dg), None => { - // No bloom filter hit and no cached coordinates - self.send_error(from, CoordsRequired { - dest_addr: packet.dest_addr, + // Cannot route — send error back to source via src_addr + self.send_error_to_source(dg, CoordsRequired { + dest_addr: dg.dest_addr, reporter: self.node_addr, }); } } } + + /// Send an error signal back to the source of a SessionDatagram. + /// Creates a new SessionDatagram addressed to dg.src_addr. + fn send_error_to_source(&self, dg: &SessionDatagram, error: impl LinkError) { + let error_dg = SessionDatagram { + src_addr: self.node_addr, // We are the reporter + dest_addr: dg.src_addr, // Route back to original source + hop_limit: 64, + payload: error.encode(), // CoordsRequired or PathBroken + }; + // Route the error — if we can't reach source either, drop silently + if let Some(next) = self.find_next_hop(&dg.src_addr) { + self.forward(next, &error_dg); + } + // If find_next_hop returns None for source: drop silently. + // No cascading errors. + } } ``` @@ -557,7 +579,7 @@ impl Router { ```rust struct CoordCache { - entries: HashMap, + entries: HashMap, max_entries: usize, } @@ -583,22 +605,28 @@ by: When a router's cache entry is evicted mid-session: ```text -1. Data packet arrives (minimal header), cache miss -2. Router sends CoordsRequired to packet source -3. Source marks route as cold -4. Source resends with COORDS_PRESENT flag set -5. Router caches coordinates from packet, forwards -6. After N successful packets, source clears flag +1. SessionDatagram{src:S, dest:D, payload:DataPacket} arrives, router can't route +2. Router R creates SessionDatagram{src:R, dest:S, payload:CoordsRequired{dest:D}} +3. Router routes the error back to S using find_next_hop(S) +4. S receives CoordsRequired, marks route to D as "cold" +5. S resends with COORDS_PRESENT flag set in DataPacket +6. Routers cache coordinates from DataPacket, forward normally +7. After N successful packets, S clears the flag ``` The crypto session remains active throughout—only routing state is refreshed. From application perspective: one packet delayed, transparent recovery. +If the router also cannot route to S (no bloom filter hit, no cached +coordinates for S), the error is dropped silently. No cascading errors are +generated. The source will eventually detect the loss via application-layer +timeout. + ### Sender State Machine ```rust impl Sender { - fn send(&mut self, dest: Ipv6Addr, data: &[u8]) { + fn send(&mut self, dest: NodeAddr, data: &[u8]) { if !self.session_established(dest) { // Need to establish crypto session first let dest_coords = self.discover_or_cached(dest)?; @@ -608,9 +636,20 @@ impl Sender { // Check route state let include_coords = self.route_state(dest) == RouteCold; - self.send_data_packet(dest, data, include_coords); + let data_packet = DataPacket::new(data, include_coords); + + // Wrap in SessionDatagram for forwarding + let dg = SessionDatagram { + src_addr: self.node_addr, + dest_addr: dest, + hop_limit: 64, + payload: data_packet.encode(), + }; + self.forward_datagram(dg); } + /// Handle CoordsRequired received inside a SessionDatagram addressed to us. + /// The SessionDatagram.src_addr identifies the reporting router (informational). fn handle_coords_required(&mut self, err: CoordsRequired) { // Route cache expired at intermediate router // Crypto session still valid - just need to re-warm route @@ -629,16 +668,21 @@ enum RouteState { ## Part 5: Packet Type Summary +All session-layer messages and error signals are carried inside a +SessionDatagram envelope (34 bytes: msg_type + src_addr + dest_addr + +hop_limit). Sizes below include the SessionDatagram header. + | Type | Purpose | Size | When Used | |------|---------|------|-----------| | FilterAnnounce | Bloom filter propagation | ~1 KB | Topology changes | | LookupRequest | Discover coordinates | ~300 bytes | First contact with distant node | | LookupResponse | Return coordinates | ~400 bytes | Reply to discovery | -| SessionSetup | Warm router caches + crypto init | ~400-700 bytes | Before data transfer | -| SessionAck | Confirm session + crypto response | ~300-500 bytes | Session confirmation | -| DataPacket | Application data | 36 bytes + payload (minimal) | Bulk of traffic | -| DataPacket | With coordinates | ~300-500 bytes + payload | After CoordsRequired | -| CoordsRequired | Request coords in next packet | ~50 bytes | Cache miss recovery | +| SessionDatagram+SessionSetup | Warm caches + crypto init | ~230-400 bytes | Before data transfer | +| SessionDatagram+SessionAck | Confirm session + crypto | ~100-200 bytes | Session confirmation | +| SessionDatagram+DataPacket | Application data | 38 bytes + payload (minimal) | Bulk of traffic | +| SessionDatagram+DataPacket | With coordinates | ~170 bytes + payload | After CoordsRequired | +| SessionDatagram+CoordsRequired | Coords needed signal | 68 bytes | Cache miss recovery | +| SessionDatagram+PathBroken | Routing failed signal | 68+ bytes | Greedy routing local minimum | > **Note**: SessionSetup/SessionAck sizes vary based on coordinate depth and > whether they carry crypto handshake payloads (combined establishment per diff --git a/docs/design/fips-session-protocol.md b/docs/design/fips-session-protocol.md index 16d7871..f0b79be 100644 --- a/docs/design/fips-session-protocol.md +++ b/docs/design/fips-session-protocol.md @@ -423,26 +423,39 @@ handshakes succeed regardless of the responder's actual key parity. See ### 6.4 Handshake Integration with SessionSetup -The Noise handshake messages embed in SessionSetup/SessionAck: +The Noise handshake messages embed in SessionSetup/SessionAck, which are +carried inside a SessionDatagram envelope that provides addressing: ```text -SessionSetup { - // Routing portion (processed by routers) - src_coords: Vec, - dest_coords: Vec, - src_addr: Ipv6Addr, - dest_addr: Ipv6Addr, +SessionDatagram { + // Addressing (used by routers for forwarding and error routing) + src_addr: NodeAddr, + dest_addr: NodeAddr, + hop_limit: u8, - // Crypto portion (opaque to routers, processed by destination) - handshake_payload: Vec, // Noise IK message 1 + // Payload: SessionSetup + payload: SessionSetup { + // Routing portion (processed by routers for cache warming) + src_coords: Vec, + dest_coords: Vec, + + // Crypto portion (opaque to routers, processed by destination) + handshake_payload: Vec, // Noise IK message 1 + } } -SessionAck { - // Routing portion - src_coords: Vec, // Responder's coordinates +SessionDatagram { + src_addr: NodeAddr, // Responder + dest_addr: NodeAddr, // Original sender + hop_limit: u8, - // Crypto portion - handshake_payload: Vec, // Noise IK message 2 + payload: SessionAck { + // Routing portion + src_coords: Vec, // Responder's coordinates + + // Crypto portion + handshake_payload: Vec, // Noise IK message 2 + } } ``` @@ -529,44 +542,97 @@ for TreeAnnounce and FilterAnnounce wire formats. ## 8. Session Layer Wire Format Session layer messages are carried inside `SessionDatagram` (type 0x40) at the -link layer. The session datagram is encrypted hop-by-hop with link keys, but -the inner payload is encrypted end-to-end with session keys. +link layer. The session datagram provides source and destination addressing for +multi-hop forwarding: it is encrypted hop-by-hop with link keys, and the inner +payload is either encrypted end-to-end with session keys (for data traffic) or +plaintext (for link-layer error signals from transit routers). -### 8.1 Message Type Codes +### 8.0 SessionDatagram Envelope + +The SessionDatagram is a link-layer message (type 0x40) that carries all +routable traffic through the mesh. It provides the source and destination +addresses that transit routers use for forwarding decisions. + +```text +┌─────────────────────────────────────────────────────────────────────────────┐ +│ SESSION DATAGRAM (0x40) │ +├────────┬──────────────────┬───────────┬─────────────────────────────────────┤ +│ Offset │ Field │ Size │ Description │ +├────────┼──────────────────┼───────────┼─────────────────────────────────────┤ +│ 0 │ msg_type │ 1 byte │ 0x40 (link-layer SessionDatagram) │ +│ 1 │ src_addr │ 16 bytes │ Source node_addr │ +│ 17 │ dest_addr │ 16 bytes │ Destination node_addr │ +│ 33 │ hop_limit │ 1 byte │ Decremented each hop │ +│ 34 │ payload │ variable │ Session-layer message (see §8.1) │ +└────────┴──────────────────┴───────────┴─────────────────────────────────────┘ + +Fixed header: 34 bytes +``` + +**src_addr**: The originator of the datagram. For data traffic, this is the +source endpoint. For error signals (CoordsRequired, PathBroken), this is the +transit router that generated the error. Transit routers use `src_addr` to +route error signals back to the packet's originator. + +**dest_addr**: The intended recipient. Transit routers use this for next-hop +selection via `find_next_hop()`. + +**hop_limit**: Decremented at each forwarding hop. When it reaches zero, the +datagram is dropped silently. This prevents infinite forwarding loops. Initial +value is configurable (default 64). + +### 8.1 Payload Message Types + +The SessionDatagram payload begins with a message type byte, followed by the +message-specific content. + +**Session-layer messages** (end-to-end encrypted with session keys): | Type Code | Message | Direction | Purpose | |-----------|----------------|-----------|-----------------------------------| | 0x00 | SessionSetup | S → D | Establish session + warm caches | | 0x01 | SessionAck | D → S | Confirm session establishment | | 0x10 | DataPacket | Both | Application data | + +**Link-layer error signals** (plaintext, generated by transit routers): + +| Type Code | Message | Direction | Purpose | +|-----------|----------------|-----------|-----------------------------------| | 0x20 | CoordsRequired | R → S | Router cache miss | | 0x21 | PathBroken | R → S | Greedy routing failed | -> **Address terminology**: The `src_addr` and `dest_addr` fields in session packet -> headers are node_addrs (16-byte truncated SHA-256 hashes of pubkeys). These are visible to -> intermediate routers for routing decisions. The actual FIPS addresses (pubkeys/npubs) -> are exchanged only during the Noise IK handshake and never appear in packet -> headers—routers cannot determine endpoint identities from the node_addrs they see. +Error signals are generated by transit router R and sent back to the source S +inside a *new* SessionDatagram with `src_addr=R, dest_addr=S`. They are +plaintext (not end-to-end encrypted) because the transit router has no session +with the source. Link-layer encryption protects them hop-by-hop. + +> **Address terminology**: The `src_addr` and `dest_addr` fields in the +> SessionDatagram header are node_addrs (16-byte truncated SHA-256 hashes of +> pubkeys). These are visible to intermediate routers for routing decisions. The +> actual FIPS addresses (pubkeys/npubs) are exchanged only during the Noise IK +> handshake and never appear in packet headers—routers cannot determine endpoint +> identities from the node_addrs they see. ### 8.2 SessionSetup (0x00) Establishes a crypto session and warms router coordinate caches along the path. +The `src_addr` and `dest_addr` are carried in the enclosing SessionDatagram +envelope (§8.0); the SessionSetup payload carries only coordinates and the +Noise handshake. ```text ┌─────────────────────────────────────────────────────────────────────────────┐ -│ SESSION SETUP PACKET │ +│ SESSION SETUP PAYLOAD (inside SessionDatagram) │ ├────────┬──────────────────┬───────────┬─────────────────────────────────────┤ │ Offset │ Field │ Size │ Description │ ├────────┼──────────────────┼───────────┼─────────────────────────────────────┤ │ 0 │ msg_type │ 1 byte │ 0x00 │ │ 1 │ flags │ 1 byte │ Bit 0: REQUEST_ACK │ │ │ │ │ Bit 1: BIDIRECTIONAL │ -│ 2 │ src_addr │ 16 bytes │ Source node_addr │ -│ 18 │ dest_addr │ 16 bytes │ Destination node_addr │ -│ 34 │ src_coords_count │ 2 bytes │ u16 LE, number of src coord entries │ -│ 36 │ src_coords │ 16 × n │ NodeAddr array (self → root) │ +│ 2 │ src_coords_count │ 2 bytes │ u16 LE, number of src coord entries │ +│ 4 │ src_coords │ 16 × n │ NodeAddr array (self → root) │ │ ... │ dest_coords_count│ 2 bytes │ u16 LE, number of dest coord entries│ -│ ... │ dest_coords │ 16 × m │ NodeAddr array (dest → root) │ +│ ... │ dest_coords │ 16 × m │ NodeAddr array (dest → root) │ │ ... │ handshake_len │ 2 bytes │ u16 LE, Noise payload length │ │ ... │ handshake_payload│ variable │ Noise IK msg1 (82 bytes typical) │ └────────┴──────────────────┴───────────┴─────────────────────────────────────┘ @@ -575,33 +641,32 @@ Establishes a crypto session and warms router coordinate caches along the path. **Example** (depth 3 source, depth 4 destination, with Noise handshake): ```text -┌──────┬───────┬──────────────────┬──────────────────┬───────┬─────────────┐ -│ 0x00 │ 0x01 │ src_addr │ dest_addr │ 0x03 │ src_coords │ -│ type │ flags │ 16 bytes │ 16 bytes │ count │ 3 × 16 bytes│ -├──────┴───────┴──────────────────┴──────────────────┴───────┴─────────────┤ -│ 0x04 │ dest_coords │ 0x52 │ handshake_payload │ -│ count │ 4 × 16 bytes │ len=82│ 82 bytes (Noise IK msg1) │ -└───────┴───────────────┴───────┴──────────────────────────────────────────┘ +SessionDatagram { src: S, dest: D, hop_limit: 64 } + +┌──────┬───────┬───────┬─────────────┬───────┬───────────────┬───────┬───────┐ +│ 0x00 │ 0x01 │ 0x03 │ src_coords │ 0x04 │ dest_coords │ 0x52 │ noise │ +│ type │ flags │ count │ 3 × 16 bytes│ count │ 4 × 16 bytes │ len=82│ msg1 │ +└──────┴───────┴───────┴─────────────┴───────┴───────────────┴───────┴───────┘ -Total: 1 + 1 + 16 + 16 + 2 + 48 + 2 + 64 + 2 + 82 = 234 bytes +SessionDatagram header: 34 bytes +SessionSetup payload: 1 + 1 + 2 + 48 + 2 + 64 + 2 + 82 = 202 bytes +Total: 236 bytes ``` ### 8.3 SessionAck (0x01) -Confirms session establishment and completes the Noise handshake. +Confirms session establishment and completes the Noise handshake. Addressing +is in the enclosing SessionDatagram envelope. ```text ┌─────────────────────────────────────────────────────────────────────────────┐ -│ SESSION ACK PACKET │ +│ SESSION ACK PAYLOAD (inside SessionDatagram) │ ├────────┬──────────────────┬───────────┬─────────────────────────────────────┤ │ Offset │ Field │ Size │ Description │ ├────────┼──────────────────┼───────────┼─────────────────────────────────────┤ │ 0 │ msg_type │ 1 byte │ 0x01 │ │ 1 │ flags │ 1 byte │ Reserved │ -│ 2 │ src_addr │ 16 bytes │ Acknowledger's node_addr │ -│ 18 │ dest_addr │ 16 bytes │ Original sender's node_addr │ -│ 34 │ src_coords_count │ 2 bytes │ u16 LE │ -│ 36 │ src_coords │ 16 × n │ Acknowledger's coords (for caching) │ +│ 2 │ src_coords_count │ 2 bytes │ u16 LE │ +│ 4 │ src_coords │ 16 × n │ Acknowledger's coords (for caching) │ │ ... │ handshake_len │ 2 bytes │ u16 LE, Noise payload length │ │ ... │ handshake_payload│ variable │ Noise IK msg2 (33 bytes typical) │ └────────┴──────────────────┴───────────┴─────────────────────────────────────┘ @@ -609,79 +674,90 @@ Confirms session establishment and completes the Noise handshake. ### 8.4 DataPacket (0x10) -Carries encrypted application data (typically IPv6 payloads). +Carries encrypted application data (typically IPv6 payloads). Addressing and +hop limit are in the enclosing SessionDatagram envelope. ```text ┌─────────────────────────────────────────────────────────────────────────────┐ -│ DATA PACKET (Minimal Header) │ +│ DATA PACKET (Minimal Header, inside SessionDatagram) │ ├────────┬──────────────────┬───────────┬─────────────────────────────────────┤ │ Offset │ Field │ Size │ Description │ ├────────┼──────────────────┼───────────┼─────────────────────────────────────┤ │ 0 │ msg_type │ 1 byte │ 0x10 │ │ 1 │ flags │ 1 byte │ Bit 0: COORDS_PRESENT │ -│ 2 │ hop_limit │ 1 byte │ Decremented each hop │ -│ 3 │ reserved │ 1 byte │ Alignment padding │ -│ 4 │ payload_length │ 2 bytes │ u16 LE │ -│ 6 │ src_addr │ 16 bytes │ Source node_addr │ -│ 22 │ dest_addr │ 16 bytes │ Destination node_addr │ -│ 38 │ payload │ variable │ Encrypted application data │ +│ 2 │ payload_length │ 2 bytes │ u16 LE │ +│ 4 │ payload │ variable │ Encrypted application data │ └────────┴──────────────────┴───────────┴─────────────────────────────────────┘ -Minimal header: 38 bytes +Minimal header: 4 bytes (+ 34 SessionDatagram header = 38 bytes total) ``` When `COORDS_PRESENT` flag is set (route warming after CoordsRequired): ```text ┌─────────────────────────────────────────────────────────────────────────────┐ -│ DATA PACKET (With Coordinates) │ +│ DATA PACKET (With Coordinates, inside SessionDatagram) │ ├────────┬──────────────────┬───────────┬─────────────────────────────────────┤ │ Offset │ Field │ Size │ Description │ ├────────┼──────────────────┼───────────┼─────────────────────────────────────┤ │ 0 │ msg_type │ 1 byte │ 0x10 │ │ 1 │ flags │ 1 byte │ 0x01 (COORDS_PRESENT) │ -│ 2 │ hop_limit │ 1 byte │ Decremented each hop │ -│ 3 │ reserved │ 1 byte │ Alignment padding │ -│ 4 │ payload_length │ 2 bytes │ u16 LE │ -│ 6 │ src_addr │ 16 bytes │ Source node_addr │ -│ 22 │ dest_addr │ 16 bytes │ Destination node_addr │ -│ 38 │ src_coords_count │ 2 bytes │ u16 LE │ -│ 40 │ src_coords │ 16 × n │ Source coordinates │ +│ 2 │ payload_length │ 2 bytes │ u16 LE │ +│ 4 │ src_coords_count │ 2 bytes │ u16 LE │ +│ 6 │ src_coords │ 16 × n │ Source coordinates │ │ ... │ dest_coords_count│ 2 bytes │ u16 LE │ │ ... │ dest_coords │ 16 × m │ Destination coordinates │ │ ... │ payload │ variable │ Encrypted application data │ └────────┴──────────────────┴───────────┴─────────────────────────────────────┘ -With depth-4 coords both directions: 38 + 2 + 64 + 2 + 64 = 170 bytes header +With depth-4 coords both directions: + DataPacket: 4 + 2 + 64 + 2 + 64 = 136 bytes header + + SessionDatagram: 34 bytes + Total: 170 bytes header ``` ### 8.5 CoordsRequired (0x20) -Sent by an intermediate router when it cannot forward a DataPacket due to -coordinate cache miss. +Sent by an intermediate router when it cannot forward a SessionDatagram due to +coordinate cache miss for the destination. This is a **link-layer error signal**: +the transit router generates a new SessionDatagram addressed back to the +original source, with the CoordsRequired payload in plaintext (not end-to-end +encrypted, since the transit router has no session with the source). + +**Error routing**: Router R receives a SessionDatagram `{src: S, dest: D}` but +cannot route to D. R creates a new SessionDatagram `{src: R, dest: S}` carrying +a CoordsRequired payload, and calls `find_next_hop(S)` to route it back to S. +If R also cannot route to S, the error is dropped silently (no cascading errors). + +**CoordsRequired payload** (inside SessionDatagram): ```text ┌─────────────────────────────────────────────────────────────────────────────┐ -│ COORDS REQUIRED PACKET │ +│ COORDS REQUIRED PAYLOAD │ ├────────┬──────────────────┬───────────┬─────────────────────────────────────┤ │ Offset │ Field │ Size │ Description │ ├────────┼──────────────────┼───────────┼─────────────────────────────────────┤ │ 0 │ msg_type │ 1 byte │ 0x20 │ │ 1 │ flags │ 1 byte │ Reserved │ -│ 2 │ dest_addr │ 16 bytes │ The node_addr we couldn't route │ +│ 2 │ dest_addr │ 16 bytes │ The node_addr we couldn't route to │ │ 18 │ reporter │ 16 bytes │ NodeAddr of reporting router │ └────────┴──────────────────┴───────────┴─────────────────────────────────────┘ -Total: 34 bytes +Payload: 34 bytes +Wrapped in SessionDatagram: 34 + 34 = 68 bytes total ``` ### 8.6 PathBroken (0x21) -Sent when greedy routing fails (no peer is closer to destination). +Sent when greedy routing fails (no peer is closer to destination). Like +CoordsRequired, this is a **link-layer error signal** carried inside a new +SessionDatagram addressed back to the original source. + +**PathBroken payload** (inside SessionDatagram): ```text ┌─────────────────────────────────────────────────────────────────────────────┐ -│ PATH BROKEN PACKET │ +│ PATH BROKEN PAYLOAD │ ├────────┬──────────────────┬───────────┬─────────────────────────────────────┤ │ Offset │ Field │ Size │ Description │ ├────────┼──────────────────┼───────────┼─────────────────────────────────────┤ @@ -714,34 +790,45 @@ A DataPacket from source S to destination D, transiting router R: │ │ Decrypt with S↔R link keys │ │ ▼ │ │ ┌───────────────────────────────────────────────────────────────────────┐ │ -│ │ LINK MESSAGE (plaintext for R) │ │ -│ ├───────────┬───────────────────────────────────────────────────────────┤ │ -│ │ 0x40 │ SessionDatagram payload │ │ -│ │ msg_type │ (routable by R, encrypted end-to-end) │ │ -│ └───────────┴───────────────────────────────────────────────────────────┘ │ +│ │ SESSION DATAGRAM (0x40) — plaintext for R │ │ +│ ├───────────┬──────────────┬──────────────┬──────────┬──────────────────┤ │ +│ │ 0x40 │ src_addr │ dest_addr │ hop_limit│ payload │ │ +│ │ msg_type │ S (16 bytes) │ D (16 bytes) │ 64 │ (session msg) │ │ +│ └───────────┴──────────────┴──────────────┴──────────┴──────────────────┘ │ │ │ │ +│ │ R reads src_addr + dest_addr for routing │ │ ▼ │ │ ┌───────────────────────────────────────────────────────────────────────┐ │ -│ │ SESSION LAYER (S↔D encrypted) │ │ +│ │ SESSION LAYER PAYLOAD (S↔D encrypted) │ │ │ ├───────────┬───────┬──────────┬──────────┬─────────────────────────────┤ │ -│ │ 0x10 │ flags │ hop_limit│ pay_len │ src_addr │ dest_addr │ │ -│ │ DataPacket│ 0x00 │ 64 │ 1400 │ 16 bytes │ 16 bytes │ │ -│ ├───────────┴───────┴──────────┴──────────┴──────────────┴──────────────┤ │ -│ │ │ │ -│ │ ENCRYPTED PAYLOAD (S↔D session keys) │ │ -│ │ ┌─────────────────────────────────────────────┐ │ │ -│ │ │ IPv6 packet or application data │ │ │ -│ │ │ (+ 16-byte AEAD tag) │ │ │ -│ │ └─────────────────────────────────────────────┘ │ │ -│ │ │ │ -│ └───────────────────────────────────────────────────────────────────────┘ │ +│ │ 0x10 │ flags │ reserved │ pay_len │ encrypted application data │ │ +│ │ DataPacket│ 0x00 │ 0x00 │ 1400 │ (S↔D session keys + tag) │ │ +│ └───────────┴───────┴──────────┴──────────┴─────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ -Router R can see: dest_addr (for routing decision) -Router R cannot see: payload contents (encrypted with S↔D keys) +Router R can see: src_addr, dest_addr, hop_limit (for routing/error signaling) +Router R cannot see: payload contents (encrypted with S↔D session keys) ``` +### 8.7.1 Error Routing Example + +When router R cannot forward a SessionDatagram from S to D: + +```text +1. R receives SessionDatagram { src: S, dest: D, payload: DataPacket{...} } +2. R has no cached coordinates for D and no bloom filter hit +3. R creates NEW SessionDatagram: + { src: R, dest: S, hop_limit: 64, + payload: CoordsRequired { dest: D, reporter: R } } +4. R calls find_next_hop(S) to route the error back +5. If R can also not route to S: drop silently (no cascading errors) +``` + +This avoids the need for transit routers to have end-to-end sessions with +the source. The `src_addr` field in the original SessionDatagram tells the +transit router where to send the error. + ### 8.8 Encoding Rules - All multi-byte integers are **little-endian** diff --git a/docs/design/fips-wire-protocol.md b/docs/design/fips-wire-protocol.md index cdd770a..f35e676 100644 --- a/docs/design/fips-wire-protocol.md +++ b/docs/design/fips-wire-protocol.md @@ -892,11 +892,16 @@ Post-handshake data packets between authenticated peers. │ │ 1 │ payload │ variable │ Message-specific payload │ │ │ └────────┴──────────────────┴───────────┴───────────────────────────────┘ │ │ │ -│ Link message types: │ +│ Link message types (inside encrypted frame): │ │ 0x10 = TreeAnnounce 0x30 = LookupRequest │ │ 0x20 = FilterAnnounce 0x31 = LookupResponse │ │ 0x40 = SessionDatagram 0x50 = Disconnect │ │ │ +│ SessionDatagram (0x40) carries session-layer payloads: │ +│ 0x00 = SessionSetup 0x10 = DataPacket │ +│ 0x01 = SessionAck 0x20 = CoordsRequired │ +│ 0x21 = PathBroken │ +│ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` diff --git a/src/node/mod.rs b/src/node/mod.rs index 3dac75f..10f0012 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -713,16 +713,19 @@ impl Node { /// Routing priority: /// 1. Destination is self → `None` (local delivery) /// 2. Destination is a direct peer → that peer - /// 3. Bloom filter candidates + greedy tree routing → among peers whose + /// 3. Bloom filter candidates with cached dest coords → among peers whose /// bloom filter contains the destination, pick the one that minimizes - /// tree distance to the destination (if dest coords are cached), with + /// tree distance to the destination, with /// `(link_cost, tree_distance_to_dest, node_addr)` tie-breaking. - /// Falls back to greedy tree routing if no bloom filter hits. - /// 4. No route → `None` + /// The self-distance check ensures only peers strictly closer to the + /// destination than us are considered (prevents routing loops). + /// 4. Greedy tree routing fallback (requires cached dest coords) + /// 5. No route → `None` /// - /// The self-distance check from greedy routing also applies to bloom - /// filter candidates: a peer is only selected if it is strictly closer - /// to the destination than we are (prevents routing loops). + /// Both the bloom filter and tree routing paths require cached destination + /// coordinates. Without coordinates, the node cannot make loop-free + /// forwarding decisions. The caller should signal `CoordsRequired` back + /// to the source when `None` is returned for a non-local destination. pub fn find_next_hop(&self, dest_node_addr: &NodeAddr) -> Option<&ActivePeer> { // 1. Local delivery if dest_node_addr == self.node_addr() { @@ -736,21 +739,20 @@ impl Node { } } - // Look up destination coords (used by both bloom and tree paths) + // Look up destination coords (required by both bloom and tree paths) let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); - let dest_coords = self.coord_cache.get(dest_node_addr, now_ms); + let dest_coords = self.coord_cache.get(dest_node_addr, now_ms)?; - // 3. Bloom filter candidates, scored by tree distance to dest + // 3. Bloom filter candidates — requires dest_coords for loop-free selection let candidates: Vec<&ActivePeer> = self.destination_in_filters(dest_node_addr); if !candidates.is_empty() { return self.select_best_candidate(&candidates, dest_coords); } // 4. Greedy tree routing fallback - let dest_coords = dest_coords?; let next_hop_id = self.tree_state.find_next_hop(dest_coords)?; self.peers.get(&next_hop_id).filter(|p| p.can_send()) @@ -758,22 +760,18 @@ impl Node { /// Select the best peer from a set of bloom filter candidates. /// - /// When dest_coords are available, uses distance from each candidate's - /// coordinates to the destination as the primary metric (after link_cost). - /// Only selects peers that are strictly closer to the destination than - /// we are (self-distance check prevents loops). - /// - /// When dest_coords are not available, falls back to distance from us - /// to the candidate peer (a weaker heuristic — prefers closer peers on - /// the theory that shorter paths are better). + /// Uses distance from each candidate's tree coordinates to the destination + /// as the primary metric (after link_cost). Only selects peers that are + /// strictly closer to the destination than we are (self-distance check + /// prevents routing loops). /// /// Ordering: `(link_cost, distance_to_dest, node_addr)`. fn select_best_candidate<'a>( &'a self, candidates: &[&'a ActivePeer], - dest_coords: Option<&crate::tree::TreeCoordinate>, + dest_coords: &crate::tree::TreeCoordinate, ) -> Option<&'a ActivePeer> { - let my_distance = dest_coords.map(|dc| self.tree_state.my_coords().distance_to(dc)); + let my_distance = self.tree_state.my_coords().distance_to(dest_coords); let mut best: Option<(&ActivePeer, f64, usize)> = None; @@ -784,25 +782,16 @@ impl Node { let cost = candidate.link_cost(); - // Compute distance: peer→dest if coords available, else us→peer - let dist = match dest_coords { - Some(dc) => self - .tree_state - .peer_coords(candidate.node_addr()) - .map(|pc| pc.distance_to(dc)) - .unwrap_or(usize::MAX), - None => self - .tree_state - .distance_to_peer(candidate.node_addr()) - .unwrap_or(usize::MAX), - }; + let dist = self + .tree_state + .peer_coords(candidate.node_addr()) + .map(|pc| pc.distance_to(dest_coords)) + .unwrap_or(usize::MAX); - // Self-distance check: when dest coords are available, - // only consider peers that are strictly closer than us - if let Some(my_dist) = my_distance { - if dist >= my_dist { - continue; - } + // Self-distance check: only consider peers strictly closer + // to the destination than we are (prevents routing loops) + if dist >= my_distance { + continue; } let dominated = match &best { diff --git a/src/node/tests/routing.rs b/src/node/tests/routing.rs index 9a886a6..a1f2277 100644 --- a/src/node/tests/routing.rs +++ b/src/node/tests/routing.rs @@ -54,6 +54,7 @@ fn test_routing_unknown_destination() { fn test_routing_bloom_filter_hit() { let mut node = make_node(); let transport_id = TransportId::new(1); + let my_addr = *node.node_addr(); // Create two peers let link_id1 = LinkId::new(1); @@ -68,8 +69,27 @@ fn test_routing_bloom_filter_hit() { node.add_connection(conn2).unwrap(); node.promote_connection(link_id2, id2, 2000).unwrap(); - // Destination not directly connected + // Set up tree: we are root, both peers are our children + let peer1_coords = TreeCoordinate::from_addrs(vec![peer1_addr, my_addr]).unwrap(); + node.tree_state_mut().update_peer( + ParentDeclaration::new(peer1_addr, my_addr, 1, 1000), + peer1_coords, + ); + let peer2_coords = TreeCoordinate::from_addrs(vec![peer2_addr, my_addr]).unwrap(); + node.tree_state_mut().update_peer( + ParentDeclaration::new(peer2_addr, my_addr, 1, 1000), + peer2_coords, + ); + + // Destination not directly connected — placed under peer1 in the tree let dest = make_node_addr(99); + let dest_coords = + TreeCoordinate::from_addrs(vec![dest, peer1_addr, my_addr]).unwrap(); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + node.coord_cache_mut().insert(dest, dest_coords, now_ms); // Add dest to peer1's bloom filter only let peer1 = node.get_peer_mut(&peer1_addr).unwrap(); @@ -77,7 +97,7 @@ fn test_routing_bloom_filter_hit() { filter.insert(&dest); peer1.update_filter(filter, 1, 3000); - // Should route through peer1 (bloom filter hit) + // Should route through peer1 (bloom filter hit, closer to dest) let result = node.find_next_hop(&dest); assert!(result.is_some()); assert_eq!(result.unwrap().node_addr(), &peer1_addr); @@ -90,6 +110,7 @@ fn test_routing_bloom_filter_hit() { fn test_routing_bloom_filter_multiple_hits_tiebreak() { let mut node = make_node(); let transport_id = TransportId::new(1); + let my_addr = *node.node_addr(); // Create three peers let mut peer_addrs = Vec::new(); @@ -102,7 +123,25 @@ fn test_routing_bloom_filter_multiple_hits_tiebreak() { node.promote_connection(link_id, id, 2000).unwrap(); } + // Set up tree: we are root, all peers are our children (equidistant) + for &addr in &peer_addrs { + let coords = TreeCoordinate::from_addrs(vec![addr, my_addr]).unwrap(); + node.tree_state_mut().update_peer( + ParentDeclaration::new(addr, my_addr, 1, 1000), + coords, + ); + } + + // Destination placed under the first peer (arbitrary — all peers are + // equidistant from dest since dest is 2 hops from root via any child) let dest = make_node_addr(99); + let dest_coords = + TreeCoordinate::from_addrs(vec![dest, peer_addrs[0], my_addr]).unwrap(); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + node.coord_cache_mut().insert(dest, dest_coords, now_ms); // Add dest to ALL peers' bloom filters for &addr in &peer_addrs { @@ -112,13 +151,13 @@ fn test_routing_bloom_filter_multiple_hits_tiebreak() { peer.update_filter(filter, 1, 3000); } - // All peers have equal link_cost (1.0) and no tree coords set, - // so tree distance is usize::MAX for all. Tie-break by smallest node_addr. + // All peers have equal link_cost (1.0). peer_addrs[0] is closest to dest + // (distance 1 vs distance 3 for the others). Self-distance check filters + // peers that aren't strictly closer than us (our distance = 2). + // peer_addrs[0] has distance 1 (passes), others have distance 3 (filtered). let result = node.find_next_hop(&dest); assert!(result.is_some()); - - let smallest_addr = peer_addrs.iter().min().unwrap(); - assert_eq!(result.unwrap().node_addr(), smallest_addr); + assert_eq!(result.unwrap().node_addr(), &peer_addrs[0]); } // === Greedy tree routing === @@ -183,6 +222,42 @@ fn test_routing_tree_no_coords_in_cache() { assert!(node.find_next_hop(&dest).is_none()); } +// === Bloom filter without coords → no route (loop prevention) === + +#[test] +fn test_routing_bloom_hit_without_coords_returns_none() { + let mut node = make_node(); + let transport_id = TransportId::new(1); + + // Create two peers + let link_id1 = LinkId::new(1); + let (conn1, id1) = make_completed_connection(&mut node, link_id1, transport_id, 1000); + let peer1_addr = *id1.node_addr(); + node.add_connection(conn1).unwrap(); + node.promote_connection(link_id1, id1, 2000).unwrap(); + + let link_id2 = LinkId::new(2); + let (conn2, id2) = make_completed_connection(&mut node, link_id2, transport_id, 1000); + let peer2_addr = *id2.node_addr(); + node.add_connection(conn2).unwrap(); + node.promote_connection(link_id2, id2, 2000).unwrap(); + + let dest = make_node_addr(99); + + // Add dest to BOTH peers' bloom filters + for &addr in &[peer1_addr, peer2_addr] { + let peer = node.get_peer_mut(&addr).unwrap(); + let mut filter = BloomFilter::new(); + filter.insert(&dest); + peer.update_filter(filter, 1, 3000); + } + + // Bloom filter candidates exist, but dest coords are NOT cached. + // find_next_hop must return None to prevent routing loops. + // The caller should signal CoordsRequired back to the source. + assert!(node.find_next_hop(&dest).is_none()); +} + // === Integration: converged network === #[tokio::test] @@ -270,18 +345,31 @@ async fn test_routing_bloom_preferred_over_tree() { drain_all_packets(&mut nodes, false).await; - // Create a destination beyond the network + // Create a destination beyond the network and cache its coords. + // Place dest as a child of peer2 in the converged tree so bloom + // filter routing selects peer2 (strictly closer to dest than us). let dest = make_node_addr(99); + let peer2_addr = *nodes[2].node.node_addr(); + let mut dest_path: Vec = + nodes[2].node.tree_state().my_coords().node_addrs().copied().collect(); + dest_path.insert(0, dest); + let dest_coords = TreeCoordinate::from_addrs(dest_path).unwrap(); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + nodes[0] + .node + .coord_cache_mut() + .insert(dest, dest_coords, now_ms); // Add dest to peer 2's bloom filter (from node 0's perspective) - let peer2_addr = *nodes[2].node.node_addr(); let peer2 = nodes[0].node.get_peer_mut(&peer2_addr).unwrap(); let mut filter = BloomFilter::new(); filter.insert(&dest); peer2.update_filter(filter, 100, 50000); - // Even though we could use tree routing (if coords were cached), - // the bloom filter hit should be preferred. + // Bloom filter hit with cached coords should route via peer 2. let hop = nodes[0].node.find_next_hop(&dest); assert!(hop.is_some(), "Should route via bloom filter"); assert_eq!( @@ -401,7 +489,8 @@ async fn test_routing_reachability_100_nodes() { // Populate coord caches: every node learns every other node's coordinates. // In production this happens via SessionSetup/LookupResponse; here we - // inject them directly so routing can make progress-based decisions. + // inject them directly. Bloom filter routing requires cached dest_coords + // for loop-free forwarding — without coords, find_next_hop returns None. let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) diff --git a/src/protocol/link.rs b/src/protocol/link.rs index 856f260..f02c76e 100644 --- a/src/protocol/link.rs +++ b/src/protocol/link.rs @@ -244,25 +244,45 @@ impl Disconnect { // Session Datagram (Link-Layer Encapsulation) // ============================================================================ -/// Encapsulated session-layer datagram for forwarding. +/// Encapsulated session-layer datagram for multi-hop forwarding. /// -/// This is a link-layer message that carries an opaque, end-to-end encrypted -/// session-layer payload. Intermediate nodes route based on the destination -/// address but cannot decrypt the payload. +/// This is a link-layer message (type 0x40) that carries session-layer +/// payloads through the mesh. The envelope provides source and destination +/// addressing that transit routers use for forwarding decisions and error +/// routing. +/// +/// ## Wire Format (34-byte fixed header) +/// +/// | Offset | Field | Size | Description | +/// |--------|-----------|----------|--------------------------------| +/// | 0 | msg_type | 1 byte | 0x40 | +/// | 1 | src_addr | 16 bytes | Source node_addr | +/// | 17 | dest_addr | 16 bytes | Destination node_addr | +/// | 33 | hop_limit | 1 byte | Decremented each hop | +/// | 34 | 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. #[derive(Clone, Debug)] pub struct SessionDatagram { + /// Source node address (originator of this datagram). + /// For data traffic: the source endpoint. + /// For error signals: the transit router that generated the error. + pub src_addr: NodeAddr, /// Destination node address (for routing decisions). pub dest_addr: NodeAddr, - /// Hop limit (decremented at each hop). + /// Hop limit (decremented at each hop, dropped at zero). pub hop_limit: u8, - /// Encrypted session-layer payload (opaque to intermediate nodes). + /// Session-layer payload (e2e encrypted or plaintext error signal). pub payload: Vec, } impl SessionDatagram { /// Create a new session datagram. - pub fn new(dest_addr: NodeAddr, payload: Vec) -> Self { + pub fn new(src_addr: NodeAddr, dest_addr: NodeAddr, payload: Vec) -> Self { Self { + src_addr, dest_addr, hop_limit: 64, payload, diff --git a/src/protocol/session.rs b/src/protocol/session.rs index efc89dd..79a1498 100644 --- a/src/protocol/session.rs +++ b/src/protocol/session.rs @@ -8,11 +8,13 @@ use std::fmt; // Session Layer Message Types // ============================================================================ -/// Session-layer message type identifiers. +/// SessionDatagram payload message type identifiers. /// -/// These messages are exchanged end-to-end between FIPS nodes, encrypted -/// with session keys that intermediate nodes cannot read. They are carried -/// as payloads inside `LinkMessageType::SessionDatagram`. +/// These messages are carried as payloads inside `SessionDatagram` (link +/// message type 0x40). Session-layer messages (SessionSetup, SessionAck, +/// DataPacket) are end-to-end encrypted with session keys. Error signals +/// (CoordsRequired, PathBroken) are plaintext link-layer messages generated +/// by transit routers that cannot establish e2e sessions with the source. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(u8)] pub enum SessionMessageType { @@ -26,10 +28,10 @@ pub enum SessionMessageType { /// Encrypted IPv6 datagram payload. DataPacket = 0x10, - // Errors (0x20-0x2F) - /// Router cache miss - needs coordinates. + // Link-layer error signals (0x20-0x2F) — plaintext, from transit routers + /// Router cache miss — needs coordinates (link-layer error signal). CoordsRequired = 0x20, - /// Routing failure (local minimum or unreachable). + /// Routing failure — local minimum or unreachable (link-layer error signal). PathBroken = 0x21, } @@ -123,14 +125,12 @@ impl SessionFlags { /// Session setup to establish cached coordinate state. /// -/// Sent before data packets to warm router caches with coordinate -/// information. Routers along the path cache the mappings. +/// Carried inside a SessionDatagram envelope which provides src_addr and +/// dest_addr. The SessionSetup payload contains only coordinates and the +/// Noise handshake data needed for route cache warming and session +/// establishment. #[derive(Clone, Debug)] pub struct SessionSetup { - /// Source node address. - pub src_addr: NodeAddr, - /// Destination node address. - pub dest_addr: NodeAddr, /// Source coordinates (for return path caching). pub src_coords: TreeCoordinate, /// Destination coordinates (for forward routing). @@ -141,15 +141,8 @@ pub struct SessionSetup { impl SessionSetup { /// Create a new session setup message. - pub fn new( - src_addr: NodeAddr, - dest_addr: NodeAddr, - src_coords: TreeCoordinate, - dest_coords: TreeCoordinate, - ) -> Self { + pub fn new(src_coords: TreeCoordinate, dest_coords: TreeCoordinate) -> Self { Self { - src_addr, - dest_addr, src_coords, dest_coords, flags: SessionFlags::new(), @@ -169,25 +162,19 @@ impl SessionSetup { /// Session acknowledgement. /// -/// Sent in response to SessionSetup when request_ack is set. +/// Carried inside a SessionDatagram envelope which provides src_addr and +/// dest_addr. The SessionAck payload contains the acknowledger's coordinates +/// for route cache warming. #[derive(Clone, Debug)] pub struct SessionAck { - /// Source node address (the acknowledger). - pub src_addr: NodeAddr, - /// Destination node address (original session initiator). - pub dest_addr: NodeAddr, /// Acknowledger's coordinates. pub src_coords: TreeCoordinate, } impl SessionAck { /// Create a new session acknowledgement. - pub fn new(src_addr: NodeAddr, dest_addr: NodeAddr, src_coords: TreeCoordinate) -> Self { - Self { - src_addr, - dest_addr, - src_coords, - } + pub fn new(src_coords: TreeCoordinate) -> Self { + Self { src_coords } } } @@ -252,73 +239,44 @@ impl DataFlags { } } -/// Data packet header size in bytes (excluding payload). -/// flags(1) + hop_limit(1) + payload_length(2) + src_addr(16) + dest_addr(16) = 36 -pub const DATA_HEADER_SIZE: usize = 36; +/// DataPacket header size in bytes (excluding payload). +/// msg_type(1) + flags(1) + payload_length(2) = 4 +/// (Addressing and hop_limit are in the SessionDatagram envelope.) +pub const DATA_HEADER_SIZE: usize = 4; -/// Minimal data packet with addresses only (no coordinates). +/// Encrypted application data carried inside a SessionDatagram. /// -/// The 36-byte header contains: -/// - flags (1 byte) -/// - hop_limit (1 byte) +/// The 4-byte header contains: +/// - msg_type (1 byte): 0x10 +/// - flags (1 byte): COORDS_PRESENT, etc. /// - payload_length (2 bytes) -/// - src_addr (16 bytes) -/// - dest_addr (16 bytes) /// -/// Routers use cached coordinates for routing decisions. +/// Addressing (src_addr, dest_addr) and hop_limit are provided by the +/// enclosing SessionDatagram envelope. The total on-wire overhead for a +/// minimal data packet is 34 (SessionDatagram) + 4 (DataPacket) = 38 bytes. #[derive(Clone, Debug)] pub struct DataPacket { /// Packet flags. pub flags: DataFlags, - /// Hop limit (TTL). - pub hop_limit: u8, - /// Source node address. - pub src_addr: NodeAddr, - /// Destination node address. - pub dest_addr: NodeAddr, - /// Payload data. + /// Payload data (end-to-end encrypted application data). pub payload: Vec, } impl DataPacket { /// Create a new data packet. - pub fn new(src_addr: NodeAddr, dest_addr: NodeAddr, payload: Vec) -> Self { + pub fn new(payload: Vec) -> Self { Self { flags: DataFlags::new(), - hop_limit: 64, - src_addr, - dest_addr, payload, } } - /// Set the hop limit. - pub fn with_hop_limit(mut self, hop_limit: u8) -> Self { - self.hop_limit = hop_limit; - self - } - /// Set the flags. pub fn with_flags(mut self, flags: DataFlags) -> Self { self.flags = flags; self } - /// Decrement hop limit, returning false if exhausted. - pub fn decrement_hop_limit(&mut self) -> bool { - if self.hop_limit > 0 { - self.hop_limit -= 1; - true - } else { - false - } - } - - /// Check if the packet can be forwarded. - pub fn can_forward(&self) -> bool { - self.hop_limit > 0 - } - /// Get the payload length. pub fn payload_len(&self) -> usize { self.payload.len() @@ -339,10 +297,14 @@ impl DataPacket { // Error Messages // ============================================================================ -/// Error indicating router cache miss - needs coordinates. +/// Link-layer error signal indicating router cache miss. /// -/// Sent back to the source when a router doesn't have cached -/// coordinates for the destination. +/// Generated by a transit router when it cannot forward a SessionDatagram +/// due to missing cached coordinates for the destination. Carried inside +/// a new SessionDatagram addressed back to the original source +/// (src_addr=reporter, dest_addr=original_source). Plaintext — not +/// end-to-end encrypted, since the transit router has no session with +/// the source. #[derive(Clone, Debug)] pub struct CoordsRequired { /// Destination that couldn't be routed. @@ -360,11 +322,12 @@ impl CoordsRequired { /// Error indicating routing failure (local minimum or unreachable). /// -/// Sent back to the source when greedy routing fails. +/// Carried inside a SessionDatagram addressed back to the original source. +/// The reporting router creates a new SessionDatagram with src_addr=reporter +/// and dest_addr=original_source, so the `original_src` field from the old +/// design is no longer needed — it's the SessionDatagram's dest_addr. #[derive(Clone, Debug)] pub struct PathBroken { - /// Original source of the failed packet. - pub original_src: NodeAddr, /// Destination that couldn't be reached. pub dest_addr: NodeAddr, /// Node that detected the failure. @@ -375,9 +338,8 @@ pub struct PathBroken { impl PathBroken { /// Create a new PathBroken error. - pub fn new(original_src: NodeAddr, dest_addr: NodeAddr, reporter: NodeAddr) -> Self { + pub fn new(dest_addr: NodeAddr, reporter: NodeAddr) -> Self { Self { - original_src, dest_addr, reporter, last_known_coords: None, @@ -457,39 +419,19 @@ mod tests { #[test] fn test_data_packet_size() { - let packet = DataPacket::new(make_node_addr(1), make_node_addr(2), vec![0u8; 100]); + let packet = DataPacket::new(vec![0u8; 100]); - // 36 byte header + 100 byte payload - assert_eq!(packet.total_size(), 136); - assert_eq!(packet.header_size(), 36); + // 4 byte header + 100 byte payload + assert_eq!(packet.total_size(), 104); + assert_eq!(packet.header_size(), 4); assert_eq!(packet.payload_len(), 100); } - #[test] - fn test_data_packet_hop_limit() { - let mut packet = DataPacket::new(make_node_addr(1), make_node_addr(2), vec![]); - - packet.hop_limit = 2; - assert!(packet.can_forward()); - - assert!(packet.decrement_hop_limit()); - assert_eq!(packet.hop_limit, 1); - - assert!(packet.decrement_hop_limit()); - assert_eq!(packet.hop_limit, 0); - assert!(!packet.can_forward()); - - assert!(!packet.decrement_hop_limit()); - assert_eq!(packet.hop_limit, 0); - } - #[test] fn test_data_packet_builder() { - let packet = DataPacket::new(make_node_addr(1), make_node_addr(2), vec![1, 2, 3]) - .with_hop_limit(32) + let packet = DataPacket::new(vec![1, 2, 3]) .with_flags(DataFlags::from_byte(0x80)); - assert_eq!(packet.hop_limit, 32); assert_eq!(packet.flags.to_byte(), 0x80); } @@ -525,13 +467,8 @@ mod tests { #[test] fn test_session_setup() { - let setup = SessionSetup::new( - make_node_addr(1), - make_node_addr(2), - make_coords(&[1, 0]), - make_coords(&[2, 0]), - ) - .with_flags(SessionFlags::new().with_ack()); + let setup = SessionSetup::new(make_coords(&[1, 0]), make_coords(&[2, 0])) + .with_flags(SessionFlags::new().with_ack()); assert!(setup.flags.request_ack); assert!(!setup.flags.bidirectional); @@ -551,9 +488,11 @@ mod tests { #[test] fn test_path_broken() { - let err = PathBroken::new(make_node_addr(1), make_node_addr(2), make_node_addr(3)) + let err = PathBroken::new(make_node_addr(2), make_node_addr(3)) .with_last_coords(make_coords(&[2, 0])); + assert_eq!(err.dest_addr, make_node_addr(2)); + assert_eq!(err.reporter, make_node_addr(3)); assert!(err.last_known_coords.is_some()); } }