Skip to content

DTLS 1.3 client and server handshakes (RFC 9147), part 3 of 4 for #1468 - #2441

Open
mondain wants to merge 30 commits into
bcgit:mainfrom
mondain:dtls13/3-client-server
Open

mondain wants to merge 30 commits into
bcgit:mainfrom
mondain:dtls13/3-client-server

Conversation

@mondain

@mondain mondain commented Sep 12, 2026

Copy link
Copy Markdown

DTLS 1.3 (RFC 9147), part 3 of 4: the client and server handshakes.

Stacked on #2440 (reliable handshake), which is stacked on #2439 (record layer). Review those first;
this PR's diff against #2440 is the handshake logic itself.

The series was planned as five parts and is now four: the client and the server are submitted together
here, because the two are mirror images and splitting them would have left a part that could not be tested
against anything. Part 4 is the post-handshake work.

With this PR a DTLSClientProtocol and DTLSServerProtocol pair whose peers list ProtocolVersion.DTLSv13
completes a full certificate-authenticated 1-RTT DTLS 1.3 handshake, including HelloRetryRequest, optional
client authentication, ACK-driven recovery from packet loss, and RFC 5705 / RFC 8446 7.5 exporters. The
immediate motivation is DTLS-SRTP (RFC 5764) over DTLS 1.3 for WebRTC, which is verified here end to end.

What a maintainer will want to check first

No shared-path changes. TlsClientProtocol, TlsServerProtocol, TlsProtocol and RecordStream are
untouched. The only file shared with TLS over TCP that changes at all is TlsUtils, which gains a
null != recordStream guard in establish13TrafficSecrets that TLS callers never reach.

DTLS 1.2 is unchanged on the wire. Every new branch in DTLSRecordLayer and DTLSReliableHandshake is
gated on the negotiated version; the legacy 13-byte record read paths only gain an additional else if; and
the transcript change below is conditional. The full existing DTLS 1.2 and TLS test suites pass unchanged.

There is exactly one behavioural change on the DTLS 1.2 path, and it is a deliberate convergence rather than
a side effect: notifySecureRenegotiation now fires from generateServerHello after
processClientExtensions, which is where TlsServerProtocol has always called it, instead of earlier from
processClientHello. No wire bytes move. The reason for the change is that gating that callback on the
versions the client offered, rather than on the version selected, made a DTLS 1.3 server reject a client
offering both versions without renegotiation_info or the SCSV — a client a BC TLS 1.3 server accepts.

Version ceilings. CLIENT_LATEST_SUPPORTED_DTLS and SERVER_LATEST_SUPPORTED_DTLS both move to
DTLSv13. To be precise about what that does and does not mean: AbstractTlsPeer.getSupportedVersions()
returns TLS versions, so every DTLS peer already names its own version list, and these two constants only
bound what isSupportedDTLSVersionClient/Server will permit. A peer that does not list DTLSv13 is
unaffected. DTLS 1.3 is not silently switched on for anyone.

The transcript

DTLS 1.3 hashes a 4-byte TLS-style handshake header — msg_type plus a uint24 length — not DTLS's 12-byte
header with message_seq, fragment_offset and fragment_length (RFC 9147 5.2). DTLSReliableHandshake
now defers encoding until the version is decided, because the ClientHello is hashed before a version exists,
and DTLSTranscriptHashTest asserts the resulting bytes against an independently computed SHA-256 rather
than against another BC peer.

This is worth calling out because it is the one class of defect that no BC-to-BC handshake can surface: both
peers would have agreed on the wrong transcript and every test would have passed while all interop failed.

DTLS-SRTP over DTLS 1.3

Verified, and the test earns the claim rather than asserting it: both peers derive byte-identical 60-byte
EXTRACTOR-dtls_srtp material; the ServerHello on the wire is checked to carry no use_srtp, proving the
profile was negotiated through the encrypted EncryptedExtensions; and the server deliberately selects the
last of two offered profiles so the client cannot be echoing its own preference. No main-source code was
needed for this — the existing SRTP plumbing works once the exporter does.

Known gaps

