STANDARD.md: scope the nonce guarantee to a server run, add optional restart mitigations - #165
Merged
Merged
Conversation
…restart mitigations 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.
This was referenced Jul 25, 2026
Merged
Merged
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the blind spot Glenn raised: the spec said sequence numbers are used as nonces, but never said how far the uniqueness guarantee reaches.
Purely additive — 26 lines added, 0 removed. No existing normative text changes, no packet changes, protocol version stays
NETCODE 1.02. A server that ignores every optional part remains conformant and fully interoperable with existing clients and servers.The gap
The guarantee holds within a single run: per-client sequences start at 0, the global sequence starts at 2^63, so they never collide under the same key. It does not survive a restart — 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. It is 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). The window is already bounded by connect token expiry.Invariant, not sites — this one nearly went wrong
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 sets it to 0 in
netcode_server_stop(netcode.c:4348), while netcode.go (server.go:427) and netcode.rs (server.rs:421) set it to 2^63. All three are correct, becausestartrestores the floor. My first draft said "set on start and on stop", which would have made the reference implementation non-conformant to its own specification.Two optional mitigations, both
MAYPlaced explicitly in the ordered connection-request steps, immediately after the expiry check and before the decrypt, because it reads only the unencrypted expire timestamp.
A rejected earlier draft, recorded because it looked right
I first proposed rejecting by create timestamp. That is impossible:
create_timestamplives in the public connect token, which only the client parses. The private token the server decrypts holdsclient_id,timeout_seconds, addresses, both keys and user data — no creation time. Putting it there would change the wire format, which is exactly what this change must not do.Client state machine
Updated: the rejection reuses the existing ignore path. No new state, no new transition. The client cannot distinguish it from an unreachable server — it retries, tries the next address, then reaches
connection request timed out(-2) orconnect token expired(-6). The correct response, requesting a new token, is already what those states imply.Conformance of our three implementations
All three already satisfy the normative requirement (
startsets 2^63): C at netcode.c:4130, netcode.go at server.go:253, netcode.rs at server.rs:404. None implements the optional mitigations, which is fine — they are optional. No implementation change is required by this PR.Follow-up required in the same landing
STANDARD.mdis vendored verbatim into netcode.go and netcode.rs, with aspec-syncCI job diffing againstnetcode/main. Those two go red the moment this merges, until their copies are synced. PRs follow immediately.