Files
fips/docs/design/fips-session-layer.md
Johnathan Corgan 3a757a5737 Keep a forged SessionSetup from blocking the address owner's session
A SessionSetup proves nothing about the address it names, yet the
half-open entry it left answered every later setup for that address with
its own ack, so the owner's genuine setup drew an ack it could not read.
On a lost simultaneous-initiation tie-break the node discarded its own
handshake for it, and its own traffic to that address queued behind it
until the 30 s reap.

An entry being set up now holds up to four responder candidates, one per
distinct setup, each with its own handshake and ack, beside this node's
own initiator leg; the first leg to complete establishes the session. An
identical resent setup draws its candidate's own ack. At the bound a
newcomer replaces the oldest candidate at least 5 s old, preferring one
that arrived over the newcomer's own link peer, and is otherwise refused.
Candidates count individually toward the half-open share of the session
table. A msg3 is tried against each candidate, and one whose key does
not derive the address drops only its own candidate.

The hold a received setup opens dates from that first setup. Neither
later setups nor a forger dropping its own candidates with msg3s restart
it: an entry left with no leg is kept, and counted toward the half-open
share, until the hold is a handshake timeout old. Traffic, TUN or native,
to an address whose entry has held for 5 s with no leg of this node's
own makes the node initiate itself, keeping the candidates for a late
msg3, and asks discovery for a route when it has none. A native flow
caches the key its client supplied first, so the answer is accepted.

On a lost tie-break the node keeps its own leg with its resends paused,
and resumes them once the hold is 5 s old with nothing completed, or at
once if no candidate is left beside it. The tie-break winner answers the
peer's setup, giving up its own leg, once its own setup has gone
unanswered for 5 s. When the loser's own leg completes it keeps the
peer's candidates until they expire, and a msg3 completing one replaces
its session: the winner, once established, takes no other handshake, so
an ack it sent before initiating beside the loser's candidate cannot
leave each side the initiator of a different handshake. An established
initiator still holding its msg3 answers a setup from the peer with that
msg3 instead of arming a rekey.

A responder now sends one SessionAck per admitted setup and no longer
resends it on its own timer, which sent five acks per forged setup
toward the address it named. A lost ack is recovered by the initiator's
own setup resend. The documented ack bound per link peer is corrected to
match.

A forger that keeps all four of an address's candidates younger than
5 s, about 0.8 setups a second, still refuses that address's own setups;
the node's own initiation then completes the session when it has
traffic for it.
2026-10-14 10:37:39 +00:00

28 KiB

FIPS Session Protocol (FSP)

The FIPS Session Protocol is the topmost layer of the FIPS protocol stack. It sits above the FIPS Mesh Protocol (FMP) and below applications (native FIPS API or IPv6 adapter). FSP provides end-to-end authenticated, encrypted datagram delivery between any two FIPS nodes, regardless of how many intermediate hops separate them.

Role

FSP manages end-to-end communication sessions between FIPS nodes identified by their public keys (npubs). Each session provides:

  • End-to-end encryption: Payload confidentiality independent of how many intermediate nodes handle the traffic
  • Mutual authentication: Both parties prove they control the private key for their claimed identity
  • Replay protection: Counter-based nonces with sliding window, tolerant of UDP packet loss and reordering
  • Transport independence: Sessions survive transport changes, route changes, and address changes — they are bound to npub identities, not to transport paths

FSP is a datagram session protocol. It provides encrypted datagrams, not reliable streams. There is no FIPS equivalent of TCP; if applications need reliability, ordering, or flow control, they provide it themselves (typically by running TCP over the FIPS IPv6 adapter).

Services Provided to Applications

Applications access the FIPS mesh through two interfaces, both served by FSP:

Native FIPS API

Applications address destinations directly by npub or public key. The FIPS stack resolves the destination's node_addr, establishes or reuses a session, encrypts the payload, and routes through FMP. No DNS involvement.

IPv6 Adapter

Unmodified IPv6 applications use a TUN device with fd00::/8 routing. A local DNS service maps npub → IPv6 address and primes the identity cache. Packets arriving at the TUN are translated to FIPS datagrams and routed through FSP.

See fips-ipv6-adapter.md for the IPv6 adaptation layer.

Port-Based Service Dispatch

FSP DataPackets carry a 4-byte port header (source and destination port) inside the AEAD envelope, enabling multiple services to share a single session. The IPv6 adapter runs on port 256; the native FIPS API and future services (gateways, application protocols) register on other ports. Port dispatch is internal to the FSP layer — services see only their payload, not the port header.