Stated plainly, because they are the reasons this is part 3 of 4 rather than the whole feature.

  • No interop testing against another DTLS 1.3 implementation yet. This is the honest headline risk. The
    transcript header form is the one interop-critical fact covered by a byte-level test against independently
    computed bytes; the HelloRetryRequest message_hash substitution is the second. Everything else is
    currently proven BC-to-BC. Interop against BoringSSL, wolfSSL, OpenSSL master and NSS is being built out;
    community requests for OpenSSL 4.1/master and for NSS/Firefox on DTLS 1.3 support #1468 are noted and planned.
  • A cookie-only HelloRetryRequest is not supported. A stateless third-party server's HRR carries a cookie
    and no key_share; this client answers missing_extension. Marked TODO[dtls13:psk_ke] in place. This is
    the most likely real interop failure, and it is a hard failure.
  • No RFC 9147 5.1 stateless DoS countermeasure. DTLS 1.3 forbids HelloVerifyRequest, so the existing
    DTLSVerifier front end cannot front a 1.3 handshake. A 1.3-capable server reached through accept(DTLSRequest, ...) therefore
    refuses to negotiate 1.3 rather than failing obscurely later, with a diagnostic naming the cause. It is
    worth saying why it refuses instead of quietly negotiating 1.2: the DOWNGRD sentinel (RFC 8446 4.1.3) is
    derived from the server peer's own configured versions, not from anything this code can narrow per
    connection, so a local downgrade would leave the sentinel written and every 1.3-capable client aborting. A stateless HelloRetryRequest front end
    analogous to DTLSVerifier needs its own PR — it is a new public class, not a few lines — and an earlier
    draft of this PR that tried to add a half-measure was removed rather than shipped, because a cookie demand
    that is bypassable is worse than none.
  • Out of scope here, deliberately: PSK and resumption, 0-RTT early data, connection IDs (RFC 9146), and
    post-handshake KeyUpdate, NewSessionTicket and client authentication. Post-handshake work is part 4. The
    corresponding skip13* paths and TODO[dtls13-psk] markers are in place.
  • The second ClientHello's extensions are not compared against the first beyond the fields everything
    downstream depends on, mirroring the existing TODO on the TLS path.

Tests

837 tests in the tls module, no failures; checkstyle clean. tls/src/main/java stays within the legacy
Java 1.4/1.5 Ant build constraints.

Beyond the handshake variants, the tests that carry the most weight are the ones with negative controls:
MTU 512 against MTU 1500 to establish from the wire, without decrypting, that the authenticated client flight
genuinely fragments; a deterministic drop of that flight, with the retransmission proven by the observed
epoch sequence; null versus zero-length exporter context yielding the same material under 1.3 and different
material under 1.2, which distinguishes RFC 8446 7.5 from RFC 5705 4 rather than merely observing that two
outputs differ; and flight-matching unit tests that reject wrong epoch, wrong type, wrong length, truncation,
and a foreign fragment packed beside a real one.

Disclosure

This work was produced with generative-AI assistance, per the contributing guidelines. Every design decision,
every security finding and its fix, and every test were reviewed; the RFC citations above were checked
against the specification text rather than recalled.

Refs #1468.

@JonathanLennox

Copy link
Copy Markdown

@mondain First interop result, from Jitsi Videobridge built on dtls13/4-post-handshake as DTLS server against Chrome (BoringSSL) as client, offering DTLS 1.3 with X25519MLKEM768: the handshake did not complete, and the cause is a single spec point rather than anything in the record layer or handshake logic.

RFC 9147 section 5.9 requires the HKDF-Expand-Label prefix "dtls13" (no trailing space) for DTLS 1.3, for key separation from TLS 1.3. TlsCryptoUtils.hkdfExpandLabel hardcodes "tls13 ", and the series does not change it, so every DTLS 1.3 secret, traffic key, sn key, Finished key and exporter differs from what other implementations derive. BC-to-BC cannot detect it, for the reason you gave for the transcript header: both peers make the same mistake.

What it looked like on the wire, in case anyone else hits it: Chrome accepted the ServerHello (X25519MLKEM768 selected, so the hybrid key share exchange itself was fine) and then sent only ACKs, each covering one more record than the last. Those were the plaintext ServerHello records of each retransmission; it never acknowledged any epoch 2 record, because it could not decrypt them, and DTLS drops undecryptable records without an alert. The server retransmitted until the handshake timed out.

