Testing: - Add 14 bloom module tests (39 total): from_bytes error paths, from_slice round-trip, insert_bytes/contains_bytes, estimated_count saturation, Default/Debug traits, mark_changed_peers cascade prevention (4 scenarios), remove_peer_state, record_sent_filter, leaf_dependents accessor. Benchmarks: - Add criterion benchmark suite for bloom filter hot-path operations: insert, contains, merge, from_bytes, fill_ratio, estimated_count, equality, compute_outgoing_filter, mark_changed_peers, base_filter. Parameterized over realistic occupancy levels and peer counts. Design doc corrections: - Fix visited bloom filter hash_count in gossip protocol doc (7→5, matching code for 256-byte filter occupancy). - Correct LookupResponse proof signature scope in fips-routing.md and fips-gossip-protocol.md: proof covers (request_id || target) only — coords excluded to survive tree reconvergence during lookup RTT.
41 KiB
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
- Routing design and bloom filter concepts: 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
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:
- After Noise IK handshake completes, both peers send TreeAnnounce
- Each peer processes the received announcement (see §2.5)
- If processing triggers a parent change, send updated TreeAnnounce to all peers
On parent change:
- Increment sequence number
- Update timestamp
- Sign new declaration
- Send TreeAnnounce to all peers
Periodic refresh:
- Root refreshes every 30 minutes (prevents stale root detection)
- Non-root nodes forward root refresh when received
- Nodes may refresh their own declaration periodically (implementation choice)
2.5 Processing Rules
When receiving TreeAnnounce from peer P:
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
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:
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:
- This node's own node_addr
- Node_ids of leaf-only dependents (nodes using this node as sole peer)
- 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:
- After TreeAnnounce exchange, send FilterAnnounce
- 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:
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
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. Used by the target
for the first hop of response routing (via find_next_hop).
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:
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
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 — cached by the originator to enable routing to the target.
proof: Target's signature over (request_id || target). Prevents
malicious nodes from claiming reachability and blackholing traffic.
Coordinates are excluded from the proof to avoid invalidation during
tree reconvergence (see fips-routing.md §2.4).
5.3 Routing
LookupResponse uses a two-phase routing mechanism:
1. Response created at target (or node with target in filter)
2. First hop: target routes toward origin via find_next_hop
(standard bloom filter → tree routing path)
3. Subsequent hops: reverse-path forwarding via recent_requests
(each transit node recorded which peer sent the request)
4. Origin receives response, caches target_coords in RouteCache
The first hop from the target uses find_next_hop(origin) because
the target was not a transit node for the request (it was the
destination). All transit nodes that forwarded the request stored a
(request_id → from_peer) entry in recent_requests, enabling
reverse-path forwarding for the response.
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 §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 | 64 | Discovery request propagation limit |
| LOOKUP_TIMEOUT | 10 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.
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.
┌─────────────────────────────────────────────────────────────────────────────┐
│ 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]):
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.
┌─────────────────────────────────────────────────────────────────────────────┐
│ 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):
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.
┌─────────────────────────────────────────────────────────────────────────────┐
│ 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):
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
40 ← ttl = 64
04 00 ← origin_coords_count = 4
[16 bytes] × 4 ← origin's ancestry (64 bytes)
05 ← visited hash_count = 5
[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.
┌─────────────────────────────────────────────────────────────────────────────┐
│ 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) │
│ Coords excluded to survive tree reconvergence during lookup RTT. │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
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):
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:
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=64, ...) │ │
│ └──────┴───────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
2. Request propagates through network, reaches D
3. D creates LookupResponse, routes back via find_next_hop + reverse-path:
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 - Tree protocol behavior
- fips-routing.md - Routing concepts and algorithms
- fips-wire-protocol.md - Link-layer framing
- fips-session-protocol.md - End-to-end sessions