Skip to content

STANDARD.md: scope the nonce guarantee to a server run, add optional restart mitigations - #165

Merged
rowan-claude merged 1 commit into
mainfrom
spec/restart-nonce-guidance
Jul 25, 2026
Merged

STANDARD.md: scope the nonce guarantee to a server run, add optional restart mitigations#165
rowan-claude merged 1 commit into
mainfrom
spec/restart-nonce-guidance

Conversation

@rowan-claude

Copy link
Copy Markdown
Contributor

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, because start restores 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 MAY

  • Reject connect tokens predating server start — ignore any request whose expire timestamp is earlier than server start + the deployment's maximum token lifetime. Stateless, two comparisons, no startup delay, no client change.
  • Persist the used-connect-token history — durable state, but the right choice when token lifetimes vary.

Placed 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_timestamp lives in the public connect token, which only the client parses. The private token the server decrypts holds client_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) or connect 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 (start sets 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.md is vendored verbatim into netcode.go and netcode.rs, with a spec-sync CI job diffing against netcode/main. Those two go red the moment this merges, until their copies are synced. PRs follow immediately.

…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.
@rowan-claude
rowan-claude merged commit 6d6910a into main Jul 25, 2026
14 checks passed
@rowan-claude
rowan-claude deleted the spec/restart-nonce-guidance branch July 25, 2026 19:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant