Skip to content

Commit 6d6910a

Browse files
authored
STANDARD.md: scope the nonce guarantee to a server run, add optional restart mitigations (#165)
Purely additive: 26 lines added, 0 removed. No existing normative text changes, no packet changes, protocol version stays NETCODE 1.02. An implementation that ignores all of this remains conformant and interoperable. THE BLIND SPOT. The spec stated that sequence numbers are used as nonces, but never stated the SCOPE of the uniqueness guarantee. It holds within a single server run: per-client sequences start at 0, the global sequence starts at 2^63. It does not survive a restart, because the server-to-client key lives in the connect token and the used-token history is in memory. A client presenting the same still-unexpired connect token to a restarted server repeats a nonce under that key. Identical in the C reference, netcode.go and netcode.rs -- a design limit, not a port defect, and out of scope of dc21b70 (which is about the two sequence spaces staying disjoint WITHIN a run). INVARIANT, NOT SITES. The normative requirement is that STARTING the server sets the global sequence to 2^63; the value while stopped is explicitly unspecified. This matters: the C reference zeroes it in netcode_server_stop, while netcode.go and netcode.rs set 2^63 there. All three are correct because start restores the floor. Wording it as 'set on start and on stop' would have made the reference implementation non-conformant to its own specification. TWO OPTIONAL MITIGATIONS, both MAY, both wire-compatible, neither needing any client change: - reject connect tokens whose expire timestamp predates server start + the maximum token lifetime (stateless, two comparisons, no startup delay); - persist the used-connect-token history across restarts (durable state, but correct when token lifetimes vary). The optional check is placed explicitly in the ordered connection-request steps, immediately after the expiry check and before the decrypt, since it reads only the unencrypted expire timestamp. An earlier draft proposed rejecting tokens by CREATE timestamp. That is impossible: create_timestamp lives in the public connect token that only the client parses. The private token the server decrypts carries client_id, timeout, addresses, both keys and user data -- no creation time. Putting it there would change the wire format. Client state machine section updated to say the rejection reuses the existing ignore path: no new state, no new transition, indistinguishable from an unreachable server, and the correct response (request a new token) is already what those states imply.
1 parent 100ab63 commit 6d6910a

1 file changed

Lines changed: 26 additions & 0 deletions

File tree

STANDARD.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,28 @@ Replay protection is applied to the following packet types on both client and se
313313

314314
The replay buffer size is implementation specific, but as a guide, a few seconds worth of packets at a typical send rate (20-60HZ) should be supported. Conservatively, a replay buffer size of 256 entries per-client should be sufficient for most applications.
315315

316+
## Nonce Reuse and Server Restarts
317+
318+
Encrypted packet sequence numbers are used directly as nonces for packet encryption (see _Replay Protection_ above). Within a single run of a dedicated server instance, netcode keeps the nonce spaces disjoint:
319+
320+
* Per-client packet sequence numbers start at zero for each client slot.
321+
322+
* The server's _global sequence number_, used for packets sent before a client is assigned to a slot (_connection challenge packet_ and _connection denied packet_), is set to 2^63 when the server starts.
323+
324+
The normative requirement is that starting the server sets the global sequence number to 2^63. The value it holds while the server is not running is unspecified, and an implementation may leave it at 2^63 or zero it; what matters is that a start restores the floor. Because per-client sequence numbers start at zero and the global sequence number is at least 2^63 for the whole run, global packets and per-client packets never share a sequence number under the same key during a run.
325+
326+
**This guarantee is scoped to a single run of the server, and does not extend across a restart.** The server to client key is carried inside the private connect token, so it is the same key every time that connect token is presented. The history of connect tokens already used (see _Processing Connection Requests_) is held in memory, and is therefore empty after a restart. Per-client sequence numbers also begin again at zero. A client that presents the same connect token to a server that has restarted will therefore have packets encrypted under that key at sequence numbers that were already used before the restart, repeating a nonce under that key.
327+
328+
The window is bounded by connect token expiry. A server ignores any connection request whose connect token expire timestamp is <= the current timestamp, so only a connect token that is still unexpired after the restart can be affected.
329+
330+
An implementation MAY close the window entirely. Both of the options below are optional, neither changes any packet, and neither requires any change to clients. **A server that implements neither remains conformant and fully interoperable, and the protocol version remains "NETCODE 1.02" either way.**
331+
332+
* **Reject connect tokens that predate the server start.** The server records its start time, and ignores any connection request whose connect token expire timestamp is earlier than the start time plus the maximum connect token lifetime issued by the deployment. A connect token issued before the restart always fails this test, because its expire timestamp is at most its issue time plus that lifetime. A connect token issued with a full lifetime after the restart always passes it, so the server can accept connections immediately on start. This requires no persistent state, and no data beyond the expire timestamp already present in the connection request packet. Note that a connect token deliberately issued with a shorter lifetime than the configured maximum will be rejected until the maximum has elapsed.
333+
334+
* **Persist the connect token history.** The server writes the history of connect tokens already used - the private connect token hmac, address and time - to durable storage, and restores it on start. A connect token used before the restart is then rejected after it by the existing rule. This costs durable storage, and is the option to choose when the deployment issues connect tokens with widely varying lifetimes.
335+
336+
Neither option requires the client to know which, if either, the server implements. In both cases a rejected connect token produces the existing behaviour: the connection request is ignored, and the client requests a new connect token from the web backend.
337+
316338
## Client State Machine
317339

318340
The client has the following states:
@@ -356,6 +378,8 @@ All other transitions from _sending connection request_ are failure cases. In th
356378

357379
If a _connection request denied_ packet is received while in _sending connection request_ the client transitions to _connection denied_. If neither a _connection challenge packet_ or a _connection denied packet_ are received within the timeout period specified in the connect token, the client transitions to _connection request timed out_.
358380

381+
A server that implements either of the optional restart mitigations described in _Nonce Reuse and Server Restarts_ rejects an affected connect token by ignoring the connection request, which is the same path as any other rejected request. No new client state and no new transition is required, and the client cannot distinguish this case from a server that is unreachable: it retries at its normal rate, moves on to the next server address in the connect token, and finally transitions to _connection request timed out_, or to _connect token expired_ if the whole process outlasts the token. The correct client response is to request a new connect token from the web backend, which is already the response to those states.
382+
359383
### Sending Challenge Response
360384

361385
While in _sending challenge response_ the client sends _challenge response packets_ to the server at some rate, like 10HZ.
@@ -432,6 +456,8 @@ The server takes the following steps, in this exact order, when processing a _co
432456

433457
* If the connect token expire timestamp is <= the current timestamp, ignore the packet.
434458

459+
* OPTIONAL. If the server implements the connect token restart mitigation described in _Nonce Reuse and Server Restarts_, and the connect token expire timestamp is earlier than the server start time plus the maximum connect token lifetime issued by the deployment, ignore the packet. This check belongs here, immediately after the expiry check and before the decrypt, because it uses only the unencrypted expire timestamp and so costs nothing on a request that is going to be rejected anyway. A server that does not implement the mitigation omits this step entirely.
460+
435461
* If the encrypted private connect token data doesn't decrypt with the private key, using the associated data constructed from: version info, protocol id and expire timestamp, ignore the packet.
436462

437463
* If the decrypted private connect token fails to be read for any reason, for example, having a number of server addresses outside of the expected range of [1,32], or having an address type value that is not 1 (IPv4) or 2 (IPv6), ignore the packet.

0 commit comments

Comments
 (0)