diff --git a/Cargo.toml b/Cargo.toml index e8e3f5412..bb6516ddc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ crc = "3" directories-next = "2" futures-io = "0.3.19" getrandom = { version = "0.2", default-features = false } +fastbloom = "0.8" hdrhistogram = { version = "7.2", default-features = false } hex-literal = "0.4" lazy_static = "1" diff --git a/quinn-proto/Cargo.toml b/quinn-proto/Cargo.toml index 8a91c14df..d7c6d9378 100644 --- a/quinn-proto/Cargo.toml +++ b/quinn-proto/Cargo.toml @@ -11,7 +11,7 @@ categories.workspace = true workspace = ".." [features] -default = ["rustls-ring", "log"] +default = ["rustls-ring", "log", "fastbloom"] aws-lc-rs = ["dep:aws-lc-rs", "aws-lc-rs?/aws-lc-sys", "aws-lc-rs?/prebuilt-nasm"] aws-lc-rs-fips = ["aws-lc-rs", "aws-lc-rs?/fips"] # For backwards compatibility, `rustls` forwards to `rustls-ring` @@ -34,6 +34,7 @@ rustls-log = ["rustls?/logging"] arbitrary = { workspace = true, optional = true } aws-lc-rs = { workspace = true, optional = true } bytes = { workspace = true } +fastbloom = { workspace = true, optional = true } rustc-hash = { workspace = true } rand = { workspace = true } ring = { workspace = true, optional = true } diff --git a/quinn-proto/src/bloom_token_log.rs b/quinn-proto/src/bloom_token_log.rs new file mode 100644 index 000000000..267b72e64 --- /dev/null +++ b/quinn-proto/src/bloom_token_log.rs @@ -0,0 +1,216 @@ +use std::{ + collections::HashSet, + f64::consts::LN_2, + hash::{BuildHasher, Hasher}, + mem::{size_of, swap}, + sync::Mutex, +}; + +use fastbloom::BloomFilter; +use rustc_hash::FxBuildHasher; +use tracing::warn; + +use crate::{Duration, SystemTime, TokenLog, TokenReuseError, UNIX_EPOCH}; + +/// The token's rand needs to guarantee uniqueness because of the role it plays in the encryption +/// of the tokens, so it is 128 bits. But since the token log can tolerate both false positives and +/// false negatives, we trim it down to 64 bits, which would still only have a small collision rate +/// even at significant amounts of usage, while allowing us to store twice as many in the hash set +/// variant. +/// +/// Token rand values are uniformly randomly generated server-side and cryptographically integrity- +/// checked, so we don't need to employ secure hashing for this, we can simply truncate. +fn rand_to_fingerprint(rand: u128) -> u64 { + (rand & 0xffffffff) as u64 +} + +/// `BuildHasher` of `IdentityHasher`. +#[derive(Default)] +struct IdentityBuildHasher; + +impl BuildHasher for IdentityBuildHasher { + type Hasher = IdentityHasher; + + fn build_hasher(&self) -> Self::Hasher { + IdentityHasher::default() + } +} + +/// Hasher that assumes the thing being hashes is a `u64` and is the identity operation. +#[derive(Default)] +struct IdentityHasher { + data: [u8; 8], + #[cfg(debug_assertions)] + wrote_8_byte_slice: bool, +} + +impl Hasher for IdentityHasher { + fn write(&mut self, bytes: &[u8]) { + #[cfg(debug_assertions)] + { + assert!(!self.wrote_8_byte_slice); + assert_eq!(bytes.len(), 8); + self.wrote_8_byte_slice = true; + } + self.data.copy_from_slice(bytes); + } + + fn finish(&self) -> u64 { + #[cfg(debug_assertions)] + assert!(self.wrote_8_byte_slice); + u64::from_ne_bytes(self.data) + } +} + +/// Hash set of `u64` which are assumed to already be uniformly randomly distributed, and thus +/// effectively pre-hashed. +type IdentityHashSet = HashSet; + +/// Bloom filter that uses `FxHasher`s. +type FxBloomFilter = BloomFilter<512, FxBuildHasher>; + +/// Bloom filter-based `TokenLog` +/// +/// Parameterizable over an approximate maximum number of bytes to allocate. Starts out by storing +/// used tokens in a hash set. Once the hash set becomes too large, converts it to a bloom filter. +/// This achieves a memory profile of linear growth with an upper bound. +/// +/// Divides time into periods based on `lifetime` and stores two filters at any given moment, for +/// each of the two periods currently non-expired tokens could expire in. As such, turns over +/// filters as time goes on to avoid bloom filter false positive rate increasing infinitely over +/// time. +pub struct BloomTokenLog(Mutex); + +/// Lockable state of [`BloomTokenLog`] +struct State { + filter_max_bytes: usize, + k_num: u32, + + // filter_1 covers tokens that expire in the period starting at + // UNIX_EPOCH + period_idx_1 * lifetime and extending lifetime after. + // filter_2 covers tokens for the next lifetime after that. + period_idx_1: u128, + filter_1: Filter, + filter_2: Filter, +} + +/// Period filter within [`State`] +enum Filter { + Set(IdentityHashSet), + Bloom(FxBloomFilter), +} + +impl BloomTokenLog { + /// Construct with an approximate maximum memory usage and expected number of validation token + /// usages per expiration period + /// + /// Calculates the optimal bloom filter k number automatically. + pub fn new_expected_items(max_bytes: usize, expected_hits: u64) -> Self { + Self::new(max_bytes, optimal_k_num(max_bytes, expected_hits)) + } + + /// Construct with an approximate maximum memory usage and a bloom filter k number + /// + /// If choosing a custom k number, note that `BloomTokenLog` always maintains two filters + /// between them and divides the allocation budget of `max_bytes` evenly between them. As such, + /// each bloom filter will contain `max_bytes * 4` bits. + pub fn new(max_bytes: usize, k_num: u32) -> Self { + assert!(max_bytes >= 2, "BloomTokenLog max_bytes too low"); + assert!(k_num >= 1, "BloomTokenLog k_num must be at least 1"); + + Self(Mutex::new(State { + filter_max_bytes: max_bytes / 2, + k_num, + period_idx_1: 0, + filter_1: Filter::Set(IdentityHashSet::default()), + filter_2: Filter::Set(IdentityHashSet::default()), + })) + } +} + +fn optimal_k_num(num_bytes: usize, expected_hits: u64) -> u32 { + assert!(expected_hits > 0, "BloomTokenLog expected hits too low"); + let num_bits = (num_bytes as u64) + .checked_mul(8) + .expect("BloomTokenLog num bytes too high"); + (((num_bits as f64 / expected_hits as f64) * LN_2).round() as u32).max(1) +} + +impl TokenLog for BloomTokenLog { + fn check_and_insert( + &self, + rand: u128, + issued: SystemTime, + lifetime: Duration, + ) -> Result<(), TokenReuseError> { + let mut guard = self.0.lock().unwrap(); + let state = &mut *guard; + let fingerprint = rand_to_fingerprint(rand); + + // calculate period index for token + let period_idx = (issued + lifetime) + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + / lifetime.as_nanos(); + + // get relevant filter + let filter = if period_idx < state.period_idx_1 { + // shouldn't happen unless time travels backwards or new_token_lifetime changes + warn!("BloomTokenLog presented with token too far in past"); + return Err(TokenReuseError); + } else if period_idx == state.period_idx_1 { + &mut state.filter_1 + } else if period_idx == state.period_idx_1 + 1 { + &mut state.filter_2 + } else { + // turn over filters + if period_idx == state.period_idx_1 + 2 { + swap(&mut state.filter_1, &mut state.filter_2); + } else { + state.filter_1 = Filter::Set(IdentityHashSet::default()); + } + state.filter_2 = Filter::Set(IdentityHashSet::default()); + state.period_idx_1 = period_idx - 1; + + &mut state.filter_2 + }; + + // query and insert + match *filter { + Filter::Set(ref mut hset) => { + if !hset.insert(fingerprint) { + return Err(TokenReuseError); + } + + if hset.capacity() * size_of::() > state.filter_max_bytes { + // convert to bloom + let mut bloom = BloomFilter::with_num_bits(state.filter_max_bytes * 8) + .hasher(FxBuildHasher) + .hashes(state.k_num); + for item in hset.iter() { + bloom.insert(item); + } + *filter = Filter::Bloom(bloom); + } + } + Filter::Bloom(ref mut bloom) => { + if bloom.insert(&fingerprint) { + return Err(TokenReuseError); + } + } + } + + Ok(()) + } +} + +const DEFAULT_MAX_BYTES: usize = 10 << 20; +const DEFAULT_EXPECTED_HITS: u64 = 1_000_000; + +/// Default to 20 MiB max memory consumption and expected one million hits +impl Default for BloomTokenLog { + fn default() -> Self { + Self::new_expected_items(DEFAULT_MAX_BYTES, DEFAULT_EXPECTED_HITS) + } +} diff --git a/quinn-proto/src/config.rs b/quinn-proto/src/config.rs index 7bdd7c086..54e12c60a 100644 --- a/quinn-proto/src/config.rs +++ b/quinn-proto/src/config.rs @@ -11,6 +11,8 @@ use rustls::client::WebPkiServerVerifier; use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use thiserror::Error; +#[cfg(feature = "fastbloom")] +use crate::bloom_token_log::BloomTokenLog; #[cfg(any(feature = "rustls-aws-lc-rs", feature = "rustls-ring"))] use crate::crypto::rustls::{configured_provider, QuicServerConfig}; use crate::{ @@ -18,7 +20,7 @@ use crate::{ congestion, crypto::{self, HandshakeTokenKey, HmacKey}, shared::ConnectionId, - Duration, RandomConnectionIdGenerator, VarInt, VarIntBoundsExceeded, + Duration, RandomConnectionIdGenerator, TokenLog, VarInt, VarIntBoundsExceeded, DEFAULT_SUPPORTED_VERSIONS, INITIAL_MTU, MAX_CID_SIZE, MAX_UDP_PAYLOAD, }; @@ -810,6 +812,16 @@ pub struct ServerConfig { /// Duration after a retry token was issued for which it's considered valid pub(crate) retry_token_lifetime: Duration, + /// Duration after an address validation token was issued for which it's considered valid + pub(crate) validation_token_lifetime: Duration, + + /// Responsible for limiting clients' ability to reuse tokens from NEW_TOKEN frames + pub(crate) validation_token_log: Option>, + + /// Number of address validation tokens sent to a client via NEW_TOKEN frames when its path is + /// validated + pub(crate) validation_tokens_sent: u32, + /// Whether to allow clients to migrate to new addresses /// /// Improves behavior for clients that move between different internet connections or suffer NAT @@ -825,6 +837,7 @@ pub struct ServerConfig { } const DEFAULT_RETRY_TOKEN_LIFETIME_SECS: u64 = 15; +const DEFAULT_VALIDATION_TOKEN_LIFETIME_SECS: u64 = 2 * 7 * 24 * 60 * 60; impl ServerConfig { /// Create a default config with a particular handshake token key @@ -832,12 +845,19 @@ impl ServerConfig { crypto: Arc, token_key: Arc, ) -> Self { + #[cfg(feature = "fastbloom")] + let validation_token_log = Some(Arc::new(BloomTokenLog::default()) as _); + #[cfg(not(feature = "fastbloom"))] + let validation_token_log = None; Self { transport: Arc::new(TransportConfig::default()), crypto, token_key, retry_token_lifetime: Duration::from_secs(DEFAULT_RETRY_TOKEN_LIFETIME_SECS), + validation_token_lifetime: Duration::from_secs(DEFAULT_VALIDATION_TOKEN_LIFETIME_SECS), + validation_token_log, + validation_tokens_sent: 2, migration: true, @@ -870,6 +890,38 @@ impl ServerConfig { self } + /// Duration after an address validation token was issued for which it's considered valid + /// + /// This refers only to tokens sent in NEW_TOKEN frames, in contrast to retry tokens. + /// + /// Defaults to 2 weeks. + pub fn validation_token_lifetime(&mut self, value: Duration) -> &mut Self { + self.validation_token_lifetime = value; + self + } + + /// Set a custom [`TokenLog`] + /// + /// Setting this to `None` makes the server ignore all address validation tokens (that is, + /// tokens originating from NEW_TOKEN frames--retry tokens may still be accepted). + /// + /// Defaults to a default [`BloomTokenLog`], unless the `fastbloom` default feature is + /// disabled, in which case this defaults to `None`. + pub fn validation_token_log(&mut self, log: Option>) -> &mut Self { + self.validation_token_log = log; + self + } + + /// Number of address validation tokens sent to a client when its path is validated + /// + /// This refers only to tokens sent in NEW_TOKEN frames, in contrast to retry tokens. + /// + /// Defaults to 2. + pub fn validation_tokens_sent(&mut self, value: u32) -> &mut Self { + self.validation_tokens_sent = value; + self + } + /// Whether to allow clients to migrate to new addresses /// /// Improves behavior for clients that move between different internet connections or suffer NAT @@ -987,6 +1039,8 @@ impl fmt::Debug for ServerConfig { // crypto not debug // token not debug .field("retry_token_lifetime", &self.retry_token_lifetime) + .field("validation_token_lifetime", &self.validation_token_lifetime) + .field("validation_tokens_sent", &self.validation_tokens_sent) .field("migration", &self.migration) .field("preferred_address_v4", &self.preferred_address_v4) .field("preferred_address_v6", &self.preferred_address_v6) diff --git a/quinn-proto/src/connection/mod.rs b/quinn-proto/src/connection/mod.rs index babb0d757..fdcf40ba7 100644 --- a/quinn-proto/src/connection/mod.rs +++ b/quinn-proto/src/connection/mod.rs @@ -19,7 +19,7 @@ use crate::{ coding::BufMutExt, config::{ServerConfig, TransportConfig}, crypto::{self, KeyPair, Keys, PacketKey}, - frame::{self, Close, Datagram, FrameStruct}, + frame::{self, Close, Datagram, FrameStruct, NewToken}, packet::{ FixedLengthConnectionIdParser, Header, InitialHeader, InitialPacket, LongType, Packet, PacketNumber, PartialDecode, SpaceId, @@ -29,11 +29,11 @@ use crate::{ ConnectionEvent, ConnectionEventInner, ConnectionId, DatagramConnectionEvent, EcnCodepoint, EndpointEvent, EndpointEventInner, }, - token::ResetToken, + token::{ResetToken, Token, TokenInner}, transport_parameters::TransportParameters, - Dir, Duration, EndpointConfig, Frame, Instant, Side, StreamId, Transmit, TransportError, - TransportErrorCode, VarInt, INITIAL_MTU, MAX_CID_SIZE, MAX_STREAM_COUNT, MIN_INITIAL_SIZE, - TIMER_GRANULARITY, + Dir, Duration, EndpointConfig, Frame, Instant, Side, StreamId, SystemTime, Transmit, + TransportError, TransportErrorCode, VarInt, INITIAL_MTU, MAX_CID_SIZE, MAX_STREAM_COUNT, + MIN_INITIAL_SIZE, TIMER_GRANULARITY, }; mod ack_frequency; @@ -360,6 +360,9 @@ impl Connection { stats: ConnectionStats::default(), version, }; + if path_validated { + this.on_path_validated(); + } if side.is_client() { // Kick off the connection this.write_crypto(); @@ -2440,7 +2443,7 @@ impl Connection { ); return Ok(()); } - self.path.validated = true; + self.on_path_validated(); self.process_early_payload(now, packet)?; if self.state.is_closed() { @@ -2854,7 +2857,7 @@ impl Connection { self.update_rem_cid(); } } - Frame::NewToken { token } => { + Frame::NewToken(NewToken { token }) => { if self.side.is_server() { return Err(TransportError::PROTOCOL_VIOLATION("client sent NEW_TOKEN")); } @@ -3247,6 +3250,27 @@ impl Connection { self.datagrams.send_blocked = false; } + // NEW_TOKEN + while let Some((remote_addr, new_token)) = space.pending.new_tokens.pop() { + debug_assert_eq!(space_id, SpaceId::Data); + + if remote_addr != self.path.remote { + continue; + } + + if buf.len() + new_token.size() >= max_size { + space.pending.new_tokens.push((remote_addr, new_token)); + break; + } + + new_token.encode(buf); + sent.retransmits + .get_or_create() + .new_tokens + .push((remote_addr, new_token)); + self.stats.frame_tx.new_token += 1; + } + // STREAM if space_id == SpaceId::Data { sent.stream_frames = @@ -3608,6 +3632,33 @@ impl Connection { // but that would needlessly prevent sending datagrams during 0-RTT. key.map_or(16, |x| x.tag_len()) } + + /// Mark the path as validated, and enqueue NEW_TOKEN frames to be sent as appropriate + fn on_path_validated(&mut self) { + self.path.validated = true; + if let Some(server_config) = self.server_config.as_ref() { + self.spaces[SpaceId::Data as usize] + .pending + .new_tokens + .clear(); + for _ in 0..server_config.validation_tokens_sent { + let token_inner = TokenInner::Validation { + issued: SystemTime::now(), + }; + let token = Token::new(&mut self.rng, token_inner) + .encode(&*server_config.token_key, &self.path.remote); + self.spaces[SpaceId::Data as usize] + .pending + .new_tokens + .push(( + self.path.remote, + NewToken { + token: token.into(), + }, + )); + } + } + } } impl fmt::Debug for Connection { diff --git a/quinn-proto/src/connection/spaces.rs b/quinn-proto/src/connection/spaces.rs index ed58b51c1..343e38813 100644 --- a/quinn-proto/src/connection/spaces.rs +++ b/quinn-proto/src/connection/spaces.rs @@ -2,6 +2,7 @@ use std::{ cmp, collections::{BTreeMap, VecDeque}, mem, + net::SocketAddr, ops::{Bound, Index, IndexMut}, }; @@ -309,6 +310,8 @@ pub struct Retransmits { pub(super) retire_cids: Vec, pub(super) ack_frequency: bool, pub(super) handshake_done: bool, + /// NEW_TOKEN frames excluded from retransmission if path has changed + pub(super) new_tokens: Vec<(SocketAddr, frame::NewToken)>, } impl Retransmits { @@ -326,6 +329,7 @@ impl Retransmits { && self.retire_cids.is_empty() && !self.ack_frequency && !self.handshake_done + && self.new_tokens.is_empty() } } @@ -347,6 +351,7 @@ impl ::std::ops::BitOrAssign for Retransmits { self.retire_cids.extend(rhs.retire_cids); self.ack_frequency |= rhs.ack_frequency; self.handshake_done |= rhs.handshake_done; + self.new_tokens.extend_from_slice(&rhs.new_tokens); } } diff --git a/quinn-proto/src/endpoint.rs b/quinn-proto/src/endpoint.rs index ba7e1d495..3ee1fd448 100644 --- a/quinn-proto/src/endpoint.rs +++ b/quinn-proto/src/endpoint.rs @@ -496,27 +496,44 @@ impl Endpoint { let server_config = self.server_config.as_ref().unwrap().clone(); - let (retry_src_cid, orig_dst_cid) = if header.token.is_empty() { - (None, header.dst_cid) + let (retry_src_cid, orig_dst_cid, validated) = if header.token.is_empty() { + (None, header.dst_cid, false) } else { let valid_token = Token::decode(&*server_config.token_key, &addresses.remote, &header.token) - .and_then(|token| { - let TokenInner { - orig_dst_cid, - issued, - } = token.inner; - if issued + server_config.retry_token_lifetime > SystemTime::now() { - Ok((Some(header.dst_cid), orig_dst_cid)) - } else { - Err(TokenDecodeError::InvalidRetry) + .and_then(|token| match token.inner { + TokenInner::Retry { orig_dst_cid, issued } => { + if issued + server_config.retry_token_lifetime > SystemTime::now() { + Ok((Some(header.dst_cid), orig_dst_cid)) + } else { + Err(TokenDecodeError::InvalidRetry) + } + } + TokenInner::Validation { issued } => { + if server_config + .validation_token_log + .as_ref() + .map(|log| { + let reuse_ok = log.check_and_insert(token.rand, issued, server_config.validation_token_lifetime).is_ok(); + if !reuse_ok { + debug!("rejecting token from NEW_TOKEN frame because detected as reuse"); + } + issued + server_config.validation_token_lifetime > SystemTime::now() && reuse_ok + }) + .unwrap_or(false) + { + trace!("accepting token from NEW_TOKEN frame"); + Ok((None, header.dst_cid)) + } else { + Err(TokenDecodeError::UnknownToken) + } } }); match valid_token { - Ok((retry_src_cid, orig_dst_cid)) => (retry_src_cid, orig_dst_cid), + Ok((retry_src_cid, orig_dst_cid)) => (retry_src_cid, orig_dst_cid, true), Err(TokenDecodeError::UnknownToken) => { trace!("ignoring unknown token"); - (None, header.dst_cid) + (None, header.dst_cid, false) } Err(TokenDecodeError::InvalidRetry) => { debug!("rejecting invalid retry token"); @@ -550,6 +567,7 @@ impl Endpoint { retry_src_cid, orig_dst_cid, incoming_idx, + validated, improper_drop_warner: IncomingImproperDropWarner, })) } @@ -755,7 +773,7 @@ impl Endpoint { /// Respond with a retry packet, requiring the client to retry with address validation /// - /// Errors if `incoming.remote_address_validated()` is true. + /// Errors if `incoming.may_retry()` is false. pub fn retry(&mut self, incoming: Incoming, buf: &mut Vec) -> Result { if incoming.remote_address_validated() { return Err(RetryError(incoming)); @@ -774,7 +792,7 @@ impl Endpoint { // retried by the application layer. let loc_cid = self.local_cid_generator.generate_cid(); - let token_inner = TokenInner { + let token_inner = TokenInner::Retry { orig_dst_cid: incoming.packet.header.dst_cid, issued: SystemTime::now(), }; @@ -1204,6 +1222,7 @@ pub struct Incoming { retry_src_cid: Option, orig_dst_cid: ConnectionId, incoming_idx: usize, + validated: bool, improper_drop_warner: IncomingImproperDropWarner, } @@ -1224,8 +1243,19 @@ impl Incoming { /// /// This means that the sender of the initial packet has proved that they can receive traffic /// sent to `self.remote_address()`. + /// + /// If `self.remote_address_validated()` is false, `self.may_retry()` is guaranteed to be true. + /// The inverse is not guaranteed. pub fn remote_address_validated(&self) -> bool { - self.retry_src_cid.is_some() + self.validated + } + + /// Whether it is legal to respond with a retry packet + /// + /// If `self.remote_address_validated()` is false, `self.may_retry()` is guaranteed to be true. + /// The inverse is not guaranteed. + pub fn may_retry(&self) -> bool { + self.retry_src_cid.is_none() } /// The original destination connection ID sent by the client @@ -1244,6 +1274,7 @@ impl fmt::Debug for Incoming { .field("retry_src_cid", &self.retry_src_cid) .field("orig_dst_cid", &self.orig_dst_cid) .field("incoming_idx", &self.incoming_idx) + .field("validated", &self.validated) // improper drop warner contains no information .finish_non_exhaustive() } diff --git a/quinn-proto/src/frame.rs b/quinn-proto/src/frame.rs index 0bc7f34ad..421acc313 100644 --- a/quinn-proto/src/frame.rs +++ b/quinn-proto/src/frame.rs @@ -147,7 +147,7 @@ pub(crate) enum Frame { ResetStream(ResetStream), StopSending(StopSending), Crypto(Crypto), - NewToken { token: Bytes }, + NewToken(NewToken), Stream(Stream), MaxData(VarInt), MaxStreamData { id: StreamId, offset: u64 }, @@ -200,7 +200,7 @@ impl Frame { PathResponse(_) => FrameType::PATH_RESPONSE, NewConnectionId { .. } => FrameType::NEW_CONNECTION_ID, Crypto(_) => FrameType::CRYPTO, - NewToken { .. } => FrameType::NEW_TOKEN, + NewToken(_) => FrameType::NEW_TOKEN, Datagram(_) => FrameType(*DATAGRAM_TYS.start()), AckFrequency(_) => FrameType::ACK_FREQUENCY, ImmediateAck => FrameType::IMMEDIATE_ACK, @@ -525,6 +525,23 @@ impl Crypto { } } +#[derive(Debug, Clone)] +pub(crate) struct NewToken { + pub(crate) token: Bytes, +} + +impl NewToken { + pub(crate) fn encode(&self, out: &mut W) { + out.write(FrameType::NEW_TOKEN); + out.write_var(self.token.len() as u64); + out.put_slice(&self.token); + } + + pub(crate) fn size(&self) -> usize { + 1 + VarInt::from_u64(self.token.len() as u64).unwrap().size() + self.token.len() + } +} + pub(crate) struct Iter { // TODO: ditch io::Cursor after bytes 0.5 bytes: io::Cursor, @@ -676,9 +693,9 @@ impl Iter { offset: self.bytes.get_var()?, data: self.take_len()?, }), - FrameType::NEW_TOKEN => Frame::NewToken { + FrameType::NEW_TOKEN => Frame::NewToken(NewToken { token: self.take_len()?, - }, + }), FrameType::HANDSHAKE_DONE => Frame::HandshakeDone, FrameType::ACK_FREQUENCY => Frame::AckFrequency(AckFrequency { sequence: self.bytes.get()?, diff --git a/quinn-proto/src/lib.rs b/quinn-proto/src/lib.rs index 2adb4b46e..9625f6458 100644 --- a/quinn-proto/src/lib.rs +++ b/quinn-proto/src/lib.rs @@ -88,6 +88,14 @@ pub use crate::cid_generator::{ mod token; use token::{ResetToken, Token}; +mod token_log; +pub use token_log::{TokenLog, TokenReuseError}; + +#[cfg(feature = "fastbloom")] +mod bloom_token_log; +#[cfg(feature = "fastbloom")] +pub use bloom_token_log::BloomTokenLog; + #[cfg(feature = "arbitrary")] use arbitrary::Arbitrary; diff --git a/quinn-proto/src/token.rs b/quinn-proto/src/token.rs index 9cf942ab4..9eb57d448 100644 --- a/quinn-proto/src/token.rs +++ b/quinn-proto/src/token.rs @@ -14,23 +14,31 @@ use crate::{ Duration, SystemTime, RESET_TOKEN_SIZE, UNIX_EPOCH, }; -/// A retry token +/// An address validation / retry token /// /// The data in this struct is encoded and encrypted in the context of not only a handshake token /// key, but also a client socket address. pub(crate) struct Token { /// Randomly generated value, which must be unique, and is visible to the client pub(crate) rand: u128, - /// Content which is encrypted from the client + /// Content depending on how token originated, which is encrypted from the client pub(crate) inner: TokenInner, } -/// Content of [`Token`] that is encrypted from the client -pub(crate) struct TokenInner { - /// The destination connection ID set in the very first packet from the client - pub(crate) orig_dst_cid: ConnectionId, - /// The time at which this token was issued - pub(crate) issued: SystemTime, +/// Content of [`Token`] that depends on how token originated, and is encrypted from the client +pub(crate) enum TokenInner { + /// Token that originated from a Retry packet + Retry { + /// The destination connection ID set in the very first packet from the client + orig_dst_cid: ConnectionId, + /// The time at which this token was issued + issued: SystemTime, + }, + /// Token that originated from a NEW_TOKEN frame + Validation { + /// The time at which this token was issued + issued: SystemTime, + }, } impl Token { @@ -84,23 +92,51 @@ impl Token { impl TokenInner { /// Encode without encryption fn encode(&self, buf: &mut Vec, address: &SocketAddr) { - encode_socket_addr(buf, address); - self.orig_dst_cid.encode_long(buf); - encode_time(buf, self.issued); + match *self { + Self::Retry { + orig_dst_cid, + issued, + } => { + buf.push(0); + encode_socket_addr(buf, address); + orig_dst_cid.encode_long(buf); + encode_time(buf, issued); + } + Self::Validation { issued } => { + buf.push(1); + encode_ip_addr(buf, &address.ip()); + encode_time(buf, issued); + } + } } /// Try to decode without encryption, but do validate that the address is acceptable fn decode(buf: &mut B, address: &SocketAddr) -> Result { - let token_address = decode_socket_addr(buf).ok_or(TokenDecodeError::UnknownToken)?; - if token_address != *address { - return Err(TokenDecodeError::InvalidRetry); + match buf.get::().ok().ok_or(TokenDecodeError::UnknownToken)? { + 0 => { + let token_address = + decode_socket_addr(buf).ok_or(TokenDecodeError::UnknownToken)?; + if token_address != *address { + return Err(TokenDecodeError::InvalidRetry); + } + let orig_dst_cid = + ConnectionId::decode_long(buf).ok_or(TokenDecodeError::UnknownToken)?; + let issued = decode_time(buf).ok_or(TokenDecodeError::UnknownToken)?; + Ok(Self::Retry { + orig_dst_cid, + issued, + }) + } + 1 => { + let token_address = decode_ip_addr(buf).ok_or(TokenDecodeError::UnknownToken)?; + if token_address != address.ip() { + return Err(TokenDecodeError::UnknownToken); + } + let issued = decode_time(buf).ok_or(TokenDecodeError::UnknownToken)?; + Ok(Self::Validation { issued }) + } + _ => Err(TokenDecodeError::UnknownToken), } - let orig_dst_cid = ConnectionId::decode_long(buf).ok_or(TokenDecodeError::UnknownToken)?; - let issued = decode_time(buf).ok_or(TokenDecodeError::UnknownToken)?; - Ok(Self { - orig_dst_cid, - issued, - }) } } @@ -165,6 +201,9 @@ pub(crate) enum TokenDecodeError { /// /// > If the token is invalid, then the server SHOULD proceed as if the client did not have a /// > validated address, including potentially sending a Retry packet. + /// + /// That said, this error is also used for tokens that _can_ be unambiguously decrypted/decoded + /// as a token from a NEW_TOKEN frame, but which are simply not valid. UnknownToken, /// Token was unambiguously from a Retry packet, and was not valid. /// @@ -255,7 +294,7 @@ mod test { let issued_1 = UNIX_EPOCH + Duration::new(42, 0); // Fractional seconds would be lost let token = Token { rand: rng.gen(), - inner: TokenInner { + inner: TokenInner::Retry { orig_dst_cid: orig_dst_cid_1, issued: issued_1, }, @@ -264,8 +303,16 @@ mod test { let token_2 = Token::decode(&prk, &addr, &encoded).expect("token didn't validate"); assert_eq!(token.rand, token_2.rand); - assert_eq!(orig_dst_cid_1, token_2.inner.orig_dst_cid); - assert_eq!(issued_1, token_2.inner.issued); + match token_2.inner { + TokenInner::Retry { + orig_dst_cid: orig_dst_cid_2, + issued: issued_2, + } => { + assert_eq!(orig_dst_cid_1, orig_dst_cid_2); + assert_eq!(issued_1, issued_2); + } + _ => panic!("token decoded as wrong variant"), + } } #[test] diff --git a/quinn-proto/src/token_log.rs b/quinn-proto/src/token_log.rs new file mode 100644 index 000000000..b2d609648 --- /dev/null +++ b/quinn-proto/src/token_log.rs @@ -0,0 +1,41 @@ +//! Limiting clients' ability to reuse tokens from NEW_TOKEN frames + +use crate::{Duration, SystemTime}; + +/// Error for when a validation token may have been reused +pub struct TokenReuseError; + +/// Responsible for limiting clients' ability to reuse validation tokens +/// +/// [_RFC 9000 ยง 8.1.4:_](https://www.rfc-editor.org/rfc/rfc9000.html#section-8.1.4) +/// +/// > Attackers could replay tokens to use servers as amplifiers in DDoS attacks. To protect +/// > against such attacks, servers MUST ensure that replay of tokens is prevented or limited. +/// > Servers SHOULD ensure that tokens sent in Retry packets are only accepted for a short time, +/// > as they are returned immediately by clients. Tokens that are provided in NEW_TOKEN frames +/// > (Section 19.7) need to be valid for longer but SHOULD NOT be accepted multiple times. +/// > Servers are encouraged to allow tokens to be used only once, if possible; tokens MAY include +/// > additional information about clients to further narrow applicability or reuse. +/// +/// `TokenLog` pertains only to tokens provided in NEW_TOKEN frames. +pub trait TokenLog: Send + Sync { + /// Record that the token was used and, ideally, return a token reuse error if the token was + /// already used previously + /// + /// False negatives and false positives are both permissible. Called when a client uses an + /// address validation token. + /// + /// Parameters: + /// - `rand`: A server-generated random unique value for the token. + /// - `issued`: The time the server issued the token. + /// - `lifetime`: The expiration time of address validation tokens sent via NEW_TOKEN frames, + /// as configured by [`ServerConfig::validation_token_lifetime`][1]. + /// + /// [1]: crate::ServerConfig::validation_token_lifetime + fn check_and_insert( + &self, + rand: u128, + issued: SystemTime, + lifetime: Duration, + ) -> Result<(), TokenReuseError>; +} diff --git a/quinn/Cargo.toml b/quinn/Cargo.toml index a061520d7..791a21659 100644 --- a/quinn/Cargo.toml +++ b/quinn/Cargo.toml @@ -12,7 +12,7 @@ edition.workspace = true rust-version.workspace = true [features] -default = ["log", "platform-verifier", "runtime-tokio", "rustls-ring"] +default = ["log", "platform-verifier", "runtime-tokio", "rustls-ring", "fastbloom"] # Enables `Endpoint::client` and `Endpoint::server` conveniences aws-lc-rs = ["proto/aws-lc-rs"] aws-lc-rs-fips = ["proto/aws-lc-rs-fips"] @@ -32,6 +32,8 @@ ring = ["proto/ring"] runtime-tokio = ["tokio/time", "tokio/rt", "tokio/net"] runtime-async-std = ["async-io", "async-std"] runtime-smol = ["async-io", "smol"] +# Enables BloomTokenLog +fastbloom = ["proto/fastbloom"] # Configure `tracing` to log events via `log` if no `tracing` subscriber exists. log = ["tracing/log", "proto/log", "udp/log"] diff --git a/quinn/src/incoming.rs b/quinn/src/incoming.rs index fb57db6c2..570de1b40 100644 --- a/quinn/src/incoming.rs +++ b/quinn/src/incoming.rs @@ -48,7 +48,7 @@ impl Incoming { /// Respond with a retry packet, requiring the client to retry with address validation /// - /// Errors if `remote_address_validated()` is true. + /// Errors if `may_retry()` is false. pub fn retry(mut self) -> Result<(), RetryError> { let state = self.0.take().unwrap(); state.endpoint.retry(state.inner).map_err(|e| { @@ -79,10 +79,21 @@ impl Incoming { /// /// This means that the sender of the initial packet has proved that they can receive traffic /// sent to `self.remote_address()`. + /// + /// If `self.remote_address_validated()` is false, `self.may_retry()` is guaranteed to be true. + /// The inverse is not guaranteed. pub fn remote_address_validated(&self) -> bool { self.0.as_ref().unwrap().inner.remote_address_validated() } + /// Whether it is legal to respond with a retry packet + /// + /// If `self.remote_address_validated()` is false, `self.may_retry()` is guaranteed to be true. + /// The inverse is not guaranteed. + pub fn may_retry(&self) -> bool { + self.0.as_ref().unwrap().inner.may_retry() + } + /// The original destination CID when initiating the connection pub fn orig_dst_cid(&self) -> ConnectionId { *self.0.as_ref().unwrap().inner.orig_dst_cid() @@ -107,7 +118,7 @@ struct State { /// Error for attempting to retry an [`Incoming`] which already bears an address validation token /// from a previous retry #[derive(Debug, Error)] -#[error("retry() with validated Incoming")] +#[error("retry() with Incoming already validated by retry")] pub struct RetryError(Incoming); impl RetryError { diff --git a/quinn/src/lib.rs b/quinn/src/lib.rs index 84a3821f6..0019b0352 100644 --- a/quinn/src/lib.rs +++ b/quinn/src/lib.rs @@ -61,12 +61,15 @@ mod runtime; mod send_stream; mod work_limiter; +#[cfg(feature = "fastbloom")] +pub use proto::BloomTokenLog; pub use proto::{ congestion, crypto, AckFrequencyConfig, ApplicationClose, Chunk, ClientConfig, ClosedStream, ConfigError, ConnectError, ConnectionClose, ConnectionError, ConnectionId, ConnectionIdGenerator, ConnectionStats, Dir, EcnCodepoint, EndpointConfig, FrameStats, - FrameType, IdleTimeout, MtuDiscoveryConfig, PathStats, ServerConfig, Side, StreamId, Transmit, - TransportConfig, TransportErrorCode, UdpStats, VarInt, VarIntBoundsExceeded, Written, + FrameType, IdleTimeout, MtuDiscoveryConfig, PathStats, ServerConfig, Side, StreamId, TokenLog, + TokenReuseError, Transmit, TransportConfig, TransportErrorCode, UdpStats, VarInt, + VarIntBoundsExceeded, Written, }; #[cfg(any(feature = "rustls-aws-lc-rs", feature = "rustls-ring"))] pub use rustls;