mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-09 00:04:54 +00:00
Optional peer discovery and NAT hole-punching path gated behind a new
`nostr-discovery` cargo feature. Nodes publish signed overlay endpoint
adverts to public Nostr relays, consume peer adverts to populate
fallback dial addresses, and use STUN-assisted UDP hole punching with
NIP-59 gift-wrap offer/answer signaling to establish direct UDP paths
between NATed peers. Once a punched socket is up, it is handed into
the existing FIPS UDP transport and the standard Noise/FMP session
stack takes over unchanged.
The cargo feature is in the default feature set
(`default = ["nostr-discovery"]`) so stock builds include it; a
build that explicitly disables default features (or selects a
feature set without `nostr-discovery`) does not link the nostr /
nostr-sdk crates and does not emit a no-op poll in the tick loop.
Runtime behavior is independently gated by
`node.discovery.nostr.enabled`, which defaults to false; if the
config enables Nostr on a non-feature build, startup logs a
warning and continues without it.
== Cargo feature and dependencies
- New cargo feature `nostr-discovery = ["dep:nostr", "dep:nostr-sdk"]`.
Not in the default feature set.
- New optional Linux-only dependencies: `nostr 0.44` (features: std,
nip59) and `nostr-sdk 0.44`. Gift-wrap unwrap is hand-rolled in
`src/discovery/nostr/signal.rs` rather than relying on the SDK's
rumor-author check, which FIPS sidesteps by trusting `seal.pubkey`
exclusively.
== Wire format
Overlay advert event: `kind 37195`, parameterized replaceable
(NIP-01 application-defined replaceable range 30000-39999), with
`d = "fips-overlay-v1"`. The digits visually spell FIPS (7=F, 1=I,
9=P, 5=S); a relay survey confirmed the kind is unused.
Advert content carries the version tag, endpoint list
(`udp|tcp|tor` + addr), optional signal-relay and stun-server
metadata, and `issuedAt` / `expiresAt` timestamps. Endpoint
`addr: "nat"` is the sentinel that triggers traversal on the peer
side. NIP-40 `expiration` tag bounds staleness on permanent
shutdown. Lifecycle relies on parameterized-replaceable
supersession; the daemon does not emit NIP-09 kind-5 deletes —
strict relays (Damus, Primal) race delete-against-replace and can
silently drop the replacement.
Gift-wrapped signal event: `kind 21059`. Punch packets carry magic
values `PUNCH_MAGIC` / `PUNCH_ACK_MAGIC`, a sequence number, and a
16-byte session hash.
== Discovery surface
- `src/discovery.rs` (always compiled)
- `EstablishedTraversal`: bound UDP socket + selected remote +
peer npub + optional transport name/config tuning overrides.
- `BootstrapHandoffResult`: returned on successful handoff —
allocated transport id, local/remote addrs, peer NodeAddr,
session id.
- `src/discovery/nostr/` (`#![cfg(feature = "nostr-discovery")]`)
- `types.rs`: wire and control types described above. `ADVERT_KIND`
constant. `BootstrapError` enumerates failure modes (disabled,
missing advert, missing NAT endpoint, no usable relays, invalid
advert, invalid npub, signal timeout, punch timeout, replay,
STUN failure, protocol, nostr, io, serde, event-parse).
- `runtime.rs`: `NostrDiscovery` coordinator. Owns the shared
nostr-sdk `Client`, subscribes to advert + signal event kinds,
maintains a bounded advert cache and a bounded seen-sessions
replay set, drains `BootstrapEvent::{Established, Failed}` for
the node to consume, exposes `update_local_advert`,
`request_connect`, `advert_endpoints_for_peer`,
`cached_open_discovery_candidates`, and `shutdown`.
- `signal.rs`: NIP-59 gift-wrap encode/decode. Outbound wraps are
built against per-attempt ephemeral keys; inbound events are
unwrapped against the node identity.
- `stun.rs`: RFC 5389/8489 Binding Request client with
XOR-MAPPED-ADDRESS parsing for both IPv4 and IPv6; used only to
observe the initiator's own reflexive address against its
locally configured STUN list (peer-advertised STUN is
informational, never an egress target).
- `traversal.rs`: per-attempt candidate-pair punch planner.
Allocates a fresh `0.0.0.0:0` UDP socket per attempt, enumerates
LAN-private and ULA interface addresses alongside the STUN
reflexive address, schedules probe/ack exchanges at the
configured interval for the configured duration, and picks the
first candidate pair that authenticates end-to-end.
Strategy ordering is Reflexive↔Reflexive first, then LAN, then
Mixed. The STUN-observed pair is the only candidate that's reliable
across arbitrary network topologies; trying it first prevents the
planner from latching onto a misleading host-candidate path before
the reflexive path gets a chance. There is no catch-all
Local↔Local strategy: a previous design that paired every local
host candidate from one side with every local host candidate from
the other could declare success on a one-way reachable asymmetric
L3 path (corporate VPN, Tailscale subnet route, overlapping private
address space), only for the FMP handshake to stall because the
return path didn't match. The legitimate `Lan` strategy still pairs
candidates that share a subnet.
== Configuration surface
`node.discovery.nostr.*` (`NostrDiscoveryConfig`), all `serde(default)`
with `deny_unknown_fields`:
- `enabled` (default false), `advertise` (default true)
- `advert_relays`, `dm_relays`, `stun_servers`: defaults are
`wss://relay.damus.io`, `wss://nos.lol`, `wss://offchain.pub`
for both relay lists, and Google / Cloudflare / Twilio for STUN.
Operators are expected to override for production. Other
verified-working public relays for reference:
`nostr.bitcoiner.social`, `nostr-pub.wellorder.net`,
`nostr.oxtr.dev`, `nostr.mom`.
- `app` (default `"fips-overlay-v1"`), `signal_ttl_secs` (120)
- `policy`: `NostrDiscoveryPolicy::{Disabled, ConfiguredOnly (default),
Open}` — controls whether advert-derived endpoints are consumed
only for peers carrying `via_nostr = true`, or also for
non-configured peers within a budget cap.
- `share_local_candidates` (default false) — when false, the offer's
`local_addresses` list is empty and peers see only the reflexive
address. Enable per-node only for genuinely same-LAN deployments;
off-by-default eliminates the misleading-path failure mode for
the common case where peers are not on the same broadcast domain.
- `open_discovery_max_pending` (64) — caps queued open-discovery
retries; bounded by available outbound slots.
- `max_concurrent_incoming_offers` (16) — semaphore against offer
spam; excess offers are debug-logged and dropped.
- `advert_cache_max_entries` (2048) and `seen_sessions_max_entries`
(2048) — bound memory under ambient relay volume; overflow
evictions are debug-logged.
- `attempt_timeout_secs` (10), `replay_window_secs` (300)
- `punch_start_delay_ms` (2000), `punch_interval_ms` (200),
`punch_duration_ms` (10000)
- `advert_ttl_secs` (3600), `advert_refresh_secs` (1800)
Per-peer and per-transport flags:
- `PeerConfig.via_nostr: bool` — when true (and Nostr is enabled),
advert-derived addresses are appended as fallback dial candidates
after static addresses for that peer.
- `PeerConfig.addresses` is now `serde(default)` and may be empty
when `via_nostr: true`; validation requires at least one of the
two to be present per peer, and the error message names the
peer's npub.
- `UdpConfig.advertise_on_nostr: Option<bool>` and
`UdpConfig.public: Option<bool>` — UDP transports can be
advertised either as direct `host:port` (public = true) or as the
`addr: "nat"` sentinel that triggers rendezvous on the peer side.
- `TcpConfig.advertise_on_nostr` and `TorConfig.advertise_on_nostr`
— TCP and Tor onion endpoints can be advertised as directly
reachable.
- A reserved peer address `transport: udp, addr: "nat"` parses without
special-casing in YAML and routes through the bootstrap runtime.
Cross-field validation (`Config::validate`, called from `Node::new`
and `Node::with_identity`):
- Any transport with `advertise_on_nostr = true` requires
`node.discovery.nostr.enabled = true`.
- Any peer with `via_nostr = true` requires
`node.discovery.nostr.enabled = true`.
- A non-public UDP advert (`advertise_on_nostr = true`,
`public = false` — i.e. `udp:nat`) additionally requires at least
one `dm_relay` and at least one `stun_server`.
Surfaced as `ConfigError::Validation`.
== Node integration
`src/node/lifecycle.rs` is the main integration point.
- At node start (after transports are up, before TUN), if Nostr is
enabled and the feature is compiled in, `NostrDiscovery::start` is
invoked, the initial local overlay advert is built from the live
transport set and published, and the runtime handle is stored.
- The rx tick loop calls `poll_nostr_discovery` (feature-gated both
at method definition and call site), which refreshes the local
advert, drains bootstrap events, adopts established traversals,
schedules retries for failed traversals, and — under `policy:
open` — enqueues outbound retries for non-configured peers
visible in the advert cache, bounded by
`open_discovery_max_pending` and the remaining outbound slots.
- Outbound peer dialing is refactored to `try_peer_addresses`, which
first exhausts the static address list in priority order and only
then appends advert-derived fallback addresses; both lists run
through the same `attempt_peer_address_list` code path. The
`udp:nat` sentinel address triggers `NostrDiscovery::request_connect`
for the peer instead of a direct dial and returns `Ok(())`.
- `build_overlay_advert` walks operational transports, consults
per-instance `UdpConfig` / `TcpConfig` / `TorConfig` (matching by
optional transport instance name), and emits an `OverlayAdvert`
including `signalRelays` and `stunServers` when any UDP endpoint
is advertised as NAT.
- `adopt_established_traversal` is the bootstrap handoff API:
allocates a new `TransportId`, constructs a `UdpTransport` with
the user-supplied (or default) `UdpConfig`, calls the new
`adopt_socket_async` to reuse the punched socket verbatim,
registers the transport in the normal transport map, records it
in `bootstrap_transports`, and calls `initiate_connection` so the
normal handshake path runs. On failure, the transport is stopped
and removed cleanly and the set membership is rolled back.
- On clean shutdown, `NostrDiscovery::shutdown` is awaited so
background tasks stop before transports are torn down. (The
advert is not explicitly retracted; NIP-40 expiration plus the
next refresh from any live publisher supersedes it.)
New `Node` fields:
- `nostr_discovery: Option<Arc<NostrDiscovery>>` (feature-gated).
- `bootstrap_transports: HashSet<TransportId>` — per-peer UDP
transports adopted from NAT traversal, cleaned up via
`cleanup_bootstrap_transport_if_unused` whenever the link,
connection, peer, or pending-connect referencing them is removed.
Retry and error surface:
- `RetryState.expires_at_ms: Option<u64>` — optional absolute expiry
for a retry entry. `pump_retries` drops expired entries with an
info log. Used for open-discovery retries, which expire at two
times the advert TTL.
- New `NodeError::BootstrapHandoff(String)` returned from
`adopt_established_traversal` when the underlying transport
adoption fails or local address discovery fails.
- New `ConfigError::Validation(String)`.
- A small refactor extracts `Node::now_ms()` and reuses it across
lifecycle, rx-loop tick, and timeout bookkeeping.
== UDP transport
`src/transport/udp/`:
- `UdpRawSocket::adopt(std::net::UdpSocket, recv_buf, send_buf)`:
adopts an externally bound socket, makes it non-blocking, applies
the configured buffer sizes (warning if the kernel clamps), and
reports the resulting local address. Preserves the NAT mapping —
no rebind.
- `UdpTransport::adopt_socket_async(std::net::UdpSocket)`: the
`start_async` analogue for an already-bound socket, wiring the
async socket and recv task exactly as the fresh-bind path would.
- `Drop` impl for `UdpTransport`: if a transport is dropped while
still holding a recv task or socket (for example on error
teardown), aborts the task, clears the socket, and emits a debug
log so the cleanup is visible in tracing rather than silent.
== Logging and observability
Default `EnvFilter` demotes third-party relay-pool DEBUG output to
TRACE-only: `nostr_relay_pool`, `nostr_sdk`, and `nostr` are pinned
at INFO when our level is anything below TRACE, and at TRACE when
our level is TRACE — so the raw frames are still reachable when
explicitly asked for. RUST_LOG continues to override completely.
Concise one-line DEBUG events are emitted at the meaningful points
in the discovery / hole-punch sequence:
- `advert: published` (event id, relay count, endpoints, ttl)
- `advert: peer cached` (notify-loop ingress for non-self)
- `advert: resolved` (cache hit / relay fetch outcome)
- `traversal: initiator starting`
- `traversal: initiator STUN observed` (reflexive, local count)
- `traversal: offer sent` (session id, relay count, event id)
- `traversal: answer received` (accepted, reflexive, local)
- `traversal: initiator punch succeeded` (remote addr)
- `traversal: offer received` (responder side)
- `traversal: responder STUN observed`
- `traversal: answer sent`
- `traversal: responder punch succeeded`
Npubs are shortened to `npub1<4>..<4>` and event/session ids to
their first 8 hex characters.
Other operator-facing logs:
- `UdpTransport` adoption and drop paths log at info / debug.
- `adopt_established_traversal` logs at debug on entry and info on
successful return, tagged with peer npub, session id, transport
id, and both socket endpoints, so the bootstrap handoff is
traceable end-to-end alongside the `UdpTransport::drop` log.
- `cleanup_bootstrap_transport_if_unused` logs at debug when the
reference-count check drops an adopted transport.
- `connect_peer` tags its entry `debug!` with `peer_npub` so
downstream STUN, punch, and handshake logs for the same peer
correlate for operators.
- Advert-cache and seen-sessions overflow evictions log at debug so
mis-sized caps are visible under ambient relay volume.
- Gift-wrap unwrap failures on `SIGNAL_KIND` events log at trace
(hot path: fires for every unrelated signal event on the same
relay).
- Traversal-offer handler failures log at debug. Expected conditions
such as punch timeout on symmetric NAT are covered there; real
problems are reported upstream via `BootstrapEvent::Failed`.
- Inbound-offer rate-limit messages name the governing config field
(`max_concurrent_incoming_offers`) and state that the offer was
rate-limited rather than failing.
== Tests
- 18 new unit tests in `src/discovery/nostr/tests.rs` covering advert
encoding, signal envelope round-trip, STUN parsing, punch-packet
codec, and replay-window enforcement. Run under the
`nostr-discovery` feature.
- Config-validation tests in `src/config/mod.rs` covering the three
cross-field invariants and YAML parsing of the full
`node.discovery.nostr` block plus `peers[].via_nostr`, empty
`addresses` with `via_nostr: true`, and a `udp: nat` address.
- `src/node/tests/bootstrap.rs` integration tests that drive a
synthetic traversal (bound UDP socket pair + synthetic peer
identity) through `adopt_established_traversal` and assert the
Noise handshake completes over the adopted socket.
- Punch-planner tests assert reflexive-before-LAN ordering and that
same-LAN scenarios still include the LAN target in the plan.
- `testing/nat/` Docker NAT lab harness:
- Local `strfry` relay, local STUN responder, and one or two
router containers performing `iptables` NAT.
- Node LAN interfaces are provisioned with explicit `veth` pairs
injected into the node and router namespaces so every packet
traverses the router namespace (plain Docker bridges are not
used for the LAN).
- `cone` scenario: both peers behind full-cone-emulation NAT
(SNAT with source-port preservation, inbound DNAT back to the
single LAN host regardless of remote source); asserts UDP
traversal succeeds and link remote addresses are on the router
WAN subnet.
- `symmetric` scenario: `MASQUERADE --random-fully`; asserts UDP
traversal fails and TCP fallback converges over router-
published WAN addresses.
- `lan` scenario: both peers share a LAN subnet; asserts LAN
addresses are preferred over reflexive ones.
- Cleanup tears down all profile-gated services
(`--profile cone --profile symmetric --profile lan`) so no
orphan containers survive a run.
- `testing/scripts/build.sh` builds the Docker test image with
`--features "tui nostr-discovery"` by default so NAT-harness
binaries include bootstrap support.
== CI
- Linux release build and nextest unit-test job both use
`--features "gateway nostr-discovery"` so the feature-gated code
and its unit tests compile and run in CI.
- Three new integration matrix entries (`nat-cone`, `nat-symmetric`,
`nat-lan`) invoke `testing/nat/scripts/nat-test.sh`, collect
`docker compose logs` on failure, and always stop containers.
== Packaging and operations
- `packaging/common/fips.yaml` ships a fully commented
`node.discovery.nostr.*` block, plus documented
`advertise_on_nostr` / `public` examples under the UDP transport,
an `advertise_on_nostr` example under TCP, and a `via_nostr: true`
example under the static peer section with both a direct
`host:port` UDP address and a `udp: nat` fallback.
- `.github/workflows/package-openwrt.yml`: NIP-94 release event
publishes target the new default relay set.
== Documentation
- `README.md`: overlay discovery + NAT traversal moved from
"Near-term priorities" into "What works today".
- `docs/design/fips-intro.md`: rewrites the paragraphs that
previously described Nostr discovery and NAT traversal as future
work; describes the shipped mechanism and the feature gate.
- `docs/design/fips-transport-layer.md`: drops the "(future
direction)" qualifier from the Nostr Relay Discovery section,
expands with the `udp:nat` advertisement and bootstrap handoff
description, and updates the Current State callout.
- `docs/design/fips-mesh-layer.md`: notes that mid-session NAT
rebinding (roaming) and initial NAT traversal (Nostr path) are
distinct mechanisms.
- `docs/design/fips-configuration.md`: documents the full
`node.discovery.nostr.*` surface, including the three resource
caps and `share_local_candidates`.
- `docs/design/fips-nostr-discovery.md`: design and configuration
reference for the shipped mechanism, including the empty-
`addresses`-with-`via_nostr` shorthand.
- `docs/proposals/nostr-udp-hole-punch-protocol.md`: adds an
Implemented status callout, clarifies that the punch socket is
per-peer and per-attempt rather than shared with the application
listener, aligns field names with the shipped JSON
(`sessionId`, `issuedAt` / `expiresAt`, `reflexiveAddress`,
`localAddresses`, `stunServer`), sets the `d`-tag to
`fips-overlay-v1`, names the kind as 37195, and notes that
advertised STUN entries are informational.
- `docs/proposals/README.md`: adds a Status column and marks the
hole-punching proposal Implemented.
- `CHANGELOG.md`: Unreleased > Added entry covering the discovery
path, STUN/punch path, configuration surface, and Docker NAT lab.
Co-authored-by: Johnathan Corgan <johnathan@corganlabs.com>
593 lines
25 KiB
Markdown
593 lines
25 KiB
Markdown
# FIPS Mesh Protocol (FMP)
|
||
|
||
The FIPS Mesh Protocol is the middle layer of the FIPS protocol stack. It sits
|
||
between the transport layer below and the FIPS Session Protocol (FSP) above.
|
||
FMP is where anonymous transport addresses become authenticated peers, where
|
||
the mesh self-organizes, and where forwarding decisions are made.
|
||
|
||
## Role
|
||
|
||
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
|
||
link carries all inter-peer communication: spanning tree gossip, bloom filter
|
||
updates, coordinate discovery, and forwarded session datagrams — all encrypted
|
||
per-hop.
|
||
|
||
FMP is the boundary between opaque transport addresses and identified peers.
|
||
Below FMP, everything is transport-specific addresses (host:port, MAC, .onion).
|
||
Above FMP, everything is peers identified by public keys and routable by
|
||
node_addr. The transport layer never sees FIPS-level structure; FSP never sees
|
||
transport addresses or routing details.
|
||
|
||
## Services Provided to FSP
|
||
|
||
From the session layer's perspective, FMP is a black box providing three
|
||
services. FSP knows nothing about transports, links, peers, spanning trees,
|
||
coordinates, bloom filters, hop counts, or network topology.
|
||
|
||
### Datagram Forwarding
|
||
|
||
FMP accepts a datagram addressed by source and destination node_addr and
|
||
delivers it best-effort toward the destination. The datagram travels hop by
|
||
hop — at each node, FMP decrypts the link layer, reads the destination
|
||
node_addr, makes a local forwarding decision, and re-encrypts onto the
|
||
next-hop link.
|
||
|
||
FSP provides: source node_addr, destination node_addr, hop limit, and an
|
||
opaque payload (the session-layer encrypted message).
|
||
|
||
FMP provides: best-effort delivery. No acknowledgment, no retransmission, no
|
||
ordering guarantee. Datagrams may be dropped, duplicated, or delivered out of
|
||
order.
|
||
|
||
### Error Signaling
|
||
|
||
When forwarding fails, FMP signals the source endpoint asynchronously:
|
||
|
||
- **CoordsRequired**: A transit node lacks the destination's tree coordinates
|
||
and cannot make a forwarding decision. The source should re-initiate
|
||
discovery and reset its coordinate warmup strategy.
|
||
- **PathBroken**: Greedy routing reached a dead end — no peer is closer to the
|
||
destination than the current node. The source should re-discover the
|
||
destination's current coordinates.
|
||
|
||
Both signals travel inside the SessionDatagram envelope (using the existing
|
||
src/dest/hop_limit addressing) but are generated by transit nodes and are not
|
||
end-to-end encrypted. They are rate-limited at 100ms per destination to prevent
|
||
storms during topology changes.
|
||
|
||
### Local Delivery
|
||
|
||
When a datagram arrives with a destination node_addr matching the local node,
|
||
FMP delivers it up to FSP for session-layer processing.
|
||
|
||
## Services Required from Transport Layer
|
||
|
||
FMP requires the following from each transport:
|
||
|
||
### Datagram Delivery
|
||
|
||
Send and receive raw datagrams to/from transport addresses. The transport
|
||
handles all medium-specific details. FMP sees only "send bytes to address" and
|
||
"bytes arrived from address."
|
||
|
||
### MTU Reporting
|
||
|
||
The maximum datagram size for a given link. FMP needs this to determine how
|
||
much payload fits in a single packet after link encryption overhead (37 bytes
|
||
for the encrypted frame wrapper: 16-byte outer header + 5-byte inner header +
|
||
16-byte AEAD tag).
|
||
|
||
### Connection Lifecycle
|
||
|
||
For connection-oriented transports, the transport must establish the underlying
|
||
connection before FMP can begin the Noise IK handshake. For connectionless
|
||
transports, datagrams can flow immediately.
|
||
|
||
### Endpoint Discovery (Optional)
|
||
|
||
When a transport discovers a FIPS-capable endpoint (via beacon, query, or
|
||
other transport-specific mechanism), it notifies FMP so that link setup can
|
||
be initiated. Transports without discovery support provide peer addresses
|
||
through configuration.
|
||
|
||
## Peer Authentication
|
||
|
||
### Noise IK Handshake
|
||
|
||
Every peer connection begins with a Noise IK handshake that mutually
|
||
authenticates both parties and establishes symmetric keys for link encryption.
|
||
|
||
The IK 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
|
||
|
||
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.
|
||
|
||
### 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:
|
||
|
||
- **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
|
||
|
||
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.
|
||
|
||
### Identity Binding
|
||
|
||
The Noise handshake binds the link to the peer's cryptographic identity. 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)
|
||
- All subsequent traffic on the link is authenticated by the Noise session —
|
||
successful decryption proves the sender is the authenticated peer
|
||
- The link is registered in the dispatch table for O(1) packet routing
|
||
|
||
### 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
|
||
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.
|
||
|
||
### Auto-Reconnect
|
||
|
||
When MMP's liveness detection removes a peer (dead timeout exceeded), FMP
|
||
automatically re-initiates the connection if the peer is configured for it.
|
||
The auto-reconnect path:
|
||
|
||
1. `check_link_heartbeats()` detects the dead peer and calls
|
||
`remove_active_peer()`, tearing down the link and triggering tree/bloom
|
||
reconvergence
|
||
2. `schedule_reconnect()` checks whether the peer is in the auto-connect list
|
||
with `auto_reconnect: true` (the default)
|
||
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
|
||
peer's configured transport addresses
|
||
|
||
Auto-reconnect only applies to peers in the static peer list with
|
||
`connect_policy: auto_connect`. Inbound-only peers (those not in the local
|
||
config) are not reconnected — the owning node (the one with the outbound
|
||
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
|
||
within the handshake timeout window. This handles packet loss on the
|
||
underlying transport without waiting for the full handshake timeout to
|
||
expire.
|
||
|
||
Configuration (under `node.rate_limit.*`):
|
||
|
||
- `handshake_resend_interval_ms` (default 1000): initial resend interval
|
||
- `handshake_resend_backoff` (default 2.0): backoff multiplier per resend
|
||
- `handshake_max_resends` (default 5): max resends per handshake attempt
|
||
|
||
With defaults, resends occur at 1s, 2s, 4s, 8s, 16s — all within the 30s
|
||
handshake timeout. Under 19% per-attempt loss (10% bidirectional), the
|
||
probability of all 6 attempts (initial + 5 resends) failing is ~0.005%.
|
||
|
||
Session-layer resends wrap the stored payload in a fresh SessionDatagram so
|
||
routing adapts to topology changes between resends. Responder idempotency:
|
||
duplicate msg1/SessionSetup triggers resend of the stored msg2/SessionAck.
|
||
|
||
## Link Encryption
|
||
|
||
All traffic between authenticated peers is encrypted. Every packet on a link
|
||
— gossip messages, routing queries, forwarded session datagrams, disconnect
|
||
notifications — passes through Noise's ChaCha20-Poly1305 AEAD.
|
||
|
||
### Encrypted Frame Structure
|
||
|
||
Post-handshake packets are wrapped in an encrypted frame consisting of:
|
||
|
||
- A 4-byte common prefix (version, phase, flags, payload length)
|
||
- A receiver index for O(1) session lookup
|
||
- An explicit counter used as the AEAD nonce
|
||
- The ciphertext with a Poly1305 authentication tag
|
||
|
||
The 16-byte outer header (common prefix + receiver index + counter) is used as
|
||
AAD for the AEAD, binding the header to the ciphertext without encrypting it.
|
||
|
||
The plaintext inside the encrypted frame begins with a 5-byte inner header
|
||
(4-byte session-relative timestamp followed by a message type byte), then the
|
||
message-specific payload.
|
||
|
||
See [fips-wire-formats.md](fips-wire-formats.md) for the complete wire format
|
||
specification.
|
||
|
||
### What Encryption Provides
|
||
|
||
- **Confidentiality**: An observer on the underlying transport sees only
|
||
encrypted packets, packet timing, and packet sizes
|
||
- **Integrity**: Any modification to a packet is detected by the AEAD tag
|
||
- **Authentication**: Only the authenticated peer can produce valid ciphertext
|
||
for this link's session keys
|
||
|
||
### What Encryption Does Not Provide
|
||
|
||
- **End-to-end confidentiality**: Link encryption protects traffic on a single
|
||
hop. Each transit node decrypts, reads the routing envelope, and re-encrypts
|
||
for the next hop. Session-layer encryption (FSP) provides end-to-end
|
||
confidentiality.
|
||
- **Traffic analysis protection**: Packet timing, sizes, and volume are visible
|
||
to transport-layer observers.
|
||
|
||
## Index-Based Session Dispatch
|
||
|
||
Incoming packets are dispatched to the correct Noise session using a
|
||
receiver index — a random 32-bit value chosen by the receiver during the
|
||
handshake. This enables O(1) lookup without relying on source addresses.
|
||
|
||
Each party in a link has two indices:
|
||
|
||
- **our_index**: Chosen by us, included by the peer in packets sent to us
|
||
- **their_index**: Chosen by them, included by us in packets sent to them
|
||
|
||
The tuple `(transport_id, receiver_idx)` uniquely identifies a session.
|
||
|
||
### Index Properties
|
||
|
||
- **Random**: Cryptographically random to prevent guessing
|
||
- **Unique per transport**: No two active sessions on the same transport share
|
||
an index
|
||
- **Scoped to transport**: The same index value may appear on different
|
||
transports
|
||
- **Rotated on rekey**: New indices allocated on rekey to prevent cross-session
|
||
correlation by passive observers
|
||
|
||
### Dispatch Flow
|
||
|
||
1. Read the 4-byte common prefix to determine the phase (established,
|
||
handshake msg1, msg2)
|
||
2. For established frames (phase 0x0): look up `(transport_id, receiver_idx)`
|
||
in the session table — O(1) hash lookup. Unknown indices are rejected
|
||
before any cryptographic operation.
|
||
3. For handshake msg2 (phase 0x2): look up by our sender index to match to a
|
||
pending outbound handshake
|
||
4. For handshake msg1 (phase 0x1): rate-limited processing, creates new state
|
||
|
||
This approach follows WireGuard's design: source address is informational,
|
||
not authoritative. Only successful cryptographic verification establishes
|
||
authenticity.
|
||
|
||
## Roaming
|
||
|
||
When an encrypted packet successfully decrypts, the sender is the
|
||
authenticated peer regardless of what transport address the packet arrived
|
||
from. FMP updates the peer's current address to the packet's source address,
|
||
and subsequent outbound packets use the updated address.
|
||
|
||
This allows peers to change transport addresses (e.g., host:port for UDP)
|
||
without session interruption. The mechanism is:
|
||
|
||
1. Packet arrives from a different address than expected
|
||
2. Receiver index lookup finds the session
|
||
3. AEAD decryption succeeds — the sender is cryptographically authenticated
|
||
4. Peer's address is updated to the new source address
|
||
|
||
Roaming is most useful for UDP, where source addresses can change due to NAT
|
||
rebinding or network changes. For connection-oriented transports, "roaming"
|
||
manifests as reconnection rather than mid-session address change.
|
||
|
||
Roaming addresses *mid-session* NAT rebinding. Establishing the initial UDP
|
||
path through NAT is a separate concern, addressed by the optional
|
||
Nostr-mediated overlay discovery and STUN-assisted hole punching feature
|
||
(see [fips-transport-layer.md](fips-transport-layer.md) and
|
||
[fips-configuration.md](fips-configuration.md)).
|
||
|
||
## Replay Protection
|
||
|
||
Each link session maintains per-direction counters:
|
||
|
||
- **Send counter**: Monotonically increasing, used as the AEAD nonce for each
|
||
outbound packet
|
||
- **Receive window**: A sliding bitmap (2048 entries) tracking which counters
|
||
have been seen
|
||
|
||
The receive window handles the realities of unreliable transports: packets may
|
||
arrive out of order, be duplicated, or be lost. The window accepts any counter
|
||
not yet seen and within 2048 of the highest counter received. Counters older
|
||
than the window are rejected.
|
||
|
||
The replay check is performed before decryption to prevent CPU exhaustion from
|
||
replayed packets that would pass the index lookup but fail decryption.
|
||
|
||
During link transitions, stale packets from a previous Noise session arrive
|
||
encrypted with old counters and are correctly rejected. To avoid excessive log
|
||
volume from these benign bursts, replay detection logging is suppressed after
|
||
the first 3 occurrences per peer. A summary is emitted when the peer's session
|
||
is re-established or the peer is removed.
|
||
|
||
## Rate Limiting
|
||
|
||
Handshake initiation (msg1) is the primary attack surface for unauthenticated
|
||
traffic. Each msg1 requires Noise DH operations (~200µs on modern CPUs),
|
||
state allocation, and response generation.
|
||
|
||
FMP uses a global token bucket rate limiter:
|
||
|
||
- **Burst capacity**: Handles legitimate connection storms (e.g., node restart
|
||
with many configured peers)
|
||
- **Sustained rate**: Limits steady-state new connections per second
|
||
- **Global scope**: Rate limiting is global, not per-source, because UDP
|
||
source addresses are trivially spoofable
|
||
|
||
Additional protections:
|
||
|
||
- **Connection limit**: Maximum number of pending inbound handshakes, capping
|
||
memory usage
|
||
- **Handshake timeout**: Stale pending handshakes are cleaned up after a
|
||
configurable timeout
|
||
- **Allowlist/blocklist**: Optional peer filtering before handshake processing
|
||
|
||
## Disconnect
|
||
|
||
FMP supports orderly link teardown via a Disconnect message carrying a reason
|
||
code (shutdown, restart, protocol error, transport failure, resource
|
||
exhaustion, security violation, configuration change, timeout).
|
||
|
||
On receiving Disconnect, FMP immediately cleans up state: removes the peer
|
||
from the peer table, frees the session index, removes the link, and cleans up
|
||
address mappings. If the departed peer was the tree parent, FMP triggers parent
|
||
reselection.
|
||
|
||
Disconnect is best-effort — if the transport is broken, the message won't
|
||
arrive. Timeout-based detection remains the fallback for detecting failed
|
||
links.
|
||
|
||
On node shutdown, Disconnect is sent to all active peers before transports are
|
||
stopped.
|
||
|
||
## Liveness Detection
|
||
|
||
FMP detects link liveness through a combination of explicit heartbeats and
|
||
traffic observation.
|
||
|
||
### Heartbeat
|
||
|
||
A Heartbeat message (0x51) is sent to each active peer every
|
||
`node.heartbeat_interval_secs` (default 10s). The heartbeat is a minimal
|
||
encrypted frame with no payload beyond the standard inner header (timestamp +
|
||
message type). Any successfully decrypted frame — data, gossip, MMP report,
|
||
or heartbeat — resets the peer's last-receive timestamp tracked by the MMP
|
||
receiver.
|
||
|
||
### Dead Timeout
|
||
|
||
When no traffic (of any kind) is received from a peer for
|
||
`node.link_dead_timeout_secs` (default 30s), the peer is declared dead and
|
||
removed via `remove_active_peer()`. This triggers the full teardown cascade:
|
||
spanning tree parent reselection (if the dead peer was the parent),
|
||
TreeAnnounce propagation, coordinate cache flush, and bloom filter recompute.
|
||
|
||
If the dead peer is eligible for auto-reconnect (see [Auto-Reconnect]
|
||
(#auto-reconnect)), reconnection is scheduled immediately after removal.
|
||
|
||
The heartbeat is independent of MMP — it is needed because idle links in
|
||
Lightweight MMP mode have no guaranteed periodic traffic (gossip is
|
||
event-driven, and MMP reports require at least one side running Full mode).
|
||
|
||
## Link Message Types
|
||
|
||
FMP defines eight message types carried inside encrypted frames:
|
||
|
||
| Type | Name | Purpose |
|
||
| ---- | ---- | ------- |
|
||
| 0x10 | TreeAnnounce | Spanning tree state announcements between peers |
|
||
| 0x20 | FilterAnnounce | Bloom filter reachability updates |
|
||
| 0x30 | LookupRequest | Coordinate discovery — flood toward destination |
|
||
| 0x31 | LookupResponse | Coordinate discovery — response with coordinates |
|
||
| 0x00 | SessionDatagram | Encapsulated session-layer payload for forwarding |
|
||
| 0x01 | SenderReport | MMP sender-side metrics report |
|
||
| 0x02 | ReceiverReport | MMP receiver-side metrics report |
|
||
| 0x50 | Disconnect | Orderly link teardown with reason code |
|
||
| 0x51 | Heartbeat | Link liveness probe |
|
||
|
||
Additionally, handshake messages (phase 0x1 msg1, phase 0x2 msg2) are sent
|
||
unencrypted before the link session is established.
|
||
|
||
TreeAnnounce and FilterAnnounce are exchanged between direct peers only — they
|
||
are not forwarded. LookupRequest and LookupResponse are forwarded through the
|
||
mesh (flooded with deduplication). SessionDatagram is forwarded hop-by-hop
|
||
toward the destination. Disconnect is peer-to-peer.
|
||
|
||
See [fips-mesh-operation.md](fips-mesh-operation.md) for how these messages
|
||
work together to build and maintain the mesh, and
|
||
[fips-wire-formats.md](fips-wire-formats.md) for byte-level message layouts.
|
||
|
||
## Metrics Measurement Protocol (MMP)
|
||
|
||
Each active peer link runs an instance of the Metrics Measurement Protocol,
|
||
providing per-link quality metrics to the operator and to the spanning tree
|
||
layer for cost-based parent selection.
|
||
|
||
### Metrics Tracked
|
||
|
||
MMP computes the following metrics from the per-frame counter and timestamp
|
||
fields in the FMP wire format:
|
||
|
||
- **SRTT** — Smoothed round-trip time (Jacobson/RFC 6298, α=1/8). Derived
|
||
from timestamp-echo in ReceiverReports with dwell-time compensation.
|
||
- **Loss rate** — Bidirectional loss inferred from counter gaps. Tracked as
|
||
both instantaneous (per-interval) and long-term EWMA.
|
||
- **Jitter** — Interarrival jitter (RFC 3550 algorithm) in microseconds.
|
||
- **Goodput** — Bytes per second of payload data (excludes MMP reports).
|
||
- **OWD trend** — One-way delay trend (µs/s, signed). Indicates congestion
|
||
buildup before loss occurs.
|
||
- **ETX** — Expected Transmission Count, computed from bidirectional delivery
|
||
ratios. Used in cost-based parent selection via
|
||
`link_cost = etx * (1.0 + srtt_ms / 100.0)`; not yet used in
|
||
`find_next_hop()` candidate ranking.
|
||
- **Dual EWMA trends** — Short-term (α=1/4) and long-term (α=1/32) trend
|
||
indicators for both RTT and loss, enabling change detection.
|
||
|
||
### Operating Modes
|
||
|
||
MMP supports three modes, configured via `node.mmp.mode`:
|
||
|
||
| Mode | Reports Exchanged | Metrics Available |
|
||
| ---- | ----------------- | ----------------- |
|
||
| **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. |
|
||
|
||
### Report Scheduling
|
||
|
||
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
|
||
|
||
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.
|
||
|
||
### ECN Congestion Signaling
|
||
|
||
The CE (Congestion Experienced) flag (bit 1 in the FMP flags byte) provides
|
||
hop-by-hop congestion signaling through the mesh. Transit nodes detect
|
||
congestion on outgoing links and set CE on forwarded packets; once set, the
|
||
flag stays set for all subsequent hops to the destination.
|
||
|
||
**Congestion detection** (`detect_congestion()`) triggers on any of:
|
||
|
||
- Outgoing link MMP loss rate ≥ `node.ecn.loss_threshold` (default 5%)
|
||
- Outgoing link MMP ETX ≥ `node.ecn.etx_threshold` (default 3.0)
|
||
- Kernel receive buffer drops detected on any local transport (via
|
||
`SO_RXQ_OVFL` on UDP)
|
||
|
||
**CE relay**: The forwarding path computes `outgoing_ce = incoming_ce ||
|
||
local_congestion`. The `send_encrypted_link_message_with_ce()` method ORs
|
||
`FLAG_CE` into the FMP header flags when ce is true. The original
|
||
`send_encrypted_link_message()` delegates with `ce_flag=false`, leaving the
|
||
20+ existing call sites unchanged.
|
||
|
||
**IPv6 ECN-CE marking**: When a CE-flagged DataPacket arrives at its final
|
||
destination, the IPv6 Traffic Class ECN bits are marked CE (0b11) before
|
||
TUN delivery — but only for ECN-capable packets (ECT(0) or ECT(1)). Not-ECT
|
||
packets are never marked per RFC 3168. The host TCP stack then echoes ECE in
|
||
ACKs, triggering sender cwnd reduction through standard congestion control.
|
||
|
||
**Session-layer tracking**: The `ecn_ce_count` field in MMP ReceiverReports
|
||
tracks CE-flagged packets received per link, providing end-to-end visibility
|
||
into congestion propagation.
|
||
|
||
**Monitoring**: `CongestionStats` tracks four counters — `ce_forwarded`,
|
||
`ce_received`, `congestion_detected`, and `kernel_drop_events` — exposed via
|
||
`fipsctl show routing` (congestion block) and `fipstop` (routing tab).
|
||
Rate-limited warn logging (5s interval) alerts on congestion detection events.
|
||
|
||
See `node.ecn.*` in
|
||
[fips-configuration.md](fips-configuration.md#ecn-signaling-nodeecn) for
|
||
tuning parameters.
|
||
|
||
### Operator Logging
|
||
|
||
MMP emits periodic link metrics at info level (configurable via
|
||
`node.mmp.log_interval_secs`, default 30s):
|
||
|
||
```text
|
||
MMP link metrics peer=node-b rtt=2.3ms loss=0.2% jitter=0.1ms goodput=76.0MB/s tx_pkts=1234 rx_pkts=5678
|
||
```
|
||
|
||
Teardown logs include final SRTT, loss rate, jitter, ETX, goodput, and
|
||
cumulative tx/rx packet and byte counts.
|
||
|
||
## Security Properties
|
||
|
||
### Threat Resistance
|
||
|
||
| Threat | Mitigation |
|
||
| ------ | ---------- |
|
||
| Connection exhaustion | Token bucket rate limit + connection count limit |
|
||
| CPU exhaustion (msg1 flood) | Rate limit before crypto operations |
|
||
| Replay attacks | Counter-based nonces with sliding window |
|
||
| State confusion | Strict handshake state machine validation |
|
||
| Spoofed encrypted packets | Index lookup + AEAD verification |
|
||
| Spoofed msg2 | Index lookup + Noise ephemeral key binding |
|
||
| Address spoofing | Cryptographic authority, not address-based |
|
||
| Session correlation | Index rotation on rekey |
|
||
|
||
### Unauthenticated Attack Surface
|
||
|
||
Only handshake msg1 can be sent by unauthenticated parties. Encrypted frames
|
||
require a known session index, and msg2 requires a response to a specific
|
||
ephemeral key. The msg1 attack surface is protected by rate limiting,
|
||
connection limits, and handshake timeouts.
|
||
|
||
### Authenticated Peer Misbehavior
|
||
|
||
Authentication establishes identity but does not grant trust. An authenticated
|
||
peer can send malformed packets (which fail AEAD and are dropped) or
|
||
high-frequency traffic (rate-limited by higher layers). False tree coordinate
|
||
claims are constrained by signature verification on TreeAnnounce messages.
|
||
|
||
### Silent Drop Policy
|
||
|
||
Invalid packets are silently dropped without error responses. This prevents
|
||
information leakage about internal state and avoids amplification attacks where
|
||
an attacker sends invalid packets to elicit responses.
|
||
|
||
## Implementation Status
|
||
|
||
| Feature | Status |
|
||
| ------- | ------ |
|
||
| Noise IK handshake (with epoch) | **Implemented** |
|
||
| Peer restart detection (epoch mismatch) | **Implemented** |
|
||
| Link encryption (ChaCha20-Poly1305) | **Implemented** |
|
||
| Index-based session dispatch | **Implemented** |
|
||
| Replay protection (sliding window) | **Implemented** |
|
||
| Roaming (address-follows-crypto) | **Implemented** |
|
||
| Rate limiting (token bucket) | **Implemented** |
|
||
| Disconnect with reason codes | **Implemented** |
|
||
| Heartbeat liveness detection | **Implemented** |
|
||
| Reconnection handling | **Implemented** |
|
||
| Auto-reconnect after link-dead removal | **Implemented** |
|
||
| Handshake message retry (link + session layer) | **Implemented** |
|
||
| Common prefix framing | **Implemented** |
|
||
| AAD binding on encrypted frames | **Implemented** |
|
||
| Inner header timestamps | **Implemented** |
|
||
| Path MTU tracking (SessionDatagram) | **Implemented** |
|
||
| Metrics Measurement Protocol (MMP) | **Implemented** |
|
||
| ECN congestion signaling (CE relay, IPv6 marking) | **Implemented** |
|
||
| Rekey with index rotation | **Implemented** |
|
||
| Allowlist/blocklist | Planned |
|
||
|
||
## References
|
||
|
||
- [fips-intro.md](fips-intro.md) — Protocol overview and architecture
|
||
- [fips-transport-layer.md](fips-transport-layer.md) — Transport layer (below FMP)
|
||
- [fips-session-layer.md](fips-session-layer.md) — FSP (above FMP)
|
||
- [fips-mesh-operation.md](fips-mesh-operation.md) — How FMP's routing and
|
||
self-organization work in practice
|
||
- [fips-wire-formats.md](fips-wire-formats.md) — Byte-level wire format reference
|