Skip to content

fix(dtls): retransmit the last flight when the peer repeats its own (RFC 6347 4.2.4) - #243

Open
killdashnine wants to merge 4 commits into
webrtc-rs:masterfrom
killdashnine:fix/dtls-last-flight-retransmit
Open

killdashnine wants to merge 4 commits into
webrtc-rs:masterfrom
killdashnine:fix/dtls-last-flight-retransmit

Conversation

@killdashnine

Copy link
Copy Markdown

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.

Endpoint never does this.

  • handshaker.rs send() returns Finished for the last flight without arming
    current_retransmit_timer (the else branch is the only place it is set).
  • Endpoint::handle_timeout gates its only call to handshake_timeout behind
    current_retransmit_timer.is_some() && !is_handshake_completed(), and both are false once the
    handshake is done.
  • Endpoint::read re-enters the FSM only while !is_handshake_completed().

So the // Retransmit last flight branch inside handshake_timeout is unreachable through
Endpoint, and poll_timeout returns None for a completed association, so a caller cannot schedule
it 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:

  1. Endpoint::read: when the association was already complete and the datagram carried a handshake
    record (handshake_rx), drive handshake_timeout, which already implements the
    Finished -> Sending self loop. Regenerating through the state machine is the point: a verbatim
    replay 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.

  2. handshaker.rs: let a Finished -> Finished self transition return, the way Waiting -> Waiting
    already 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 yields
    Finished, and finish() yields Finished again.

The test drops the server's last flight, takes the client's poll_timeout, drives handle_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.

@codecov

codecov Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.69%. Comparing base (4239564) to head (d41c497).

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     
Flag Coverage Δ
aws-lc-rs 87.47% <75.00%> (-0.01%) ⬇️
ring 87.68% <100.00%> (+0.05%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@killdashnine

Copy link
Copy Markdown
Author

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.

Comment thread rtc-dtls/src/endpoint.rs Outdated
if is_handshake && !conn.is_handshake_completed() {
conn.handshake(now)?;
}
} else if conn.handshake_rx.is_some() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread rtc-dtls/src/handshaker.rs Outdated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread rtc-dtls/src/endpoint.rs Outdated
if is_handshake && !conn.is_handshake_completed() {
conn.handshake(now)?;
}
} else if conn.handshake_rx.is_some() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread rtc-dtls/src/endpoint.rs Outdated
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ?.

Comment thread rtc-dtls/src/endpoint.rs Outdated
/// 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<()>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@killdashnine

Copy link
Copy Markdown
Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants