Skip to content

Latest commit

 

History

History
247 lines (221 loc) · 16.8 KB

File metadata and controls

247 lines (221 loc) · 16.8 KB

HOT — read before reasoning about this repo

WHAT: the C reference implementation of the netcode protocol (encrypted, connection-oriented UDP with connect tokens). NOT netcode.rs / netcode.go (the ports), NOT the crates.io crate netcode (unrelated, taken 2017 — ours is netcode-official).

THE PROTOCOL IS THE PRODUCT. STANDARD.md is the spec every implementation follows. A change here that alters wire behaviour breaks every port and every third-party implementation. Spec change first, code second, ports after — never the reverse.

DECISIONS THAT READ AS BUGS (they are not — do not "fix" them)

  • One ~9,300-line file. A deliberate style choice trading contribution ergonomics for trivial integration (drop in two files, no build system).
  • Flat linear scans, not hash maps, for per-client address lookup and the encryption mapping search. A hash WAS considered and rejected: netcode targets ~100 players, and an attacker controls the keys (source addresses) and could drive a hash into its worst case. Linear is the hardened choice here, not the lazy one.
  • Connect-token single-use tracking is constant-time worst-case on purpose (netcode.c:3701, says so in a comment). Timing must not leak whether a token was seen.
  • Per-packet socket errors are ignored deliberately. UDP is unreliable, so a send error is semantically identical to a dropped packet; a persistently dead socket surfaces as a connection timeout through the state machine.
  • The running server has no state machine of its own — only stopped/started (netcode_server_running). All other state is per-client. That is not an omission.
  • Global packet sequence starts at 1ULL << 63 (netcode.c:3940) and is re-seeded on BOTH start and stop. Nonce-space separation: pre-connection and per-client packets share a key, so their nonces must not collide. Seeding on create only was the AEAD nonce-reuse bug fixed in 1.4.0. Keep every seeding site.
  • Replay protection advances the window only AFTER authentication (netcode.c:1863, 1907). The cheap pre-decrypt reject is an optimisation; moving the window advance before auth would let spoofed plaintext sequence numbers poison it.

SECURITY: netcode 1.3.5 and earlier carry the nonce-reuse issue above; see SECURITY.md for affected versions and which channels still serve them.

KNOWN OPEN DEFECTS -- do not rediscover these, and do not assert them in tests

  • #173 netcode_generate_connect_token bounds num_server_addresses with netcode_assert only. Under -DNDEBUG (the release build, and what Debian ships) the asserts vanish and the parse loops write past a 32-element STACK array. Note the same loop body already returns NETCODE_ERROR for a bad address string, so the inconsistency is within one function. Caller-precondition, not attacker-controlled by design -- not filed as an advisory.
  • #174 netcode_parse_address("[::1") returns OK and yields ::1. The opening bracket is skipped whether or not it was ever closed. The suite already rejects [, [] and []:, so this is a gap, not deliberate leniency. Low severity. Both were found by WRITING TESTS against a release build, not by reading the source. Neither is asserted in mas-bandwidth/apt's autopkgtest on purpose: the shipped -DNDEBUG library does not promise the first, and the second is unfixed. A packaging test that asserts an unshipped fix makes a faithful package look broken.

