fix(dtls): retransmit the last flight when the peer repeats its own (RFC 6347 4.2.4) - #243
killdashnine wants to merge 4 commits into
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #243 +/- ##
==========================================
+ Coverage 87.63% 87.69% +0.05%
==========================================
Files 498 498
Lines 71322 71325 +3
==========================================
+ Hits 62506 62546 +40
+ Misses 8816 8779 -37
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Pushed d41c497, rustfmt only on the test signature I added, no change to the fix itself. The runs for that push are sitting on approval so they haven't started. Happy to fix anything else that comes back red. |
| if is_handshake && !conn.is_handshake_completed() { | ||
| conn.handshake(now)?; | ||
| } | ||
| } else if conn.handshake_rx.is_some() { |
There was a problem hiding this comment.
Unauthenticated packet amplification:
The new else if conn.handshake_rx.is_some() { conn.handshake_timeout(now)? } makes last-flight retransmission reachable from read() with no retransmission budget. handshake_timeout's Finished branch (handshaker.rs:292) goes straight to Sending without touching current_retransmit_count / maximum_retransmit_number — unlike the Waiting branch directly above it. And handshake_rx is set for any record FragmentBuffer::push classifies as a handshake: before Handshake::unmarshal, before flight parsing, and for plaintext epoch-0 records carrying no authentication at all.
with 29-byte epoch-0 handshake records with junk bodies and unique sequence numbers, 40 iterations:
- client with a certificate (the WebRTC config): 1160 bytes in → 26000 bytes out, ~22x
- server: 1160 in → 3000 out, ~2.6x
No error, no timer armed, no ceiling. CPU amplifies too — each client pass runs Flight5::parse, meaning cache.pull_and_merge over the whole transcript plus prf_verify_data_server, per inbound packet. That's a remote amplification vector off unauthenticated input.
There was a problem hiding this comment.
The missing cap:
That Some(HandshakeState::Sending) was dead code before this PR, so the absent counter increment and maximum_retransmit_number check never mattered. It's now the natural place for the budget. Worth noting there's no state where this side ever gives up: a peer legitimately retransmitting for maximum_retransmit_number rounds gets answered every time, forever.
| if is_handshake && !conn.is_handshake_completed() { | ||
| conn.handshake(now)?; | ||
| } | ||
| } else if conn.handshake_rx.is_some() { |
There was a problem hiding this comment.
Sticky flag, not "this datagram":
The gate is the persisted handshake_rx field. The sibling branch sets it inside handle_incoming_queued_packets and then declines to run the FSM when the handshake just completed — nothing clears it. So any later datagram, including pure application data, takes the new branch and emits a spurious full flight 5 (certificate included, for a client). Inspecting data for a handshake record in this call would make the trigger self-contained.
| 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 |
There was a problem hiding this comment.
The comment is only true for the server:
It claims handshake_timeout owns the Finished -> Sending self-loop, but Flight5::is_last_send_flight() is false, so the client path is Finished -> Sending -> Waiting -> Finished. If Flight5::parse returns Err((None, None)), wait() leaves the association permanently in Waiting with handshake_completed == true. The next handshake record then walks the Waiting branch to Errored, and handshake()'s ErrInvalidFsmTransition propagates out of Endpoint::read — where peer_connection/handler/dtls.rs:182 applies ? and tears down an established PeerConnection, dropping queued transmits since the poll_transmit drain sits after the ?.
| /// 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<()> |
There was a problem hiding this comment.
Test coverage:
Server role only, and it just asserts poll_transmit().is_some() — never feeds the flight back to confirm completion, never checks the record sequence numbers are fresh (the PR's stated reason for regenerating rather than replaying), and never exercises the client role that finding 1 shows is the expensive case.
|
Pushed 6aeaf1a, which covers all five. 1/2: the Finished branch now counts against current_retransmit_count and stops at maximum_retransmit_number. It returns None rather than Errored, so a spent budget goes quiet instead of tearing down an established association. The count is 0 at completion and never reset after it, so a completed association answers at most 7 times ever. 3: the trigger is no longer handshake_rx. read() records whether that datagram carried a handshake record and the completed branch gates on that, so application data can't provoke a flight. handshake_rx is cleared before handshake_timeout so the retransmit re-sends the buffered flight instead of re-parsing the transcript. 4: send() returns Finished on is_last_send_flight() || is_handshake_completed(), so the client stays terminal instead of dropping into Waiting. Comment rewritten, it was only true for the server. 5: tests now cover both roles, feed the flight back and assert the peer completes, assert the retransmitted records carry fresh sequence numbers, and flood a completed client with 12 unauthenticated epoch-0 records to assert it answers 7 then stays silent without erroring. One correction on 4: in the junk flood case Flight5::parse actually succeeds on re-entry, because the client never advances handshake_recv_sequence past the server Finished, so the unpatched code amplifies rather than strands. The stranding needs the Finished to be unpullable. Either way both paths are prevented. |
RFC 6347 section 4.2.4 makes the sender of the last handshake flight responsible for retransmitting
it: that side cannot know its flight arrived, so when the peer repeats its own final flight, the last
flight has to be sent again.
Endpointnever does this.handshaker.rssend()returnsFinishedfor the last flight without armingcurrent_retransmit_timer(theelsebranch is the only place it is set).Endpoint::handle_timeoutgates its only call tohandshake_timeoutbehindcurrent_retransmit_timer.is_some() && !is_handshake_completed(), and both are false once thehandshake is done.
Endpoint::readre-enters the FSM only while!is_handshake_completed().So the
// Retransmit last flightbranch insidehandshake_timeoutis unreachable throughEndpoint, andpoll_timeoutreturnsNonefor a completed association, so a caller cannot scheduleit either.
The effect on a DTLS server: if its flight 6 (ChangeCipherSpec + Finished) is lost, the client
retransmits flight 5 until it gives up. The server considers the handshake complete and exports its
keying material, so a media server reports a healthy, keyed session while the call carries no media
and nothing logs an error.
Two changes:
Endpoint::read: when the association was already complete and the datagram carried a handshakerecord (
handshake_rx), drivehandshake_timeout, which already implements theFinished -> Sendingself loop. Regenerating through the state machine is the point: a verbatimreplay of the cached records is discarded by the peer's replay window (section 4.1.2.6), so a
conforming retransmission needs fresh record sequence numbers.
handshaker.rs: let aFinished -> Finishedself transition return, the wayWaiting -> Waitingalready does. Without it the loop spins. A completed association has passed the loop head guard
(which only returns while
!is_handshake_completed()),send()queues the flight and yieldsFinished, andfinish()yieldsFinishedagain.The test drops the server's last flight, takes the client's
poll_timeout, driveshandle_timeout,feeds the client's retransmitted flight 5 back, and asserts the server answers with a fresh flight. It
fails on current master and passes with these changes. The existing tests are unaffected.