Files
fips/docs/design/fips-mesh-layer.md
T
34e00b9f6e Add Nostr-mediated overlay discovery and UDP NAT traversal (#53)
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>
2026-04-27 08:15:58 -07:00

25 KiB
Raw Blame History

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.

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 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 and 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).

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 for how these messages work together to build and maintain the mesh, and 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 for tuning parameters.

Operator Logging

MMP emits periodic link metrics at info level (configurable via node.mmp.log_interval_secs, default 30s):

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