What Applications Get

  • Authenticated datagram delivery: Each datagram is encrypted and authenticated with session keys bound to both parties' npubs
  • Session transparency: Sessions are established on demand and maintained automatically. Applications send packets; FSP handles session setup, encryption, and teardown.
  • Endpoint identity: Applications address destinations by npub. The FIPS address is the public key.

What Applications Do Not Get

  • Reliability: Datagrams may be lost, duplicated, or delivered out of order. FSP provides no retransmission or ordering.
  • Path MTU discovery: FSP does not signal MTU to applications. The IPv6 adapter handles MTU enforcement via ICMP Packet Too Big and TCP MSS clamping.
  • Congestion control: FSP does not throttle traffic. Applications running TCP over IPv6 get TCP's congestion control; native API applications must manage their own sending rate.

Services Required from FMP

FSP treats FMP as a black box providing three services. FSP knows nothing about transports, transport addresses, links, peers, spanning trees, coordinates, bloom filters, hop counts, or network topology.

SessionDatagram Forwarding

FMP accepts a SessionDatagram (source node_addr, destination node_addr, TTL, path MTU, payload) and delivers it best-effort toward the destination. Delivery may traverse multiple hops, each with independent link encryption.

Error Signaling

FMP signals routing failures asynchronously:

  • CoordsRequired: A transit node lacks the destination's tree coordinates. FSP responds by sending a standalone CoordsWarmup (0x14) message (rate-limited), re-initiating discovery, and resetting the coordinate warmup counter.
  • PathBroken: Greedy routing reached a dead end. FSP responds by sending a standalone CoordsWarmup (rate-limited), re-discovering the destination's current coordinates, and resetting the warmup counter.
  • MtuExceeded: A transit node cannot forward a SessionDatagram because the packet exceeds the next-hop link MTU; FSP adjusts its session-layer path MTU estimate from the reported bottleneck. See fips-mtu.md for the full forward/reverse MTU model.

All three signals are generated by transit nodes (not the destination) and travel back to the source inside a new SessionDatagram. They are plaintext (not end-to-end encrypted) because transit nodes have no session with the source.

Local Delivery

When a SessionDatagram arrives with a destination node_addr matching the local node, FMP delivers it to FSP for session-layer processing.

Session Lifecycle

Session Establishment

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.

The handshake is a three-message flow carried in SessionSetup, SessionAck, and SessionMsg3:

  1. Initiator sends SessionSetup containing Noise XK 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.

Each side's epoch (an 8-byte random value generated at startup) is exchanged encrypted in msg2 and msg3. On subsequent handshakes, an epoch mismatch indicates the peer has restarted, triggering session re-establishment — similar to IKEv2's INITIAL_CONTACT notification for peer restart detection (RFC 7296 §2.4).

Packets that trigger session establishment are queued (with bounded buffer) while a session or a coordinate lookup is in progress for the destination, and transmitted after the session is established. When neither is in progress, a TUN packet is answered at once with ICMPv6 Destination Unreachable instead, and held packets are answered the same way once nothing is working to deliver them. They are also answered once their destination has been held longer than the lookup schedule plus the handshake timeout (50 seconds by default). That age counts from when the destination was first held or, if later, from the latest lookup or session this node started for it, or from the first packet that rode a lookup already pending for it; later packets riding the same lookup do not restart it. A packet held before a lookup and then a session started for its destination can therefore wait longer than 50 seconds, since each start is new work that ends by its own timeout. A destination with no lookup running (no peer's bloom filter reaches it, only a non-tree peer's does, or its backoff window is open) is still held while the node has no peers and for 30 seconds after it gains one, while routing information arrives. Outside that window, a packet to a destination that is a direct peer or has a next hop, whose session setup failed on the link, is dropped unanswered rather than answered, so a retransmit retries the session.

Self-Bootstrapping

SessionSetup is self-bootstrapping for routing. It carries the source's and destination's tree coordinates in the clear (not inside the Noise payload). As the message transits intermediate nodes, each node caches these coordinates, warming the path for subsequent data packets that carry only addresses (no coordinates).

SessionAck carries both the responder's and initiator's coordinates back along the reverse path, warming caches in the other direction. This ensures return-path transit nodes can route even when the reverse path diverges from the forward path (e.g., after tree reconvergence).

Simultaneous Initiation

