Files
fips/docs/design/fips-gossip-protocol.md
T
Johnathan Corgan d41009b778 SessionDatagram redesign: add src_addr, reclassify error signals
Add src_addr to SessionDatagram envelope (34-byte header: msg_type +
src_addr + dest_addr + hop_limit) so transit routers can route error
signals back to the packet's originator.

Reclassify CoordsRequired/PathBroken as link-layer error signals
(plaintext inside SessionDatagram) rather than e2e encrypted session
messages. Transit routers generate these when forwarding fails and
route them to src_addr; if source is also unreachable, drop silently.

Remove redundant src_addr/dest_addr/hop_limit from SessionSetup,
SessionAck, and DataPacket (now in envelope). DataPacket header
reduced from 36 to 4 bytes. Remove PathBroken.original_src.

Fix routing loop vulnerability: gate bloom filter path on having
cached dest_coords to prevent blind forwarding between peers.
Simplify select_best_candidate() to require coordinates.

Fix gossip protocol type codes (0x11->0x20, 0x12->0x30, 0x13->0x31)
for consistency across all design docs.

All 5 design docs updated and cross-checked for consistency.
335 tests pass, zero warnings.
2026-02-12 11:32:45 +00:00

785 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# FIPS Gossip Protocol
This document specifies the wire formats and exchange rules for FIPS gossip
messages: TreeAnnounce, FilterAnnounce, and the discovery protocol
(LookupRequest/LookupResponse).
For conceptual background on how these protocols work:
- Spanning tree dynamics: [spanning-tree-dynamics.md](spanning-tree-dynamics.md)
- Routing design and bloom filter concepts: [fips-routing.md](fips-routing.md)
---
## 1. Message Type Summary
All gossip messages are link-layer messages, encrypted with per-peer Noise IK
session keys. They travel one hop (peer-to-peer), though their effects may
propagate further through subsequent gossip.
| Type | Purpose | Direction | Trigger |
|------|---------|-----------|---------|
| TreeAnnounce | Spanning tree state | Bidirectional | Peer connect, parent change, periodic |
| FilterAnnounce | Bloom filter reachability | Bidirectional | Peer connect, filter change |
| LookupRequest | Coordinate discovery | Flooded | Route cache miss |
| LookupResponse | Return coordinates | Routed back | LookupRequest reaches target |
---
## 2. TreeAnnounce
TreeAnnounce messages propagate spanning tree state between peers. Each node
announces its parent selection and ancestry, enabling peers to compute tree
coordinates and distances.
### 2.1 Wire Format
```text
TreeAnnounce (v1) {
version: u8, // Protocol version (0x01 for v1)
sequence: u64, // Monotonic, increments on parent change
timestamp: u64, // Unix timestamp (seconds)
parent: NodeAddr, // 16 bytes, truncated SHA-256(pubkey) of selected parent
ancestry_count: u16, // Number of ancestry entries
ancestry: [AncestryEntry], // Path from self to root
signature: Signature, // 64 bytes, outer signature over entire message
}
AncestryEntry (v1) {
node_addr: NodeAddr, // 16 bytes
sequence: u64, // That node's sequence number
timestamp: u64, // That node's timestamp
}
```
Note: v1 ancestry entries are 32 bytes each (no per-entry signature). See §2.7 Trust Model.
### 2.2 Field Semantics
**version**: Protocol version number. v1 = 0x01. Receivers MUST reject messages
with unrecognized version numbers to ensure forward compatibility.
**sequence**: Incremented each time the node changes its parent declaration.
Higher sequence numbers supersede lower ones for conflict resolution.
**timestamp**: Used for distributed consistency. A declaration is considered
stale if `now - timestamp > ROOT_TIMEOUT` (default 60 minutes for root).
**parent**: The node_addr of the selected parent. If `parent == self.node_addr`,
the node is declaring itself as root.
**ancestry**: The chain from this node up to the root. The first entry is this
node's own declaration, followed by parent, grandparent, etc. In v1, entries
carry only routing metadata (node_addr, sequence, timestamp) without per-entry
signatures. See §2.7 for the trust model.
### 2.3 Size Estimate
| Component | Size |
|-----------|------|
| version | 1 byte |
| sequence | 8 bytes |
| timestamp | 8 bytes |
| parent | 16 bytes |
| ancestry_count | 2 bytes |
| signature | 64 bytes |
| Per ancestry entry (v1) | 16 + 8 + 8 = 32 bytes |
For tree depth D (ancestry_count = D + 1): `100 + (D + 1) × 32` bytes payload.
| Tree Depth | Payload Size | With Link Overhead |
|------------|--------------|--------------------|
| 0 (root) | 132 bytes | 161 bytes |
| 3 | 228 bytes | 257 bytes |
| 5 | 292 bytes | 321 bytes |
| 10 | 452 bytes | 481 bytes |
Note: v1 ancestry entries omit per-entry signatures (32 bytes vs 96 bytes in
the original design). See §2.7 for the rationale.
### 2.4 Exchange Rules
**On peer connection:**
1. After Noise IK handshake completes, both peers send TreeAnnounce
2. Each peer processes the received announcement (see §2.5)
3. If processing triggers a parent change, send updated TreeAnnounce to all peers
**On parent change:**
1. Increment sequence number
2. Update timestamp
3. Sign new declaration
4. Send TreeAnnounce to all peers
**Periodic refresh:**
1. Root refreshes every 30 minutes (prevents stale root detection)
2. Non-root nodes forward root refresh when received
3. Nodes may refresh their own declaration periodically (implementation choice)
### 2.5 Processing Rules
When receiving TreeAnnounce from peer P:
```text
1. Decode message; reject if version != 0x01
2. Verify P's declaration signature using P's known public key (from Noise IK)
3. Verify that declaration node_addr matches sender's identity
4. Check sequence freshness:
- If sequence <= stored sequence for P: discard (stale)
5. Update peer P's tree state (declaration + ancestry)
6. Re-evaluate parent selection:
- Find smallest root visible across all peers
- Among peers reaching smallest root, prefer shallowest depth
- Apply stability threshold to prevent flapping (depth improvement ≥ 1)
- If parent changed: increment own sequence, sign, recompute coords, announce to all
```
Note: In v1, only the sender's declaration signature is verified (step 2).
Ancestry entries beyond the direct peer are accepted on trust. See §2.7.
### 2.6 Rate Limiting
To prevent announcement storms during reconvergence:
- Minimum interval between announcements to same peer: 500ms
- If change occurs during cooldown: mark pending, send after cooldown
- Coalesce multiple pending changes into single announcement
### 2.7 Trust Model (v1)
**v1 uses transitive trust**: each node verifies only its direct peer's
declaration signature. The peer's public key is known from the Noise IK
handshake, so verification is straightforward. Ancestry entries from nodes
beyond the direct peer are accepted on trust from the authenticated sender.
**Why transitive trust?** NodeAddr values are truncated SHA-256 hashes of
public keys — this mapping is intentionally one-way. To verify an ancestry
entry's signature, a node would need the entry's public key, but FIPS does not
distribute node_addr→pubkey mappings by design. Exposing these mappings would
enable traffic analysis, undermining a core privacy property.
**Limitation**: An adversarial interior node could fabricate ancestry chains,
potentially attracting traffic to itself (sinkhole attack) or manipulating tree
topology. This risk is mitigated by:
- **Authenticated peers have reputation cost**: Misbehaving nodes can be
disconnected and blocked by their direct peers.
- **Multi-path observation**: Nodes receiving conflicting tree state from
multiple peers can detect inconsistencies (future enhancement).
**Versioning**: The wire format includes a version byte (v1 = 0x01) to enable
future protocol evolution. A future version could introduce stronger ancestry
verification (e.g., zero-knowledge proofs of key ownership) without breaking
backward compatibility. Nodes MUST reject TreeAnnounce messages with
unrecognized version numbers.
---
## 3. FilterAnnounce
FilterAnnounce messages propagate Bloom filter reachability information. Each
node's filter indicates which destinations are reachable through it.
### 3.1 Wire Format
```text
FilterAnnounce {
sequence: u64, // For freshness/deduplication
filter: BloomFilter, // Variable size based on size_class
}
BloomFilter {
hash_count: u8, // Number of hash functions (5 for v1)
size_class: u8, // Filter size: bytes = 512 << size_class
bits: [u8; 512 << size_class], // Bit array (1024 bytes for v1)
}
```
### 3.2 Size Classes
Filter sizes are powers of 2 to enable folding (shrinking by ORing halves):
| size_class | Bits | Bytes | Status |
|------------|--------|-------|---------------------|
| 0 | 4,096 | 512 | Reserved (future) |
| 1 | 8,192 | 1,024 | **v1 default** |
| 2 | 16,384 | 2,048 | Reserved (future) |
| 3 | 32,768 | 4,096 | Reserved (future) |
**v1 protocol**: All nodes MUST use size_class=1 (1 KB filters). Nodes MUST
reject FilterAnnounce with size_class ≠ 1.
**Future versions**: Nodes may negotiate larger filters via capability exchange.
Receivers can fold larger filters down to their preferred size.
### 3.3 Field Semantics
**sequence**: Monotonic counter for this node's filter. Allows receivers to
detect stale or duplicate announcements.
**hash_count**: Number of hash functions used. v1 uses k=5, which is optimal
for 800-1,600 entries in a 1 KB filter.
**size_class**: Indicates filter size as `512 << size_class` bytes. Allows
forward-compatible extension to larger filters.
**bits**: The Bloom filter bit array. To test membership:
```text
for i in 0..hash_count:
bit_index = hash(node_addr, i) % (8 * bits.len())
if !bits[bit_index]: return false
return true // "maybe present"
```
### 3.3 Filter Contents
A node's outgoing filter to peer Q contains:
1. This node's own node_addr
2. Node_ids of leaf-only dependents (nodes using this node as sole peer)
3. Entries merged from filters received from all other peers (not Q)
This split-horizon merge (excluding the destination peer's own filter from
the computation) prevents a node's entries from being echoed back to it,
providing loop prevention. Filters propagate transitively through the
network without any hop limit.
### 3.4 Exchange Rules
**On peer connection:**
1. After TreeAnnounce exchange, send FilterAnnounce
2. Filter contains current reachability view
**On filter change:**
Triggering events:
- Peer connects or disconnects
- Received filter changes outgoing filter
- Local state change (new leaf dependent, become gateway)
Rate limiting:
- Minimum interval between filter announcements: 500ms
- Debounce rapid changes into single announcement
**Processing received filter:**
```text
1. Store: peer_filters[P] = received.filter
2. Recompute outgoing filters for all other peers:
- For each peer Q (Q != P):
outgoing[Q] = merge(self_filter, peer_filters[all peers except Q])
- If outgoing[Q] changed, send FilterAnnounce to Q
```
### 3.5 Filter Expiration
Bloom filters cannot remove individual entries. Expiration handled by:
- **Peer disconnect**: Remove that peer's filter entirely, recompute
- **Filter replacement**: Each FilterAnnounce replaces the previous one
- **Implicit timeout**: If no updates from peer within threshold, consider stale
---
## 4. LookupRequest
LookupRequest initiates coordinate discovery for destinations not covered by
local Bloom filters.
### 4.1 Wire Format
```text
LookupRequest {
request_id: u64, // Unique identifier for this request
target: NodeAddr, // 16 bytes, who we're looking for
origin: NodeAddr, // 16 bytes, who's asking
origin_coords: Vec<NodeAddr>, // Origin's ancestry (for return path)
ttl: u8, // Remaining propagation hops
visited: CompactBloomFilter,// ~256 bytes, prevents loops
}
CompactBloomFilter {
bits: [u8; 256], // Smaller filter for visited set
hash_count: u8,
}
```
### 4.2 Field Semantics
**request_id**: Randomly generated, used to match responses and detect
duplicates.
**target**: The node_addr being searched for.
**origin**: The node_addr of the original requester. Used for response routing.
**origin_coords**: The requester's current tree coordinates. Enables greedy
routing of the response back to origin.
**ttl**: Propagation limit. Prevents unbounded flooding.
**visited**: Compact Bloom filter tracking nodes that have seen this request.
Prevents loops (revisiting a node on the same path). Note: does NOT prevent
convergent duplicates arriving via different paths — `request_id` dedup
(section 4.4) is required for that.
### 4.3 Propagation Rules
When receiving LookupRequest:
```text
1. Check request_id against recent-request cache - if present, drop (duplicate
via convergent path). This is REQUIRED, not optional — the visited filter
alone does not prevent duplicates arriving via different paths.
2. Add request_id to recent-request cache
3. Check visited filter - if self likely present, drop (already processed)
4. Add self to visited filter
5. Decrement TTL
6. Check if target is local:
- If target == self.node_addr: generate LookupResponse
- If target in local peer_filters: may respond on behalf (optional)
7. If TTL > 0 and not found locally:
- Forward to peers not in visited filter
- Optionally prioritize peers whose filter indicates target "maybe" present
```
The recent-request cache need only retain entries for a few seconds (long
enough for the flood to complete across the TTL scope) and is bounded by the
rate limit on incoming requests.
### 4.4 Rate Limiting
- Limit requests per origin per time window
- Limit total outstanding requests
---
## 5. LookupResponse
LookupResponse returns the target's coordinates to the requester.
### 5.1 Wire Format
```text
LookupResponse {
request_id: u64, // Echoes LookupRequest.request_id
target: NodeAddr, // 16 bytes, confirms who was found
target_coords: Vec<NodeAddr>, // Target's ancestry (the key payload)
proof: Signature, // 64 bytes, target signs to prove existence
}
```
### 5.2 Field Semantics
**request_id**: Matches the original request, allowing requester to correlate.
**target**: Confirms the identity found.
**target_coords**: The target's current tree coordinates. This is the primary
payload - enables greedy routing to the target.
**proof**: Target's signature over `(request_id || target || target_coords)`.
Prevents malicious nodes from claiming reachability and blackholing traffic.
### 5.3 Routing
LookupResponse uses greedy tree routing based on `origin_coords` from the
request:
```text
1. Response created at target (or node with target in filter)
2. Each hop forwards toward origin using tree distance
3. Origin receives response, caches target_coords
```
### 5.4 Security
The proof signature is critical:
- Without it, any node could claim to be (or know) any target
- Requester verifies signature against target's known public key
- Invalid signatures cause response to be dropped
---
## 6. Message Type Codes
Within the link-layer message framing:
| Type Code | Message |
|-----------|---------|
| 0x10 | TreeAnnounce |
| 0x20 | FilterAnnounce |
| 0x30 | LookupRequest |
| 0x31 | LookupResponse |
These are carried inside the encrypted link-layer payload after Noise IK
handshake completion. See [fips-wire-protocol.md](fips-wire-protocol.md) §2.6
for the full link message type table.
---
## 7. Timing Parameters
| Parameter | Default | Notes |
|-----------|---------|-------|
| ROOT_REFRESH_INTERVAL | 30 min | Root regenerates timestamp |
| ROOT_TIMEOUT | 60 min | Root declaration considered stale |
| TREE_ENTRY_TTL | 5-10 min | Individual entry expiration |
| ANNOUNCE_MIN_INTERVAL | 500 ms | Rate limit for announcements |
| LOOKUP_TTL | 8 | Discovery request propagation limit |
| LOOKUP_TIMEOUT | 5 sec | Time to wait for response |
---
## 8. Encoding
All multi-byte integers are little-endian. NodeAddr is 16 bytes (truncated SHA-256 hash).
Signatures are 64 bytes (secp256k1 Schnorr).
Variable-length fields (ancestry, coordinates) are prefixed with a 2-byte
length count indicating number of entries.
```text
Vec<T> encoding:
count: u16 (little-endian)
items: T[count]
```
---
## Appendix A: Detailed Packet Layouts
All gossip messages are link-layer messages carried inside encrypted frames
(discriminator 0x00). The layouts below show the plaintext structure after
link-layer decryption.
### A.1 TreeAnnounce (0x10)
Propagates spanning tree state between directly connected peers.
```text
┌─────────────────────────────────────────────────────────────────────────────┐
│ FULL PACKET (Link Layer + TreeAnnounce) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ LINK LAYER FRAME (encrypted) │ │
│ ├───────────┬──────────────┬────────────┬───────────────────────────────┤ │
│ │ 0x00 │ receiver_idx │ counter │ ciphertext + tag │ │
│ │ 1 byte │ 4 bytes LE │ 8 bytes LE │ N + 16 bytes │ │
│ └───────────┴──────────────┴────────────┴───────────────────────────────┘ │
│ │ │
│ ┌───────────────────────┘ │
│ │ Decrypt │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ TREE ANNOUNCE v1 (plaintext) │ │
│ ├────────┬──────────────────┬───────────┬───────────────────────────────┤ │
│ │ Offset │ Field │ Size │ Description │ │
│ ├────────┼──────────────────┼───────────┼───────────────────────────────┤ │
│ │ 0 │ msg_type │ 1 byte │ 0x10 │ │
│ │ 1 │ version │ 1 byte │ 0x01 (v1) │ │
│ │ 2 │ sequence │ 8 bytes │ u64 LE, monotonic counter │ │
│ │ 10 │ timestamp │ 8 bytes │ u64 LE, Unix seconds │ │
│ │ 18 │ parent │ 16 bytes │ NodeAddr of selected parent │ │
│ │ 34 │ ancestry_count │ 2 bytes │ u16 LE, number of entries │ │
│ │ 36 │ ancestry[0..n] │ 32 × n │ AncestryEntry array │ │
│ │ ... │ signature │ 64 bytes │ Schnorr sig over all above │ │
│ └────────┴──────────────────┴───────────┴───────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ ANCESTRY ENTRY v1 (32 bytes each) │ │
│ ├────────┬──────────────────┬───────────┬───────────────────────────────┤ │
│ │ Offset │ Field │ Size │ Description │ │
│ ├────────┼──────────────────┼───────────┼───────────────────────────────┤ │
│ │ 0 │ node_addr │ 16 bytes │ Truncated SHA-256(pubkey) │ │
│ │ 16 │ sequence │ 8 bytes │ u64 LE, node's seq number │ │
│ │ 24 │ timestamp │ 8 bytes │ u64 LE, node's timestamp │ │
│ └────────┴──────────────────┴───────────┴───────────────────────────────┘ │
│ │
│ Note: v1 entries omit per-entry signatures. Only the sender's outer │
│ signature is verified (transitive trust model, see §2.7). │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
**Size calculation**: `1 + 1 + 8 + 8 + 16 + 2 + (depth × 32) + 64 = 100 + (depth × 32)` bytes
| Tree Depth | Payload Size | With Link Overhead |
|------------|--------------|--------------------|
| 0 (root) | 132 bytes | 161 bytes |
| 3 | 228 bytes | 257 bytes |
| 5 | 292 bytes | 321 bytes |
| 10 | 452 bytes | 481 bytes |
**Concrete example** (node D at depth 3, ancestry = [D, P1, P2, Root]):
```text
PLAINTEXT BYTES (hex layout):
10 ← msg_type = TreeAnnounce
01 ← version = 1
05 00 00 00 00 00 00 00 ← sequence = 5
C3 B2 A1 67 00 00 00 00 ← timestamp (Unix seconds)
[16 bytes P1's node_addr] ← parent
04 00 ← ancestry_count = 4
ANCESTRY[0] - Self (D):
[16 bytes D's node_addr]
05 00 00 00 00 00 00 00 ← D's sequence
C3 B2 A1 67 00 00 00 00 ← D's timestamp
ANCESTRY[1] - Parent (P1):
[16 bytes P1's node_addr]
0A 00 00 00 00 00 00 00 ← P1's sequence
00 B0 A1 67 00 00 00 00 ← P1's timestamp
ANCESTRY[2] - Grandparent (P2):
[16 bytes P2's node_addr]
03 00 00 00 00 00 00 00 ← P2's sequence
00 A0 A1 67 00 00 00 00 ← P2's timestamp
ANCESTRY[3] - Root:
[16 bytes Root's node_addr]
01 00 00 00 00 00 00 00 ← Root's sequence
00 90 A1 67 00 00 00 00 ← Root's timestamp
[64 bytes D's outer signature] ← signs entire message
Total payload: 1 + 1 + 8 + 8 + 16 + 2 + (4 × 32) + 64 = 228 bytes
```
### A.2 FilterAnnounce (0x20)
Propagates Bloom filter reachability information.
```text
┌─────────────────────────────────────────────────────────────────────────────┐
│ FILTER ANNOUNCE (0x20) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ WIRE FORMAT │ │
│ ├────────┬──────────────────┬───────────┬───────────────────────────────┤ │
│ │ Offset │ Field │ Size │ Description │ │
│ ├────────┼──────────────────┼───────────┼───────────────────────────────┤ │
│ │ 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 │ │
│ │ 11 │ filter_bits │ variable │ 512 << size_class bytes │ │
│ └────────┴──────────────────┴───────────┴───────────────────────────────┘ │
│ │
│ Size classes (powers of 2 for foldability): │
│ 0 = 512 bytes (4,096 bits) - Reserved for future │
│ 1 = 1,024 bytes (8,192 bits) - v1 default │
│ 2 = 2,048 bytes (16,384 bits) - Reserved for future │
│ 3 = 4,096 bytes (32,768 bits) - Reserved for future │
│ │
│ v1 total payload: 1 + 8 + 1 + 1 + 1024 = 1035 bytes │
│ With link overhead: 1064 bytes │
│ │
├─────────────────────────────────────────────────────────────────────────────┤
│ BLOOM FILTER STRUCTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ filter_bits[1024] (v1, size_class=1): │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Byte 0 │ Byte 1 │ ... │ Byte 1023 │ │
│ │ bits 0-7 │ bits 8-15 │ │ bits 8184-8191 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ To test membership of node_addr: │
│ filter_bits = 8 * (512 << size_class) // 8192 for v1 │
│ for i in 0..hash_count: │
│ bit_index = hash(node_addr, i) % filter_bits │
│ if !bits[bit_index]: return false │
│ return true // "maybe present" │
│ │
│ Folding (for future heterogeneous sizes): │
│ To shrink a filter by half, OR its two halves: │
│ small[i] = large[i] | large[i + small.len()] │
│ This increases FPR but preserves correctness (no false negatives). │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
**Concrete example** (v1 with size_class=1):
```text
PLAINTEXT BYTES:
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)
[1024 bytes of filter bits] ← Bloom filter
Total: 1035 bytes
```
### A.3 LookupRequest (0x30)
Discovers tree coordinates for distant destinations.
```text
┌─────────────────────────────────────────────────────────────────────────────┐
│ LOOKUP REQUEST (0x30) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ WIRE FORMAT │ │
│ ├────────┬──────────────────┬───────────┬───────────────────────────────┤ │
│ │ Offset │ Field │ Size │ Description │ │
│ ├────────┼──────────────────┼───────────┼───────────────────────────────┤ │
│ │ 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 │ │
│ │ 41 │ ttl │ 1 byte │ Remaining propagation hops │ │
│ │ 42 │ origin_coords_cnt│ 2 bytes │ u16 LE │ │
│ │ 44 │ origin_coords │ 16 × n │ Requester's ancestry │ │
│ │ ... │ visited_hash_cnt │ 1 byte │ Hash functions for visited │ │
│ │ ... │ visited_bits │ 256 bytes │ Compact bloom of visited nodes│ │
│ └────────┴──────────────────┴───────────┴───────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
**Size calculation**: `1 + 8 + 16 + 16 + 1 + 2 + (depth × 16) + 1 + 256` bytes
| Origin Depth | Payload Size |
|--------------|--------------|
| 3 | 349 bytes |
| 5 | 381 bytes |
| 10 | 461 bytes |
**Concrete example** (origin at depth 4):
```text
PLAINTEXT BYTES:
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
08 ← ttl = 8
04 00 ← origin_coords_count = 4
[16 bytes] × 4 ← origin's ancestry (64 bytes)
07 ← visited hash_count = 7
[256 bytes visited bloom] ← nodes that have seen this request
Total: 1 + 8 + 16 + 16 + 1 + 2 + 64 + 1 + 256 = 365 bytes
```
### A.4 LookupResponse (0x31)
Returns target's coordinates to the requester.
```text
┌─────────────────────────────────────────────────────────────────────────────┐
│ LOOKUP RESPONSE (0x31) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ WIRE FORMAT │ │
│ ├────────┬──────────────────┬───────────┬───────────────────────────────┤ │
│ │ Offset │ Field │ Size │ Description │ │
│ ├────────┼──────────────────┼───────────┼───────────────────────────────┤ │
│ │ 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 │ │
│ │ 27 │ target_coords │ 16 × n │ Target's ancestry to root │ │
│ │ ... │ proof │ 64 bytes │ Target's signature │ │
│ └────────┴──────────────────┴───────────┴───────────────────────────────┘ │
│ │
│ Proof signature covers: (request_id || target || target_coords) │
│ Prevents malicious nodes from claiming reachability for any target. │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
**Size calculation**: `1 + 8 + 16 + 2 + (depth × 16) + 64` bytes
| Target Depth | Payload Size |
|--------------|--------------|
| 3 | 139 bytes |
| 5 | 171 bytes |
| 10 | 251 bytes |
**Concrete example** (target at depth 5):
```text
PLAINTEXT BYTES:
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
[16 bytes] × 5 ← target's ancestry (80 bytes)
[64 bytes proof signature] ← target signs to prove existence
Total: 1 + 8 + 16 + 2 + 80 + 64 = 171 bytes
```
### A.5 Message Flow Example
Complete lookup flow showing packet nesting:
```text
Source S wants to reach distant destination D (not in local filters)
1. S creates LookupRequest, sends to peer P1:
UDP DATAGRAM
┌──────────────────────────────────────────────────────────────┐
│ LINK FRAME (S→P1 encrypted) │
│ ┌──────┬────────────┬─────────┬─────────────────────────────┐│
│ │ 0x00 │ P1_recv_idx│ counter │ ciphertext + tag ││
│ └──────┴────────────┴─────────┴─────────────────────────────┘│
│ │ │
│ ┌───────────┘ │
│ ▼ │
│ ┌──────┬───────────────────────────────────┐ │
│ │ 0x30 │ LookupRequest payload │ │
│ │ │ (target=D, origin=S, ttl=8, ...) │ │
│ └──────┴───────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
2. Request propagates through network, reaches D
3. D creates LookupResponse, routes back via greedy routing:
UDP DATAGRAM
┌──────────────────────────────────────────────────────────────┐
│ LINK FRAME (D→Pn encrypted) │
│ ┌──────┬────────────┬─────────┬─────────────────────────────┐│
│ │ 0x00 │ Pn_recv_idx│ counter │ ciphertext + tag ││
│ └──────┴────────────┴─────────┴─────────────────────────────┘│
│ │ │
│ ┌───────────┘ │
│ ▼ │
│ ┌──────┬───────────────────────────────────┐ │
│ │ 0x31 │ LookupResponse payload │ │
│ │ │ (target=D, coords=[D,P1,P2,Root]) │ │
│ └──────┴───────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
4. S receives response, caches D's coordinates, can now route directly
```
---
## References
- [spanning-tree-dynamics.md](spanning-tree-dynamics.md) - Tree protocol behavior
- [fips-routing.md](fips-routing.md) - Routing concepts and algorithms
- [fips-wire-protocol.md](fips-wire-protocol.md) - Link-layer framing
- [fips-session-protocol.md](fips-session-protocol.md) - End-to-end sessions