With the prefix corrected, the same bridge negotiates DTLS 1.3, X25519MLKEM768 with Chrome in about 100 ms and DTLS-SRTP media flows both ways, so the exporter agrees too. Fix and an independently computed known-answer test are in a PR against your branch: mondain#1. Everything else in the series worked unchanged against BoringSSL for this handshake shape (fragmented ClientHello, hybrid key share, server-side 1-RTT with client authentication), which is a good sign.

Two things I still want to check against draft-ietf-tls-rfc9147bis, which has post-RFC corrections on ACK handling and key updates, but they did not affect this handshake.

@JonathanLennox

Copy link
Copy Markdown

@mondain Two follow-ups after the label fix (mondain#1).

Interop status. With that fix, Jitsi Videobridge on this branch now completes DTLS 1.3 with X25519MLKEM768 as server against both Chrome (BoringSSL) and Firefox (NSS), with DTLS-SRTP media flowing both ways, so the exporter agrees with both stacks. The Firefox run also turned up a path-MTU problem on our side (a 1317-byte ServerHello datagram with an ML-KEM key share on a 1280-byte path), fixed in the bridge by sizing DTLS datagrams at 1200 like the browsers do; nothing for bctls there, but worth knowing that a DTLS 1.3 ServerHello with a hybrid key share no longer fits a conservative MTU on its own and relies on handshake fragmentation working, which it did.

Conformance review against RFC 9147 and draft-ietf-tls-rfc9147bis. I went through the series against the draft revision at tlswg/dtls13-spec, which has picked up several post-RFC corrections. Three items are fixed with tests in mondain#2:

  1. Plaintext (epoch 0) ACK records are dropped unread: ack is missing from the DTLSPlaintext content-type dispatch in DTLSRecordLayer.processRecord, so a peer acknowledging a fragmented ClientHello or ServerHello at epoch 0 is ignored and the flight is retransmitted on the timer. BC sends such ACKs itself, so two BC peers drop each other's.
  2. legacy_session_id_echo: the server echoes the client's session ID and the client requires the echo, where RFC 9147 5 requires an empty echo and the bis draft (Possible bug with ProvSSLSessionImpl#getId #298) requires the client to abort on a non-empty one. Two BC peers agree with each other and hide it; against a conforming peer it fails whenever a BC client offers a session cached from a DTLS 1.2 server.
  3. A non-empty legacy_cookie in a DTLS 1.3 ClientHello is not rejected (RFC 9147 5.3 MUST). Strictness only.

Two more bis MUSTs I did not change, since they only matter with a misbehaving peer or after 65k KeyUpdates: post-handshake messages arriving in an epoch after the peer's KeyUpdate in that epoch should be unexpected_message (#289), and message_seq must not wrap (#304; writeUint16 truncates silently). Happy to add them if you want them in the series.

One place where the draft's wording, not the code, is the problem: #294 (errata 8108) makes an ACK naming an epoch higher than "the epoch in which the ACK was received" fatal. Taken as the carrying record's epoch, which the errata's motivation implies, that breaks a second one-sided KeyUpdate, whose ACK the peer must send from its older epoch; taken as the receiver's epoch it no longer stops the forged plaintext ACK it was written for. The series' two-phase handling, and your testAckRecordNumbersAboveTheAckEpochSurviveAfterTheHandshake, are the right behaviour; I am raising the wording at tlswg/dtls13-spec, suggesting the post-handshake check be "an epoch the receiver has never sent in", which the series does not check today and would be a small follow-up if adopted.

Everything else I checked (ACK encoding and generation rules, ACK epoch rules, retransmission and the 2×MSL server state, KeyUpdate gating and key retention, invalid-record discard, sequence number and epoch reconstruction, fragmentation and reassembly, timers, codepoints) matched both texts.

This review and the fixes were produced with generative-AI assistance, per the contributing guidelines, and checked against the RFC and draft text.

mondain and others added 22 commits September 15, 2026 10:04
…le (RFC 9147 section 5.9) instead of TLS 1.3's "tls13 ", so DTLS 1.3 traffic keys, record number keys, Finished keys and exporters interoperate with other implementations, relates to github bcgit#1468.
…atagrams

Adds the ACK-driven reliable handshake of RFC 9147 sections 5.8 and 7, and packs
handshake flights into as few datagrams as the MTU allows.

The packing applies to DTLS 1.2 as well as 1.3, since that is what github bcgit#1487
asks for: on the existing aggregated-handshake test a client flight went from 5
datagrams to 3 for the same 1279 bytes. Only handshake records are packed, and a
non-handshake record flushes the buffer before it leaves, so write order is
preserved; that matters for the implicit change_cipher_spec, which must not
overtake the flight it follows.

The reliable handshake registers each written fragment against the record number
that carried it, retires fragments when an ACK arrives, retransmits only what is
outstanding, and emits ACKs on the RFC 9147 7.1 triggers. Inbound ACKs are
filtered by the epoch of the record carrying them, and both ACK emission and the
accumulated record-number list are bounded by what fits in one datagram.

DTLS 1.3 still cannot be negotiated, so none of the 1.3 paths are reachable yet.
DTLSReassembler.contributeFragment changed from void to boolean and gained
acceptsFragment and getNextExpectedOffset; the admission predicate is unchanged,
and DTLS 1.2 behaviour is unaffected.

Two test helpers, MinimalHandshakeAggregator and ServerHandshakeDropper, decided
what to do by inspecting only the first record of a datagram, which was exact
only while each datagram carried one record. Both now walk every record.

relates to github bcgit#1468.
closes github bcgit#1487.
…seq, relates to github bcgit#1468.

The re-review of the previous fixes found two comments claiming more than the
code does and two test assertions weaker than their messages.

The notification comment said the call was placed exactly as
TlsServerProtocol does it. The version gating is mirrored; the ordering
relative to the other TlsServer callbacks is not, and cannot be, because
DTLS selects the version in generateServerHello, by which point
establishClientSigAlgs and processClientExtensions have already run. Say
both, rather than claiming a wholesale mirror.

DTLSVerifier produces a DTLSRequest for any ClientHello carrying a cookie it
has verified, so it need not have sent the HelloVerifyRequest during that
call. The invariant is that the ClientHello arrived through the cookie
exchange.

The server-refusal test asserted the internal_error the client receives,
which is not specific to this cause, so the harness now records what the
server itself threw and the test asserts that diagnostic. The harness also
stops printing a stack trace for a server abort a test expects, which
otherwise makes a passing test look like a failing one.

Counting datagrams that carry a ClientHello cannot tell a new message from a
retransmission of the first, so the scripted transport records each
ClientHello's message_seq and the test requires it to advance to 1, per
RFC 6347 4.2.2.
@mondain
mondain force-pushed the dtls13/3-client-server branch from 32b0c5d to 49d7433 Compare September 15, 2026 17:31
@mondain

mondain commented Sep 15, 2026

Copy link
Copy Markdown
Author

[AI] @JonathanLennox, an update on your two comments above.

dtls13 label prefix (mondain#1). Now in the series. The key schedule is shared by every part, so the commit went into part 1 (#2439) rather than part 4, and parts 2 to 4 are rebased on top of it:

PR Branch Head
#2439 dtls13/1-record-layer 405626ef57
#2440 dtls13/2-reliable-handshake 5643cc6143
#2441 dtls13/3-client-server 49d7433468
#2442 dtls13/4-post-handshake b274dc3402

The only change to your commit is in Tls13NullCipher: it used an accessor that only exists from part 4, so the DTLS flag is now passed in from the security parameters the caller already holds. :tls:test and :tls:test25 pass on every part, and checkstyle is clean. If you rebuild the bridge, build from the new dtls13/4-post-handshake head rather than the old one.

Conformance fixes (mondain#2). Reviewed there. The three changes are correct. The legacy_cookie check also needs to cover the second ClientHello after a HelloRetryRequest, and three javadoc blocks need moving back to their methods. Once those are in, the commits go into part 3 (#2441), since that's where the handshake and ACK code they change is introduced.

The stack is also rebased onto current main (ab16374d37), so the four PRs no longer conflict. The only overlap was 604c661, the DTLS handshake deadlock fix, which touches the same epoch-selection code as the series: its current-epoch branches sit alongside the series' plaintext-retransmit branch in all three receive paths, lastReceivedEpoch is now recorded for every delivered record on both the DTLS 1.2 and DTLS 1.3 paths, and the reliable handshake's ACK collection checks each message against getLastReceivedEpoch(). Upstream's lossy-transport DTLS tests pass on every part.

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