diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eeb63f6..d7bbac2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,7 @@ jobs: # the interop pin: bump deliberately, in its own commit, after reading # the upstream diff (a wire change upstream is a spec change first). repository: mas-bandwidth/netcode - ref: 12d25754a8dafdaa77b9cc306901089576685777 + ref: 47a156b17df9110c1a2d5d8cbf5b7768d1838a47 path: c-netcode - uses: actions/setup-dotnet@v6 with: diff --git a/README.md b/README.md index 63578b5..3a50054 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,10 @@ ConnectTokenGenerator.Generate(publicAddresses, internalAddresses, userData, tokenBuffer); ``` +Set `ServerConfig.MaxConnectTokenLifetime` to the longest lifetime issued by +the backend (the default is 30 seconds); the server rejects tokens that could +have been issued before its current start. + Like the C library, client and server objects are single-threaded by design and perform no internal synchronization. @@ -135,7 +139,9 @@ Inherited from the C reference implementation, on purpose: scale (~100 players). - **Connect-token single-use tracking is constant-time worst-case**: the find-or-add scan always walks every entry, so timing does not leak whether a - token was seen. + token was seen. Pending entries admit only same-address retransmits; + installation consumes the token for every address, and unexpired entries are + never evicted. - **Per-packet socket send errors are swallowed.** UDP is unreliable; a send error is semantically identical to a dropped packet, and a persistently dead socket surfaces as a connection timeout through the state machine. diff --git a/STANDARD.md b/STANDARD.md index 6af8f0e..e4642df 100644 --- a/STANDARD.md +++ b/STANDARD.md @@ -327,13 +327,11 @@ The normative requirement is that starting the server sets the global sequence n The window is bounded by connect token expiry. A server ignores any connection request whose connect token expire timestamp is <= the current timestamp, so only a connect token that is still unexpired after the restart can be affected. -An implementation MAY close the window entirely. Both of the options below are optional, neither changes any packet, and neither requires any change to clients. **A server that implements neither remains conformant and fully interoperable, and the protocol version remains "NETCODE 1.02" either way.** +**A server MUST reject any connect token that could have been issued before it started.** The server is configured with the maximum connect token lifetime its deployment issues, and records the time it started. It ignores any connection request whose connect token expire timestamp minus that maximum lifetime is earlier than the server start time. A connect token issued before the start always fails this test, because its expire timestamp is at most its issue time plus that lifetime. A connect token issued with a full lifetime after the start always passes it, so the server accepts connections immediately on start. This requires no persistent state, and no data beyond the expire timestamp already present in the connection request packet. A connect token deliberately issued with a shorter lifetime than the configured maximum is rejected until the difference has elapsed. -* **Reject connect tokens that predate the server start.** The server records its start time, and ignores any connection request whose connect token expire timestamp is earlier than the start time plus the maximum connect token lifetime issued by the deployment. A connect token issued before the restart always fails this test, because its expire timestamp is at most its issue time plus that lifetime. A connect token issued with a full lifetime after the restart always passes it, so the server can accept connections immediately on start. This requires no persistent state, and no data beyond the expire timestamp already present in the connection request packet. Note that a connect token deliberately issued with a shorter lifetime than the configured maximum will be rejected until the maximum has elapsed. +A server MAY also persist the connect token history: it writes the history entries to durable storage, and restores them on start, so that a connect token consumed before the start is refused after it. Persistence is meaningful only together with the consumed state, because a restored entry is only useful if it refuses the address that used the token as well as every other address. It costs durable storage, and is the option to choose when the deployment issues connect tokens with widely varying lifetimes. -* **Persist the connect token history.** The server writes the history of connect tokens already used - the private connect token hmac, address and time - to durable storage, and restores it on start. A connect token used before the restart is then rejected after it by the existing rule. This costs durable storage, and is the option to choose when the deployment issues connect tokens with widely varying lifetimes. - -Neither option requires the client to know which, if either, the server implements. In both cases a rejected connect token produces the existing behaviour: the connection request is ignored, and the client requests a new connect token from the web backend. +Neither rule changes any packet, and neither requires any change to clients: the protocol version remains "NETCODE 1.02", and a rejected connect token produces the existing behavior, where the connection request is ignored and the client requests a new connect token from the web backend. ## Client State Machine @@ -378,7 +376,7 @@ All other transitions from _sending connection request_ are failure cases. In th If a _connection request denied_ packet is received while in _sending connection request_ the client transitions to _connection denied_. If neither a _connection challenge packet_ or a _connection denied packet_ are received within the timeout period specified in the connect token, the client transitions to _connection request timed out_. -A server that implements either of the optional restart mitigations described in _Nonce Reuse and Server Restarts_ rejects an affected connect token by ignoring the connection request, which is the same path as any other rejected request. No new client state and no new transition is required, and the client cannot distinguish this case from a server that is unreachable: it retries at its normal rate, moves on to the next server address in the connect token, and finally transitions to _connection request timed out_, or to _connect token expired_ if the whole process outlasts the token. The correct client response is to request a new connect token from the web backend, which is already the response to those states. +A server rejects a connect token that could have been issued before it started, as described in _Nonce Reuse and Server Restarts_, by ignoring the connection request, which is the same path as any other rejected request. No new client state and no new transition is required, and the client cannot distinguish this case from a server that is unreachable: it retries at its normal rate, moves on to the next server address in the connect token, and finally transitions to _connection request timed out_, or to _connect token expired_ if the whole process outlasts the token. The correct client response is to request a new connect token from the web backend, which is already the response to those states. ### Sending Challenge Response @@ -456,7 +454,7 @@ The server takes the following steps, in this exact order, when processing a _co * If the connect token expire timestamp is <= the current timestamp, ignore the packet. -* OPTIONAL. If the server implements the connect token restart mitigation described in _Nonce Reuse and Server Restarts_, and the connect token expire timestamp is earlier than the server start time plus the maximum connect token lifetime issued by the deployment, ignore the packet. This check belongs here, immediately after the expiry check and before the decrypt, because it uses only the unencrypted expire timestamp and so costs nothing on a request that is going to be rejected anyway. A server that does not implement the mitigation omits this step entirely. +* If the connect token expire timestamp minus the maximum connect token lifetime issued by the deployment is earlier than the server start time, ignore the packet, as required by _Nonce Reuse and Server Restarts_. This check belongs here, immediately after the expiry check and before the decrypt, because it uses only the unencrypted expire timestamp and so costs nothing on a request that is going to be rejected anyway. * If the encrypted private connect token data doesn't decrypt with the private key, using the associated data constructed from: version info, protocol id and expire timestamp, ignore the packet. @@ -468,9 +466,9 @@ The server takes the following steps, in this exact order, when processing a _co * If a client with the client id contained in the private connect token data is already connected, ignore the packet. -* If the connect token has already been used by a different packet source IP address and port, ignore the packet. +* If the history of connect tokens already used does not admit this connection request, as defined in _Connect Token History_ below, ignore the packet. -* Otherwise, add the private connect token hmac + packet source IP address and port to the history of connect tokens already used. +* Otherwise, the history holds a pending entry for the private connect token hmac and the packet source IP address and port. * If no client slots are available, then the server is full. Respond with a _connection denied packet_. @@ -480,6 +478,26 @@ The server takes the following steps, in this exact order, when processing a _co * Otherwise, respond with a _connection challenge packet_ and increment the _connection challenge sequence number_. +### Connect Token History + +The server keeps a history of the connect tokens it has already seen. Each entry holds the private connect token hmac, the packet source IP address and port that presented it, the connect token expire timestamp, the time the entry was created, and a state, which is either _pending_ or _consumed_. + +* An entry is created in the _pending_ state when the server accepts a connection request presenting a connect token that is not already in the history. + +* An entry becomes _consumed_ when the server accepts the connection response for its connect token and assigns the client to a client slot. The server associates the entry with the connection it is establishing, for example through the encryption mapping it added for that packet source IP address and port, so nothing extra is carried on the wire. + +* A _pending_ entry admits a connection request presenting its connect token from the same packet source IP address and port, and no other. This is what lets a client retransmit its connection request while the handshake is in progress. + +* A _consumed_ entry admits nothing. A connection request presenting its connect token is ignored whatever the packet source IP address and port, and whether or not the client is still in its slot. A connect token is therefore usable for exactly one connection, and the keys it carries encrypt exactly one session. + +* An entry lives until its connect token expires. Once the entry's connect token expire timestamp has passed, the server may reuse the entry for another connect token, because a request presenting the expired token is already ignored by the expiry check. + +* When every entry in the history holds an unexpired connect token, the server ignores a connection request presenting a connect token that is not already in the history. A full history refuses new connect tokens, and never evicts an unexpired entry to make room, so a flood of connect tokens cannot reopen one that has been used. + +* An entry's time is set when the entry is created, and is never refreshed afterwards. Neither a retransmitted connection request nor the transition to _consumed_ changes it. + +The size of the history is implementation specific. It bounds the number of distinct connect tokens a server accepts within one connect token lifetime, so it should be comfortably larger than the number of client slots. + ### Processing Connection Response Packets When the client receives a _connection challenge packet_ from the server it responds with a _connection response packet_. @@ -503,6 +521,8 @@ The server takes these steps, in this exact order, when processing a _connection * Assign the packet IP address + port and client id to a free client slot and mark that client as connected. +* Mark the connect token history entry created for the connection request that produced this challenge as consumed. + * Copy across the user data from the challenge token into the client slot so it is accessible to the server application. * Set the _confirmed_ flag for that client slot to false. diff --git a/compat/c/compat.c b/compat/c/compat.c index f21c75b..8ea4c76 100644 --- a/compat/c/compat.c +++ b/compat/c/compat.c @@ -347,7 +347,7 @@ static int do_verify( const char * dir ) if ( !read_file( dir, "packet_request.bin", buffer, bytes ) ) return 0; struct netcode_connection_request_packet_t * packet = (struct netcode_connection_request_packet_t*) - netcode_read_packet( buffer, bytes, &sequence, packet_key, GOLDEN_PROTOCOL_ID, 0, token_key, allowed_packets, NULL, NULL, NULL ); + netcode_read_packet( buffer, bytes, &sequence, packet_key, GOLDEN_PROTOCOL_ID, 0, 0, token_key, allowed_packets, NULL, NULL, NULL ); VERIFY( packet != NULL ); VERIFY( packet->packet_type == NETCODE_CONNECTION_REQUEST_PACKET ); VERIFY( packet->protocol_id == GOLDEN_PROTOCOL_ID ); @@ -359,7 +359,7 @@ static int do_verify( const char * dir ) int bytes = 1 + 8 + NETCODE_MAC_BYTES; /* prefix + 8 sequence bytes + mac */ if ( !read_file( dir, "packet_denied.bin", buffer, bytes ) ) return 0; - void * packet = netcode_read_packet( buffer, bytes, &sequence, packet_key, GOLDEN_PROTOCOL_ID, 0, NULL, allowed_packets, NULL, NULL, NULL ); + void * packet = netcode_read_packet( buffer, bytes, &sequence, packet_key, GOLDEN_PROTOCOL_ID, 0, 0, NULL, allowed_packets, NULL, NULL, NULL ); VERIFY( packet != NULL ); VERIFY( ( (uint8_t*) packet )[0] == NETCODE_CONNECTION_DENIED_PACKET ); VERIFY( sequence == 1ULL << 63 ); @@ -371,7 +371,7 @@ static int do_verify( const char * dir ) if ( !read_file( dir, "packet_challenge.bin", buffer, bytes ) ) return 0; struct netcode_connection_challenge_packet_t * packet = (struct netcode_connection_challenge_packet_t*) - netcode_read_packet( buffer, bytes, &sequence, packet_key, GOLDEN_PROTOCOL_ID, 0, NULL, allowed_packets, NULL, NULL, NULL ); + netcode_read_packet( buffer, bytes, &sequence, packet_key, GOLDEN_PROTOCOL_ID, 0, 0, NULL, allowed_packets, NULL, NULL, NULL ); VERIFY( packet != NULL ); VERIFY( packet->packet_type == NETCODE_CONNECTION_CHALLENGE_PACKET ); VERIFY( packet->challenge_token_sequence == GOLDEN_CHALLENGE_SEQUENCE ); @@ -384,7 +384,7 @@ static int do_verify( const char * dir ) if ( !read_file( dir, "packet_response.bin", buffer, bytes ) ) return 0; struct netcode_connection_response_packet_t * packet = (struct netcode_connection_response_packet_t*) - netcode_read_packet( buffer, bytes, &sequence, packet_key, GOLDEN_PROTOCOL_ID, 0, NULL, allowed_packets, NULL, NULL, NULL ); + netcode_read_packet( buffer, bytes, &sequence, packet_key, GOLDEN_PROTOCOL_ID, 0, 0, NULL, allowed_packets, NULL, NULL, NULL ); VERIFY( packet != NULL ); VERIFY( packet->packet_type == NETCODE_CONNECTION_RESPONSE_PACKET ); VERIFY( packet->challenge_token_sequence == GOLDEN_CHALLENGE_SEQUENCE ); @@ -397,7 +397,7 @@ static int do_verify( const char * dir ) if ( !read_file( dir, "packet_keepalive.bin", buffer, bytes ) ) return 0; struct netcode_connection_keep_alive_packet_t * packet = (struct netcode_connection_keep_alive_packet_t*) - netcode_read_packet( buffer, bytes, &sequence, packet_key, GOLDEN_PROTOCOL_ID, 0, NULL, allowed_packets, NULL, NULL, NULL ); + netcode_read_packet( buffer, bytes, &sequence, packet_key, GOLDEN_PROTOCOL_ID, 0, 0, NULL, allowed_packets, NULL, NULL, NULL ); VERIFY( packet != NULL ); VERIFY( packet->packet_type == NETCODE_CONNECTION_KEEP_ALIVE_PACKET ); VERIFY( packet->client_index == 5 ); @@ -411,7 +411,7 @@ static int do_verify( const char * dir ) if ( !read_file( dir, "packet_payload.bin", buffer, bytes ) ) return 0; struct netcode_connection_payload_packet_t * packet = (struct netcode_connection_payload_packet_t*) - netcode_read_packet( buffer, bytes, &sequence, packet_key, GOLDEN_PROTOCOL_ID, 0, NULL, allowed_packets, NULL, NULL, NULL ); + netcode_read_packet( buffer, bytes, &sequence, packet_key, GOLDEN_PROTOCOL_ID, 0, 0, NULL, allowed_packets, NULL, NULL, NULL ); VERIFY( packet != NULL ); VERIFY( packet->packet_type == NETCODE_CONNECTION_PAYLOAD_PACKET ); VERIFY( packet->payload_bytes == NETCODE_MAX_PAYLOAD_BYTES ); @@ -426,7 +426,7 @@ static int do_verify( const char * dir ) int bytes = 1 + 1 + NETCODE_MAC_BYTES; if ( !read_file( dir, "packet_disconnect.bin", buffer, bytes ) ) return 0; - void * packet = netcode_read_packet( buffer, bytes, &sequence, packet_key, GOLDEN_PROTOCOL_ID, 0, NULL, allowed_packets, NULL, NULL, NULL ); + void * packet = netcode_read_packet( buffer, bytes, &sequence, packet_key, GOLDEN_PROTOCOL_ID, 0, 0, NULL, allowed_packets, NULL, NULL, NULL ); VERIFY( packet != NULL ); VERIFY( ( (uint8_t*) packet )[0] == NETCODE_CONNECTION_DISCONNECT_PACKET ); VERIFY( sequence == 7 ); diff --git a/notes/port-map.md b/notes/port-map.md index c5d8f07..0730860 100644 --- a/notes/port-map.md +++ b/notes/port-map.md @@ -1,7 +1,7 @@ # netcode.cs port map C# port of [mas-bandwidth/netcode](https://github.com/mas-bandwidth/netcode) -(C reference, version 1.4.3, protocol "NETCODE 1.02"). The WIRE is the C +(C reference, version 1.4.8, protocol "NETCODE 1.02"). The WIRE is the C library's, byte-exact; the API is idiomatic C#, mirroring the C library's non-blocking update-loop model (games call `Update()` each frame — no async redesign). @@ -18,7 +18,7 @@ redesign). | `netcode_replay_protection_t` | `sealed class ReplayProtection` (internal) | 256-entry window, identical overflow-safe compare | | `netcode_packet_queue_t` | `sealed class PacketQueue` (internal) | ring of 256; payload buffers come from a private fixed pool, not the GC | | `netcode_encryption_manager_t` | `sealed class EncryptionManager` (internal) | flat arrays + linear scan, same as C — deliberate (attacker controls addresses; linear is the hardened choice, per upstream) | -| `netcode_connect_token_entry_t[]` | inside `Server` | constant-time worst-case scan preserved (timing must not leak whether a token was seen) | +| `netcode_connect_token_entry_t[]` | inside `Server` | constant-time worst-case scan; pending/consumed state, expiry timestamps, and history index carried by the encryption mapping | | `netcode_network_simulator_t` | `public sealed class NetworkSimulator` | same xorshift64* RNG, same seed, so loss/jitter sequences match the C library run-for-run | | `netcode_client_t` | `public sealed class Client` | states as `public enum ClientState` (same numeric values −6..3) | | `netcode_server_t` | `public sealed class Server` | flat per-client arrays, max 256 clients, global sequence seeded `1UL<<63` on start AND stop→start (nonce-space separation — upstream 1.4.0 fix, every seeding site kept) | @@ -115,8 +115,9 @@ token-reuse → full? denied : add-mapping → challenge). `expire_time` (token timeout after request, cleared to −1 on connect). - Global packet sequence starts at `1UL<<63` on every `Server.Start` (nonce disjointness vs per-client sequences starting at 0 under the same key). -- Connect token single-use history: constant-time worst-case scan, replace - oldest, same-address re-use allowed. +- Connect token single-use history: constant-time worst-case scan; pending and + consumed states; expiry timestamps; pending same-address retransmits only; + no eviction while entries are unexpired; entry time is never refreshed. - Client timeout uses `last_packet_receive_time + timeout < time`; server uses `<= time`. (Yes, they differ in the C source; ported as-is.) diff --git a/src/Client.cs b/src/Client.cs index 056abfe..4692e18 100644 --- a/src/Client.cs +++ b/src/Client.cs @@ -52,6 +52,8 @@ public enum ClientCreateError CreateSocketIpv4Failed = 4, /// The IPv6 socket could not be created or bound. CreateSocketIpv6Failed = 5, + /// OverrideSendAndReceive was set without both override callbacks. + MissingOverrideCallback = 7, } /// Thrown when a Client or Server cannot be created (bad bind address, socket failure). @@ -153,6 +155,14 @@ public Client(string bindAddress1, string? bindAddress2, ClientConfig? config, d _config = config ?? new ClientConfig(); _simulatorReceiveHandler = ProcessPacketFromSimulator; + // the overrides are called on the update path with no null check. a missing one is a + // configuration error, refused here rather than dereferenced on the first update. + if (_config.OverrideSendAndReceive && (_config.SendPacketOverride == null || _config.ReceivePacketOverride == null)) + { + NetcodeLog.Error("error: override_send_and_receive requires both send_packet_override and receive_packet_override\n"); + throw new NetcodeException("override_send_and_receive requires both send_packet_override and receive_packet_override", (int)ClientCreateError.MissingOverrideCallback); + } + if (!Address.TryParse(bindAddress1, out Address address1)) { NetcodeLog.Error("error: failed to parse client address\n"); @@ -778,6 +788,15 @@ public void ConnectLoopback(int clientIndex, int maxClients) { if (_state > ClientState.Disconnected) throw new InvalidOperationException("client must be disconnected before connecting loopback"); + + // a loopback client sends only through this callback. without it the first send would + // call a null pointer, so refuse to enter loopback at all, in every build. + if (_config.SendLoopbackPacket == null) + { + NetcodeLog.Error("error: a loopback client requires send_loopback_packet_callback\n"); + return; + } + NetcodeLog.Info($"client connected to server via loopback as client {clientIndex}\n"); _state = ClientState.Connected; _clientIndex = clientIndex; diff --git a/src/Netcode.cs b/src/Netcode.cs index 8ced7d7..00ca4c0 100644 --- a/src/Netcode.cs +++ b/src/Netcode.cs @@ -46,6 +46,8 @@ public static class Protocol public const int MaxServersPerConnect = 32; /// Maximum number of client slots per server. public const int MaxClients = 256; + /// Default maximum lifetime, in seconds, of connect tokens issued by the backend. + public const int DefaultMaxConnectTokenLifetime = 30; /// Largest payload accepted by SendPacket / delivered by ReceivePacket. public const int MaxPacketSize = 1200; /// The protocol version string carried on the wire (13 bytes with null terminator). @@ -890,6 +892,7 @@ internal struct ReadPacketResult { public int Type; // -1 when the packet was rejected public ulong Sequence; + public ulong ConnectTokenExpireTimestamp; public int DataOffset; // offset of decrypted per-packet-type data in the buffer public int DataLength; // length of decrypted per-packet-type data } @@ -996,7 +999,8 @@ public static ReadPacketResult ReadPacket( bool hasPrivateKey, ReadOnlySpan privateKey, ReadOnlySpan allowedPackets, - ReplayProtection? replayProtection) + ReplayProtection? replayProtection, + ulong minConnectTokenExpireTimestamp = 0) { ReadPacketResult result = default; result.Type = -1; @@ -1050,6 +1054,12 @@ public static ReadPacketResult ReadPacket( return result; } + if (packetExpireTimestamp < minConnectTokenExpireTimestamp) + { + NetcodeLog.Debug("ignored connection request packet. connect token predates the server start\n"); + return result; + } + ReadOnlySpan packetNonce = buffer.Slice(1 + Defines.VersionInfoBytes + 8 + 8, Defines.ConnectTokenNonceBytes); int tokenOffset = 1 + Defines.VersionInfoBytes + 8 + 8 + Defines.ConnectTokenNonceBytes; @@ -1064,6 +1074,7 @@ public static ReadPacketResult ReadPacket( result.Type = PacketType.ConnectionRequest; result.Sequence = 0; + result.ConnectTokenExpireTimestamp = packetExpireTimestamp; result.DataOffset = tokenOffset; result.DataLength = Defines.ConnectTokenPrivateBytes; return result; @@ -1501,6 +1512,7 @@ internal sealed class EncryptionManager public readonly double[] LastAccessTime = new double[MaxEncryptionMappings]; public readonly Address[] Address = new Address[MaxEncryptionMappings]; public readonly int[] ClientIndex = new int[MaxEncryptionMappings]; + public readonly int[] ConnectTokenEntryIndex = new int[MaxEncryptionMappings]; public readonly byte[] SendKey = new byte[Protocol.KeyBytes * MaxEncryptionMappings]; public readonly byte[] ReceiveKey = new byte[Protocol.KeyBytes * MaxEncryptionMappings]; @@ -1518,6 +1530,7 @@ public void Reset() for (int i = 0; i < MaxEncryptionMappings; i++) { ClientIndex[i] = -1; + ConnectTokenEntryIndex[i] = -1; ExpireTime[i] = -1.0; LastAccessTime[i] = -1000.0; Address[i] = default; @@ -1534,7 +1547,7 @@ public bool EntryExpired(int index, double time) (ExpireTime[index] >= 0.0 && ExpireTime[index] < time); } - public bool AddEncryptionMapping(in Address address, ReadOnlySpan sendKey, ReadOnlySpan receiveKey, double time, double expireTime, int timeout) + public bool AddEncryptionMapping(in Address address, ReadOnlySpan sendKey, ReadOnlySpan receiveKey, double time, double expireTime, int timeout, int connectTokenEntryIndex = -1) { for (int i = 0; i < NumEncryptionMappings; i++) { @@ -1543,6 +1556,7 @@ public bool AddEncryptionMapping(in Address address, ReadOnlySpan sendKey, Timeout[i] = timeout; ExpireTime[i] = expireTime; LastAccessTime[i] = time; + ConnectTokenEntryIndex[i] = connectTokenEntryIndex; sendKey.CopyTo(SendKey.AsSpan(i * Protocol.KeyBytes, Protocol.KeyBytes)); receiveKey.CopyTo(ReceiveKey.AsSpan(i * Protocol.KeyBytes, Protocol.KeyBytes)); return true; @@ -1558,6 +1572,7 @@ public bool AddEncryptionMapping(in Address address, ReadOnlySpan sendKey, Address[i] = address; ExpireTime[i] = expireTime; LastAccessTime[i] = time; + ConnectTokenEntryIndex[i] = connectTokenEntryIndex; sendKey.CopyTo(SendKey.AsSpan(i * Protocol.KeyBytes, Protocol.KeyBytes)); receiveKey.CopyTo(ReceiveKey.AsSpan(i * Protocol.KeyBytes, Protocol.KeyBytes)); if (i + 1 > NumEncryptionMappings) @@ -1577,6 +1592,7 @@ public bool RemoveEncryptionMapping(in Address address, double time) { ExpireTime[i] = -1.0; LastAccessTime[i] = -1000.0; + ConnectTokenEntryIndex[i] = -1; Address[i] = default; SendKey.AsSpan(i * Protocol.KeyBytes, Protocol.KeyBytes).Clear(); ReceiveKey.AsSpan(i * Protocol.KeyBytes, Protocol.KeyBytes).Clear(); @@ -1627,6 +1643,13 @@ public void SetExpireTime(int index, double expireTime) ExpireTime[index] = expireTime; } + public int GetConnectTokenEntryIndex(int index) + { + if (index == -1) + return -1; + return ConnectTokenEntryIndex[index]; + } + public Span GetSendKey(int index) { if (index == -1) diff --git a/src/Server.cs b/src/Server.cs index 2e8fc79..a08c0a7 100644 --- a/src/Server.cs +++ b/src/Server.cs @@ -16,6 +16,84 @@ linear is the hardened choice. namespace Netcode { + internal enum ConnectTokenEntryState + { + Free = 0, + Pending = 1, + Consumed = 2, + } + + internal sealed class ConnectTokenHistory + { + public const int MaxEntries = Protocol.MaxClients * 8; + public const int Refused = -1; + public const int Full = -2; + + private readonly ConnectTokenEntryState[] _state = new ConnectTokenEntryState[MaxEntries]; + private readonly double[] _time = new double[MaxEntries]; + private readonly ulong[] _expireTimestamp = new ulong[MaxEntries]; + private readonly byte[] _mac = new byte[MaxEntries * Protocol.MacBytes]; + private readonly Address[] _address = new Address[MaxEntries]; + + public ConnectTokenHistory() => Reset(); + + public void Reset() + { + Array.Clear(_state); + Array.Clear(_expireTimestamp); + Array.Clear(_mac); + Array.Clear(_address); + for (int i = 0; i < MaxEntries; i++) + _time[i] = -1000.0; + } + + public int FindOrAdd(in Address address, ReadOnlySpan mac, ulong expireTimestamp, ulong currentTimestamp, double time) + { + int matchingTokenIndex = -1; + int freeTokenIndex = -1; + + for (int i = 0; i < MaxEntries; i++) + { + if (_state[i] != ConnectTokenEntryState.Free && + mac.SequenceEqual(_mac.AsSpan(i * Protocol.MacBytes, Protocol.MacBytes))) + matchingTokenIndex = i; + + if (freeTokenIndex == -1 && + (_state[i] == ConnectTokenEntryState.Free || _expireTimestamp[i] <= currentTimestamp)) + freeTokenIndex = i; + } + + if (matchingTokenIndex == -1) + { + if (freeTokenIndex == -1) + return Full; + + _state[freeTokenIndex] = ConnectTokenEntryState.Pending; + _time[freeTokenIndex] = time; + _expireTimestamp[freeTokenIndex] = expireTimestamp; + _address[freeTokenIndex] = address; + mac.CopyTo(_mac.AsSpan(freeTokenIndex * Protocol.MacBytes, Protocol.MacBytes)); + return freeTokenIndex; + } + + if (_state[matchingTokenIndex] == ConnectTokenEntryState.Pending && + _address[matchingTokenIndex].Equals(address)) + return matchingTokenIndex; + + return Refused; + } + + public void Consume(int index) + { + if ((uint)index >= MaxEntries) + throw new ArgumentOutOfRangeException(nameof(index)); + _state[index] = ConnectTokenEntryState.Consumed; + } + + public ConnectTokenEntryState State(int index) => _state[index]; + public double EntryTime(int index) => _time[index]; + } + /// Why server creation failed (carried by NetcodeException.ErrorCode). Bind failures /// are reported separately because a port already in use is the common operational failure. public enum ServerCreateError @@ -34,6 +112,8 @@ public enum ServerCreateError BindSocketIpv4Failed = 5, /// The IPv6 socket could not be bound (port likely in use). BindSocketIpv6Failed = 6, + /// OverrideSendAndReceive was set without both override callbacks. + MissingOverrideCallback = 8, } /// Why the client in a server slot was last disconnected. Recorded before the @@ -70,6 +150,8 @@ public sealed class ServerConfig public SendPacketOverrideDelegate? SendPacketOverride; /// Incoming packet hook (used when OverrideSendAndReceive is true). public ReceivePacketOverrideDelegate? ReceivePacketOverride; + /// The longest lifetime, in seconds, of connect tokens issued by the backend. Values at or below zero use the default. + public int MaxConnectTokenLifetime = Protocol.DefaultMaxConnectTokenLifetime; /// Tag outgoing packets DSCP EF (low latency). Off by default; no-op on Windows. public bool EnablePacketTagging; } @@ -84,8 +166,6 @@ public sealed class Server : IDisposable internal const uint FlagIgnoreConnectionRequestPackets = 1; internal const uint FlagIgnoreConnectionResponsePackets = 1 << 1; - private const int MaxConnectTokenEntries = Protocol.MaxClients * 8; - private static readonly bool[] AllowedPackets = BuildAllowedPackets(); private static bool[] BuildAllowedPackets() @@ -112,6 +192,8 @@ private static bool[] BuildAllowedPackets() private int _numConnectedClients; private ulong _globalSequence; private ulong _challengeSequence; + private readonly int _maxConnectTokenLifetime; + private ulong _minConnectTokenExpireTimestamp; private readonly byte[] _challengeKey = new byte[Protocol.KeyBytes]; private readonly bool[] _clientConnected = new bool[Protocol.MaxClients]; private readonly int[] _clientTimeout = new int[Protocol.MaxClients]; @@ -130,9 +212,7 @@ private static bool[] BuildAllowedPackets() // connect token single-use history. the find-or-add scan is constant time // worst case on purpose: timing must not leak whether a token was seen. - private readonly double[] _connectTokenEntryTime = new double[MaxConnectTokenEntries]; - private readonly byte[] _connectTokenEntryMac = new byte[MaxConnectTokenEntries * Protocol.MacBytes]; - private readonly Address[] _connectTokenEntryAddress = new Address[MaxConnectTokenEntries]; + private readonly ConnectTokenHistory _connectTokenHistory = new ConnectTokenHistory(); private readonly EncryptionManager _encryptionManager = new EncryptionManager(); @@ -160,9 +240,20 @@ public Server(string serverAddress1, string? serverAddress2, ServerConfig config throw new ArgumentException($"config.PrivateKey must be {Protocol.KeyBytes} bytes", nameof(config)); _config = config; + _maxConnectTokenLifetime = config.MaxConnectTokenLifetime > 0 + ? config.MaxConnectTokenLifetime + : Protocol.DefaultMaxConnectTokenLifetime; config.PrivateKey.CopyTo(_privateKey.AsSpan()); _simulatorReceiveHandler = ProcessPacketFromSimulator; + // the overrides are called on the update path with no null check. a missing one is a + // configuration error, refused here rather than dereferenced on the first update. + if (_config.OverrideSendAndReceive && (_config.SendPacketOverride == null || _config.ReceivePacketOverride == null)) + { + NetcodeLog.Error("error: override_send_and_receive requires both send_packet_override and receive_packet_override\n"); + throw new NetcodeException("override_send_and_receive requires both send_packet_override and receive_packet_override", (int)ServerCreateError.MissingOverrideCallback); + } + if (!Address.TryParse(serverAddress1, out Address address1)) { NetcodeLog.Error("error: failed to parse server public address\n"); @@ -291,6 +382,8 @@ public void Start(int maxClients) _challengeSequence = 0; Rng.GenerateKey(_challengeKey); + _minConnectTokenExpireTimestamp = (ulong)DateTimeOffset.UtcNow.ToUnixTimeSeconds() + (ulong)_maxConnectTokenLifetime; + // global packets (challenge, denied) encrypt with the same per-token server to // client keys as per-client packets, whose sequences start at zero, so the global // sequence lives in the top half of the sequence space to keep AEAD nonces @@ -326,6 +419,7 @@ public void Stop() _globalSequence = 0; _challengeSequence = 0; + _minConnectTokenExpireTimestamp = 0; Array.Clear(_challengeKey); ConnectTokenEntriesReset(); @@ -341,51 +435,12 @@ public void Stop() private void ConnectTokenEntriesReset() { - for (int i = 0; i < MaxConnectTokenEntries; i++) - { - _connectTokenEntryTime[i] = -1000.0; - _connectTokenEntryAddress[i] = default; - } - Array.Clear(_connectTokenEntryMac); + _connectTokenHistory.Reset(); } - private bool ConnectTokenEntriesFindOrAdd(in Address address, ReadOnlySpan mac, double time) + private int ConnectTokenEntriesFindOrAdd(in Address address, ReadOnlySpan mac, ulong expireTimestamp, ulong currentTimestamp, double time) { - // find the matching entry for the token mac and the oldest token entry. - // constant time worst case. This is intentional! - - int matchingTokenIndex = -1; - int oldestTokenIndex = -1; - double oldestTokenTime = 0.0; - - for (int i = 0; i < MaxConnectTokenEntries; i++) - { - if (mac.SequenceEqual(_connectTokenEntryMac.AsSpan(i * Protocol.MacBytes, Protocol.MacBytes))) - matchingTokenIndex = i; - - if (oldestTokenIndex == -1 || _connectTokenEntryTime[i] < oldestTokenTime) - { - oldestTokenTime = _connectTokenEntryTime[i]; - oldestTokenIndex = i; - } - } - - // if no entry is found with the mac, this is a new connect token. replace the oldest. - - if (matchingTokenIndex == -1) - { - _connectTokenEntryTime[oldestTokenIndex] = time; - _connectTokenEntryAddress[oldestTokenIndex] = address; - mac.CopyTo(_connectTokenEntryMac.AsSpan(oldestTokenIndex * Protocol.MacBytes, Protocol.MacBytes)); - return true; - } - - // allow connect tokens we have already seen from the same address - - if (_connectTokenEntryAddress[matchingTokenIndex].Equals(address)) - return true; - - return false; + return _connectTokenHistory.FindOrAdd(in address, mac, expireTimestamp, currentTimestamp, time); } // -------------------------------------------------------------- @@ -580,6 +635,10 @@ private void ConnectClient(int clientIndex, in Address address, ulong clientId, _clientLastPacketReceiveTime[clientIndex] = _time; userData.CopyTo(_clientUserData.AsSpan(clientIndex * Protocol.UserDataBytes, Protocol.UserDataBytes)); + int connectTokenEntryIndex = _encryptionManager.GetConnectTokenEntryIndex(encryptionIndex); + if (connectTokenEntryIndex >= 0) + _connectTokenHistory.Consume(connectTokenEntryIndex); + if (NetcodeLog.Enabled(LogLevel.Info)) NetcodeLog.Info($"server accepted client {address} {clientId:x16} in slot {clientIndex}\n"); @@ -592,7 +651,7 @@ private void ConnectClient(int clientIndex, in Address address, ulong clientId, // packet processing // -------------------------------------------------------------- - private void ProcessConnectionRequestPacket(in Address from, Span decryptedToken) + private void ProcessConnectionRequestPacket(in Address from, Span decryptedToken, ulong connectTokenExpireTimestamp, ulong currentTimestamp) { var connectTokenPrivate = new ConnectTokenPrivate(); if (!connectTokenPrivate.Read(decryptedToken)) @@ -628,10 +687,18 @@ private void ProcessConnectionRequestPacket(in Address from, Span decrypte } // the private token's HMAC survives in-place decryption in the last 16 bytes - if (!ConnectTokenEntriesFindOrAdd( + int connectTokenEntryIndex = ConnectTokenEntriesFindOrAdd( in from, decryptedToken.Slice(Defines.ConnectTokenPrivateBytes - Protocol.MacBytes, Protocol.MacBytes), - _time)) + connectTokenExpireTimestamp, + currentTimestamp, + _time); + if (connectTokenEntryIndex == ConnectTokenHistory.Full) + { + NetcodeLog.Debug("server ignored connection request. connect token history is full\n"); + return; + } + if (connectTokenEntryIndex == ConnectTokenHistory.Refused) { NetcodeLog.Debug("server ignored connection request. connect token has already been used\n"); return; @@ -652,7 +719,8 @@ private void ProcessConnectionRequestPacket(in Address from, Span decrypte connectTokenPrivate.ClientToServerKey, _time, expireTime, - connectTokenPrivate.TimeoutSeconds)) + connectTokenPrivate.TimeoutSeconds, + connectTokenEntryIndex)) { NetcodeLog.Debug("server ignored connection request. failed to add encryption mapping\n"); return; @@ -726,7 +794,7 @@ private void ProcessConnectionResponsePacket(in Address from, Span packetD ConnectClient(clientIndex, in from, challengeClientId, encryptionIndex, timeoutSeconds, challengeUserData); } - private void ProcessPacketInternal(in Address from, in ReadPacketResult packet, Span buffer, int encryptionIndex, int clientIndex) + private void ProcessPacketInternal(in Address from, in ReadPacketResult packet, Span buffer, int encryptionIndex, int clientIndex, ulong currentTimestamp) { switch (packet.Type) { @@ -736,7 +804,7 @@ private void ProcessPacketInternal(in Address from, in ReadPacketResult packet, { if (NetcodeLog.Enabled(LogLevel.Debug)) NetcodeLog.Debug($"server received connection request from {from}\n"); - ProcessConnectionRequestPacket(in from, buffer.Slice(packet.DataOffset, packet.DataLength)); + ProcessConnectionRequestPacket(in from, buffer.Slice(packet.DataOffset, packet.DataLength), packet.ConnectTokenExpireTimestamp, currentTimestamp); } } break; @@ -840,12 +908,13 @@ private void ReadAndProcessPacket(in Address from, Span packetData, ulong hasPrivateKey: true, _privateKey, AllowedPackets, - clientIndex != -1 ? _clientReplayProtection[clientIndex] : null); + clientIndex != -1 ? _clientReplayProtection[clientIndex] : null, + _minConnectTokenExpireTimestamp); if (packet.Type < 0) return; - ProcessPacketInternal(in from, in packet, packetData, encryptionIndex, clientIndex); + ProcessPacketInternal(in from, in packet, packetData, encryptionIndex, clientIndex, currentTimestamp); } /// @@ -1081,6 +1150,14 @@ public int ReceivePacket(int clientIndex, Span payload, out ulong sequence /// Attach a loopback client to a slot: payloads flow through the loopback callbacks, no networking. public void ConnectLoopbackClient(int clientIndex, ulong clientId, ReadOnlySpan userData) { + // the server sends to a loopback client only through this callback. without it the + // first send would call a null pointer, so refuse the slot at all, in every build. + if (_config.SendLoopbackPacket == null) + { + NetcodeLog.Error("error: a loopback client requires send_loopback_packet_callback\n"); + return; + } + if (!_running) return; diff --git a/tests/ClientServer.cs b/tests/ClientServer.cs index 41d5b03..01cdf7b 100644 --- a/tests/ClientServer.cs +++ b/tests/ClientServer.cs @@ -146,6 +146,107 @@ public static void TestServerRestartGlobalSequence() Check.That(server.GlobalSequence == 1UL << 63); } + public static void TestConnectTokenHistory() + { + var history = new ConnectTokenHistory(); + Check.That(Address.TryParse("[::1]:50000", out Address addressA)); + Check.That(Address.TryParse("[::1]:50001", out Address addressB)); + + ulong currentTimestamp = 1000; + ulong expireTimestamp = currentTimestamp + 30; + byte[] mac = new byte[Protocol.MacBytes]; + mac[0] = 1; + + int index = history.FindOrAdd(in addressA, mac, expireTimestamp, currentTimestamp, 100.0); + Check.That(index >= 0); + Check.That(history.State(index) == ConnectTokenEntryState.Pending); + Check.That(history.EntryTime(index) == 100.0); + + Check.That(history.FindOrAdd(in addressA, mac, expireTimestamp, currentTimestamp, 200.0) == index); + Check.That(history.EntryTime(index) == 100.0); + Check.That(history.FindOrAdd(in addressB, mac, expireTimestamp, currentTimestamp, 200.0) == ConnectTokenHistory.Refused); + + history.Consume(index); + Check.That(history.State(index) == ConnectTokenEntryState.Consumed); + Check.That(history.FindOrAdd(in addressA, mac, expireTimestamp, currentTimestamp, 300.0) == ConnectTokenHistory.Refused); + Check.That(history.FindOrAdd(in addressB, mac, expireTimestamp, currentTimestamp, 300.0) == ConnectTokenHistory.Refused); + + for (int i = 1; i < ConnectTokenHistory.MaxEntries; i++) + { + mac = new byte[Protocol.MacBytes]; + mac[0] = (byte)(i + 1); + mac[1] = (byte)((i + 1) >> 8); + Check.That(history.FindOrAdd(in addressA, mac, expireTimestamp, currentTimestamp, 400.0) >= 0); + } + + mac = new byte[Protocol.MacBytes]; + mac[0] = 0xFF; + mac[1] = 0xFF; + Check.That(history.FindOrAdd(in addressA, mac, expireTimestamp, currentTimestamp, 500.0) == ConnectTokenHistory.Full); + + mac[0] = 1; + mac[1] = 0; + Check.That(history.FindOrAdd(in addressA, mac, expireTimestamp, currentTimestamp, 500.0) == ConnectTokenHistory.Refused); + + mac[0] = 0xFF; + mac[1] = 0xFF; + Check.That(history.FindOrAdd(in addressA, mac, expireTimestamp, expireTimestamp, 600.0) >= 0); + } + + public static void TestClientReconnectUsedConnectToken() + { + NetworkSimulator simulator = CreateSimulator(); + double time = 0.0; + double deltaTime = 1.0 / 10.0; + using var server = new Server("[::1]:40000", CreateServerConfig(simulator), time); + using var client = new Client("[::]:50000", new ClientConfig { Simulator = simulator }, time); + server.Start(1); + + byte[] connectToken = GenerateConnectToken("[::1]:40000", 0x1234); + client.Connect(connectToken); + ConnectLoop(simulator, client, server, ref time, deltaTime); + Check.That(client.State == ClientState.Connected); + + server.DisconnectClient(0); + while (client.State > ClientState.Disconnected) + { + simulator.Update(time); + client.Update(time); + server.Update(time); + time += deltaTime; + } + + simulator.Reset(); + client.Connect(connectToken); + ConnectLoop(simulator, client, server, ref time, deltaTime); + + Check.That(client.State == ClientState.ConnectionRequestTimedOut); + Check.That(server.NumConnectedClients == 0); + } + + public static void TestClientErrorConnectTokenPredatesServerStart() + { + NetworkSimulator simulator = CreateSimulator(); + double time = 0.0; + double deltaTime = 1.0 / 10.0; + var serverConfig = CreateServerConfig(simulator); + serverConfig.MaxConnectTokenLifetime = 30; + using var server = new Server("[::1]:40000", serverConfig, time); + using var client = new Client("[::]:50000", new ClientConfig { Simulator = simulator }, time); + server.Start(1); + + client.Connect(GenerateConnectToken("[::1]:40000", 0x1234, expiry: 20)); + ConnectLoop(simulator, client, server, ref time, deltaTime); + Check.That(client.State == ClientState.ConnectionRequestTimedOut); + Check.That(server.NumConnectedClients == 0); + + simulator.Reset(); + client.Connect(GenerateConnectToken("[::1]:40000", 0x5678, expiry: 30)); + ConnectLoop(simulator, client, server, ref time, deltaTime); + Check.That(client.State == ClientState.Connected); + Check.That(server.NumConnectedClients == 1); + } + public static void TestClientServerConnect() { NetworkSimulator simulator = CreateSimulator(); @@ -1063,6 +1164,108 @@ public static void TestLoopback() Check.That(server.NumConnectedClients == 1); Check.That(loopbackClient.State == ClientState.Disconnected); } + + public static void TestClientCreateMissingOverrideCallback() + { + // override_send_and_receive with either override callback missing is refused at + // create time with the new code, rather than calling null on the first update. + + var config = new ClientConfig + { + OverrideSendAndReceive = true, + SendPacketOverride = (in Address to, ReadOnlySpan payload) => { }, + }; + + try + { + using var client = new Client("127.0.0.1:40000", config); + Check.That(false); + } + catch (NetcodeException e) + { + Check.That(e.ErrorCode == (int)ClientCreateError.MissingOverrideCallback); + } + + config.SendPacketOverride = null; + config.ReceivePacketOverride = (ref Address from, Span payload) => 0; + + try + { + using var client = new Client("127.0.0.1:40000", config); + Check.That(false); + } + catch (NetcodeException e) + { + Check.That(e.ErrorCode == (int)ClientCreateError.MissingOverrideCallback); + } + } + + public static void TestServerCreateMissingOverrideCallback() + { + // override_send_and_receive with either override callback missing is refused at + // create time with the new code, rather than calling null on the first update. + + var config = CreateServerConfig(null); + config.OverrideSendAndReceive = true; + config.SendPacketOverride = (in Address to, ReadOnlySpan payload) => { }; + + try + { + using var server = new Server("127.0.0.1:40000", config); + Check.That(false); + } + catch (NetcodeException e) + { + Check.That(e.ErrorCode == (int)ServerCreateError.MissingOverrideCallback); + } + + config.SendPacketOverride = null; + config.ReceivePacketOverride = (ref Address from, Span payload) => 0; + + try + { + using var server = new Server("127.0.0.1:40000", config); + Check.That(false); + } + catch (NetcodeException e) + { + Check.That(e.ErrorCode == (int)ServerCreateError.MissingOverrideCallback); + } + } + + public static void TestClientLoopbackRequiresCallback() + { + // entering loopback with SendLoopbackPacket unset must refuse to connect, in + // every build, rather than calling null on the next send. + + using var client = new Client("127.0.0.1:40000"); + + client.ConnectLoopback(0, 1); + + byte[] payload = new byte[Protocol.MaxPacketSize]; + client.SendPacket(payload); + + Check.That(client.State == ClientState.Disconnected); + Check.That(!client.IsLoopback); + } + + public static void TestServerLoopbackRequiresCallback() + { + // attaching a loopback client with SendLoopbackPacket unset must refuse the slot, + // in every build, rather than calling null on the next send. + + using var server = new Server("127.0.0.1:40000", CreateServerConfig(null)); + server.Start(1); + + server.ConnectLoopbackClient(0, 0x11111111, ReadOnlySpan.Empty); + + byte[] payload = new byte[Protocol.MaxPacketSize]; + server.SendPacket(0, payload); + + Check.That(!server.ClientLoopback(0)); + Check.That(!server.ClientConnected(0)); + Check.That(server.NumConnectedClients == 0); + } } internal static class Soak diff --git a/tests/Tests.cs b/tests/Tests.cs index 5b7d689..426be2c 100644 --- a/tests/Tests.cs +++ b/tests/Tests.cs @@ -124,6 +124,9 @@ private static int Main(string[] args) RunTest("test_client_create", ClientServerTests.TestClientCreate); RunTest("test_server_create", ClientServerTests.TestServerCreate); RunTest("test_server_restart_global_sequence", ClientServerTests.TestServerRestartGlobalSequence); + RunTest("test_connect_token_history", ClientServerTests.TestConnectTokenHistory); + RunTest("test_client_reconnect_used_connect_token", ClientServerTests.TestClientReconnectUsedConnectToken); + RunTest("test_client_error_connect_token_predates_server_start", ClientServerTests.TestClientErrorConnectTokenPredatesServerStart); RunTest("test_client_server_connect", ClientServerTests.TestClientServerConnect); RunTest("test_client_server_ipv4_socket_connect", ClientServerTests.TestClientServerIpv4SocketConnect); RunTest("test_client_server_ipv6_socket_connect", ClientServerTests.TestClientServerIpv6SocketConnect); @@ -143,6 +146,10 @@ private static int Main(string[] args) RunTest("test_client_reconnect", ClientServerTests.TestClientReconnect); RunTest("test_disable_timeout", ClientServerTests.TestDisableTimeout); RunTest("test_loopback", ClientServerTests.TestLoopback); + RunTest("test_client_create_missing_override_callback", ClientServerTests.TestClientCreateMissingOverrideCallback); + RunTest("test_server_create_missing_override_callback", ClientServerTests.TestServerCreateMissingOverrideCallback); + RunTest("test_client_loopback_requires_callback", ClientServerTests.TestClientLoopbackRequiresCallback); + RunTest("test_server_loopback_requires_callback", ClientServerTests.TestServerLoopbackRequiresCallback); RunTest("test_fuzz_read_packet_raw", Hostile.TestFuzzReadPacketRaw); RunTest("test_fuzz_packet_round_trip_corruption", Hostile.TestFuzzPacketRoundTripCorruption); RunTest("test_fuzz_connect_token", Hostile.TestFuzzConnectToken);