# 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 two entry points:
- `connect(peer, node, config) -> PeerConnection` — the convenience entry for a caller with NO live
transport handles; it composes only the tiers requiring no runtime input (currently **Direct**).
- `connect_with_runtime(peer, node, config, runtime) -> PeerConnection` — auto-composes the **FULL**
ladder from the `NatRuntime` carrier's live handles (§4a). `connect` is exactly
`connect_with_runtime` with an empty runtime.
The caller describes the peer once (identity + candidate addresses) and passes its own
`dig_tls::NodeCert` (its mTLS identity) 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 — delegated to `dig-tls`
The certificate model is owned entirely by the canonical **`dig-tls`** crate (hierarchy L00);
`dig-nat` (L10) **CONSUMES** it and holds NO cert / mTLS-config / binding / `peer_id` code of its own
(the duplicated copies were extracted to dig-tls in 0.6.0, so there is exactly one implementation and
no byte-drift risk). The names below are re-exported from dig-tls for consumer convenience.
- A peer's identity is `peer_id = SHA-256(TLS SubjectPublicKeyInfo DER)`, byte-identical to
`dig-gossip` and to `dig-tls`. All node-to-node traffic is mutual TLS.
- The local mTLS identity is a `dig_tls::NodeCert`: a per-peer leaf **signed by the shipped public
DigNetwork CA** (dig-tls ships the CA cert + key; the CA key is intentionally public — a shared
trust-domain marker, the Chia precedent — so real authentication comes from the `peer_id` pin + the
BLS binding, never CA-key secrecy). The dialer **MUST** present this `NodeCert` as the mTLS client
certificate. Certificates are NO LONGER self-signed as of 0.6.0 — a peer that presents a leaf that
does not chain to the DigNetwork CA **MUST** be rejected. (Migration: consumers regenerate their
cert as a CA-signed `NodeCert` on adopt.)
- The rustls mutual-auth `ClientConfig`/`ServerConfig` — including the DigNetwork-CA chain check, the
`peer_id` pin, and the #1204 BLS-binding verification — **MUST** be obtained from
`dig_tls::client_config` / `dig_tls::server_config`. The dial **MUST** reject the handshake unless
the remote's derived `peer_id` equals the `peer_id` the caller asked to reach, and the verified
identity (and any bound BLS pubkey) **MUST** be reported on the returned connection.
- The node's PKCS#8 private key is held by `dig_tls::NodeCert` in a zeroizing container so the
plaintext key bytes are scrubbed on drop; `NodeCert` is deliberately not `Clone` and is shared
behind an `Arc`, so it is never copied per dial.
## 2a. Cert BLS-binding — peer_id ↔ BLS G1 identity (NORMATIVE, #1204)
The transport `peer_id` (§2) **MUST** be cryptographically bound to the node/relay **BLS12-381 G1
identity key** (dig-identity slot `0x0010`, EIP-2333 path `m/12381'/8444'/9'/0'`) so the
recipient-seal family (#1075 node↔node, #1199 relay) can seal a payload to a peer's BLS key and know a
misdelivery cannot be opened by the wrong node. This binding is the anti-substitution ROOT: an
attacker **MUST NOT** be able to present a victim's `peer_id` under a BLS key it controls, nor claim a
BLS key it does not control for a given `peer_id`.
### 2a.1 The binding (embedded in the leaf certificate)
- The mTLS leaf certificate (a CA-signed `dig_tls::NodeCert`) **MUST** carry a custom X.509 extension,
OID `1.3.6.1.4.1.58968.1.1` (a DIG provisional private-use arc; canonical), whose value is:
`version(1 byte = 0x01) || bls_pub(48 bytes, compressed G1) || bls_sig(96 bytes, G2)`.
- `bls_sig` **MUST** be a BLS G2 signature (Chia AugScheme) by the node/relay BLS secret key over
`binding_message = "dig-nat/cert-bls-binding/v1" || SPKI_DER`, where `SPKI_DER` is the leaf's own
`SubjectPublicKeyInfo` DER. Because `peer_id = SHA-256(SPKI_DER)`, signing the SPKI commits the BLS
key to exactly that `peer_id`.
- The extension is **additive** (§5.1 spirit): it is non-critical and unknown to old verifiers, which
ignore it. New writers **MAY** bump the `version` byte; verifiers **MUST** dispatch on it and keep
accepting every version they understand — an unknown version is treated as "no binding this verifier
understands", never as tampering.
### 2a.2 Verification (on every handshake)
A verifier that checks the binding **MUST**, for the presented leaf: recompute `binding_message` from
the leaf's own SPKI; reject unless the embedded `bls_pub` passes the G1 subgroup / non-identity check;
and reject unless `bls_sig` verifies under `bls_pub` over `binding_message`. A valid binding yields the
verified `bls_pub`, which **MUST** be reported on the connection (`PeerConnection::peer_bls_pub`) for
the sealing layer.
### 2a.3 Rollout policy (LOCAL, never wire-negotiated)
Verification is governed by a **local** `BindingPolicy` — it **MUST NOT** be negotiated from a value
the peer supplies, so a peer cannot request a downgrade:
- **Off** — do not verify the binding (pre-adoption / opt-out).
- **Opportunistic** (the rollout DEFAULT) — verify a binding when present; **reject** a
present-but-INVALID one; **accept** an ABSENT one (tolerates legacy un-bound peers).
- **Required** — a valid binding is mandatory; **reject** both ABSENT and INVALID.
A downgrade that strips the extension **MUST** be rejected under **Required** (an absent binding fails
closed), so stripping the extension can never silently disable a required-mode session.
### 2a.4 Relay descriptor (#1199) — self-authenticating discovery record
A relay/peer discovery record (`RelayDescriptor`: `peer_id_spki_hash`, `bls_pub`, `addresses`,
`network_id`, optional `did`, `signature`) learned BEFORE a direct handshake (PEX/DHT/relay
registration, or relay store-and-forward) **MUST** be verified before it is trusted as a seal target:
the `bls_pub` **MUST** pass the G1 subgroup check; the `signature` (BLS G2 over the canonical
length-prefixed descriptor bytes behind `"dig-nat/relay-descriptor/v1"`) **MUST** verify under
`bls_pub`; on a live dial the `peer_id_spki_hash` **MUST** equal `SHA-256(presented SPKI)`; and where a
`did` and a resolver are available, a resolvable `did` **MUST** resolve to the same `bls_pub` (an
unresolvable DID is tolerated — nodes/relays are normally DID-less). On a direct connection the cert
binding (§2a.1) is authoritative and **MUST** be re-verified; the descriptor is only a pre-dial hint.
## 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**. The
authority for address-family discovery, the local∩peer family intersection, and the happy-eyeballs
dial is the canonical **`dig-ip` crate** (CLAUDE.md §5.2) — see its `SPEC.md`. `dig-nat` MUST use
`dig_ip::connect` / `dig_ip::dial_order` rather than hand-rolling candidate ordering or a racer; it is
the FIRST consumer of that crate. This section states how `dig-nat` wires into it.
### 3.1 Candidate model
- A peer's directly-dialable addresses are carried as a **candidate list**
(`PeerTarget::direct_addrs`), NOT a single address.
- The list is stored in **discovery order** — the caller supplies candidates in whatever order it
learned them; `dig-nat` **MUST NOT** re-order them. The IPv6-first preference + the family
intersection are applied at DIAL time by `dig-ip` (§3.3), which derives each address's family via
`dig_ip::Family::of` (never a string heuristic; an IPv4-mapped IPv6 address is treated as IPv4).
- The constructors `PeerTarget::with_addr` / `PeerTarget::with_addrs` and the mutator
`PeerTarget::set_direct_addrs` preserve the supplied order.
- `PeerTarget::direct_addrs()` returns the candidate list in discovery order. `PeerTarget::direct_addr()`
returns the first 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`), in discovery order, never empty on success.
- The direct and mapping methods (Direct, UPnP, NAT-PMP, PCP) **MUST** carry the peer's whole
candidate list so the dial can fall back across families. They construct the outcome via
`MethodOutcome::candidates` (which stores the list as-is).
- 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 candidate in discovery order.
### 3.3 Dial path — `dig-ip` (family intersection + happy eyeballs, RFC 8305)
The dialer **MUST** delegate family selection + racing to `dig_ip::connect`. `MtlsDialer::dial`:
- aggregates the outcome's addresses into a `dig_ip::PeerCandidates` (`dialer::candidates_from_outcome`),
tagging each with a `dig_ip::CandidateSource` for observability (provenance MUST NOT influence the
intersection rule);
- resolves the local host's `dig_ip::LocalStack` — `LocalStack::cached()` in production, or a pinned
stack via `MtlsDialer::with_local_stack` for deterministic tests;
- calls `dig_ip::connect(&local, &candidates, config, dial_fn)` where `dial_fn` performs one
candidate's raw TCP connect; then runs the single mTLS handshake over the winning stream.
The behaviour `dig-nat` INHERITS from `dig-ip` (its structural guarantees, tested here in
`tests/dial_family.rs`):
- **G1** — the dial NEVER attempts an address of a family the LOCAL host lacks (an IPv4-only host
never emits an IPv6 SYN).
- **G2** — the dial NEVER attempts an address of a family the PEER lacks.
- **IPv6-first preference** — a viable IPv6 candidate wins even if a hedged IPv4 attempt connects
sooner; IPv4 wins only when IPv6 genuinely fails/stalls.
- **Clean disjoint outcome** — when the local host and the peer share no family, the dial fails
IMMEDIATELY with `dig_ip::ConnectError::NoCommonFamily` (surfaced as a `MethodError` whose reason
contains "no common address family"); NO dial is attempted, so there is no doomed, hanging SYN.
- The per-attempt timeout + inter-attempt stagger are configurable (`HappyEyeballsConfig`, mapped to
`dig_ip::DialConfig`) so the racing is deterministically testable.
- The established connection's reported remote address (`PeerConnection::remote_addr`) reflects the
candidate (and therefore family) actually used.
### 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.
## 4a. Full-ladder auto-composition + the runtime carrier (NORMATIVE)
`connect_with_runtime` **MUST** auto-compose the traversal ladder from two inputs kept strictly
separate: the DATA config (`NatConfig`, `Clone + Debug`) and the LIVE handles (`NatRuntime`, which
**MUST NOT** be `Clone` or `Debug` — it carries `Arc<dyn …>` trait objects + live sockets).
- A tier is composed **iff** it is enabled in `config.enabled_methods` AND its runtime inputs are
present in `runtime`. A tier missing its inputs **MUST** be omitted silently — the crate **MUST
NOT** attempt a tier it cannot actually run (no phantom or silently-broken dial). Per-tier inputs:
- **Direct** — none (always composable).
- **UPnP** — `local_port` (+ optional injected `IgdGateway`; else the real SSDP gateway).
- **NAT-PMP** — `local_port` + `gateway_v4`.
- **PCP** — `local_port` + `gateway_v4` + `client_ip`.
- **HolePunch** — `hole_punch` coordinator + `my_external_addr` (STUN reflexive).
- **Relayed** — a `relayed` `RelayedDialer` handle.
- If no tier is composable, `connect`/`connect_with_runtime` **MUST** return `NoMethodsEnabled`.
- Every composed tier — INCLUDING the relayed one — **MUST** run the identical `dig-tls` mTLS
(CA-chain check + `peer_id` pin + #1204 BLS binding). A relayed or hole-punched connection **MUST
NOT** be weaker than a direct one.
## 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`.
### 5b.1 Relayed dial — mTLS over the relay tunnel (NORMATIVE)
The relayed tier is composed by `connect_with_runtime` from a `RelayedDialer` handle (implemented by
`ReservationRelayedTransport`). Dialing it **MUST** carry the SAME mTLS as a direct dial:
- `RelayedDialMethod::attempt` gates on `RelayedDialer::is_ready()` (a held reservation) and yields the
relay endpoint as the observability dial address. When not ready it **MUST** fail cleanly (the
strategy records the failure), never produce a doomed dial.
- The dialer **MUST** open the byte tunnel via `RelayedDialer::open_dial_tunnel(peer_id_hex,
network_id)`, wrap the resulting `RelayTunnel` in a byte-stream adapter (`RelayTunnelStream`,
tokio `AsyncRead + AsyncWrite`), and run the IDENTICAL `dig_tls::client_config` handshake +
`yamux` session over it — so a relayed `PeerConnection` presents the same CA chain, `peer_id` pin,
and #1204 BLS binding as a direct one.
- The relay routes tunnels by `peer_id`. A relay that substitutes a DIFFERENT peer for the pinned one
(a redirect attack) **MUST** be rejected by the mTLS `peer_id` pin — identity is proven by the
certificate, never by the relay's routing. The relay sees only TLS records (ciphertext); §5.4
recipient-sealing remains a layer ABOVE this transport.
- `RelayTunnelStream` maps each write to one RLY-002 frame (≤ `MAX_RELAY_PAYLOAD`) and each read to
one inbound payload (buffering the remainder across reads); a dropped reservation surfaces as a
clean stream EOF that fails the handshake.
## 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), the STUN
server (default derived from the relay host on port 3478), and the peer cert-`binding_policy`
(default `Opportunistic`; `connect` applies it to the peer's #1204 cert binding).
- `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, discovery order
PeerTarget::relay_only(peer_id, network_id) // no direct candidates
PeerTarget::direct_addrs() -> &[SocketAddr] // candidates, discovery order
PeerTarget::direct_addr() -> Option<SocketAddr> // first candidate
PeerTarget::set_direct_addrs(Vec<SocketAddr>) // replace (order preserved)
MethodOutcome::single(kind, addr) // hole-punch / relayed (one address)
MethodOutcome::candidates(kind, Vec<SocketAddr>) // direct / mapping (discovery order)
MethodOutcome::dial_addr() -> Option<SocketAddr> // first candidate
MethodOutcome.dial_addrs: Vec<SocketAddr> // candidates, discovery order
// Family selection + happy-eyeballs racing is dig-ip's job (dial-time); dig-nat wires into it:
dialer::HappyEyeballsConfig { per_attempt_timeout, stagger } // -> dig_ip::DialConfig
dialer::candidates_from_outcome(&MethodOutcome) -> dig_ip::PeerCandidates
dialer::MtlsDialer::new(Arc<dig_tls::NodeCert>).with_happy_eyeballs(cfg).with_local_stack(dig_ip::LocalStack)
dialer::MtlsDialer::with_binding_policy(BindingPolicy) // cert-binding stance (default Opportunistic)
connect(peer, &Arc<dig_tls::NodeCert>, config) -> Result<PeerConnection, NatError>
config.binding_policy: BindingPolicy // peer cert-binding stance (default Opportunistic)
PeerConnection.peer_bls_pub: Option<[u8; 48]> // verified peer BLS G1 key (§2a)
// Certificate / mTLS / identity model — RE-EXPORTED from dig-tls (dig-nat owns no copy):
dig_nat::NodeCert // = dig_tls::NodeCert (CA-signed leaf + key + peer_id)
dig_nat::PeerId, peer_id_from_tls_spki_der, peer_id_from_leaf_cert_der // = dig_tls
dig_nat::BindingPolicy { Off, Opportunistic (default), Required } // = dig_tls
dig_nat::verify_binding_from_leaf_cert(cert_der) -> BindingOutcome // = dig_tls (Bound{bls_pub}/Absent/Invalid)
dig_nat::{CapturedPeerId, CapturedBlsPub} // = dig_tls::verify handles
// Relay descriptor (§2a.4, #1199)
relay_descriptor::RelayDescriptor { peer_id_spki_hash, bls_pub, addresses, network_id, did, signature }
relay_descriptor::verify_relay_descriptor(&desc, presented_spki_der, did_resolver) -> Result<(), RelayDescriptorError>
```
## 9. Conformance
- Candidate storage: `direct_addrs()` returns candidates in discovery order (no re-sorting); ordering
+ intersection are dig-ip's job at dial time.
- Family intersection (via dig-ip, `tests/dial_family.rs` using `LocalStack::from_flags`): dual-stack
prefers IPv6; a failed IPv6 falls back to IPv4; a v4-only host dials only IPv4 (G1); a v4-only peer
is dialed only over IPv4 (G2); a disjoint local/peer pair fails immediately with `NoCommonFamily` and
attempts NO dial. End-to-end over the production `MtlsDialer`: `[unreachable IPv6, reachable IPv4]`
connects over IPv4; a v4-only host asked for a v6-only peer returns a clean no-common-family error.
- 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` (`tests/identity.rs` conformance).
- Cross-crate BLS conformance (`tests/identity.rs`, the extraction's keystone): dig-tls's BLS G1/G2
work (via its own `chia-bls`/`blst`) and dig-identity's **MUST** agree byte-for-byte — the same
secret scalar derives the same 48-byte G1 pubkey in both, signatures cross-verify in both
directions, and the pubkey dig-tls binds into a real `NodeCert` (recovered via
`verify_binding_from_leaf_cert`) equals dig-identity's derived pubkey. This is the check dig-tls's
`bls.rs` defers to the integration level; it FAILS if a future `chia-bls`/`blst` bump ever diverges.
- Cert BLS-binding (§2a, verified via re-exported `dig_tls` in `tests/identity.rs` + exhaustively in
dig-tls's own suite): a CA-signed `NodeCert` verifies to `Bound{bls_pub}`; a substituted BLS pubkey,
a binding replayed onto a different SPKI, a bad G1 point, and a malformed/unknown-version extension
all verify to `Invalid`; an un-bound cert is `Absent`. Over a real `MtlsDialer` handshake
(`tests/dialer.rs`): the dial verifies the peer's `peer_id`, rejects a mismatch, and muxes. Relay
descriptors reject a tampered signature, a `peer_id_spki_hash` mismatch, a substituted
pubkey, a bad G1 point, and a DID resolving to a different key; an unresolvable DID is tolerated.
- 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.