Skip to content

Commit 8eb9583

Browse files
netcode 1.4.5: the connect token lifecycle, and the fixes around it (#184)
* netcode 1.4.5: the connect token lifecycle, and the fixes around it 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> * The test wire drops packets once either end is being destroyed 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 connect token entry fields say what they are 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> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 9ff6a3d commit 8eb9583

16 files changed

Lines changed: 987 additions & 120 deletions

File tree

.github/workflows/ci.yml

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,99 @@ jobs:
8383
test -f stage/lib/libnetcode.so || test -L stage/lib/libnetcode.so
8484
echo "install layout ok"
8585
86+
# the installed library is what a package manager ships, so CI links a program against it
87+
# from a clean prefix: the default vendored build carries its own crypto into libnetcode,
88+
# and the system libsodium build exports the libsodium it found.
89+
consumer:
90+
name: consumer (${{ matrix.name }})
91+
runs-on: ubuntu-24.04
92+
timeout-minutes: 30
93+
strategy:
94+
fail-fast: false
95+
matrix:
96+
include:
97+
- name: vendored sodium
98+
options: ""
99+
system_sodium: false
100+
- name: system sodium
101+
options: "-DNETCODE_SYSTEM_SODIUM=ON"
102+
system_sodium: true
103+
steps:
104+
- uses: actions/checkout@v4
105+
106+
- name: Install libsodium
107+
if: matrix.system_sodium
108+
run: sudo apt-get update && sudo apt-get install -y libsodium-dev
109+
110+
- name: Configure
111+
run: cmake -B build -DCMAKE_BUILD_TYPE=Release -DNETCODE_BUILD_TESTS=OFF ${{ matrix.options }}
112+
113+
- name: Build
114+
run: cmake --build build --parallel
115+
116+
- name: Install to a clean prefix
117+
run: cmake --install build --prefix "${{ github.workspace }}/prefix"
118+
119+
- name: Build and run the consumer
120+
run: |
121+
cmake -S tools/consumer -B consumer-build -DCMAKE_PREFIX_PATH="${{ github.workspace }}/prefix"
122+
cmake --build consumer-build --parallel
123+
./consumer-build/consumer
124+
125+
# the only big endian coverage in the matrix: the address conversions read and write
126+
# network byte order for real here, where host order is not a synonym for it
127+
big-endian:
128+
name: big endian s390x / ${{ matrix.config }}
129+
runs-on: ubuntu-24.04
130+
timeout-minutes: 30
131+
strategy:
132+
fail-fast: false
133+
matrix:
134+
config: [ Debug, Release ]
135+
steps:
136+
- uses: actions/checkout@v4
137+
138+
- name: Install s390x cross toolchain and qemu
139+
run: |
140+
sudo apt-get update
141+
sudo apt-get install -y gcc-s390x-linux-gnu g++-s390x-linux-gnu qemu-user-static
142+
143+
# static linking so the emulated binaries need no s390x sysroot at runtime
144+
- name: Configure
145+
run: >
146+
cmake -B build
147+
-DCMAKE_BUILD_TYPE=${{ matrix.config }}
148+
-DCMAKE_SYSTEM_NAME=Linux
149+
-DCMAKE_SYSTEM_PROCESSOR=s390x
150+
-DCMAKE_C_COMPILER=s390x-linux-gnu-gcc
151+
-DCMAKE_CXX_COMPILER=s390x-linux-gnu-g++
152+
-DCMAKE_C_FLAGS="-static"
153+
-DCMAKE_CXX_FLAGS="-static"
154+
-DCMAKE_CROSSCOMPILING_EMULATOR=qemu-s390x-static
155+
156+
- name: Build
157+
run: cmake --build build --parallel
158+
159+
- name: Test
160+
run: ctest --test-dir build --output-on-failure --timeout 600
161+
162+
# every packet the suite encrypts is recorded by key and nonce, and a repeat fails the run
163+
nonce-audit:
164+
name: nonce audit
165+
runs-on: ubuntu-24.04
166+
timeout-minutes: 30
167+
steps:
168+
- uses: actions/checkout@v4
169+
170+
- name: Configure
171+
run: cmake -B build -DCMAKE_BUILD_TYPE=Release -DNETCODE_NONCE_AUDIT=ON
172+
173+
- name: Build
174+
run: cmake --build build --parallel
175+
176+
- name: Test
177+
run: ctest --test-dir build --output-on-failure --timeout 600
178+
86179
sanitizers:
87180
name: sanitizers (asan+ubsan)
88181
runs-on: ubuntu-24.04

BUILDING.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,15 @@ By default netcode builds as a static library against the vendored libsodium sub
3535
cmake --install build --prefix /some/prefix
3636

3737
- `NETCODE_SYSTEM_SODIUM=ON` links the system libsodium instead of the vendored copy (they are interchangeable — the vendored subset is a byte-identical slice of upstream).
38-
- `BUILD_SHARED_LIBS=ON` builds `libnetcode` as a shared library.
39-
- `cmake --install` installs `netcode.h` and the library (`NETCODE_INSTALL=OFF` disables the install target, e.g. when embedding netcode as a subproject).
38+
- `BUILD_SHARED_LIBS=ON` builds `libnetcode` as a shared library. Shared builds are not supported on Windows: `netcode.h` declares no export macro, so a DLL built from these sources exports nothing, and CMake refuses the combination. On Windows link the static library or compile `netcode.c` into your application.
39+
- `cmake --install` installs `netcode.h`, the library, and a CMake package. In the default vendored build the sodium objects are compiled into `libnetcode`, so the installed library is self contained; the system libsodium build exports the libsodium it found instead.
40+
41+
A consumer picks the installed library up with:
42+
43+
find_package(netcode CONFIG REQUIRED)
44+
target_link_libraries(your_app PRIVATE netcode::netcode)
45+
46+
pointing CMake at the install prefix with `-DCMAKE_PREFIX_PATH=/some/prefix`. `NETCODE_INSTALL=OFF` disables the install target, e.g. when embedding netcode as a subproject.
4047

4148
## Floating point: netcode builds with -ffp-contract=off
4249

@@ -65,6 +72,8 @@ To build everything with AddressSanitizer and UndefinedBehaviorSanitizer, config
6572

6673
Fuzz harnesses for the untrusted-input surface live in `fuzz/` and are built with `-DNETCODE_FUZZ=ON`. See [fuzz/README.md](fuzz/README.md) for details.
6774

75+
`-DNETCODE_NONCE_AUDIT=ON` builds the test runner with the key and nonce of every packet it encrypts recorded, and adds a test that fails if any pair repeats. It is a test-only option: nothing it adds is compiled into the library.
76+
6877
## Building on Windows
6978

7079
You need Visual Studio to build the source code. If you don't have Visual Studio you can [download the community edition for free](https://visualstudio.microsoft.com/downloads/).

CLAUDE.md

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ DECISIONS THAT READ AS BUGS (they are not — do not "fix" them)
2727
BOTH start and stop. Nonce-space separation: pre-connection and per-client packets share
2828
a key, so their nonces must not collide. Seeding on create only was the AEAD nonce-reuse
2929
bug fixed in 1.4.0. Keep every seeding site.
30+
- **A connect token is single use.** Its history entry is created pending on the first
31+
connection request, admits retransmitted requests from that same address while the
32+
handshake runs, and becomes consumed when the client is installed in a slot. A consumed
33+
entry admits nothing, whatever the address, so the keys inside a connect token encrypt
34+
exactly one session. Entries live until their token expires and a full history refuses new
35+
tokens rather than evicting one. The server also refuses any connect token that could have
36+
been issued before it started, using `max_connect_token_lifetime` in the server config.
37+
STANDARD.md's *Connect Token History* and *Nonce Reuse and Server Restarts* are the contract.
3038
- **Replay protection advances the window only AFTER authentication** (netcode.c:1863,
3139
1907). The cheap pre-decrypt reject is an optimisation; moving the window advance before
3240
auth would let spoofed plaintext sequence numbers poison it.
@@ -90,16 +98,23 @@ independent implementations (C#, Go, Rust, TypeScript).
9098
- `sodium/` — vendored subset of libsodium, amalgamated into a single `sodium.h` +
9199
`sodium.c` pair (see `sodium/NOTES.md` for how it is generated and validated).
92100
- Build: CMake. `cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build --parallel`,
93-
then `ctest --test-dir build --output-on-failure` runs the suite (42 tests). The
101+
then `ctest --test-dir build --output-on-failure` runs the suite (51 tests). The
94102
`netcode_test` target compiles netcode.c into itself with `NETCODE_ENABLE_TESTS`, so it
95103
links only sodium. `-DNETCODE_SANITIZE=ON` adds ASan+UBSan (sodium gets ASan only);
96104
`-DNETCODE_FUZZ=ON` builds the `fuzz/` harnesses (libFuzzer where available, else a
97-
standalone file replayer). For packaging: `-DNETCODE_SYSTEM_SODIUM=ON` links the
98-
system libsodium instead of the vendored copy, `-DBUILD_SHARED_LIBS=ON` builds
99-
libnetcode shared, and `cmake --install` installs netcode.h + the library
100-
(this is the homebrew configuration, covered by a CI leg). CI (`.github/workflows/ci.yml`) builds and tests Debug +
105+
standalone file replayer); `-DNETCODE_NONCE_AUDIT=ON` records the key and nonce of every
106+
packet the tests encrypt and fails the run on a repeat (test-only, nothing enters the
107+
library). For packaging: `-DNETCODE_SYSTEM_SODIUM=ON` links the system libsodium instead
108+
of the vendored copy, `-DBUILD_SHARED_LIBS=ON` builds libnetcode shared everywhere except
109+
Windows, where CMake refuses it because netcode.h declares no export macro, and
110+
`cmake --install` installs netcode.h, the library and a CMake package consumers find with
111+
`find_package(netcode CONFIG)` and link as `netcode::netcode` (the vendored build folds the
112+
sodium objects into libnetcode, so the installed static library is self contained; the
113+
system-sodium build, the homebrew configuration, exports its libsodium instead).
114+
CI (`.github/workflows/ci.yml`) builds and tests Debug +
101115
Release on Linux x64, Linux arm64, macOS Apple Silicon, and Windows x64 (MSVC + a
102-
MinGW leg), plus a Linux ASan+UBSan leg and a bounded smoke-fuzz leg. A separate
116+
MinGW leg), plus a Linux ASan+UBSan leg, a bounded smoke-fuzz leg, a big endian s390x leg
117+
under QEMU, a clean-prefix consumer leg for both install shapes, and the nonce audit leg. A separate
103118
nightly workflow (`.github/workflows/scheduled.yml`) runs deep fuzzing with an
104119
accumulating cached corpus, a 15-minute ASan soak, and a libsodium-upstream-release
105120
check that opens a tracking issue when the vendored version falls behind.

