From b59f7d1d7bc17ab4776da827aaa3f4d683907a76 Mon Sep 17 00:00:00 2001 From: killdashnine <674985+killdashnine@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:23:11 +0200 Subject: [PATCH 1/4] test(dtls): show the last flight is not retransmitted when the peer repeats its own --- rtc-dtls/src/endpoint.rs | 63 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/rtc-dtls/src/endpoint.rs b/rtc-dtls/src/endpoint.rs index 86354309..175f1682 100644 --- a/rtc-dtls/src/endpoint.rs +++ b/rtc-dtls/src/endpoint.rs @@ -533,4 +533,67 @@ mod tests { Ok(()) } + + /// RFC 6347 4.2.4: the sender of the last flight has to retransmit it when the peer repeats its + /// own final flight, because it cannot know its flight arrived. Here the server's last flight + /// (ChangeCipherSpec + Finished) is lost in transit, the client retransmits on its own timer, and + /// the server has to answer with a fresh flight so the client can complete. + #[cfg(feature = "crypto-ring")] + #[test] + fn a_completed_server_retransmits_its_last_flight_when_the_client_repeats_its_own() + -> Result<()> { + let provider: Arc = Arc::new(crypto::providers::RingProvider::new()); + let suites = [CipherSuiteId::Tls_Ecdhe_Ecdsa_With_Aes_128_Gcm_Sha256]; + let client_config = config(provider.clone(), true, &suites)?; + let server_config = config(provider, false, &suites)?; + let mut client = Endpoint::new(client_addr(), TransportProtocol::UDP, None); + let mut server = Endpoint::new(server_addr(), TransportProtocol::UDP, Some(server_config)); + client.connect(Instant::now(), server_addr(), client_config, None)?; + + // Drive until the server completes. Its last flight is queued at that point, not yet taken. + let mut server_complete = false; + for _ in 0..32 { + for event in transfer(&mut client, &mut server, client_addr())? { + server_complete |= matches!(event, EndpointEvent::HandshakeComplete); + } + if server_complete { + break; + } + transfer(&mut server, &mut client, server_addr())?; + } + assert!(server_complete, "the server side never completed"); + + // Lose it: drain the server's queued last flight and deliver none of it. + let mut lost = 0usize; + while server.poll_transmit().is_some() { + lost += 1; + } + assert!(lost > 0, "the server had a last flight to lose"); + + // The client is still waiting for that flight, with its retransmit timer armed. + let deadline = client + .poll_timeout(&server_addr()) + .expect("a client waiting for the last flight arms a retransmit timer"); + client.handle_timeout(server_addr(), deadline)?; + + // Its retransmitted flight reaches the server... + let mut retransmitted = 0usize; + while let Some(transmit) = client.poll_transmit() { + retransmitted += 1; + server.read( + deadline, + client_addr(), + transmit.transport.ecn, + transmit.message, + )?; + } + assert!(retransmitted > 0, "the client retransmitted its own flight"); + + // ...and the server owes it a fresh last flight. + assert!( + server.poll_transmit().is_some(), + "a completed server has to retransmit its last flight when the peer repeats its own" + ); + Ok(()) + } } From 185780d69151dbafe698fd49f37704b9281421fd Mon Sep 17 00:00:00 2001 From: killdashnine <674985+killdashnine@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:23:58 +0200 Subject: [PATCH 2/4] fix(dtls): retransmit the last flight when the peer repeats its own (RFC 6347 4.2.4) --- rtc-dtls/src/endpoint.rs | 11 +++++++++++ rtc-dtls/src/handshaker.rs | 12 ++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/rtc-dtls/src/endpoint.rs b/rtc-dtls/src/endpoint.rs index 175f1682..a95b40d8 100644 --- a/rtc-dtls/src/endpoint.rs +++ b/rtc-dtls/src/endpoint.rs @@ -201,6 +201,17 @@ impl Endpoint { if is_handshake && !conn.is_handshake_completed() { conn.handshake(now)?; } + } else if conn.handshake_rx.is_some() { + // RFC 6347 4.2.4: the sender of the last flight cannot know it arrived, so a peer + // repeating its own final flight means ours was lost and has to be sent again. The + // association is complete, so the FSM above is skipped, and `send()` arms + // `current_retransmit_timer` only on the non-final path, so `handle_timeout` never + // fires for it either. Without this the repeat is absorbed into the handshake cache + // and answered with nothing, and the peer retransmits until it gives up. + // `handshake_timeout` owns the Finished -> Sending self loop, which regenerates the + // flight with fresh record sequence numbers; a verbatim replay would be dropped by + // the peer's replay window (4.1.2.6). + conn.handshake_timeout(now)?; } if !is_handshake_completed_before && conn.is_handshake_completed() { messages.push(EndpointEvent::HandshakeComplete) diff --git a/rtc-dtls/src/handshaker.rs b/rtc-dtls/src/handshaker.rs index 00f713de..7bf4114b 100644 --- a/rtc-dtls/src/handshaker.rs +++ b/rtc-dtls/src/handshaker.rs @@ -102,9 +102,17 @@ impl DTLSConn { }; if previous_handshake_state == self.current_handshake_state - && previous_handshake_state == HandshakeState::Waiting + && matches!( + previous_handshake_state, + HandshakeState::Waiting | HandshakeState::Finished + ) { - // wait for timeout or incoming packet + // Wait for a timeout or an incoming packet. `Finished` self transitions for the same + // reason `Waiting` does, and has to terminate for a stronger one: an association + // re-entered here after completing (the RFC 6347 4.2.4 last flight retransmission) + // has already passed the loop head guard, which only returns while + // `!is_handshake_completed()`. `send()` queues the flight and yields `Finished`, + // `finish()` yields `Finished` again, and without this the loop would spin. return Ok(()); } } From d41c4979ba3cce89926a35d630b0a9fadefc9586 Mon Sep 17 00:00:00 2001 From: killdashnine <674985+killdashnine@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:12:14 +0200 Subject: [PATCH 3/4] style(dtls): wrap the retransmission test signature as rustfmt does --- rtc-dtls/src/endpoint.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rtc-dtls/src/endpoint.rs b/rtc-dtls/src/endpoint.rs index a95b40d8..a0765a4c 100644 --- a/rtc-dtls/src/endpoint.rs +++ b/rtc-dtls/src/endpoint.rs @@ -551,8 +551,8 @@ mod tests { /// the server has to answer with a fresh flight so the client can complete. #[cfg(feature = "crypto-ring")] #[test] - fn a_completed_server_retransmits_its_last_flight_when_the_client_repeats_its_own() - -> Result<()> { + fn a_completed_server_retransmits_its_last_flight_when_the_client_repeats_its_own() -> Result<()> + { let provider: Arc = Arc::new(crypto::providers::RingProvider::new()); let suites = [CipherSuiteId::Tls_Ecdhe_Ecdsa_With_Aes_128_Gcm_Sha256]; let client_config = config(provider.clone(), true, &suites)?; From 6aeaf1a3c5593c5c97ce96ff4b9f69254b5ba63f Mon Sep 17 00:00:00 2001 From: killdashnine <674985+killdashnine@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:13:38 +0200 Subject: [PATCH 4/4] fix(dtls): cap the last-flight retransmit and trigger it per datagram --- rtc-dtls/src/conn/mod.rs | 11 ++ rtc-dtls/src/endpoint.rs | 326 +++++++++++++++++++++++++++++++++---- rtc-dtls/src/handshaker.rs | 31 +++- 3 files changed, 337 insertions(+), 31 deletions(-) diff --git a/rtc-dtls/src/conn/mod.rs b/rtc-dtls/src/conn/mod.rs index a1b8f79b..f7ffc555 100644 --- a/rtc-dtls/src/conn/mod.rs +++ b/rtc-dtls/src/conn/mod.rs @@ -103,6 +103,10 @@ pub struct DTLSConn { pub(crate) handshake_config: Arc, pub(crate) retransmit: bool, pub(crate) handshake_rx: Option<()>, + /// Whether the datagram most recently passed to [`Self::read`] carried a handshake record. + /// Reset on every `read`, unlike the sticky `handshake_rx`, so a completed association can tell + /// an RFC 6347 §4.2.4 last-flight repeat from ordinary post-handshake application data. + pub(crate) received_handshake_in_last_datagram: bool, } impl DTLSConn { @@ -167,6 +171,7 @@ impl DTLSConn { handshake_config, retransmit: false, handshake_rx: None, + received_handshake_in_last_datagram: false, } } @@ -467,6 +472,10 @@ impl DTLSConn { /// Feeds one received datagram into the connection. /// + /// Records whether this datagram carried a handshake record in + /// `received_handshake_in_last_datagram`, which a completed association uses to tell an + /// RFC 6347 §4.2.4 last-flight repeat from ordinary post-handshake application data. + /// /// # Errors /// /// Fails if the record is malformed or fails authentication. @@ -474,6 +483,7 @@ impl DTLSConn { // Per RFC 6347: buffer future-epoch packets only until Finished is received // (i.e. until handshake completes). After that, discard them. let enqueue = !self.is_handshake_completed(); + self.received_handshake_in_last_datagram = false; for pkt in unpack_datagram(buf)? { let (hs, alert, err) = self.handle_incoming_packet(pkt, enqueue); if let Some(alert) = alert { @@ -502,6 +512,7 @@ impl DTLSConn { } if hs { + self.received_handshake_in_last_datagram = true; self.handshake_rx = Some(()); } } diff --git a/rtc-dtls/src/endpoint.rs b/rtc-dtls/src/endpoint.rs index a0765a4c..675c1fe9 100644 --- a/rtc-dtls/src/endpoint.rs +++ b/rtc-dtls/src/endpoint.rs @@ -201,16 +201,21 @@ impl Endpoint { if is_handshake && !conn.is_handshake_completed() { conn.handshake(now)?; } - } else if conn.handshake_rx.is_some() { - // RFC 6347 4.2.4: the sender of the last flight cannot know it arrived, so a peer - // repeating its own final flight means ours was lost and has to be sent again. The - // association is complete, so the FSM above is skipped, and `send()` arms - // `current_retransmit_timer` only on the non-final path, so `handle_timeout` never - // fires for it either. Without this the repeat is absorbed into the handshake cache - // and answered with nothing, and the peer retransmits until it gives up. - // `handshake_timeout` owns the Finished -> Sending self loop, which regenerates the - // flight with fresh record sequence numbers; a verbatim replay would be dropped by - // the peer's replay window (4.1.2.6). + } else if conn.received_handshake_in_last_datagram { + // RFC 6347 4.2.4: this datagram carried a handshake record although the association + // is already established, so the peer is repeating its own final flight — ours was + // lost and has to be sent again. The gate is this datagram's handshake record, not + // the sticky `handshake_rx` flag, so ordinary post-handshake application data can + // never provoke a spurious flight (certificate included, for a client). The + // association is complete, so the FSM above is skipped, and `send()` never arms + // `current_retransmit_timer` on the completed path, so `handle_timeout` cannot fire + // for it either. `handshake_timeout`'s Finished branch regenerates the flight — with + // fresh record sequence numbers, since a verbatim replay would be dropped by the + // peer's replay window (4.1.2.6) — under a retransmission budget, so an + // unauthenticated repeat cannot amplify without limit. Clear `handshake_rx` first so + // that retransmission re-sends the buffered flight instead of re-parsing the whole + // transcript in `finish()`. + conn.handshake_rx = None; conn.handshake_timeout(now)?; } if !is_handshake_completed_before && conn.is_handshake_completed() { @@ -310,6 +315,7 @@ mod tests { use super::*; use crate::cipher_suite::CipherSuiteId; use crate::config::ConfigBuilder; + use crate::conn::DEFAULT_MAXIMUM_RETRANSMIT_NUMBER; use crate::crypto::Certificate; use crypto::{CryptoError, RTCCrypto, RTCCryptoProvider, RTCRandom}; @@ -545,14 +551,124 @@ mod tests { Ok(()) } + /// Runs a full handshake to completion and returns both endpoints, drained of any pending + /// transmit so a later assertion sees only newly generated output. + #[cfg(feature = "crypto-ring")] + fn completed_client_and_server() -> Result<(Endpoint, Endpoint)> { + let provider: Arc = Arc::new(crypto::providers::RingProvider::new()); + let suites = [CipherSuiteId::Tls_Ecdhe_Ecdsa_With_Aes_128_Gcm_Sha256]; + let client_config = config(provider.clone(), true, &suites)?; + let server_config = config(provider, false, &suites)?; + let mut client = Endpoint::new(client_addr(), TransportProtocol::UDP, None); + let mut server = Endpoint::new(server_addr(), TransportProtocol::UDP, Some(server_config)); + client.connect(Instant::now(), server_addr(), client_config, None)?; + + let mut client_complete = false; + let mut server_complete = false; + for _ in 0..32 { + for event in transfer(&mut client, &mut server, client_addr())? { + server_complete |= matches!(event, EndpointEvent::HandshakeComplete); + } + for event in transfer(&mut server, &mut client, server_addr())? { + client_complete |= matches!(event, EndpointEvent::HandshakeComplete); + } + if client_complete && server_complete { + break; + } + } + assert!( + client_complete && server_complete, + "the handshake completed for both sides" + ); + while client.poll_transmit().is_some() {} + while server.poll_transmit().is_some() {} + Ok((client, server)) + } + + /// A 29-byte epoch-0 record whose content type marks it a handshake and whose body is a + /// well-formed fragment header over a junk payload: [`crate::fragment_buffer::FragmentBuffer`] + /// classifies the datagram as a handshake record — arming the RFC 6347 4.2.4 retransmission + /// trigger — although nothing in it is authenticated (epoch 0 carries no MAC). + fn junk_epoch_zero_handshake_datagram(sequence_number: u64) -> BytesMut { + use crate::content::ContentType; + use crate::record_layer::record_layer_header::{PROTOCOL_VERSION1_2, RecordLayerHeader}; + + let header = RecordLayerHeader { + content_type: ContentType::Handshake, + protocol_version: PROTOCOL_VERSION1_2, + epoch: 0, + sequence_number, + content_len: 16, + }; + let mut raw = vec![]; + header.marshal(&mut raw).expect("a record header marshals"); + raw.extend_from_slice(&[ + 0xff, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, + 0x00, 0x00, + ]); + BytesMut::from(&raw[..]) + } + + /// The `(epoch, record sequence number)` of every record in `datagram`, read from the plaintext + /// record headers (the epoch-1 Finished body is encrypted, but its header is not). + fn record_sequence_numbers(datagram: &[u8]) -> Vec<(u16, u64)> { + use crate::record_layer::record_layer_header::RecordLayerHeader; + use crate::record_layer::unpack_datagram; + + unpack_datagram(datagram) + .expect("a datagram splits into records") + .into_iter() + .map(|record| { + let mut reader = record.as_slice(); + let header = + RecordLayerHeader::unmarshal(&mut reader).expect("each record has a header"); + (header.epoch, header.sequence_number) + }) + .collect() + } + + /// Asserts every record in `retransmit` carries a sequence number strictly greater than any + /// record of the same epoch in `original` — i.e. the flight was regenerated, not replayed + /// verbatim (a replay would be dropped by the peer's replay window, RFC 6347 4.1.2.6). + fn assert_fresh_sequence_numbers(original: &[BytesMut], retransmit: &[BytesMut]) { + use std::collections::HashMap; + + let by_epoch = |flight: &[BytesMut]| { + let mut per_epoch: HashMap> = HashMap::new(); + for datagram in flight { + for (epoch, sequence_number) in record_sequence_numbers(datagram) { + per_epoch.entry(epoch).or_default().push(sequence_number); + } + } + per_epoch + }; + + let original = by_epoch(original); + let retransmit = by_epoch(retransmit); + assert!( + retransmit.contains_key(&1), + "the retransmitted flight carries the encrypted Finished (epoch 1)" + ); + for (epoch, retransmit_seqs) in &retransmit { + if let Some(original_seqs) = original.get(epoch) { + let max_original = original_seqs.iter().copied().max().unwrap_or(0); + let min_retransmit = retransmit_seqs.iter().copied().min().unwrap_or(0); + assert!( + min_retransmit > max_original, + "epoch {epoch}: retransmitted record seq {min_retransmit} must be fresh, not a replay of {max_original}" + ); + } + } + } + /// RFC 6347 4.2.4: the sender of the last flight has to retransmit it when the peer repeats its /// own final flight, because it cannot know its flight arrived. Here the server's last flight - /// (ChangeCipherSpec + Finished) is lost in transit, the client retransmits on its own timer, and - /// the server has to answer with a fresh flight so the client can complete. + /// (ChangeCipherSpec + Finished) is lost in transit, the client retransmits Flight 5 on its own + /// timer, and the server answers with a fresh flight (fresh record sequence numbers, not a + /// replay) so the client completes. #[cfg(feature = "crypto-ring")] #[test] - fn a_completed_server_retransmits_its_last_flight_when_the_client_repeats_its_own() -> Result<()> - { + fn a_completed_server_retransmits_a_fresh_last_flight_so_the_client_completes() -> Result<()> { let provider: Arc = Arc::new(crypto::providers::RingProvider::new()); let suites = [CipherSuiteId::Tls_Ecdhe_Ecdsa_With_Aes_128_Gcm_Sha256]; let client_config = config(provider.clone(), true, &suites)?; @@ -574,23 +690,34 @@ mod tests { } assert!(server_complete, "the server side never completed"); - // Lose it: drain the server's queued last flight and deliver none of it. - let mut lost = 0usize; - while server.poll_transmit().is_some() { - lost += 1; + // Lose it: capture the server's queued last flight (to compare sequence numbers) but + // deliver none of it. + let mut original_last_flight = vec![]; + while let Some(transmit) = server.poll_transmit() { + original_last_flight.push(transmit.message); } - assert!(lost > 0, "the server had a last flight to lose"); + assert!( + !original_last_flight.is_empty(), + "the server had a last flight to lose" + ); - // The client is still waiting for that flight, with its retransmit timer armed. + // The client is still waiting for that flight; its retransmit timer fires and it repeats + // Flight 5. let deadline = client .poll_timeout(&server_addr()) .expect("a client waiting for the last flight arms a retransmit timer"); client.handle_timeout(server_addr(), deadline)?; - - // Its retransmitted flight reaches the server... - let mut retransmitted = 0usize; + let mut client_repeat = vec![]; while let Some(transmit) = client.poll_transmit() { - retransmitted += 1; + client_repeat.push(transmit); + } + assert!( + !client_repeat.is_empty(), + "the client retransmitted its own flight" + ); + + // RFC 6347 4.2.4 requires the completed server to answer with its last flight again. + for transmit in client_repeat { server.read( deadline, client_addr(), @@ -598,13 +725,156 @@ mod tests { transmit.message, )?; } - assert!(retransmitted > 0, "the client retransmitted its own flight"); - - // ...and the server owes it a fresh last flight. + let mut retransmit = vec![]; + while let Some(transmit) = server.poll_transmit() { + retransmit.push(transmit.message); + } assert!( - server.poll_transmit().is_some(), + !retransmit.is_empty(), "a completed server has to retransmit its last flight when the peer repeats its own" ); + assert_fresh_sequence_numbers(&original_last_flight, &retransmit); + + // Feed the fresh flight back; the client must now actually complete. + let mut client_complete = false; + for datagram in retransmit { + for event in client.read(deadline, server_addr(), None, datagram)? { + client_complete |= matches!(event, EndpointEvent::HandshakeComplete); + } + } + assert!( + client_complete, + "the client completes once the retransmitted last flight arrives" + ); + Ok(()) + } + + /// RFC 6347 4.2.4 for the client role: a completed client repeats its own last flight (Flight 5) + /// when the peer repeats its final flight. Flight 5 carries the client certificate, so it spans + /// epoch-0 records (certificate/key-exchange/verify/ChangeCipherSpec) plus the epoch-1 Finished, + /// and each repeat carries fresh record sequence numbers. + #[cfg(feature = "crypto-ring")] + #[test] + fn a_completed_client_retransmits_its_full_last_flight_with_fresh_record_sequence_numbers() + -> Result<()> { + let (mut client, _server) = completed_client_and_server()?; + + client.read( + Instant::now(), + server_addr(), + None, + junk_epoch_zero_handshake_datagram(20_000), + )?; + let mut first = vec![]; + while let Some(transmit) = client.poll_transmit() { + first.push(transmit.message); + } + assert!(!first.is_empty(), "the client retransmits its last flight"); + let epochs: std::collections::HashSet = first + .iter() + .flat_map(|datagram| record_sequence_numbers(datagram)) + .map(|(epoch, _)| epoch) + .collect(); + assert!( + epochs.contains(&0) && epochs.contains(&1), + "the retransmit is the full Flight 5 (epoch-0 records plus the epoch-1 Finished), not a bare answer" + ); + + client.read( + Instant::now(), + server_addr(), + None, + junk_epoch_zero_handshake_datagram(20_001), + )?; + let mut second = vec![]; + while let Some(transmit) = client.poll_transmit() { + second.push(transmit.message); + } + assert!( + !second.is_empty(), + "the client retransmits again on the next repeat" + ); + assert_fresh_sequence_numbers(&first, &second); + Ok(()) + } + + /// A completed client fed a stream of unauthenticated epoch-0 handshake records must answer at + /// most `maximum_retransmit_number` times and then fall silent (closing the RFC 6347 4.2.4 + /// retransmission amplification), and must never error out of `read` — an `ErrInvalidFsmTransition` + /// escaping here would tear down the established association. + #[cfg(feature = "crypto-ring")] + #[test] + fn a_completed_client_neither_amplifies_nor_tears_down_when_flooded_with_handshake_records() + -> Result<()> { + let (mut client, _server) = completed_client_and_server()?; + + let floods = DEFAULT_MAXIMUM_RETRANSMIT_NUMBER + 5; + let mut answered = 0usize; + let mut silent_after_budget = true; + for index in 0..floods { + let datagram = junk_epoch_zero_handshake_datagram(10_000 + index as u64); + let result = client.read(Instant::now(), server_addr(), None, datagram); + assert!( + result.is_ok(), + "read {index} of a junk handshake record must not tear down an established association" + ); + let mut produced = false; + while client.poll_transmit().is_some() { + produced = true; + } + if produced { + answered += 1; + } + if index >= DEFAULT_MAXIMUM_RETRANSMIT_NUMBER { + silent_after_budget &= !produced; + } + } + assert!(answered > 0, "the retransmission path is reachable at all"); + assert!( + answered <= DEFAULT_MAXIMUM_RETRANSMIT_NUMBER, + "a completed client answered {answered} floods but the budget is {DEFAULT_MAXIMUM_RETRANSMIT_NUMBER}" + ); + assert!( + silent_after_budget, + "the retransmit budget must silence the flood once it is spent" + ); + Ok(()) + } + + /// The last-flight retransmission must be gated on this datagram carrying a handshake record, + /// not on the persisted `handshake_rx` flag. Application data arriving after the handshake — + /// even with `handshake_rx` left set — must be decrypted and answered with nothing, never with a + /// spurious flight. + #[cfg(feature = "crypto-ring")] + #[test] + fn application_data_does_not_retrigger_a_completed_client_even_with_a_stale_handshake_flag() + -> Result<()> { + let (mut client, mut server) = completed_client_and_server()?; + + // Simulate the leak finding 3 describes: a `handshake_rx` left set from an earlier datagram. + client + .connections + .get_mut(&server_addr()) + .expect("the client holds the association") + .handshake_rx = Some(()); + + server.write( + Instant::now(), + client_addr(), + b"post-handshake application data", + )?; + let events = transfer(&mut server, &mut client, server_addr())?; + assert!( + events.iter().any(|event| matches!( + event, + EndpointEvent::ApplicationData(data) if data.as_ref() == b"post-handshake application data" + )), + "the completed client still decrypts application data" + ); + assert!( + client.poll_transmit().is_none(), + "application data must not make a completed client emit a handshake flight" + ); Ok(()) } } diff --git a/rtc-dtls/src/handshaker.rs b/rtc-dtls/src/handshaker.rs index 7bf4114b..26ffe9e5 100644 --- a/rtc-dtls/src/handshaker.rs +++ b/rtc-dtls/src/handshaker.rs @@ -172,7 +172,13 @@ impl DTLSConn { self.write_packets(pkts); } - if self.current_flight.is_last_send_flight() { + // A Sending reached after the association has completed is an RFC 6347 4.2.4 + // retransmission of our own last flight (the peer repeated its final flight), so it returns + // to Finished and never re-arms the receive timer. Only `Flight6` (the server) overrides + // `is_last_send_flight`, so on its own that check would send the client — whose last flight + // is `Flight5` — back into Waiting, where a failed `Flight5::parse` would strand a completed + // association forever. Gating on completion keeps both roles terminal here. + if self.current_flight.is_last_send_flight() || self.is_handshake_completed() { Ok(HandshakeState::Finished) } else { self.current_retransmit_timer = Some(now + self.handshake_config.retransmit_interval); @@ -289,8 +295,27 @@ impl DTLSConn { Some(HandshakeState::Waiting) } } else if self.current_handshake_state == HandshakeState::Finished { - // Retransmit last flight - Some(HandshakeState::Sending) + // RFC 6347 4.2.4: retransmit our last flight because the peer repeated its own, which + // means ours was lost. Count it against the same budget as any handshake + // retransmission, so a peer — or an attacker replaying unauthenticated epoch-0 + // handshake records — cannot make a completed association regenerate its flight (a full + // Flight5 with certificate, for a client) without limit. Once the budget is spent, stop + // answering and stay Finished: the association is established, so exhausting + // retransmissions here must never take it to Errored the way the Waiting branch does — + // that would tear down a live connection. + self.current_retransmit_count += 1; + if self.current_retransmit_count > self.maximum_retransmit_number { + warn!( + "[handshake:{}] {} last-flight retransmit budget spent ({} > {}), staying Finished", + srv_cli_str(self.state.is_client), + self.current_flight, + self.current_retransmit_count, + self.maximum_retransmit_number, + ); + None + } else { + Some(HandshakeState::Sending) + } } else { None };