feat: implement authenticated NAT mediator (#11)
quality-gate / quality (push) Successful in 59s

Closes #11
This commit is contained in:
KyuubiYoru
2026-07-16 07:37:02 +02:00
parent 1baa1055dc
commit 6d076c281a
19 changed files with 1660 additions and 174 deletions
@@ -60,12 +60,14 @@ the supplied ID.
### UDP presence
Only a structurally valid `HostPresence` datagram with the issued capability can
refresh presence. The public endpoint is the UDP packet's observed source on the
host's gameplay socket; the HTTP API never accepts one. The bounded local candidate
comes from the authenticated datagram. Invalid, unknown, or client-presence packets
receive no response. Presence expiry demotes public visibility but keeps the lease,
so the same handle can restore visibility without changing session identity.
Only a structurally valid frozen `HostPresence` envelope or native LiteNetLib
host-presence request with the issued capability can refresh presence. The public
endpoint is the UDP packet's observed source on the host's gameplay socket; the
HTTP API never accepts one. The bounded local candidate comes from the authenticated
packet. Invalid or unknown inputs receive no response. ADR 0009 defines the later
attempt-role use of frozen `ClientPresence` and native host/client requests.
Presence expiry demotes public visibility but keeps the lease, so the same handle
can restore visibility without changing session identity.
Public listing responses contain bounded listing data only. They never contain
public/local endpoints, lease tokens, presence capabilities, fingerprints, store
@@ -0,0 +1,68 @@
# ADR 0009: authenticated bounded LiteNetLib NAT mediator
- Status: Accepted
- Date: 2026-07-16
- Tracking: #11
## Decision
The server owns one LiteNetLib `NetManager` and its `NatPunchModule` on the
configured UDP endpoint. It runs in manual mode with a configured maximum number
of datagrams per poll and a short caller-owned poll interval. LiteNetLib events
are unsynchronized so authenticated requests are processed immediately on that
single polling path rather than accumulated in an unbounded event queue. The
mediator never accepts a LiteNetLib gameplay connection or handles application
payloads.
The packet layer also consumes the frozen v1 presence envelope on the same
socket. Native NAT requests use a canonical fixed-size 192-character token that
binds a role (`HostPresence`, attempt `Host`, or attempt `Client`), mediation
handle, and the already-issued capability. Both transports enter one processor
and the same atomic store operations. No transport-supplied public address is
trusted; the socket source is authoritative.
LiteNetLib's native NAT packet family also contains introduction-response and
punch frames that are appropriate for peers but unsafe on a public mediator: a
forged response can name arbitrary destinations. The packet layer therefore
decodes only the pinned `NatIntroduceRequest` wire shape and consumes every
inbound packet before `NatPunchModule` sees it. The module is outbound-only and
may send introductions solely from a completed authorized plan.
Listing presence refreshes authorize no response. Attempt contributions bind the
first observed endpoint for exactly one capability role. Exact duplicates are
idempotent; a different endpoint, the opposite role, an expired/cancelled
attempt, or a stale listing presence cannot replace it. The introduction is
consumed atomically only after both roles bind and their observed address
families match, preventing concurrent attempts for one listing from cross-wiring.
A reported local candidate is eligible only when it is RFC 1918 IPv4 or IPv6
unique-local unicast, matches the observed family, and both peers have the same
observed public address. Otherwise `NatIntroduce` receives the observed public
endpoint in the local slot. Loopback, link-local, multicast, unspecified,
documentation IPv6, global-address claims, and cross-family claims are never
disclosed as local targets. IPv4 is required; observed global IPv6 can be used
when both peers contribute IPv6, without claiming guaranteed IPv6 NAT traversal.
The introduction carries only the distinct connection ticket and is emitted at
most once to each verified observed endpoint. The fixed authenticated native
request and bounded frozen envelope keep the combined response bytes within the
2.0 verified amplification budget; unauthenticated inputs receive zero bytes.
Malformed, truncated, oversized, spoofed, or unrelated LiteNetLib packets do not
grow Rendezvous state. Raw endpoints and credentials are never logged or exposed
through diagnostic string representations.
Frozen IPv6 listing-presence refresh remains valid because it emits no response.
IPv6 attempt roles require the fixed-size native LiteNetLib request; accepting the
short frozen envelope would exceed the 2.0 byte budget for two IPv6 introduction
frames. The required IPv4 listen address and optional IPv6 listen address are
configured separately so enabling one family never widens the other family to a
wildcard bind.
## Consequences
- Hosts refresh listing presence and answer invitations from their actual
gameplay socket; a separate mediator socket would observe the wrong mapping.
- Caller-owned SDK coordination in #12 must poll the host invitation endpoint,
send the corresponding native role token, and consume the returned ticket.
- UDP loss can prevent traversal, but it cannot cause an arbitrary destination,
replay, role substitution, or cross-attempt introduction.
+1
View File
@@ -11,6 +11,7 @@ decision requires a superseding ADR and corresponding contract/test updates.
- [ADR 0006: bounded compatible session browser](0006-compatible-session-browser.md)
- [ADR 0007: caller-owned .NET publisher and browser SDK](0007-caller-owned-dotnet-client-sdk.md)
- [ADR 0008: scoped join attempts and one-time connection tickets](0008-scoped-join-attempts-and-tickets.md)
- [ADR 0009: authenticated bounded LiteNetLib NAT mediator](0009-authenticated-litenet-nat-mediator.md)
- [Threat model](../security/threat-model.md)
- [Security promise and test matrix](../security/control-matrix.md)
- [Versioned HTTP and UDP contracts](../contracts/README.md)
+51 -8
View File
@@ -1,11 +1,11 @@
# UDP presence contract v1
# UDP presence and NAT-punch contract v1
Tracking: #4
Tracking: #4, #11
The UDP mediator accepts a single bounded presence envelope from a host or
client. It associates the authenticated mediation handle with the packet's
observed public source endpoint and the sender's reported local endpoint. It
does not carry gameplay packets.
The UDP mediator accepts the frozen bounded presence envelope below and native
LiteNetLib NAT-introduction requests. Both forms associate an authenticated
mediation handle with the packet's observed public source endpoint and the
sender's reported local endpoint. Neither form carries gameplay packets.
All multi-byte integers use network byte order. UUID bytes use the canonical
RFC 4122 textual order (the byte pairs from the 32 hexadecimal digits), not the
@@ -49,5 +49,48 @@ Capabilities are short-lived, single-purpose, scoped to one mediation handle,
and compared without exposing them in logs. A valid-looking packet does not
prove authorization until the capability is checked. Invalid packets receive
no UDP response, preventing the mediator from becoming an amplification oracle.
Replay, expiry, pairing, and rate-limit policy are defined by later mediator
issues; the v1 envelope deliberately leaves no unbounded or reflected payload.
For the frozen envelope, `HostPresence` is resolved against either the listing's
host-presence capability or an attempt's host-role capability. `ClientPresence`
is resolved only against the attempt's client-role capability. Handles are
globally distinct in the active store, so this does not permit role confusion.
## Native LiteNetLib request token
A game using LiteNetLib sends `NatPunchModule.SendNatIntroduceRequest` from its
gameplay `NetManager`. The `additionalInfo` value is produced by
`NatPunchRequestTokenCodec` and is exactly 192 ASCII characters:
```text
rv1:<role>:<32 lowercase handle hex>:<43-character capability><dot padding>
```
`role` is `p` for listing host-presence refresh, `h` for the host side of a join
attempt, or `c` for its client side. Padding is canonical and leaves the token
below LiteNetLib's 256-character ceiling. Its fixed size also ensures that the
two authenticated introduction responses remain within the 2.0 response-byte
budget. Tokens with a wrong length, role, handle, capability, or padding receive
no response.
The mediator runs LiteNetLib in bounded manual-poll mode. Its packet layer admits
only the pinned native `NatIntroduceRequest` frame, consumes every inbound frame
before LiteNetLib can act on it, and uses `NatPunchModule` only to emit authorized
introductions. Native and frozen v1 inputs reach the same atomic role/capability
checks. Only the packet source is
used as the public endpoint. A claimed private candidate is retained only when
it is private unicast, matches the observed address family, and both authorized
peers were observed behind the same public address; otherwise the observed
public endpoint is substituted. IPv4 punching is required. IPv6 sources must be
observed global unicast and both roles must use IPv6; IPv6 NAT traversal remains
best-effort rather than a v1 release requirement.
The second valid contribution atomically consumes the introduction and starts
the connection-ticket lifetime. `NatIntroduce` is called once with the distinct
43-character connection ticket. Reordered and exact duplicate requests are
idempotent. Endpoint substitution, cross-role use, stale host presence, expired
or cancelled attempts, malformed packets, and gameplay payloads produce no
introduction and create no mediator queue or endpoint state.
Frozen envelopes may refresh listing presence over IPv6 because that operation
has no response. IPv6 attempt contributions must use the fixed-size native token;
the shorter frozen IPv6 envelope cannot fund two IPv6 introduction frames within
the 2.0 response-byte ceiling and is therefore dropped without response.