CMakeLists.txt

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ if(CMAKE_CURRENT_SOURCE_DIR STREQUAL CMAKE_SOURCE_DIR)
1111
set(CMAKE_TARGET_MESSAGES OFF)
1212
endif()
1313

14-
project(netcode VERSION 1.4.4 LANGUAGES C CXX)
14+
project(netcode VERSION 1.4.5 LANGUAGES C CXX)
1515

1616
set(CMAKE_C_STANDARD 99)
1717
set(CMAKE_C_STANDARD_REQUIRED ON)
@@ -69,6 +69,17 @@ option(NETCODE_FUZZ "Build the fuzz targets" OFF)
6969
option(NETCODE_BUILD_TESTS "Build netcode tests and examples" ${NETCODE_TOP_LEVEL})
7070
option(NETCODE_SYSTEM_SODIUM "Link against the system libsodium instead of the vendored copy" OFF)
7171
option(NETCODE_INSTALL "Generate the install target (netcode.h and the netcode library)" ON)
72+
option(NETCODE_NONCE_AUDIT "Record the key and nonce of every packet the tests encrypt and fail on a repeat" OFF)
73+
74+
# netcode.h declares no export macro, so a Windows DLL built from these sources exports
75+
# nothing and every consumer fails to link. Static is the supported shape on Windows: link
76+
# the static library, or compile netcode.c straight into your application.
77+
78+
if(BUILD_SHARED_LIBS AND WIN32)
79+
message(FATAL_ERROR "netcode does not support shared library builds on Windows. Build the static library (the default), or compile netcode.c into your application.")
80+
endif()
81+
82+
include(GNUInstallDirs)
7283