THE WRITE/READ RULE — read this BEFORE reporting any assert as a missing bounds check Glenn, 2026-07-26: "intention is on write, user is responsible to not crash or do undefined behavior. asserts are there to help. callers responsibility. on read, obviously, we must check." Plus Postel: "be conservative in what you send, permissive in what you receive." WRITE / caller-supplied -> the CALLER validates. An assert-only bound is the DESIGN, and -DNDEBUG removing it is correct. Do not add runtime checks here. READ / off the wire -> the library checks at runtime, for SAFETY (bounds, sizes). Permissive about format variation; strict about never crashing. This repo states the policy itself at netcode.c:4101-4103 -- "an out of range value here must not get through in release builds where asserts compile out" -- attached to the entry points that were hardened (3387, 4104, 4998, 5010, every client_index accessor). I audited this file, quoted that comment, and STILL filed the write-path asserts as defects. Do not repeat that. DELIBERATELY LENIENT, do not "fix": netcode_parse_address("[::1") returns OK and yields ::1. That is permissive-on-receive and intended (#174 closed as by-design). netcode_generate_connect_token WAS genuinely missing its runtime check -- it was the one entry point skipped in the hardening pass above. Fixed; regression test test_generate_connect_token_out_of_range. GOTCHA: netcode_set_assert_function( NULL ) is a landmine. netcode_assert calls the pointer with NO null guard (netcode.h:350-356), so NULL turns the next failing assert into a crash. Restore &netcode_default_assert_handler instead -- which is what netcode.c:6984 already does. A custom handler MAY return (documented netcode.h:362-366); that is the only way a test with asserts compiled in can reach a release-build code path.

CLAUDE.md

What this is

netcode is a secure connection-oriented protocol over UDP for multiplayer games: connect-token authentication against a trusted backend, challenge/response handshake, encrypted/signed packets via libsodium, client slots, keep-alives, timeouts, and clean disconnect. The protocol is specified in STANDARD.md (netcode 1.02) and has multiple independent implementations (C#, Go, Rust, TypeScript).

  • netcode.h / netcode.c — the entire library. netcode.c is ~9,300 lines: ~5,400 of implementation and ~3,900 of tests gated behind NETCODE_ENABLE_TESTS.
  • client.c, server.c, client_server.c, soak.c, profile.c — examples and harnesses.
  • sodium/ — vendored subset of libsodium, amalgamated into a single sodium.h + sodium.c pair (see sodium/NOTES.md for how it is generated and validated).
  • Build: CMake. cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build --parallel, then ctest --test-dir build --output-on-failure runs the suite (42 tests). The netcode_test target compiles netcode.c into itself with NETCODE_ENABLE_TESTS, so it links only sodium. -DNETCODE_SANITIZE=ON adds ASan+UBSan (sodium gets ASan only); -DNETCODE_FUZZ=ON builds the fuzz/ harnesses (libFuzzer where available, else a standalone file replayer). For packaging: -DNETCODE_SYSTEM_SODIUM=ON links the system libsodium instead of the vendored copy, -DBUILD_SHARED_LIBS=ON builds libnetcode shared, and cmake --install installs netcode.h + the library (this is the homebrew configuration, covered by a CI leg). CI (.github/workflows/ci.yml) builds and tests Debug + Release on Linux x64, Linux arm64, macOS Apple Silicon, and Windows x64 (MSVC + a MinGW leg), plus a Linux ASan+UBSan leg and a bounded smoke-fuzz leg. A separate nightly workflow (.github/workflows/scheduled.yml) runs deep fuzzing with an accumulating cached corpus, a 15-minute ASan soak, and a libsodium-upstream-release check that opens a tracking issue when the vendored version falls behind.
  • fuzz/ — fuzz harnesses over the untrusted-input surface (netcode_read_packet, the connect token readers, netcode_parse_address), each with write/read round-trip invariants, plus a checked-in seed corpus under fuzz/corpus/. See fuzz/README.md.
  • IMPLEMENTERS.md — findings other-language ports should check against (the replay-protection overflow, the STANDARD.md errata).

Honest assessment

The short version

This is a mature, disciplined, security-conscious C library that does exactly one thing and does it well. The protocol design is its strongest asset. The weaknesses called out by earlier passes of this assessment — thin error reporting, internal duplication, assert-only input validation, per-packet allocation — have each been either fixed or documented below as deliberate design. What remains is one genuinely external step — OSS-Fuzz enrollment for continuous rather than nightly-bounded fuzzing — and the inherent contribution cost of a single ~9,300-line file, a deliberate style choice that trades contribution ergonomics for trivial integration.

What's genuinely good

The protocol design is the real product, and it's excellent. Authentication is delegated to a trusted backend via encrypted connect tokens, so the server never has to trust the network. The security details are handled with visible care:

  • AEAD everywhere, with version info, protocol id, and the packet prefix byte bound as associated data — you can't transplant ciphertext across packet types or protocol versions (netcode_write_packet, netcode.c:1606).
  • Nonce-space separation: server global packets start sequence at 1ULL << 63 (netcode.c:3940) so pre-connection and per-client packet nonces can't collide under a shared key.
  • Replay protection does a cheap pre-decrypt rejection of stale sequence numbers, but only advances the window after authentication succeeds (netcode.c:1863, 1907) — plaintext sequence numbers can't be spoofed to poison the window. This is the correct fix for the vulnerability class credited in the README.
  • Connect-token single-use tracking is a deliberately constant-time worst-case scan, and says so in a comment (netcode.c:3701).
  • Per-packet address lookups (netcode_server_find_client_index_by_address, the encryption mapping search) are deliberately flat linear scans, not hash maps. A hash was considered and rejected: netcode targets ~100 players or fewer per server, and an attacker who controls the keys (source addresses) can drive a hash table into worst-case behavior, while a linear scan over small n is predictable — slower in the best case, but with no attacker-controllable worst case. Same philosophy as the connect-token scan above.
  • The challenge/response step defeats source-address spoofing, and "server is full" is only revealed after the token decrypts — no free oracle for random scanners.
  • Every read path length-checks before it touches or allocates anything; connection requests must be exactly the right size (netcode.c:1722).

The engineering shows production scars. SIO_UDP_CONNRESET on Windows so a hard-disconnecting client doesn't wedge recvfrom (netcode.c:554), IPV6_V6ONLY, 4MB socket buffers, DSCP packet tagging with a documented MinGW/Qwave workaround, true dual-stack IPv4+IPv6 sockets, loopback clients for integrated host-and-play, allocator override hooks, and full send/receive transport overrides. A built-in network simulator (latency/jitter/loss/duplication) makes the connection tests deterministic without touching real sockets. Few networking libraries ship this complete a test story: 42 unit + integration tests covering every client error state, reconnect, multi-server fallback, dual-stack, loopback — plus a soak test and a profiler. All pass today.

The API is small and honest. Opaque structs, create/update/destroy lifecycle, explicit netcode_client_update(client, time) — time is injected, not sampled, which makes the state machines testable and frame-loop friendly. The header is a clean ~300 lines with zero dependencies beyond stdint.

Error reporting matches the transport's nature. Asynchronous connection failures (denial, timeouts, token problems) surface through the client's negative NETCODE_CLIENT_STATE_* values polled from the game loop — the only channel that can carry errors that happen seconds after the call that caused them. Create failures are queryable: netcode_client_create_error() / netcode_server_create_error() report why create returned NULL, with server bind failures (port already in use — the common operational failure) distinguished from other socket errors per address family. Per-packet socket errors are deliberately ignored: UDP is unreliable, so a send error is semantically identical to a dropped packet, and a persistently dead socket surfaces the same way persistent loss does — as a connection timeout through the state machine. The running server deliberately has no state machine of its own: its only states are stopped and started (netcode_server_running), and everything else is per-client.

What's not so good

Allocation is the integrator's contract. Every allocation inside the library goes through the allocator hooks in the client/server config. Per-packet allocation on the receive path is by design: games that care are expected to supply an allocator whose alloc and free are cheap and don't call out to the OS (a pool or arena), and that is how shipped games actually use it — yojimbo, the author's higher-level library built on netcode, provides its own allocator through these exact hooks. The library defines where allocations happen; the application decides how fast they are. The default malloc/free is a convenience for getting started, not the intended production configuration.

Small sharp edges:

  • Global mutable state (log level, printf/assert hooks, the netcode_init reference count, static timers) means the library is single-threaded by design. The header now documents this at the top, but the state is still global.
  • The real-socket connect tests advance virtual time while pumping real sockets; they sleep 10ms per iteration so OS packet delivery can keep up. Without that yield they are timing-sensitive on loaded CI runners (this bit once: macOS Release, run 28993111836).

(Fixed in July 2026: atoi port truncation now rejects invalid ports; the public netcode_assert macro no longer force-exits, so a custom assert handler may choose to continue; zero-byte payload sends are rejected at the API instead of silently vanishing at the receiver; thread-safety expectations are documented in netcode.h; the NETCODE_ADDRESS_BUFFER_SAFETY margin in netcode_parse_address is removed — port parsing is validated and the indexing is guarded, verified by fuzz_parse_address under ASan; the leftover void casts in netcode_client_process_packet are gone; public entry points that take max_clients, a client index, or a loopback packet size now pair their asserts with runtime bounds guards, so out-of-range values from the application can't index past the per-client arrays in release builds — covered by test_runtime_guards, which runs with a continuing assert handler so it exercises the guards in debug too; netcode_init/netcode_term are reference counted, so multiple subsystems can init and term independently; a zeroed client/server config gets default allocators instead of crashing; the network simulator uses a per-instance seeded xorshift64* instead of global rand(), so simulator runs are deterministic — pinned by test_network_simulator_determinism; netcode_client_create_error() and netcode_server_create_error() report why create returned NULL, with server bind failures distinguished per address family; the internal duplication that used to be the biggest code-quality issue is consolidated — netcode_server_process_packet delegates to the shared read-and-process path, the client receive loops feed netcode_client_process_packet, sockaddr conversion and simulator/override/socket send dispatch are single helpers, and client slot reset is shared between the disconnect paths, for a net −116 lines with no public header or wire change; the server re-seeds its global packet sequence to 1ULL << 63 in netcode_server_start, not just in create — netcode_server_stop zeroes it, so a stopped-and-restarted server used to send challenge packets from sequence 0 and reuse AEAD nonces against per-client packets under the same per-token key — pinned by test_server_restart_global_sequence and written up as finding 3 in IMPLEMENTERS.md.)

Process. CI builds and tests every push across Linux x64, Linux arm64, macOS Apple Silicon, and Windows (MSVC + MinGW) in Debug and Release, runs an ASan+UBSan leg, and smoke-fuzzes the parsing surface. A nightly workflow adds deep fuzzing with a cached accumulating corpus, a 15-minute ASan soak, and a libsodium-release watch that files a tracking issue when the vendored subset falls behind upstream — which is the one real liability of vendoring (decoupling from upstream security updates; that plus symbol collision risk if the host app links its own libsodium are the price of trivially reliable builds). The remaining process step that is genuinely external is OSS-Fuzz enrollment — the harnesses are already libFuzzer-shaped, so it is mostly a submission to google/oss-fuzz — which would turn the nightly bounded run into continuous coverage.

Verdict

As a protocol and reference implementation, this is top-tier work — the threat model is correct, the crypto usage is careful, the tests actually exercise the state machines, and the operational details reflect someone who has shipped real multiplayer games.