Corrections to the user, reference and design documentation and to source comments where they disagree with the code. No behaviour or wire change; the tests added here pin the behaviour the corrected text now states. Correct the OpenWrt tutorial on restricting port forwards. The OpenWrt gateway tutorial said the FIPS mesh firewall is the layer that limits which peers reach a port forward, pointed at the mesh firewall how-to, and told readers to add a fips-firewall drop-in for the listen port. None of that applies: the OpenWrt package ships neither the mesh firewall baseline nor fips-firewall.service, and on Linux the baseline hooks the input chain only and never sees a forward to a LAN host. A reader following the text would leave the forward open to every mesh peer that can reach the AP. The tutorial now says that the package's fips zone accepts port forwards from every mesh peer by default, and shows the fw4 rules that restrict one: an accept for the allowed peer followed by a REJECT for the rest, matching the target's port, with the note that an accept alone restricts nothing because other peers fall through to the packaged accept. The summary bullet and the troubleshooting row point at fw4 instead of the mesh firewall, and Linux readers are sent to the forward-hook recipe in deploy-gateway.md. Describe conntrack-less pinning and BLE evictions correctly. The gateway troubleshooting guide said that with no readable conntrack source a mapping is reclaimed on its TTL and grace period alone. It left out what the pool does on top of that: no mapping counts as having carried traffic, so a re-query does not extend a name (only a used mapping is refreshed, and the answer TTL is capped at the time left since creation), and at the ceiling a new name can replace any mapping. The guide now quotes the daemon's whole warning, states both effects, and says when mappings count traffic again: those created after conntrack reads have succeeded for three ticks in a row. The doc comment on the BLE pool's eviction counter said it counts a non-static peer being displaced. There is no static-peer notion in the pool; it records the eviction of an unverified link past its verify grace by a newcomer, which is the only kind the pool makes. Bring the IPv6 adapter docs and comments in line with the code. Several statements about the IPv6 adapter disagreed with the code: - FIPS_IPV6_OVERHEAD was described as 77 bytes on top of the 106-byte FIPS_OVERHEAD; 77 is the total (106 + 4 + 7 - 40). - The effective-MTU table called a 1472-byte transport the standard deployment; the UDP default is 1280. - All ICMPv6 errors were said to be rate-limited by a per-source token bucket. Only Packet Too Big is limited, by a per-source minimum interval of 100 ms; Destination Unreachable is not limited. - The TUN writer was said to clamp MSS on inbound SYN-ACKs only, and clamped SYN-ACKs were listed as one of its packet sources. The clamp acts on any SYN-flagged segment, in both the reader and the writer. The design docs, the MTU how-to, the writer's doc comments and its trace message now say so. - The design doc said the reader needs no "is this for me?" check. It has one: a packet to the node's own address is written back to the TUN, which macOS utun needs. - The app-owned TUN docs (design doc and the enable_app_owned_tun rustdoc) said FIPS neither filters off-mesh destinations nor answers them with ICMPv6 there, and that the embedder clamps only outbound SYNs. On that path an off-mesh destination is looked up and answered with Destination Unreachable on app_inbound_rx (an oversize packet with Packet Too Big), and no MSS is clamped in either direction. - The transport_mtu fallback comment said "UDP first, then Ethernet"; it takes a configured UDP instance's MTU, else 1280. A new test pins the app-owned behaviour the rustdoc now states: an off-mesh unicast destination is answered with Destination Unreachable from the node's own address on app_inbound_rx, and a multicast destination gets no answer. State what the debug inject command and the outbound path do. The debug_commands documentation (the config field's doc comment, the configuration reference and the Inject parameters doc) said inject writes only into one of the calling client's own flows, and so grants the client nothing it does not already have. inject looks the flow up by id in the node-wide flow table, so it can write into any flow on the node, including one another client opened or a listener accepted; the comment on the command itself and the native API reference already said so. All three now say the same, and the claim that it grants nothing new is gone. The handle_native_outbound doc said an error is reported to the client rather than as an ICMPv6 message. Nothing is reported: an oversize datagram is counted in drop_oversize and dropped, a send or session-initiation failure is logged at debug, and a datagram past the pending-queue bounds is dropped. The doc now lists those. Stop claiming a withdrawn advert is deleted with NIP-09. The Nostr events reference, the discovery design doc and the generic port-advertisement doc said the node withdraws its advert with a NIP-09 kind 5 deletion when advertising is turned off or the responder retires. The node never sends one. The delete plan is produced only when advertising is off and an advert event id is recorded, but an id is recorded only after a successful publish, which needs advertising on, and the advertise flag is fixed when the advert machine is built. A transient missing advert and shutdown both deliberately skip the delete, because on relays that apply deletions strictly it can also remove the replacement the next start publishes. The docs now say that no deletion is sent, and that an advert the node stops publishing stays on relays until its NIP-40 expiration, advert_ttl_secs after publication, unless a newer advert replaces it. The generic description keeps deletion as something a responder may do, and says FIPS does not. The doc comments on the publish plan's delete variant, plan_publish and clear_event_id now say that the delete path is never reached by a running node, and why. Correct the traversal signaling description. The Nostr events reference and the design docs disagreed with the signaling code in four places: - inReplyTo was described as the offer's event id. The responder sets it to the offer's nonce, the initiator matches answers by nonce, and the validator refuses any other value as a session mismatch. An implementation written from the docs would have every answer dropped. - The answer's PunchHint was said to tell both sides when to probe. Only the responder probes on it; the initiator does not read the hint and starts probing from its own punch_* settings when the answer arrives. Fixed in the reference, the port-advertisement doc and the discovery design doc. - All three event kinds were said to be signed with the node's identity key. The kind 21059 wrap is signed by a one-time key; only the seal inside it is signed with the identity key. - The FIPS advert example did not say that FIPS emits only the d, protocol, version and expiration tags and carries its signaling relays and STUN servers in the content as signalRelays and stunServers, present only with a udp:nat endpoint. Two tests pin the behaviour the docs now state: an answer whose inReplyTo is the offer's event id is refused as a session mismatch, and a signal's wrap is signed by a key other than the sender's while unwrapping recovers the sender's identity from the seal. Label the second msg2 DH as a repeat of es, not se. The IK msg2 code, the noise module doc, the wire-formats reference, the mesh-layer design doc and the msg2 diagram all called the second DH in msg2 `se`. Both sides actually pair the responder's static key with the initiator's ephemeral key, which is msg1's `es` pairing, so the step mixes the msg1 `es` value a second time and nothing computes Noise IK's `se`. Relabel it as `es` again everywhere, say in the code that it is not Noise's `se`, that it is the FMP v1 wire format and cannot change without breaking v1 peers, and that it leaves FMP v1 open to key compromise impersonation (FRC-1201 Section 9.3). Rename the two local bindings to `es2`. No behaviour or wire change. Correct the path MTU notification doc and the fips-mtu design doc. The PathMtuNotification doc said it is sent by a node that learns a path MTU from transit router feedback or ICMP Packet Too Big. It is sent by the session destination, echoing the forward-path minimum it saw in the envelope's path_mtu; a received MtuExceeded only updates local state. fips-mtu.md said TCP reports a per-connection MTU from TCP_MAXSEG. TCP reports the configured mtu and checks sends against it. It also said the TCP clamp mirror is tighter-only and never loosened. Entries are released on PathBroken (at most once a second per destination), peer removal and session timeout, discovery-learned entries expire with the coordinate cache TTL, and a link seed from a different transport replaces the stored value. Describe that lifecycle, and add a short section on the defences against forged signals: the 256-byte floors, the admission gate, corroboration against what this node has sent, and the rate-limited release. Correct transport comments and docs on TCP MTU, mDNS, BLE, Ethernet, Tor and UDP. Several transport comments and docs described behaviour the code does not have: - TCP: the config doc, configuration reference and transport-layer doc said the per-connection MTU comes from TCP_MAXSEG. TCP reports the configured mtu to FMP and checks sends against it. - mDNS: the module and supervisor comments said identity is proven by a Noise XX handshake. FMP here is Noise IK. Drop the note in the Nostr discovery doc that pointed at those comments. - BLE: the pubkey-exchange prefix doc said it is distinguished from FMP packets of version 1 or more. FMP is version 0, so the framer cannot tell them apart, as the caution below it already says. - Ethernet: the config doc said the runtime requires Linux. It is built for Linux (AF_PACKET) and macOS (BPF). - Tor: the config and transport docs, and the onion-service how-to, said control_port mode runs an inbound onion service. It only adds control-port monitoring to SOCKS5 outbound; directory mode is the only inbound mode. - UDP: the security reference said a UDP instance has no inbound exposure until bind_addr is set. A declared UDP instance listens on 0.0.0.0:2121 by default unless outbound_only is set. Correct the spanning tree docs on signatures, sequence, sizes, cache invalidation and flap dampening. - The TreeAnnounce signature was described as covering the entire message. It covers SHA-256 of the 48-byte declaration (node_addr, parent, sequence, timestamp); version and ancestry are not signed. - The sequence was said to increment on parent change. It also increments on becoming root and on an ancestry change under the same parent. - The TreeAnnounce size table added msg_type twice in the link-overhead column. The measured sizes are 168/264/328/488 bytes; note the 36-byte link overhead under the table. - Several design docs and a rate-limit comment said a parent change flushes the coordinate cache. The node drops only cached coordinates that route through it (parent switch or ancestry change) or sit under another root (becoming root), and both on parent loss or loop detection. - Flap dampening was described as engaging after more than flap_threshold switches in a sliding window. It engages when the count reaches flap_threshold, within a fixed window opened by the first switch. Correct the bloom filter frame size, triggers and propagation bound. - The wire-formats reference gave the v1 FilterAnnounce with link overhead as 1,072 bytes. It is 1,071 (1,035 payload plus 16 header, 4 timestamp and 16 tag), as the bloom design doc already says. - The bloom design doc listed local state changes (new identity, leaf-only dependent changes) as a send trigger. The identity does not change at runtime and nothing outside the tests adds a leaf-only dependent, so drop it. - The bloom and spanning tree docs bounded propagation at about 500 ms per hop. Announces leave only on the node's maintenance tick, so the bound is about one node.tick_interval_secs per hop (1 s by default); the 500 ms debounce is a floor below that. Correct forwarding docs on ranking, loop freedom and the coordinate cache. - The mesh-operation doc called link_cost an uncommon tie-breaker resolved by tree distance. Candidate ranking compares link cost first; tree distance and node_addr break ties. - Several docs and comments said the strict-progress check prevents routing loops even with stale coordinates. Each hop judges progress on its own cached coordinates for the destination, so the check does not guarantee loop freedom when hops hold different coordinates. Say that, and that the TTL bounds any loop. - The session-layer doc said FMP can fall back to bloom-only routing without cached coordinates. find_next_hop returns no next hop on a cache miss and the caller signals CoordsRequired. - The insert_coord_hint doc said a foreign-root destination can never be selected by the bloom step. A peer advertising coordinates under that root can be; the refusal stands for the reflector reason given in the same paragraph. - The find_next_hop and try_warm_coord_cache_ref doc blocks sat above the wrong functions, so rustdoc gave those two no docs and gave insert_coord_hint and warm_coord the wrong opening line. Move each block onto its function and fix the find_next_hop ranking wording. Correct FSP restart handling, error datagram sizes and XK labels. - The session-layer doc said an epoch mismatch on a later handshake marks a peer restart and triggers re-establishment. FSP exchanges the epoch but never reads it. A SessionSetup naming an established peer only arms a handshake beside the running session, and new keys are adopted at msg3 only when the authenticated static key matches. Describe that, and rename the status row accordingly. - Wire-format sizes: SessionDatagram + CoordsRequired is 74 bytes, not 70; SessionDatagram + PathBroken is 76 + 16n, not "70+"; PathBroken alone is 40 + 16n, not 35 + 16n; CoordsRequired with link overhead is 110, not 111; and the CoordsWarmup example is 238, not 239. State in the summary table which rows include link overhead. - Three FSP wire comments called the session handshake Noise IK. It is Noise XK, as the module's own phase table says. Correct the rationale for dropping our own looped-back request. The comment on the own-request check in classify_request, its test and the handle_lookup_response doc said that without the check the answer to our own lookup would be reverse-path forwarded to the peer that looped the request, and that recent_requests decides originator status. That stopped being true when classify_response began deciding originator status from the pending lookup before it reads the transit record. Say what the check still does: it stops the copy being forwarded a second time and keeps our own id out of the transit record, so a response after the lookup completes has nothing to be relayed by. Extend the test to pin that: once the lookup is accepted, a later response for the same id is unsolicited.
25 KiB
Port Advertisement and NAT Traversal via Nostr
Abstract
This document describes two related-but-independent mechanisms that an application protocol can build on top of Nostr relays:
- Port advertisement. A node publishes a parameterized replaceable event describing the application protocol it speaks, the version, and the endpoint(s) at which it can be reached. Other nodes discover the advert by querying relays.
- NAT traversal. When the advertised endpoint indicates that the responder is behind NAT, the two peers exchange ephemeral gift-wrapped offer/answer events through Nostr relays, run STUN against a public server to learn their reflexive addresses, and coordinate UDP hole punching so they can exchange application traffic over a direct UDP path.
The two mechanisms compose naturally — an advert that includes a
<protocol>:nat endpoint signals "reach me by running the traversal
protocol" — but they are independently useful. An advert with only
public-IP endpoints needs no traversal. A pair of peers that already
know each other's pubkeys but want to coordinate a traversal can do so
without ever publishing a public advert.
The protocol described here is generic. Any application protocol can
adopt it by picking its own kind number, d-tag scope, and endpoint
schema. FIPS (the Free
Internetworking Peering System) is used throughout the document as an
example implementation; FIPS-specific values appear in clearly marked
example blocks and do not affect the generic protocol shape.
No WebRTC, DTLS, or ICE stack is required. The protocol operates at the raw UDP level, using Nostr solely for ephemeral signaling and STUN solely for reflexive address discovery.
Terminology
- Application protocol. The protocol that runs on top of the punched UDP channel after this document's procedures complete.
- Initiator. The peer that discovers the responder's advert and begins the traversal exchange.
- Responder. The peer that publishes a service advertisement and is willing to be dialled.
- Reflexive address. The public
IP:porttuple that a STUN server observes for a UDP socket — i.e., the NAT's external mapping for that socket. - Punch socket. The single UDP socket a peer uses for STUN, for the offer/answer exchange's address fields, for the punch packets themselves, and for the application traffic that follows. The same socket must be used across all phases of one traversal attempt.
Socket lifecycle
The protocol assumes per-peer, per-attempt punch sockets:
- Each outbound traversal attempt allocates a fresh UDP socket bound
to
0.0.0.0:0(OS-assigned port). - That socket is owned by exactly one remote peer and exactly one traversal session.
- STUN, the offer/answer reflexive-address fields, the punch packets, and the eventual adopted application transport all share that socket for the lifetime of the attempt.
- If the attempt fails, the socket is discarded. A retry allocates a new socket and obtains a fresh reflexive address.
- A long-lived application listener (for example, a fixed UDP port shared across peers) must not be reused as the punch socket — doing so couples NAT mappings and retry state across peers.
This rule is not optional: closing or rebinding the socket between phases invalidates the NAT mapping that the rest of the protocol depends on.
Part 1: Service Advertisement
Event shape
The advert is a NIP-01 parameterized replaceable event whose kind
falls in the application-defined replaceable range
30000–39999. The event carries:
- A
dtag scoping the advert (so the same pubkey can publish multiple distinct adverts under different scopes). - A
protocoltag carrying the application protocol's name, used as a discovery filter for peers that don't already know the responder's pubkey. - A
versiontag carrying the application protocol version. - An optional
expirationtag (NIP-40) so a relay garbage-collects the advert when the responder goes offline without explicitly deleting it. - An optional
relaystag listing relays where the responder subscribes for incoming signaling messages (used by Part 2). - An optional
stuntag listing STUN servers the responder recommends. - A
contentfield carrying the application-specific payload — typically the endpoint set, capability flags, and any encryption keys the application layer needs. The content may be plaintext or NIP-44-encrypted; encryption requires the consumer to already know the responder's pubkey.
The replaceable semantics let the responder update the advert in
place under the same d tag. A responder may delete the advert with a
NIP-09 deletion when it retires for good; FIPS does not, and relies on the NIP-40
expiration instead (see the FIPS example below).
{
"kind": <application-specific>,
"pubkey": "<responder_pubkey>",
"created_at": <unix_seconds>,
"tags": [
["d", "<application-defined-scope>"],
["protocol", "<application_protocol_name>"],
["version", "<protocol_version>"],
["relays", "wss://relay1.example.com", "wss://relay2.example.com"],
["stun", "stun.l.google.com:19302"],
["expiration", "<unix_seconds + ttl>"]
],
"content": "<application payload, optionally NIP-44 encrypted>",
"sig": "<signature>"
}
Endpoint schema
The content field is application-defined. Its structure typically
includes a list of endpoints describing how the responder can be
reached. Endpoint entries should distinguish:
- Direct public endpoints (transport + address + port) where any initiator can connect without traversal.
- NAT-mapped endpoints that signal "I can be reached by running the traversal protocol against this transport on my pubkey."
- Anonymity-network endpoints (e.g. Tor onion services) where the addressing scheme implies its own connection semantics.
FIPS example: kind 37195 advertisement
FIPS uses kind 37195 (the digits visually spell FIPS —
7=F, 1=I, 9=P, 5=S). The d tag is hardcoded to
fips-overlay-v1; the configurable app value populates the
separate protocol tag, scoping adverts within a relay set
without splitting them across multiple d-tag streams.
The advert content is a JSON document carrying a list of endpoint
entries, each shaped as {transport, addr}. The transport field
takes one of udp, tcp or tor, and addr carries the rest:
{"transport": "udp", "addr": "host:port"}— direct public UDP endpoint.{"transport": "udp", "addr": "nat"}— NAT-mapped UDP endpoint; reach via Part 2 traversal.{"transport": "tcp", "addr": "host:port"}— direct public TCP endpoint, for peers whose networks filter outbound UDP. Public-only; there is no NAT analogue.{"transport": "tor", "addr": "<onion>:<port>"}— Tor onion-service endpoint.
FIPS publishes the advert with expiration set to now + advert_ttl_secs (default 1 hour) and refreshes it every
advert_refresh_secs (default 30 minutes). It sends no NIP-09
deletion: an advert it stops publishing stays on relays until that
expiration, unless a newer advert replaces it first.
FIPS emits only the d, protocol, version and expiration tags,
not the generic relays and stun tags. It carries its signaling
relays and STUN servers in the content instead, as signalRelays and
stunServers, present only when an endpoint is udp:nat.
Public-IP discovery on advertisement
A responder behind a NAT or wildcard-bound to a non-routable address needs to determine what external address to put in its advert. The responder uses a fixed precedence:
- An operator-supplied external address override (FIPS:
transports.{udp,tcp}.external_addr) wins. - A non-wildcard
local_addris used directly. - For a wildcard-bound UDP listener with an explicit "publish this"
flag (FIPS:
public: true), the runtime queries STUN against the configured servers and publishes the reflexive address. - For a wildcard-bound TCP listener, no STUN equivalent exists. Implementations should refuse to silently advertise an unreachable endpoint; FIPS emits a loud WARN and omits the endpoint.
This precedence keeps adverts honest: an endpoint that appears in the published content is one the responder believes is reachable.
Discovery (consumer side)
A consumer queries one or more relays for an advert it can act on. Two filter shapes are typical:
By author, when the responder's pubkey is already known:
["REQ", "<sub_id>", {
"kinds": [<advert_kind>],
"authors": ["<responder_pubkey>"],
"#d": ["<application-defined-scope>"]
}]
By application protocol, for "open discovery" of any peer running the same application:
["REQ", "<sub_id>", {
"kinds": [<advert_kind>],
"#protocol": ["<application_protocol_name>"]
}]
Adverts whose protocol tag does not match the consumer's expected
value, or whose expiration tag has elapsed, are rejected at
validation. Consumers cache adverts in memory keyed by author npub
and respect the embedded expiration.
FIPS example: discovery filters
The FIPS daemon issues both filter shapes: by-author for peers it
intends to dial directly, and by-#protocol when an operator has
opted into open discovery against the same application namespace.
Cached adverts persist until their expiration lapses; a periodic
prune drops expired entries.
Part 2: NAT Traversal
The traversal protocol coordinates UDP hole punching between two peers via gift-wrapped Nostr signaling. It is invoked when the initiator decides to dial a NAT-mapped endpoint advertised by the responder.
Signaling event shape
Signaling messages are ephemeral kinds in the range 20000–29999,
NIP-44-encrypted to the recipient, and NIP-59 gift-wrapped so the
outer event is signed by an ephemeral keypair rather than the
sender's long-term identity. The wrap carries a p tag pointing at
the recipient's pubkey and an NIP-40 expiration tag bounding how
long the relay should retain it.
FIPS example: signaling kind 21059
FIPS signaling uses kind 21059. Wraps are addressed by p
tag and published to the merge of the responder's NIP-17 inbox relay
list (kind 10050), the signal relays its advert names, and the local
dm_relays configuration, kept to relays in the sender's own relay
pool. Each side publishes its own inbox relay list on startup and
daily so dialers can discover it.
Phase 1: Initiator STUN binding
Before constructing any signaling message, the initiator:
- Allocates a fresh UDP punch socket bound to
0.0.0.0:0. - Sends a STUN Binding Request (RFC 8489) to one of its locally configured STUN servers.
- Parses the Binding Response, extracts the
XOR-MAPPED-ADDRESSattribute, and records that as its reflexive address. Other STUN attributes are ignored. - Records local-candidate addresses for the same socket port: active private non-loopback interface addresses (RFC1918 IPv4, IPv6 ULA) and probed local egress addresses.
The punch socket must remain open across all subsequent phases. Closing or rebinding it discards the NAT mapping.
Phase 2: Initiator sends offer
The initiator constructs an offer payload containing its reflexive
address, its local-candidate addresses, an opaque session
identifier, freshness timestamps, and any application-specific
parameters. The payload is NIP-44-encrypted to the responder's
pubkey, wrapped with NIP-59, and published to the responder's
signaling relays. The initiator also subscribes by p tag on
those relays to receive the answer.
{
"type": "offer",
"sessionId": "<random_hex_32>",
"issuedAt": <unix_millis>,
"expiresAt": <unix_millis>,
"nonce": "<random_nonce>",
"senderNpub": "<initiator_npub>",
"recipientNpub": "<responder_npub>",
"reflexiveAddress": {"protocol":"udp","ip":"<ip>","port":<port>},
"localAddresses": [{"protocol":"udp","ip":"<ip>","port":<port>}],
"stunServer": "<host>:<port>",
"app_params": { ... }
}
sessionIdis a random identifier correlating offer and answer.reflexiveAddressis the address STUN observed in Phase 1.localAddressesenables a same-LAN fast path when both peers happen to share a private subnet.stunServeris informational, recording which server the initiator used.issuedAt/expiresAtbound the freshness window — the responder rejects stale offers, since a NAT mapping that has not been refreshed in tens of seconds may already be gone.
Phase 3: Responder validates and answers
The responder maintains a standing p-tagged subscription on its
advertised signaling relays. On receiving an offer:
- Decrypts the wrap and recovers the offer payload.
- Validates freshness (rejects if outside the configured window; see Skew tolerance below).
- Rejects replays — if the
sessionIdis in a recently-seen cache, drop the offer. - Allocates its own punch socket (
0.0.0.0:0) and runs its own STUN query. - Constructs an answer payload that echoes
sessionId, carries the responder's reflexive and local addresses, includes aPunchHint { startAtMs, intervalMs, durationMs }giving the responder's own probing schedule, and is wrapped, encrypted, and published the same way as the offer. In FIPS the initiator does not read the hint: it starts its own probing when the answer arrives, on its local configuration.
{
"type": "answer",
"sessionId": "<same as offer>",
"issuedAt": <unix_millis>,
"expiresAt": <unix_millis>,
"nonce": "<random_nonce>",
"senderNpub": "<responder_npub>",
"recipientNpub": "<initiator_npub>",
"inReplyTo": "<offer_nonce>",
"accepted": true,
"reflexiveAddress": {"protocol":"udp","ip":"<ip>","port":<port>},
"localAddresses": [{"protocol":"udp","ip":"<ip>","port":<port>}],
"stunServer": "<host>:<port>",
"punch": {"startAtMs": <ms>, "intervalMs": <ms>, "durationMs": <ms>},
"offerReceivedAt": <unix_millis>,
"app_params": { ... }
}
If the responder has no usable addresses, it returns
accepted: false with an explanatory reason and no punch.
The optional offerReceivedAt field carries the responder's
wall-clock at the moment the offer arrived. The initiator can
combine its own T1 (offer-publish time), T2 = offerReceivedAt,
T3 (answer's issuedAt), and T4 (answer-receive time) into the
NTP-style estimate ((T2 − T1) + (T3 − T4)) / 2, giving a per-peer
clock-skew measurement that's useful for tuning freshness windows
and for telemetry.
Immediately after publishing the answer, the responder begins Phase 4 punching without waiting for any acknowledgement that the initiator received the answer. NAT mappings are decaying and time is the binding constraint.
The responder must bind the inner JSON senderNpub /
recipientNpub fields to the actual Nostr pubkeys that delivered
the gift wrap, rather than treating those JSON fields as
independently trustworthy. The wrap pubkey is the authentication
ground-truth.
Phase 4: Hole punching
Both peers now know each other's reflexive and local addresses. Both begin sending UDP packets from their respective punch sockets:
-
Send punch packets every
intervalMs(typically 200 ms) across each planned target path:- reflexive-to-reflexive
- private-subnet local-address paths (when subnet-compatible)
- mixed local/reflexive fallbacks
-
Each punch packet carries a fixed magic header so transit and peer code can distinguish it from stray UDP traffic:
Bytes 0–3: <PROBE_MAGIC> (application-defined u32) Bytes 4–7: sequence number (u32, big-endian, starting at 0) Bytes 8–23: first 16 bytes of SHA-256(sessionId) -
On receiving a valid punch packet (magic matches, session-id hash matches), the peer records the source address as the confirmed peer address and replies with an acknowledgement packet under a different magic value:
Bytes 0–3: <ACK_MAGIC> (application-defined u32) Bytes 4–7: echoed sequence number Bytes 8–23: first 16 bytes of SHA-256(sessionId) -
On receiving an acknowledgement, the peer considers the path punched and transitions to Phase 5.
If both peers advertised compatible local-subnet candidates, the local-address path will typically punch through faster than the reflexive path. The first path to acknowledge wins.
Phase 5: Application protocol takeover
Once the path has acknowledged in both directions:
- The application protocol takes over the punch socket.
- The signaling subscription can be closed.
- The application is responsible for sending keepalive traffic at least every 15 seconds to refresh the NAT mapping. A flow that goes idle longer risks losing its mapping and having to retraverse.
Phase 6: Cleanup
After the attempt completes (success or failure):
- Close the relay subscription used for signaling.
- Do not publish a NIP-09 deletion request for the signaling events. Under NIP-59 a relay deletes a gift wrap only at the request of its p-tagged recipient, so the request would be signed by the recipient's long-term key and would name the traversal's events, linking that key to them. The wraps are ephemeral kinds with NIP-40 expiration tags: well-behaved relays discard them without being asked, and a relay that stores them keeps them until they expire.
- Discard the per-attempt punch socket if the attempt failed; a retry must allocate a new socket and a fresh reflexive address.
If the responder is going offline permanently it should also delete its kind-37195 (or equivalent) advert.
Timeouts and retries
- If the initiator publishes an offer and receives no answer within a configured window (e.g. 10 s from offer publish), the attempt has failed. Causes: responder offline, advert stale, responder relay unreachable.
- If the answer arrives but no valid punch acknowledgement is
observed within
durationMs(typically 10 s), the attempt has failed. Causes: symmetric NAT on either side, firewall interference, stale reflexive addresses.
The initiator may retry with a fresh STUN query, a fresh punch socket, and a new offer. Repeated failures against the same responder should be suppressed by the application layer; see Application-specific failure handling below.
Security
Authentication
Offer and answer payloads are NIP-44-encrypted to the recipient and NIP-59 gift-wrapped, so only the intended recipient can decrypt. Authentication of the sender comes from the inner-wrap signature (the rumour signed by the sender's long-term identity inside the NIP-59 seal), not from the outer wrap signature (which is the ephemeral pubkey).
The inner JSON senderNpub / recipientNpub fields must be bound
to the actual signing pubkey of the inner rumour. Treating those
JSON fields as independently trustworthy is a vulnerability —
implementations must compare them against the unwrapped signature.
Once the UDP path is punched, the raw UDP channel has no inherent authentication or encryption. The application layer is responsible for establishing its own security on the punched channel — for example, a Noise Protocol handshake keyed from the Nostr identity, or an application-specific authenticated-encryption layer. FIPS runs its FMP Noise IK handshake immediately after adoption; the identity proven by the Noise handshake is the same Nostr pubkey that signed the inner offer/answer rumour, so a man-in-the-middle on the relay cannot impersonate the responder.
Replay protection
The sessionId and issuedAt / expiresAt fields together
defeat replays at the signaling layer. The responder must keep a
bounded cache of recently-seen sessionId values and reject
duplicates within the freshness window.
Skew tolerance
Strict freshness checks fail under modest clock skew between peers. Implementations should accept offers and answers whose timestamps are off by a small absolute amount (FIPS uses ±60 s), and feed observed skew into a per-peer estimate for telemetry and tuning. Outright rejection should be reserved for grossly stale or future-dated messages.
Metadata exposure
Even though signaling content is encrypted, the gift-wrap metadata reveals that the initiator's ephemeral pubkey contacted the responder's pubkey at a particular time, through a particular relay. The advert itself is public and reveals the responder's pubkey and the application protocol it speaks.
If metadata privacy is required, the advert content can be encrypted (consumers must already know the responder's pubkey), both peers can use ephemeral Nostr identities rather than their long-term keys, and the operator can run a private relay.
NAT mapping integrity
If too much wall-clock time elapses between STUN discovery and the hole-punch attempt, the reflexive address goes stale. Both peers should complete the entire signaling exchange within tens of seconds of their respective STUN queries. Relay latency is the primary risk factor. Implementations targeting flaky relays should prefer relays known to deliver ephemeral events sub-second.
Relay requirements
The protocol works best with relays that:
- Support ephemeral event kinds (
20000–29999) and do not persist them. - Honor NIP-40
expirationtags and garbage-collect expired events. - Deliver events with low latency (sub-second WebSocket push).
- Support NIP-09 deletion requests.
Relays that do not support ephemeral kinds will store the signaling events as regular events. The encrypted content remains opaque, but persisted wraps are wasteful and expose metadata unnecessarily. Operators deploying this protocol at scale should prefer relays that handle ephemeral kinds correctly, or run their own.
Failure modes
| Failure | Symptom | Mitigation |
|---|---|---|
| Symmetric NAT (one side) | Punch timeout | Retry with port-prediction heuristics; otherwise fall back to an application-level relay |
| Symmetric NAT (both sides) | Punch timeout | Application-level relay required |
| Relay latency > 60 s | Stale reflexive address | Use low-latency relays; consider self-hosted relay |
| Relay does not support ephemeral kinds | Signaling events persist | NIP-40 expiration bounds how long; no deletion request is sent (see Phase 6) |
| Responder offline | No answer received | Initiator times out after configurable period |
| Stale advert (responder no longer up) | Offer reaches no listener | Application-level failure suppression (see below) |
| STUN server unreachable | No reflexive address | Fall back to alternate STUN server; fail if none reachable |
| Firewall blocks outbound UDP | STUN fails entirely | NAT-traversal does not apply; reachable peers are limited to those that publish a non-UDP transport (e.g. TCP) and accept inbound |
Application-specific failure handling
Repeated traversal failures against the same responder are common in practice — the responder may be offline, the advert may be stale, or the responder may be on a network that doesn't admit incoming UDP. A naive implementation that retries on every dial attempt floods the relay layer and the operator's logs.
Implementations should layer per-peer suppression on top of the basic retry. The shape of that suppression is application-specific.
FIPS example: failure suppression
FIPS layers the following suppression machinery on the basic retry loop:
- Per-npub WARN log rate-limit (
warn_log_interval_secs, default 5 minutes). Subsequent failures inside the window log at debug level instead. - Per-npub consecutive-failure counter and extended cooldown.
After
failure_streak_threshold(default 5) consecutive failures, the per-peer retry deadline is pushed pastextended_cooldown_secs(default 30 minutes). Open-discovery sweeps consult the cooldown so they don't immediately re-enqueue the same peer. - Stale-advert eviction on streak transition. When a peer hits the failure-streak threshold, the daemon actively re-fetches its advert from the configured advert relays. If the advert has been removed or replaced, the cache entry is evicted and the streak resets; if the advert is unchanged, the cooldown applies.
- Per-peer skew estimate. The NTP-style skew computed from
offerReceivedAtis recorded so consistently-skewed peers don't trip the freshness check on every attempt. - Bounded failure-state cache (
failure_state_max_entries, default 4096) with LRU eviction so the suppression machinery itself does not grow unbounded.
These knobs are documented in
FIPS configuration reference
under node.rendezvous.nostr.
References
- RFC 8489 — Session Traversal Utilities for NAT (STUN)
- RFC 8445 — Interactive Connectivity Establishment (ICE)
- RFC 4787 — NAT Behavioral Requirements for Unicast UDP
- NIP-01 — Basic Nostr protocol flow
- NIP-09 — Event deletion request
- NIP-17 — Inbox relay list (kind
10050) for direct-message routing - NIP-40 — Expiration timestamp
- NIP-44 — Versioned encryption
- NIP-59 — Gift wrap
- NIP-78 — Application-specific data