7384
# sanitizers apply to the whole build. the vendored crypto is exempted from UBSan
7485
# below (third-party SIMD code uses intentional type punning / unaligned access that
@@ -102,7 +113,7 @@ else()
102113
# vendored libsodium subset, amalgamated into a single header + source pair.
103114
# see sodium/NOTES.md for how it is generated and validated.
104115

105-
add_library(sodium STATIC sodium/sodium.c sodium/sodium.h)
116+
add_library(sodium OBJECT sodium/sodium.c sodium/sodium.h)
106117
target_include_directories(sodium PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/sodium)
107118
set_target_properties(sodium PROPERTIES POSITION_INDEPENDENT_CODE ON)
108119
if(NOT MSVC)
@@ -120,13 +131,28 @@ else()
120131

121132
endif()
122133

123-
# the netcode library. static by default; -DBUILD_SHARED_LIBS=ON builds it shared
124-
# (the vendored sodium objects are position independent, so they fold in either way)
134+
# the netcode library. static by default; -DBUILD_SHARED_LIBS=ON builds it shared on the
135+
# platforms that support it (the vendored sodium objects are position independent, so they
136+
# fold in either way).
137+
#
138+
# in the default vendored build the sodium objects are compiled into libnetcode itself, so
139+
# the installed library is self contained: a consumer links netcode and nothing else. the
140+
# system-sodium build exports the libsodium it found instead, because that libsodium is the
141+
# package manager's to supply and update.
125142