When both nodes send a SessionSetup to each other at the same time ("crossing hellos"), the handshake initiated by the node with the smaller address wins, as in IKEv2's simultaneous initiation (RFC 7296 §2.8). A SessionSetup proves nothing about its sender until its msg3 arrives, so the rule is applied in a way a forged setup cannot use to block the address owner's session:

  • The winner (smaller address) drops the peer's setup while its own setup is less than 5 s old, since the peer will answer it. If its own setup has gone unanswered for 5 s, it gives up its own leg and answers the peer's setup instead.
  • The loser (larger address) answers the peer's setup and keeps its own initiator leg beside it, with its resends paused. The resends resume once the peer's setup has been held for 5 s with nothing completed, or at once if no half-open setup from the peer is left.

Each setup a node answers is held as a half-open responder setup with its own handshake and SessionAck, up to four per address. If the loser's own leg completes first, it keeps the winner's half-open setups beside its session, and a msg3 completing one of them replaces its session, so both sides end on the same handshake. The winner, once established, keeps none.

Each half-open setup expires on its own deadline, a handshake timeout (30 s by default) after it was admitted, including those the loser keeps beside an established session. With four held, a new setup replaces the oldest one that is at least 5 s old, preferring one that arrived over the same link peer, and is refused if none is that old. A forged setup therefore cannot be evicted before it is 5 s old.

Data Transfer

Once established, sessions carry encrypted data using the FSP pipeline. Each encrypted message includes:

  • A 12-byte cleartext header (used as AEAD AAD) containing the counter and flags (including the CP flag for coordinate cache warming)
  • Optional cleartext coordinates when the CP flag is set
  • An AEAD-encrypted payload containing a 6-byte inner header (session-relative timestamp, message type, inner flags) followed by DataPacket content: a 4-byte port header (src_port, dst_port) and the service payload. The receiver dispatches by destination port to the registered service handler.

Session Idle Timeout

Sessions that see no traffic for a configurable duration (default 90s) are torn down. When traffic resumes, a new session is established automatically.

Only DataPacket (message type 0x10) send/receive and session establishment reset last_activity. MMP traffic — SenderReport, ReceiverReport, and PathMtuNotification — does not reset the idle timer. This means a session carrying only MMP reports and no application data will still tear down after node.session.idle_timeout_secs. MMP reports continue flowing until the session is torn down, providing final measurement data for the teardown log.

The idle timeout is deliberately shorter than the coordinate cache TTL (300s). This ordering ensures that when traffic stops and the session tears down, the transit node coordinate caches are still warm when a new session is established. The fresh SessionSetup re-warms the caches, maintaining routing continuity.

Session Independence from Transport

Sessions exist above the routing layer and are bound to npub identities, not transport addresses or routing paths — following WireGuard's approach (Donenfeld 2017) of binding cryptographic sessions to identity keys rather than network addresses. A session survives:

  • Transport failover (UDP → Ethernet → back to UDP)
  • Route changes (different intermediate hops)
  • Transport address changes (IP address or port changes)
  • Topology changes (direct peer becomes multi-hop or vice versa)

End-to-End Encryption

Noise XK 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, with one deviation recorded in the security reference: the handshake AEAD uses an empty associated-data field where standard Noise EncryptAndHash uses the handshake hash h.

The XK pattern (pre-message: ← s):

  • 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.
  • msg3 (→ s, se): Initiator sends encrypted static key and encrypted epoch. 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.

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.

Cryptographic Primitives

FSP uses ChaCha20-Poly1305 with secp256k1 ECDH; see ../reference/security.md for the full primitive table shared with the link layer.

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.

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.

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.

Data Packet Authentication

FSP uses AEAD authentication only — no per-packet signatures. The Noise handshake binds session keys to both parties' static keys, so only holders of the corresponding nsecs can derive the session keys. This provides implicit authentication for every packet, matching WireGuard and Lightning's approach.

Forward Secrecy

Ephemeral keys in the Noise handshake provide forward secrecy — a standard property of Noise handshake patterns that include ephemeral key exchange. Compromise of static keys (nsec) does not reveal past session keys, because session keys are derived in part from ephemeral-ephemeral DH (ee), and ephemeral keys are discarded after the handshake.

Replay Protection

FSP uses explicit 8-byte counters on the wire for replay protection, adapted from the DTLS anti-replay mechanism (Rescorla & Modadugu, RFC 6347). Each side maintains a monotonically increasing send counter, included in the 12-byte cleartext header of every encrypted message. The receiver maintains a sliding window (2048-entry bitmap) tracking which counters have been seen.

This design is critical for operation over unreliable transports. Under UDP packet loss or reordering, implicit nonce counters (where the receiver increments on each decrypt attempt) would desynchronize permanently — a failed decrypt() increments the nonce, and the desync grows with each lost packet. Explicit counters with a sliding bitmap window — the standard DTLS approach — allow the receiver to decrypt any packet independently, regardless of what packets were lost or reordered.

