From 04db199d676dcd7dd530dcc73812f7425b486ae2 Mon Sep 17 00:00:00 2001 From: Marcus Asteborg Date: Sat, 5 Sep 2026 11:15:22 -0700 Subject: [PATCH] Handshake timer --- .github/workflows/cargo.yml | 2 +- CHANGELOG.md | 3 + src/auto.rs | 67 +++--- src/config.rs | 34 ++- src/dtls12/client.rs | 15 +- src/dtls12/engine.rs | 192 +++++++-------- src/dtls12/server.rs | 14 +- src/dtls13/client.rs | 11 +- src/dtls13/engine.rs | 223 +++++++++--------- src/dtls13/server.rs | 10 + src/error.rs | 6 + src/lib.rs | 13 +- src/timer.rs | 182 +++++++++++++- tests/dtls12/common.rs | 2 + tests/dtls13/common.rs | 2 + tests/dtls13/conformance.rs | 83 +++++++ tests/dtls13/edge.rs | 4 +- tests/dtls13/retransmit.rs | 16 +- tests/dtls13/wolfssl.rs | 2 +- tests/timing.rs | 458 ++++++++++++++++++++++++++++++++++++ 20 files changed, 1042 insertions(+), 297 deletions(-) create mode 100644 tests/timing.rs diff --git a/.github/workflows/cargo.yml b/.github/workflows/cargo.yml index f4aad512..c051b8c7 100644 --- a/.github/workflows/cargo.yml +++ b/.github/workflows/cargo.yml @@ -37,7 +37,7 @@ jobs: - name: Build run: cargo +${{steps.toolchain.outputs.name}} build --no-default-features --features "${{ matrix.feature }}" - name: Test - if: matrix.feature == 'aws-lc-rs' || matrix.feature == 'rust-crypto' + if: matrix.feature == 'rcgen' || matrix.feature == 'rust-crypto' run: cargo +${{steps.toolchain.outputs.name}} test --no-default-features --features "${{ matrix.feature }}" snowflake: diff --git a/CHANGELOG.md b/CHANGELOG.md index 045bba72..1f026f25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Unreleased + * Start the handshake deadline at the first sent packet, not at construction #161 + * Apply flight retry jitter as ±25% of the RTO instead of ±250ms #161 + # 0.7.5 * Reject internally reordered DTLS 1.2 datagrams #169 diff --git a/src/auto.rs b/src/auto.rs index f220a3fd..9dbe3103 100644 --- a/src/auto.rs +++ b/src/auto.rs @@ -16,7 +16,7 @@ /// and falls back to DTLS 1.2 via [`Error::Dtls12Fallback`] if the /// reassembled ClientHello does not offer DTLS 1.3. use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::Instant; use arrayvec::ArrayVec; @@ -28,6 +28,7 @@ use crate::dtls13::message::Random; use crate::dtls13::message::SignatureAlgorithmsExtension; use crate::dtls13::message::SupportedGroupsExtension; use crate::dtls13::message::UseSrtpExtension; +use crate::timer::HandshakeTimers; use crate::types::NamedGroup; use crate::{Config, CryptoError, DtlsCertificate, Error, Output, SeededRng, TimeoutError}; // Extension type constants @@ -269,10 +270,8 @@ pub(crate) struct ClientPending { needs_send: bool, /// Last time handle_timeout was called. last_now: Instant, - /// When to retransmit the wire_packet. - retransmit_at: Option, - /// How many retransmits have occurred. - retransmit_count: usize, + timers: HandshakeTimers, + rng: SeededRng, } impl ClientPending { @@ -283,6 +282,9 @@ impl ClientPending { ) -> Result { let hybrid = HybridClientHello::new(&config)?; let wire_packet = hybrid.wire_packet(); + let mut rng = SeededRng::new(config.rng_seed()); + let mut timers = HandshakeTimers::new(&config, &mut rng); + timers.begin_flight(&mut rng); Ok(ClientPending { hybrid, config, @@ -290,31 +292,22 @@ impl ClientPending { wire_packet, needs_send: true, last_now: now, - retransmit_at: None, - retransmit_count: 0, + timers, + rng, }) } pub fn handle_timeout(&mut self, now: Instant) -> Result<(), Error> { self.last_now = now; - // Arm initial retransmit timer on first call - if self.retransmit_at.is_none() { - self.retransmit_at = Some(now + Duration::from_secs(1)); - return Ok(()); - } - if let Some(deadline) = self.retransmit_at { - if now >= deadline { - if self.retransmit_count >= self.config.flight_retries() { - return Err(Error::Timeout(TimeoutError::HybridClientHello)); - } - self.retransmit_count += 1; - self.needs_send = true; - // Exponential backoff: 2s, 4s, 8s, ... - let shift = self.retransmit_count.min(5) as u32; - let rto = Duration::from_secs(1u64 << shift); - self.retransmit_at = Some(now + rto); - } - } + self.needs_send |= self + .timers + .handle_timeout(now, &mut self.rng) + .map_err(|error| { + Error::Timeout(match error { + TimeoutError::Handshake => TimeoutError::HybridClientHello, + other => other, + }) + })?; Ok(()) } @@ -327,16 +320,30 @@ impl ClientPending { } self.needs_send = false; buf[..len].copy_from_slice(&self.wire_packet); + self.timers.start_handshake(); + self.timers.flight_sent(); return Output::Packet(&buf[..len]); } - let next = self - .retransmit_at - .unwrap_or(self.last_now + Duration::from_secs(1)); + let next = self.timers.poll_timeout(self.last_now); Output::Timeout(next) } - pub fn into_parts(self) -> (HybridClientHello, Arc, DtlsCertificate, Instant) { - (self.hybrid, self.config, self.certificate, self.last_now) + pub fn into_parts( + self, + ) -> ( + HybridClientHello, + Arc, + DtlsCertificate, + Instant, + HandshakeTimers, + ) { + ( + self.hybrid, + self.config, + self.certificate, + self.last_now, + self.timers, + ) } } diff --git a/src/config.rs b/src/config.rs index b84dc122..0623b05a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -362,8 +362,8 @@ impl ConfigBuilder { /// Set the time of first retry. /// - /// Every flight restarts with this value. - /// Doubled for every retry with a ±25% jitter. + /// Every flight, including a DTLS 1.3 KeyUpdate, restarts with this value. + /// Doubled for every retry with a ±25% jitter. Must be nonzero. /// Defaults to 1 second. pub fn flight_start_rto(mut self, rto: Duration) -> Self { self.flight_start_rto = rto; @@ -372,6 +372,10 @@ impl ConfigBuilder { /// Set the max number of retries per flight. /// + /// Excludes the initial send; 0 disables retransmission. Timer-driven and + /// duplicate-triggered resends share this budget. Exhausting it fails the + /// handshake, which may happen before [`Self::handshake_timeout`]: with the + /// defaults an unanswered flight gives up after roughly 31 seconds. /// Defaults to 4. pub fn flight_retries(mut self, retries: usize) -> Self { self.flight_retries = retries; @@ -380,7 +384,11 @@ impl ConfigBuilder { /// Set the timeout for the entire handshake, regardless of flights. /// - /// Defaults to 40 seconds. + /// Starts when a client emits its first ClientHello packet, or a server + /// accepts its first ClientHello fragment, and is one absolute deadline + /// across cookie exchanges, retransmissions and Auto version selection. + /// Idle time before that is free. It no longer applies once connected. + /// Must be nonzero. Defaults to 40 seconds. pub fn handshake_timeout(mut self, timeout: Duration) -> Self { self.handshake_timeout = timeout; self @@ -518,6 +526,13 @@ impl ConfigBuilder { return Err(Error::ConfigError(ConfigError::AeadEncryptionLimitTooSmall)); } + if self.handshake_timeout.is_zero() { + return Err(Error::ConfigError(ConfigError::HandshakeTimeoutTooSmall)); + } + if self.flight_start_rto.is_zero() { + return Err(Error::ConfigError(ConfigError::FlightStartRtoTooSmall)); + } + // Validate cipher suite filters: at least one version must have suites. // Mirror Config::dtls12_cipher_suites() by dropping PSK suites when no PSK // is configured, so a PSK-only filter without a PSK resolver fails fast. @@ -734,6 +749,19 @@ mod tests { } } + #[test] + fn rejects_zero_timing() { + let zero = Duration::ZERO; + assert_eq!( + Config::builder().handshake_timeout(zero).build().err(), + Some(Error::ConfigError(ConfigError::HandshakeTimeoutTooSmall)) + ); + assert_eq!( + Config::builder().flight_start_rto(zero).build().err(), + Some(Error::ConfigError(ConfigError::FlightStartRtoTooSmall)) + ); + } + #[test] fn accepts_minimum_aead_limit() { Config::builder() diff --git a/src/dtls12/client.rs b/src/dtls12/client.rs index cf7150db..7c4ff8d1 100644 --- a/src/dtls12/client.rs +++ b/src/dtls12/client.rs @@ -29,6 +29,7 @@ use crate::dtls12::message::{CompressionMethod, ContentType, Cookie}; use crate::dtls12::message::{DigitallySigned, Dtls12CipherSuite}; use crate::dtls12::message::{ExtensionType, KeyExchangeAlgorithm, MessageType, ProtocolVersion}; use crate::dtls12::message::{Random, SessionId, SignatureAndHashAlgorithm, UseSrtpExtension}; +use crate::timer::HandshakeTimers; use crate::{Config, DtlsCertificate, Error, InternalError, KeyingMaterial, Output}; /// DTLS client @@ -129,6 +130,7 @@ impl Client { config: std::sync::Arc, certificate: DtlsCertificate, now: Instant, + timers: HandshakeTimers, ) -> Result { assert!( !certificate.certificate.is_empty(), @@ -149,15 +151,7 @@ impl Client { }; let mut engine = Engine::new(config, auth); engine.set_client(true); - // The hybrid ClientHello was sent with message_seq=0 outside this - // engine. Advance the counter so the with-cookie CH gets message_seq=1 - // per RFC 6347 §4.2.2. - engine.set_next_handshake_seq_no(1); - // Inject the hybrid CH into the transcript so it matches the server's - // transcript when the server skips HelloVerifyRequest. - engine.transcript.extend_from_slice(handshake_fragment); - // Advance epoch-0 record sequence past the hybrid CH record. - engine.advance_epoch_0_sequence(); + engine.inject_hybrid_client_hello(handshake_fragment, timers); let extension_data = engine.pop_buffer(); let defragment_buffer = engine.pop_buffer(); @@ -212,6 +206,9 @@ impl Client { } pub fn poll_output<'a>(&mut self, buf: &'a mut [u8]) -> Output<'a> { + if self.state == State::SendClientHello { + return Output::Timeout(self.last_now); + } if let Some(event) = self.local_events.pop_front() { return event.into_output(buf, &self.server_certificates); } diff --git a/src/dtls12/engine.rs b/src/dtls12/engine.rs index 621d48f0..79cb392c 100644 --- a/src/dtls12/engine.rs +++ b/src/dtls12/engine.rs @@ -1,7 +1,7 @@ use std::mem; use std::sync::Arc; use std::sync::atomic::AtomicBool; -use std::time::{Duration, Instant}; +use std::time::Instant; use super::queue::{QueueRx, QueueTx}; use crate::buffer::{Buf, BufferPool, TmpBuf}; @@ -11,7 +11,7 @@ use crate::dtls12::incoming::{Incoming, Record, RecordHandler}; use crate::dtls12::message::{Body, HashAlgorithm, Header, MessageType, ProtocolVersion, Sequence}; use crate::dtls12::message::{ContentType, DTLSRecord, Dtls12CipherSuite, Handshake}; use crate::error::bounded_error_len; -use crate::timer::ExponentialBackoff; +use crate::timer::{HandshakeTimers, Timeout, deadline}; use crate::window::ReplayWindow; use crate::{Config, Error, InternalError, Output, SeededRng}; @@ -83,20 +83,13 @@ pub struct Engine { /// The records that have been sent in the current flight. flight_saved_records: Vec, - /// Flight backoff - flight_backoff: ExponentialBackoff, - - /// Timeout for the current flight - flight_timeout: Timeout, + timers: HandshakeTimers, /// Cooldown for duplicate-triggered resends of the current flight. - /// Disabled allows a resend; Unarmed and Armed suppress it. Expiry only + /// Disabled allows a resend; Pending and Armed suppress it. Expiry only /// allows another duplicate response, including after periodic retries stop. flight_dupe_timeout: Timeout, - /// Global timeout for the entire connect operation. - connect_timeout: Timeout, - /// Whether we are ready to release application data from poll_output. release_app_data: bool, @@ -119,13 +112,6 @@ pub struct Engine { close_notify_reported: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Timeout { - Disabled, - Unarmed, - Armed(Instant), -} - #[derive(Debug)] struct Entry { content_type: ContentType, @@ -143,8 +129,7 @@ impl Engine { pub fn new(config: Arc, auth: AuthMode) -> Self { let mut rng = SeededRng::new(config.rng_seed()); - let flight_backoff = - ExponentialBackoff::new(config.flight_start_rto(), config.flight_retries(), &mut rng); + let timers = HandshakeTimers::new(&config, &mut rng); let crypto_context = CryptoContext::new(auth, Arc::clone(&config)); let mut buffers_free = BufferPool::default(); @@ -169,10 +154,8 @@ impl Engine { transcript, replay: ReplayWindow::new(), flight_saved_records: Vec::new(), - flight_backoff, - flight_timeout: Timeout::Unarmed, + timers, flight_dupe_timeout: Timeout::Disabled, - connect_timeout: Timeout::Unarmed, release_app_data: false, peer_handshake_confirmed: false, close_notify_received: false, @@ -184,19 +167,26 @@ impl Engine { self.is_client = is_client; } - /// Set the next outgoing handshake message sequence number. - /// - /// Used by `Client::new_from_hybrid` to account for the hybrid - /// ClientHello (message_seq=0) that was already sent outside this engine. - pub fn set_next_handshake_seq_no(&mut self, seq: u16) { - self.next_handshake_seq_no = seq; + pub fn handshake_deadline(&self) -> Timeout { + self.timers.handshake_deadline() } - /// Advance the epoch-0 record sequence number by one. - /// - /// Used by `Client::new_from_hybrid` so subsequent epoch-0 records - /// don't reuse the sequence number of the hybrid ClientHello record. - pub fn advance_epoch_0_sequence(&mut self) { + pub fn set_handshake_deadline(&mut self, deadline: Timeout) { + self.timers.set_handshake_deadline(deadline); + } + + /// Restore an already emitted hybrid ClientHello and its outstanding timers. + pub fn inject_hybrid_client_hello(&mut self, fragment: &[u8], timers: HandshakeTimers) { + self.timers = timers; + self.transcript.extend_from_slice(fragment); + let mut saved_fragment = self.buffers_free.pop(); + saved_fragment.extend_from_slice(fragment); + self.flight_saved_records.push(Entry { + content_type: ContentType::Handshake, + epoch: 0, + fragment: saved_fragment, + }); + self.next_handshake_seq_no = 1; self.sequence_epoch_0.sequence_number += 1; } @@ -316,6 +306,14 @@ impl Engine { return Err(Error::RenegotiationAttempt); } + let header = &handshake.header; + if !self.is_client + && header.msg_type == MessageType::ClientHello + && header.fragment_offset + header.fragment_length <= header.length + { + self.timers.start_handshake(); + } + let search_result = self.queue_rx.binary_search_by(|item| { let key_other = item .first() @@ -393,25 +391,9 @@ impl Engine { } pub fn handle_timeout(&mut self, now: Instant) -> Result<(), Error> { - if self.connect_timeout == Timeout::Unarmed { - debug!( - "Connect timeout in: {:.03}s", - self.config.handshake_timeout().as_secs_f32() - ); - let timeout = now + self.config.handshake_timeout(); - self.connect_timeout = Timeout::Armed(timeout); - } - if self.flight_timeout == Timeout::Unarmed { - debug!( - "Flight timeout in: {:.03}s", - self.flight_backoff.rto().as_secs_f32() - ); - let timeout = now + self.flight_backoff.rto(); - self.flight_timeout = Timeout::Armed(timeout); - } match self.flight_dupe_timeout { - Timeout::Unarmed => { - self.flight_dupe_timeout = Timeout::Armed(now + self.flight_backoff.rto()); + Timeout::Pending => { + self.flight_dupe_timeout = deadline(now, self.timers.rto()); } Timeout::Armed(deadline) if now >= deadline => { self.flight_dupe_timeout = Timeout::Disabled; @@ -419,32 +401,13 @@ impl Engine { _ => {} } - // The connect timeout is the overall timeout for establishing the connection - if let Timeout::Armed(connect_timeout) = self.connect_timeout { - if now >= connect_timeout { - return Err(Error::Timeout(crate::TimeoutError::Connect)); - } - } - - // If there is no flight timeout, we have already checked the global connect timeout. - let Timeout::Armed(flight_timeout) = self.flight_timeout else { - return Ok(()); - }; - - if now >= flight_timeout { - if self.flight_backoff.can_retry() { - self.flight_backoff.attempt(&mut self.rng); - debug!( - "Re-arm flight timeout due to resend in {}", - self.flight_backoff.rto().as_secs_f32() - ); - let timeout = now + self.flight_backoff.rto(); - self.flight_timeout = Timeout::Armed(timeout); - self.flight_resend("flight timeout")?; - self.flight_dupe_timeout = Timeout::Armed(timeout); - } else { - return Err(Error::Timeout(crate::TimeoutError::Handshake)); - } + if self + .timers + .handle_timeout(now, &mut self.rng) + .map_err(Error::Timeout)? + { + self.flight_resend("flight timeout")?; + self.flight_dupe_timeout = deadline(now, self.timers.rto()); } Ok(()) @@ -461,7 +424,13 @@ impl Engine { }; match self.poll_packet_tx(buf) { - PollOutput::Data(p) => return Output::Packet(p), + PollOutput::Data(p) => { + if self.is_client { + self.timers.start_handshake(); + } + self.timers.flight_sent(); + return Output::Packet(p); + } PollOutput::BufferTooSmall { needed } => return Output::BufferTooSmall { needed }, PollOutput::None(_) => {} } @@ -541,39 +510,24 @@ impl Engine { } fn poll_timeout(&self, now: Instant) -> Instant { - let timeouts = [ - self.connect_timeout, - self.flight_timeout, - self.flight_dupe_timeout, - ]; - // Request an immediate handle_timeout(now) to arm pending timers with - // fresh caller time. An armed connection deadline must not hide them. - if timeouts.contains(&Timeout::Unarmed) { - return now; + let timeout = self.timers.poll_timeout(now); + match self.flight_dupe_timeout { + Timeout::Pending => now, + Timeout::Armed(deadline) => timeout.min(deadline), + _ => timeout, } - const DISTANT_FUTURE: Duration = Duration::from_secs(10 * 365 * 24 * 60 * 60); - timeouts - .into_iter() - .filter_map(|timeout| match timeout { - Timeout::Armed(deadline) => Some(deadline), - _ => None, - }) - .min() - .unwrap_or(now + DISTANT_FUTURE) } pub fn flight_begin(&mut self, flight_no: u8) { debug!("Begin flight {}", flight_no); - self.flight_backoff.reset(&mut self.rng); + self.timers.begin_flight(&mut self.rng); self.flight_clear_resends(); - self.flight_timeout = Timeout::Unarmed; self.flight_dupe_timeout = Timeout::Disabled; } pub fn flight_stop_resend_timers(&mut self) { debug!("Stop connect and flight timeouts"); - self.flight_timeout = Timeout::Disabled; - self.connect_timeout = Timeout::Disabled; + self.timers.stop(); // The client stops its resend timer only once it has received the // server's final flight, which proves the server received the client's @@ -594,18 +548,14 @@ impl Engine { } fn flight_resend_on_dupe(&mut self) -> Result<(), Error> { - if self.flight_dupe_timeout != Timeout::Disabled { + if self.flight_dupe_timeout != Timeout::Disabled + || self.flight_saved_records.is_empty() + || !self.timers.request_resend(&mut self.rng) + { return Ok(()); } self.flight_resend("dupe triggers resend")?; - self.flight_backoff.attempt(&mut self.rng); - self.flight_dupe_timeout = Timeout::Unarmed; - // Restart the regular flight timer too, so its old deadline cannot - // produce another resend immediately after this one. Keep final-flight - // periodic retries disabled; only the duplicate cooldown remains active. - if self.flight_timeout != Timeout::Disabled { - self.flight_timeout = Timeout::Unarmed; - } + self.flight_dupe_timeout = Timeout::Pending; Ok(()) } @@ -1091,9 +1041,8 @@ impl Engine { pub fn abort(&mut self) { self.queue_tx.clear(); self.flight_saved_records.clear(); - self.flight_timeout = Timeout::Disabled; + self.timers.stop(); self.flight_dupe_timeout = Timeout::Disabled; - self.connect_timeout = Timeout::Disabled; } /// Pop a buffer from the buffer pool for temporary use @@ -1457,6 +1406,8 @@ impl RecordHandler for Engine { #[cfg(test)] mod tests { + use std::time::Duration; + use super::*; fn poll_packets(engine: &mut Engine, now: Instant) -> (usize, Instant) { @@ -1644,4 +1595,21 @@ mod tests { "discarding a duplicate must preserve its reusable receive allocation" ); } + + #[test] + fn timing_resend_queue_failure_is_fatal() { + let now = Instant::now(); + let mut engine = resend_engine(now); + let (_, retry_at) = poll_packets(&mut engine, now); + engine.config = Arc::new( + Config::builder() + .max_queue_tx(0) + .build() + .expect("inject exhausted transmit capacity"), + ); + assert_eq!( + engine.handle_timeout(retry_at), + Err(Error::TransmitQueueFull) + ); + } } diff --git a/src/dtls12/server.rs b/src/dtls12/server.rs index 515a9287..6539eb23 100644 --- a/src/dtls12/server.rs +++ b/src/dtls12/server.rs @@ -36,6 +36,7 @@ use crate::dtls12::message::{ServerHello, SessionId, SignatureAlgorithm}; use crate::dtls12::message::{SignatureAlgorithmsExtension, SignatureAndHashAlgorithm}; use crate::dtls12::message::{SignatureAndHashAlgorithmVec, SrtpProfileId}; use crate::dtls12::message::{SrtpProfileVec, SupportedGroupsExtension, UseSrtpExtension}; +use crate::timer::Timeout; use crate::{Config, Error, InternalError, Output}; /// Length of the random dummy PSK used when identity resolution fails. @@ -200,14 +201,25 @@ impl Server { && !self.engine.has_pending_close_output() } + pub fn set_handshake_deadline(&mut self, deadline: Timeout) { + self.engine.set_handshake_deadline(deadline); + } + pub fn handle_packet(&mut self, packet: &[u8]) -> Result<(), Error> { + let deadline = self.engine.handshake_deadline(); match self .engine .parse_packet(packet) .and_then(|_| self.make_progress()) { Ok(()) => Ok(()), - Err(e) => e.into_public_error().map_or(Ok(()), Err), + Err(e) => { + // A rejected ClientHello must not start the handshake clock. + if self.state == State::AwaitClientHello { + self.engine.set_handshake_deadline(deadline); + } + e.into_public_error().map_or(Ok(()), Err) + } } } diff --git a/src/dtls13/client.rs b/src/dtls13/client.rs index 0407ecee..f2156f31 100644 --- a/src/dtls13/client.rs +++ b/src/dtls13/client.rs @@ -59,6 +59,7 @@ use crate::dtls13::message::SupportedVersionsClientHello; use crate::dtls13::message::SupportedVersionsServerHello; use crate::dtls13::message::UseSrtpExtension; use crate::dtls13::message::parse_cookie_extension; +use crate::timer::HandshakeTimers; use crate::{Error, InternalError, KeyingMaterial, Output}; /// DTLS 1.3 client @@ -177,13 +178,18 @@ impl Client { config: std::sync::Arc, certificate: crate::DtlsCertificate, now: Instant, + timers: HandshakeTimers, ) -> Result { let mut engine = Engine::new(config, certificate); engine.set_client(true); // Inject transcript + sequence state from the hybrid CH that was // already sent on the wire by ClientPending. - engine.inject_hybrid_client_hello(&hybrid.transcript_bytes); + engine.inject_hybrid_client_hello( + &hybrid.transcript_bytes, + &hybrid.handshake_fragment, + timers, + ); let extension_data = engine.pop_buffer(); let defragment_buffer = engine.pop_buffer(); @@ -245,6 +251,9 @@ impl Client { } pub fn poll_output<'a>(&mut self, buf: &'a mut [u8]) -> Output<'a> { + if self.state == State::SendClientHello { + return Output::Timeout(self.last_now); + } if let Some(event) = self.local_events.pop_front() { return event.into_output(buf, &self.server_certificates); } diff --git a/src/dtls13/engine.rs b/src/dtls13/engine.rs index 36a59565..77aa9177 100644 --- a/src/dtls13/engine.rs +++ b/src/dtls13/engine.rs @@ -25,7 +25,7 @@ use crate::dtls13::message::Header; use crate::dtls13::message::KeyUpdateRequest; use crate::dtls13::message::MessageType; use crate::dtls13::message::Sequence; -use crate::timer::ExponentialBackoff; +use crate::timer::{HandshakeTimers, Timeout}; use crate::types::{HashAlgorithm, Random}; use crate::window::ReplayWindow; use crate::{Config, DtlsCertificate, Error, InternalError, Output, SeededRng}; @@ -131,14 +131,7 @@ pub struct Engine { /// The records that have been sent in the current flight. flight_saved_records: ArrayVec, - /// Flight backoff - flight_backoff: ExponentialBackoff, - - /// Timeout for the current flight - flight_timeout: Timeout, - - /// Global timeout for the entire connect operation. - connect_timeout: Timeout, + timers: HandshakeTimers, /// Whether we are ready to release application data from poll_output. release_app_data: bool, @@ -185,13 +178,6 @@ struct RecvEpochEntry { replay: ReplayWindow, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Timeout { - Disabled, - Unarmed, - Armed(Instant), -} - #[derive(Debug)] struct Entry { content_type: ContentType, @@ -211,8 +197,7 @@ impl Engine { pub fn new(config: Arc, certificate: DtlsCertificate) -> Self { let mut rng = SeededRng::new(config.rng_seed()); - let flight_backoff = - ExponentialBackoff::new(config.flight_start_rto(), config.flight_retries(), &mut rng); + let timers = HandshakeTimers::new(&config, &mut rng); let signing_key = config .crypto_provider() @@ -256,9 +241,7 @@ impl Engine { handshake_ack_deadline: None, datagram_sealed: false, flight_saved_records: ArrayVec::new(), - flight_backoff, - flight_timeout: Timeout::Unarmed, - connect_timeout: Timeout::Unarmed, + timers, release_app_data: false, exporter_master_secret: None, app_send_record_count: 0, @@ -278,6 +261,14 @@ impl Engine { self.is_client = is_client; } + pub fn handshake_deadline(&self) -> Timeout { + self.timers.handshake_deadline() + } + + pub fn set_handshake_deadline(&mut self, deadline: Timeout) { + self.timers.set_handshake_deadline(deadline); + } + /// Inject a pre-built hybrid ClientHello into this engine. /// /// Inject the transcript and state from a hybrid ClientHello that was @@ -286,9 +277,24 @@ impl Engine { /// Sets the transcript, advances the handshake sequence number to 1, /// and bumps the epoch-0 record sequence so subsequent records don't /// collide. Does **not** enqueue the record for output — the hybrid - /// CH was already transmitted. - pub fn inject_hybrid_client_hello(&mut self, transcript_bytes: &[u8]) { + /// CH was already transmitted. Retains its outstanding retry state. + pub fn inject_hybrid_client_hello( + &mut self, + transcript_bytes: &[u8], + fragment: &[u8], + timers: HandshakeTimers, + ) { + self.timers = timers; self.transcript.extend_from_slice(transcript_bytes); + let mut saved_fragment = self.buffers_free.pop(); + saved_fragment.extend_from_slice(fragment); + self.flight_saved_records.push(Entry { + content_type: ContentType::Handshake, + epoch: 0, + send_seq: 0, + fragment: saved_fragment, + acked: false, + }); self.next_handshake_seq_no = 1; // Advance past the record sequence used by the hybrid CH. // Defense-in-depth: guard against epoch-0 sequence overflow. @@ -389,7 +395,10 @@ impl Engine { .next(); if let Some(dupe_seq) = maybe_dupe_seq { - if dupe_seq < self.peer_handshake_seq_no { + if dupe_seq < self.peer_handshake_seq_no + && !self.flight_saved_records.is_empty() + && self.timers.request_resend(&mut self.rng) + { if let Err(error) = self.flight_resend("dupe triggers resend") { self.recycle_incoming(incoming); return Err(error); @@ -413,6 +422,14 @@ impl Engine { return Err(Error::RenegotiationAttempt); } + let header = &handshake.header; + if !self.is_client + && header.msg_type == MessageType::ClientHello + && header.fragment_offset + header.fragment_length <= header.length + { + self.timers.start_handshake(); + } + let search_result = self.queue_rx.binary_search_by(|item| { let key_other = item .first() @@ -506,46 +523,12 @@ impl Engine { } pub fn handle_timeout(&mut self, now: Instant) -> Result<(), Error> { - if self.connect_timeout == Timeout::Unarmed { - debug!( - "Connect timeout in: {:.03}s", - self.config.handshake_timeout().as_secs_f32() - ); - let timeout = now + self.config.handshake_timeout(); - self.connect_timeout = Timeout::Armed(timeout); - } - if self.flight_timeout == Timeout::Unarmed { - debug!( - "Flight timeout in: {:.03}s", - self.flight_backoff.rto().as_secs_f32() - ); - let timeout = now + self.flight_backoff.rto(); - self.flight_timeout = Timeout::Armed(timeout); - } - - if let Timeout::Armed(connect_timeout) = self.connect_timeout { - if now >= connect_timeout { - return Err(Error::Timeout(crate::TimeoutError::Connect)); - } - } - - let Timeout::Armed(flight_timeout) = self.flight_timeout else { - return Ok(()); - }; - - if now >= flight_timeout { - if self.flight_backoff.can_retry() { - self.flight_backoff.attempt(&mut self.rng); - debug!( - "Re-arm flight timeout due to resend in {}", - self.flight_backoff.rto().as_secs_f32() - ); - let timeout = now + self.flight_backoff.rto(); - self.flight_timeout = Timeout::Armed(timeout); - self.flight_resend("flight timeout")?; - } else { - return Err(Error::Timeout(crate::TimeoutError::Handshake)); - } + if self + .timers + .handle_timeout(now, &mut self.rng) + .map_err(Error::Timeout)? + { + self.flight_resend("flight timeout")?; } // During handshake, schedule/flush ACKs to help peer with selective retransmission @@ -567,7 +550,13 @@ impl Engine { self.maybe_schedule_handshake_ack(now); match self.poll_packet_tx(buf) { - PollOutput::Data(p) => return Output::Packet(p), + PollOutput::Data(p) => { + if self.is_client { + self.timers.start_handshake(); + } + self.timers.flight_sent(); + return Output::Packet(p); + } PollOutput::BufferTooSmall { needed } => return Output::BufferTooSmall { needed }, PollOutput::None(_) => {} } @@ -653,26 +642,7 @@ impl Engine { } fn poll_timeout(&self, now: Instant) -> Instant { - if self.connect_timeout == Timeout::Disabled - && self.flight_timeout == Timeout::Disabled - && self.handshake_ack_deadline.is_none() - { - const DISTANT_FUTURE: Duration = Duration::from_secs(10 * 365 * 24 * 60 * 60); - return now + DISTANT_FUTURE; - } - - let mut timeout = match (self.connect_timeout, self.flight_timeout) { - (Timeout::Armed(c), Timeout::Armed(f)) => { - if c < f { - c - } else { - f - } - } - (Timeout::Armed(c), _) => c, - (_, Timeout::Armed(f)) => f, - _ => now + Duration::from_secs(10 * 365 * 24 * 60 * 60), - }; + let mut timeout = self.timers.poll_timeout(now); if let Some(deadline) = self.handshake_ack_deadline { if deadline < timeout { @@ -685,15 +655,13 @@ impl Engine { pub fn flight_begin(&mut self, flight_no: u8) { debug!("Begin flight {}", flight_no); - self.flight_backoff.reset(&mut self.rng); + self.timers.begin_flight(&mut self.rng); self.flight_clear_resends(); - self.flight_timeout = Timeout::Unarmed; } pub fn flight_stop_resend_timers(&mut self) { - debug!("Stop connect and flight timeouts"); - self.flight_timeout = Timeout::Disabled; - self.connect_timeout = Timeout::Disabled; + debug!("Stop flight timeout"); + self.timers.stop_flight(); } fn flight_clear_resends(&mut self) { @@ -1277,10 +1245,12 @@ impl Engine { pub fn release_application_data(&mut self) { self.release_app_data = true; self.hs_recv_keys = None; + self.timers.finish_handshake(); } pub fn release_application_data_retaining_handshake_keys(&mut self) { self.release_app_data = true; + self.timers.finish_handshake(); } /// Whether a close_notify alert has been received from the peer. @@ -1303,8 +1273,7 @@ impl Engine { /// allowing the queued close_notify alert to be sent. pub fn cancel_flights(&mut self) { self.flight_saved_records.clear(); - self.flight_timeout = Timeout::Disabled; - self.connect_timeout = Timeout::Disabled; + self.timers.stop(); self.handshake_ack_deadline = None; } @@ -1313,8 +1282,7 @@ impl Engine { pub fn abort(&mut self) { self.queue_tx.clear(); self.flight_saved_records.clear(); - self.flight_timeout = Timeout::Disabled; - self.connect_timeout = Timeout::Disabled; + self.timers.stop(); self.handshake_ack_deadline = None; } @@ -1327,6 +1295,10 @@ impl Engine { pub fn send_ack_retransmittable(&mut self) -> Result<(), Error> { if !self.received_record_numbers.is_empty() { + // This ACK is a new courtesy-resend flight: reset its duplicate + // budget, but keep timer-driven retransmission disabled. + self.timers.begin_flight(&mut self.rng); + self.timers.stop_flight(); self.flight_clear_resends(); } self.send_ack_inner(true) @@ -1403,7 +1375,7 @@ impl Engine { .all(|e| e.acked); if has_epoch2 && all_epoch2_acked { debug!("Handshake flight ACKed; stopping retransmission"); - self.flight_timeout = Timeout::Disabled; + self.timers.stop_flight(); self.flight_clear_resends(); } @@ -1418,7 +1390,7 @@ impl Engine { self.prev_app_send_keys = None; self.key_update_in_flight = false; self.flight_clear_resends(); - self.flight_timeout = Timeout::Disabled; + self.timers.stop_flight(); } Ok(()) @@ -1549,15 +1521,10 @@ impl Engine { let delay = if self.has_gap_in_incoming_handshake() { Duration::from_millis(0) } else { - let rto = self.flight_backoff.rto(); - if rto > Duration::from_millis(0) { - rto / 4 - } else { - Duration::from_millis(0) - } + self.timers.rto() / 4 }; - self.handshake_ack_deadline = Some(now + delay); + self.handshake_ack_deadline = now.checked_add(delay); } /// Flush a scheduled handshake ACK if the deadline has passed. @@ -1940,9 +1907,8 @@ impl Engine { /// the current app epoch. Send keys rotate only after its ACK arrives. pub fn create_key_update(&mut self, request: KeyUpdateRequest) -> Result<(), Error> { // Set up retransmission - self.flight_backoff.reset(&mut self.rng); + self.timers.begin_flight(&mut self.rng); self.flight_clear_resends(); - self.flight_timeout = Timeout::Unarmed; let msg_seq = self.next_handshake_seq_no; self.next_handshake_seq_no += 1; @@ -2566,20 +2532,54 @@ impl RecordHandler for Engine { } } -#[cfg(test)] +#[cfg(all(test, feature = "rcgen"))] mod tests { use super::*; - #[cfg(feature = "rcgen")] use crate::certificate::generate_self_signed_certificate; - #[cfg(feature = "rcgen")] fn test_engine() -> Engine { let cert = generate_self_signed_certificate().expect("gen cert"); let config = Arc::new(Config::builder().build().expect("build config")); Engine::new(config, cert) } + #[test] + fn timing_resend_queue_failure_is_fatal() { + let now = Instant::now(); + let certificate = generate_self_signed_certificate().expect("certificate"); + let config = Arc::new( + Config::builder() + .dangerously_set_rng_seed(42) + .build() + .expect("valid config"), + ); + let mut engine = Engine::new(config, certificate); + engine.flight_begin(1); + engine + .create_plaintext_record(ContentType::Handshake, true, |fragment| fragment.push(1)) + .expect("queue original flight"); + let mut buffer = [0; 64]; + assert!(matches!( + engine.poll_output(&mut buffer, now), + Output::Packet(_) + )); + engine.handle_timeout(now).expect("arm flight timer"); + let Output::Timeout(retry_at) = engine.poll_output(&mut buffer, now) else { + panic!("expected flight timer"); + }; + engine.config = Arc::new( + Config::builder() + .max_queue_tx(0) + .build() + .expect("inject exhausted transmit capacity"), + ); + assert_eq!( + engine.handle_timeout(retry_at), + Err(Error::TransmitQueueFull) + ); + } + #[derive(Default)] struct PassthroughRecordHandler { buffers: BufferPool, @@ -2680,7 +2680,6 @@ mod tests { } #[test] - #[cfg(feature = "rcgen")] fn duplicate_datagram_recycles_its_pooled_buffer() { let mut engine = test_engine(); let packet = encrypted_application_data_record(1, b"buffered ciphertext"); @@ -2737,7 +2736,6 @@ mod tests { /// to exceed MAX_SEQUENCE_NUMBER (2^48 - 1). This test sets the counter /// to MAX and verifies that `create_plaintext_record` returns an error. #[test] - #[cfg(feature = "rcgen")] fn epoch_0_sequence_number_rejects_overflow() { let mut engine = test_engine(); @@ -2757,7 +2755,6 @@ mod tests { /// alongside the traffic secrets, eliminating the need for a separate /// `derive_handshake_secret` method. #[test] - #[cfg(feature = "rcgen")] fn derive_handshake_secrets_returns_handshake_secret() { let mut engine = test_engine(); engine.set_cipher_suite(Dtls13CipherSuite::AES_128_GCM_SHA256); @@ -2808,7 +2805,6 @@ mod tests { /// via `self.buffers_free.pop()`, reusing pooled allocations instead of /// creating a fresh `Buf::new()`. #[test] - #[cfg(feature = "rcgen")] fn derive_early_secret_uses_buffer_pool() { let mut engine = test_engine(); engine.set_cipher_suite(Dtls13CipherSuite::AES_128_GCM_SHA256); @@ -2831,7 +2827,6 @@ mod tests { } #[test] - #[cfg(feature = "rcgen")] fn ack_tracking_full_does_not_panic_on_handshake_replacement() { let mut engine = test_engine(); @@ -2874,7 +2869,6 @@ mod tests { } #[test] - #[cfg(feature = "rcgen")] fn ack_tracking_ignores_non_handshake_records_in_coalesced_datagram() { let mut engine = test_engine(); @@ -2901,7 +2895,6 @@ mod tests { } #[test] - #[cfg(feature = "rcgen")] fn malformed_ack_record_number_vector_is_ignored() { let mut engine = test_engine(); let fragment = engine.pop_buffer(); diff --git a/src/dtls13/server.rs b/src/dtls13/server.rs index eee5c9c1..2e2c1cb3 100644 --- a/src/dtls13/server.rs +++ b/src/dtls13/server.rs @@ -66,6 +66,7 @@ use crate::dtls13::message::SupportedVersionsClientHello; use crate::dtls13::message::SupportedVersionsServerHello; use crate::dtls13::message::UseSrtpExtension; use crate::dtls13::message::parse_cookie_extension; +use crate::timer::Timeout; use crate::{Config, DtlsCertificate, Error, InternalError, Output}; /// Magic random value indicating HelloRetryRequest (RFC 8446 Section 4.1.3). @@ -234,6 +235,10 @@ impl Server { (config, cert, self.last_now, self.retained_hello) } + pub fn handshake_deadline(&self) -> Timeout { + self.engine.handshake_deadline() + } + pub(crate) fn state_name(&self) -> &'static str { self.state.name() } @@ -260,6 +265,7 @@ impl Server { self.retained_hello.push_back(packet.to_buf()); } + let deadline = self.engine.handshake_deadline(); match self .engine .parse_packet(packet) @@ -267,6 +273,10 @@ impl Server { { Ok(()) => {} Err(e) => { + // A rejected ClientHello must not start the handshake clock. + if self.state == State::AwaitClientHello { + self.engine.set_handshake_deadline(deadline); + } if let Some(err) = e.into_public_error() { return Err(err); } diff --git a/src/error.rs b/src/error.rs index c22663f7..3eb8a59e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -547,6 +547,10 @@ pub enum ConfigError { }, /// The configured AEAD encryption limit is too small. AeadEncryptionLimitTooSmall, + /// The configured handshake timeout is zero. + HandshakeTimeoutTooSmall, + /// The configured initial flight RTO is zero. + FlightStartRtoTooSmall, /// Cipher-suite filtering removed every available suite. NoCipherSuitesAfterFiltering, /// A PSK resolver is configured but no PSK cipher suite remains enabled. @@ -1229,6 +1233,8 @@ impl fmt::Display for ConfigError { Self::AeadEncryptionLimitTooSmall => { write!(f, "aead_encryption_limit must be at least 1") } + Self::HandshakeTimeoutTooSmall => write!(f, "handshake_timeout must be nonzero"), + Self::FlightStartRtoTooSmall => write!(f, "flight_start_rto must be nonzero"), Self::NoCipherSuitesAfterFiltering => write!( f, concat!( diff --git a/src/lib.rs b/src/lib.rs index 8356df11..29af0021 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -724,7 +724,7 @@ impl Dtls { let Inner::ClientPending(cp) = inner else { unreachable!() }; - let (hybrid, config, certificate, now) = cp.into_parts(); + let (hybrid, config, certificate, now, timers) = cp.into_parts(); match version { auto::DetectedVersion::Dtls12 => { let mut client12 = Client12::new_from_hybrid( @@ -733,6 +733,7 @@ impl Dtls { config, certificate, now, + timers, )?; // Feed the HVR to Client12 — it enters // AwaitHelloVerifyRequest and processes the cookie. @@ -744,7 +745,8 @@ impl Dtls { Ok(()) } auto::DetectedVersion::Dtls13 => { - let mut client13 = Client13::new_from_hybrid(hybrid, config, certificate, now)?; + let mut client13 = + Client13::new_from_hybrid(hybrid, config, certificate, now, timers)?; if let Err(e) = client13.handle_packet(packet) { self.inner = Some(Inner::Client13(client13)); return Err(e); @@ -767,6 +769,7 @@ impl Dtls { _ => unreachable!(), }; + let deadline = server.handshake_deadline(); let (config, cert, now, buffered) = server.into_parts(); // A Server12 instance is either cert-auth or PSK-auth — the auth @@ -782,6 +785,8 @@ impl Dtls { Server12::new(config, cert, now) }; server12.handle_timeout(now)?; + // Set after the clock update above so a pending deadline arms with fresh time. + server12.set_handshake_deadline(deadline); self.inner = Some(Inner::Server12(server12)); @@ -803,6 +808,10 @@ impl Dtls { } /// Handle time-based events such as retransmission timers. + /// + /// This is also how dimpl learns the time. Timers started by sending or + /// receiving a packet use the `now` of the next call, which dimpl requests + /// by returning an [`Output::Timeout`] that is already due. pub fn handle_timeout(&mut self, now: Instant) -> Result<(), Error> { match self.inner.as_mut().unwrap() { Inner::Client12(client) => client.handle_timeout(now), diff --git a/src/timer.rs b/src/timer.rs index ad5fe5fe..3704bee6 100644 --- a/src/timer.rs +++ b/src/timer.rs @@ -1,10 +1,31 @@ -use std::ops::Mul; -use std::time::Duration; +use std::time::{Duration, Instant}; -use crate::SeededRng; +use crate::{Config, SeededRng, TimeoutError}; -// In seconds. const JITTER_RANGE: f32 = 0.5; +const DISTANT_FUTURE: Duration = Duration::from_secs(10 * 365 * 24 * 60 * 60); + +/// Overall handshake deadline and per-flight retransmission timer. +/// +/// Start events (first packet out, or a server's first ClientHello in) only +/// mark a timer [`Timeout::Pending`]. The next `handle_timeout(now)` arms it, +/// so a stale clock never shortens the budget. +pub struct HandshakeTimers { + handshake_timeout: Duration, + handshake: Timeout, + flight: Timeout, + backoff: ExponentialBackoff, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Timeout { + Disabled, + /// Not started. + Unarmed, + /// Started; armed with the time of the next `handle_timeout`. + Pending, + Armed(Instant), +} pub struct ExponentialBackoff { start_rto: Duration, @@ -14,6 +35,113 @@ pub struct ExponentialBackoff { left: usize, } +impl HandshakeTimers { + pub fn new(config: &Config, rng: &mut SeededRng) -> Self { + Self { + handshake_timeout: config.handshake_timeout(), + handshake: Timeout::Unarmed, + flight: Timeout::Disabled, + backoff: ExponentialBackoff::new( + config.flight_start_rto(), + config.flight_retries(), + rng, + ), + } + } + + pub fn start_handshake(&mut self) { + if self.handshake == Timeout::Unarmed { + self.handshake = Timeout::Pending; + } + } + + pub fn handshake_deadline(&self) -> Timeout { + self.handshake + } + + pub fn set_handshake_deadline(&mut self, deadline: Timeout) { + self.handshake = deadline; + } + + pub fn begin_flight(&mut self, rng: &mut SeededRng) { + self.backoff.reset(rng); + self.flight = Timeout::Unarmed; + } + + pub fn flight_sent(&mut self) { + if self.flight == Timeout::Unarmed { + self.flight = Timeout::Pending; + } + } + + pub fn stop_flight(&mut self) { + self.flight = Timeout::Disabled; + } + + pub fn finish_handshake(&mut self) { + self.handshake = Timeout::Disabled; + } + + pub fn stop(&mut self) { + self.finish_handshake(); + self.stop_flight(); + } + + pub fn rto(&self) -> Duration { + self.backoff.rto() + } + + /// Reserve a retry from the current sent flight's shared budget. + pub fn request_resend(&mut self, rng: &mut SeededRng) -> bool { + if self.flight == Timeout::Unarmed || !self.backoff.can_retry() { + return false; + } + self.backoff.attempt(rng); + if self.flight != Timeout::Disabled { + self.flight = Timeout::Unarmed; + } + true + } + + /// Returns `Ok(true)` when the current flight must be resent. + pub fn handle_timeout( + &mut self, + now: Instant, + rng: &mut SeededRng, + ) -> Result { + if self.handshake == Timeout::Pending { + self.handshake = deadline(now, self.handshake_timeout); + } + if self.flight == Timeout::Pending { + self.flight = deadline(now, self.backoff.rto()); + } + if let Timeout::Armed(timeout) = self.handshake { + if now >= timeout { + return Err(TimeoutError::Connect); + } + } + if let Timeout::Armed(timeout) = self.flight { + if now >= timeout { + if !self.request_resend(rng) { + return Err(TimeoutError::Handshake); + } + self.flight = deadline(now, self.backoff.rto()); + return Ok(true); + } + } + Ok(false) + } + + pub fn poll_timeout(&self, now: Instant) -> Instant { + match (self.handshake, self.flight) { + (Timeout::Pending, _) | (_, Timeout::Pending) => now, + (Timeout::Armed(connect), Timeout::Armed(flight)) => connect.min(flight), + (Timeout::Armed(timeout), _) | (_, Timeout::Armed(timeout)) => timeout, + _ => now + DISTANT_FUTURE, + } + } +} + impl ExponentialBackoff { pub fn new(start_rto: Duration, retries: usize, rng: &mut SeededRng) -> Self { Self { @@ -32,16 +160,16 @@ impl ExponentialBackoff { } pub fn rto(&self) -> Duration { + let jitter = self.rto.mul_f64(f64::from(self.jitter.abs())); if self.jitter < 0.0 { - let duration = Duration::from_secs_f32(self.jitter.abs()); - self.rto.saturating_sub(duration) + self.rto.saturating_sub(jitter) } else { - self.rto + Duration::from_secs_f32(self.jitter) + self.rto.saturating_add(jitter) } - .max(Duration::from_millis(50)) + .max(Duration::from_nanos(1)) } - // A value between -0.25s and 0.25s + // A fraction between -0.25 and 0.25 of the RTO. fn jitter(rng: &mut SeededRng) -> f32 { rng.random::() * JITTER_RANGE - (JITTER_RANGE / 2.0) } @@ -55,7 +183,7 @@ impl ExponentialBackoff { self.left = n; self.jitter = Self::jitter(rng); - self.rto = self.rto.mul(2); + self.rto = self.rto.saturating_mul(2); } pub fn can_retry(&self) -> bool { @@ -63,10 +191,44 @@ impl ExponentialBackoff { } } +/// Arm a timeout, disabling it if the deadline is not representable. +pub fn deadline(now: Instant, delay: Duration) -> Timeout { + now.checked_add(delay) + .map_or(Timeout::Disabled, Timeout::Armed) +} + #[cfg(test)] mod test { use super::*; + #[test] + fn unrepresentable_deadline_is_disabled() { + let now = Instant::now(); + assert_eq!(deadline(now, Duration::ZERO), Timeout::Armed(now)); + assert_eq!(deadline(now, Duration::MAX), Timeout::Disabled); + } + + #[test] + fn proportional_jitter() { + let mut rng = SeededRng::new(Some(42)); + for rto in [ + Duration::from_nanos(4), + Duration::from_millis(20), + Duration::from_secs(100), + ] { + let mut exp = ExponentialBackoff::new(rto, 1, &mut rng); + exp.jitter = -0.25; + assert_eq!(exp.rto(), rto - rto / 4); + exp.jitter = 0.25; + assert_eq!(exp.rto(), rto + rto / 4); + } + let mut exp = ExponentialBackoff::new(Duration::ZERO, 1, &mut rng); + assert_eq!(exp.rto(), Duration::from_nanos(1)); + exp.rto = Duration::MAX; + exp.attempt(&mut rng); + assert_eq!(exp.rto, Duration::MAX); + } + #[test] fn attempts() { let mut rng = SeededRng::new(Some(42)); diff --git a/tests/dtls12/common.rs b/tests/dtls12/common.rs index 7fc87104..7f4cef40 100644 --- a/tests/dtls12/common.rs +++ b/tests/dtls12/common.rs @@ -152,6 +152,8 @@ pub fn deliver_packets(packets: &[Vec], dest: &mut Dtls) { /// Trigger a timeout by advancing time 2 seconds. pub fn trigger_timeout(ep: &mut Dtls, now: &mut Instant) { + // Honor the immediate timeout requested after emitting a flight. + ep.handle_timeout(*now).expect("handle_timeout"); *now += Duration::from_secs(2); ep.handle_timeout(*now).expect("handle_timeout"); } diff --git a/tests/dtls13/common.rs b/tests/dtls13/common.rs index c452df07..37ded491 100644 --- a/tests/dtls13/common.rs +++ b/tests/dtls13/common.rs @@ -67,6 +67,8 @@ pub fn deliver_packets(packets: &[Vec], dest: &mut Dtls) { /// Trigger a timeout by advancing time 2 seconds. pub fn trigger_timeout(ep: &mut Dtls, now: &mut Instant) { + // Honor the immediate timeout requested after emitting a flight. + ep.handle_timeout(*now).expect("handle_timeout"); *now += Duration::from_secs(2); ep.handle_timeout(*now).expect("handle_timeout"); } diff --git a/tests/dtls13/conformance.rs b/tests/dtls13/conformance.rs index 3719dc6e..5420741e 100644 --- a/tests/dtls13/conformance.rs +++ b/tests/dtls13/conformance.rs @@ -526,3 +526,86 @@ fn server_retransmits_final_ack_for_retransmitted_client_final_flight() { "server must retransmit its final ACK when the client final flight is retransmitted" ); } + +#[test] +#[cfg(feature = "rcgen")] +fn server_final_ack_gets_fresh_duplicate_resend_budget() { + let config = |rto, retries| { + Arc::new( + Config::builder() + .dangerously_set_rng_seed(42) + .use_server_cookie(false) + .require_client_certificate(false) + .handshake_timeout(Duration::from_secs(60)) + .flight_start_rto(rto) + .flight_retries(retries) + .build() + .expect("valid config"), + ) + }; + let client_config = config(Duration::from_secs(5), 2); + let server_config = config(Duration::from_millis(20), 1); + let client_cert = generate_self_signed_certificate().expect("gen client cert"); + let server_cert = generate_self_signed_certificate().expect("gen server cert"); + let now = Instant::now(); + let mut client = Dtls::new_13(client_config, client_cert, now); + client.set_active(true); + let mut server = Dtls::new_13(server_config, server_cert, now); + + client.handle_timeout(now).expect("start client"); + server.handle_timeout(now).expect("initialize server clock"); + let client_hello = drain_outputs(&mut client).packets; + deliver_packets(&client_hello, &mut server); + let first_server_flight = drain_outputs(&mut server); + assert!(!first_server_flight.packets.is_empty()); + + server.handle_timeout(now).expect("arm server flight timer"); + let server_retry_at = drain_outputs(&mut server).timeout.expect("server retry"); + server + .handle_timeout(server_retry_at) + .expect("use the only server-flight retry"); + let retried_server_flight = drain_outputs(&mut server).packets; + assert!(!retried_server_flight.is_empty()); + + client + .handle_timeout(server_retry_at) + .expect("advance client clock"); + deliver_packets(&retried_server_flight, &mut client); + let client_final = drain_outputs(&mut client); + assert!(client_final.connected); + assert!(!client_final.packets.is_empty()); + + server + .handle_timeout(server_retry_at) + .expect("advance server clock"); + deliver_packets(&client_final.packets, &mut server); + let completion = drain_outputs(&mut server); + assert!(completion.connected); + assert!(!completion.packets.is_empty(), "server completion ACK"); + + client + .handle_timeout(server_retry_at) + .expect("arm client final-flight timer"); + let client_retry_at = drain_outputs(&mut client) + .timeout + .expect("client final-flight retry"); + client + .handle_timeout(client_retry_at) + .expect("retransmit unacknowledged final flight"); + let retransmitted_final = drain_outputs(&mut client).packets; + assert!(!retransmitted_final.is_empty()); + + server + .handle_timeout(client_retry_at) + .expect("advance completed server clock"); + assert!( + drain_outputs(&mut server).packets.is_empty(), + "completion ACK must not retransmit on a timer" + ); + deliver_packets(&retransmitted_final, &mut server); + let replacement_ack = drain_outputs(&mut server).packets; + assert!( + !replacement_ack.is_empty(), + "server must retransmit its completion ACK with a fresh flight budget" + ); +} diff --git a/tests/dtls13/edge.rs b/tests/dtls13/edge.rs index c68ea460..152fa614 100644 --- a/tests/dtls13/edge.rs +++ b/tests/dtls13/edge.rs @@ -856,6 +856,7 @@ fn dtls13_post_encryption_plaintext_ack_does_not_stop_retransmit() { !first_server_flight.is_empty(), "server should emit first flight" ); + server.handle_timeout(now).expect("arm server flight"); // If this unauthenticated plaintext ACK reaches process_ack, it can mark // the saved epoch-2 handshake flight as fully acknowledged and disable @@ -865,8 +866,7 @@ fn dtls13_post_encryption_plaintext_ack_does_not_stop_retransmit() { .handle_packet(&dtls13_ack_record_for_records(0x200, &acked_records)) .expect("post-encryption plaintext ACK should be ignored"); - // The flight timer jitter is absolute (+/-250 ms), so wait past the - // maximum possible jitter for a 100 ms start RTO. + // Wait past the maximum jittered 100 ms start RTO. now += Duration::from_millis(400); server.handle_timeout(now).expect("server timeout"); let retransmit = collect_packets(&mut server); diff --git a/tests/dtls13/retransmit.rs b/tests/dtls13/retransmit.rs index ad0226be..d99e3347 100644 --- a/tests/dtls13/retransmit.rs +++ b/tests/dtls13/retransmit.rs @@ -628,6 +628,7 @@ fn dtls13_retransmit_exponential_backoff() { // Use enough retries to observe several backoff steps let config = Arc::new( Config::builder() + .dangerously_set_rng_seed(42) .flight_retries(6) .handshake_timeout(Duration::from_secs(300)) .build() @@ -643,10 +644,10 @@ fn dtls13_retransmit_exponential_backoff() { // Kick off the handshake client.handle_timeout(now).expect("client start"); - client.handle_timeout(now).expect("client arm"); // Collect initial packets (ClientHello) without delivering them let _initial_packets = collect_packets(&mut client); + client.handle_timeout(now).expect("client arm"); // Record successive timeout values by triggering retransmissions let mut timeouts: Vec = Vec::new(); @@ -686,16 +687,11 @@ fn dtls13_retransmit_exponential_backoff() { ); } - // Verify rough doubling: each timeout should be at least 1.5x the previous - // (accounting for jitter of +/- 0.25s) - for i in 1..timeouts.len() { - let ratio = timeouts[i].as_secs_f64() / timeouts[i - 1].as_secs_f64(); + for (attempt, timeout) in timeouts.iter().enumerate() { + let nominal = Duration::from_secs(1 << attempt); assert!( - ratio > 1.4, - "Timeout ratio {}/{} = {:.2} should be > 1.4 (exponential backoff)", - i, - i - 1, - ratio + *timeout >= nominal.mul_f64(0.75) && *timeout <= nominal.mul_f64(1.25), + "Timeout {attempt} ({timeout:?}) must be within +/-25% of {nominal:?}" ); } diff --git a/tests/dtls13/wolfssl.rs b/tests/dtls13/wolfssl.rs index f0683469..4723aa03 100644 --- a/tests/dtls13/wolfssl.rs +++ b/tests/dtls13/wolfssl.rs @@ -515,12 +515,12 @@ fn dtls13_wolfssl_client_retransmit_on_timeout() { // Get initial ClientHello dimpl_client.handle_timeout(now).expect("client start"); - dimpl_client.handle_timeout(now).expect("client arm"); let initial_out = drain_dimpl_outputs(&mut dimpl_client); assert!( !initial_out.packets.is_empty(), "Client should send ClientHello" ); + dimpl_client.handle_timeout(now).expect("client arm"); // Don't deliver to server, trigger timeout now += Duration::from_secs(2); diff --git a/tests/timing.rs b/tests/timing.rs new file mode 100644 index 00000000..b310e5da --- /dev/null +++ b/tests/timing.rs @@ -0,0 +1,458 @@ +#![cfg(feature = "rcgen")] + +use std::mem; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use dimpl::certificate::generate_self_signed_certificate; +use dimpl::{Config, Dtls, DtlsCertificate, Error, Output, ProtocolVersion, TimeoutError}; + +#[path = "dtls13/common.rs"] +mod common; + +use common::{DrainedOutputs, drain_outputs}; + +const VERSIONS: &[Version] = &[Version::Dtls12, Version::Dtls13, Version::Auto]; +const BUDGET: Duration = Duration::from_millis(100); +const PAIRS: &[(Version, Version)] = &[ + (Version::Dtls12, Version::Dtls12), + (Version::Dtls13, Version::Dtls13), + (Version::Auto, Version::Dtls12), + (Version::Auto, Version::Dtls13), + (Version::Dtls12, Version::Auto), + (Version::Dtls13, Version::Auto), + (Version::Auto, Version::Auto), +]; + +#[derive(Clone, Copy, Debug)] +enum Version { + Dtls12, + Dtls13, + Auto, +} + +impl Version { + fn endpoint(self, config: Arc, certificate: DtlsCertificate, now: Instant) -> Dtls { + match self { + Self::Dtls12 => Dtls::new_12(config, certificate, now), + Self::Dtls13 => Dtls::new_13(config, certificate, now), + Self::Auto => Dtls::new_auto(config, certificate, now), + } + } +} + +/// Short overall budget, with retries far enough out not to interfere. +fn config() -> Arc { + Arc::new( + Config::builder() + .dangerously_set_rng_seed(42) + .handshake_timeout(BUDGET) + .flight_start_rto(Duration::from_secs(1)) + .flight_retries(0) + .build() + .expect("valid timing config"), + ) +} + +fn retry_config(retries: usize, use_cookie: bool) -> Arc { + Arc::new( + Config::builder() + .dangerously_set_rng_seed(42) + .use_server_cookie(use_cookie) + .handshake_timeout(Duration::from_secs(3600)) + .flight_start_rto(Duration::from_millis(20)) + .flight_retries(retries) + .build() + .expect("valid retry config"), + ) +} + +/// Behave like an event loop at a single instant: tick while a due timeout is advertised. +fn settle(endpoint: &mut Dtls, now: Instant) -> (Vec>, Instant) { + let mut packets = Vec::new(); + for _ in 0..10 { + let output = drain_outputs(endpoint); + packets.extend(output.packets); + let deadline = output.timeout.expect("polled timeout"); + if deadline > now { + return (packets, deadline); + } + endpoint.handle_timeout(now).expect("tick"); + } + panic!("timers did not settle"); +} + +fn client_hello(version: Version, config: Arc, now: Instant) -> Vec> { + let certificate = generate_self_signed_certificate().expect("certificate"); + let mut client = version.endpoint(config, certificate, now); + client.set_active(true); + settle(&mut client, now).0 +} + +fn merge_output(received: &mut DrainedOutputs, output: DrainedOutputs) { + received.connected |= output.connected; + received.app_data.extend(output.app_data); + received.packets.extend(output.packets); + received.timeout = output.timeout; +} + +fn deliver_queued(source: &mut DrainedOutputs, target: &mut Dtls, received: &mut DrainedOutputs) { + for packet in mem::take(&mut source.packets) { + target.handle_packet(&packet).expect("deliver packet"); + merge_output(received, drain_outputs(target)); + } +} + +fn exchange( + client: &mut Dtls, + server: &mut Dtls, + now: Instant, +) -> (DrainedOutputs, DrainedOutputs) { + let mut client_output = drain_outputs(client); + let mut server_output = drain_outputs(server); + for _ in 0..100 { + deliver_queued(&mut client_output, server, &mut server_output); + deliver_queued(&mut server_output, client, &mut client_output); + client.handle_timeout(now).expect("client pending progress"); + merge_output(&mut client_output, drain_outputs(client)); + server.handle_timeout(now).expect("server pending progress"); + merge_output(&mut server_output, drain_outputs(server)); + if client_output.packets.is_empty() && server_output.packets.is_empty() { + return (client_output, server_output); + } + } + panic!("packet exchange did not settle"); +} + +#[test] +fn clients_start_deadline_at_first_emission() { + let base = Instant::now(); + let certificate = generate_self_signed_certificate().expect("certificate"); + for &version in VERSIONS { + let mut client = version.endpoint(config(), certificate.clone(), base); + let late = base + Duration::from_secs(60); + client.set_active(true); + let (packets, deadline) = settle(&mut client, late); + assert!(!packets.is_empty(), "{version:?}"); + assert_eq!(deadline, late + BUDGET, "{version:?}"); + assert_eq!( + client.handle_timeout(deadline), + Err(Error::Timeout(TimeoutError::Connect)) + ); + } +} + +/// An idle server advertises no timeout, so a caller may hand it a packet +/// without refreshing the clock. Its deadline must start from the next tick. +#[test] +fn idle_server_starts_deadline_from_fresh_clock() { + let base = Instant::now(); + let certificate = generate_self_signed_certificate().expect("certificate"); + for &version in VERSIONS { + let mut server = version.endpoint(config(), certificate.clone(), base); + server.handle_timeout(base).expect("server clock"); + let arrival = base + Duration::from_secs(3600); + assert!( + drain_outputs(&mut server).timeout.expect("idle deadline") > arrival, + "idle server must not wake" + ); + + for packet in client_hello(version, config(), arrival) { + server.handle_packet(&packet).expect("ClientHello"); + } + let (response, deadline) = settle(&mut server, arrival); + assert!(!response.is_empty(), "{version:?}"); + assert_eq!(deadline, arrival + BUDGET, "{version:?}"); + assert_eq!( + server.handle_timeout(deadline), + Err(Error::Timeout(TimeoutError::Connect)) + ); + } +} + +#[test] +fn flights_retry_exactly_with_backoff() { + let base = Instant::now(); + let certificate = generate_self_signed_certificate().expect("certificate"); + let rto = Duration::from_millis(20); + for &version in VERSIONS { + for retries in [0, 2] { + for active in [false, true] { + let config = retry_config(retries, true); + let mut endpoint = version.endpoint(config.clone(), certificate.clone(), base); + endpoint.set_active(active); + endpoint.handle_timeout(base).expect("initialize clock"); + if !active { + for packet in client_hello(version, config, base) { + endpoint.handle_packet(&packet).expect("accept ClientHello"); + } + } + let (original, mut deadline) = settle(&mut endpoint, base); + assert!(!original.is_empty(), "{version:?}, active={active}"); + let mut now = base; + for attempt in 0..=retries { + let nominal = rto * (1 << attempt); + let interval = deadline - now; + assert!(interval >= nominal.mul_f64(0.75), "{version:?} {active}"); + assert!(interval <= nominal.mul_f64(1.25), "{version:?} {active}"); + now = deadline; + if attempt == retries { + break; + } + endpoint.handle_timeout(now).expect("retry"); + let (retried, next) = settle(&mut endpoint, now); + assert_eq!(retried.len(), original.len(), "{version:?} {active}"); + deadline = next; + } + let reason = if active && matches!(version, Version::Auto) { + TimeoutError::HybridClientHello + } else { + TimeoutError::Handshake + }; + assert_eq!(endpoint.handle_timeout(now), Err(Error::Timeout(reason))); + } + } + } +} + +#[test] +fn duplicate_client_hellos_share_the_retry_budget() { + let base = Instant::now(); + let certificate = generate_self_signed_certificate().expect("certificate"); + for &version in VERSIONS { + let config = retry_config(1, false); + let mut server = version.endpoint(config.clone(), certificate.clone(), base); + server.handle_timeout(base).expect("server clock"); + let hello = client_hello(version, config, base); + for packet in &hello { + server.handle_packet(packet).expect("ClientHello"); + } + let (original, _) = settle(&mut server, base); + + let now = base + Duration::from_millis(1); + server.handle_timeout(now).expect("before retry timer"); + for packet in &hello { + server.handle_packet(packet).expect("duplicate ClientHello"); + } + let (resent, deadline) = settle(&mut server, now); + assert_eq!(resent.len(), original.len(), "{version:?}"); + assert_eq!( + server.handle_timeout(deadline), + Err(Error::Timeout(TimeoutError::Handshake)), + "{version:?}" + ); + } +} + +#[test] +fn auto_client_keeps_its_deadline_across_version_selection() { + let base = Instant::now(); + let certificate = generate_self_signed_certificate().expect("certificate"); + for (peer, expected) in [ + (Version::Dtls12, ProtocolVersion::DTLS1_2), + (Version::Dtls13, ProtocolVersion::DTLS1_3), + ] { + let mut client = Version::Auto.endpoint(config(), certificate.clone(), base); + client.set_active(true); + let (hello, deadline) = settle(&mut client, base); + assert_eq!(deadline, base + BUDGET); + + let selection = base + Duration::from_millis(70); + let mut server = peer.endpoint(config(), certificate.clone(), selection); + server.handle_timeout(selection).expect("server clock"); + for packet in hello { + server.handle_packet(&packet).expect("hybrid ClientHello"); + } + let (responses, _) = settle(&mut server, selection); + client.handle_timeout(selection).expect("client clock"); + for packet in responses { + client.handle_packet(&packet).expect("version selection"); + } + let (second_hello, after) = settle(&mut client, selection); + assert!(!second_hello.is_empty(), "{peer:?}"); + assert_eq!(client.protocol_version(), Some(expected)); + assert_eq!(after, deadline, "{peer:?}"); + assert_eq!( + client.handle_timeout(deadline), + Err(Error::Timeout(TimeoutError::Connect)) + ); + } +} + +#[test] +fn completed_handshakes_ignore_the_deadline() { + let base = Instant::now(); + let certificate = generate_self_signed_certificate().expect("certificate"); + let config = Arc::new( + Config::builder() + .dangerously_set_rng_seed(42) + .handshake_timeout(BUDGET) + .flight_start_rto(Duration::from_millis(20)) + .aead_encryption_limit(3) + .build() + .expect("small key-update threshold"), + ); + for &versions in PAIRS { + let mut client = versions + .0 + .endpoint(config.clone(), certificate.clone(), base); + let mut server = versions + .1 + .endpoint(config.clone(), certificate.clone(), base); + client.set_active(true); + let output = exchange(&mut client, &mut server, base); + assert!(output.0.connected && output.1.connected, "{versions:?}"); + + for round in 0..6 { + let now = base + Duration::from_secs(60 + round); + client.send_application_data(b"ping").expect("client send"); + let received = exchange(&mut client, &mut server, now); + assert_eq!(received.1.app_data, [b"ping".to_vec()], "{versions:?}"); + server.send_application_data(b"pong").expect("server send"); + let received = exchange(&mut client, &mut server, now); + assert_eq!(received.0.app_data, [b"pong".to_vec()], "{versions:?}"); + assert!(received.0.timeout.expect("client timeout") > now); + assert!(received.1.timeout.expect("server timeout") > now); + } + } +} + +/// Same budget as [`config`], with the minimum MTU so the ClientHello fragments. +fn fragmenting_config() -> Arc { + Arc::new( + Config::builder() + .dangerously_set_rng_seed(42) + .mtu(64) + .handshake_timeout(BUDGET) + .flight_start_rto(Duration::from_secs(1)) + .flight_retries(0) + .build() + .expect("valid fragmenting config"), + ) +} + +/// Make a ClientHello unparseable by overstating an extension's inner length. +fn corrupt_supported_groups(hello: &mut [u8]) { + // Record header (13) + handshake header (12) + version (2) + random (32). + let mut cursor = 59; + cursor += 1 + hello[cursor] as usize; // session id + cursor += 1 + hello[cursor] as usize; // cookie + cursor += 2 + u16::from_be_bytes([hello[cursor], hello[cursor + 1]]) as usize; + cursor += 1 + hello[cursor] as usize; // compression methods + cursor += 2; // extensions length + while cursor + 4 <= hello.len() { + let kind = u16::from_be_bytes([hello[cursor], hello[cursor + 1]]); + let length = u16::from_be_bytes([hello[cursor + 2], hello[cursor + 3]]) as usize; + if kind == 0x000A { + hello[cursor + 4..cursor + 6].copy_from_slice(&u16::MAX.to_be_bytes()); + return; + } + cursor += 4 + length; + } + panic!("ClientHello has no supported_groups extension"); +} + +#[test] +fn undersized_output_does_not_start_client_timers() { + let base = Instant::now(); + let certificate = generate_self_signed_certificate().expect("certificate"); + for &version in VERSIONS { + let mut client = version.endpoint(config(), certificate.clone(), base); + client.set_active(true); + client.handle_timeout(base).expect("build ClientHello"); + assert!(matches!( + client.poll_output(&mut []), + Output::BufferTooSmall { .. } + )); + let late = base + Duration::from_secs(60); + client + .handle_timeout(late) + .expect("nothing sent, nothing started"); + let (packets, deadline) = settle(&mut client, late); + assert!(!packets.is_empty(), "{version:?}"); + assert_eq!(deadline, late + BUDGET, "{version:?}"); + } +} + +#[test] +fn servers_start_at_first_accepted_client_hello_fragment() { + let base = Instant::now(); + let certificate = generate_self_signed_certificate().expect("certificate"); + for &version in VERSIONS { + let mut server = version.endpoint(config(), certificate.clone(), base); + server.handle_timeout(base).expect("server clock"); + server + .handle_packet(&[0, 1, 2]) + .expect("malformed input is discarded"); + assert!(settle(&mut server, base).1 > base + BUDGET, "{version:?}"); + + let arrival = base + Duration::from_secs(60); + // The hybrid Auto ClientHello is never fragmented. + let client = match version { + Version::Auto => Version::Dtls13, + other => other, + }; + let hello = client_hello(client, fragmenting_config(), arrival); + assert!(hello.len() > 1, "{version:?}: ClientHello not fragmented"); + server.handle_packet(&hello[0]).expect("first fragment"); + let (_, deadline) = settle(&mut server, arrival); + assert_eq!(deadline, arrival + BUDGET, "{version:?}"); + assert_eq!( + server.handle_timeout(deadline), + Err(Error::Timeout(TimeoutError::Connect)) + ); + } +} + +#[test] +fn rejected_client_hello_does_not_start_server_clock() { + let base = Instant::now(); + let certificate = generate_self_signed_certificate().expect("certificate"); + // Without cookies the server parses, and so rejects, the first ClientHello. + let config = Arc::new( + Config::builder() + .dangerously_set_rng_seed(42) + .use_server_cookie(false) + .handshake_timeout(BUDGET) + .build() + .expect("valid config"), + ); + for version in [Version::Dtls12, Version::Dtls13] { + let mut server = version.endpoint(config.clone(), certificate.clone(), base); + server.handle_timeout(base).expect("server clock"); + let mut hello = client_hello(version, config.clone(), base); + assert_eq!(hello.len(), 1); + corrupt_supported_groups(&mut hello[0]); + let _ = server.handle_packet(&hello[0]); + let (response, wake) = settle(&mut server, base); + assert!(response.is_empty(), "{version:?}"); + assert!(wake > base + BUDGET, "{version:?}"); + } +} + +#[test] +fn auto_server_keeps_its_deadline_across_dtls12_fallback() { + let base = Instant::now(); + let certificate = generate_self_signed_certificate().expect("certificate"); + let mut server = Version::Auto.endpoint(config(), certificate.clone(), base); + server.handle_timeout(base).expect("server clock"); + let hello = client_hello(Version::Dtls12, fragmenting_config(), base); + assert!(hello.len() > 1, "ClientHello not fragmented"); + server.handle_packet(&hello[0]).expect("first fragment"); + let (_, deadline) = settle(&mut server, base); + assert_eq!(deadline, base + BUDGET); + + let selection = base + Duration::from_millis(70); + server.handle_timeout(selection).expect("server clock"); + for packet in &hello[1..] { + server.handle_packet(packet).expect("remaining fragments"); + } + let (response, after) = settle(&mut server, selection); + assert!(!response.is_empty()); + assert_eq!(server.protocol_version(), Some(ProtocolVersion::DTLS1_2)); + assert_eq!(after, deadline); + assert_eq!( + server.handle_timeout(deadline), + Err(Error::Timeout(TimeoutError::Connect)) + ); +}