126-
add_library(netcode netcode.c netcode.h)
127-
target_include_directories(netcode PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
143+
if(NETCODE_SYSTEM_SODIUM)
144+
add_library(netcode netcode.c netcode.h)
145+
target_include_directories(netcode PRIVATE ${NETCODE_SODIUM_INCLUDE_DIR})
146+
target_link_libraries(netcode PUBLIC ${NETCODE_SODIUM_LIBRARY})
147+
else()
148+
add_library(netcode netcode.c netcode.h $<TARGET_OBJECTS:sodium>)
149+
target_include_directories(netcode PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/sodium)
150+
endif()
151+
152+
target_include_directories(netcode PUBLIC
153+
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}>
154+
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>)
128155
target_compile_options(netcode PRIVATE ${NETCODE_WARNINGS})
129-
target_link_libraries(netcode PUBLIC sodium)
130156
set_target_properties(netcode PROPERTIES
131157
VERSION ${PROJECT_VERSION}
132158
SOVERSION ${PROJECT_VERSION_MAJOR})
@@ -135,12 +161,31 @@ if(WIN32)
135161
endif()
136162

137163
if(NETCODE_INSTALL)
138-
include(GNUInstallDirs)
139-
install(TARGETS netcode
164+
include(CMakePackageConfigHelpers)
165+
166+
install(TARGETS netcode EXPORT netcodeTargets
140167
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
141168
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
142-
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
169+
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
170+
INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
143171
install(FILES netcode.h DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
172+
173+
install(EXPORT netcodeTargets
174+
FILE netcodeTargets.cmake
175+
NAMESPACE netcode::
176+
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/netcode)
177+
178+
configure_package_config_file(cmake/netcodeConfig.cmake.in
179+
${CMAKE_CURRENT_BINARY_DIR}/netcodeConfig.cmake
180+
INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/netcode)
181+
182+
write_basic_package_version_file(${CMAKE_CURRENT_BINARY_DIR}/netcodeConfigVersion.cmake
183+
VERSION ${PROJECT_VERSION}
184+
COMPATIBILITY SameMajorVersion)
185+
186+
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/netcodeConfig.cmake
187+
${CMAKE_CURRENT_BINARY_DIR}/netcodeConfigVersion.cmake
188+
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/netcode)
144189
endif()
145190

146191
if(NETCODE_BUILD_TESTS)
@@ -166,6 +211,9 @@ if(NETCODE_BUILD_TESTS)
166211
if(WIN32)
167212
target_link_libraries(netcode_test PRIVATE ws2_32 iphlpapi)
168213
endif()
214+
if(NETCODE_NONCE_AUDIT)
215+
target_compile_definitions(netcode_test PRIVATE NETCODE_ENABLE_NONCE_AUDIT=1)
216+
endif()
169217

170218
add_test(NAME netcode_test COMMAND netcode_test)
171219

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ char * server_address = "127.0.0.1:40000";
4646
4747
struct netcode_server_config_t server_config;
4848
netcode_default_server_config( &server_config );
49+
server_config.max_connect_token_lifetime = 30;
4950
memcpy( &server_config.private_key, private_key, NETCODE_KEY_BYTES );
5051
5152
struct netcode_server_t * server = netcode_server_create( server_address, &server_config, time );
@@ -56,6 +57,8 @@ if ( !server )
5657
}
5758
```
5859

60+
`max_connect_token_lifetime` is the longest lifetime in seconds your backend issues connect tokens with. The server refuses any connect token that could have been issued before it started, and this is how it knows which those are, so set it to the lifetime your backend uses.
61+
5962
Then start the server with the number of client slots you want:
6063

6164
```c

client_server.c

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ int main( int argc, char ** argv )
7777
struct netcode_server_config_t server_config;
7878
netcode_default_server_config( &server_config );
7979
server_config.protocol_id = PROTOCOL_ID;
80+
server_config.max_connect_token_lifetime = CONNECT_TOKEN_EXPIRY;
8081
memcpy( &server_config.private_key, private_key, NETCODE_KEY_BYTES );
8182

8283
char * server_address = "[::1]:40000";

cmake/netcodeConfig.cmake.in

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
@PACKAGE_INIT@
2+
3+
include("${CMAKE_CURRENT_LIST_DIR}/netcodeTargets.cmake")
4+
5+
check_required_components(netcode)

fuzz/fuzz_read_packet.c

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ static void * fuzz_call_read_packet( uint8_t * buffer, int buffer_length, uint64
4646
packet_key,
4747
FUZZ_PROTOCOL_ID,
4848
0, // current timestamp: zero so fuzz-chosen expire timestamps pass
49+
0, // minimum expire timestamp: zero so no connect token is refused for predating the server start
4950
private_key,
5051
allowed_packets,
5152
replay_protection,

0 commit comments

Comments
 (0)