The same ReplayWindow and decrypt_with_replay_check() implementation is used at both the link and session layers.

Hybrid Coordinate Warmup Strategy

Session establishment (SessionSetup/SessionAck) warms transit node coordinate caches along the path. But coordinate caches have a finite TTL (default 300s), and entries may be evicted under memory pressure. When a transit node's cache entry expires, it cannot forward data packets (which carry only addresses, not coordinates) and sends a CoordsRequired error.

FSP uses a hybrid warmup strategy combining proactive piggybacking with reactive standalone messages to keep transit caches populated (Yggdrasil uses a similar approach of embedding coordinates in session traffic to warm transit node caches):

Proactive Warmup Phase

After session establishment, the first N data packets (configurable, default 5) per session attempt to piggyback source and destination coordinates via the CP flag in the FSP common prefix. The coordinates appear in cleartext between the 12-byte header and the ciphertext, allowing transit nodes to cache them without decryption.

If piggybacking coordinates would cause the total packet to exceed the transport MTU, the source sends a standalone CoordsWarmup message (0x14) first, followed by the data packet without the CP flag. This ensures transit caches are warmed even when data packets are near the MTU limit.

CoordsWarmup Message (0x14)

CoordsWarmup is an encrypted FSP message (phase 0x0) with the CP flag set and inner msg_type 0x14. It carries cleartext source and destination coordinates between the FSP header and the AEAD ciphertext, using the same format as CP-flagged data packets. The inner body is empty — the message exists solely to deliver coordinates to transit nodes.

Transit nodes extract coordinates via the existing try_warm_coord_cache() code path with zero changes to the transit forwarding logic. CoordsWarmup is indistinguishable from any other CP-flagged message at the transit layer.

On the wire, a CoordsWarmup message consists of the standard 12-byte FSP header (used as AEAD AAD) with ver=0, phase=0, flags=CP, followed by cleartext source and destination coordinates (same encoding as any CP-flagged packet), followed by 22 bytes of AEAD ciphertext (6-byte inner header and 16-byte Poly1305 tag). The total FSP payload is 12 bytes of header, the coordinate data, and 22 bytes of ciphertext.

Steady State

After the warmup count is reached, FSP clears the CP flag and sends minimal data packets (12-byte header + ciphertext). Transit nodes serve from their coordinate caches.

Reactive Re-Warm

When FSP receives a CoordsRequired or PathBroken signal:

  1. A standalone CoordsWarmup message is sent immediately to re-warm transit caches, rate-limited at one per destination per configurable interval (default 2000ms, node.session.coords_response_interval_ms)
  2. The warmup counter resets — subsequent data packets piggyback coordinates again when possible (or send additional CoordsWarmup messages when the data packet would exceed the MTU)
  3. A new LookupRequest may be initiated to rediscover the destination's current coordinates (always for PathBroken; optionally for CoordsRequired)
  4. When the LookupResponse arrives for an established session, the warmup counter resets again (handling the timing gap where warmup packets might fire before transit caches are repopulated by discovery)

The source-side rate limiting prevents amplification: at most one standalone CoordsWarmup response per destination per coords_response_interval_ms (default 2s). This is independent of the transit-node rate limiting on error signal generation (100ms per destination).

Warmup State Machine

Coordinate warmup state machine

Identity Cache

The IPv6 adapter requires an identity cache to map fd00::/8 addresses back to (NodeAddr, PublicKey) for routing; see fips-ipv6-adapter.md for the cache rationale, eviction policy, and population mechanics.

Coordinate Cache

The coordinate cache maps NodeAddr → TreeCoordinate and is the critical data structure that enables efficient multi-hop routing. Without cached coordinates for a destination, FMP cannot make forwarding decisions and must either fall back to bloom-filter-only routing or signal CoordsRequired.

Unified Cache

The coordinate cache is a single unified cache (merged from previously separate coord_cache and route_cache). All coordinate sources — SessionSetup transit, CP-flagged data packets, LookupResponse — write to the same cache.

Eviction Policy

  • TTL-based expiration: Entries expire after a configurable duration (default 300s)
  • Refresh on use: Active routing through a cache entry resets its TTL, keeping hot entries alive
  • LRU eviction: When the cache is full, the least recently used entry is evicted, skipping entries whose verification (from a lookup whose proof this node checked) is still live; a hint is refused only when every entry is live-verified. An expired entry is not sought out first: every hint carries the same TTL, so the least recently used entry is also the first to expire. The victim is found through an ordered index, so an insert at capacity costs O(log n) rather than a scan of the cache, and ties are broken by insertion order, never by address
  • Address bound: Besides its entry count, the cache bounds the coordinate addresses it stores at 16 per configured entry. Each entry is charged its coordinate length or 16, whichever is larger, and a write that would exceed the bound evicts like a write to a full cache. Deep coordinates still cache and route; they reduce how many entries fit. A coordinate heavier than the whole bound is stored alone
  • Per-link-peer budget: Each authenticated link peer pays for the hints it writes through this node, in units of the weight above, at 1,024 units a second up to 4,096; a newly seen peer starts with none, and a peer keeps what it accrues while it stays connected. A new destination the peer can pay for goes into the cache's main segment, or into probation if every main entry is live-verified. One it cannot pay for goes into a probation segment of max(1, coord_size / 32) entries on top, which evicts only its own least recently used entries, so the frame is still routed and nothing a paid write placed is displaced. A later write of the same destination that pays its whole weight, and at least the most weight probation has held for it, promotes it; using an entry does not. A write of an expired entry is placed as a new destination's would be. An update is charged only for depth beyond the most the entry has been charged for, and one that is not paid for keeps the old coordinates; making an entry shallower does not lower its charge, so a forged shallow rewrite does not make the honest correction cost anything. Writes this node's own handshakes make (the SessionAck's source coordinates) and verified lookup results are not charged. The coord_hint_unfunded counter counts unpaid writes
  • Flush on parent change: When the local node's tree parent changes, the entire coordinate cache is flushed. Tree parent changes mean the node's own coordinates have changed, making cached coordinates for other nodes potentially stale for routing purposes.

Timer Ordering

Cache and session timers are ordered so that idle sessions tear down before transit caches expire:

Timer Default Purpose
Session idle timeout 90s Tear down unused sessions
Coordinate cache TTL 300s Expire stale coordinates
DNS TTL 300s Expire DNS resolutions

When traffic stops: the session tears down at 90s. When traffic resumes: DNS re-resolves the identity, a fresh SessionSetup carries coordinates, and transit node caches (still within their 300s TTL) are re-warmed.

Session-Layer MMP

FSP runs an MMP instance per established session for end-to-end metrics independent of hop count. Reports are encrypted and forwarded through every transit link, so bandwidth cost is proportional to path length; the session-layer report intervals are correspondingly higher than the link-layer intervals (clamped to [500ms, 10s] vs. [1s, 5s]).

The session-layer instance shares its algorithms (SRTT, jitter, loss, ETX) and report wire format with link-layer MMP. The differences — configuration namespace (node.session_mmp.*), routing scope, send-failure backoff, idle-timeout interaction, and the PathMtuNotification mechanism — are documented in the unified MMP treatment at fips-mmp.md. For the end-to-end path-MTU echo specifically, see fips-mtu.md. Reports and PathMtuNotification do not reset the session idle timer, so a session carrying only MMP traffic still tears down at the configured idle threshold.

MtuExceeded Handling

When FMP signals MtuExceeded (a transit node could not forward a SessionDatagram because it exceeded the next-hop link MTU), FSP uses the reported bottleneck MTU to adjust its session-layer path MTU estimate immediately. See fips-mtu.md for the full reactive PMTUD mechanism.

Implementation Status

Feature Status
Session establishment (Noise XK) Implemented
Peer restart detection (epoch exchange) Implemented
MtuExceeded handling Implemented
End-to-end encryption (ChaCha20-Poly1305) Implemented
Explicit counter replay protection Implemented
Hybrid coordinate warmup (CP + CoordsWarmup) Implemented
FSP wire format (prefix, AAD, inner header) Implemented
Session-layer MMP (with send-failure backoff) Implemented
Identity cache (LRU-only) Implemented
Coordinate cache (unified, TTL + refresh) Implemented
Session idle timeout Implemented
CoordsRequired handling Implemented
PathBroken handling Implemented
Simultaneous initiation tie-breaker Implemented
Flush coord cache on parent change Implemented
Rekey Implemented
Port-based service multiplexing Implemented
IPv6 header compression (shim on port 256) Implemented
Path MTU tracking (FMP SessionDatagram field) Implemented
Path MTU notification (end-to-end echo) Implemented

References

FIPS Internal Documentation

External References

  • Mogul, J., Deering, S. RFC 1191: "Path MTU Discovery". 1990. End-to-end path MTU discovery; FSP adapts this for overlay networks using transit-node min() propagation.