Support ECH with DTLS 1.3

I would be surprised if this were ever used by anyone, but we should
either make it work, or add code to turn the feature off when
configured. All the things that prevented it from working we just bugs,
so it was easier to just fix this and enable all the tests.

The main nuisance is how our in-memory representation reflects the old
DTLS 1.2 header, conflicting ECH's many uses of the transcript. (We
really should switch the in-memory representation.) Then some bits of
ClientHello processing needed to not lose track of the cookie.

(This test already covers HelloVerifyRequest interactions because we
run through HelloVerifyRequest in DTLS 1.2.)

Bug: 42290594
Change-Id: Ic09b78ddc0c5524ffc6c5b965eafab881eff0a0f
Reviewed-on: https://boringssl-review.googlesource.com/c/boringssl/+/73729
Reviewed-by: Nick Harper <nharper@chromium.org>
Commit-Queue: David Benjamin <davidben@google.com>
diff --git a/include/openssl/ssl.h b/include/openssl/ssl.h
index 9e9acee..ac492cc 100644
--- a/include/openssl/ssl.h
+++ b/include/openssl/ssl.h
@@ -4647,6 +4647,8 @@
   size_t random_len;
   const uint8_t *session_id;
   size_t session_id_len;
+  const uint8_t *dtls_cookie;
+  size_t dtls_cookie_len;
   const uint8_t *cipher_suites;
   size_t cipher_suites_len;
   const uint8_t *compression_methods;
