From 44667d9cc25897a8ceb7ae7eb2fe658637a040bd Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Sat, 11 Apr 2026 13:14:36 +0000 Subject: [PATCH] Update design docs and diagrams for Noise XX and forklift changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update CHANGELOG Breaking section with all v0.4.0 wire format changes. Comprehensive update to fips-wire-formats.md (handshake, MMP, FilterAnnounce, discovery, size tables). Update fips-mesh-layer.md, fips-session-layer.md, fips-intro.md for IK/XK→XX transition and spin bit removal. Document disable_routing and leaf_only profile config in fips-configuration.md. Update fips-transport-layer.md for 4-byte beacon and unified header. Update fips-bloom-filters.md for delta compression and variable sizing. Update fips-mesh-operation.md for node profiles and discovery min_mtu. Create/update 8 SVG diagrams (XX msg1/2/3, handshake flow, session setup/ack/msg3, filter-announce, sender/receiver report). Update README.md Noise pattern references. --- CHANGELOG.md | 74 +++- README.md | 7 +- docs/design/diagrams/filter-announce.svg | 59 +-- docs/design/diagrams/handshake-flow.svg | 77 ++-- docs/design/diagrams/noise-ik-msg1.svg | 67 ---- .../{noise-ik-msg2.svg => noise-xx-msg1.svg} | 44 +-- docs/design/diagrams/noise-xx-msg2.svg | 82 +++++ docs/design/diagrams/noise-xx-msg3.svg | 72 ++++ docs/design/diagrams/receiver-report.svg | 125 +++---- docs/design/diagrams/sender-report.svg | 80 ++--- docs/design/diagrams/session-ack.svg | 6 +- docs/design/diagrams/session-msg3.svg | 4 +- docs/design/diagrams/session-setup.svg | 4 +- docs/design/fips-bloom-filters.md | 72 ++-- docs/design/fips-configuration.md | 10 +- docs/design/fips-intro.md | 74 ++-- docs/design/fips-mesh-layer.md | 90 ++--- docs/design/fips-mesh-operation.md | 37 +- docs/design/fips-session-layer.md | 101 +++--- docs/design/fips-spanning-tree.md | 2 +- docs/design/fips-transport-layer.md | 36 +- docs/design/fips-wire-formats.md | 336 +++++++++++------- 22 files changed, 847 insertions(+), 612 deletions(-) delete mode 100644 docs/design/diagrams/noise-ik-msg1.svg rename docs/design/diagrams/{noise-ik-msg2.svg => noise-xx-msg1.svg} (52%) create mode 100644 docs/design/diagrams/noise-xx-msg2.svg create mode 100644 docs/design/diagrams/noise-xx-msg3.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c4a283..908708e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Breaking -_Wire-format and API breaking changes for the next major release._ +Wire-format breaking changes for v0.4.0. All nodes in a mesh must +run the same major version — these changes are not backward compatible +with v0.2.x peers. + +### Changed + +#### Noise XX Handshake (FMP and FSP) + +- FMP link handshake switched from Noise IK (2 messages) to Noise XX + (3 messages). Neither side requires prior knowledge of the peer's + static key. Responder identity revealed in msg2, initiator in msg3. + FMP wire version incremented to 1. +- FSP session handshake switched from Noise XK to Noise XX. Same + 3-message flow with post-handshake identity verification using + x-only key comparison (parity-independent for npub compatibility). +- Protocol negotiation payload added to XX msg2/msg3 for both layers: + format byte, packed version min/max, 64-bit feature bitfield, and + forward-compatible TLV extensions. Enables rolling protocol upgrades + in future releases. +- FMP msg1 reduced from 106 to 33 bytes (ephemeral key only, no + encrypted static key or DH products). + +#### FMP Node Profiles + +- Node profile enum (Full, NonRouting, Leaf) advertised in FMP feature + bitfield bits 0-2. At least one side of a link must be Full. +- MMP report flow gated by wants/provides bits (bits 3-6): reports + only sent when the sender can provide and the receiver wants them. +- Non-routing nodes receive bloom filters (one-way) but do not send + them; the full peer inserts their identity as a leaf dependent. +- Leaf nodes enforce single-peer constraint with no tree, bloom, or + transit participation. + +#### MMP Report Format + +- Spin bit removed. Reclaims FMP flags bit 2 and FSP inner flags + bit 0. Superseded by MMP receiver report timestamp echo for RTT. +- SenderReport reduced from 48 to 20 bytes (3 fields: interval + packets/bytes sent, cumulative packets sent). +- ReceiverReport reduced from 68 to 54 bytes (10 fields retained; + removed max/mean burst loss and interval recv counters). +- Both report types use extensibility header: `[format_version:1] + [total_length:2 LE]` replacing reserved bytes. Decoders skip + unknown trailing bytes for forward compatibility. + +#### Discovery Wire Format + +- Dropped `origin_coords` from LookupRequest (saves 2 + 16*depth + bytes per request). Reverse-path routing via `recent_requests` is + the primary response mechanism. +- `min_mtu` field wired up in LookupRequest: transit nodes skip peers + whose link MTU is below the request's minimum. +- TLV extension section added to LookupRequest and LookupResponse + after fixed fields. Transit nodes forward TLV bytes verbatim. + +#### Shared-Media Beacons + +- Ethernet frame header unified to 4 bytes `[type][flags][length:2 + LE]` for all frame types. Beacons reduced from 34 to 5 bytes + (pubkey stripped — identity learned from XX handshake). +- BLE pre-handshake pubkey exchange removed. Cross-probe tie-breaker + eliminated (unnecessary with XX). + +#### Bloom Filter Wire Format + +- FilterAnnounce gains flags byte (delta bit), `base_seq` field, and + RLE-compressed payload for XOR-diff delta compression. +- New FilterNack message type (0x21) for out-of-sequence delta + recovery (triggers full retransmit). +- Filter size decoupled from FMP negotiation: announced dynamically in + filter updates. Bit 7 and TLV field 1 removed from handshake. +- Variable filter sizes (512 bytes to 32 KB) with adaptive sizing + based on outgoing fill ratio (step-up at 20%, step-down at 5%). ## [Unreleased] diff --git a/README.md b/README.md index 19507b6..977ee1e 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,9 @@ endpoints. filter guided discovery, no global routing tables - **Multi-transport** — UDP, TCP, Ethernet, Tor, and Bluetooth (BLE L2CAP) today; designed for serial and radio -- **Noise encryption** — hop-by-hop link encryption (IK) plus independent - end-to-end session encryption (XK), with periodic rekey for forward secrecy +- **Noise encryption** — hop-by-hop link encryption and independent + end-to-end session encryption (both Noise XX), with periodic rekey for + forward secrecy and protocol negotiation in the handshake - **Nostr-native identity** — secp256k1 keypairs as node addresses, no registration or central authority - **IPv6 adaptation** — TUN interface maps npubs to fd00::/8 addresses for @@ -322,7 +323,7 @@ Ethernet, Tor, and Bluetooth (BLE) with a small live mesh of deployed nodes. - Spanning tree construction with greedy coordinate routing - Bloom filter guided discovery (no flooding, single-path with retry) -- Noise IK (link layer) and Noise XK (session layer) encryption +- Noise XX encryption at both layers with protocol negotiation - Periodic Noise rekey with hitless cutover for forward secrecy (FMP + FSP) - Persistent node identity with key file management - IPv6 TUN adapter with DNS resolution of `.fips` names diff --git a/docs/design/diagrams/filter-announce.svg b/docs/design/diagrams/filter-announce.svg index 711b166..cf4e1b4 100644 --- a/docs/design/diagrams/filter-announce.svg +++ b/docs/design/diagrams/filter-announce.svg @@ -1,43 +1,52 @@ - + - + - FilterAnnounce (0x20) — 11 + filter bytes + FilterAnnounce (0x20) — 19-byte header + RLE payload - - 0–3 + + 0–1 - msg_type - 0x20 + msg_type + 0x20 - - sequence + + flags + 1 byte — bit 0: delta (XOR diff) - - 4–8 + + 2–9 - 8 bytes LE — monotonic counter + sequence + 8 bytes LE — per-peer monotonic counter - - 9–10 + + 10–17 - - hash_count - 1 byte + + base_seq + 8 bytes LE — reference sequence for delta (0 if full) - - size_class - 1 byte + + 18 - - 11– + + size_class + 1 byte — filter size = 512 « size_class bytes - - filter_bits (variable, 512 << size_class bytes) + + 19– + + + compressed_payload (RLE: [count:2 LE][word:8 LE] per run) + + + + delta: XOR diff of current vs last-sent filter — full: raw filter words - v1 payload: 1,035 bytes (11 header + 1,024 filter) + 19-byte header + variable compressed payload diff --git a/docs/design/diagrams/handshake-flow.svg b/docs/design/diagrams/handshake-flow.svg index 3c44c49..fca1d75 100644 --- a/docs/design/diagrams/handshake-flow.svg +++ b/docs/design/diagrams/handshake-flow.svg @@ -1,26 +1,23 @@ - + - - - - + - FMP Handshake Flow (Noise IK) + FMP Handshake Flow (Noise XX) Initiator Responder - - + + generates sender_idx @@ -28,39 +25,51 @@ - - [0x01|flags=0|len] | sender_idx | noise_msg1 - phase 0x1 — 114 bytes + + [0x11|flags=0|len] | sender_idx | noise_msg1 + phase 0x1 — 41 bytes — pattern: → e - - validates msg1 - learns initiator's static key - generates sender_idx - generates ephemeral keypair + + validates msg1 (ephemeral only) + generates sender_idx + generates ephemeral keypair - - - - [0x02|flags=0|len] | sender_idx | receiver_idx | noise_msg2 - phase 0x2 — 69 bytes + + + [0x12|flags=0|len] | sender_idx | receiver_idx | noise_msg2 + negotiation + phase 0x2 — 144 bytes — pattern: ← e, ee, s, es + responder identity revealed - - validates msg2 - derives session keys + + validates msg2 + learns responder identity + checks epoch (restart detection) + + + + + [0x13|flags=0|len] | sender_idx | receiver_idx | noise_msg3 + neg + phase 0x3 — 111 bytes — pattern: → s, se + initiator identity revealed + + + validates msg3 + learns initiator identity + checks epoch, derives session keys - - HANDSHAKE COMPLETE - + + HANDSHAKE COMPLETE + - - first encrypted frame: - - - [0x00|flags|len] | receiver_idx | counter=0 | ciphertext+tag - phase 0x0 — established frame + + first encrypted frame: + + + [0x00|flags|len] | receiver_idx | counter=0 | ciphertext+tag + phase 0x0 — established frame - Both parties hold identical symmetric keys. Epoch exchange enables restart detection. + Both parties hold identical symmetric keys. Epoch + negotiation exchanged in msg2/msg3. diff --git a/docs/design/diagrams/noise-ik-msg1.svg b/docs/design/diagrams/noise-ik-msg1.svg deleted file mode 100644 index 5d2458a..0000000 --- a/docs/design/diagrams/noise-ik-msg1.svg +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - Noise IK Message 1 — phase 0x1 (114 bytes) - - - 0–3 - - - ver - 4 bits - - - phase - 4 bits - - - flags - 1 byte - - - payload_len - 2 bytes LE - - - 4–7 - - - sender_idx - 4 bytes LE - - - Noise IK msg1 (106 bytes) - - - - - - 8–40 - - - ephemeral_pubkey - - - 33 bytes (compressed secp256k1) - - - 41–89 - - - encrypted_static - - - 49 bytes (static key 33 + AEAD tag 16) - - - 90–113 - - - encrypted_epoch - 24 bytes (epoch 8 + AEAD tag 16) - - - total: 114 bytes · payload_len = 110 · pattern: e, es, s, ss - diff --git a/docs/design/diagrams/noise-ik-msg2.svg b/docs/design/diagrams/noise-xx-msg1.svg similarity index 52% rename from docs/design/diagrams/noise-ik-msg2.svg rename to docs/design/diagrams/noise-xx-msg1.svg index 4878f8b..b446cbe 100644 --- a/docs/design/diagrams/noise-ik-msg2.svg +++ b/docs/design/diagrams/noise-xx-msg1.svg @@ -1,12 +1,12 @@ - + - + - Noise IK Message 2 — phase 0x2 (69 bytes) + Noise XX Message 1 — phase 0x1 (41 bytes) - 0–3 + 0–3 ver @@ -25,41 +25,19 @@ 2 bytes LE - 4–7 + 4–7 sender_idx 4 bytes LE - - 8–11 + + 8–40 - - receiver_idx - 4 bytes LE - - - Noise IK msg2 (57 bytes) - - - - - - 12–44 - - - ephemeral_pubkey - - - 33 bytes (compressed secp256k1) - - - 45–68 - - - encrypted_epoch - 24 bytes (epoch 8 + AEAD tag 16) + + ephemeral_pubkey + 33 bytes (compressed secp256k1) - total: 69 bytes · payload_len = 65 · pattern: e, ee, se + total: 41 bytes · payload_len = 37 · pattern: → e diff --git a/docs/design/diagrams/noise-xx-msg2.svg b/docs/design/diagrams/noise-xx-msg2.svg new file mode 100644 index 0000000..fde158d --- /dev/null +++ b/docs/design/diagrams/noise-xx-msg2.svg @@ -0,0 +1,82 @@ + + + + + + Noise XX Message 2 — phase 0x2 (118–144 bytes) + + + 0–3 + + + ver + 4 bits + + + phase + 4 bits + + + flags + 1 byte + + + payload_len + 2 bytes LE + + + 4–7 + + + sender_idx + 4 bytes LE + + + + + + receiver_idx + 4 bytes LE + + + Noise XX msg2 (106 bytes base) + + + + + + 12–44 + + + ephemeral_pubkey + 33 bytes (compressed secp256k1) + + + 45–93 + + + encrypted_static + 49 bytes (static key 33 + AEAD tag 16) + + + 94–117 + + + encrypted_epoch + 24 bytes (epoch 8 + AEAD tag 16) + + + 118+ + + + encrypted negotiation payload + 26 bytes typical (10 payload + 16 AEAD tag) + + + + negotiation appended via encrypt_payload() — extends Noise hash chain + + + base: 118 bytes · with FMP negotiation: 144 bytes + pattern: ← e, ee, s, es + diff --git a/docs/design/diagrams/noise-xx-msg3.svg b/docs/design/diagrams/noise-xx-msg3.svg new file mode 100644 index 0000000..08fe394 --- /dev/null +++ b/docs/design/diagrams/noise-xx-msg3.svg @@ -0,0 +1,72 @@ + + + + + + Noise XX Message 3 — phase 0x3 (85–111 bytes) + + + 0–3 + + + ver + 4 bits + + + phase + 4 bits + + + flags + 1 byte + + + payload_len + 2 bytes LE + + + 4–11 + + + sender_idx + 4 bytes LE + + + receiver_idx + 4 bytes LE + + + Noise XX msg3 (73 bytes base) + + + + + + 12–60 + + + encrypted_static + 49 bytes (static key 33 + AEAD tag 16) + + + 61–84 + + + encrypted_epoch + 24 bytes (epoch 8 + AEAD tag 16) + + + 85+ + + + encrypted negotiation payload + 26 bytes typical (10 payload + 16 AEAD tag) + + + + negotiation appended via encrypt_payload() — extends Noise hash chain + + + base: 85 bytes · with FMP negotiation: 111 bytes + pattern: → s, se + diff --git a/docs/design/diagrams/receiver-report.svg b/docs/design/diagrams/receiver-report.svg index 9b5bc82..ff6c10a 100644 --- a/docs/design/diagrams/receiver-report.svg +++ b/docs/design/diagrams/receiver-report.svg @@ -3,104 +3,95 @@ - ReceiverReport (0x02) — 68 bytes + ReceiverReport (0x02) — 54 bytes - - 0–3 + + 0–3 - msg_type - 0x02 + msg_type + 0x02 - - reserved (3 bytes zero) + + fmt_ver + 1 byte - - 4–11 + + total_length + 2 bytes LE (= 50) + + + 4–7 - highest_counter - 8 bytes LE + timestamp_echo + 4 bytes LE (echoed sender timestamp for RTT) - - 12–19 + + 8–9 - - cumulative_packets_recv - 8 bytes LE + + dwell_time + 2 bytes LE (ms between receive and echo) - - 20–27 + + 10–17 - cumulative_bytes_recv + highest_counter 8 bytes LE - - 28–33 + + 18–25 - - timestamp_echo - 4 bytes LE + + cumulative_packets_recv + 8 bytes LE - - dwell_time - 2 bytes LE ms + + 26–33 - - 34–39 + + cumulative_bytes_recv + 8 bytes LE - - max_burst - 2 bytes LE + + 34–41 - - mean_burst - 2 bytes u8.8 - - - reserved - - - 40–47 - - + jitter - 4 bytes LE µs + 4 bytes LE (µs) - + ecn_ce_count - 4 bytes LE + 4 bytes LE - - 48–55 + + 42–49 - + owd_trend - 4 bytes i32 LE µs/s + i32 LE (µs/s) - + burst_loss_count - 4 bytes LE + 4 bytes LE - - 56–63 + + 50–53 - - reorder_count - 4 bytes LE cumulative + + cumulative_reorder_count + 4 bytes LE - - interval_pkts_recv - 4 bytes LE + + + decoders skip trailing bytes beyond total_length (forward compatibility) - - 64–67 - - - interval_bytes_recv - 4 bytes LE + + + removed from v0.2: max_burst_loss, mean_burst_loss, interval_packets/bytes_recv - total: 68 bytes + total: 54 bytes · format_version = 0 · total_length = 50 diff --git a/docs/design/diagrams/sender-report.svg b/docs/design/diagrams/sender-report.svg index 9fb5942..ed4a319 100644 --- a/docs/design/diagrams/sender-report.svg +++ b/docs/design/diagrams/sender-report.svg @@ -1,66 +1,50 @@ - + - + - SenderReport (0x01) — 48 bytes + SenderReport (0x01) — 20 bytes - - 0–3 + + 0–3 - msg_type - 0x01 + msg_type + 0x01 - - reserved (3 bytes zero) + + fmt_ver + 1 byte - - 4–11 + + total_length + 2 bytes LE (= 16) + + + 4–7 - interval_start_counter - 8 bytes LE + interval_packets_sent + 4 bytes LE - - 12–19 + + 8–11 - - interval_end_counter - 8 bytes LE + + interval_bytes_sent + 4 bytes LE - - 20–27 + + 12–19 - - interval_start_ts - 4 bytes LE + + cumulative_packets_sent + 8 bytes LE - - interval_end_ts - 4 bytes LE - - - 28–31 - - - interval_bytes_sent - 4 bytes LE - - - 32–39 - - - cumulative_packets_sent - 8 bytes LE - - - 40–47 - - - cumulative_bytes_sent - 8 bytes LE + + + decoders skip trailing bytes beyond total_length (forward compatibility) - total: 48 bytes + total: 20 bytes · format_version = 0 · total_length = 16 diff --git a/docs/design/diagrams/session-ack.svg b/docs/design/diagrams/session-ack.svg index 699006c..c3d23dd 100644 --- a/docs/design/diagrams/session-ack.svg +++ b/docs/design/diagrams/session-ack.svg @@ -3,7 +3,7 @@ - SessionAck (phase 0x2) — Noise XK msg2 + SessionAck (phase 0x2) — Noise XX msg2 0–3 @@ -40,8 +40,8 @@ handshake_payload - Noise XK msg2 (57 bytes — ephemeral + epoch) + Noise XX msg2 (106+ bytes — ephemeral + static + epoch) - typical ~190 bytes (depth-dependent, carries both endpoints' coords) + typical ~240 bytes (depth-dependent, carries both endpoints' coords) diff --git a/docs/design/diagrams/session-msg3.svg b/docs/design/diagrams/session-msg3.svg index 4db2df2..aac5b9b 100644 --- a/docs/design/diagrams/session-msg3.svg +++ b/docs/design/diagrams/session-msg3.svg @@ -3,7 +3,7 @@ - SessionMsg3 (phase 0x3) — Noise XK msg3 + SessionMsg3 (phase 0x3) — Noise XX msg3 0–3 @@ -28,7 +28,7 @@ handshake_payload - Noise XK msg3 (73 bytes — encrypted static + encrypted epoch) + Noise XX msg3 (73+ bytes — encrypted static + epoch + optional negotiation) ~80 bytes · no coordinates (both endpoints already have them) diff --git a/docs/design/diagrams/session-setup.svg b/docs/design/diagrams/session-setup.svg index e054f2f..90aba89 100644 --- a/docs/design/diagrams/session-setup.svg +++ b/docs/design/diagrams/session-setup.svg @@ -3,7 +3,7 @@ - SessionSetup (phase 0x1) — Noise XK msg1 + SessionSetup (phase 0x1) — Noise XX msg1 0–3 @@ -40,7 +40,7 @@ handshake_payload - Noise XK msg1 (33 bytes — ephemeral key) + Noise XX msg1 (33 bytes — ephemeral key) typical ~170 bytes (depth-dependent) diff --git a/docs/design/fips-bloom-filters.md b/docs/design/fips-bloom-filters.md index fda6565..3be7bee 100644 --- a/docs/design/fips-bloom-filters.md +++ b/docs/design/fips-bloom-filters.md @@ -245,7 +245,7 @@ for i in 0..hash_count: return true // Maybe present (possible false positive) ``` -Where `filter_bits = 8 × (512 << size_class)` — 8,192 for v1. +Where `filter_bits = 8 × (512 << size_class)` — 8,192 for size_class 1 (default). ## Wire Format @@ -254,27 +254,30 @@ FilterAnnounce messages are carried inside encrypted link-layer frames: | Offset | Field | Size | Description | | ------ | ----- | ---- | ----------- | | 0 | msg_type | 1 byte | 0x20 | -| 1 | sequence | 8 bytes LE | Monotonic counter for freshness | -| 9 | hash_count | 1 byte | Number of hash functions (5 in v1) | -| 10 | size_class | 1 byte | Filter size: `512 << size_class` bytes | -| 11 | filter_bits | 1,024 bytes | Bloom filter bit array (v1) | +| 1 | flags | 1 byte | Bit 0: delta (XOR diff), bits 1-7 reserved | +| 2 | sequence | 8 bytes LE | Monotonic counter, per-peer | +| 10 | base_seq | 8 bytes LE | Reference filter sequence for delta (0 if full) | +| 18 | size_class | 1 byte | Filter size: `512 << size_class` bytes | +| 19 | compressed_payload | variable | RLE-encoded filter or XOR diff | -**v1 total**: 1,035 bytes payload, 1,064 bytes with link encryption -overhead. +Payloads are RLE-compressed: each run = `[count:2 LE][word:8 LE]` +(10 bytes per run). XOR diffs between consecutive filters are mostly +zero words, compressing to very few runs. A FilterNack (msg_type 0x21) +requests full retransmission when a sequence gap is detected. See [fips-wire-formats.md](fips-wire-formats.md) for the complete wire format reference. ## Scale and Size Classes -### v1 Scale Limits +### Scale Limits Coordinate-based tree distance checking ensures correct routing decisions at all network sizes — bloom filters are an optimization that narrows the set of peers considered, not a correctness requirement. As filters saturate, routing still works; it just evaluates more candidates per hop. -With the v1 mandatory 1 KB filter (size_class 1): +With the default 1 KB filter (size_class 1): - **Small networks (< 1,000 nodes)**: Both upward and downward filters are highly accurate (worst-case FPR < 1%). Filters effectively narrow @@ -295,34 +298,29 @@ With the v1 mandatory 1 KB filter (size_class 1): ### Size Class Table -| size_class | Bytes | Bits | Status | -| ---------- | ----- | ---- | ------ | -| 0 | 512 | 4,096 | Reserved | -| 1 | 1,024 | 8,192 | **v1 (MUST use)** | -| 2 | 2,048 | 16,384 | Reserved | -| 3 | 4,096 | 32,768 | Reserved | +| size_class | Bytes | Bits | Notes | +| ---------- | ----- | ---- | ----- | +| 0 | 512 | 4,096 | Minimum | +| 1 | 1,024 | 8,192 | Default | +| 2 | 2,048 | 16,384 | | +| 3 | 4,096 | 32,768 | | +| 4 | 8,192 | 65,536 | | +| 5 | 16,384 | 131,072 | | +| 6 | 32,768 | 262,144 | Maximum | -FMP v1 mandates size_class = 1. Nodes MUST use size_class = 1 and MUST -reject FilterAnnounce messages with any other size_class. The size_class -field is reserved in the wire format to support future protocol versions -with larger default filter sizes. +### Adaptive Sizing -### Scaling Strategy +Filter size is a node property, not a link property. Each node selects +its own size class based on outgoing filter fill ratio: step up above +~20%, step down below ~5%, with hysteresis to prevent oscillation. +Nodes near the root of the spanning tree — which carry larger combined +filters — naturally upsize, while leaf and edge nodes stay small. -The 1 KB filter becomes a practical limitation beyond ~2,000 nodes. The -size class mechanism provides the path forward: future FMP versions may -use larger default filters (size_class 2 or 3) to support larger networks -while remaining compatible with constrained nodes through folding. -Size_class 2 (2 KB, 16,384 bits) would roughly double the practical -network size limit. - -The envisioned approach is that hub nodes near the root — which carry the -largest downward filters — would use larger size classes, while leaf nodes -and resource-constrained nodes continue with smaller filters. A node -receiving a filter larger than its own size class folds it down locally. -The mechanism by which heterogeneous filter sizes propagate through the -tree is a future design direction not specified in v1. See -[IDEA-0043](../../ideas/IDEA-0043-heterogeneous-filter-propagation.md). +When a node receives a filter at a different size class than its own, +it converts on receipt: larger filters are folded down, smaller filters +are expanded via bit duplication. Routing queries use the peer's filter +at its native (advertised) size for full resolution; conversion happens +only when building the node's own outgoing filter. ### Folding @@ -339,7 +337,7 @@ positions that folding produces. | Feature | Status | | ------- | ------ | -| 1 KB bloom filter (size_class 1) | **Implemented** | +| Variable-size bloom filters (512 B – 32 KB) | **Implemented** | | 5 hash functions | **Implemented** | | Split-horizon filter computation | **Implemented** | | Tree-only merge propagation | **Implemented** | @@ -347,6 +345,10 @@ positions that folding produces. | Per-peer filter maintenance | **Implemented** | | Event-driven updates | **Implemented** | | 500ms rate limiting | **Implemented** | +| Delta compression (XOR diff + RLE) | **Implemented** | +| FilterNack sequence recovery | **Implemented** | +| Adaptive sizing (fill-ratio heuristic) | **Implemented** | +| Fold/duplicate size conversion | **Implemented** | | FilterAnnounce gossip (all peers) | **Implemented** | | Filter cardinality logging | **Implemented** | | Size class negotiation | Future direction | diff --git a/docs/design/fips-configuration.md b/docs/design/fips-configuration.md index dc87248..2e34db4 100644 --- a/docs/design/fips-configuration.md +++ b/docs/design/fips-configuration.md @@ -90,7 +90,8 @@ to the highest-priority config file for operator visibility, even in ephemeral m | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `node.leaf_only` | bool | `false` | Leaf-only mode: node does not forward traffic or participate in routing | +| `node.disable_routing` | bool | `false` | Non-routing mode: participates in spanning tree but does not forward transit traffic or send bloom filters | +| `node.leaf_only` | bool | `false` | Leaf mode: single upstream peer, no tree/bloom/transit participation. Implies `disable_routing: true` | | `node.tick_interval_secs` | u64 | `1` | Periodic maintenance tick interval (retry checks, timeout cleanup, tree refresh) | | `node.base_rtt_ms` | u64 | `100` | Initial RTT estimate for new links before measurements converge | | `node.heartbeat_interval_secs` | u64 | `10` | Heartbeat send interval per peer for liveness detection | @@ -109,7 +110,7 @@ Controls capacity for connections, peers, and links. ### Rate Limiting (`node.rate_limit.*`) -Handshake rate limiting protects against DoS on the Noise IK handshake path. +Handshake rate limiting protects against DoS on the Noise XX handshake path. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| @@ -204,7 +205,7 @@ stays set for all subsequent hops to the destination. ### Rekey (`node.rekey.*`) Controls periodic Noise rekey for forward secrecy. When enabled, both FMP -(link-layer IK) and FSP (session-layer XK) sessions perform fresh Diffie-Hellman +(link-layer XX) and FSP (session-layer XX) sessions perform fresh Diffie-Hellman key exchanges after a time or message count threshold, whichever comes first. A 10-second drain window keeps the old session active for decryption during cutover. @@ -238,7 +239,7 @@ Metrics Measurement Protocol for per-peer link measurement. See | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `node.mmp.mode` | string | `"full"` | Operating mode: `full` (sender + receiver reports), `lightweight` (receiver reports only), or `minimal` (spin bit + CE echo only, no reports) | +| `node.mmp.mode` | string | `"full"` | Operating mode: `full` (sender + receiver reports), `lightweight` (receiver reports only), or `minimal` (CE echo only, no reports) | | `node.mmp.log_interval_secs` | u64 | `30` | Periodic operator log interval for link metrics | | `node.mmp.owd_window_size` | usize | `32` | One-way delay trend ring buffer size | @@ -666,6 +667,7 @@ node: identity: nsec: null # secret key in nsec or hex (null = depends on persistent) persistent: false # true = load/save fips.key; false = ephemeral each start + disable_routing: false leaf_only: false tick_interval_secs: 1 base_rtt_ms: 100 diff --git a/docs/design/fips-intro.md b/docs/design/fips-intro.md index 046e59a..7bfd729 100644 --- a/docs/design/fips-intro.md +++ b/docs/design/fips-intro.md @@ -134,7 +134,7 @@ See [fips-transport-layer.md](fips-transport-layer.md) for the transport layer specification. **FIPS Mesh Protocol (FMP)**: Manages peer connections, authenticates peers -via Noise IK handshakes, and encrypts all traffic on each link. FMP is where +via Noise XX handshakes, and encrypts all traffic on each link. FMP is where the mesh organizes itself — nodes exchange spanning tree announcements and bloom filters with their direct peers, and FMP makes forwarding decisions for transit traffic. FMP provides authenticated, encrypted forwarding to FSP @@ -203,7 +203,7 @@ same keypair. ![Identity Derivation](diagrams/fips-identity-derivation.svg) -The pubkey is the node's cryptographic identity, used in Noise IK handshakes +The pubkey is the node's cryptographic identity, used in Noise XX handshakes for both link and session encryption. It is never exposed beyond the endpoints of an encrypted channel. The node_addr, a one-way SHA-256 hash truncated to 16 bytes, serves as the routing identifier in packet headers @@ -243,31 +243,27 @@ FIPS uses independent encryption at two protocol layers: | Layer | Scope | Pattern | Purpose | | ----- | ----- | ------- | ------- | -| **FMP (Mesh)** | Hop-by-hop | Noise IK | Encrypt all traffic on each peer link | -| **FSP (Session)** | End-to-end | Noise XK | Encrypt application payload between endpoints | +| **FMP (Mesh)** | Hop-by-hop | Noise XX | Encrypt all traffic on each peer link | +| **FSP (Session)** | End-to-end | Noise XX | Encrypt application payload between endpoints | ### Link Layer (Hop-by-Hop) When two nodes establish a direct connection, they perform a [Noise -IK](https://noiseprotocol.org/) handshake. This authenticates both parties +XX](https://noiseprotocol.org/) handshake. This authenticates both parties and establishes symmetric keys for encrypting all traffic on that link. Every packet between direct peers is encrypted — gossip messages, routing -queries, and forwarded session datagrams alike. - -The IK pattern is used because outbound connections know the peer's npub -from configuration, while inbound connections learn the initiator's identity -from the first handshake message. +queries, and forwarded session datagrams alike. Neither side requires prior +knowledge of the other's static key — both identities are revealed during +the three-message handshake, along with a protocol negotiation payload +that enables rolling upgrades. ### Session Layer (End-to-End) FIPS establishes end-to-end encrypted sessions between any two communicating -nodes using Noise XK, regardless of how many hops separate them. The -initiator knows the destination's npub (required for XK's pre-message); -the responder learns the initiator's identity from the third handshake -message. Unlike the link-layer IK pattern where the initiator's identity -is revealed in msg1, XK delays identity disclosure until msg3, providing -stronger initiator identity protection for traffic traversing untrusted -intermediate nodes. +nodes using Noise XX, regardless of how many hops separate them. The same +three-message XX pattern is used at both layers — neither side reveals its +identity until msg2 (responder) or msg3 (initiator), providing mutual +identity protection for traffic traversing untrusted intermediate nodes. A packet from A to D through intermediate nodes B and C: @@ -455,7 +451,7 @@ MMP operates in three modes. **Full** mode exchanges both SenderReports and ReceiverReports to compute all metrics including RTT. **Lightweight** mode exchanges only ReceiverReports, providing loss and jitter but not RTT — useful for constrained links. **Minimal** mode disables reports entirely, relying -only on spin bit and congestion echo flags in the frame header. +only on congestion echo flags in the frame header. Reports are sent at RTT-adaptive intervals (clamped to 100 ms–2 s), so high-latency links don't generate excessive measurement traffic while @@ -472,7 +468,7 @@ evaluates `effective_depth = depth + link_cost` using `find_next_hop()` candidate ranking for data forwarding. See [fips-mesh-layer.md](fips-mesh-layer.md) for MMP operating modes, report -scheduling, and the spin bit design. +scheduling, and RTT measurement. --- @@ -488,7 +484,7 @@ A **transport** is a driver for a particular medium. A **link** is a peer connection established over a transport. Transport addresses (IP:port, MAC address, .onion) are opaque to all layers above FMP — they exist only to deliver datagrams and are discarded once FMP has authenticated the peer via -the Noise IK handshake. From that point on, the peer is identified solely by +the Noise XX handshake. From that point on, the peer is identified solely by its cryptographic identity. Transports fall into three categories based on their connectivity model: @@ -543,7 +539,7 @@ different layer of the protocol. A passive observer on the underlying transport — someone monitoring a WiFi network, tapping an Ethernet segment, or inspecting UDP traffic — sees only -encrypted packets. The FMP link-layer Noise IK session encrypts all traffic +encrypted packets. The FMP link-layer Noise XX session encrypts all traffic between direct peers, including routing gossip and forwarded session datagrams. The observer can infer timing, packet sizes, and which transport endpoints are exchanging traffic, but cannot read content or determine @@ -555,7 +551,7 @@ infer communication relationships — is not defended against (see ### Active Attackers on the Transport An adversary who can inject, modify, drop, or replay packets on the -transport is also defeated by the FMP link-layer Noise IK session. Mutual +transport is also defeated by the FMP link-layer Noise XX session. Mutual authentication prevents impersonation, AEAD encryption detects tampering, and counter-based nonces with a sliding replay window reject replayed packets. @@ -565,7 +561,7 @@ packets. The most important adversary class is the operators of other nodes in the mesh — the peers that forward your traffic. FIPS treats every intermediate router as potentially adversarial. The FSP session layer establishes a -completely independent Noise XK session between the communicating endpoints, +completely independent Noise XX session between the communicating endpoints, so intermediate nodes cannot read application payloads even though they decrypt and re-encrypt the link-layer envelope at each hop. @@ -678,20 +674,16 @@ confidentiality and integrity rather than hiding traffic patterns. ### Noise Protocol Framework FIPS uses the [Noise Protocol Framework](https://noiseprotocol.org/) at both -protocol layers, with different handshake patterns chosen for each layer's -threat model. FMP link encryption uses **Noise IK**, providing mutual -authentication with a single round trip where the initiator knows the -responder's static key in advance. -[WireGuard](https://www.wireguard.com/) uses the same IK base pattern -(extended with a pre-shared key as IKpsk2) for VPN tunnels. FSP session -encryption uses **Noise XK**, the same pattern used by the -[Lightning Network](https://github.com/lightning/bolts/blob/master/08-transport.md), -where the initiator's static key is transmitted in a third message rather -than the first. XK provides stronger initiator identity hiding at the cost -of an additional round trip — a worthwhile tradeoff for session-layer traffic -that traverses untrusted intermediate nodes. At the link layer, where both -peers are configured and directly connected, IK's single round trip is -preferred. +protocol layers with the **Noise XX** handshake pattern. XX requires no +prior knowledge of the peer's static key — both identities are revealed +during a three-message handshake (responder in msg2, initiator in msg3). +This enables anonymous peer discovery on shared-media transports and +allows a protocol negotiation payload to be exchanged alongside the +handshake, supporting rolling protocol upgrades without extra round trips. +[WireGuard](https://www.wireguard.com/) uses the related IK pattern for +VPN tunnels; +[Lightning Network](https://github.com/lightning/bolts/blob/master/08-transport.md) +uses XK for transport encryption. ### Index-Based Session Dispatch @@ -728,13 +720,6 @@ computation used in TCP for retransmission timeout calculation since 1988. MMP derives RTT from timestamp-echo in ReceiverReports with dwell-time compensation, rather than from packet round-trips. -The spin bit in the FMP frame header follows the -[QUIC](https://www.rfc-editor.org/rfc/rfc9000) spin bit -([RFC 9312](https://www.rfc-editor.org/rfc/rfc9312)) — a single bit that -alternates each round trip, enabling passive latency measurement. FIPS -implements the spin bit state machine but relies on timestamp-echo for SRTT, -as irregular mesh traffic makes spin bit RTT unreliable. - The Expected Transmission Count (ETX) metric, computed from bidirectional delivery ratios, was introduced by [De Couto et al. (2003)](https://pdos.csail.mit.edu/papers/grid:mobicom03/paper.pdf) @@ -809,7 +794,6 @@ of self-sovereign identity systems. No novel cryptography is introduced. - [WireGuard Whitepaper](https://www.wireguard.com/papers/wireguard.pdf) - [Lightning Network BOLT #8 — Transport](https://github.com/lightning/bolts/blob/master/08-transport.md) - [QUIC (RFC 9000)](https://www.rfc-editor.org/rfc/rfc9000) -- [QUIC Spin Bit (RFC 9312)](https://www.rfc-editor.org/rfc/rfc9312) - [RTCP (RFC 3550)](https://www.rfc-editor.org/rfc/rfc3550) - [TCP SRTT / RTO (RFC 6298)](https://www.rfc-editor.org/rfc/rfc6298) - [ECN (RFC 3168)](https://www.rfc-editor.org/rfc/rfc3168) diff --git a/docs/design/fips-mesh-layer.md b/docs/design/fips-mesh-layer.md index 5adb243..3646fd8 100644 --- a/docs/design/fips-mesh-layer.md +++ b/docs/design/fips-mesh-layer.md @@ -9,7 +9,7 @@ the mesh self-organizes, and where forwarding decisions are made. FMP manages direct peer connections over transports. When a transport delivers a datagram from an unknown address, FMP authenticates the sender through a -Noise IK handshake, establishing a cryptographic link. Once authenticated, the +Noise XX handshake, establishing a cryptographic link. Once authenticated, the link carries all inter-peer communication: spanning tree gossip, bloom filter updates, coordinate discovery, and forwarded session datagrams — all encrypted per-hop. @@ -82,7 +82,7 @@ for the encrypted frame wrapper: 16-byte outer header + 5-byte inner header + ### Connection Lifecycle For connection-oriented transports, the transport must establish the underlying -connection before FMP can begin the Noise IK handshake. For connectionless +connection before FMP can begin the Noise XX handshake. For connectionless transports, datagrams can flow immediately. ### Endpoint Discovery (Optional) @@ -94,46 +94,52 @@ through configuration. ## Peer Authentication -### Noise IK Handshake +### Noise XX Handshake -Every peer connection begins with a Noise IK handshake that mutually +Every peer connection begins with a Noise XX handshake that mutually authenticates both parties and establishes symmetric keys for link encryption. -The IK pattern is chosen because: +The XX pattern is chosen because: -- The **initiator** knows the responder's static public key from configuration - or discovery, and sends their own static key encrypted in the first message -- The **responder** learns the initiator's identity from the first message, - then responds with their own ephemeral key +- **Neither side** requires prior knowledge of the other's static public key +- The **responder** reveals its identity in msg2; the **initiator** reveals + its identity in msg3 +- A protocol negotiation payload (version range, feature bitfield, TLV + extensions) is appended to msg2 and msg3, enabling rolling protocol + upgrades without additional round-trips -After the two-message handshake completes, both parties share symmetric -session keys derived from four DH operations (es, ss, ee, se). The handshake -provides mutual authentication, forward secrecy, and identity hiding for the -initiator. +After the three-message handshake completes, both parties share symmetric +session keys derived from three DH operations (ee, es, se). The handshake +provides mutual authentication, forward secrecy, and identity hiding for +both parties until they choose to reveal. ### Epoch Exchange and Peer Restart Detection -Both IK handshake messages carry an encrypted epoch payload — an 8-byte -random value generated once at node startup: +XX handshake messages msg2 and msg3 carry an encrypted epoch payload — an +8-byte random value generated once at node startup: -- **msg1**: Ephemeral key (33 bytes) + encrypted static key (49 bytes) + - encrypted epoch (24 bytes) = 106 bytes total -- **msg2**: Ephemeral key (33 bytes) + encrypted epoch (24 bytes) = 57 bytes - total +- **msg1**: Ephemeral key only (33 bytes) — no identity or epoch +- **msg2**: Ephemeral key (33 bytes) + encrypted static key (49 bytes) + + encrypted epoch (24 bytes) = 106 bytes base, plus negotiation payload +- **msg3**: Encrypted static key (49 bytes) + encrypted epoch (24 bytes) + = 73 bytes base, plus negotiation payload The encrypted epoch (EPOCH_ENCRYPTED_SIZE = 24 bytes) consists of the 8-byte epoch value plus a 16-byte AEAD tag. -On reconnection, each peer compares the received epoch with the previously -stored epoch for that peer. An epoch mismatch indicates the peer has -restarted (generated a new epoch), triggering full link re-establishment -rather than treating the handshake as a simple reconnection. This prevents -stale session state from persisting across restarts. +Because msg1 carries no identity, restart detection is deferred: the +initiator checks the responder's epoch after msg2, and the responder +checks the initiator's epoch after msg3. An epoch mismatch indicates the +peer has restarted, triggering full link re-establishment rather than +treating the handshake as a simple reconnection. This prevents stale +session state from persisting across restarts. ### Identity Binding -The Noise handshake binds the link to the peer's cryptographic identity. After -handshake completion: +The Noise handshake binds the link to the peer's cryptographic identity. +With XX, identity confirmation happens at different points: the initiator +learns the responder's identity from msg2, and the responder learns the +initiator's identity from msg3. After handshake completion: - The peer's public key (FIPS identity) is confirmed - The node_addr is computed from the public key (SHA-256, truncated to 128 bits) @@ -143,8 +149,12 @@ handshake completion: ### Reconnection -When a Noise IK msg1 arrives from a peer that already has an authenticated -link, FMP accepts the new handshake alongside the existing session. If the new +When a Noise XX msg1 arrives from an address that already has an +authenticated link, FMP accepts the new handshake alongside the existing +session. With XX, the peer's identity is not known at msg1 time — FMP can +only detect the duplicate by transport address. Identity-based checks +(restart detection, rekey recognition, cross-connection resolution) are +deferred to msg3 when the initiator's identity is revealed. If the new handshake completes successfully, it replaces the old session. This handles legitimate reconnection (network change, process restart, NAT rebinding) without disrupting ongoing traffic until the new session is confirmed. @@ -163,7 +173,7 @@ The auto-reconnect path: 3. If eligible, the peer is fed into the retry system with unlimited retries and exponential backoff (same base interval and max backoff as startup retries, configured via `node.retry.*`) -4. On each retry tick, a fresh Noise IK handshake is initiated toward the +4. On each retry tick, a fresh Noise XX handshake is initiated toward the peer's configured transport addresses Auto-reconnect only applies to peers in the static peer list with @@ -173,8 +183,8 @@ config) is responsible for re-establishing the link. ### Handshake Message Retry -Both link-layer (Noise IK msg1/msg2) and session-layer (SessionSetup/ -SessionAck) handshakes use message-level retry with exponential backoff +Both link-layer (Noise XX msg1/msg2/msg3) and session-layer (SessionSetup/ +SessionAck/SessionMsg3) handshakes use message-level retry with exponential backoff within the handshake timeout window. This handles packet loss on the underlying transport without waiting for the full handshake timeout to expire. @@ -446,7 +456,7 @@ MMP supports three modes, configured via `node.mmp.mode`: | ---- | ----------------- | ----------------- | | **Full** (default) | SenderReport + ReceiverReport | All metrics including RTT, loss, jitter, goodput, OWD trend | | **Lightweight** | ReceiverReport only | Loss (from counter gaps), jitter, OWD trend. No RTT. | -| **Minimal** | None | Spin bit and CE echo flags only. No computed metrics. | +| **Minimal** | None | CE echo flags only. No computed metrics. | ### Report Scheduling @@ -454,16 +464,10 @@ Reports are sent at RTT-adaptive intervals, clamped to [100ms, 2s]. A cold-start interval of 500ms is used before SRTT converges. The interval formula is `clamp(2 × SRTT, 100ms, 2000ms)`. -### Spin Bit and RTT +### RTT Measurement -The SP (spin bit) flag in the FMP inner header follows the QUIC spin bit -pattern: reflected on receive, toggled on send when the reflected value -matches the last sent value. The spin bit state machine runs for TX -reflection, but **RTT samples from the spin bit are discarded**. In a mesh -protocol where frames are sent irregularly (tree announces, bloom filters, -MMP reports on different timers), inter-frame processing delays inflate spin -bit RTT measurements unpredictably. Timestamp-echo from ReceiverReports -(with dwell-time compensation) is the sole SRTT source. +RTT is measured exclusively via timestamp-echo in ReceiverReports with +dwell-time compensation. ### ECN Congestion Signaling @@ -555,7 +559,9 @@ an attacker sends invalid packets to elicit responses. | Feature | Status | | ------- | ------ | -| Noise IK handshake (with epoch) | **Implemented** | +| Noise XX handshake (with epoch and negotiation) | **Implemented** | +| Protocol negotiation (version + features + TLV) | **Implemented** | +| Node profiles (Full, NonRouting, Leaf) | **Implemented** | | Peer restart detection (epoch mismatch) | **Implemented** | | Link encryption (ChaCha20-Poly1305) | **Implemented** | | Index-based session dispatch | **Implemented** | diff --git a/docs/design/fips-mesh-operation.md b/docs/design/fips-mesh-operation.md index 1a14725..967a0f8 100644 --- a/docs/design/fips-mesh-operation.md +++ b/docs/design/fips-mesh-operation.md @@ -347,8 +347,8 @@ The source creates a LookupRequest containing: - **request_id**: Unique identifier for deduplication - **target**: The node_addr being sought - **origin**: The requester's node_addr -- **origin_coords**: The requester's current tree coordinates (so the - response can route back) +- **min_mtu**: Minimum transport MTU the origin requires (transit nodes + skip peers whose link MTU is below this) - **TTL**: Bounds the forwarding radius ### Bloom-Guided Tree Routing @@ -433,8 +433,8 @@ the primary mechanism: each transit node looks up the `request_id` in its `recent_requests` table to find the peer that forwarded the original request, and sends the response back through that peer. This ensures the response follows the same path as the request. Greedy tree routing toward the -`origin_coords` is used only as a fallback if the reverse-path entry has -expired. +greedy tree routing toward the origin's coordinates is used only as a +fallback if the reverse-path entry has expired. **Response-forwarded flag**: Each `recent_requests` entry tracks whether a response has already been forwarded for that `request_id`. If a second @@ -663,16 +663,23 @@ When traffic resumes: 3. Coordinates: discovery may be needed if cache has expired 4. SessionSetup re-warms transit caches on the new path -## Leaf-Only Operation *(under development)* +## Node Profiles -Leaf-only operation is an optimization for resource-constrained nodes -(sensors, battery-powered devices). The core infrastructure exists (config -flag, node constructor, bloom filter support) but is not yet enabled in -normal operation. +Nodes advertise a profile during FMP negotiation (bits 0-2 of the feature +bitfield): **Full** (default), **NonRouting**, or **Leaf**. At least one +side of a link must be Full. Config mapping: `disable_routing: true` → +NonRouting, `leaf_only: true` → Leaf. -### Concept +### Non-Routing Nodes -A leaf-only node connects to a single upstream peer that handles all routing +A non-routing node participates in the spanning tree but does not forward +transit traffic or send bloom filters. Its full peer inserts the +non-routing node's identity as a leaf dependent. MMP report flow is gated +by wants/provides bits negotiated during the handshake. + +### Leaf Nodes + +A leaf node connects to a single upstream peer that handles all routing on its behalf: - **No bloom filter storage or processing**: The upstream peer includes the @@ -695,7 +702,7 @@ The upstream peer: Even as a leaf-only node, it still: -- Maintains its own Noise IK link session with the upstream peer (FMP layer) +- Maintains its own Noise XX link session with the upstream peer (FMP layer) - Can establish end-to-end FSP sessions with arbitrary destinations - Has its own identity (npub, node_addr) @@ -707,8 +714,8 @@ routing decisions but retains its own end-to-end encryption and identity. | Message | Typical Size | When | Forwarded? | | ------- | ------------ | ---- | ---------- | | TreeAnnounce | Variable (depth-dependent) | Topology changes | No (peer-to-peer) | -| FilterAnnounce | ~1 KB | Topology changes | No (peer-to-peer) | -| LookupRequest | ~300 bytes | First contact, recovery | Yes (bloom-guided tree) | +| FilterAnnounce | variable (RLE compressed) | Topology changes | No (peer-to-peer) | +| LookupRequest | 44 bytes + TLV | First contact, recovery | Yes (bloom-guided tree) | | LookupResponse | ~400 bytes | Response to discovery | Yes (greedy routed) | | SessionDatagram + SessionSetup | ~232–402 bytes | Session establishment | Yes (routed) | | SessionDatagram + SessionAck | ~170 bytes | Session confirmation | Yes (routed) | @@ -766,7 +773,7 @@ recovery). | Discovery originator backoff | **Implemented** | | Discovery transit-side rate limiting | **Implemented** | | Discovery response-forwarded dedup | **Implemented** | -| Leaf-only operation | Under development | +| Node profiles (Full, NonRouting, Leaf) | **Implemented** | | Link cost in parent selection (ETX) | **Implemented** | | Link cost in candidate ranking | **Implemented** | diff --git a/docs/design/fips-session-layer.md b/docs/design/fips-session-layer.md index 7892032..c3f2be2 100644 --- a/docs/design/fips-session-layer.md +++ b/docs/design/fips-session-layer.md @@ -120,24 +120,29 @@ node, FMP delivers it to FSP for session-layer processing. Sessions are established on demand when the first datagram needs to be sent to a destination with no existing session. -FSP uses Noise XK for session key agreement (Noise Protocol Framework; -Perrin 2018). The initiator knows the destination's npub (required for -XK's pre-message `s` token); the responder learns the initiator's -identity from msg3 (not msg1, unlike IK at the link layer). This provides stronger initiator identity hiding -— the initiator's static key is encrypted under the established shared -secret rather than under only the responder's static key. +FSP uses Noise XX for session key agreement (Noise Protocol Framework; +Perrin 2018). Neither side requires prior knowledge of the other's +static key — both identities are revealed during the handshake +(responder in msg2, initiator in msg3). An optional protocol negotiation +payload may be appended to msg2/msg3 (omitted for rekey handshakes). The handshake is a three-message flow carried in SessionSetup, SessionAck, and SessionMsg3: -1. **Initiator** sends SessionSetup containing Noise XK msg1 (ephemeral key +1. **Initiator** sends SessionSetup containing Noise XX msg1 (ephemeral key only) and both parties' tree coordinates -2. **Responder** processes msg1, sends SessionAck containing Noise XK msg2 - (ephemeral key + encrypted epoch) and both parties' tree coordinates. - The responder transitions to AwaitingMsg3 state. -3. **Initiator** processes msg2, sends SessionMsg3 containing the encrypted - static key and encrypted epoch. Both parties derive identical symmetric - session keys and the session is established. +2. **Responder** processes msg1, sends SessionAck containing Noise XX msg2 + (ephemeral key + encrypted static key + encrypted epoch) and both + parties' tree coordinates. The responder transitions to AwaitingMsg3 + state. +3. **Initiator** processes msg2 (learning the responder's identity), sends + SessionMsg3 containing its encrypted static key and encrypted epoch. + The responder learns the initiator's identity from msg3. Both parties + derive identical symmetric session keys and the session is established. + +Post-handshake identity verification uses x-only key comparison +(parity-independent) to confirm the revealed identity matches the +expected npub. Each side's epoch (an 8-byte random value generated at startup) is exchanged encrypted in msg2 and msg3. On subsequent handshakes, an epoch @@ -218,29 +223,30 @@ than network addresses. A session survives: ## End-to-End Encryption -### Noise XK Pattern +### Noise XX Pattern -FSP uses Noise XK for session encryption, distinct from the Noise IK -pattern used at the link layer. The full Noise descriptor is -`Noise_XK_secp256k1_ChaChaPoly_SHA256`. +FSP uses the same Noise XX pattern as the link layer (FMP). The full +Noise descriptor is `Noise_XX_secp256k1_ChaChaPoly_SHA256`. -The XK pattern (pre-message: `← s`): +The XX pattern (no pre-message): -- **msg1** (`→ e, es`): Initiator sends ephemeral key only. The initiator's - static identity is not revealed in this message. -- **msg2** (`← e, ee`): Responder sends ephemeral key and encrypted epoch. +- **msg1** (`→ e`): Initiator sends ephemeral key only. No identity + disclosed, no DH with static keys. +- **msg2** (`← e, ee, s, es`): Responder sends ephemeral key, encrypted + static key, and encrypted epoch. The initiator learns the responder's + identity. - **msg3** (`→ s, se`): Initiator sends encrypted static key and encrypted - epoch. Both parties now share identical session keys. + epoch. The responder learns the initiator's identity. Both parties now + share identical session keys. After the handshake, Noise produces two directional symmetric keys -(`send_key`, `recv_key`) used with ChaCha20-Poly1305 for all subsequent data. +(`send_key`, `recv_key`) used with ChaCha20-Poly1305 for all subsequent +data. -The XK pattern requires the initiator to know the responder's static key -in advance (the `← s` pre-message), which is satisfied by the discovery -or DNS lookup that precedes session establishment. In exchange, XK -provides stronger initiator identity protection than IK — the initiator's -static key is encrypted under the full shared secret (after three DH -operations) rather than under only the responder's static key. +XX requires no prior knowledge of the peer's static key. The initiator +still needs the destination's npub to address the SessionSetup, but the +Noise handshake itself does not depend on it — identity is verified +post-handshake by comparing the revealed key against the expected npub. ### Cryptographic Primitives @@ -258,26 +264,27 @@ messaging standard. ### secp256k1 Parity Normalization -Nostr npubs encode x-only public keys (32 bytes, no y-coordinate parity). The -Noise XK pre-message mixes the responder's static key as a 33-byte compressed -key, and the default secp256k1 ECDH hash includes a parity-dependent version -byte. +Nostr npubs encode x-only public keys (32 bytes, no y-coordinate parity). +When the Noise XX handshake reveals a peer's static key via +`public_key().serialize()`, the key has its actual parity (0x02 or 0x03 +prefix). The default secp256k1 ECDH hash also includes a parity-dependent +version byte. -Both operations are normalized to be parity-independent: the pre-message hash -uses even parity (`0x02` prefix), and ECDH hashes only the x-coordinate of the -result point. This ensures handshakes succeed regardless of the responder's -actual key parity. +Both operations are normalized to be parity-independent: ECDH hashes only +the x-coordinate of the result point, and post-handshake identity +verification uses `x_only_public_key()` to strip parity before comparing +against the expected npub. This ensures handshakes and identity checks +succeed regardless of key parity. ### Privacy Note -Noise XK provides stronger initiator identity protection than IK. In XK, the -initiator's static key is encrypted in msg3 under the full shared secret -(derived from three DH operations), so an attacker who compromises only the -responder's nsec cannot decrypt the initiator's identity from captured -handshake messages (they would also need the responder's ephemeral key). -This is the primary reason FSP uses XK rather than IK — session-layer -traffic traverses untrusted intermediate nodes, making initiator identity -protection more valuable than at the link layer. +Noise XX provides mutual identity protection — both the initiator's and +responder's static keys are encrypted under the evolving shared secret +(derived from DH operations completed in earlier messages). An attacker +who compromises only one side's nsec cannot decrypt the other side's +identity from captured handshake messages without also obtaining the +corresponding ephemeral key. Since session-layer traffic traverses +untrusted intermediate nodes, this mutual identity hiding is valuable. ### Data Packet Authentication @@ -534,7 +541,7 @@ MMP session metrics session=npub1tdwa...84le rtt=4.3ms loss=0.6% jitter=0.2ms go | Feature | Status | | ------- | ------ | -| Session establishment (Noise XK) | **Implemented** | +| Session establishment (Noise XX) | **Implemented** | | Peer restart detection (epoch exchange) | **Implemented** | | MtuExceeded handling | **Implemented** | | End-to-end encryption (ChaCha20-Poly1305) | **Implemented** | @@ -571,7 +578,7 @@ MMP session metrics session=npub1tdwa...84le rtt=4.3ms loss=0.6% jitter=0.2ms go - Perrin, T. ["The Noise Protocol Framework"](https://noiseprotocol.org/noise.html). Revision 34, 2018. *Framework for building crypto protocols using Diffie-Hellman - key agreement and AEAD ciphers. FSP uses the XK handshake pattern.* + key agreement and AEAD ciphers. FSP uses the XX handshake pattern.* - Donenfeld, J.A. ["WireGuard: Next Generation Kernel Network Tunnel"](https://www.wireguard.com/papers/wireguard.pdf). NDSS 2017. *Transport-independent cryptographic sessions bound to identity keys diff --git a/docs/design/fips-spanning-tree.md b/docs/design/fips-spanning-tree.md index 72d68f9..06f6a82 100644 --- a/docs/design/fips-spanning-tree.md +++ b/docs/design/fips-spanning-tree.md @@ -165,7 +165,7 @@ When a node receives a TreeAnnounce from peer P: 1. **Validate version**: Reject if version ≠ 0x01 2. **Verify signature**: Check P's declaration signature using P's known - public key (established during Noise IK handshake) + public key (established during Noise XX handshake) 3. **Verify identity**: Confirm the declaration's node_addr matches the sender's known identity 4. **Check freshness**: If `sequence ≤ stored sequence for P`, discard diff --git a/docs/design/fips-transport-layer.md b/docs/design/fips-transport-layer.md index e5b1711..0424896 100644 --- a/docs/design/fips-transport-layer.md +++ b/docs/design/fips-transport-layer.md @@ -16,7 +16,7 @@ to the FIPS Mesh Protocol (FMP) above. The transport layer deals exclusively in **transport addresses** — IP:port or hostname:port addresses, MAC addresses, .onion identifiers, radio device addresses. These are opaque to every layer above FMP. The mapping from transport address to FIPS -identity happens at the link layer after the Noise IK link handshake completes. +identity happens at the link layer after the Noise XX link handshake completes. The word "peer" belongs to the link layer and above; the transport layer knows only about remote endpoints identified by transport addresses. @@ -69,7 +69,7 @@ forwarding and LookupResponse transit annotation. For connection-oriented transports, manage the underlying connection: TCP handshake, Tor circuit establishment, Bluetooth pairing. FMP cannot begin -the Noise IK link handshake until the transport-layer connection is +the Noise XX link handshake until the transport-layer connection is established. Connection-oriented transports expose a non-blocking connect interface. @@ -153,7 +153,7 @@ and duplication at the routing layer. **Connection model**: Connectionless transports (UDP, raw Ethernet) allow immediate datagram exchange. Connection-oriented transports (TCP, Tor, BLE) -require connection setup before FMP can begin the Noise IK link handshake, +require connection setup before FMP can begin the Noise XX link handshake, adding startup latency. **Stream vs. datagram**: Datagram transports have natural packet boundaries. @@ -292,30 +292,28 @@ EtherType 0x2121. SOCK_DGRAM mode lets the kernel handle Ethernet header construction and parsing — the transport deals only with payloads and MAC addresses. -Data frames use a 3-byte header: a 1-byte frame type (`0x00`) followed by -a 2-byte little-endian payload length. The length field allows the receiver -to trim Ethernet minimum-frame padding that would otherwise corrupt AEAD -verification. Beacon frames (`0x01`) use only the 1-byte type prefix -(fixed 34-byte payload). Beacons and data share the same EtherType and -socket. +All frames use a unified 4-byte header: `[type:1][flags:1][length:2 LE]`. +The length field allows the receiver to trim Ethernet minimum-frame padding +that would otherwise corrupt AEAD verification. Frame types: `0x00` (data), +`0x01` (beacon). Beacons and data share the same EtherType and socket. | Property | Value | | -------- | ----- | | EtherType | 0x2121 | | Socket type | AF_PACKET SOCK_DGRAM | -| Data frame header | `[type:1][length:2 LE][payload]` | -| Beacon frame header | `[type:1][payload]` (fixed 34 bytes) | -| Effective MTU | Interface MTU - 3 (typically 1497) | +| Frame header | `[type:1][flags:1][length:2 LE][payload]` | +| Effective MTU | Interface MTU - 4 (typically 1496) | | Addressing | 6-byte MAC address | | Platform | Linux only (`CAP_NET_RAW` required) | ### Beacon Discovery Ethernet nodes discover peers via broadcast beacons sent to -ff:ff:ff:ff:ff:ff. Each beacon is a 34-byte frame containing the sender's -x-only public key. Receiving nodes extract the MAC source address from the -frame and the public key from the payload, then report the discovered peer -to FMP. +ff:ff:ff:ff:ff:ff. Beacons are minimal 5-byte frames (4-byte header + +1-byte beacon type) — no public key is included. The peer's identity is +learned from the Noise XX handshake after the connection is established. +Receiving nodes extract the MAC source address from the frame and report +the discovered address to FMP. Four configuration flags control discovery behavior: @@ -400,7 +398,7 @@ every tick, `poll_pending_connects()` calls `connection_state(addr)` to check progress. When the transport reports `Connected`, the completed connection is promoted to the established pool (stream split into read/write halves, per-connection receive task spawned), and the node -initiates the Noise IK link handshake. If the transport reports `Failed`, +initiates the Noise XX link handshake. If the transport reports `Failed`, the node schedules a retry with exponential backoff. As a fallback, `send(addr, data)` still performs synchronous @@ -447,7 +445,7 @@ socket is created). The Tor transport routes FIPS traffic through the Tor network, hiding a node's IP address from its peers. A node behind Tor connects outbound through a local Tor SOCKS5 proxy; the remote peer sees the Tor exit -node's IP, not the initiator's. After the Noise IK handshake, the remote +node's IP, not the initiator's. After the Noise XX handshake, the remote peer knows the initiator's FIPS identity (npub) but not its network location. @@ -528,7 +526,7 @@ The inbound accept loop mirrors the TCP transport's pattern: accept connection, configure socket (TCP_NODELAY, keepalive), spawn a per-connection receive loop using the shared FMP stream reader. Inbound connections arrive from `127.0.0.1` (Tor daemon's local forwarding); peer -identity is resolved during the Noise IK handshake, not from the transport +identity is resolved during the Noise XX handshake, not from the transport address. Configuration requires coordinating `torrc` and `fips.yaml`: diff --git a/docs/design/fips-wire-formats.md b/docs/design/fips-wire-formats.md index 45bd213..0c5891c 100644 --- a/docs/design/fips-wire-formats.md +++ b/docs/design/fips-wire-formats.md @@ -24,14 +24,14 @@ byte stream; the common prefix `payload_len` field provides this framing directly. TCP and Tor share a common stream reader (`tcp/stream.rs`) that implements this framing. -**Ethernet data frame header.** The Ethernet transport prepends a 3-byte -header before the FMP payload on data frames: a 1-byte frame type -(`0x00`) followed by a 2-byte little-endian payload length. The length -field allows the receiver to trim Ethernet minimum-frame padding that -would otherwise corrupt AEAD verification. Beacon frames (`0x01`) have -no length field (fixed 34-byte payload). These bytes are consumed by the -transport layer and are not visible to FMP. The effective MTU for FMP is -the interface MTU minus three bytes (typically 1497). +**Ethernet frame header.** The Ethernet transport prepends a 4-byte +unified header before the payload: `[type:1][flags:1][length:2 LE]`. +The length field allows the receiver to trim Ethernet minimum-frame +padding that would otherwise corrupt AEAD verification. Frame types: +`0x00` (data), `0x01` (beacon). Beacons are 5 bytes total (4-byte +header + 1-byte beacon type). These bytes are consumed by the +transport layer and are not visible to FMP. The effective MTU for FMP +is the interface MTU minus four bytes (typically 1496). ## Link-Layer Formats @@ -45,7 +45,7 @@ length. | Field | Size | Description | | ----- | ---- | ----------- | -| version | 4 bits (high) | Protocol version. Currently 0x0 | +| version | 4 bits (high) | Protocol version. Currently 0x1 | | phase | 4 bits (low) | Session lifecycle phase (see table) | | flags | 1 byte | Per-packet signal flags (zero during handshake) | | payload_len | 2 bytes LE | Length of payload after phase-specific header, excluding AEAD tag | @@ -55,8 +55,9 @@ length. | Phase | Type | Description | | ----- | ---- | ----------- | | 0x0 | Established frame | Post-handshake encrypted traffic | -| 0x1 | Noise IK msg1 | Handshake initiation | -| 0x2 | Noise IK msg2 | Handshake response | +| 0x1 | Noise XX msg1 | Handshake initiation (ephemeral only) | +| 0x2 | Noise XX msg2 | Handshake response (responder identity + negotiation) | +| 0x3 | Noise XX msg3 | Handshake completion (initiator identity + negotiation) | ### Flags (Established Phase Only) @@ -64,10 +65,9 @@ length. | --- | ---- | ----------- | | 0 | K (key epoch) | Selects active key during rekeying | | 1 | CE | Congestion Experienced echo | -| 2 | SP (spin bit) | RTT measurement | -| 3-7 | — | Reserved (must be zero) | +| 2-7 | — | Reserved (must be zero) | -Flags must be zero in handshake packets (phase 0x1 and 0x2). +Flags must be zero in handshake packets (phase 0x1, 0x2, and 0x3). ### Established Frame (phase 0x0) @@ -80,7 +80,7 @@ encrypted link-layer message. | Field | Size | Description | | ----- | ---- | ----------- | -| common prefix | 4 bytes | ver=0, phase=0, flags, payload_len | +| common prefix | 4 bytes | ver=1, phase=0, flags, payload_len | | receiver_idx | 4 bytes LE | Session index for O(1) lookup | | counter | 8 bytes LE | Monotonic nonce, used as AEAD nonce and for replay detection | @@ -108,67 +108,141 @@ the 1-byte message type and message-specific fields. | Type | Message | Description | | ---- | ------- | ----------- | | 0x00 | SessionDatagram | Encapsulated session-layer payload for forwarding | -| 0x01 | SenderReport | MMP sender-side metrics report (48 bytes) | -| 0x02 | ReceiverReport | MMP receiver-side metrics report (68 bytes) | +| 0x01 | SenderReport | MMP sender-side metrics report (20 bytes) | +| 0x02 | ReceiverReport | MMP receiver-side metrics report (54 bytes) | | 0x10 | TreeAnnounce | Spanning tree state announcement | | 0x20 | FilterAnnounce | Bloom filter reachability update | +| 0x21 | FilterNack | Bloom filter delta NACK (retransmit request) | | 0x30 | LookupRequest | Coordinate discovery request | | 0x31 | LookupResponse | Coordinate discovery response | | 0x50 | Disconnect | Orderly link teardown | | 0x51 | Heartbeat | Link liveness probe | -### Noise IK Message 1 (phase 0x1) +### Noise XX Message 1 (phase 0x1) -Handshake initiation from connecting party. +Handshake initiation from connecting party. The initiator sends only its +ephemeral key — neither side's static identity is revealed in msg1. -![Noise IK message 1](diagrams/noise-ik-msg1.svg) +![Noise XX message 1](diagrams/noise-xx-msg1.svg) -Common prefix: ver=0, phase=0x1, flags=0, payload_len=110 (4 + 106). +Common prefix: ver=1, phase=0x1, flags=0, payload_len=37 (4 + 33). | Field | Size | Description | | ----- | ---- | ----------- | -| common prefix | 4 bytes | ver=0, phase=1, flags=0, payload_len | +| common prefix | 4 bytes | ver=1, phase=1, flags=0, payload_len | | sender_idx | 4 bytes LE | Initiator's session index (becomes receiver's `receiver_idx`) | -| noise_msg1 | 106 bytes | Noise IK first message | +| noise_msg1 | 33 bytes | Noise XX first message | -**Noise msg1 breakdown** (106 bytes): +**Noise msg1 breakdown** (33 bytes): | Offset | Field | Size | Description | | ------ | ----- | ---- | ----------- | | 0 | ephemeral_pubkey | 33 bytes | Initiator's ephemeral key (compressed secp256k1) | -| 33 | encrypted_static | 49 bytes | Initiator's static key (33) + AEAD tag (16) | -| 82 | encrypted_epoch | 24 bytes | Startup epoch (8) + AEAD tag (16) | -Noise pattern: `-> e, es, s, ss` with epoch payload +Noise pattern: `-> e` -### Noise IK Message 2 (phase 0x2) +**Total wire size**: 41 bytes (4 prefix + 4 sender_idx + 33 noise). -Handshake response from responder. +### Noise XX Message 2 (phase 0x2) -![Noise IK message 2](diagrams/noise-ik-msg2.svg) +Handshake response from responder. The responder reveals its identity +(ephemeral key, encrypted static key, and encrypted epoch) plus an +optional protocol negotiation payload. -Common prefix: ver=0, phase=0x2, flags=0, payload_len=65 (4 + 4 + 57). +![Noise XX message 2](diagrams/noise-xx-msg2.svg) + +Common prefix: ver=1, phase=0x2, flags=0, payload_len varies. | Field | Size | Description | | ----- | ---- | ----------- | -| common prefix | 4 bytes | ver=0, phase=2, flags=0, payload_len | +| common prefix | 4 bytes | ver=1, phase=2, flags=0, payload_len | | sender_idx | 4 bytes LE | Responder's session index | | receiver_idx | 4 bytes LE | Echo of initiator's sender_idx from msg1 | -| noise_msg2 | 57 bytes | Noise IK second message | +| noise_msg2 | 106+ bytes | Noise XX second message (variable with negotiation) | -**Noise msg2 breakdown** (57 bytes): +**Noise msg2 breakdown** (106 bytes base): | Offset | Field | Size | Description | | ------ | ----- | ---- | ----------- | | 0 | ephemeral_pubkey | 33 bytes | Responder's ephemeral key (compressed secp256k1) | -| 33 | encrypted_epoch | 24 bytes | Startup epoch (8) + AEAD tag (16) | +| 33 | encrypted_static | 49 bytes | Responder's static key (33) + AEAD tag (16) | +| 82 | encrypted_epoch | 24 bytes | Startup epoch (8) + AEAD tag (16) | -Noise pattern: `<- e, ee, se` with epoch payload +Noise pattern: `<- e, ee, s, es` with epoch and negotiation payload -After msg2, both parties derive identical symmetric session keys. The -encrypted epoch in msg1 and msg2 enables peer restart detection — if a -peer's epoch changes, the other side knows it restarted and must -re-establish the link. +**Negotiation payload** (variable, appended via `encrypt_payload()`): +encrypted negotiation bytes + AEAD tag. Minimum 26 bytes (10 payload + +16 tag) when present. See [Protocol Negotiation Payload](#protocol-negotiation-payload). + +**Total wire size**: 118 bytes minimum (without negotiation), 144 bytes +typical (with FMP negotiation: 118 + 26). + +After msg2, the responder's identity is known to the initiator. + +### Noise XX Message 3 (phase 0x3) + +Handshake completion from initiator. The initiator reveals its encrypted +static identity and epoch, plus an optional negotiation payload. + +![Noise XX message 3](diagrams/noise-xx-msg3.svg) + +| Field | Size | Description | +| ----- | ---- | ----------- | +| common prefix | 4 bytes | ver=1, phase=3, flags=0, payload_len | +| sender_idx | 4 bytes LE | Echo of initiator's sender_idx | +| receiver_idx | 4 bytes LE | Echo of responder's sender_idx from msg2 | +| noise_msg3 | 73+ bytes | Noise XX third message (variable with negotiation) | + +**Noise msg3 breakdown** (73 bytes base): + +| Offset | Field | Size | Description | +| ------ | ----- | ---- | ----------- | +| 0 | encrypted_static | 49 bytes | Initiator's static key (33) + AEAD tag (16) | +| 49 | encrypted_epoch | 24 bytes | Startup epoch (8) + AEAD tag (16) | + +Noise pattern: `-> s, se` with epoch and negotiation payload + +**Negotiation payload** (variable, appended via `encrypt_payload()`): +same format as msg2. Minimum 26 bytes when present. + +**Total wire size**: 85 bytes minimum (without negotiation), 111 bytes +typical (with FMP negotiation: 85 + 26). + +After msg3, both parties derive identical symmetric session keys and +have exchanged negotiation payloads. The encrypted epoch in msg2 and +msg3 enables peer restart detection — if a peer's epoch changes, the +other side knows it restarted and must re-establish the link. + +### Protocol Negotiation Payload + +Appended to XX msg2 and msg3 via Noise `encrypt_payload()` — extends +the Noise hash chain so negotiation data is authenticated alongside the +handshake transcript. + +**Wire format**: `[format:1][versions:1][features:8][TLV entries...]` + +| Offset | Field | Size | Description | +| ------ | ----- | ---- | ----------- | +| 0 | format | 1 byte | Must be 0 | +| 1 | versions | 1 byte | High nibble = version_min, low nibble = version_max | +| 2 | features | 8 bytes LE | 64-bit feature bitfield | +| 10 | tlv_entries | variable | TLV extensions: `[field_num:2 LE][length:2 LE][value:N]` per entry | + +**Minimum size**: 10 bytes (no TLV entries). With AEAD tag: 26 bytes on +the wire. + +**FMP feature bits**: + +| Bits | Name | Description | +| ---- | ---- | ----------- | +| 0-2 | Node profile | Full (0), NonRouting (1), Leaf (2) | +| 3-6 | MMP wants/provides | MMP capability negotiation | +| 7 | Bloom | Bloom filter capability | +| 8-63 | — | Reserved | + +**Critical**: `decrypt_payload()` MUST be called even if the result is +discarded, to maintain hash chain consistency between the two Noise +transport channels. ### Index Semantics @@ -230,28 +304,49 @@ includes self) ### FilterAnnounce (0x20) Bloom filter reachability update, exchanged between direct peers only. +Supports both full sends and delta (XOR diff) updates with RLE +compression. ![FilterAnnounce](diagrams/filter-announce.svg) | Offset | Field | Size | Description | | ------ | ----- | ---- | ----------- | | 0 | msg_type | 1 byte | 0x20 | -| 1 | sequence | 8 bytes LE | Monotonic counter for freshness | -| 9 | hash_count | 1 byte | Number of hash functions (5 in v1) | -| 10 | size_class | 1 byte | Filter size: `512 << size_class` bytes | -| 11 | filter_bits | variable | Bloom filter bit array | +| 1 | flags | 1 byte | Bit 0: delta (XOR diff), bits 1-7 reserved | +| 2 | sequence | 8 bytes LE | Monotonic counter, per-peer (only increments on actual send) | +| 10 | base_seq | 8 bytes LE | Sequence of reference filter for delta (0 if full send) | +| 18 | size_class | 1 byte | Filter size: `512 << size_class` bytes | +| 19 | compressed_payload | variable | RLE-encoded filter or XOR diff | + +**RLE format**: each run = `[count:2 LE][word:8 LE]` (10 bytes per run). +Sparse XOR diffs compress to very few runs. **Size class table**: -| size_class | Bytes | Bits | Status | -| ---------- | ----- | ---- | ------ | -| 0 | 512 | 4,096 | Reserved | -| 1 | 1,024 | 8,192 | **v1 (MUST use)** | -| 2 | 2,048 | 16,384 | Reserved | -| 3 | 4,096 | 32,768 | Reserved | +| size_class | Bytes | Bits | Notes | +| ---------- | ----- | ---- | ----- | +| 0 | 512 | 4,096 | Minimum | +| 1 | 1,024 | 8,192 | Default | +| 2 | 2,048 | 16,384 | | +| 3 | 4,096 | 32,768 | | +| 4 | 8,192 | 65,536 | | +| 5 | 16,384 | 131,072 | | +| 6 | 32,768 | 262,144 | Maximum | -**v1 payload**: 1,035 bytes (11 header + 1,024 filter). -With link overhead: 1,072 bytes. +**Size**: variable (19-byte header + RLE-compressed payload). + +### FilterNack (0x21) + +Request full filter retransmission. Sent when a node receives an +out-of-sequence delta update (sequence gap detected), triggering a +full retransmit from the sender. + +| Offset | Field | Size | Description | +| ------ | ----- | ---- | ----------- | +| 0 | msg_type | 1 byte | 0x21 | +| 1 | expected_seq | 8 bytes LE | Sequence number the receiver expected | + +**Total**: 9 bytes. ### LookupRequest (0x30) @@ -271,16 +366,10 @@ restructuring. | 25 | origin | 16 bytes | Requester's NodeAddr | | 41 | ttl | 1 byte | Remaining hops (default 64) | | 42 | min_mtu | 2 bytes LE | Minimum transport MTU the origin requires (0 = no requirement) | -| 44 | origin_coords_cnt | 2 bytes LE | Number of coordinate entries | -| 46 | origin_coords | 16 x n bytes | Requester's ancestry (NodeAddr only) | +| 44 | tlv_entries | variable | TLV extensions, forwarded verbatim by transit | -**Size**: `46 + (n x 16)` bytes, where n = origin depth + 1 - -| Origin Depth | Payload | -| ------------ | ------- | -| 3 | 110 bytes | -| 5 | 142 bytes | -| 10 | 222 bytes | +**Size**: 44 bytes fixed + TLV entries. TLV format: +`[field_num:2 LE][length:2 LE][value:N]` per entry. ### LookupResponse (0x31) @@ -298,11 +387,12 @@ requester via the transit nodes that forwarded the request. | 27 | target_coords_cnt | 2 bytes LE | Number of coordinate entries | | 29 | target_coords | 16 x n bytes | Target's ancestry (NodeAddr only) | | 29 + 16n | proof | 64 bytes | Schnorr signature over `(request_id \|\| target \|\| target_coords)` | +| 93 + 16n | tlv_entries | variable | TLV extensions, forwarded verbatim by transit | -**Size**: `93 + (n x 16)` bytes +**Size**: `93 + (n x 16)` bytes + TLV entries -| Target Depth | Payload | -| ------------ | ------- | +| Target Depth | Payload (no TLV) | +| ------------ | ---------------- | | 3 | 141 bytes | | 5 | 173 bytes | | 10 | 253 bytes | @@ -375,16 +465,16 @@ Sent by the frame sender to provide interval-based transmission statistics. | Offset | Field | Size | Encoding | | ------ | ----- | ---- | -------- | | 0 | msg_type | 1 | `0x01` | -| 1 | reserved | 3 | Zero | -| 4 | interval_start_counter | 8 | u64 LE — first counter in this interval | -| 12 | interval_end_counter | 8 | u64 LE — last counter in this interval | -| 20 | interval_start_timestamp | 4 | u32 LE — timestamp at interval start | -| 24 | interval_end_timestamp | 4 | u32 LE — timestamp at interval end | -| 28 | interval_bytes_sent | 4 | u32 LE — payload bytes sent in interval | -| 32 | cumulative_packets_sent | 8 | u64 LE — total packets sent on this link | -| 40 | cumulative_bytes_sent | 8 | u64 LE — total bytes sent on this link | +| 1 | format_version | 1 | 0 (current) | +| 2 | total_length | 2 | u16 LE — 16 (v0 payload size) | +| 4 | interval_packets_sent | 4 | u32 LE — packets sent in interval | +| 8 | interval_bytes_sent | 4 | u32 LE — payload bytes sent in interval | +| 12 | cumulative_packets_sent | 8 | u64 LE — total packets sent on this link | -**Total: 48 bytes.** +**Total: 20 bytes.** + +Decoders skip trailing bytes beyond `total_length` for forward +compatibility with future format versions. ### ReceiverReport (0x02) @@ -395,24 +485,26 @@ Sent by the frame receiver to provide loss, jitter, and timing feedback. | Offset | Field | Size | Encoding | | ------ | ----- | ---- | -------- | | 0 | msg_type | 1 | `0x02` | -| 1 | reserved | 3 | Zero | -| 4 | highest_counter | 8 | u64 LE — highest counter value received | -| 12 | cumulative_packets_recv | 8 | u64 LE — total packets received | -| 20 | cumulative_bytes_recv | 8 | u64 LE — total bytes received | -| 28 | timestamp_echo | 4 | u32 LE — echoed sender timestamp for RTT | -| 32 | dwell_time | 2 | u16 LE — time between receive and echo (ms) | -| 34 | max_burst_loss | 2 | u16 LE — largest loss burst in interval | -| 36 | mean_burst_loss | 2 | u16 LE — mean burst length (u8.8 fixed-point) | -| 38 | reserved | 2 | Zero | -| 40 | jitter | 4 | u32 LE — interarrival jitter (microseconds) | -| 44 | ecn_ce_count | 4 | u32 LE — cumulative ECN-CE marked packets | -| 48 | owd_trend | 4 | i32 LE — one-way delay trend (µs/s, signed) | -| 52 | burst_loss_count | 4 | u32 LE — number of loss bursts in interval | -| 56 | cumulative_reorder_count | 4 | u32 LE — total reordered packets | -| 60 | interval_packets_recv | 4 | u32 LE — packets received in interval | -| 64 | interval_bytes_recv | 4 | u32 LE — bytes received in interval | +| 1 | format_version | 1 | 0 (current) | +| 2 | total_length | 2 | u16 LE — 50 (v0 payload size) | +| 4 | timestamp_echo | 4 | u32 LE — echoed sender timestamp for RTT | +| 8 | dwell_time | 2 | u16 LE — time between receive and echo (ms) | +| 10 | highest_counter | 8 | u64 LE — highest counter value received | +| 18 | cumulative_packets_recv | 8 | u64 LE — total packets received | +| 26 | cumulative_bytes_recv | 8 | u64 LE — total bytes received | +| 34 | jitter | 4 | u32 LE — interarrival jitter (microseconds) | +| 38 | ecn_ce_count | 4 | u32 LE — cumulative ECN-CE marked packets | +| 42 | owd_trend | 4 | i32 LE — one-way delay trend (µs/s, signed) | +| 46 | burst_loss_count | 4 | u32 LE — number of loss bursts in interval | +| 50 | cumulative_reorder_count | 4 | u32 LE — total reordered packets | -**Total: 68 bytes.** +**Total: 54 bytes.** + +Fields removed from v0.2: `max_burst_loss`, `mean_burst_loss`, +`interval_packets_recv`, `interval_bytes_recv`. + +Decoders skip trailing bytes beyond `total_length` for forward +compatibility with future format versions. ## Session-Layer Message Formats @@ -434,9 +526,9 @@ protocol version, session lifecycle phase, per-packet flags, and payload length. | Phase | Type | Description | | ----- | ---- | ----------- | | 0x0 | Established | Post-handshake encrypted traffic or plaintext error signals | -| 0x1 | Handshake msg1 | SessionSetup (Noise XK msg1) | -| 0x2 | Handshake msg2 | SessionAck (Noise XK msg2) | -| 0x3 | Handshake msg3 | SessionMsg3 (Noise XK msg3) | +| 0x1 | Handshake msg1 | SessionSetup (Noise XX msg1) | +| 0x2 | Handshake msg2 | SessionAck (Noise XX msg2) | +| 0x3 | Handshake msg3 | SessionMsg3 (Noise XX msg3) | ### FSP Flags (Established Phase Only) @@ -479,7 +571,7 @@ Transit nodes parse the CP flag and extract coordinates without decryption. | ----- | ---- | ----------- | | timestamp | 4 bytes LE | Session-relative milliseconds (u32) | | msg_type | 1 byte | Session-layer message type | -| inner_flags | 1 byte | Bit 0: SP (spin bit for RTT measurement) | +| inner_flags | 1 byte | Reserved (must be zero) | After the inner header, the remaining plaintext is the message-type-specific body. @@ -513,8 +605,8 @@ happens at the session level based on the FSP message type. ### SessionSetup (phase 0x1) Establishes a session and warms transit coordinate caches. Contains the -first message of the Noise XK handshake (ephemeral key only — the -initiator's static identity is not revealed until msg3). +first message of the Noise XX handshake (ephemeral key only — neither +side's static identity is revealed until msg2/msg3). SessionSetup, SessionAck, and SessionMsg3 are identified by the **phase** field in the FSP common prefix (0x1, 0x2, 0x3), not by a message type @@ -535,12 +627,13 @@ Encoded with FSP prefix: ver=0, phase=0x1, flags=0, payload_len. | ... | dest_coords_count | 2 bytes LE | Number of dest coordinate entries | | ... | dest_coords | 16 x m bytes | Destination's ancestry | | ... | handshake_len | 2 bytes LE | Noise payload length | -| ... | handshake_payload | variable | Noise XK msg1 (33 bytes — ephemeral key only) | +| ... | handshake_payload | variable | Noise XX msg1 (33 bytes — ephemeral key only) | ### SessionAck (phase 0x2) -Second message of the Noise XK handshake. The responder sends its -ephemeral key and encrypted epoch. +Second message of the Noise XX handshake. The responder reveals its +identity (ephemeral key, encrypted static key, and encrypted epoch). +Optional FSP negotiation payload may be appended (omitted for rekey). Encoded with FSP prefix: ver=0, phase=0x2, flags=0, payload_len. ![SessionAck](diagrams/session-ack.svg) @@ -555,12 +648,13 @@ Encoded with FSP prefix: ver=0, phase=0x2, flags=0, payload_len. | ... | dest_coords_count | 2 bytes LE | Number of initiator coordinate entries | | ... | dest_coords | 16 x m bytes | Initiator's ancestry (for return-path cache warming) | | ... | handshake_len | 2 bytes LE | Noise payload length | -| ... | handshake_payload | variable | Noise XK msg2 (57 bytes — ephemeral key + encrypted epoch) | +| ... | handshake_payload | variable | Noise XX msg2 (106+ bytes — ephemeral + encrypted static + encrypted epoch + optional negotiation) | ### SessionMsg3 (phase 0x3) -Third and final message of the Noise XK handshake. The initiator reveals -its encrypted static identity and epoch. After msg3, both parties derive +Third and final message of the Noise XX handshake. The initiator reveals +its encrypted static identity and epoch. Optional FSP negotiation payload +may be appended (omitted for rekey). After msg3, both parties derive identical symmetric session keys and the session is established. Encoded with FSP prefix: ver=0, phase=0x3, flags=0, payload_len. @@ -572,9 +666,9 @@ Encoded with FSP prefix: ver=0, phase=0x3, flags=0, payload_len. | ------ | ----- | ---- | ----------- | | 0 | flags | 1 byte | Reserved | | 1 | handshake_len | 2 bytes LE | Noise payload length | -| 3 | handshake_payload | variable | Noise XK msg3 (73 bytes — encrypted static + encrypted epoch) | +| 3 | handshake_payload | variable | Noise XX msg3 (73+ bytes — encrypted static + encrypted epoch + optional negotiation) | -**Noise XK msg3 breakdown** (73 bytes): +**Noise XX msg3 breakdown** (73 bytes base): | Offset | Field | Size | Description | | ------ | ----- | ---- | ----------- | @@ -823,29 +917,33 @@ endpoint session keys). ## Size Summary -### FMP Handshake Messages (Noise IK) +### FMP Handshake Messages (Noise XX) | Message | Raw Noise | Wire Frame | | ------- | --------- | ---------- | -| IK msg1 (ephemeral + encrypted static + encrypted epoch) | 106 bytes | 114 bytes | -| IK msg2 (ephemeral + encrypted epoch) | 57 bytes | 69 bytes | +| XX msg1 (ephemeral only) | 33 bytes | 41 bytes | +| XX msg2 (ephemeral + encrypted static + epoch) | 106 bytes | 144 bytes (with negotiation) | +| XX msg3 (encrypted static + epoch) | 73 bytes | 111 bytes (with negotiation) | -### FSP Handshake Messages (Noise XK) +### FSP Handshake Messages (Noise XX) | Message | Raw Noise | Notes | | ------- | --------- | ----- | -| XK msg1 (ephemeral only) | 33 bytes | Carried in SessionSetup | -| XK msg2 (ephemeral + encrypted epoch) | 57 bytes | Carried in SessionAck | -| XK msg3 (encrypted static + encrypted epoch) | 73 bytes | Carried in SessionMsg3 | +| XX msg1 (ephemeral only) | 33 bytes | Carried in SessionSetup | +| XX msg2 (ephemeral + encrypted static + epoch) | 106 bytes | Carried in SessionAck | +| XX msg3 (encrypted static + epoch) | 73 bytes | Carried in SessionMsg3 | ### Link-Layer Messages (inside encrypted frame) | Message | Size | Notes | | ------- | ---- | ----- | | TreeAnnounce | 100 + 32n bytes | n = depth + 1 | -| FilterAnnounce | 1,035 bytes | v1 (1KB filter) | -| LookupRequest | 303 + 16n bytes | n = origin depth + 1 | -| LookupResponse | 93 + 16n bytes | n = target depth + 1 | +| FilterAnnounce | variable | 19-byte header + RLE-compressed payload | +| FilterNack | 9 bytes | | +| LookupRequest | 44 bytes + TLV | Fixed (no longer depth-dependent) | +| LookupResponse | 93 + 16n bytes + TLV | n = target depth + 1 | +| SenderReport | 20 bytes | Extensibility header | +| ReceiverReport | 54 bytes | Extensibility header | | SessionDatagram | 36 + payload bytes | Fixed 36-byte header | | Disconnect | 2 bytes | | @@ -853,13 +951,13 @@ endpoint session keys). | Message | Typical Size | Notes | | ------- | ------------ | ----- | -| SessionSetup | ~170 bytes | Depth-dependent (XK msg1 = 33 bytes) | -| SessionAck | ~190 bytes | Depth-dependent, carries both endpoints' coords (XK msg2 = 57 bytes) | -| SessionMsg3 | ~80 bytes | Fixed (XK msg3 = 73 bytes, no coords) | +| SessionSetup | ~170 bytes | Depth-dependent (XX msg1 = 33 bytes) | +| SessionAck | ~240 bytes | Depth-dependent, carries both endpoints' coords (XX msg2 = 106+ bytes) | +| SessionMsg3 | ~80 bytes | Fixed (XX msg3 = 73+ bytes, no coords) | | Data (minimal) | 12 + 6 + 4 + payload + 16 bytes | Steady state (port header included) | | Data (with coords) | 12 + ~130 + 6 + 4 + payload + 16 bytes | Warmup/recovery (port header included) | -| SenderReport | 12 + 6 + 46 + 16 bytes | MMP metrics | -| ReceiverReport | 12 + 6 + 66 + 16 bytes | MMP metrics | +| SenderReport | 12 + 6 + 20 + 16 bytes | MMP metrics | +| ReceiverReport | 12 + 6 + 54 + 16 bytes | MMP metrics | | PathMtuNotification | 12 + 6 + 2 + 16 bytes | MTU signal | | CoordsWarmup | 12 + coords + 6 + 16 bytes | Standalone warmup (empty body) | | CoordsRequired | 38 bytes | Fixed (prefix + msg_type + body) |