dig-nat 0.3.0

Abstract NAT traversal for DIG Node peer connections — one connect() API over direct, UPnP/IGD, NAT-PMP, PCP, relay-coordinated hole-punch, and relay.dig.net as last-resort fallback; establishes an mTLS peer connection with peer_id = SHA256(TLS SPKI DER).
Documentation
# dig-nat — normative specification

`dig-nat` establishes a single mutually-authenticated (mTLS) connection to a DIG Node peer using the
best available NAT-traversal method, transparently. This document is the authoritative, normative
statement of what the crate implements: its public contract, address-family policy, traversal
strategy, dial behavior, identity model, and conformance points. Keywords **MUST**, **SHOULD**, and
**MAY** are used as in RFC 2119.

## 1. Scope

An implementation of this spec provides `connect(peer, identity, config) -> PeerConnection`, where
the caller describes the peer once (identity + candidate addresses) and receives a verified,
multiplexed, encrypted connection. The caller **MUST NOT** be required to choose the traversal
method; the crate selects it. The traversal technique that established the connection **MUST** be
reported (`PeerConnection::method`) for observability but **MUST NOT** change how the caller uses the
connection.

## 2. Peer identity + mTLS (unchanged contract)

- A peer's identity is `peer_id = SHA-256(TLS SubjectPublicKeyInfo DER)`, byte-identical to
  `dig-gossip`. All node-to-node traffic is mutual TLS. Certificates are self-signed; the key IS the
  identity (there is no CA).
- The dialer **MUST** present this node's certificate as the mTLS client certificate and **MUST**
  reject the handshake unless the remote's derived `peer_id` equals the `peer_id` the caller asked to
  reach (`PeerIdPinningVerifier`). The verified identity **MUST** be reported on the returned
  connection.
