Skip to content

netcode 1.4.5: the connect token lifecycle, and the fixes around it - #184

Merged
gafferongames merged 3 commits into
mainfrom
token-lifecycle
Sep 4, 2026
Merged

netcode 1.4.5: the connect token lifecycle, and the fixes around it#184
gafferongames merged 3 commits into
mainfrom
token-lifecycle

Conversation

@gafferongames

Copy link
Copy Markdown
Contributor

The implementation of the rules the STANDARD.md PR states (#183), plus the build, portability and hygiene fixes that ride with the release. Version 1.4.5 in netcode.h and CMake.

Nothing on the wire moves.

The connect token lifecycle

A connect token history entry carries a state:

  • pending on the first connection request presenting a connect token the server has not seen. A pending entry admits a retransmitted connection request from that same address, so a handshake that loses a packet still completes, and refuses every other address.
  • consumed when the server accepts the connection response and installs the client in a slot. A consumed entry admits nothing, whatever the source address and whether or not the client is still in its slot, so the keys inside a connect token encrypt exactly one session.

Entries live until their connect token expires, and a history whose entries all hold unexpired connect tokens refuses a new connect token instead of evicting one, so a flood of connect tokens cannot reopen a token that has been used. An entry's time is set at creation and never refreshed.

The encryption mapping carries the index of the history entry its handshake belongs to, so installing a client consumes the right entry with nothing extra on the wire.

The restart rule

max_connect_token_lifetime in the server config is the longest lifetime the backend issues connect tokens with. The server ignores any connection request whose connect token expire timestamp minus that lifetime is earlier than the time it started, next to the existing expiry check and before the decrypt. The field defaults to NETCODE_DEFAULT_MAX_CONNECT_TOKEN_LIFETIME (30 seconds, the lifetime the examples issue), a zero or negative value takes that default, and the examples and harnesses set it to the lifetime they actually issue.

Also in this release

  • Key erasure. sodium_memzero rather than memset for the encryption manager's keys on reset and on removal, the client's connect token and context, the server's challenge key on stop, and the private key in the server config at destroy.
  • Byte order. IPv4 addresses convert through ntohl and htonl, IPv6 halves through memcpy instead of a uint16_t pointer cast. A big endian s390x job under QEMU runs the suite in Debug and Release.
  • netcode_packet_queue_clear pops until the queue is empty instead of freeing the first num_packets slots, which frees the wrong pointers once start_index has moved. The drain loops its two callers ran beforehand are gone, and netcode_client_destroy is covered as a caller.
  • snprintf in the soak harness.
  • The install. The default static install is self contained: the vendored sodium objects are compiled into libnetcode, and the install carries an exported CMake package. A consumer writes find_package(netcode CONFIG REQUIRED) and links netcode::netcode. The system libsodium build exports the libsodium it found instead. A CI job installs both to a clean prefix and links a program that calls netcode_init against them.
  • Shared builds on Windows are refused by CMake and documented as unsupported: netcode.h declares no export macro, so a DLL built from these sources exports nothing.

Tests

  • test_connect_token_entries — the pending and consumed acceptance rules, the entry time that is never refreshed, the full history refusal, and reuse of an entry once its connect token has expired.
  • test_client_server_connection_request_retransmission — a client and server wired directly to each other through the send and receive overrides, with the first three packets the server sends dropped, so the client retransmits its connection request into a handshake the server already holds a pending entry for, and still connects.
  • test_client_server_replay_across_sessions — a payload datagram captured from a first session is replayed into a second one and delivers nothing.
  • test_client_reconnect_with_used_connect_token — presenting a spent connect token again, from the address that used it, connects nothing.
  • test_client_error_connect_token_predates_server_start — a connect token that expires earlier than any the backend could have issued after the start is refused, and one with the full lifetime connects.
  • -DNETCODE_NONCE_AUDIT=ON builds the test runner with the key and nonce of every packet it encrypts recorded, and test_nonce_audit fails the run if any pair repeats. 3716 pairs, zero repeats. It is a test-only build option and a CI leg; nothing it adds is compiled into the library.

Each test was run against a build with the rule it covers reverted, and each goes red:

test reverted result
test_connect_token_entries consumed entries admit their old address check failed: ... == NETCODE_CONNECT_TOKEN_ENTRY_REFUSED
test_connect_token_entries the full history evicts the oldest entry check failed: ... == NETCODE_CONNECT_TOKEN_HISTORY_FULL
test_client_server_connection_request_retransmission entries are created consumed, so a pending entry admits nothing check failed: ... == NETCODE_CLIENT_STATE_CONNECTED
test_client_server_replay_across_sessions the second session reuses the first connect token check failed: netcode_server_receive_packet( ... ) == NULL
test_client_reconnect_with_used_connect_token consumed entries admit their old address check failed: ... == NETCODE_CLIENT_STATE_CONNECTION_REQUEST_TIMED_OUT
test_client_error_connect_token_predates_server_start the minimum expire timestamp check check failed: ... == NETCODE_CLIENT_STATE_CONNECTION_REQUEST_TIMED_OUT
test_nonce_audit consumed entries admit their old address, second session on the used token 13 repeated key and nonce pairs, check failed: netcode_nonce_audit_repeats() == 0

Built and tested Debug and Release locally, and both install shapes were installed to a clean prefix and linked by the consumer program.

🤖 Generated with Claude Code

gafferongames and others added 3 commits September 4, 2026 16:29
A connect token history entry now carries a state. It is created pending when the server
accepts a connection request for a connect token it has not seen, admits retransmitted
requests from that same address while the handshake runs, and becomes consumed when the
client is installed in a client slot. A consumed entry admits nothing, whatever the source
address, so the keys inside a connect token encrypt exactly one session. Entries live until
their connect token expires, and a history whose entries all hold unexpired tokens refuses a
new connect token instead of evicting one. An entry's time is set at creation and never
refreshed. The encryption mapping carries the index of the history entry its handshake
belongs to, so installing a client consumes the right entry without anything new on the wire.

The server refuses any connect token that could have been issued before it started:
max_connect_token_lifetime in the server config is the longest lifetime the backend issues,
and a connection request whose connect token expire timestamp minus that lifetime is earlier
than the server start time is ignored, alongside the existing expiry check and before the
decrypt. The field defaults to NETCODE_DEFAULT_MAX_CONNECT_TOKEN_LIFETIME, and the examples
and harnesses set it to the lifetime they issue.

Also in this release:

- Key material is erased with sodium_memzero rather than memset: the encryption manager's
  keys on reset and on removal, the client's connect token and context, the server's
  challenge key on stop and its configured private key on destroy.
- IPv4 addresses are converted through ntohl and htonl and IPv6 halves through memcpy, so
  address conversion is correct on big endian machines, and CI proves it on s390x under QEMU.
- netcode_packet_queue_clear pops until the queue is empty rather than freeing the first
  num_packets slots, so a partially drained queue is cleared correctly. The drain loops its
  two callers ran first are gone.
- The soak harness formats addresses with snprintf.
- The default static install is self contained: the vendored sodium objects are compiled into
  libnetcode, and the install carries an exported CMake package that consumers find with
  find_package(netcode CONFIG) and link as netcode::netcode. The system libsodium build
  exports its libsodium instead. CI links a program against both from a clean prefix.
- Shared library builds are refused on Windows, where netcode.h declares no export macro.

Tests: test_connect_token_entries covers the pending and consumed rules, the unrefreshed
entry time, the full history refusal and reuse after expiry; a client and server wired
directly through the send and receive overrides drive
test_client_server_connection_request_retransmission, which drops the first server packets so
the client retransmits its connection request, test_client_server_replay_across_sessions,
which replays a first-session datagram into a second session, and
test_client_reconnect_with_used_connect_token and
test_client_error_connect_token_predates_server_start, which pin the two refusals. Building
the test runner with -DNETCODE_NONCE_AUDIT=ON records the key and nonce of every packet the
suite encrypts and fails on a repeat; it is a CI leg.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
netcode_server_destroy and netcode_client_destroy each send disconnect packets on the way
out, and the wire handed them straight to the other end, which the first destroy had already
freed. The wire is down once teardown starts, which is what an application shutting down
finds too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The entry time is created-at, not last-seen, and the expire timestamp is the connect token's,
which is what bounds the entry's life. Both read as ordinary bookkeeping without that.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@gafferongames
gafferongames marked this pull request as ready for review September 4, 2026 07:09
@gafferongames
gafferongames merged commit 8eb9583 into main Sep 4, 2026
19 checks passed
@gafferongames
gafferongames deleted the token-lifecycle branch September 4, 2026 07:27
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