diff --git a/ssl/encrypted_client_hello.cc b/ssl/encrypted_client_hello.cc
index abdb27d..672d3ed 100644
--- a/ssl/encrypted_client_hello.cc
+++ b/ssl/encrypted_client_hello.cc
@@ -65,8 +65,17 @@
       !CBB_add_bytes(out, client_hello->random, client_hello->random_len) ||
       !CBB_add_u8_length_prefixed(out, &cbb) ||
       !CBB_add_bytes(&cbb, client_hello->session_id,
-                     client_hello->session_id_len) ||
-      !CBB_add_u16_length_prefixed(out, &cbb) ||
+                     client_hello->session_id_len)) {
+    return false;
+  }
+  if (SSL_is_dtls(client_hello->ssl)) {
+    if (!CBB_add_u8_length_prefixed(out, &cbb) ||
+        !CBB_add_bytes(&cbb, client_hello->dtls_cookie,
+                       client_hello->dtls_cookie_len)) {
+      return false;
+    }
+  }
+  if (!CBB_add_u16_length_prefixed(out, &cbb) ||
       !CBB_add_bytes(&cbb, client_hello->cipher_suites,
                      client_hello->cipher_suites_len) ||
       !CBB_add_u8_length_prefixed(out, &cbb) ||
diff --git a/ssl/extensions.cc b/ssl/extensions.cc
index c0cb2d3..64e0200 100644
--- a/ssl/extensions.cc
+++ b/ssl/extensions.cc
@@ -243,12 +243,16 @@
   out->session_id = CBS_data(&session_id);
   out->session_id_len = CBS_len(&session_id);
 
-  // Skip past DTLS cookie
   if (SSL_is_dtls(out->ssl)) {
     CBS cookie;
     if (!CBS_get_u8_length_prefixed(cbs, &cookie)) {
       return false;
     }
+    out->dtls_cookie = CBS_data(&cookie);
+    out->dtls_cookie_len = CBS_len(&cookie);
+  } else {
+    out->dtls_cookie = nullptr;
+    out->dtls_cookie_len = 0;
   }
 
   CBS cipher_suites, compression_methods;
diff --git a/ssl/handshake.cc b/ssl/handshake.cc
index 0b5db42..366f818 100644
--- a/ssl/handshake.cc
+++ b/ssl/handshake.cc
@@ -174,7 +174,9 @@
     out_msg->is_v2_hello = false;
     out_msg->type = SSL3_MT_CLIENT_HELLO;
     out_msg->raw = CBS(ech_client_hello_buf);
-    out_msg->body = MakeConstSpan(ech_client_hello_buf).subspan(4);
+    size_t header_len =
+        SSL_is_dtls(ssl) ? DTLS1_HM_HEADER_LENGTH : SSL3_HM_HEADER_LENGTH;
+    out_msg->body = MakeConstSpan(ech_client_hello_buf).subspan(header_len);
   } else if (!ssl->method->get_message(ssl, out_msg)) {
     // The message has already been read, so this cannot fail.
     OPENSSL_PUT_ERROR(SSL, ERR_R_INTERNAL_ERROR);
diff --git a/ssl/test/runner/handshake_client.go b/ssl/test/runner/handshake_client.go
index f9dfb98..09f6d94 100644
--- a/ssl/test/runner/handshake_client.go
+++ b/ssl/test/runner/handshake_client.go
@@ -871,12 +871,16 @@
 
 	if c.config.Bugs.MinimalClientHelloOuter {
 		*hello = clientHelloMsg{
+			isDTLS:             c.isDTLS,
 			vers:               VersionTLS12,
 			random:             hello.random,
 			sessionID:          hello.sessionID,
 			cipherSuites:       []uint16{0x0a0a},
 			compressionMethods: hello.compressionMethods,
 		}
+		if c.isDTLS {
+			hello.vers = VersionDTLS12
+		}
 	}
 
 	if c.config.Bugs.TruncateClientECHEnc {
diff --git a/ssl/test/runner/handshake_messages.go b/ssl/test/runner/handshake_messages.go
index eadb360..9a41b72 100644
--- a/ssl/test/runner/handshake_messages.go
+++ b/ssl/test/runner/handshake_messages.go
@@ -1069,12 +1069,19 @@
 
 func decodeClientHelloInner(config *Config, encoded []byte, helloOuter *clientHelloMsg) (*clientHelloMsg, error) {
 	reader := cryptobyte.String(encoded)
-	var versAndRandom, sessionID, cipherSuites, compressionMethods []byte
+	var versAndRandom, sessionID, cookie, cipherSuites, compressionMethods []byte
 	var extensions cryptobyte.String
 	if !reader.ReadBytes(&versAndRandom, 2+32) ||
 		!readUint8LengthPrefixedBytes(&reader, &sessionID) ||
-		len(sessionID) != 0 || // Copied from |helloOuter|
-		!readUint16LengthPrefixedBytes(&reader, &cipherSuites) ||
+		len(sessionID) != 0 { // Copied from |helloOuter|
+		return nil, errors.New("tls: error parsing EncodedClientHelloInner")
+	}
+	if helloOuter.isDTLS {
+		if !readUint8LengthPrefixedBytes(&reader, &cookie) || len(cookie) != 0 {
+			return nil, errors.New("tls: error parsing EncodedClientHelloInner")
+		}
+	}
+	if !readUint16LengthPrefixedBytes(&reader, &cipherSuites) ||
 		!readUint8LengthPrefixedBytes(&reader, &compressionMethods) ||
 		!reader.ReadUint16LengthPrefixed(&extensions) {
 		return nil, errors.New("tls: error parsing EncodedClientHelloInner")
@@ -1093,6 +1100,9 @@
 	builder.AddUint24LengthPrefixed(func(body *cryptobyte.Builder) {
 		body.AddBytes(versAndRandom)
 		addUint8LengthPrefixedBytes(body, helloOuter.sessionID)
+		if helloOuter.isDTLS {
+			addUint8LengthPrefixedBytes(body, cookie)
+		}
 		addUint16LengthPrefixedBytes(body, cipherSuites)
 		addUint8LengthPrefixedBytes(body, compressionMethods)
 		body.AddUint16LengthPrefixed(func(newExtensions *cryptobyte.Builder) {
@@ -1171,7 +1181,7 @@
 		}
 	}
 
-	ret := new(clientHelloMsg)
+	ret := &clientHelloMsg{isDTLS: helloOuter.isDTLS}
 	if !ret.unmarshal(bytes) {
 		return nil, errors.New("tls: error parsing reconstructed ClientHello")
 	}
diff --git a/ssl/test/runner/prf.go b/ssl/test/runner/prf.go
index 5d56257..c497ae9 100644
--- a/ssl/test/runner/prf.go
+++ b/ssl/test/runner/prf.go
@@ -410,8 +410,8 @@
 	return hkdfExpandLabel(h.suite.hash(), h.secret, label, h.appendContextHashes(nil), h.hash.Size(), h.isDTLS)
 }
 
-// echConfirmation computes the ECH accept confirmation signal, as defined in
-// sections 7.2 and 7.2.1 of draft-ietf-tls-esni-13. The transcript hash is
+// echAcceptConfirmation computes the ECH accept confirmation signal, as defined
+// in sections 7.2 and 7.2.1 of draft-ietf-tls-esni-13. The transcript hash is
 // computed by concatenating |h| with |extraMessages|.
 func (h *finishedHash) echAcceptConfirmation(clientRandom, label, extraMessages []byte) []byte {
 	secret := hkdf.Extract(h.suite.hash().New, clientRandom, h.zeroSecret())
diff --git a/ssl/test/runner/runner.go b/ssl/test/runner/runner.go
index 41fa454..8f2fcfe 100644
--- a/ssl/test/runner/runner.go
+++ b/ssl/test/runner/runner.go
@@ -8993,10 +8993,6 @@
 				if ech && ver.version < VersionTLS13 {
 					continue
 				}
-				// TODO(crbug.com/42290594): This test is broken when run with DTLS 1.3 and ECH.
-				if protocol == dtls && ver.version >= VersionTLS13 && ech {
-					continue
-				}
 
 				test := testCase{
 					protocol:           protocol,
@@ -18642,7 +18638,7 @@
 		BasicConstraintsValid: true,
 	}, &ecdsaP256Key)
 
-	for _, protocol := range []protocol{tls, quic} {
+	for _, protocol := range []protocol{tls, quic, dtls} {
 		prefix := protocol.String() + "-"
 
 		// There are two ClientHellos, so many of our tests have
@@ -19004,9 +19000,16 @@
 write hs 15
 write hs 20
 read hs 20
+write ack
 write hs 4
 write hs 4
+read ack
+read ack
 `
+			if protocol != dtls {
+				expectMsgCallback = strings.ReplaceAll(expectMsgCallback, "write ack\n", "")
+				expectMsgCallback = strings.ReplaceAll(expectMsgCallback, "read ack\n", "")
+			}
 			testCases = append(testCases, testCase{
 				testType: serverTest,
 				protocol: protocol,
@@ -19355,51 +19358,55 @@
 		})
 
 		// Test early data works with ECH, in both accept and reject cases.
-		testCases = append(testCases, testCase{
-			testType: serverTest,
-			protocol: protocol,
-			name:     prefix + "ECH-Server-EarlyData",
-			config: Config{
-				ServerName:      "secret.example",
-				ClientECHConfig: echConfig.ECHConfig,
-			},
-			resumeSession: true,
-			earlyData:     true,
-			flags: []string{
-				"-ech-server-config", base64FlagValue(echConfig.ECHConfig.Raw),
-				"-ech-server-key", base64FlagValue(echConfig.Key),
-				"-ech-is-retry-config", "1",
-				"-expect-ech-accept",
-			},
-			expectations: connectionExpectations{
-				echAccepted: true,
-			},
-		})
-		testCases = append(testCases, testCase{
-			testType: serverTest,
-			protocol: protocol,
-			name:     prefix + "ECH-Server-EarlyDataRejected",
-			config: Config{
-				ServerName:      "secret.example",
-				ClientECHConfig: echConfig.ECHConfig,
-				Bugs: ProtocolBugs{
-					// Cause the server to reject 0-RTT with a bad ticket age.
-					SendTicketAge: 1 * time.Hour,
+		// TODO(crbug.com/381113363): Enable these tests for DTLS once we
+		// support early data in DTLS 1.3.
+		if protocol != dtls {
+			testCases = append(testCases, testCase{
+				testType: serverTest,
+				protocol: protocol,
+				name:     prefix + "ECH-Server-EarlyData",
+				config: Config{
+					ServerName:      "secret.example",
+					ClientECHConfig: echConfig.ECHConfig,
 				},
-			},
-			resumeSession:           true,
-			earlyData:               true,
-			expectEarlyDataRejected: true,
-			flags: []string{
-				"-ech-server-config", base64FlagValue(echConfig.ECHConfig.Raw),
-				"-ech-server-key", base64FlagValue(echConfig.Key),
-				"-ech-is-retry-config", "1",
-				"-expect-ech-accept",
-			},
-			expectations: connectionExpectations{
-				echAccepted: true,
-			},
-		})
+				resumeSession: true,
+				earlyData:     true,
+				flags: []string{
+					"-ech-server-config", base64FlagValue(echConfig.ECHConfig.Raw),
+					"-ech-server-key", base64FlagValue(echConfig.Key),
+					"-ech-is-retry-config", "1",
+					"-expect-ech-accept",
+				},
+				expectations: connectionExpectations{
+					echAccepted: true,
+				},
+			})
+			testCases = append(testCases, testCase{
+				testType: serverTest,
+				protocol: protocol,
+				name:     prefix + "ECH-Server-EarlyDataRejected",
+				config: Config{
+					ServerName:      "secret.example",
+					ClientECHConfig: echConfig.ECHConfig,
+					Bugs: ProtocolBugs{
+						// Cause the server to reject 0-RTT with a bad ticket age.
+						SendTicketAge: 1 * time.Hour,
+					},
+				},
+				resumeSession:           true,
+				earlyData:               true,
+				expectEarlyDataRejected: true,
+				flags: []string{
+					"-ech-server-config", base64FlagValue(echConfig.ECHConfig.Raw),
+					"-ech-server-key", base64FlagValue(echConfig.Key),
+					"-ech-is-retry-config", "1",
+					"-expect-ech-accept",
+				},
+				expectations: connectionExpectations{
+					echAccepted: true,
+				},
+			})
+		}
 
 		// Test servers with ECH disabled correctly ignore the extension and
 		// handshake with the ClientHelloOuter.
@@ -19831,52 +19838,56 @@
 		})
 
 		// Test the client can negotiate ECH with early data.
-		testCases = append(testCases, testCase{
-			testType: clientTest,
-			protocol: protocol,
-			name:     prefix + "ECH-Client-EarlyData",
-			config: Config{
-				MinVersion:       VersionTLS13,
-				MaxVersion:       VersionTLS13,
-				ServerECHConfigs: []ServerECHConfig{echConfig},
-				Bugs: ProtocolBugs{
-					ExpectServerName: "secret.example",
+		// TODO(crbug.com/381113363): Enable these tests for DTLS once we
+		// support early data in DTLS 1.3.
+		if protocol != dtls {
+			testCases = append(testCases, testCase{
+				testType: clientTest,
+				protocol: protocol,
+				name:     prefix + "ECH-Client-EarlyData",
+				config: Config{
+					MinVersion:       VersionTLS13,
+					MaxVersion:       VersionTLS13,
+					ServerECHConfigs: []ServerECHConfig{echConfig},
+					Bugs: ProtocolBugs{
+						ExpectServerName: "secret.example",
+					},
+					Credential: &echSecretCertificate,
 				},
-				Credential: &echSecretCertificate,
-			},
-			flags: []string{
-				"-ech-config-list", base64FlagValue(CreateECHConfigList(echConfig.ECHConfig.Raw)),
-				"-host-name", "secret.example",
-				"-expect-ech-accept",
-			},
-			resumeSession: true,
-			earlyData:     true,
-			expectations:  connectionExpectations{echAccepted: true},
-		})
-		testCases = append(testCases, testCase{
-			testType: clientTest,
-			protocol: protocol,
-			name:     prefix + "ECH-Client-EarlyDataRejected",
-			config: Config{
-				MinVersion:       VersionTLS13,
-				MaxVersion:       VersionTLS13,
-				ServerECHConfigs: []ServerECHConfig{echConfig},
-				Bugs: ProtocolBugs{
-					ExpectServerName:      "secret.example",
-					AlwaysRejectEarlyData: true,
+				flags: []string{
+					"-ech-config-list", base64FlagValue(CreateECHConfigList(echConfig.ECHConfig.Raw)),
+					"-host-name", "secret.example",
+					"-expect-ech-accept",
 				},
-				Credential: &echSecretCertificate,
-			},
-			flags: []string{
-				"-ech-config-list", base64FlagValue(CreateECHConfigList(echConfig.ECHConfig.Raw)),
-				"-host-name", "secret.example",
-				"-expect-ech-accept",
-			},
-			resumeSession:           true,
-			earlyData:               true,
-			expectEarlyDataRejected: true,
-			expectations:            connectionExpectations{echAccepted: true},
-		})
+				resumeSession: true,
+				earlyData:     true,
+				expectations:  connectionExpectations{echAccepted: true},
+			})
+			testCases = append(testCases, testCase{
+				testType: clientTest,
+				protocol: protocol,
+				name:     prefix + "ECH-Client-EarlyDataRejected",
+				config: Config{
+					MinVersion:       VersionTLS13,
+					MaxVersion:       VersionTLS13,
+					ServerECHConfigs: []ServerECHConfig{echConfig},
+					Bugs: ProtocolBugs{
+						ExpectServerName:      "secret.example",
+						AlwaysRejectEarlyData: true,
+					},
+					Credential: &echSecretCertificate,
+				},
+				flags: []string{
+					"-ech-config-list", base64FlagValue(CreateECHConfigList(echConfig.ECHConfig.Raw)),
+					"-host-name", "secret.example",
+					"-expect-ech-accept",
+				},
+				resumeSession:           true,
+				earlyData:               true,
+				expectEarlyDataRejected: true,
+				expectations:            connectionExpectations{echAccepted: true},
+			})
+		}
 
 		if protocol != quic {
 			// Test that an ECH client does not offer a TLS 1.2 session.
@@ -20181,6 +20192,10 @@
 
 		// If the ClientHelloOuter disables TLS 1.3, e.g. in QUIC, the client
 		// should also compress supported_versions.
+		tls13Vers := VersionTLS13
+		if protocol == dtls {
+			tls13Vers = VersionDTLS13
+		}
 		testCases = append(testCases, testCase{
 			testType: clientTest,
 			protocol: protocol,
@@ -20198,7 +20213,7 @@
 				"-ech-config-list", base64FlagValue(CreateECHConfigList(echConfig.ECHConfig.Raw)),
 				"-host-name", "secret.example",
 				"-expect-ech-accept",
-				"-min-version", strconv.Itoa(int(VersionTLS13)),
+				"-min-version", strconv.Itoa(int(tls13Vers)),
 			},
 			expectations: connectionExpectations{echAccepted: true},
 		})
@@ -20309,7 +20324,8 @@
 
 			// Test that the client disables False Start when ECH is rejected.
 			testCases = append(testCases, testCase{
-				name: prefix + "ECH-Client-Reject-TLS12-NoFalseStart",
+				protocol: protocol,
+				name:     prefix + "ECH-Client-Reject-TLS12-NoFalseStart",
 				config: Config{
 					MaxVersion:   VersionTLS12,
 					CipherSuites: []uint16{TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256},
@@ -20402,7 +20418,13 @@
 			expectations:       connectionExpectations{echAccepted: true},
 			resumeExpectations: &connectionExpectations{echAccepted: false},
 		})
-		if protocol != quic {
+		if protocol == tls {
+			// This is only syntactically possible with TLS. In DTLS, we don't
+			// have middlebox compatibility mode, so the session ID will only
+			// filled in if we are offering a DTLS 1.2 session. But a DTLS 1.2
+			// would never be offered in ClientHelloInner. Without a session ID,
+			// the server syntactically cannot express a resumption at DTLS 1.2.
+			// In QUIC, the above is true, and 1.2 does not exist anyway.
 			testCases = append(testCases, testCase{
 				testType: clientTest,
 				protocol: protocol,
@@ -20485,7 +20507,9 @@
 			expectedLocalError:      "remote error: ECH required",
 			expectedError:           ":ECH_REJECTED:",
 		})
-		if protocol != quic {
+		// TODO(crbug.com/381113363): Enable this test for DTLS once we
+		// support early data in DTLS 1.3.
+		if protocol != quic && protocol != dtls {
 			testCases = append(testCases, testCase{
 				testType: clientTest,
 				protocol: protocol,
@@ -20637,56 +20661,37 @@
 			}
 		}
 
-		// Test that ECH and Channel ID can be used together.
-		testCases = append(testCases, testCase{
-			testType: clientTest,
-			protocol: protocol,
-			name:     prefix + "ECH-Client-ChannelID",
-			config: Config{
-				ServerECHConfigs: []ServerECHConfig{echConfig},
-				RequestChannelID: true,
-			},
-			flags: []string{
-				"-send-channel-id", channelIDKeyPath,
-				"-ech-config-list", base64FlagValue(CreateECHConfigList(echConfig.ECHConfig.Raw)),
-				"-expect-ech-accept",
-			},
-			resumeSession: true,
-			expectations: connectionExpectations{
-				channelID:   true,
-				echAccepted: true,
-			},
-		})
-
-		// Handshakes where ECH is rejected do not offer or accept Channel ID.
-		testCases = append(testCases, testCase{
-			testType: clientTest,
-			protocol: protocol,
-			name:     prefix + "ECH-Client-Reject-NoChannelID-TLS13",
-			config: Config{
-				MinVersion: VersionTLS13,
-				MaxVersion: VersionTLS13,
-				Bugs: ProtocolBugs{
-					AlwaysNegotiateChannelID: true,
-				},
-				Credential: &echPublicCertificate,
-			},
-			flags: []string{
-				"-send-channel-id", channelIDKeyPath,
-				"-ech-config-list", base64FlagValue(CreateECHConfigList(echConfig.ECHConfig.Raw)),
-			},
-			shouldFail:         true,
-			expectedLocalError: "remote error: unsupported extension",
-			expectedError:      ":UNEXPECTED_EXTENSION:",
-		})
-		if protocol != quic {
+		// Test that ECH and Channel ID can be used together. Channel ID does
+		// not exist in DTLS.
+		if protocol != dtls {
 			testCases = append(testCases, testCase{
 				testType: clientTest,
 				protocol: protocol,
-				name:     prefix + "ECH-Client-Reject-NoChannelID-TLS12",
+				name:     prefix + "ECH-Client-ChannelID",
 				config: Config{
-					MinVersion: VersionTLS12,
-					MaxVersion: VersionTLS12,
+					ServerECHConfigs: []ServerECHConfig{echConfig},
+					RequestChannelID: true,
+				},
+				flags: []string{
+					"-send-channel-id", channelIDKeyPath,
+					"-ech-config-list", base64FlagValue(CreateECHConfigList(echConfig.ECHConfig.Raw)),
+					"-expect-ech-accept",
+				},
+				resumeSession: true,
+				expectations: connectionExpectations{
+					channelID:   true,
+					echAccepted: true,
+				},
+			})
+
+			// Handshakes where ECH is rejected do not offer or accept Channel ID.
+			testCases = append(testCases, testCase{
+				testType: clientTest,
+				protocol: protocol,
+				name:     prefix + "ECH-Client-Reject-NoChannelID-TLS13",
+				config: Config{
+					MinVersion: VersionTLS13,
+					MaxVersion: VersionTLS13,
 					Bugs: ProtocolBugs{
 						AlwaysNegotiateChannelID: true,
 					},
@@ -20700,6 +20705,28 @@
 				expectedLocalError: "remote error: unsupported extension",
 				expectedError:      ":UNEXPECTED_EXTENSION:",
 			})
+			if protocol != quic {
+				testCases = append(testCases, testCase{
+					testType: clientTest,
+					protocol: protocol,
+					name:     prefix + "ECH-Client-Reject-NoChannelID-TLS12",
+					config: Config{
+						MinVersion: VersionTLS12,
+						MaxVersion: VersionTLS12,
+						Bugs: ProtocolBugs{
+							AlwaysNegotiateChannelID: true,
+						},
+						Credential: &echPublicCertificate,
+					},
+					flags: []string{
+						"-send-channel-id", channelIDKeyPath,
+						"-ech-config-list", base64FlagValue(CreateECHConfigList(echConfig.ECHConfig.Raw)),
+					},
+					shouldFail:         true,
+					expectedLocalError: "remote error: unsupported extension",
+					expectedError:      ":UNEXPECTED_EXTENSION:",
+				})
+			}
 		}
 
 		// Test that ECH correctly overrides the host name for certificate
@@ -20796,44 +20823,48 @@
 			resumeSession: true,
 			expectations:  connectionExpectations{echAccepted: true},
 		})
-		testCases = append(testCases, testCase{
-			testType: clientTest,
-			protocol: protocol,
-			name:     prefix + "ECH-Client-Reject-EarlyDataRejected-OverrideNameOnRetry",
-			config: Config{
-				ServerECHConfigs: []ServerECHConfig{echConfig},
-				Credential:       &echPublicCertificate,
-			},
-			resumeConfig: &Config{
-				Credential: &echPublicCertificate,
-			},
-			flags: []string{
-				"-verify-peer",
-				"-use-custom-verify-callback",
-				"-ech-config-list", base64FlagValue(CreateECHConfigList(echConfig.ECHConfig.Raw)),
-				// Although the resumption connection does not accept ECH, the
-				// API will report ECH was accepted at the 0-RTT point.
-				"-expect-ech-accept",
-				// The resumption connection verifies certificates twice. First,
-				// if reverification is enabled, we verify the 0-RTT certificate
-				// as if ECH as accepted. There should be no name override.
-				// Next, on the post-0-RTT-rejection retry, we verify the new
-				// server certificate. This picks up the ECH reject, so it
-				// should use public.example.
-				"-reverify-on-resume",
-				"-on-resume-expect-no-ech-name-override",
-				"-on-retry-expect-ech-name-override", "public.example",
-			},
-			resumeSession:           true,
-			expectResumeRejected:    true,
-			earlyData:               true,
-			expectEarlyDataRejected: true,
-			expectations:            connectionExpectations{echAccepted: true},
-			resumeExpectations:      &connectionExpectations{echAccepted: false},
-			shouldFail:              true,
-			expectedError:           ":ECH_REJECTED:",
-			expectedLocalError:      "remote error: ECH required",
-		})
+		// TODO(crbug.com/381113363): Enable this test for DTLS once we
+		// support early data in DTLS 1.3.
+		if protocol != dtls {
+			testCases = append(testCases, testCase{
+				testType: clientTest,
+				protocol: protocol,
+				name:     prefix + "ECH-Client-Reject-EarlyDataRejected-OverrideNameOnRetry",
+				config: Config{
+					ServerECHConfigs: []ServerECHConfig{echConfig},
+					Credential:       &echPublicCertificate,
+				},
+				resumeConfig: &Config{
+					Credential: &echPublicCertificate,
+				},
+				flags: []string{
+					"-verify-peer",
+					"-use-custom-verify-callback",
+					"-ech-config-list", base64FlagValue(CreateECHConfigList(echConfig.ECHConfig.Raw)),
+					// Although the resumption connection does not accept ECH, the
+					// API will report ECH was accepted at the 0-RTT point.
+					"-expect-ech-accept",
+					// The resumption connection verifies certificates twice. First,
+					// if reverification is enabled, we verify the 0-RTT certificate
+					// as if ECH as accepted. There should be no name override.
+					// Next, on the post-0-RTT-rejection retry, we verify the new
+					// server certificate. This picks up the ECH reject, so it
+					// should use public.example.
+					"-reverify-on-resume",
+					"-on-resume-expect-no-ech-name-override",
+					"-on-retry-expect-ech-name-override", "public.example",
+				},
+				resumeSession:           true,
+				expectResumeRejected:    true,
+				earlyData:               true,
+				expectEarlyDataRejected: true,
+				expectations:            connectionExpectations{echAccepted: true},
+				resumeExpectations:      &connectionExpectations{echAccepted: false},
+				shouldFail:              true,
+				expectedError:           ":ECH_REJECTED:",
+				expectedLocalError:      "remote error: ECH required",
+			})
+		}
 
 		// Test that the client checks both HelloRetryRequest and ServerHello
 		// for a confirmation signal.
@@ -20873,9 +20904,13 @@
 read hs 15
 read hs 20
 write hs 20
+read ack
 read hs 4
 read hs 4
 `
+		if protocol != dtls {
+			finishHandshake = strings.ReplaceAll(finishHandshake, "read ack\n", "")
+		}
 		testCases = append(testCases, testCase{
 			testType: clientTest,
 			protocol: protocol,
diff --git a/ssl/tls13_enc.cc b/ssl/tls13_enc.cc
index 5ae270a..e6a8372 100644
--- a/ssl/tls13_enc.cc
+++ b/ssl/tls13_enc.cc
@@ -672,12 +672,19 @@
     return false;
   }
 
-  auto before_zeros = msg.subspan(0, offset);
+  // We represent DTLS messages with the longer DTLS 1.2 header, but DTLS 1.3
+  // removes the extra fields from the transcript.
+  auto header = msg.subspan(0, SSL3_HM_HEADER_LENGTH);
+  size_t full_header_len =
+      SSL_is_dtls(hs->ssl) ? DTLS1_HM_HEADER_LENGTH : SSL3_HM_HEADER_LENGTH;
+  auto before_zeros = msg.subspan(full_header_len, offset - full_header_len);
   auto after_zeros = msg.subspan(offset + ECH_CONFIRMATION_SIGNAL_LEN);
+
   uint8_t context[EVP_MAX_MD_SIZE];
   unsigned context_len;
   ScopedEVP_MD_CTX ctx;
   if (!transcript.CopyToHashContext(ctx.get(), transcript.Digest()) ||
+      !EVP_DigestUpdate(ctx.get(), header.data(), header.size()) ||
       !EVP_DigestUpdate(ctx.get(), before_zeros.data(), before_zeros.size()) ||
       !EVP_DigestUpdate(ctx.get(), kZeros, ECH_CONFIRMATION_SIGNAL_LEN) ||
       !EVP_DigestUpdate(ctx.get(), after_zeros.data(), after_zeros.size()) ||