- `LocalIdentity::key_der` (this node's PKCS#8 private key DER) **MUST** be held in a zeroizing
  container (`zeroize::Zeroizing<Vec<u8>>`) so the plaintext key bytes are scrubbed on every clone
  and drop — `LocalIdentity` is cloned per dial. Implementations **MUST NOT** hold the private key
  material in a plain, non-zeroizing buffer.

## 3. Address-family policy — IPv6-first, IPv4-fallback (NORMATIVE)

All peer-to-peer address handling in `dig-nat` is **IPv6-first with IPv4 as a fallback**. This
applies to the candidate model, the outcome model, and the dial path.

### 3.1 Candidate model

- A peer's directly-dialable addresses are carried as an **ordered candidate list**
  (`PeerTarget::direct_addrs`), NOT a single address.
- The list **MUST** be ordered **IPv6-first**: every IPv6 candidate precedes every IPv4 candidate.
  The relative order **WITHIN** each family **MUST** be preserved (a stable ordering), so a caller can
  express a preference among same-family candidates.
- Ordering **MUST** be decided by the address family (`SocketAddr::is_ipv6`). Implementations **MUST
  NOT** decide family by inspecting the string form (a bracketed `[v6]:port` and an `v4:port` both
  contain `:`).
- The constructors `PeerTarget::with_addr` (one address) and `PeerTarget::with_addrs` (many) and the
  mutator `PeerTarget::set_direct_addrs` **MUST** apply this ordering regardless of input order.
- `PeerTarget::direct_addrs()` returns the ordered list. `PeerTarget::direct_addr()` returns the
  single best (first, i.e. IPv6-preferred) candidate, or `None` for a relay-only target. A relay-only
  target (`PeerTarget::relay_only`) has an empty candidate list.

### 3.2 Outcome model

- A traversal method yields a `MethodOutcome` carrying the candidate addresses to dial
  (`MethodOutcome::dial_addrs`), ordered IPv6-first, never empty on success.
- The direct and mapping methods (Direct, UPnP, NAT-PMP, PCP) **MUST** carry the peer's whole
  IPv6-first candidate list so the dial can fall back across families. They construct the outcome via
  `MethodOutcome::candidates`, which re-applies the IPv6-first ordering.
- The hole-punch and relayed methods yield a single coordinated peer address or the relay endpoint
  respectively, via `MethodOutcome::single`.
- `MethodOutcome::dial_addr()` returns the first (IPv6-preferred) candidate.

### 3.3 Dial path — happy eyeballs (RFC 8305-style)

The dialer **MUST** attempt a peer's candidate addresses IPv6-first and **MUST** use IPv4 only as a
fallback when the IPv6 candidate(s) fail or stall:

- Candidates are attempted in IPv6-first priority order. The implementation **MUST** defensively
  re-order the candidates IPv6-first before racing (it does not rely on the caller having sorted).
  An implementation **MAY** skip the re-order step when it can cheaply prove the input is already
  IPv6-first (e.g. `peer::is_ipv6_first`) — this is a pure performance optimization and **MUST NOT**
  weaken the guarantee: genuinely unsorted input **MUST** still be corrected.
- The IPv6 candidate(s) **MUST** be started first. A lower-priority (IPv4) candidate **MAY** be
  started as a hedge once the preferred candidate has not completed within a configurable stagger
  (RFC 8305 "Connection Attempt Delay").
- IPv6 is the **preference**, not merely the first to start: a lower-priority (IPv4) success **MUST**
  be returned only once every higher-priority (IPv6) attempt has concluded (failed or timed out). A
  viable IPv6 candidate therefore wins even if a hedged IPv4 attempt happens to connect first; IPv4
  wins only when IPv6 genuinely fails.
- Each candidate attempt **MUST** be bounded by a configurable per-attempt timeout. The stagger and
  per-attempt timeout are configurable (`HappyEyeballsConfig`) so the racing logic is deterministically
  testable.
- If every candidate fails, the dial **MUST** return an error enumerating the per-candidate reasons;
  it **MUST NOT** panic or hang. An empty candidate list is an error.
- The established connection's reported remote address (`PeerConnection::remote_addr`) **MUST** reflect
  the candidate (and therefore family) actually used.

The pure racing function is `dialer::happy_eyeballs_connect`; the production dialer
(`dialer::MtlsDialer`) uses it to race the TCP connect, then runs the single mTLS handshake over the
winning stream.

### 3.4 Per-method address-family notes

- **STUN (RFC 5389)** parses BOTH `FAMILY_IPV4` and `FAMILY_IPV6` XOR-MAPPED-ADDRESS attributes;
  reflexive-address discovery is family-agnostic.
- **PCP (RFC 6887)** uses 16-byte (128-bit) address fields throughout and is IPv6-capable (IPv4 is
  encoded as an IPv4-mapped IPv6 address).
- **UPnP/IGD** and **NAT-PMP (RFC 6886)** are protocol-inherently IPv4 (they map an inbound IPv4
  pinhole / speak an IPv4-only datagram). They remain the IPv4 fallback for inbound reachability.
  Because a host with a global-unicast IPv6 address needs no NAT mapping, the UPnP path **SHOULD**
  additionally discover a routable IPv6 candidate for advertisement:
  `upnp::select_global_ipv6` selects the address to advertise, preferring a global-unicast IPv6 over
  link-local (`fe80::/10`), ULA (`fc00::/7`), loopback, and unspecified addresses (which are not
  peer-reachable across the internet). Such an IPv6 candidate **MUST** be advertised ordered first.

## 4. Traversal strategy

- Methods are always attempted in `TraversalKind::rank` order regardless of the order the caller
  listed them: `Direct (0) → Upnp (1) → NatPmp (2) → Pcp (3) → HolePunch (4) → Relayed (5)`. The
  cheapest/most-direct path is preferred; the fully-relayed transport is the last resort.
- Each method attempt AND its dial are each bounded by a per-method timeout; a hung method or dial
  **MUST NOT** block `connect`.
- The first method that produces a verified mTLS `PeerConnection` wins; later methods are not
  attempted. If no method is enabled, `connect` returns `NoMethodsEnabled`. If every enabled method
  fails, it returns `AllMethodsFailed` with the ordered per-method reasons.
- Tier 4 (hole punch) uses the relay as a **signaling-only** rendezvous (the data path is direct
  peer-to-peer); tier 5 (relayed) proxies ALL data through the relay. Tier 5 **MUST** be attempted
  only after tier 4 fails, to conserve relay bandwidth. Both tiers wrap the resulting byte path in the
  same mTLS session.

## 5. Transport surface

- Whatever tier establishes the link, the result is one mTLS byte stream wrapped in `yamux`
  multiplexing (`PeerSession`). The caller opens many concurrent logical streams
  (`PeerConnection::open_stream`), byte-range streams (`PeerConnection::open_range_stream`,
  `dig.fetchRange`), and availability pre-checks (`PeerConnection::query_availability`,
  `dig.getAvailability`). Field names + wire shapes conform to the L7 peer-network spec.

## 5a. Persistent relay reservation + discovery (NORMATIVE)

A node behind NAT holds a CONSTANT registered connection to the relay (`run_relay_connection`) — its
reachability channel and the rendezvous for relay-coordinated hole-punch. This ONE long-lived
WebSocket is ALSO the relay-introducer discovery channel; discovery **MUST NOT** open a fresh
ephemeral socket per pass (two nodes whose sub-second register-then-close windows never overlap would
never see each other).

- The reservation **MUST** register exactly once per session (RLY-001) and keep the socket open,
  sending RLY-006 keepalives, and reconnect with capped-exponential backoff on any drop.
- Over the SAME socket the reservation **MUST** send RLY-005 `GetPeers` immediately after registering
  and periodically thereafter (`DISCOVERY_INTERVAL_SECS`), and **MUST** fold the `Peers` response plus
  relay-pushed `PeerConnected` / `PeerDisconnected` notices into the discovered-peer set.
- The discovered-peer set is exposed via `RelayStatus::known_peers` / `known_peer_count` for the
  consumer (dig-gossip's pool/address book) to read. It is per-session — cleared on every reconnect so
  a stale list is never served across a drop.
- The relay is an UNTRUSTED intermediary. The discovered-peer set **MUST** be bounded to a fixed cap
  (`MAX_KNOWN_PEERS`, 1024) and deduped by `peer_id`: a hostile/compromised relay can stream an
  unbounded flood of `PeerConnected` frames — or a single oversized `Peers` frame — with distinct
  fabricated `peer_id`s. Once the set is full, further distinct peers **MUST** be dropped (the set
  never grows past the cap), and both the per-push fold and the `Peers`-frame replace **MUST** enforce
  it. Membership/dedup **MUST** be O(1) so the flood cannot impose an O(n²) insert cost.

### 5a.1 Address-carrying introduction (B1, NORMATIVE)

The reservation advertises dialable candidates so a relay-discovered peer can be DIRECT-dialed over
the existing mTLS path instead of only reached over relayed transport. Both wire fields are additive
(NC-6 soft-fork) — default-empty, skipped-when-empty, appended last — so a pre-#924 peer/relay omits
them and falls back to identity-only relayed reachability. They are byte-identical to
`dig-relay-protocol` 0.2.0.

- On `Register` (RLY-001) the node **MUST** advertise its gossip LISTEN candidate address(es) in
  `Register.listen_addrs` (IPv6-first, §3). The host is typically the unspecified dual-stack address
  (`[::]`); the useful part the relay keeps is the port.
- When folding discovered peers, the reservation **MUST** parse `RelayPeerInfo.addresses` (the
  relay-resolved dialable candidates) and expose them via `RelayStatus::known_peers`, so the consumer
  (dig-gossip's pool/address book) can direct-dial them. A peer with empty `addresses` remains
  identity-only (today's relayed reachability).

## 5b. Relayed transport — the tier-6 TURN fallback (B2, NORMATIVE)

When no more-direct method (Direct/UPnP/PMP/hole-punch) can reach a peer, the connection is carried
THROUGH the relay by RLY-002 `relay_message` forwarding — the genuine last resort.

- The relayed transport **MUST** reuse the node's ONE persistent reservation socket (§5a) — it
  **MUST NOT** open a second connection to the relay. Outbound frames are injected into the live
  reservation write half; inbound `relay_message` frames are routed to the matching tunnel by the
  sender's `peer_id` (`from`).
- `RelayStatus::open_tunnel(target_peer, network_id)` yields a `RelayTunnel` — a bidirectional payload
  channel forwarded A→relay→B. `open_tunnel` **MUST** fail when no reservation is held
  (`relay_transport_ready` is false). Dropping the tunnel deregisters its routing.
- Per NC-1 / §5.4 the tunnel payload **MUST** be END-TO-END SEALED to the recipient's key by the
  caller: the relay is an untrusted forwarder that sees only ciphertext. This crate carries opaque
  bytes and does not itself seal — the consumer (dig-gossip) seals.
- Backpressure + flood defense: a single payload **MUST NOT** exceed `MAX_RELAY_PAYLOAD` (1 MiB) — an
  oversized `send` errors and an oversized inbound frame is dropped; each tunnel's inbound buffer is
  bounded (`RELAY_TUNNEL_INBOUND_CAP`) and a full buffer drops the frame (the RLY-002 `seq` lets the
  consumer detect the gap).
- The production `RelayedTransport` (the strategy's tier-6 seam) is `ReservationRelayedTransport`; it
  gates on a live reservation and reports the relay endpoint for observability, while the byte stream
  is taken via `open_tunnel`.

## 6. STUN/PCP anti-spoof requirements (NORMATIVE)

STUN Binding responses (RFC 5389) and PCP MAP responses (RFC 6887) travel over unauthenticated UDP.
Both protocols correlate a response to its request with a caller-chosen id (a 96-bit STUN transaction
id / a 96-bit PCP MAP nonce) that is the primary defense against an off-path attacker forging a
response before the real server's reply arrives. This crate additionally validates the response
source address. Both properties are REQUIRED, independently of each other:

- **Id/nonce generation MUST use a CSPRNG.** `stun::new_transaction_id` and the PCP MAP nonce
  generator **MUST** fill every byte of the 96-bit id/nonce from a cryptographically secure random
  source (`ring::rand::SystemRandom`). They **MUST NOT** derive the id/nonce from wall-clock time,
  a counter, or any other attacker-predictable input — RFC 5389 §10.1 requires the STUN transaction
  id be "uniformly and randomly chosen"; RFC 6887 §11.1 requires the PCP nonce be unpredictable.
  A predictable id/nonce is a poisoning vulnerability: it is the only check that gates a forged
  `BINDING_SUCCESS` / MAP-success response.
- **Response source validation.** A STUN client performing [`stun::query_reflexive_address`] and the
  PCP method's `transact` **MUST** verify the response datagram's source address equals the address
  the request was sent to (the configured STUN `server` / the PCP `gateway`) before accepting the
  response. A response from any other source **MUST** be discarded and the client **MUST** continue
  waiting within the transaction's deadline (dropping the whole transaction on one mismatched
  datagram would let a single spoofed packet defeat it). This is independent, defense-in-depth
  hygiene alongside the id/nonce check — it does not replace it.
- Both checks apply to every UDP request/response protocol this crate speaks (STUN today; PCP's
  nonce check is the same pattern). NAT-PMP (RFC 6886) has no per-transaction nonce by protocol design
  and is not held to the id/nonce requirement, but source validation still applies where practical.

## 7. Configuration + defaults

- `NatConfig` selects the enabled methods (default: all six), the per-method timeout, the relay
  endpoint (default `dig_constants::DIG_RELAY_URL`, `DIG_RELAY_URL=off` opt-out honored), and the STUN
  server (default derived from the relay host on port 3478).
- `HappyEyeballsConfig` defaults to a ~250ms stagger and a generous per-attempt timeout (the strategy
  per-method timeout is the real outer bound).

## 8. Public API surface (normative)

```
PeerTarget::with_addr(peer_id, addr, network_id)            // single candidate
PeerTarget::with_addrs(peer_id, Vec<SocketAddr>, network_id)// many candidates, sorted IPv6-first
PeerTarget::relay_only(peer_id, network_id)                 // no direct candidates
PeerTarget::direct_addrs() -> &[SocketAddr]                 // ordered IPv6-first
PeerTarget::direct_addr()  -> Option<SocketAddr>            // first (IPv6-preferred) candidate
PeerTarget::set_direct_addrs(Vec<SocketAddr>)               // replace, re-sort IPv6-first
peer::sort_ipv6_first(&mut [SocketAddr])                    // the ordering primitive
peer::is_ipv6_first(&[SocketAddr]) -> bool                  // cheap "already ordered?" check

MethodOutcome::single(kind, addr)                           // hole-punch / relayed (one address)
MethodOutcome::candidates(kind, Vec<SocketAddr>)            // direct / mapping (IPv6-first list)
MethodOutcome::dial_addr() -> Option<SocketAddr>            // first (IPv6-preferred) candidate
MethodOutcome.dial_addrs: Vec<SocketAddr>                   // ordered IPv6-first

dialer::HappyEyeballsConfig { per_attempt_timeout, stagger }
dialer::happy_eyeballs_connect(&[SocketAddr], cfg, connect_one) -> Result<T, String>
dialer::MtlsDialer::new(identity).with_happy_eyeballs(cfg)

connect(peer, identity, config) -> Result<PeerConnection, NatError>
```

## 9. Conformance

- Candidate ordering: given a mixed list, `direct_addrs()` returns all IPv6 before any IPv4, stable
  within family; the ordering is by `IpAddr` family, not the string form.
- Happy eyeballs: given `[unreachable IPv6, reachable IPv4]`, the dial connects over IPv4; given both
  reachable, IPv6 wins; IPv6 is attempted before IPv4 even when input is IPv4-first; all-fail returns
  every candidate's reason.
- IPv6 selection: `select_global_ipv6` returns a global-unicast IPv6 and rejects link-local / ULA /
  loopback / unspecified.
- Identity: `peer_id = SHA-256(SPKI DER)` matches `dig-gossip` (cross-crate conformance test).
- STUN/PCP anti-spoof: transaction id / MAP nonce samples show CSPRNG-level variation across their
  full byte range (not a wall-clock-derived pattern); `query_reflexive_address` and the PCP
  `transact` accept a response only from the address the request was sent to, looping past a
  mismatched-source datagram rather than failing the transaction outright.