mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
Standardize naming conventions across docs and source
Rename NodeId to NodeAddr and npub to pubkey throughout documentation and source code for consistency with the established identity model. Add FIPS API vs IPv6 adapter overview to session protocol document. Add transport address terminology note to wire protocol document.
This commit is contained in:
@@ -35,7 +35,7 @@ Node
|
||||
├── coord_cache: CoordCache // address → coordinates for routing
|
||||
├── transports: HashMap<TransportId, Transport>
|
||||
├── links: HashMap<LinkId, Link>
|
||||
└── peers: HashMap<NodeId, Peer>
|
||||
└── peers: HashMap<NodeAddr, Peer>
|
||||
```
|
||||
|
||||
### Identity
|
||||
@@ -46,13 +46,27 @@ Cryptographic identity using Nostr keys (secp256k1).
|
||||
Identity
|
||||
├── npub: PublicKey // public key (bech32: npub1...)
|
||||
├── nsec: SecretKey // secret key (bech32: nsec1...)
|
||||
├── node_id: NodeId // SHA-256(npub), 32 bytes
|
||||
└── address: FipsAddress // IPv6 ULA derived from node_id (fd::/8)
|
||||
├── node_addr: NodeAddr // SHA-256(pubkey), 32 bytes
|
||||
└── address: FipsAddress // IPv6 ULA derived from node_addr (fd::/8)
|
||||
```
|
||||
|
||||
`NodeId` is the routing identifier, derived deterministically from `npub`.
|
||||
`NodeAddr` is the routing identifier, derived deterministically from `npub`.
|
||||
Transport addresses and FIPS identity are fully decoupled.
|
||||
|
||||
### Protocol Layer Visibility
|
||||
|
||||
| Observer | Link Addr | Node Addr | FIPS Addr (pubkey) | Payload |
|
||||
|-------------------------------|---------------|--------------|--------------------| --------|
|
||||
| Transport (IP router, switch) | Yes | No | No | No |
|
||||
| FIPS routing node | Last hop only | Yes (header) | No | No |
|
||||
| Destination endpoint | Yes | Yes | Yes | Yes |
|
||||
|
||||
**Key insight**: Three independent encryption layers ensure:
|
||||
|
||||
- Passive transport observers see only encrypted blobs
|
||||
- FIPS routing nodes see node_addrs but not pubkeys (FIPS addresses) or payload
|
||||
- Only endpoints know each other's FIPS addresses (pubkeys) and can decrypt payload
|
||||
|
||||
### Transport
|
||||
|
||||
A physical or logical interface over which links can be established. Transports
|
||||
@@ -130,14 +144,14 @@ An authenticated remote FIPS node, reachable via a link.
|
||||
|
||||
```
|
||||
Peer
|
||||
├── node_id: NodeId // routing identity
|
||||
├── node_addr: NodeAddr // routing identity
|
||||
├── npub: PublicKey // cryptographic identity
|
||||
├── link_id: LinkId // which link reaches this peer
|
||||
├── state: PeerState // lifecycle state
|
||||
│
|
||||
│ // Spanning tree
|
||||
├── declaration: ParentDeclaration // their latest
|
||||
├── ancestry: Vec<NodeId> // their path to root
|
||||
├── ancestry: Vec<NodeAddr> // their path to root
|
||||
│
|
||||
│ // Bloom filter (inbound—what's reachable through them)
|
||||
├── inbound_filter: BloomFilter
|
||||
@@ -182,12 +196,12 @@ transport per peer).
|
||||
```
|
||||
TreeState
|
||||
├── my_declaration: ParentDeclaration
|
||||
│ ├── node_id: NodeId
|
||||
│ ├── parent_id: NodeId // self if root candidate
|
||||
│ ├── node_addr: NodeAddr
|
||||
│ ├── parent_id: NodeAddr // self if root candidate
|
||||
│ ├── sequence: u64 // monotonic
|
||||
│ └── signature: Signature
|
||||
├── my_coords: Vec<NodeId> // [self, parent, grandparent, ..., root]
|
||||
└── root: NodeId // elected root (smallest reachable node_id)
|
||||
├── my_coords: Vec<NodeAddr> // [self, parent, grandparent, ..., root]
|
||||
└── root: NodeAddr // elected root (smallest reachable node_addr)
|
||||
```
|
||||
|
||||
### Per-Peer State
|
||||
@@ -222,8 +236,8 @@ Nodes do NOT know about other subtrees—only paths toward root.
|
||||
|
||||
```
|
||||
BloomState
|
||||
├── own_node_id: NodeId // always included in outgoing filters
|
||||
├── leaf_dependents: HashSet<NodeId> // leaf-only nodes we speak for
|
||||
├── own_node_addr: NodeAddr // always included in outgoing filters
|
||||
├── leaf_dependents: HashSet<NodeAddr> // leaf-only nodes we speak for
|
||||
├── is_leaf_only: bool // if true, no filter processing
|
||||
└── update_debounce: Duration // rate limit outgoing updates
|
||||
```
|
||||
@@ -243,7 +257,7 @@ Outgoing filter to peer Q is computed, not stored:
|
||||
|
||||
```
|
||||
outbound_filter(Q) =
|
||||
own_node_id
|
||||
own_node_addr
|
||||
∪ leaf_dependents
|
||||
∪ { entries from peer[P].inbound_filter for all P ≠ Q where filter_ttl > 0 }
|
||||
```
|
||||
@@ -373,7 +387,7 @@ PeerEvent
|
||||
├── LinkFailed { reason }
|
||||
├── Msg1Received { noise_payload }
|
||||
├── Msg2Received { noise_payload }
|
||||
├── HandshakeComplete { npub, node_id }
|
||||
├── HandshakeComplete { npub, node_addr }
|
||||
├── HandshakeFailed { reason }
|
||||
├── TreeAnnounceReceived { declaration, ancestry }
|
||||
├── FilterAnnounceReceived { filter, sequence, ttl }
|
||||
@@ -582,7 +596,7 @@ A leaf-only node uses a minimal subset of the full architecture:
|
||||
|
||||
```
|
||||
LeafOnlyNode
|
||||
├── identity: Identity // full (npub, nsec, node_id, address)
|
||||
├── identity: Identity // full (npub, nsec, node_addr, address)
|
||||
├── config: Config // simplified
|
||||
├── tun: TunInterface // full (provides IPv6 to local apps)
|
||||
├── transport: Transport // one
|
||||
@@ -622,7 +636,7 @@ The upstream peer entry for a leaf-only node:
|
||||
|
||||
```
|
||||
UpstreamPeer (leaf-only)
|
||||
├── node_id: NodeId
|
||||
├── node_addr: NodeAddr
|
||||
├── npub: PublicKey
|
||||
├── link_id: LinkId
|
||||
├── state: PeerState // auth lifecycle only
|
||||
@@ -739,7 +753,7 @@ The startup sequence initializes components in dependency order:
|
||||
|
||||
2. Initialize identity
|
||||
├── Load nsec from config (or generate if absent)
|
||||
├── Derive npub, node_id, and FIPS address
|
||||
├── Derive npub, node_addr, and FIPS address
|
||||
└── Log identity information
|
||||
|
||||
3. Initialize transports
|
||||
|
||||
@@ -38,13 +38,13 @@ coordinates and distances.
|
||||
TreeAnnounce {
|
||||
sequence: u64, // Monotonic, increments on parent change
|
||||
timestamp: u64, // Unix timestamp (seconds)
|
||||
parent: NodeId, // 32 bytes, SHA-256(npub) of selected parent
|
||||
parent: NodeAddr, // 32 bytes, SHA-256(pubkey) of selected parent
|
||||
ancestry: Vec<AncestryEntry>, // Path from self to root
|
||||
signature: Signature, // 64 bytes, signs (sequence || timestamp || parent || ancestry)
|
||||
}
|
||||
|
||||
AncestryEntry {
|
||||
node_id: NodeId, // 32 bytes
|
||||
node_addr: NodeAddr, // 32 bytes
|
||||
sequence: u64, // That node's sequence number
|
||||
timestamp: u64, // That node's timestamp
|
||||
signature: Signature, // That node's signature over its declaration
|
||||
@@ -59,7 +59,7 @@ 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_id of the selected parent. If `parent == self.node_id`,
|
||||
**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
|
||||
@@ -182,7 +182,7 @@ forward-compatible extension to larger filters.
|
||||
|
||||
```text
|
||||
for i in 0..hash_count:
|
||||
bit_index = hash(node_id, i) % (8 * bits.len())
|
||||
bit_index = hash(node_addr, i) % (8 * bits.len())
|
||||
if !bits[bit_index]: return false
|
||||
return true // "maybe present"
|
||||
```
|
||||
@@ -191,7 +191,7 @@ return true // "maybe present"
|
||||
|
||||
A node's outgoing filter to peer Q contains:
|
||||
|
||||
1. This node's own node_id
|
||||
1. This node's own node_addr
|
||||
2. Node_ids of leaf-only dependents (nodes using this node as sole peer)
|
||||
3. Entries from filters received from other peers (not Q) with TTL > 0
|
||||
|
||||
@@ -248,9 +248,9 @@ local Bloom filters.
|
||||
```text
|
||||
LookupRequest {
|
||||
request_id: u64, // Unique identifier for this request
|
||||
target: NodeId, // 32 bytes, who we're looking for
|
||||
origin: NodeId, // 32 bytes, who's asking
|
||||
origin_coords: Vec<NodeId>, // Origin's ancestry (for return path)
|
||||
target: NodeAddr, // 32 bytes, who we're looking for
|
||||
origin: NodeAddr, // 32 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
|
||||
}
|
||||
@@ -266,9 +266,9 @@ CompactBloomFilter {
|
||||
**request_id**: Randomly generated, used to match responses and detect
|
||||
duplicates.
|
||||
|
||||
**target**: The node_id being searched for.
|
||||
**target**: The node_addr being searched for.
|
||||
|
||||
**origin**: The node_id of the original requester. Used for response routing.
|
||||
**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.
|
||||
@@ -288,7 +288,7 @@ When receiving LookupRequest:
|
||||
3. Decrement TTL
|
||||
|
||||
4. Check if target is local:
|
||||
- If target == self.node_id: generate LookupResponse
|
||||
- If target == self.node_addr: generate LookupResponse
|
||||
- If target in local peer_filters: may respond on behalf (optional)
|
||||
|
||||
5. If TTL > 0 and not found locally:
|
||||
@@ -313,8 +313,8 @@ LookupResponse returns the target's coordinates to the requester.
|
||||
```text
|
||||
LookupResponse {
|
||||
request_id: u64, // Echoes LookupRequest.request_id
|
||||
target: NodeId, // 32 bytes, confirms who was found
|
||||
target_coords: Vec<NodeId>, // Target's ancestry (the key payload)
|
||||
target: NodeAddr, // 32 bytes, confirms who was found
|
||||
target_coords: Vec<NodeAddr>, // Target's ancestry (the key payload)
|
||||
proof: Signature, // 64 bytes, target signs to prove existence
|
||||
}
|
||||
```
|
||||
@@ -384,7 +384,7 @@ handshake completion.
|
||||
|
||||
## 8. Encoding
|
||||
|
||||
All multi-byte integers are little-endian. NodeId is 32 bytes (SHA-256 hash).
|
||||
All multi-byte integers are little-endian. NodeAddr is 32 bytes (SHA-256 hash).
|
||||
Signatures are 64 bytes (secp256k1 Schnorr).
|
||||
|
||||
Variable-length fields (ancestry, coordinates) are prefixed with a 2-byte
|
||||
@@ -431,7 +431,7 @@ Propagates spanning tree state between directly connected peers.
|
||||
│ │ 0 │ msg_type │ 1 byte │ 0x10 │ │
|
||||
│ │ 1 │ sequence │ 8 bytes │ u64 LE, monotonic counter │ │
|
||||
│ │ 9 │ timestamp │ 8 bytes │ u64 LE, Unix seconds │ │
|
||||
│ │ 17 │ parent │ 32 bytes │ NodeId of selected parent │ │
|
||||
│ │ 17 │ parent │ 32 bytes │ NodeAddr of selected parent │ │
|
||||
│ │ 49 │ ancestry_count │ 2 bytes │ u16 LE, number of entries │ │
|
||||
│ │ 51 │ ancestry[0..n] │ 112 × n │ AncestryEntry array │ │
|
||||
│ │ ... │ signature │ 64 bytes │ Schnorr sig over all above │ │
|
||||
@@ -442,7 +442,7 @@ Propagates spanning tree state between directly connected peers.
|
||||
│ ├────────┬──────────────────┬───────────┬───────────────────────────────┤ │
|
||||
│ │ Offset │ Field │ Size │ Description │ │
|
||||
│ ├────────┼──────────────────┼───────────┼───────────────────────────────┤ │
|
||||
│ │ 0 │ node_id │ 32 bytes │ SHA-256(npub) of this node │ │
|
||||
│ │ 0 │ node_addr │ 32 bytes │ SHA-256(pubkey) of this node │ │
|
||||
│ │ 32 │ sequence │ 8 bytes │ u64 LE, node's seq number │ │
|
||||
│ │ 40 │ timestamp │ 8 bytes │ u64 LE, node's timestamp │ │
|
||||
│ │ 48 │ signature │ 64 bytes │ Node's sig over its decl │ │
|
||||
@@ -467,29 +467,29 @@ PLAINTEXT BYTES (hex layout):
|
||||
10 ← msg_type = TreeAnnounce
|
||||
05 00 00 00 00 00 00 00 ← sequence = 5
|
||||
C3 B2 A1 67 00 00 00 00 ← timestamp (Unix seconds)
|
||||
[32 bytes P1's node_id] ← parent
|
||||
[32 bytes P1's node_addr] ← parent
|
||||
04 00 ← ancestry_count = 4
|
||||
|
||||
ANCESTRY[0] - Self (D):
|
||||
[32 bytes D's node_id]
|
||||
[32 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
|
||||
[64 bytes D's signature]
|
||||
|
||||
ANCESTRY[1] - Parent (P1):
|
||||
[32 bytes P1's node_id]
|
||||
[32 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
|
||||
[64 bytes P1's signature]
|
||||
|
||||
ANCESTRY[2] - Grandparent (P2):
|
||||
[32 bytes P2's node_id]
|
||||
[32 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
|
||||
[64 bytes P2's signature]
|
||||
|
||||
ANCESTRY[3] - Root:
|
||||
[32 bytes Root's node_id]
|
||||
[32 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 Root's signature]
|
||||
@@ -540,10 +540,10 @@ Propagates Bloom filter reachability information.
|
||||
│ │ bits 0-7 │ bits 8-15 │ │ bits 8184-8191 │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ To test membership of node_id: │
|
||||
│ 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_id, i) % filter_bits │
|
||||
│ bit_index = hash(node_addr, i) % filter_bits │
|
||||
│ if !bits[bit_index]: return false │
|
||||
│ return true // "maybe present" │
|
||||
│ │
|
||||
@@ -585,8 +585,8 @@ Discovers tree coordinates for distant destinations.
|
||||
│ ├────────┼──────────────────┼───────────┼───────────────────────────────┤ │
|
||||
│ │ 0 │ msg_type │ 1 byte │ 0x12 │ │
|
||||
│ │ 1 │ request_id │ 8 bytes │ u64 LE, unique identifier │ │
|
||||
│ │ 9 │ target │ 32 bytes │ NodeId being searched for │ │
|
||||
│ │ 41 │ origin │ 32 bytes │ NodeId of requester │ │
|
||||
│ │ 9 │ target │ 32 bytes │ NodeAddr being searched for │ │
|
||||
│ │ 41 │ origin │ 32 bytes │ NodeAddr of requester │ │
|
||||
│ │ 73 │ ttl │ 1 byte │ Remaining propagation hops │ │
|
||||
│ │ 74 │ origin_coords_cnt│ 2 bytes │ u16 LE │ │
|
||||
│ │ 76 │ origin_coords │ 32 × n │ Requester's ancestry │ │
|
||||
@@ -611,8 +611,8 @@ Discovers tree coordinates for distant destinations.
|
||||
PLAINTEXT BYTES:
|
||||
12 ← msg_type = LookupRequest
|
||||
[8 bytes request_id] ← random unique ID
|
||||
[32 bytes target node_id] ← who we're looking for
|
||||
[32 bytes origin node_id] ← who's asking
|
||||
[32 bytes target node_addr] ← who we're looking for
|
||||
[32 bytes origin node_addr] ← who's asking
|
||||
08 ← ttl = 8
|
||||
04 00 ← origin_coords_count = 4
|
||||
[32 bytes] × 4 ← origin's ancestry (128 bytes)
|
||||
@@ -638,7 +638,7 @@ Returns target's coordinates to the requester.
|
||||
│ ├────────┼──────────────────┼───────────┼───────────────────────────────┤ │
|
||||
│ │ 0 │ msg_type │ 1 byte │ 0x13 │ │
|
||||
│ │ 1 │ request_id │ 8 bytes │ u64 LE, echoes request │ │
|
||||
│ │ 9 │ target │ 32 bytes │ NodeId that was found │ │
|
||||
│ │ 9 │ target │ 32 bytes │ NodeAddr that was found │ │
|
||||
│ │ 41 │ target_coords_cnt│ 2 bytes │ u16 LE │ │
|
||||
│ │ 43 │ target_coords │ 32 × n │ Target's ancestry to root │ │
|
||||
│ │ ... │ proof │ 64 bytes │ Target's signature │ │
|
||||
@@ -664,7 +664,7 @@ Returns target's coordinates to the requester.
|
||||
PLAINTEXT BYTES:
|
||||
13 ← msg_type = LookupResponse
|
||||
[8 bytes request_id] ← echoed from request
|
||||
[32 bytes target node_id] ← confirms who was found
|
||||
[32 bytes target node_addr] ← confirms who was found
|
||||
05 00 ← target_coords_count = 5
|
||||
[32 bytes] × 5 ← target's ancestry (160 bytes)
|
||||
[64 bytes proof signature] ← target signs to prove existence
|
||||
|
||||
+54
-27
@@ -116,37 +116,47 @@ approach, enabling O(1) packet routing without relying on source addresses.
|
||||
|
||||
## Identity System
|
||||
|
||||
FIPS uses Nostr keypairs (secp256k1) as node identities. The public key (npub)
|
||||
identifies the node; the private key (nsec) signs protocol messages and
|
||||
establishes encrypted sessions.
|
||||
FIPS uses Nostr keypairs (secp256k1) as node identities. The public key
|
||||
identifies the node; the private key signs protocol messages and establishes
|
||||
encrypted sessions.
|
||||
|
||||
### Node ID Derivation
|
||||
The FIPS address (synonymous with the pubkey) is the primary means for
|
||||
application-layer software to identify communication endpoints. The
|
||||
bech32-encoded npub can be used interchangeably for user interface purposes.
|
||||
The FIPS datagram service is exposed to the application layer either via a
|
||||
native API to the FIPS node software, or through an IPv6 shim driver that
|
||||
converts the node identity into an IPv6 address and provides DNS resolution
|
||||
from npub to this address for traditional software.
|
||||
|
||||
The npub is hashed to derive a node_id used for routing:
|
||||
### Node Address Derivation
|
||||
|
||||
The pubkey is hashed to derive a node_addr used for routing:
|
||||
|
||||
```
|
||||
npub (secp256k1 x-only pubkey, 32 bytes)
|
||||
pubkey (secp256k1 x-only, 32 bytes)
|
||||
→ SHA-256
|
||||
→ node_id (32 bytes)
|
||||
→ node_addr (32 bytes)
|
||||
→ truncate with prefix
|
||||
→ FIPS address (128 bits, fd::/8)
|
||||
→ IPv6 address (128 bits, fd::/8)
|
||||
```
|
||||
|
||||
**Why hash the npub?** The node_id is the only identifier used at the protocol
|
||||
level; the associated npub cannot be derived from it. This one-way derivation
|
||||
also prevents "grinding" attacks—secp256k1 public keys can be shifted to achieve
|
||||
specific prefixes via modular addition, but targeting a node_id prefix requires
|
||||
full brute force against SHA-256.
|
||||
**Separation of concerns**: The keypair handles cryptographic operations (signing,
|
||||
encryption). The node_addr derived from the pubkey handles routing. This keeps
|
||||
cryptographic material out of routing tables and packet headers—the node_addr is
|
||||
the only identifier used at the protocol level, and the pubkey cannot be derived
|
||||
from it.
|
||||
|
||||
**Separation of concerns**: The nsec/npub keypair handles cryptographic
|
||||
operations (signing, encryption). The node_id derived from the npub handles
|
||||
routing. This keeps cryptographic material out of routing tables and packet
|
||||
headers.
|
||||
The one-way hash also provides privacy from intermediate routing nodes. Routers
|
||||
see only node_addrs in packet headers—they can route traffic without learning
|
||||
the Nostr identities of the endpoints. An observer can verify "does this
|
||||
node_addr belong to pubkey X?" but cannot enumerate which pubkeys are
|
||||
communicating by inspecting traffic. Only the endpoints, which complete the
|
||||
Noise IK handshake, learn each other's pubkeys.
|
||||
|
||||
### Address Format
|
||||
|
||||
When using the IPv6 protocol adapter, FIPS addresses use the IPv6 Unique Local
|
||||
Address (ULA) prefix `fd00::/8`, providing 120 bits from the node_id hash.
|
||||
Address (ULA) prefix `fd00::/8`, providing 120 bits from the node_addr hash.
|
||||
These are overlay identifiers—they appear in the TUN interface for application
|
||||
compatibility but are not routable on the underlying transport. The fd prefix
|
||||
ensures no collision with addresses that may be in use on the transport network.
|
||||
@@ -163,6 +173,23 @@ See [fips-wire-protocol.md](fips-wire-protocol.md) for the Noise IK handshake
|
||||
and [fips-session-protocol.md](fips-session-protocol.md) for end-to-end
|
||||
session establishment.
|
||||
|
||||
### Terminology: Addresses and Identifiers
|
||||
|
||||
FIPS uses several related but distinct identifiers at different protocol layers:
|
||||
|
||||
| Term | Layer | Visible To | Description |
|
||||
|----------------------------|---------------------|----------------|-------------------------------------------------------|
|
||||
| **FIPS address / pubkey** | Application/Session | Endpoints only | 32-byte secp256k1 public key - the endpoint identity |
|
||||
| **npub** | (encoding) | Human readers | Bech32 encoding of pubkey for display/config |
|
||||
| **node_addr** | Routing | Routing nodes | SHA-256(pubkey) - cannot be reversed to pubkey |
|
||||
| **link_addr** | Transport | Direct peers | IP:port, MAC, .onion - transport-specific |
|
||||
| **IPv6 address** | IPv6 shim | Applications | fd::/8 derived from node_addr - optional compatibility|
|
||||
|
||||
**Privacy property**: The pubkey (FIPS address / Nostr identity) is never exposed to
|
||||
intermediate routing nodes. They see only the node_addr, a one-way hash. An observer
|
||||
can verify "does this node_addr belong to pubkey X?" but cannot derive the pubkey from
|
||||
traffic.
|
||||
|
||||
---
|
||||
|
||||
## Two-Layer Encryption
|
||||
@@ -238,7 +265,7 @@ minimizes distance to the destination.
|
||||
|
||||
### Root Election
|
||||
|
||||
The root is the node with the lexicographically smallest node_id among all
|
||||
The root is the node with the lexicographically smallest node_addr among all
|
||||
reachable nodes. This election is deterministic and requires no coordination—
|
||||
each node independently examines its view of the network and reaches the same
|
||||
conclusion.
|
||||
@@ -270,7 +297,7 @@ unaffected by local path changes and don't receive updates for them.
|
||||
### Partition Handling
|
||||
|
||||
If the network partitions, each isolated segment elects its own root (the
|
||||
smallest node_id within that segment). When partitions merge, nodes in the
|
||||
smallest node_addr within that segment). When partitions merge, nodes in the
|
||||
segment with the larger root discover the globally smaller root and re-parent.
|
||||
The tree reconverges automatically.
|
||||
|
||||
@@ -293,14 +320,14 @@ through a given peer, with occasional false positives handled by backtracking.
|
||||
|
||||
### How It Works
|
||||
|
||||
Each node maintains bloom filters summarizing which node_ids are reachable
|
||||
Each node maintains bloom filters summarizing which node_addrs are reachable
|
||||
through each of its peers. These filters propagate through the tree: a node
|
||||
aggregates filters from its children and announces the combined filter to its
|
||||
parent (and vice versa).
|
||||
|
||||
When a node needs to reach an unknown destination:
|
||||
|
||||
1. Check local bloom filters—which peers might be able to reach this node_id?
|
||||
1. Check local bloom filters—which peers might be able to reach this node_addr?
|
||||
2. Send a LookupRequest to peers whose filters indicate "maybe"
|
||||
3. The request propagates through the tree toward matching subtrees
|
||||
4. The destination responds with a LookupResponse containing its coordinates
|
||||
@@ -317,7 +344,7 @@ Filters propagate in the opposite direction from tree announcements:
|
||||
- Tree state propagates upward (toward root) via ancestry chains
|
||||
- Bloom filters propagate downward (toward leaves) via subtree aggregation
|
||||
|
||||
A node's filter contains all node_ids reachable through its subtree. The root's
|
||||
A node's filter contains all node_addrs reachable through its subtree. The root's
|
||||
filter contains everyone; leaf nodes have empty outbound filters.
|
||||
|
||||
See [fips-routing.md](fips-routing.md) for bloom filter design and
|
||||
@@ -490,11 +517,11 @@ Each entity in the network sees different information:
|
||||
|--------|---------|
|
||||
| Transport observer | Encrypted packets, timing, packet sizes |
|
||||
| Direct peer | Your npub (identity), traffic volume, timing |
|
||||
| Intermediate router | Source and destination node_ids, packet size |
|
||||
| Intermediate router | Source and destination node_addrs, packet size |
|
||||
| Destination | Your npub (identity), payload content |
|
||||
|
||||
Intermediate routers see node_ids, not npubs. Since node_ids are derived from
|
||||
npubs via one-way SHA-256 hash, routers cannot determine the actual identities
|
||||
Intermediate routers see node_addrs, not npubs. Since node_addrs are derived from
|
||||
pubkeys via one-way SHA-256 hash, routers cannot determine the actual identities
|
||||
of the endpoints they route for.
|
||||
|
||||
The session layer hides payload content from intermediate routers. The link
|
||||
@@ -507,7 +534,7 @@ layer hides everything from passive observers on the underlying transport.
|
||||
FIPS combines these elements into a cohesive system that achieves its design
|
||||
goals:
|
||||
|
||||
- **Self-sovereign identity** through Nostr keypairs, with node_ids providing
|
||||
- **Self-sovereign identity** through Nostr keypairs, with node_addrs providing
|
||||
routing-level privacy
|
||||
- **Transport agnosticism** via the transport abstraction layer, enabling the
|
||||
same routing logic across UDP, Ethernet/WiFi, Tor, and other link types
|
||||
|
||||
+12
-12
@@ -177,7 +177,7 @@ node's npub, truncated or used directly as the filter key.
|
||||
Each node maintains a Bloom filter for each peer direction:
|
||||
|
||||
```rust
|
||||
peer_filters: HashMap<NodeId, BloomFilter>
|
||||
peer_filters: HashMap<NodeAddr, BloomFilter>
|
||||
```
|
||||
|
||||
The filter for peer P answers: "Which destinations are reachable through P?"
|
||||
@@ -287,11 +287,11 @@ Discovered coordinates are cached:
|
||||
|
||||
```rust
|
||||
struct RouteCache {
|
||||
entries: HashMap<NodeId, CachedCoords>,
|
||||
entries: HashMap<NodeAddr, CachedCoords>,
|
||||
}
|
||||
|
||||
struct CachedCoords {
|
||||
coords: Vec<NodeId>,
|
||||
coords: Vec<NodeAddr>,
|
||||
discovered_at: Timestamp,
|
||||
last_used: Timestamp,
|
||||
}
|
||||
@@ -320,7 +320,7 @@ Example: Node D at depth 4 has coordinates `[D, P1, P2, P3, Root]`.
|
||||
Distance between two nodes is hops through their lowest common ancestor (LCA):
|
||||
|
||||
```rust
|
||||
fn tree_distance(a_coords: &[NodeId], b_coords: &[NodeId]) -> usize {
|
||||
fn tree_distance(a_coords: &[NodeAddr], b_coords: &[NodeAddr]) -> usize {
|
||||
let lca_depth = longest_common_suffix_length(a_coords, b_coords);
|
||||
let a_to_lca = a_coords.len() - lca_depth;
|
||||
let b_to_lca = b_coords.len() - lca_depth;
|
||||
@@ -333,16 +333,16 @@ Note: Coordinates are ordered self-to-root, so common ancestry is a suffix.
|
||||
### Greedy Routing Algorithm
|
||||
|
||||
```rust
|
||||
fn greedy_next_hop(&self, dest_coords: &[NodeId]) -> NodeId {
|
||||
fn greedy_next_hop(&self, dest_coords: &[NodeAddr]) -> NodeAddr {
|
||||
// Check if we are the destination
|
||||
if dest_coords[0] == self.node_id {
|
||||
if dest_coords[0] == self.node_addr {
|
||||
return LOCAL_DELIVERY;
|
||||
}
|
||||
|
||||
// Check if destination is a direct peer
|
||||
for peer in &self.peers {
|
||||
if peer.node_id == dest_coords[0] {
|
||||
return peer.node_id;
|
||||
if peer.node_addr == dest_coords[0] {
|
||||
return peer.node_addr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,7 +350,7 @@ fn greedy_next_hop(&self, dest_coords: &[NodeId]) -> NodeId {
|
||||
self.peers
|
||||
.iter()
|
||||
.min_by_key(|p| tree_distance(&p.coords, dest_coords))
|
||||
.map(|p| p.node_id)
|
||||
.map(|p| p.node_addr)
|
||||
.expect("no peers")
|
||||
}
|
||||
```
|
||||
@@ -389,7 +389,7 @@ 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(npub)`, not the
|
||||
**Partial mitigation**: FIPS addresses are derived from `SHA-256(pubkey)`, not the
|
||||
npub itself. An observer learns that `fd12:3456:...` is communicating with
|
||||
`fd78:9abc:...`, but cannot directly determine the Nostr identities without
|
||||
additional information (e.g., DNS lookup correlation, prior knowledge of the
|
||||
@@ -493,7 +493,7 @@ impl Router {
|
||||
// Cache miss — request coordinates
|
||||
self.send_error(from, CoordsRequired {
|
||||
dest_addr: packet.dest_addr,
|
||||
reporter: self.node_id,
|
||||
reporter: self.node_addr,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -510,7 +510,7 @@ struct CoordCache {
|
||||
}
|
||||
|
||||
struct CacheEntry {
|
||||
coords: Vec<NodeId>,
|
||||
coords: Vec<NodeAddr>,
|
||||
created: Timestamp,
|
||||
last_used: Timestamp,
|
||||
expires: Timestamp,
|
||||
|
||||
@@ -5,13 +5,31 @@
|
||||
This document captures design considerations for FIPS protocol message flow,
|
||||
including peer discovery, authentication, tree announcements, and data routing.
|
||||
|
||||
### Access to the FIPS Datagram Service
|
||||
|
||||
Applications can access FIPS datagram delivery through two interfaces:
|
||||
|
||||
- **Native FIPS API**: Applications address destinations directly by npub or
|
||||
public key. The FIPS stack resolves the destination's node_addr and routes
|
||||
without any DNS involvement. This is the preferred interface for
|
||||
FIPS-aware applications.
|
||||
|
||||
- **IPv6 adapter (TUN interface)**: Unmodified IPv6 applications use a TUN
|
||||
device with `fd::/8` routing. Because IPv6 addresses are one-way hashes of
|
||||
public keys, a local DNS service maps npub → IPv6 address and primes the
|
||||
identity cache so the TUN can route arriving packets.
|
||||
|
||||
The remainder of this document details the sequence of actions initiated through
|
||||
the IPv6 adapter path. The native FIPS API bypasses sections 1.1–1.4 (DNS
|
||||
entry point, identity cache) entirely, entering the flow at route discovery.
|
||||
|
||||
---
|
||||
|
||||
## 1. Application-Initiated Traffic Flow
|
||||
## 1. Application-Initiated Traffic Flow (IPv6 Adapter)
|
||||
|
||||
> **Note**: This section applies to traditional IP-based applications using the
|
||||
> TUN interface. Applications using the native FIPS datagram service address
|
||||
> destinations directly by npub, and routing proceeds from there without DNS.
|
||||
> TUN interface. Applications using the native FIPS API address destinations
|
||||
> directly by npub or public key; routing proceeds from there without DNS.
|
||||
|
||||
Traffic flow begins at the application layer with a DNS query, which triggers
|
||||
a cascade of events through the FIPS stack.
|
||||
@@ -27,7 +45,7 @@ an npub. The flow:
|
||||
2. **FIPS DNS service** performs two functions:
|
||||
- **Address derivation**: Converts the npub to an identity and derives the
|
||||
corresponding `fd::/8` IPv6 address
|
||||
- **Cache priming**: Stores the identity mapping (IPv6 address ↔ npub ↔ node_id)
|
||||
- **Cache priming**: Stores the identity mapping (IPv6 address ↔ npub ↔ node_addr)
|
||||
in the local FIPS routing cache
|
||||
|
||||
3. **DNS Response**: Returns the derived IPv6 address to the application
|
||||
@@ -65,7 +83,7 @@ for address derivation.
|
||||
|
||||
### 1.4 Identity Cache Lifetime
|
||||
|
||||
The identity cache (IPv6 address ↔ npub ↔ node_id) has the following lifetime
|
||||
The identity cache (IPv6 address ↔ npub ↔ node_addr) has the following lifetime
|
||||
semantics:
|
||||
|
||||
- **Configurable timeout**: Cache entries expire after a configured duration
|
||||
@@ -91,7 +109,7 @@ Cache entry expires
|
||||
A packet may arrive at the TUN for an `fd::/8` destination without a prior
|
||||
DNS lookup (cached address, manual configuration, etc.). Since address
|
||||
derivation is one-way (SHA-256), the npub cannot be recovered from the address,
|
||||
and without the npub we cannot determine the node_id needed for routing.
|
||||
and without the npub we cannot determine the node_addr needed for routing.
|
||||
|
||||
FIPS returns ICMPv6 Destination Unreachable (Code 0: No route to destination)
|
||||
for packets to unknown addresses. The identity cache must be populated before
|
||||
@@ -139,13 +157,13 @@ On receiving a packet, the TUN reader:
|
||||
|
||||
4. **Retrieve routing identity**: Cache hit provides:
|
||||
- `npub`: The Nostr public key of the destination
|
||||
- `node_id`: SHA-256(npub), used for spanning tree routing
|
||||
- `node_addr`: SHA-256(pubkey), used for spanning tree routing
|
||||
|
||||
5. **Session lookup**: Check for existing FIPS session with destination npub
|
||||
- **Hit**: Use existing session for encryption/signing
|
||||
- **Miss**: Initiate session establishment (see §3)
|
||||
|
||||
6. **Route determination**: Using node_id, determine the next hop peer:
|
||||
6. **Route determination**: Using node_addr, determine the next hop peer:
|
||||
- Check route cache for destination's spanning tree coordinates
|
||||
- If cache miss, initiate route discovery (see §4.4)
|
||||
- Select next hop via greedy routing toward destination coordinates
|
||||
@@ -166,7 +184,7 @@ between two FIPS nodes.
|
||||
|
||||
Each session contains:
|
||||
|
||||
- **Peer identity**: The remote node's npub and node_id
|
||||
- **Peer identity**: The remote node's npub and node_addr
|
||||
- **Symmetric session keys**: Directional keys for encryption (send_key, recv_key)
|
||||
- **Nonce counters**: Per-direction counters for replay protection
|
||||
|
||||
@@ -185,7 +203,7 @@ When the TUN reader has a packet for a destination with no existing session:
|
||||
```text
|
||||
TUN reader
|
||||
│
|
||||
├─► Identity cache lookup → node_id
|
||||
├─► Identity cache lookup → node_addr
|
||||
│
|
||||
├─► Session lookup (by npub) → MISS
|
||||
│
|
||||
@@ -243,7 +261,7 @@ FIPS routing combines three mechanisms:
|
||||
2. **Discovery protocol**: Query-based lookup for distant destinations
|
||||
3. **Greedy tree routing**: Coordinate-based forwarding using spanning tree position
|
||||
|
||||
The routing layer maintains a route cache mapping `node_id → (coordinates,
|
||||
The routing layer maintains a route cache mapping `node_addr → (coordinates,
|
||||
next_hop_peer)`. Cache hits enable immediate greedy routing; cache misses
|
||||
trigger route discovery via bloom filter queries or LookupRequest flooding.
|
||||
|
||||
@@ -400,8 +418,8 @@ The Noise handshake messages embed in SessionSetup/SessionAck:
|
||||
```text
|
||||
SessionSetup {
|
||||
// Routing portion (processed by routers)
|
||||
src_coords: Vec<NodeId>,
|
||||
dest_coords: Vec<NodeId>,
|
||||
src_coords: Vec<NodeAddr>,
|
||||
dest_coords: Vec<NodeAddr>,
|
||||
src_addr: Ipv6Addr,
|
||||
dest_addr: Ipv6Addr,
|
||||
|
||||
@@ -411,7 +429,7 @@ SessionSetup {
|
||||
|
||||
SessionAck {
|
||||
// Routing portion
|
||||
src_coords: Vec<NodeId>, // Responder's coordinates
|
||||
src_coords: Vec<NodeAddr>, // Responder's coordinates
|
||||
|
||||
// Crypto portion
|
||||
handshake_payload: Vec<u8>, // Noise IK message 2
|
||||
@@ -514,6 +532,12 @@ the inner payload is encrypted end-to-end with session keys.
|
||||
| 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 (32-byte 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.
|
||||
@@ -527,12 +551,12 @@ Establishes a crypto session and warms router coordinate caches along the path.
|
||||
│ 0 │ msg_type │ 1 byte │ 0x00 │
|
||||
│ 1 │ flags │ 1 byte │ Bit 0: REQUEST_ACK │
|
||||
│ │ │ │ Bit 1: BIDIRECTIONAL │
|
||||
│ 2 │ src_addr │ 16 bytes │ Source fd::/8 address │
|
||||
│ 18 │ dest_addr │ 16 bytes │ Destination fd::/8 address │
|
||||
│ 34 │ src_coords_count │ 2 bytes │ u16 LE, number of src coord entries │
|
||||
│ 36 │ src_coords │ 32 × n │ NodeId array (self → root) │
|
||||
│ 2 │ src_addr │ 32 bytes │ Source node_addr │
|
||||
│ 34 │ dest_addr │ 32 bytes │ Destination node_addr │
|
||||
│ 66 │ src_coords_count │ 2 bytes │ u16 LE, number of src coord entries │
|
||||
│ 68 │ src_coords │ 32 × n │ NodeAddr array (self → root) │
|
||||
│ ... │ dest_coords_count│ 2 bytes │ u16 LE, number of dest coord entries│
|
||||
│ ... │ dest_coords │ 32 × m │ NodeId array (dest → root) │
|
||||
│ ... │ dest_coords │ 32 × m │ NodeAddr array (dest → root) │
|
||||
│ ... │ handshake_len │ 2 bytes │ u16 LE, Noise payload length │
|
||||
│ ... │ handshake_payload│ variable │ Noise IK msg1 (82 bytes typical) │
|
||||
└────────┴──────────────────┴───────────┴─────────────────────────────────────┘
|
||||
@@ -543,13 +567,13 @@ Establishes a crypto session and warms router coordinate caches along the path.
|
||||
```text
|
||||
┌──────┬───────┬──────────────────┬──────────────────┬───────┬─────────────┐
|
||||
│ 0x00 │ 0x01 │ src_addr │ dest_addr │ 0x03 │ src_coords │
|
||||
│ type │ flags │ 16 bytes │ 16 bytes │ count │ 3 × 32 bytes│
|
||||
│ type │ flags │ 32 bytes │ 32 bytes │ count │ 3 × 32 bytes│
|
||||
├──────┴───────┴──────────────────┴──────────────────┴───────┴─────────────┤
|
||||
│ 0x04 │ dest_coords │ 0x52 │ handshake_payload │
|
||||
│ count │ 4 × 32 bytes │ len=82│ 82 bytes (Noise IK msg1) │
|
||||
└───────┴───────────────┴───────┴──────────────────────────────────────────┘
|
||||
|
||||
Total: 1 + 1 + 16 + 16 + 2 + 96 + 2 + 128 + 2 + 82 = 346 bytes
|
||||
Total: 1 + 1 + 32 + 32 + 2 + 96 + 2 + 128 + 2 + 82 = 378 bytes
|
||||
```
|
||||
|
||||
### 8.3 SessionAck (0x01)
|
||||
@@ -564,10 +588,10 @@ Confirms session establishment and completes the Noise handshake.
|
||||
├────────┼──────────────────┼───────────┼─────────────────────────────────────┤
|
||||
│ 0 │ msg_type │ 1 byte │ 0x01 │
|
||||
│ 1 │ flags │ 1 byte │ Reserved │
|
||||
│ 2 │ src_addr │ 16 bytes │ Acknowledger's address │
|
||||
│ 18 │ dest_addr │ 16 bytes │ Original sender's address │
|
||||
│ 34 │ src_coords_count │ 2 bytes │ u16 LE │
|
||||
│ 36 │ src_coords │ 32 × n │ Acknowledger's coords (for caching) │
|
||||
│ 2 │ src_addr │ 32 bytes │ Acknowledger's node_addr │
|
||||
│ 34 │ dest_addr │ 32 bytes │ Original sender's node_addr │
|
||||
│ 66 │ src_coords_count │ 2 bytes │ u16 LE │
|
||||
│ 68 │ src_coords │ 32 × n │ Acknowledger's coords (for caching) │
|
||||
│ ... │ handshake_len │ 2 bytes │ u16 LE, Noise payload length │
|
||||
│ ... │ handshake_payload│ variable │ Noise IK msg2 (33 bytes typical) │
|
||||
└────────┴──────────────────┴───────────┴─────────────────────────────────────┘
|
||||
@@ -588,12 +612,12 @@ Carries encrypted application data (typically IPv6 payloads).
|
||||
│ 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 fd::/8 address │
|
||||
│ 22 │ dest_addr │ 16 bytes │ Destination fd::/8 address │
|
||||
│ 38 │ payload │ variable │ Encrypted application data │
|
||||
│ 6 │ src_addr │ 32 bytes │ Source node_addr │
|
||||
│ 38 │ dest_addr │ 32 bytes │ Destination node_addr │
|
||||
│ 70 │ payload │ variable │ Encrypted application data │
|
||||
└────────┴──────────────────┴───────────┴─────────────────────────────────────┘
|
||||
|
||||
Minimal header: 38 bytes (comparable to IPv6's 40 bytes)
|
||||
Minimal header: 70 bytes
|
||||
```
|
||||
|
||||
When `COORDS_PRESENT` flag is set (route warming after CoordsRequired):
|
||||
@@ -609,16 +633,16 @@ When `COORDS_PRESENT` flag is set (route warming after CoordsRequired):
|
||||
│ 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 fd::/8 address │
|
||||
│ 22 │ dest_addr │ 16 bytes │ Destination fd::/8 address │
|
||||
│ 38 │ src_coords_count │ 2 bytes │ u16 LE │
|
||||
│ 40 │ src_coords │ 32 × n │ Source coordinates │
|
||||
│ 6 │ src_addr │ 32 bytes │ Source node_addr │
|
||||
│ 38 │ dest_addr │ 32 bytes │ Destination node_addr │
|
||||
│ 70 │ src_coords_count │ 2 bytes │ u16 LE │
|
||||
│ 72 │ src_coords │ 32 × n │ Source coordinates │
|
||||
│ ... │ dest_coords_count│ 2 bytes │ u16 LE │
|
||||
│ ... │ dest_coords │ 32 × m │ Destination coordinates │
|
||||
│ ... │ payload │ variable │ Encrypted application data │
|
||||
└────────┴──────────────────┴───────────┴─────────────────────────────────────┘
|
||||
|
||||
With depth-4 coords both directions: 38 + 2 + 128 + 2 + 128 = 298 bytes header
|
||||
With depth-4 coords both directions: 70 + 2 + 128 + 2 + 128 = 330 bytes header
|
||||
```
|
||||
|
||||
### 8.5 CoordsRequired (0x20)
|
||||
@@ -634,11 +658,11 @@ coordinate cache miss.
|
||||
├────────┼──────────────────┼───────────┼─────────────────────────────────────┤
|
||||
│ 0 │ msg_type │ 1 byte │ 0x20 │
|
||||
│ 1 │ flags │ 1 byte │ Reserved │
|
||||
│ 2 │ dest_addr │ 16 bytes │ The address we couldn't route │
|
||||
│ 18 │ reporter │ 32 bytes │ NodeId of reporting router │
|
||||
│ 2 │ dest_addr │ 32 bytes │ The node_addr we couldn't route │
|
||||
│ 34 │ reporter │ 32 bytes │ NodeAddr of reporting router │
|
||||
└────────┴──────────────────┴───────────┴─────────────────────────────────────┘
|
||||
|
||||
Total: 50 bytes
|
||||
Total: 66 bytes
|
||||
```
|
||||
|
||||
### 8.6 PathBroken (0x21)
|
||||
@@ -653,10 +677,10 @@ Sent when greedy routing fails (no peer is closer to destination).
|
||||
├────────┼──────────────────┼───────────┼─────────────────────────────────────┤
|
||||
│ 0 │ msg_type │ 1 byte │ 0x21 │
|
||||
│ 1 │ flags │ 1 byte │ Reserved │
|
||||
│ 2 │ dest_addr │ 16 bytes │ The unreachable destination │
|
||||
│ 18 │ reporter │ 32 bytes │ NodeId of reporting router │
|
||||
│ 50 │ last_coords_count│ 2 bytes │ u16 LE │
|
||||
│ 52 │ last_known_coords│ 32 × n │ Stale coords that failed │
|
||||
│ 2 │ dest_addr │ 32 bytes │ The unreachable node_addr │
|
||||
│ 34 │ reporter │ 32 bytes │ NodeAddr of reporting router │
|
||||
│ 66 │ last_coords_count│ 2 bytes │ u16 LE │
|
||||
│ 68 │ last_known_coords│ 32 × n │ Stale coords that failed │
|
||||
└────────┴──────────────────┴───────────┴─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -711,12 +735,12 @@ Router R cannot see: payload contents (encrypted with S↔D keys)
|
||||
### 8.8 Encoding Rules
|
||||
|
||||
- All multi-byte integers are **little-endian**
|
||||
- NodeId is 32 bytes (SHA-256 hash of npub)
|
||||
- NodeAddr is 32 bytes (SHA-256 hash of npub)
|
||||
- IPv6 addresses are 16 bytes (network byte order)
|
||||
- Variable-length coordinate arrays use 2-byte u16 count prefix
|
||||
|
||||
```text
|
||||
Vec<NodeId> encoding:
|
||||
Vec<NodeAddr> encoding:
|
||||
count: u16 (little-endian)
|
||||
items: [u8; 32] × count
|
||||
```
|
||||
|
||||
@@ -331,11 +331,11 @@ When using `PeerSlot` enum, need reverse lookups for packet dispatch:
|
||||
```rust
|
||||
struct Node {
|
||||
// Primary storage
|
||||
peers: HashMap<NodeId, PeerSlot>,
|
||||
peers: HashMap<NodeAddr, PeerSlot>,
|
||||
|
||||
// Reverse lookup: (transport, remote_addr) → NodeId
|
||||
// Needed because ReceivedPacket has addr, not NodeId
|
||||
addr_to_peer: HashMap<(TransportId, TransportAddr), NodeId>,
|
||||
// Reverse lookup: (transport, remote_addr) → NodeAddr
|
||||
// Needed because ReceivedPacket has addr, not NodeAddr
|
||||
addr_to_peer: HashMap<(TransportId, TransportAddr), NodeAddr>,
|
||||
}
|
||||
```
|
||||
|
||||
@@ -344,7 +344,7 @@ For inbound connections from unknown addresses:
|
||||
1. Receive Noise IK msg1 → decrypt to extract sender's static key (identity)
|
||||
2. Create new PeerConnection with discovered identity
|
||||
3. Add to `connections` (by LinkId) and `addr_to_link`
|
||||
4. After handshake completes, promote to ActivePeer (indexed by NodeId)
|
||||
4. After handshake completes, promote to ActivePeer (indexed by NodeAddr)
|
||||
|
||||
## Summary
|
||||
|
||||
|
||||
@@ -38,6 +38,11 @@ authoritative. Only successful cryptographic verification establishes authentici
|
||||
When a valid packet arrives from a different address than expected, the peer's
|
||||
address is updated rather than the packet being rejected.
|
||||
|
||||
> **Terminology note**: "Source address" in this document refers to the transport-layer
|
||||
> address (link_addr)—e.g., IP:port for UDP, MAC for Ethernet, .onion for Tor. This is
|
||||
> distinct from node_addr (the routing identifier) and FIPS address/pubkey (the endpoint
|
||||
> identity). See [fips-intro.md](fips-intro.md) §Identity System for the full terminology.
|
||||
|
||||
---
|
||||
|
||||
## 2. Wire Format
|
||||
@@ -170,11 +175,11 @@ Packet dispatch follows a two-phase approach:
|
||||
```
|
||||
Node:
|
||||
// === Authenticated sessions ===
|
||||
// Primary dispatch: our_index → NodeId
|
||||
peers_by_index: HashMap<(TransportId, u32), NodeId>
|
||||
// Primary dispatch: our_index → NodeAddr
|
||||
peers_by_index: HashMap<(TransportId, u32), NodeAddr>
|
||||
|
||||
// Peer data by identity
|
||||
peers: HashMap<NodeId, ActivePeer>
|
||||
peers: HashMap<NodeAddr, ActivePeer>
|
||||
|
||||
// === Pending handshakes ===
|
||||
// Outbound: our sender_idx → connection state
|
||||
@@ -201,11 +206,11 @@ receive_encrypted(transport_id, source_addr, data):
|
||||
ciphertext = data[13..]
|
||||
|
||||
// O(1) session lookup by index
|
||||
node_id = peers_by_index.get((transport_id, receiver_idx))
|
||||
if node_id is None:
|
||||
node_addr = peers_by_index.get((transport_id, receiver_idx))
|
||||
if node_addr is None:
|
||||
drop("unknown index") // No crypto, minimal CPU cost
|
||||
|
||||
peer = peers.get(node_id)
|
||||
peer = peers.get(node_addr)
|
||||
|
||||
// Replay check BEFORE decryption (cheap)
|
||||
if not peer.replay_window.check(counter):
|
||||
@@ -228,7 +233,7 @@ receive_encrypted(transport_id, source_addr, data):
|
||||
peer.stats.record_recv(data.len())
|
||||
|
||||
// Dispatch to message handler
|
||||
dispatch_link_message(node_id, plaintext)
|
||||
dispatch_link_message(node_addr, plaintext)
|
||||
```
|
||||
|
||||
**Key properties**:
|
||||
@@ -316,7 +321,7 @@ receive_msg1(transport_id, source_addr, data):
|
||||
// === IDENTITY CHECKS ===
|
||||
|
||||
// Check if this is a known peer reconnecting
|
||||
if peers.contains(peer_identity.node_id):
|
||||
if peers.contains(peer_identity.node_addr):
|
||||
// Existing peer from new address - handle reconnection
|
||||
handle_peer_reconnection(peer_identity, source_addr, ...)
|
||||
return
|
||||
@@ -612,14 +617,14 @@ release(transport_id, idx):
|
||||
When a session rekeys, new indices are allocated:
|
||||
|
||||
```
|
||||
rekey(node_id):
|
||||
peer = peers.get(node_id)
|
||||
rekey(node_addr):
|
||||
peer = peers.get(node_addr)
|
||||
old_index = peer.our_index
|
||||
new_index = index_allocator.allocate(peer.transport_id)
|
||||
|
||||
// Update index mapping
|
||||
peers_by_index.remove((peer.transport_id, old_index))
|
||||
peers_by_index.insert((peer.transport_id, new_index), node_id)
|
||||
peers_by_index.insert((peer.transport_id, new_index), node_addr)
|
||||
|
||||
// Release old index
|
||||
index_allocator.release(peer.transport_id, old_index)
|
||||
|
||||
@@ -64,7 +64,7 @@ maintains roughly 50 TreeState entries, not 1000.
|
||||
|
||||
### Root Election
|
||||
|
||||
The root is deterministic: the node with the lexicographically smallest node_id
|
||||
The root is deterministic: the node with the lexicographically smallest node_addr
|
||||
among all reachable nodes. No explicit election protocol exists—each node
|
||||
independently derives the same answer from its local TreeState.
|
||||
|
||||
@@ -86,7 +86,7 @@ When a node starts with no peers, it bootstraps as a single-node network.
|
||||
```
|
||||
Time T0: Node A starts
|
||||
├── Generates or loads keypair (npub_A, nsec_A)
|
||||
├── Computes node_id_A = SHA-256(npub_A)
|
||||
├── Computes node_addr_A = SHA-256(npub_A)
|
||||
├── Initializes empty TreeState
|
||||
├── Sets parent = self (A is its own root)
|
||||
├── Sets sequence = 1
|
||||
@@ -125,7 +125,7 @@ integrates it into the spanning tree.
|
||||
|
||||
```
|
||||
Existing tree structure:
|
||||
A (root, smallest node_id)
|
||||
A (root, smallest node_addr)
|
||||
/|\
|
||||
C D E
|
||||
|
||||
@@ -157,8 +157,8 @@ B receives D's TreeAnnounce:
|
||||
│ └── TreeState_B = { (B, parent=B, seq=1), (D, parent=A, seq=47), (A, parent=A, seq=203) }
|
||||
│
|
||||
├── Evaluates root:
|
||||
│ └── Compares node_id_A vs node_id_B
|
||||
│ └── If A < B: Root_B = A (A has smaller node_id)
|
||||
│ └── Compares node_addr_A vs node_addr_B
|
||||
│ └── If A < B: Root_B = A (A has smaller node_addr)
|
||||
│
|
||||
└── Evaluates parent selection:
|
||||
└── Only peer is D
|
||||
@@ -195,7 +195,7 @@ D receives and merges:
|
||||
**T5: D updates its bloom filter**:
|
||||
|
||||
```
|
||||
D adds B's node_id to its bloom filter
|
||||
D adds B's node_addr to its bloom filter
|
||||
D sends FilterAnnounce to parent A
|
||||
|
||||
A merges D's bloom filter with its view of D's subtree
|
||||
@@ -241,7 +241,7 @@ each node only knows its own ancestry and peers. Convergence means:
|
||||
|
||||
When multiple isolated nodes connect simultaneously, the network must:
|
||||
|
||||
1. Elect a single root (determined by smallest node_id)
|
||||
1. Elect a single root (determined by smallest node_addr)
|
||||
2. Form a loop-free tree structure
|
||||
3. Propagate ancestry information along peer links
|
||||
|
||||
@@ -250,7 +250,7 @@ When multiple isolated nodes connect simultaneously, the network must:
|
||||
```
|
||||
T0: Nodes A, B, C start isolated
|
||||
Each is its own root
|
||||
node_id ordering: A < B < C
|
||||
node_addr ordering: A < B < C
|
||||
|
||||
T1: Links form: A ←→ B, B ←→ C
|
||||
|
||||
@@ -301,7 +301,7 @@ depth D with gossip interval G:
|
||||
|
||||
**No coordination required**: Convergence emerges from:
|
||||
|
||||
- Deterministic root election (smallest node_id)
|
||||
- Deterministic root election (smallest node_addr)
|
||||
- Deterministic merge rules (highest sequence wins)
|
||||
- Eventually consistent gossip
|
||||
|
||||
@@ -420,7 +420,7 @@ Link C ←→ D fails:
|
||||
|
||||
After:
|
||||
Partition 1: A ← B ← C
|
||||
Partition 2: D ← E (or E ← D, depending on node_ids)
|
||||
Partition 2: D ← E (or E ← D, depending on node_addrs)
|
||||
```
|
||||
|
||||
### Partition Detection
|
||||
@@ -455,7 +455,7 @@ Partition 1 (nodes A, B, C):
|
||||
Partition 2 (nodes D, E):
|
||||
├── Previous root A is unreachable
|
||||
├── D and E exchange announcements
|
||||
├── New root = min(node_id_D, node_id_E)
|
||||
├── New root = min(node_addr_D, node_addr_E)
|
||||
├── Tree forms between D and E
|
||||
└── Routing works within partition
|
||||
```
|
||||
@@ -489,10 +489,10 @@ T4: Merged network
|
||||
### Root Stability Across Partitions
|
||||
|
||||
A key design consideration: the root should be stable to minimize reconvergence.
|
||||
If partition 2 elected a "temporary" root with a large node_id, healing is cheap—
|
||||
If partition 2 elected a "temporary" root with a large node_addr, healing is cheap—
|
||||
that root immediately defers to the global root.
|
||||
|
||||
If by chance partition 2's root has a smaller node_id than partition 1's root,
|
||||
If by chance partition 2's root has a smaller node_addr than partition 1's root,
|
||||
healing causes partition 1 to reconverge to the new global root.
|
||||
|
||||
---
|
||||
@@ -837,7 +837,7 @@ Physical topology (full mesh via switch):
|
||||
│╱ ╲│
|
||||
D ──── C ──── E
|
||||
|
||||
node_id ordering: A < C < B < E < D
|
||||
node_addr ordering: A < C < B < E < D
|
||||
```
|
||||
|
||||
**Tree formation**:
|
||||
@@ -911,7 +911,7 @@ Physical topology:
|
||||
(DSL) (fiber)
|
||||
1 Mbps
|
||||
|
||||
node_id ordering: B < A < D < E < C
|
||||
node_addr ordering: B < A < D < E < C
|
||||
```
|
||||
|
||||
**Cost calculation** (using bandwidth as primary):
|
||||
@@ -928,7 +928,7 @@ A ~ C: cost = 100000 (9600 bps)
|
||||
**Tree formation with costs**:
|
||||
|
||||
```
|
||||
Root = B (smallest node_id)
|
||||
Root = B (smallest node_addr)
|
||||
|
||||
Parent selection:
|
||||
├── A: peers are B (cost 1), C (cost 100000)
|
||||
@@ -982,7 +982,7 @@ A ─── B ──────────────────────
|
||||
│ │
|
||||
C G
|
||||
|
||||
node_id ordering: A < E < B < F < C < G
|
||||
node_addr ordering: A < E < B < F < C < G
|
||||
```
|
||||
|
||||
**Normal operation**:
|
||||
@@ -1014,7 +1014,7 @@ T3: Site 2 state:
|
||||
E loses path to A
|
||||
E evaluates remaining peers: F
|
||||
F has no path to A either
|
||||
E compares: node_id_E < node_id_F
|
||||
E compares: node_addr_E < node_addr_F
|
||||
E becomes new root for Site 2
|
||||
|
||||
T4: Site 2 reconverges:
|
||||
@@ -1033,7 +1033,7 @@ T5: WAN link restored
|
||||
|
||||
T6: E receives B's announcement:
|
||||
B's ancestry: [A, B]
|
||||
E learns: A exists, node_id_A < node_id_E
|
||||
E learns: A exists, node_addr_A < node_addr_E
|
||||
E adopts A as root
|
||||
E selects B as parent
|
||||
|
||||
@@ -1064,7 +1064,7 @@ initial exchange).
|
||||
The gossip-based spanning tree protocol achieves distributed coordination
|
||||
through:
|
||||
|
||||
1. **Deterministic root election** - Smallest node_id, no negotiation needed
|
||||
1. **Deterministic root election** - Smallest node_addr, no negotiation needed
|
||||
2. **Local parent selection** - Each node independently chooses best path to root
|
||||
3. **CRDT merge semantics** - Conflicts resolved by sequence number, then timestamp
|
||||
4. **Bounded state** - O(peers × depth) entries per node, not O(network size)
|
||||
|
||||
Reference in New Issue
Block a user