feat: implement authenticated NAT mediator (#11)
quality-gate / quality (push) Successful in 59s
quality-gate / quality (push) Successful in 59s
Closes #11
This commit is contained in:
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user