diff --git a/README.md b/README.md index f99f203..76a2df2 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,28 @@ let mut server = netcode::Server::new(server_address, protocol_id, &private_key, .expect("failed to create server"); ``` +`Server::new` uses the default maximum connect-token lifetime of 30 seconds for +the server-restart replay guard. If your backend issues tokens with a different +maximum lifetime, configure it when creating the server: + +```rust +let config = netcode::ServerConfig { max_connect_token_lifetime: 60 }; +let mut server = netcode::Server::new_with_config( + server_address, + protocol_id, + &private_key, + config, + time, +) +.expect("failed to create server"); +``` + +The server records each connect token it sees. Retransmitted requests remain +pending only from their original address; a token is consumed when its client +is installed and cannot be used again. While the server runs, entries remain +until their tokens expire, so the finite history refuses new tokens rather than +evicting unexpired entries. + Then start the server with the number of client slots you want: ```rust 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/src/lib.rs b/src/lib.rs index e7b5af2..5ff1378 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -105,7 +105,7 @@ mod wire_compat; pub use client::{Client, ClientState}; pub use crypto::generate_key; pub use error::Error; -pub use server::{DisconnectReason, Server, ServerEvent}; +pub use server::{DisconnectReason, Server, ServerConfig, ServerEvent}; pub use token::generate_connect_token; /// The size of a connect token in bytes. @@ -126,6 +126,9 @@ pub const MAX_SERVERS_PER_CONNECT: usize = 32; /// The maximum number of client slots on a server. pub const MAX_CLIENTS: usize = 256; +/// The default maximum lifetime, in seconds, for connect tokens issued by the backend. +pub const DEFAULT_MAX_CONNECT_TOKEN_LIFETIME: i32 = 30; + /// The maximum size of a payload packet in bytes. pub const MAX_PAYLOAD_BYTES: usize = 1200; diff --git a/src/packet.rs b/src/packet.rs index 3d285ac..b0bfdcd 100644 --- a/src/packet.rs +++ b/src/packet.rs @@ -198,6 +198,32 @@ pub(crate) fn read_packet( private_key: Option<&Key>, allowed_packets: AllowedPackets, replay_protection: Option<&mut ReplayProtection>, +) -> Option<(Packet, u64)> { + read_packet_with_min_connect_token_expire_timestamp( + buffer, + read_packet_key, + protocol_id, + current_timestamp, + private_key, + allowed_packets, + replay_protection, + 0, + ) +} + +/// Reads a packet while also rejecting connect tokens that could have been issued before +/// the server started. The minimum expiry timestamp is zero for clients and servers that do +/// not enable the restart mitigation. +#[allow(clippy::too_many_arguments)] +pub(crate) fn read_packet_with_min_connect_token_expire_timestamp( + buffer: &mut [u8], + read_packet_key: Option<&Key>, + protocol_id: u64, + current_timestamp: u64, + private_key: Option<&Key>, + allowed_packets: AllowedPackets, + replay_protection: Option<&mut ReplayProtection>, + min_connect_token_expire_timestamp: u64, ) -> Option<(Packet, u64)> { if buffer.is_empty() { debug!("ignored packet. buffer length is less than 1"); @@ -211,6 +237,7 @@ pub(crate) fn read_packet( buffer, protocol_id, current_timestamp, + min_connect_token_expire_timestamp, private_key, allowed_packets, ) @@ -358,6 +385,7 @@ fn read_connection_request_packet( buffer: &mut [u8], protocol_id: u64, current_timestamp: u64, + min_connect_token_expire_timestamp: u64, private_key: Option<&Key>, allowed_packets: AllowedPackets, ) -> Option { @@ -401,6 +429,11 @@ fn read_connection_request_packet( return None; } + if expire_timestamp < min_connect_token_expire_timestamp { + debug!("ignored connection request packet. connect token predates the server start"); + return None; + } + let nonce = reader.read_bytes::()?; let private_data_start = buffer.len() - CONNECT_TOKEN_PRIVATE_BYTES; @@ -598,6 +631,56 @@ mod tests { } } + #[test] + fn connection_request_rejects_token_before_server_start() { + let private_key = generate_key(); + let expire_timestamp = crate::token::unix_timestamp() + 30; + + let mut nonce = [0u8; CONNECT_TOKEN_NONCE_BYTES]; + crypto::random_bytes(&mut nonce); + + let private_token = crate::token::PrivateConnectToken::generate( + 0x1234, + 10, + &["127.0.0.1:40000".parse().unwrap()], + &[0u8; crate::USER_DATA_BYTES], + ); + let mut private_data = Box::new([0u8; CONNECT_TOKEN_PRIVATE_BYTES]); + private_token.write(&mut private_data); + crate::token::encrypt_connect_token_private( + &mut private_data, + TEST_PROTOCOL_ID, + expire_timestamp, + &nonce, + &private_key, + ) + .unwrap(); + + let input = Packet::Request { + protocol_id: TEST_PROTOCOL_ID, + expire_timestamp, + nonce, + private_data, + }; + let key = generate_key(); + let mut buffer = [0u8; MAX_PACKET_BYTES]; + let written = write_packet(&input, &mut buffer, 0, &key, TEST_PROTOCOL_ID).unwrap(); + + assert!( + read_packet_with_min_connect_token_expire_timestamp( + &mut buffer[..written], + None, + TEST_PROTOCOL_ID, + crate::token::unix_timestamp(), + Some(&private_key), + AllowedPackets::SERVER, + None, + expire_timestamp + 1, + ) + .is_none() + ); + } + #[test] fn client_rejects_request_and_response_packets() { let key = generate_key(); diff --git a/src/server.rs b/src/server.rs index 7067991..b01f84b 100644 --- a/src/server.rs +++ b/src/server.rs @@ -49,6 +49,22 @@ pub enum ServerEvent { const MAX_ENCRYPTION_MAPPINGS: usize = MAX_CLIENTS * 4; +/// Configuration for a [`Server`]. +#[derive(Debug, Clone, Copy)] +pub struct ServerConfig { + /// The longest lifetime, in seconds, of connect tokens issued by the backend. + /// A token whose expiry is earlier than the server start time plus this lifetime + /// is rejected. Values at or below zero use + /// [`crate::DEFAULT_MAX_CONNECT_TOKEN_LIFETIME`]. + pub max_connect_token_lifetime: i32, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { max_connect_token_lifetime: crate::DEFAULT_MAX_CONNECT_TOKEN_LIFETIME } + } +} + /// Maps packet source addresses to the encryption keys from their connect token. /// An entry is added when a connection request is accepted and expires after /// `timeout` seconds without packets, or at `expire_time` if the client never @@ -59,6 +75,7 @@ struct EncryptionEntry { expire_time: f64, last_access_time: f64, client_index: Option, + connect_token_entry_index: Option, send_key: Key, receive_key: Key, } @@ -71,6 +88,7 @@ impl EncryptionEntry { expire_time: -1.0, last_access_time: -1000.0, client_index: None, + connect_token_entry_index: None, send_key: [0; KEY_BYTES], receive_key: [0; KEY_BYTES], } @@ -110,6 +128,7 @@ impl EncryptionManager { time: f64, expire_time: f64, timeout_seconds: i32, + connect_token_entry_index: usize, ) -> bool { for index in 0..self.num_entries { let entry = &mut self.entries[index]; @@ -119,6 +138,7 @@ impl EncryptionManager { entry.last_access_time = time; entry.send_key = *send_key; entry.receive_key = *receive_key; + entry.connect_token_entry_index = Some(connect_token_entry_index); return true; } } @@ -132,6 +152,7 @@ impl EncryptionManager { expire_time, last_access_time: time, client_index: None, + connect_token_entry_index: Some(connect_token_entry_index), send_key: *send_key, receive_key: *receive_key, }; @@ -200,6 +221,10 @@ impl EncryptionManager { self.entries[index].client_index = client_index; } + fn connect_token_entry_index(&self, index: usize) -> Option { + self.entries[index].connect_token_entry_index + } + fn send_key(&self, index: usize) -> Key { self.entries[index].send_key } @@ -217,10 +242,27 @@ impl EncryptionManager { const MAX_CONNECT_TOKEN_ENTRIES: usize = MAX_CLIENTS * 8; -/// A history of connect tokens already used, keyed by token HMAC, so a token stolen -/// off the wire cannot be replayed from a different address. +/// A history of connect tokens seen by the server, keyed by token HMAC, so a token +/// stolen off the wire cannot be replayed from a different address. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ConnectTokenEntryState { + Free, + Pending, + Consumed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ConnectTokenEntryResult { + Accepted(usize), + Refused, + HistoryFull, +} + struct ConnectTokenEntry { + state: ConnectTokenEntryState, + #[allow(dead_code)] time: f64, + expire_timestamp: u64, mac: [u8; MAC_BYTES], address: Option, } @@ -228,47 +270,72 @@ struct ConnectTokenEntry { fn reset_connect_token_entries(entries: &mut Vec) { entries.clear(); entries.extend((0..MAX_CONNECT_TOKEN_ENTRIES).map(|_| ConnectTokenEntry { + state: ConnectTokenEntryState::Free, time: -1000.0, + expire_timestamp: 0, mac: [0; MAC_BYTES], address: None, })); } -/// Returns whether the connect token may be used from this address: true for a token -/// never seen before (recording it) or one seen only from the same address. +/// Returns the history entry that admits this connection request. fn find_or_add_connect_token_entry( entries: &mut [ConnectTokenEntry], address: SocketAddr, mac: &[u8; MAC_BYTES], + expire_timestamp: u64, + current_timestamp: u64, time: f64, -) -> bool { - // find the matching entry for the token mac and the oldest token entry. - // constant time worst case. This is intentional! +) -> ConnectTokenEntryResult { + // Find the matching entry for the token MAC and the first slot that is free + // or whose token has expired. The scan is constant time in the worst case. + // This is intentional so token history timing does not reveal whether a token + // was seen before. let mut matching_index = None; - let mut oldest_index = 0; - let mut oldest_time = f64::MAX; + let mut free_index = None; for (index, entry) in entries.iter().enumerate() { - if &entry.mac == mac { + if entry.state != ConnectTokenEntryState::Free && &entry.mac == mac { matching_index = Some(index); } - if entry.time < oldest_time { - oldest_time = entry.time; - oldest_index = index; + if free_index.is_none() + && (entry.state == ConnectTokenEntryState::Free + || entry.expire_timestamp <= current_timestamp) + { + free_index = Some(index); } } match matching_index { - // this is a new connect token: replace the oldest entry None => { - entries[oldest_index] = ConnectTokenEntry { time, mac: *mac, address: Some(address) }; - true + let Some(index) = free_index else { + return ConnectTokenEntryResult::HistoryFull; + }; + entries[index] = ConnectTokenEntry { + state: ConnectTokenEntryState::Pending, + time, + expire_timestamp, + mac: *mac, + address: Some(address), + }; + ConnectTokenEntryResult::Accepted(index) + } + Some(index) => { + if entries[index].state == ConnectTokenEntryState::Pending + && entries[index].address == Some(address) + { + ConnectTokenEntryResult::Accepted(index) + } else { + ConnectTokenEntryResult::Refused + } } - // allow connect tokens we have already seen from the same address - Some(index) => entries[index].address == Some(address), } } +fn consume_connect_token_entry(entries: &mut [ConnectTokenEntry], index: usize) { + entries[index].state = ConnectTokenEntryState::Consumed; +} + // ---------------------------------------------------------------- struct ClientSlot { @@ -314,6 +381,7 @@ impl ClientSlot { pub struct Server { protocol_id: u64, private_key: Key, + max_connect_token_lifetime: u64, socket: UdpSocket, public_address: SocketAddr, time: f64, @@ -322,6 +390,7 @@ pub struct Server { num_connected_clients: usize, global_sequence: u64, challenge_sequence: u64, + min_connect_token_expire_timestamp: u64, challenge_key: Key, clients: Vec, connect_token_entries: Vec, @@ -343,6 +412,23 @@ impl Server { protocol_id: u64, private_key: &Key, time: f64, + ) -> Result { + Self::new_with_config( + public_address, + protocol_id, + private_key, + ServerConfig::default(), + time, + ) + } + + /// Creates a server with explicit configuration. + pub fn new_with_config( + public_address: SocketAddr, + protocol_id: u64, + private_key: &Key, + config: ServerConfig, + time: f64, ) -> Result { let bind_address: SocketAddr = match public_address { SocketAddr::V4(_) => (Ipv4Addr::UNSPECIFIED, public_address.port()).into(), @@ -363,6 +449,11 @@ impl Server { Ok(Self { protocol_id, private_key: *private_key, + max_connect_token_lifetime: if config.max_connect_token_lifetime > 0 { + config.max_connect_token_lifetime as u64 + } else { + crate::DEFAULT_MAX_CONNECT_TOKEN_LIFETIME as u64 + }, socket, public_address, time, @@ -371,6 +462,7 @@ impl Server { num_connected_clients: 0, global_sequence: 1 << 63, challenge_sequence: 0, + min_connect_token_expire_timestamp: 0, challenge_key: [0; KEY_BYTES], clients: Vec::new(), connect_token_entries, @@ -397,6 +489,8 @@ impl Server { self.num_connected_clients = 0; self.challenge_sequence = 0; self.challenge_key = crypto::generate_key(); + self.min_connect_token_expire_timestamp = + token::unix_timestamp().saturating_add(self.max_connect_token_lifetime); // 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 takes the top half of the space to keep AEAD nonces @@ -421,6 +515,7 @@ impl Server { self.global_sequence = 1 << 63; self.challenge_sequence = 0; self.challenge_key = [0; KEY_BYTES]; + self.min_connect_token_expire_timestamp = 0; self.clients.clear(); reset_connect_token_entries(&mut self.connect_token_entries); @@ -631,19 +726,29 @@ impl Server { let replay_protection = client_index.map(|client_index| &mut self.clients[client_index].replay_protection); - let Some((packet, sequence)) = crate::packet::read_packet( - packet_data, - read_packet_key.as_ref(), - protocol_id, - current_timestamp, - Some(&private_key), - AllowedPackets::SERVER, - replay_protection, - ) else { + let Some((packet, sequence)) = + crate::packet::read_packet_with_min_connect_token_expire_timestamp( + packet_data, + read_packet_key.as_ref(), + protocol_id, + current_timestamp, + Some(&private_key), + AllowedPackets::SERVER, + replay_protection, + self.min_connect_token_expire_timestamp, + ) + else { return; }; - self.process_packet(from, packet, sequence, encryption_index, client_index); + self.process_packet( + from, + packet, + sequence, + encryption_index, + client_index, + current_timestamp, + ); } fn process_packet( @@ -653,11 +758,17 @@ impl Server { sequence: u64, encryption_index: Option, client_index: Option, + current_timestamp: u64, ) { match packet { - Packet::Request { private_data, .. } => { + Packet::Request { expire_timestamp, private_data, .. } => { debug!("server received connection request from {from}"); - self.process_connection_request(from, &private_data); + self.process_connection_request( + from, + &private_data, + expire_timestamp, + current_timestamp, + ); } Packet::Response { challenge_token_sequence, challenge_token_data } => { @@ -722,6 +833,8 @@ impl Server { &mut self, from: SocketAddr, private_data: &[u8; CONNECT_TOKEN_PRIVATE_BYTES], + expire_timestamp: u64, + current_timestamp: u64, ) { let Ok(private_token) = PrivateConnectToken::read(&private_data[..]) else { debug!("server ignored connection request. failed to read connect token"); @@ -749,11 +862,24 @@ impl Server { let mac: [u8; MAC_BYTES] = private_data[CONNECT_TOKEN_PRIVATE_BYTES - MAC_BYTES..].try_into().unwrap(); - if !find_or_add_connect_token_entry(&mut self.connect_token_entries, from, &mac, self.time) - { - debug!("server ignored connection request. connect token has already been used"); - return; - } + let connect_token_entry_index = match find_or_add_connect_token_entry( + &mut self.connect_token_entries, + from, + &mac, + expire_timestamp, + current_timestamp, + self.time, + ) { + ConnectTokenEntryResult::Accepted(index) => index, + ConnectTokenEntryResult::Refused => { + debug!("server ignored connection request. connect token has already been used"); + return; + } + ConnectTokenEntryResult::HistoryFull => { + debug!("server ignored connection request. connect token history is full"); + return; + } + }; if self.num_connected_clients == self.max_clients { debug!("server denied connection request. server is full"); @@ -774,6 +900,7 @@ impl Server { self.time, expire_time, private_token.timeout_seconds, + connect_token_entry_index, ) { debug!("server ignored connection request. failed to add encryption mapping"); return; @@ -881,6 +1008,11 @@ impl Server { // expires on its own, only when the client disconnects self.encryption_manager.set_expire_time(encryption_index, -1.0); self.encryption_manager.set_client_index(encryption_index, Some(client_index)); + if let Some(connect_token_entry_index) = + self.encryption_manager.connect_token_entry_index(encryption_index) + { + consume_connect_token_entry(&mut self.connect_token_entries, connect_token_entry_index); + } let client = &mut self.clients[client_index]; debug_assert!(!client.connected); @@ -1075,6 +1207,7 @@ mod tests { time, time + 5.0, 5, + 0, )); } @@ -1107,24 +1240,115 @@ mod tests { 100.0, 105.0, -1, + 0, )); assert!(manager.find_encryption_mapping(test_address(40000), 104.0).is_some()); assert!(manager.find_encryption_mapping(test_address(40000), 106.0).is_none()); } #[test] - fn connect_token_entries_reject_reuse_from_different_address() { + fn server_start_sets_configured_connect_token_restart_guard() { + let private_key = crypto::generate_key(); + let mut server = Server::new_with_config( + test_address(0), + TEST_PROTOCOL_ID, + &private_key, + ServerConfig { max_connect_token_lifetime: 60 }, + 0.0, + ) + .unwrap(); + + assert_eq!(server.min_connect_token_expire_timestamp, 0); + server.start(1).unwrap(); + + let current_timestamp = token::unix_timestamp(); + assert!(server.min_connect_token_expire_timestamp >= current_timestamp + 59); + assert!(server.min_connect_token_expire_timestamp <= current_timestamp + 60); + } + + #[test] + fn connect_token_entries_enforce_single_use_and_bounded_history() { let mut entries = Vec::new(); reset_connect_token_entries(&mut entries); let mac = [0x42u8; MAC_BYTES]; // first use records the token - assert!(find_or_add_connect_token_entry(&mut entries, test_address(40000), &mac, 0.0)); - // same token from the same address is fine - assert!(find_or_add_connect_token_entry(&mut entries, test_address(40000), &mac, 1.0)); - // same token from a different address is rejected - assert!(!find_or_add_connect_token_entry(&mut entries, test_address(40001), &mac, 2.0)); + let index = match find_or_add_connect_token_entry( + &mut entries, + test_address(40000), + &mac, + 30, + 0, + 0.0, + ) { + ConnectTokenEntryResult::Accepted(index) => index, + result => panic!("unexpected result: {result:?}"), + }; + // a pending token can be retransmitted from the address that created it, + // and the entry time is not refreshed + assert_eq!( + find_or_add_connect_token_entry(&mut entries, test_address(40000), &mac, 30, 1, 1.0,), + ConnectTokenEntryResult::Accepted(index) + ); + assert_eq!(entries[index].time, 0.0); + // a pending token refuses every other address + assert_eq!( + find_or_add_connect_token_entry(&mut entries, test_address(40001), &mac, 30, 2, 2.0,), + ConnectTokenEntryResult::Refused + ); + + // once the client is installed, the token is consumed and cannot be + // presented again, including from the address that used it + consume_connect_token_entry(&mut entries, index); + assert_eq!( + find_or_add_connect_token_entry(&mut entries, test_address(40000), &mac, 30, 3, 3.0,), + ConnectTokenEntryResult::Refused + ); + + // a history whose entries all hold unexpired tokens refuses a new token + // instead of evicting a consumed entry + for i in 1..MAX_CONNECT_TOKEN_ENTRIES { + let mut mac = [0u8; MAC_BYTES]; + mac[..8].copy_from_slice(&(i as u64).to_le_bytes()); + assert!(matches!( + find_or_add_connect_token_entry( + &mut entries, + test_address(40000), + &mac, + 30, + 0, + 4.0, + ), + ConnectTokenEntryResult::Accepted(_) + )); + } + + let new_mac = [0xFFu8; MAC_BYTES]; + assert_eq!( + find_or_add_connect_token_entry( + &mut entries, + test_address(40000), + &new_mac, + 30, + 0, + 5.0, + ), + ConnectTokenEntryResult::HistoryFull + ); + + // entries become reusable only after the token expires + assert!(matches!( + find_or_add_connect_token_entry( + &mut entries, + test_address(40000), + &new_mac, + 30, + 30, + 6.0, + ), + ConnectTokenEntryResult::Accepted(_) + )); } /// Sends `connect_token` to the server as a connection request from `socket` and diff --git a/tests/client_server.rs b/tests/client_server.rs index 7d5773b..c068196 100644 --- a/tests/client_server.rs +++ b/tests/client_server.rs @@ -187,6 +187,29 @@ fn connect_token_cannot_be_reused_from_another_address() { assert_eq!(harness.server.num_connected_clients(), 1); } +#[test] +fn connect_token_cannot_be_reused_after_disconnect() { + let mut harness = Harness::new(1); + let connect_token = harness.generate_connect_token(1); + + let mut client = Client::new("127.0.0.1:0".parse().unwrap(), 0.0).unwrap(); + client.connect(&connect_token).unwrap(); + harness.clients.push(client); + harness.run_until(1000, |h| h.clients[0].state() == ClientState::Connected); + + harness.server.disconnect_client(0); + harness.run_until(1000, |h| h.clients[0].state() == ClientState::Disconnected); + + // Reuse the same client socket and token after the session has ended. The + // token was consumed when the first client was installed. + harness.clients[0].connect(&connect_token).unwrap(); + for _ in 0..300 { + harness.update(); + assert_ne!(harness.clients[0].state(), ClientState::Connected); + } + assert_eq!(harness.server.num_connected_clients(), 0); +} + #[test] fn wrong_protocol_id_cannot_connect() { let mut harness = Harness::new(1);