Add ECH server (draft-ietf-tls-esni-09).

This CL adds an initial implementation of the ECH server, with pieces of
the client in BoGo as necessary for testing. In particular, the server
supports ClientHelloInner compression with ech_outer_extensions. When
ECH decryption fails, it can send retry_configs back to the client.

This server passes the "ech-accept" and "ech-reject" test cases in
tls-interop-runner[0] when tested against both the cloudflare-go and nss
clients. For reproducibility, I started with the main branch at commit
707604c262d8bcf3e944ed1d5a675077304732ce and updated the endpoint's
script to pass the server's ECHConfig and private key to the boringssl
tool.

Follow-up CLs will update HPKE to the latest draft and catch us up to
draft-10.

[0]: https://github.com/xvzcf/tls-interop-runner

Bug: 275
Change-Id: I49be35af46d1fd5dd9c62252f07d0bae179381ab
Reviewed-on: https://boringssl-review.googlesource.com/c/boringssl/+/45285
Reviewed-by: David Benjamin <davidben@google.com>
Commit-Queue: David Benjamin <davidben@google.com>
diff --git a/crypto/err/ssl.errordata b/crypto/err/ssl.errordata
index 44c3904..da85b1f 100644
--- a/crypto/err/ssl.errordata
+++ b/crypto/err/ssl.errordata
@@ -58,6 +58,9 @@
 SSL,296,DUPLICATE_SIGNATURE_ALGORITHM
 SSL,283,EARLY_DATA_NOT_IN_USE
 SSL,144,ECC_CERT_NOT_FOR_SIGNING
+SSL,310,ECH_SERVER_CONFIG_AND_PRIVATE_KEY_MISMATCH
+SSL,311,ECH_SERVER_CONFIG_UNSUPPORTED_EXTENSION
+SSL,313,ECH_SERVER_WOULD_HAVE_NO_RETRY_CONFIGS
 SSL,282,EMPTY_HELLO_RETRY_REQUEST
 SSL,145,EMS_STATE_INCONSISTENT
 SSL,146,ENCRYPTED_LENGTH_TOO_LONG
@@ -76,6 +79,7 @@
 SSL,157,INAPPROPRIATE_FALLBACK
 SSL,303,INCONSISTENT_CLIENT_HELLO
 SSL,259,INVALID_ALPN_PROTOCOL
+SSL,314,INVALID_CLIENT_HELLO_INNER
 SSL,158,INVALID_COMMAND
 SSL,256,INVALID_COMPRESSION_LIST
 SSL,301,INVALID_DELEGATED_CREDENTIAL
@@ -226,6 +230,7 @@
 SSL,236,UNSAFE_LEGACY_RENEGOTIATION_DISABLED
 SSL,237,UNSUPPORTED_CIPHER
 SSL,238,UNSUPPORTED_COMPRESSION_ALGORITHM
+SSL,312,UNSUPPORTED_ECH_SERVER_CONFIG
 SSL,239,UNSUPPORTED_ELLIPTIC_CURVE
 SSL,240,UNSUPPORTED_PROTOCOL
 SSL,252,UNSUPPORTED_PROTOCOL_FOR_CUSTOM_KEY
diff --git a/crypto/hpke/hpke.c b/crypto/hpke/hpke.c
index fa44d8e..9f9cf7b 100644
--- a/crypto/hpke/hpke.c
+++ b/crypto/hpke/hpke.c
@@ -32,9 +32,6 @@
 
 #define KEM_CONTEXT_LEN (2 * X25519_PUBLIC_VALUE_LEN)
 
-// HPKE KEM scheme IDs.
-#define HPKE_DHKEM_X25519_HKDF_SHA256 0x0020
-
 // This is strlen("HPKE") + 3 * sizeof(uint16_t).
 #define HPKE_SUITE_ID_LEN 10
 
@@ -51,8 +48,8 @@
 // that the suite_id used outside of the KEM also includes the kdf_id and
 // aead_id.
 static const uint8_t kX25519SuiteID[] = {
-    'K', 'E', 'M', HPKE_DHKEM_X25519_HKDF_SHA256 >> 8,
-    HPKE_DHKEM_X25519_HKDF_SHA256 & 0x00ff};
+    'K', 'E', 'M', EVP_HPKE_DHKEM_X25519_HKDF_SHA256 >> 8,
+    EVP_HPKE_DHKEM_X25519_HKDF_SHA256 & 0x00ff};
 
 // The suite_id for non-KEM pieces of HPKE is defined as concat("HPKE",
 // I2OSP(kem_id, 2), I2OSP(kdf_id, 2), I2OSP(aead_id, 2)).
@@ -61,7 +58,7 @@
   CBB cbb;
   int ret = CBB_init_fixed(&cbb, out, HPKE_SUITE_ID_LEN) &&
             add_label_string(&cbb, "HPKE") &&
-            CBB_add_u16(&cbb, HPKE_DHKEM_X25519_HKDF_SHA256) &&
+            CBB_add_u16(&cbb, EVP_HPKE_DHKEM_X25519_HKDF_SHA256) &&
             CBB_add_u16(&cbb, kdf_id) &&
             CBB_add_u16(&cbb, aead_id);
   CBB_cleanup(&cbb);
@@ -126,6 +123,14 @@
   return 1;
 }
 
+uint16_t EVP_HPKE_CTX_get_aead_id(const EVP_HPKE_CTX *hpke) {
+  return hpke->aead_id;
+}
+
+uint16_t EVP_HPKE_CTX_get_kdf_id(const EVP_HPKE_CTX *hpke) {
+  return hpke->kdf_id;
+}
+
 const EVP_AEAD *EVP_HPKE_get_aead(uint16_t aead_id) {
   switch (aead_id) {
     case EVP_HPKE_AEAD_AES_128_GCM:
diff --git a/crypto/hpke/internal.h b/crypto/hpke/internal.h
index 5bd6508..bf705b9 100644
--- a/crypto/hpke/internal.h
+++ b/crypto/hpke/internal.h
@@ -33,6 +33,9 @@
 //
 // See https://tools.ietf.org/html/draft-irtf-cfrg-hpke-07.
 
+// EVP_HPKE_DHKEM_* are KEM identifiers.
+#define EVP_HPKE_DHKEM_X25519_HKDF_SHA256 0x0020
+
 // EVP_HPKE_AEAD_* are AEAD identifiers.
 #define EVP_HPKE_AEAD_AES_128_GCM 0x0001
 #define EVP_HPKE_AEAD_AES_256_GCM 0x0002
@@ -224,6 +227,16 @@
 // set up as a sender.
 OPENSSL_EXPORT size_t EVP_HPKE_CTX_max_overhead(const EVP_HPKE_CTX *hpke);
 
+// EVP_HPKE_CTX_get_aead_id returns |hpke|'s configured AEAD. The returned value
+// is one of the |EVP_HPKE_AEAD_*| constants, or zero if the context has not
+// been set up.
+OPENSSL_EXPORT uint16_t EVP_HPKE_CTX_get_aead_id(const EVP_HPKE_CTX *hpke);
+
+// EVP_HPKE_CTX_get_aead_id returns |hpke|'s configured KDF. The returned value
+// is one of the |EVP_HPKE_HKDF_*| constants, or zero if the context has not
+// been set up.
+OPENSSL_EXPORT uint16_t EVP_HPKE_CTX_get_kdf_id(const EVP_HPKE_CTX *hpke);
+
 // EVP_HPKE_get_aead returns the AEAD corresponding to |aead_id|, or NULL if
 // |aead_id| is not a known AEAD identifier.
 OPENSSL_EXPORT const EVP_AEAD *EVP_HPKE_get_aead(uint16_t aead_id);
diff --git a/fuzz/CMakeLists.txt b/fuzz/CMakeLists.txt
index 98a959a..62652cb 100644
--- a/fuzz/CMakeLists.txt
+++ b/fuzz/CMakeLists.txt
@@ -29,3 +29,4 @@
 fuzzer(dtls_client ssl)
 fuzzer(ssl_ctx_api ssl)
 fuzzer(session ssl)
+fuzzer(decode_client_hello_inner ssl)
diff --git a/fuzz/decode_client_hello_inner.cc b/fuzz/decode_client_hello_inner.cc
new file mode 100644
index 0000000..db090c5
--- /dev/null
+++ b/fuzz/decode_client_hello_inner.cc
@@ -0,0 +1,52 @@
+/* Copyright (c) 2021, Google Inc.
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+ * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
+ * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
+ * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */
+
+#include <openssl/bytestring.h>
+#include <openssl/ssl.h>
+#include <openssl/span.h>
+
+#include "../ssl/internal.h"
+
+
+extern "C" int LLVMFuzzerTestOneInput(const uint8_t *buf, size_t len) {
+  static bssl::UniquePtr<SSL_CTX> ctx(SSL_CTX_new(TLS_method()));
+  static bssl::UniquePtr<SSL> ssl(SSL_new(ctx.get()));
+
+  CBS reader(bssl::MakeConstSpan(buf, len));
+  CBS encoded_client_hello_inner_cbs;
+
+  if (!CBS_get_u24_length_prefixed(&reader, &encoded_client_hello_inner_cbs)) {
+    return 0;
+  }
+
+  bssl::Array<uint8_t> encoded_client_hello_inner;
+  if (!encoded_client_hello_inner.CopyFrom(encoded_client_hello_inner_cbs)) {
+    return 0;
+  }
+
+  // Use the remaining bytes in |reader| as the ClientHelloOuter.
+  SSL_CLIENT_HELLO client_hello_outer;
+  if (!bssl::ssl_client_hello_init(ssl.get(), &client_hello_outer, reader)) {
+    return 0;
+  }
+
+  // Recover the ClientHelloInner from the EncodedClientHelloInner and
+  // ClientHelloOuter.
+  uint8_t alert_unused;
+  bssl::Array<uint8_t> client_hello_inner;
+  bssl::ssl_decode_client_hello_inner(
+      ssl.get(), &alert_unused, &client_hello_inner, encoded_client_hello_inner,
+      &client_hello_outer);
+  return 0;
+}
diff --git a/include/openssl/base.h b/include/openssl/base.h
index 61a8886..e5fe146 100644
--- a/include/openssl/base.h
+++ b/include/openssl/base.h
@@ -431,6 +431,7 @@
 typedef struct srtp_protection_profile_st SRTP_PROTECTION_PROFILE;
 typedef struct ssl_cipher_st SSL_CIPHER;
 typedef struct ssl_ctx_st SSL_CTX;
+typedef struct ssl_ech_server_config_list_st SSL_ECH_SERVER_CONFIG_LIST;
 typedef struct ssl_method_st SSL_METHOD;
 typedef struct ssl_private_key_method_st SSL_PRIVATE_KEY_METHOD;
 typedef struct ssl_quic_method_st SSL_QUIC_METHOD;
diff --git a/include/openssl/ssl.h b/include/openssl/ssl.h
index f420fda..16b64c1 100644
--- a/include/openssl/ssl.h
+++ b/include/openssl/ssl.h
@@ -3575,7 +3575,7 @@
     enum ssl_early_data_reason_t reason);
 
 
-// Encrypted Client Hello.
+// Encrypted ClientHello.
 //
 // ECH is a mechanism for encrypting the entire ClientHello message in TLS 1.3.
 // This can prevent observers from seeing cleartext information about the
@@ -3589,6 +3589,72 @@
 // as part of this connection.
 OPENSSL_EXPORT void SSL_set_enable_ech_grease(SSL *ssl, int enable);
 
+// SSL_ECH_SERVER_CONFIG_LIST_new returns a newly-allocated
+// |SSL_ECH_SERVER_CONFIG_LIST| or NULL on error.
+OPENSSL_EXPORT SSL_ECH_SERVER_CONFIG_LIST *SSL_ECH_SERVER_CONFIG_LIST_new(void);
+
+// SSL_ECH_SERVER_CONFIG_LIST_up_ref increments the reference count of |list|.
+OPENSSL_EXPORT void SSL_ECH_SERVER_CONFIG_LIST_up_ref(
+    SSL_ECH_SERVER_CONFIG_LIST *list);
+
+// SSL_ECH_SERVER_CONFIG_LIST_free releases memory associated with |list|.
+OPENSSL_EXPORT void SSL_ECH_SERVER_CONFIG_LIST_free(
+    SSL_ECH_SERVER_CONFIG_LIST *list);
+
+// SSL_ECH_SERVER_CONFIG_LIST_add appends an ECHConfig in |ech_config| and its
+// corresponding private key in |private_key| to |list|. When |is_retry_config|
+// is non-zero, this config will be returned to the client on configuration
+// mismatch. It returns one on success and zero on error. See also
+// |SSL_CTX_set1_ech_server_config_list|.
+//
+// This function should be called successively to register each ECHConfig in
+// decreasing order of preference. This configuration must be completed before
+// setting |list| on an |SSL_CTX| with |SSL_CTX_set1_ech_server_config_list|.
+// After that point, |list| is immutable; no more ECHConfig values may be added.
+OPENSSL_EXPORT int SSL_ECH_SERVER_CONFIG_LIST_add(
+    SSL_ECH_SERVER_CONFIG_LIST *list, int is_retry_config,
+    const uint8_t *ech_config, size_t ech_config_len,
+    const uint8_t *private_key, size_t private_key_len);
+
+// SSL_CTX_set1_ech_server_config_list atomically sets the refcounted |list|
+// onto |ctx|, releasing the old list. |SSL| objects associated with |ctx|, as
+// servers, will use |list| to decrypt incoming encrypted ClientHello messages.
+// It returns one on success, and zero on failure.
+//
+// If |list| does not contain any retry configs, this function will fail. Retry
+// configs are marked as such when they are added to |list| with
+// |SSL_ECH_SERVER_CONFIG_LIST_add|.
+//
+// Once |list| has been passed to this function, it is immutable. Unlike most
+// |SSL_CTX| configuration functions, this function may be called even if |ctx|
+// already has associated connections on multiple threads. This may be used to
+// rotate keys in a long-lived server process.
+//
+// The configured ECHConfig values should also be advertised out-of-band via DNS
+// (see draft-ietf-dnsop-svcb-https). Before advertising an ECHConfig in DNS,
+// deployments should ensure all instances of the service are configured with
+// the ECHConfig and corresponding private key.
+//
+// Only the most recent fully-deployed ECHConfigs should be advertised in DNS.
+// |list| may contain a newer set if those ECHConfigs are mid-deployment. It
+// should also contain older sets, until the DNS change has rolled out and the
+// old records have expired from caches.
+//
+// If there is a mismatch, |SSL| objects associated with |ctx| will complete the
+// handshake using the cleartext ClientHello and send updated ECHConfig values
+// to the client. The client will then retry to recover, but with a latency
+// penalty. This recovery flow depends on the public name in the ECHConfig.
+// Before advertising an ECHConfig in DNS, deployments must ensure all instances
+// of the service can present a valid certificate for the public name.
+//
+// BoringSSL negotiates ECH before certificate selection callbacks are called,
+// including |SSL_CTX_set_select_certificate_cb|. If ECH is negotiated, the
+// reported |SSL_CLIENT_HELLO| structure and |SSL_get_servername| function will
+// transparently reflect the inner ClientHello. Callers should select parameters
+// based on these values to correctly handle ECH as well as the recovery flow.
+OPENSSL_EXPORT int SSL_CTX_set1_ech_server_config_list(
+    SSL_CTX *ctx, SSL_ECH_SERVER_CONFIG_LIST *list);
+
 
 // Alerts.
 //
@@ -4960,6 +5026,10 @@
 BORINGSSL_MAKE_DELETER(SSL, SSL_free)
 BORINGSSL_MAKE_DELETER(SSL_CTX, SSL_CTX_free)
 BORINGSSL_MAKE_UP_REF(SSL_CTX, SSL_CTX_up_ref)
+BORINGSSL_MAKE_DELETER(SSL_ECH_SERVER_CONFIG_LIST,
+                       SSL_ECH_SERVER_CONFIG_LIST_free)
+BORINGSSL_MAKE_UP_REF(SSL_ECH_SERVER_CONFIG_LIST,
+                      SSL_ECH_SERVER_CONFIG_LIST_up_ref)
 BORINGSSL_MAKE_DELETER(SSL_SESSION, SSL_SESSION_free)
 BORINGSSL_MAKE_UP_REF(SSL_SESSION, SSL_SESSION_up_ref)
 
@@ -5293,6 +5363,11 @@
 #define SSL_R_NO_APPLICATION_PROTOCOL 307
 #define SSL_R_NEGOTIATED_ALPS_WITHOUT_ALPN 308
 #define SSL_R_ALPS_MISMATCH_ON_EARLY_DATA 309
+#define SSL_R_ECH_SERVER_CONFIG_AND_PRIVATE_KEY_MISMATCH 310
+#define SSL_R_ECH_SERVER_CONFIG_UNSUPPORTED_EXTENSION 311
+#define SSL_R_UNSUPPORTED_ECH_SERVER_CONFIG 312
+#define SSL_R_ECH_SERVER_WOULD_HAVE_NO_RETRY_CONFIGS 313
+#define SSL_R_INVALID_CLIENT_HELLO_INNER 314
 #define SSL_R_SSLV3_ALERT_CLOSE_NOTIFY 1000
 #define SSL_R_SSLV3_ALERT_UNEXPECTED_MESSAGE 1010
 #define SSL_R_SSLV3_ALERT_BAD_RECORD_MAC 1020
diff --git a/include/openssl/tls1.h b/include/openssl/tls1.h
index da79a08..f1242b3 100644
--- a/include/openssl/tls1.h
+++ b/include/openssl/tls1.h
@@ -257,6 +257,7 @@
 // extension number.
 #define TLSEXT_TYPE_encrypted_client_hello 0xfe09
 #define TLSEXT_TYPE_ech_is_inner 0xda09
+#define TLSEXT_TYPE_ech_outer_extensions 0xfd00
 
 // ExtensionType value from RFC6962
 #define TLSEXT_TYPE_certificate_timestamp 18
diff --git a/ssl/CMakeLists.txt b/ssl/CMakeLists.txt
index 0fb532e..38c686b 100644
--- a/ssl/CMakeLists.txt
+++ b/ssl/CMakeLists.txt
@@ -10,6 +10,7 @@
   d1_srtp.cc
   dtls_method.cc
   dtls_record.cc
+  encrypted_client_hello.cc
   handoff.cc
   handshake.cc
   handshake_client.cc
diff --git a/ssl/encrypted_client_hello.cc b/ssl/encrypted_client_hello.cc
new file mode 100644
index 0000000..f10cd3a
--- /dev/null
+++ b/ssl/encrypted_client_hello.cc
@@ -0,0 +1,444 @@
+/* Copyright (c) 2021, Google Inc.
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+ * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
+ * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
+ * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */
+
+#include <openssl/bytestring.h>
+#include <openssl/curve25519.h>
+#include <openssl/err.h>
+#include <openssl/hkdf.h>
+#include <openssl/ssl.h>
+
+#include "internal.h"
+
+
+#if defined(OPENSSL_MSAN)
+#define NO_SANITIZE_MEMORY __attribute__((no_sanitize("memory")))
+#else
+#define NO_SANITIZE_MEMORY
+#endif
+
+BSSL_NAMESPACE_BEGIN
+
+// ssl_client_hello_write_without_extensions serializes |client_hello| into
+// |out|, omitting the length-prefixed extensions. It serializes individual
+// fields, starting with |client_hello->version|, and ignores the
+// |client_hello->client_hello| field. It returns true on success and false on
+// failure.
+static bool ssl_client_hello_write_without_extensions(
+    const SSL_CLIENT_HELLO *client_hello, CBB *out) {
+  CBB cbb;
+  if (!CBB_add_u16(out, client_hello->version) ||
+      !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) ||
+      !CBB_add_bytes(&cbb, client_hello->cipher_suites,
+                     client_hello->cipher_suites_len) ||
+      !CBB_add_u8_length_prefixed(out, &cbb) ||
+      !CBB_add_bytes(&cbb, client_hello->compression_methods,
+                     client_hello->compression_methods_len) ||
+      !CBB_flush(out)) {
+    return false;
+  }
+  return true;
+}
+
+bool ssl_decode_client_hello_inner(
+    SSL *ssl, uint8_t *out_alert, Array<uint8_t> *out_client_hello_inner,
+    Span<const uint8_t> encoded_client_hello_inner,
+    const SSL_CLIENT_HELLO *client_hello_outer) {
+  SSL_CLIENT_HELLO client_hello_inner;
+  if (!ssl_client_hello_init(ssl, &client_hello_inner,
+                             encoded_client_hello_inner)) {
+    OPENSSL_PUT_ERROR(SSL, SSL_R_DECODE_ERROR);
+    return false;
+  }
+  // TLS 1.3 ClientHellos must have extensions, and EncodedClientHelloInners use
+  // ClientHelloOuter's session_id.
+  if (client_hello_inner.extensions_len == 0 ||
+      client_hello_inner.session_id_len != 0) {
+    OPENSSL_PUT_ERROR(SSL, SSL_R_DECODE_ERROR);
+    return false;
+  }
+  client_hello_inner.session_id = client_hello_outer->session_id;
+  client_hello_inner.session_id_len = client_hello_outer->session_id_len;
+
+  // Begin serializing a message containing the ClientHelloInner in |cbb|.
+  ScopedCBB cbb;
+  CBB body, extensions;
+  if (!ssl->method->init_message(ssl, cbb.get(), &body, SSL3_MT_CLIENT_HELLO) ||
+      !ssl_client_hello_write_without_extensions(&client_hello_inner, &body) ||
+      !CBB_add_u16_length_prefixed(&body, &extensions)) {
+    OPENSSL_PUT_ERROR(SSL, ERR_R_INTERNAL_ERROR);
+    return false;
+  }
+
+  // Sort the extensions in ClientHelloOuter, so ech_outer_extensions may be
+  // processed in O(n*log(n)) time, rather than O(n^2).
+  struct Extension {
+    uint16_t extension = 0;
+    Span<const uint8_t> body;
+    bool copied = false;
+  };
+
+  // MSan's libc interceptors do not handle |bsearch|. See b/182583130.
+  auto compare_extension = [](const void *a, const void *b)
+                               NO_SANITIZE_MEMORY -> int {
+    const Extension *extension_a = reinterpret_cast<const Extension *>(a);
+    const Extension *extension_b = reinterpret_cast<const Extension *>(b);
+    if (extension_a->extension < extension_b->extension) {
+      return -1;
+    } else if (extension_a->extension > extension_b->extension) {
+      return 1;
+    }
+    return 0;
+  };
+  GrowableArray<Extension> sorted_extensions;
+  CBS unsorted_extensions(MakeConstSpan(client_hello_outer->extensions,
+                                        client_hello_outer->extensions_len));
+  while (CBS_len(&unsorted_extensions) > 0) {
+    Extension extension;
+    CBS extension_body;
+    if (!CBS_get_u16(&unsorted_extensions, &extension.extension) ||
+        !CBS_get_u16_length_prefixed(&unsorted_extensions, &extension_body)) {
+      OPENSSL_PUT_ERROR(SSL, ERR_R_INTERNAL_ERROR);
+      return false;
+    }
+    extension.body = extension_body;
+    if (!sorted_extensions.Push(extension)) {
+      return false;
+    }
+  }
+  qsort(sorted_extensions.data(), sorted_extensions.size(), sizeof(Extension),
+        compare_extension);
+
+  // Copy extensions from |client_hello_inner|, expanding ech_outer_extensions.
+  CBS inner_extensions(MakeConstSpan(client_hello_inner.extensions,
+                                     client_hello_inner.extensions_len));
+  while (CBS_len(&inner_extensions) > 0) {
+    uint16_t extension_id;
+    CBS extension_body;
+    if (!CBS_get_u16(&inner_extensions, &extension_id) ||
+        !CBS_get_u16_length_prefixed(&inner_extensions, &extension_body)) {
+      OPENSSL_PUT_ERROR(SSL, SSL_R_DECODE_ERROR);
+      return false;
+    }
+    if (extension_id != TLSEXT_TYPE_ech_outer_extensions) {
+      if (!CBB_add_u16(&extensions, extension_id) ||
+          !CBB_add_u16(&extensions, CBS_len(&extension_body)) ||
+          !CBB_add_bytes(&extensions, CBS_data(&extension_body),
+                         CBS_len(&extension_body))) {
+        OPENSSL_PUT_ERROR(SSL, ERR_R_INTERNAL_ERROR);
+        return false;
+      }
+      continue;
+    }
+
+    // Replace ech_outer_extensions with the corresponding outer extensions.
+    CBS outer_extensions;
+    if (!CBS_get_u8_length_prefixed(&extension_body, &outer_extensions) ||
+        CBS_len(&extension_body) != 0) {
+      OPENSSL_PUT_ERROR(SSL, SSL_R_DECODE_ERROR);
+      return false;
+    }
+    while (CBS_len(&outer_extensions) > 0) {
+      uint16_t extension_needed;
+      if (!CBS_get_u16(&outer_extensions, &extension_needed)) {
+        OPENSSL_PUT_ERROR(SSL, SSL_R_DECODE_ERROR);
+        return false;
+      }
+      if (extension_needed == TLSEXT_TYPE_encrypted_client_hello) {
+        *out_alert = SSL_AD_ILLEGAL_PARAMETER;
+        OPENSSL_PUT_ERROR(SSL, SSL_R_DECODE_ERROR);
+        return false;
+      }
+      // Find the referenced extension.
+      Extension key;
+      key.extension = extension_needed;
+      Extension *result = reinterpret_cast<Extension *>(
+          bsearch(&key, sorted_extensions.data(), sorted_extensions.size(),
+                  sizeof(Extension), compare_extension));
+      if (result == nullptr) {
+        *out_alert = SSL_AD_ILLEGAL_PARAMETER;
+        OPENSSL_PUT_ERROR(SSL, SSL_R_DECODE_ERROR);
+        return false;
+      }
+
+      // Extensions may be referenced at most once, to bound the result size.
+      if (result->copied) {
+        *out_alert = SSL_AD_ILLEGAL_PARAMETER;
+        OPENSSL_PUT_ERROR(SSL, SSL_R_DUPLICATE_EXTENSION);
+        return false;
+      }
+      result->copied = true;
+
+      if (!CBB_add_u16(&extensions, extension_needed) ||
+          !CBB_add_u16(&extensions, result->body.size()) ||
+          !CBB_add_bytes(&extensions, result->body.data(),
+                         result->body.size())) {
+        OPENSSL_PUT_ERROR(SSL, ERR_R_MALLOC_FAILURE);
+        return false;
+      }
+    }
+  }
+  if (!CBB_flush(&body)) {
+    OPENSSL_PUT_ERROR(SSL, ERR_R_INTERNAL_ERROR);
+    return false;
+  }
+
+  // See https://github.com/tlswg/draft-ietf-tls-esni/pull/411
+  CBS extension;
+  if (!ssl_client_hello_init(ssl, &client_hello_inner,
+                             MakeConstSpan(CBB_data(&body), CBB_len(&body))) ||
+      !ssl_client_hello_get_extension(&client_hello_inner, &extension,
+                                      TLSEXT_TYPE_ech_is_inner) ||
+      CBS_len(&extension) != 0 ||
+      ssl_client_hello_get_extension(&client_hello_inner, &extension,
+                                     TLSEXT_TYPE_encrypted_client_hello) ||
+      !ssl_client_hello_get_extension(&client_hello_inner, &extension,
+                                      TLSEXT_TYPE_supported_versions)) {
+    *out_alert = SSL_AD_ILLEGAL_PARAMETER;
+    OPENSSL_PUT_ERROR(SSL, SSL_R_INVALID_CLIENT_HELLO_INNER);
+    return false;
+  }
+  // Parse supported_versions and reject TLS versions prior to TLS 1.3. Older
+  // versions are incompatible with ECH.
+  CBS versions;
+  if (!CBS_get_u8_length_prefixed(&extension, &versions) ||
+      CBS_len(&extension) != 0 ||  //
+      CBS_len(&versions) == 0) {
+    *out_alert = SSL_AD_DECODE_ERROR;
+    OPENSSL_PUT_ERROR(SSL, SSL_R_DECODE_ERROR);
+    return false;
+  }
+  while (CBS_len(&versions) != 0) {
+    uint16_t version;
+    if (!CBS_get_u16(&versions, &version)) {
+      *out_alert = SSL_AD_DECODE_ERROR;
+      OPENSSL_PUT_ERROR(SSL, SSL_R_DECODE_ERROR);
+      return false;
+    }
+    if (version == SSL3_VERSION || version == TLS1_VERSION ||
+        version == TLS1_1_VERSION || version == TLS1_2_VERSION ||
+        version == DTLS1_VERSION || version == DTLS1_2_VERSION) {
+      *out_alert = SSL_AD_ILLEGAL_PARAMETER;
+      OPENSSL_PUT_ERROR(SSL, SSL_R_INVALID_CLIENT_HELLO_INNER);
+      return false;
+    }
+  }
+
+  if (!ssl->method->finish_message(ssl, cbb.get(), out_client_hello_inner)) {
+    OPENSSL_PUT_ERROR(SSL, ERR_R_INTERNAL_ERROR);
+    return false;
+  }
+  return true;
+}
+
+bool ssl_client_hello_decrypt(
+    EVP_HPKE_CTX *hpke_ctx, Array<uint8_t> *out_encoded_client_hello_inner,
+    bool *out_is_decrypt_error, const SSL_CLIENT_HELLO *client_hello_outer,
+    uint16_t kdf_id, uint16_t aead_id, Span<const uint8_t> config_id,
+    Span<const uint8_t> enc, Span<const uint8_t> payload) {
+  *out_is_decrypt_error = false;
+
+  // Compute the ClientHello portion of the ClientHelloOuterAAD value. See
+  // draft-ietf-tls-esni-09, section 5.2.
+  ScopedCBB ch_outer_aad_cbb;
+  CBB config_id_cbb, enc_cbb, outer_hello_cbb, extensions_cbb;
+  if (!CBB_init(ch_outer_aad_cbb.get(), 0) ||
+      !CBB_add_u16(ch_outer_aad_cbb.get(), kdf_id) ||
+      !CBB_add_u16(ch_outer_aad_cbb.get(), aead_id) ||
+      !CBB_add_u8_length_prefixed(ch_outer_aad_cbb.get(), &config_id_cbb) ||
+      !CBB_add_bytes(&config_id_cbb, config_id.data(), config_id.size()) ||
+      !CBB_add_u16_length_prefixed(ch_outer_aad_cbb.get(), &enc_cbb) ||
+      !CBB_add_bytes(&enc_cbb, enc.data(), enc.size()) ||
+      !CBB_add_u24_length_prefixed(ch_outer_aad_cbb.get(), &outer_hello_cbb) ||
+      !ssl_client_hello_write_without_extensions(client_hello_outer,
+                                                 &outer_hello_cbb) ||
+      !CBB_add_u16_length_prefixed(&outer_hello_cbb, &extensions_cbb)) {
+    OPENSSL_PUT_ERROR(SSL, ERR_R_MALLOC_FAILURE);
+    return false;
+  }
+
+  CBS extensions(MakeConstSpan(client_hello_outer->extensions,
+                               client_hello_outer->extensions_len));
+  while (CBS_len(&extensions) > 0) {
+    uint16_t extension_id;
+    CBS extension_body;
+    if (!CBS_get_u16(&extensions, &extension_id) ||
+        !CBS_get_u16_length_prefixed(&extensions, &extension_body)) {
+      OPENSSL_PUT_ERROR(SSL, SSL_R_DECODE_ERROR);
+      return false;
+    }
+    if (extension_id == TLSEXT_TYPE_encrypted_client_hello) {
+      continue;
+    }
+    if (!CBB_add_u16(&extensions_cbb, extension_id) ||
+        !CBB_add_u16(&extensions_cbb, CBS_len(&extension_body)) ||
+        !CBB_add_bytes(&extensions_cbb, CBS_data(&extension_body),
+                       CBS_len(&extension_body))) {
+      OPENSSL_PUT_ERROR(SSL, ERR_R_MALLOC_FAILURE);
+      return false;
+    }
+  }
+  if (!CBB_flush(ch_outer_aad_cbb.get())) {
+    OPENSSL_PUT_ERROR(SSL, ERR_R_MALLOC_FAILURE);
+    return false;
+  }
+
+  // Attempt to decrypt into |out_encoded_client_hello_inner|.
+  if (!out_encoded_client_hello_inner->Init(payload.size())) {
+    OPENSSL_PUT_ERROR(SSL, ERR_R_MALLOC_FAILURE);
+    return false;
+  }
+  size_t encoded_client_hello_inner_len;
+  if (!EVP_HPKE_CTX_open(hpke_ctx, out_encoded_client_hello_inner->data(),
+                         &encoded_client_hello_inner_len,
+                         out_encoded_client_hello_inner->size(), payload.data(),
+                         payload.size(), CBB_data(ch_outer_aad_cbb.get()),
+                         CBB_len(ch_outer_aad_cbb.get()))) {
+    *out_is_decrypt_error = true;
+    OPENSSL_PUT_ERROR(SSL, SSL_R_DECRYPTION_FAILED);
+    return false;
+  }
+  out_encoded_client_hello_inner->Shrink(encoded_client_hello_inner_len);
+  return true;
+}
+
+
+bool ECHServerConfig::Init(Span<const uint8_t> raw,
+                           Span<const uint8_t> private_key,
+                           bool is_retry_config) {
+  assert(!initialized_);
+  is_retry_config_ = is_retry_config;
+
+  if (!raw_.CopyFrom(raw)) {
+    OPENSSL_PUT_ERROR(SSL, ERR_R_MALLOC_FAILURE);
+    return false;
+  }
+  // Read from |raw_| so we can save Spans with the same lifetime as |this|.
+  CBS reader(raw_);
+
+  uint16_t version;
+  if (!CBS_get_u16(&reader, &version)) {
+    OPENSSL_PUT_ERROR(SSL, SSL_R_DECODE_ERROR);
+    return false;
+  }
+  // Parse the ECHConfig, rejecting all unsupported parameters and extensions.
+  // Unlike most server options, ECH's server configuration is serialized and
+  // configured in both the server and DNS. If the caller configures an
+  // unsupported parameter, this is a deployment error. To catch these errors,
+  // we fail early.
+  if (version != TLSEXT_TYPE_encrypted_client_hello) {
+    OPENSSL_PUT_ERROR(SSL, SSL_R_UNSUPPORTED_ECH_SERVER_CONFIG);
+    return false;
+  }
+
+  CBS ech_config_contents, public_name, public_key, cipher_suites, extensions;
+  uint16_t kem_id, max_name_len;
+  if (!CBS_get_u16_length_prefixed(&reader, &ech_config_contents) ||
+      !CBS_get_u16_length_prefixed(&ech_config_contents, &public_name) ||
+      CBS_len(&public_name) == 0 ||
+      !CBS_get_u16_length_prefixed(&ech_config_contents, &public_key) ||
+      CBS_len(&public_key) == 0 ||
+      !CBS_get_u16(&ech_config_contents, &kem_id) ||
+      !CBS_get_u16_length_prefixed(&ech_config_contents, &cipher_suites) ||
+      CBS_len(&cipher_suites) == 0 ||
+      !CBS_get_u16(&ech_config_contents, &max_name_len) ||
+      !CBS_get_u16_length_prefixed(&ech_config_contents, &extensions) ||
+      CBS_len(&ech_config_contents) != 0 ||  //
+      CBS_len(&reader) != 0) {
+    OPENSSL_PUT_ERROR(SSL, SSL_R_DECODE_ERROR);
+    return false;
+  }
+  // We only support one KEM, and the KEM decides the length of |public_key|.
+  if (CBS_len(&public_key) != X25519_PUBLIC_VALUE_LEN ||
+      kem_id != EVP_HPKE_DHKEM_X25519_HKDF_SHA256) {
+    OPENSSL_PUT_ERROR(SSL, SSL_R_UNSUPPORTED_ECH_SERVER_CONFIG);
+    return false;
+  }
+  public_key_ = public_key;
+
+  // We do not support any ECHConfig extensions, so |extensions| must be empty.
+  if (CBS_len(&extensions) != 0) {
+    OPENSSL_PUT_ERROR(SSL, SSL_R_ECH_SERVER_CONFIG_UNSUPPORTED_EXTENSION);
+    return false;
+  }
+
+  cipher_suites_ = cipher_suites;
+  while (CBS_len(&cipher_suites) > 0) {
+    uint16_t kdf_id, aead_id;
+    if (!CBS_get_u16(&cipher_suites, &kdf_id) ||
+        !CBS_get_u16(&cipher_suites, &aead_id)) {
+      OPENSSL_PUT_ERROR(SSL, SSL_R_DECODE_ERROR);
+      return false;
+    }
+    // This parser fails when it encounters any bytes it does not understand. If
+    // the config lists any unsupported cipher suites, that is a parse error.
+    if (kdf_id != EVP_HPKE_HKDF_SHA256 ||
+        (aead_id != EVP_HPKE_AEAD_AES_128_GCM &&
+         aead_id != EVP_HPKE_AEAD_AES_256_GCM &&
+         aead_id != EVP_HPKE_AEAD_CHACHA20POLY1305)) {
+      OPENSSL_PUT_ERROR(SSL, SSL_R_UNSUPPORTED_ECH_SERVER_CONFIG);
+      return false;
+    }
+  }
+
+  // Precompute the config_id.
+  uint8_t key[EVP_MAX_KEY_LENGTH];
+  size_t key_len;
+  static const uint8_t kInfo[] = "tls ech config id";
+  if (!HKDF_extract(key, &key_len, EVP_sha256(), raw_.data(), raw_.size(),
+                    nullptr, 0) ||
+      !HKDF_expand(config_id_sha256_, sizeof(config_id_sha256_), EVP_sha256(),
+                   key, key_len, kInfo, OPENSSL_ARRAY_SIZE(kInfo) - 1)) {
+    OPENSSL_PUT_ERROR(SSL, ERR_R_INTERNAL_ERROR);
+    return false;
+  }
+
+  if (private_key.size() != X25519_PRIVATE_KEY_LEN) {
+    OPENSSL_PUT_ERROR(SSL, SSL_R_DECODE_ERROR);
+    return false;
+  }
+  uint8_t expected_public_key[X25519_PUBLIC_VALUE_LEN];
+  X25519_public_from_private(expected_public_key, private_key.data());
+  if (public_key_ != expected_public_key) {
+    OPENSSL_PUT_ERROR(SSL, SSL_R_ECH_SERVER_CONFIG_AND_PRIVATE_KEY_MISMATCH);
+    return false;
+  }
+  assert(sizeof(private_key_) == private_key.size());
+  OPENSSL_memcpy(private_key_, private_key.data(), private_key.size());
+
+  initialized_ = true;
+  return true;
+}
+
+bool ECHServerConfig::SupportsCipherSuite(uint16_t kdf_id,
+                                          uint16_t aead_id) const {
+  assert(initialized_);
+  CBS cbs(cipher_suites_);
+  while (CBS_len(&cbs) != 0) {
+    uint16_t supported_kdf_id, supported_aead_id;
+    if (!CBS_get_u16(&cbs, &supported_kdf_id) ||
+        !CBS_get_u16(&cbs, &supported_aead_id)) {
+      return false;
+    }
+    if (kdf_id == supported_kdf_id && aead_id == supported_aead_id) {
+      return true;
+    }
+  }
+  return false;
+}
+
+BSSL_NAMESPACE_END
diff --git a/ssl/handoff.cc b/ssl/handoff.cc
index 16cbdf7..6cf0a44 100644
--- a/ssl/handoff.cc
+++ b/ssl/handoff.cc
@@ -93,7 +93,7 @@
       !serialize_features(&seq) ||
       !CBB_flush(out) ||
       !ssl->method->get_message(ssl, &msg) ||
-      !ssl_client_hello_init(ssl, out_hello, msg)) {
+      !ssl_client_hello_init(ssl, out_hello, msg.body)) {
     return false;
   }
 
diff --git a/ssl/handshake.cc b/ssl/handshake.cc
index d889372..f5b6ca0 100644
--- a/ssl/handshake.cc
+++ b/ssl/handshake.cc
@@ -126,6 +126,7 @@
 
 SSL_HANDSHAKE::SSL_HANDSHAKE(SSL *ssl_arg)
     : ssl(ssl_arg),
+      ech_accept(false),
       ech_present(false),
       ech_is_inner_present(false),
       scts_requested(false),
@@ -164,6 +165,28 @@
   hash_len_ = hash_len;
 }
 
+bool SSL_HANDSHAKE::GetClientHello(SSLMessage *out_msg,
+                                   SSL_CLIENT_HELLO *out_client_hello) {
+  if (!ech_client_hello_buf.empty()) {
+    // If the backing buffer is non-empty, the ClientHelloInner has been set.
+    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);
+  } 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);
+    return false;
+  }
+
+  if (!ssl_client_hello_init(ssl, out_client_hello, out_msg->body)) {
+    OPENSSL_PUT_ERROR(SSL, SSL_R_CLIENTHELLO_PARSE_FAILED);
+    ssl_send_alert(ssl, SSL3_AL_FATAL, SSL_AD_DECODE_ERROR);
+    return false;
+  }
+  return true;
+}
+
 UniquePtr<SSL_HANDSHAKE> ssl_handshake_new(SSL *ssl) {
   UniquePtr<SSL_HANDSHAKE> hs = MakeUnique<SSL_HANDSHAKE>(ssl);
   if (!hs || !hs->transcript.Init()) {
diff --git a/ssl/handshake_server.cc b/ssl/handshake_server.cc
index bc0a0d1..1c5f0cf 100644
--- a/ssl/handshake_server.cc
+++ b/ssl/handshake_server.cc
@@ -154,6 +154,8 @@
 #include <openssl/bn.h>
 #include <openssl/bytestring.h>
 #include <openssl/cipher.h>
+#include <openssl/curve25519.h>
+#include <openssl/digest.h>
 #include <openssl/ec.h>
 #include <openssl/ecdsa.h>
 #include <openssl/err.h>
@@ -167,6 +169,7 @@
 
 #include "internal.h"
 #include "../crypto/internal.h"
+#include "../crypto/hpke/internal.h"
 
 
 BSSL_NAMESPACE_BEGIN
@@ -563,7 +566,7 @@
   }
 
   SSL_CLIENT_HELLO client_hello;
-  if (!ssl_client_hello_init(ssl, &client_hello, msg)) {
+  if (!ssl_client_hello_init(ssl, &client_hello, msg.body)) {
     OPENSSL_PUT_ERROR(SSL, SSL_R_DECODE_ERROR);
     ssl_send_alert(ssl, SSL3_AL_FATAL, SSL_AD_DECODE_ERROR);
     return ssl_hs_error;
@@ -581,12 +584,137 @@
     return ssl_hs_handoff;
   }
 
+  // If the ClientHello contains an encrypted_client_hello extension (and no
+  // ech_is_inner extension), act as a client-facing server and attempt to
+  // decrypt the ClientHelloInner.
+  CBS ech_body;
+  if (ssl_client_hello_get_extension(&client_hello, &ech_body,
+                                      TLSEXT_TYPE_encrypted_client_hello)) {
+    CBS unused;
+    if (ssl_client_hello_get_extension(&client_hello, &unused,
+                                       TLSEXT_TYPE_ech_is_inner)) {
+      OPENSSL_PUT_ERROR(SSL, SSL_R_UNEXPECTED_EXTENSION);
+      ssl_send_alert(ssl, SSL3_AL_FATAL, SSL_AD_ILLEGAL_PARAMETER);
+      return ssl_hs_error;
+    }
+
+    // Parse a ClientECH out of the extension body.
+    uint16_t kdf_id, aead_id;
+    CBS config_id, enc, payload;
+    if (!CBS_get_u16(&ech_body, &kdf_id) ||  //
+        !CBS_get_u16(&ech_body, &aead_id) ||
+        !CBS_get_u8_length_prefixed(&ech_body, &config_id) ||
+        !CBS_get_u16_length_prefixed(&ech_body, &enc) ||
+        !CBS_get_u16_length_prefixed(&ech_body, &payload) ||
+        CBS_len(&ech_body) != 0) {
+      OPENSSL_PUT_ERROR(SSL, SSL_R_DECODE_ERROR);
+      ssl_send_alert(ssl, SSL3_AL_FATAL, SSL_AD_DECODE_ERROR);
+      return ssl_hs_error;
+    }
+
+    {
+      MutexReadLock lock(&ssl->ctx->lock);
+      hs->ech_server_config_list = UpRef(ssl->ctx->ech_server_config_list);
+    }
+
+    if (hs->ech_server_config_list) {
+      for (const ECHServerConfig &ech_config :
+           hs->ech_server_config_list->configs) {
+        // Skip this config if the client-provided config_id does not match or
+        // if the client indicated an unsupported HPKE ciphersuite.
+        if (config_id != ech_config.config_id_sha256() ||
+            !ech_config.SupportsCipherSuite(kdf_id, aead_id)) {
+          continue;
+        }
+
+        static const uint8_t kInfoLabel[] = "tls ech";
+        ScopedCBB info_cbb;
+        if (!CBB_init(info_cbb.get(),
+                      sizeof(kInfoLabel) + ech_config.raw().size()) ||
+            !CBB_add_bytes(info_cbb.get(), kInfoLabel,
+                           sizeof(kInfoLabel) /* includes trailing NUL */) ||
+            !CBB_add_bytes(info_cbb.get(), ech_config.raw().data(),
+                           ech_config.raw().size())) {
+          OPENSSL_PUT_ERROR(SSL, ERR_R_MALLOC_FAILURE);
+          return ssl_hs_error;
+        }
+
+        // Set up a fresh HPKE context for each decryption attempt.
+        hs->ech_hpke_ctx.Reset();
+
+        if (CBS_len(&enc) != X25519_PUBLIC_VALUE_LEN ||
+            !EVP_HPKE_CTX_setup_base_r_x25519(
+                hs->ech_hpke_ctx.get(), kdf_id, aead_id, CBS_data(&enc),
+                CBS_len(&enc), ech_config.public_key().data(),
+                ech_config.public_key().size(), ech_config.private_key().data(),
+                ech_config.private_key().size(), CBB_data(info_cbb.get()),
+                CBB_len(info_cbb.get()))) {
+          // Ignore the error and try another ECHConfig.
+          ERR_clear_error();
+          continue;
+        }
+        Array<uint8_t> encoded_client_hello_inner;
+        bool is_decrypt_error;
+        if (!ssl_client_hello_decrypt(hs->ech_hpke_ctx.get(),
+                                      &encoded_client_hello_inner,
+                                      &is_decrypt_error, &client_hello, kdf_id,
+                                      aead_id, config_id, enc, payload)) {
+          if (is_decrypt_error) {
+            // Ignore the error and try another ECHConfig.
+            ERR_clear_error();
+            continue;
+          }
+          OPENSSL_PUT_ERROR(SSL, SSL_R_DECRYPTION_FAILED);
+          return ssl_hs_error;
+        }
+
+        // Recover the ClientHelloInner from the EncodedClientHelloInner.
+        uint8_t alert = SSL_AD_DECODE_ERROR;
+        bssl::Array<uint8_t> client_hello_inner;
+        if (!ssl_decode_client_hello_inner(ssl, &alert, &client_hello_inner,
+                                           encoded_client_hello_inner,
+                                           &client_hello)) {
+          ssl_send_alert(ssl, SSL3_AL_FATAL, alert);
+          OPENSSL_PUT_ERROR(SSL, SSL_R_DECODE_ERROR);
+          return ssl_hs_error;
+        }
+        hs->ech_client_hello_buf = std::move(client_hello_inner);
+
+        // Load the ClientHelloInner into |client_hello|.
+        if (!hs->GetClientHello(&msg, &client_hello)) {
+          OPENSSL_PUT_ERROR(SSL, ERR_R_INTERNAL_ERROR);
+          return ssl_hs_error;
+        }
+
+        hs->ech_accept = true;
+        break;
+      }
+    }
+
+    // If we did not set |hs->ech_accept| to true, we will send the current
+    // ECHConfigs as retry_configs in the ServerHello's encrypted extensions.
+    // Proceed with the ClientHelloOuter.
+  }
+
   uint8_t alert = SSL_AD_DECODE_ERROR;
   if (!extract_sni(hs, &alert, &client_hello)) {
     ssl_send_alert(ssl, SSL3_AL_FATAL, alert);
     return ssl_hs_error;
   }
 
+  hs->state = state12_read_client_hello_after_ech;
+  return ssl_hs_ok;
+}
+
+static enum ssl_hs_wait_t do_read_client_hello_after_ech(SSL_HANDSHAKE *hs) {
+  SSL *const ssl = hs->ssl;
+
+  SSLMessage msg_unused;
+  SSL_CLIENT_HELLO client_hello;
+  if (!hs->GetClientHello(&msg_unused, &client_hello)) {
+    return ssl_hs_error;
+  }
+
   // Run the early callback.
   if (ssl->ctx->select_certificate_cb != NULL) {
     switch (ssl->ctx->select_certificate_cb(&client_hello)) {
@@ -614,6 +742,7 @@
     hs->apply_jdk11_workaround = true;
   }
 
+  uint8_t alert = SSL_AD_DECODE_ERROR;
   if (!negotiate_version(hs, &alert, &client_hello)) {
     ssl_send_alert(ssl, SSL3_AL_FATAL, alert);
     return ssl_hs_error;
@@ -657,11 +786,6 @@
 static enum ssl_hs_wait_t do_select_certificate(SSL_HANDSHAKE *hs) {
   SSL *const ssl = hs->ssl;
 
-  SSLMessage msg;
-  if (!ssl->method->get_message(ssl, &msg)) {
-    return ssl_hs_read_message;
-  }
-
   // Call |cert_cb| to update server certificates if required.
   if (hs->config->cert->cert_cb != NULL) {
     int rv = hs->config->cert->cert_cb(ssl, hs->config->cert->cert_cb_arg);
@@ -701,10 +825,16 @@
     return ssl_hs_ok;
   }
 
+  // It should not be possible to negotiate TLS 1.2 with ECH. The
+  // ClientHelloInner decoding function rejects ClientHellos which offer TLS 1.2
+  // or below.
+  assert(!hs->ech_accept);
+
   ssl->s3->early_data_reason = ssl_early_data_protocol_version;
 
+  SSLMessage msg_unused;
   SSL_CLIENT_HELLO client_hello;
-  if (!ssl_client_hello_init(ssl, &client_hello, msg)) {
+  if (!hs->GetClientHello(&msg_unused, &client_hello)) {
     return ssl_hs_error;
   }
 
@@ -743,7 +873,7 @@
   }
 
   SSL_CLIENT_HELLO client_hello;
-  if (!ssl_client_hello_init(ssl, &client_hello, msg)) {
+  if (!ssl_client_hello_init(ssl, &client_hello, msg.body)) {
     return ssl_hs_error;
   }
 
@@ -1693,6 +1823,9 @@
       case state12_read_client_hello:
         ret = do_read_client_hello(hs);
         break;
+      case state12_read_client_hello_after_ech:
+        ret = do_read_client_hello_after_ech(hs);
+        break;
       case state12_select_certificate:
         ret = do_select_certificate(hs);
         break;
@@ -1773,6 +1906,8 @@
       return "TLS server start_accept";
     case state12_read_client_hello:
       return "TLS server read_client_hello";
+    case state12_read_client_hello_after_ech:
+      return "TLS server read_client_hello_after_ech";
     case state12_select_certificate:
       return "TLS server select_certificate";
     case state12_tls13:
diff --git a/ssl/internal.h b/ssl/internal.h
index e733e67..9c048bb 100644
--- a/ssl/internal.h
+++ b/ssl/internal.h
@@ -152,6 +152,7 @@
 #include <utility>
 
 #include <openssl/aead.h>
+#include <openssl/curve25519.h>
 #include <openssl/err.h>
 #include <openssl/lhash.h>
 #include <openssl/mem.h>
@@ -161,6 +162,7 @@
 
 #include "../crypto/err/internal.h"
 #include "../crypto/internal.h"
+#include "../crypto/hpke/internal.h"
 
 
 #if defined(OPENSSL_WINDOWS)
@@ -378,6 +380,8 @@
     return *this;
   }
 
+  const T *data() const { return array_.data(); }
+  T *data() { return array_.data(); }
   size_t size() const { return size_; }
   bool empty() const { return size_ == 0; }
 
@@ -1423,7 +1427,88 @@
                              const SSLMessage &msg, CBS *binders);
 
 
-// Encrypted Client Hello.
+// Encrypted ClientHello.
+
+class ECHServerConfig {
+ public:
+  ECHServerConfig() : is_retry_config_(false), initialized_(false) {}
+  ECHServerConfig(ECHServerConfig &&other) = default;
+  ~ECHServerConfig() = default;
+  ECHServerConfig &operator=(ECHServerConfig &&) = default;
+
+  // Init parses |ech_config| as an ECHConfig and saves a copy of |private_key|.
+  // It returns true on success and false on error. It will also error if
+  // |private_key| is not a valid X25519 private key or it does not correspond
+  // to the parsed public key.
+  bool Init(Span<const uint8_t> ech_config, Span<const uint8_t> private_key,
+            bool is_retry_config);
+
+  // SupportsCipherSuite returns true when this ECHConfig supports the HPKE
+  // ciphersuite composed of |kdf_id| and |aead_id|. This function must only be
+  // called on an initialized object.
+  bool SupportsCipherSuite(uint16_t kdf_id, uint16_t aead_id) const;
+
+  Span<const uint8_t> raw() const {
+    assert(initialized_);
+    return raw_;
+  }
+  Span<const uint8_t> public_key() const {
+    assert(initialized_);
+    return public_key_;
+  }
+  Span<const uint8_t> private_key() const {
+    assert(initialized_);
+    return MakeConstSpan(private_key_, sizeof(private_key_));
+  }
+  Span<const uint8_t> config_id_sha256() const {
+    assert(initialized_);
+    return MakeConstSpan(config_id_sha256_, sizeof(config_id_sha256_));
+  }
+  bool is_retry_config() const {
+    assert(initialized_);
+    return is_retry_config_;
+  }
+
+ private:
+  Array<uint8_t> raw_;
+  Span<const uint8_t> public_key_;
+  Span<const uint8_t> cipher_suites_;
+
+  // private_key_ is the key corresponding to |public_key|. For clients, it must
+  // be empty (|private_key_present_ == false|). For servers, it must be a valid
+  // X25519 private key.
+  uint8_t private_key_[X25519_PRIVATE_KEY_LEN];
+
+  // config_id_ stores the precomputed result of |ConfigID| for
+  // |EVP_HPKE_HKDF_SHA256|.
+  uint8_t config_id_sha256_[8];
+
+  bool is_retry_config_ : 1;
+  bool initialized_ : 1;
+};
+
+// ssl_decode_client_hello_inner recovers the full ClientHelloInner from the
+// EncodedClientHelloInner |encoded_client_hello_inner| by replacing its
+// outer_extensions extension with the referenced extensions from the
+// ClientHelloOuter |client_hello_outer|. If successful, it writes the recovered
+// ClientHelloInner to |out_client_hello_inner|. It returns true on success and
+// false on failure.
+OPENSSL_EXPORT bool ssl_decode_client_hello_inner(
+    SSL *ssl, uint8_t *out_alert, Array<uint8_t> *out_client_hello_inner,
+    Span<const uint8_t> encoded_client_hello_inner,
+    const SSL_CLIENT_HELLO *client_hello_outer);
+
+// ssl_client_hello_decrypt attempts to decrypt the given |payload| into
+// |out_encoded_client_hello_inner|. The decrypted value should be an
+// EncodedClientHelloInner. It returns false if any fatal errors occur and true
+// otherwise, regardless of whether the decrypt was successful. It sets
+// |out_encoded_client_hello_inner| to true if the decryption fails, and false
+// otherwise.
+bool ssl_client_hello_decrypt(
+    EVP_HPKE_CTX *hpke_ctx, Array<uint8_t> *out_encoded_client_hello_inner,
+    bool *out_is_decrypt_error, const SSL_CLIENT_HELLO *client_hello_outer,
+    uint16_t kdf_id, uint16_t aead_id, Span<const uint8_t> config_id,
+    Span<const uint8_t> enc, Span<const uint8_t> payload);
 
 // tls13_ech_accept_confirmation computes the server's ECH acceptance signal,
 // writing it to |out|. It returns true on success, and false on failure.
@@ -1507,6 +1592,7 @@
 enum tls12_server_hs_state_t {
   state12_start_accept = 0,
   state12_read_client_hello,
+  state12_read_client_hello_after_ech,
   state12_select_certificate,
   state12_tls13,
   state12_select_parameters,
@@ -1602,6 +1688,17 @@
  public:
   void ResizeSecrets(size_t hash_len);
 
+  // GetClientHello, on the server, returns either the normal ClientHello
+  // message or the ClientHelloInner if it has been serialized to
+  // |ech_client_hello_buf|. This function should only be called when the
+  // current message is a ClientHello. It returns true on success and false on
+  // error.
+  //
+  // Note that fields of the returned |out_msg| and |out_client_hello| point
+  // into a handshake-owned buffer, so their lifetimes should not exceed this
+  // SSL_HANDSHAKE.
+  bool GetClientHello(SSLMessage *out_msg, SSL_CLIENT_HELLO *out_client_hello);
+
   Span<uint8_t> secret() { return MakeSpan(secret_, hash_len_); }
   Span<uint8_t> early_traffic_secret() {
     return MakeSpan(early_traffic_secret_, hash_len_);
@@ -1654,6 +1751,10 @@
   // the first ClientHello.
   Array<uint8_t> ech_grease;
 
+  // ech_client_hello_buf, on the server, contains the bytes of the
+  // reconstructed ClientHelloInner message.
+  Array<uint8_t> ech_client_hello_buf;
+
   // key_share_bytes is the value of the previously sent KeyShare extension by
   // the client in TLS 1.3.
   Array<uint8_t> key_share_bytes;
@@ -1690,6 +1791,10 @@
   // |cert_compression_negotiated| is true.
   uint16_t cert_compression_alg_id;
 
+  // ech_hpke_ctx, on the server, is the HPKE context used to decrypt the
+  // client's ECH payloads.
+  ScopedEVP_HPKE_CTX ech_hpke_ctx;
+
   // server_params, in a TLS 1.2 server, stores the ServerKeyExchange
   // parameters. It has client and server randoms prepended for signing
   // convenience.
@@ -1726,12 +1831,21 @@
   // the client if |in_early_data| is true.
   UniquePtr<SSL_SESSION> early_session;
 
+  // ech_server_config_list, for servers, is the list of ECHConfig values that
+  // were valid when the server received the first ClientHello. Its value will
+  // not change when the config list on |SSL_CTX| is updated.
+  UniquePtr<SSL_ECH_SERVER_CONFIG_LIST> ech_server_config_list;
+
   // new_cipher is the cipher being negotiated in this handshake.
   const SSL_CIPHER *new_cipher = nullptr;
 
   // key_block is the record-layer key block for TLS 1.2 and earlier.
   Array<uint8_t> key_block;
 
+  // ech_accept, on the server, indicates whether the server should overwrite
+  // part of ServerHello.random with the ECH accept_confirmation value.
+  bool ech_accept : 1;
+
   // ech_present, on the server, indicates whether the ClientHello contained an
   // encrypted_client_hello extension.
   bool ech_present : 1;
@@ -1997,7 +2111,7 @@
 // ClientHello functions.
 
 bool ssl_client_hello_init(const SSL *ssl, SSL_CLIENT_HELLO *out,
-                           const SSLMessage &msg);
+                           Span<const uint8_t> body);
 
 bool ssl_client_hello_get_extension(const SSL_CLIENT_HELLO *client_hello,
                                     CBS *out, uint16_t extension_type);
@@ -3321,6 +3435,11 @@
   // The client's Channel ID private key.
   bssl::UniquePtr<EVP_PKEY> channel_id_private;
 
+  // ech_server_config_list contains the server's list of ECHConfig values and
+  // associated private keys. This list may be swapped out at any time, so all
+  // access must be synchronized through |lock|.
+  bssl::UniquePtr<SSL_ECH_SERVER_CONFIG_LIST> ech_server_config_list;
+
   // keylog_callback, if not NULL, is the key logging callback. See
   // |SSL_CTX_set_keylog_callback|.
   void (*keylog_callback)(const SSL *ssl, const char *line) = nullptr;
@@ -3634,5 +3753,18 @@
   friend void SSL_SESSION_free(SSL_SESSION *);
 };
 
+struct ssl_ech_server_config_list_st {
+  ssl_ech_server_config_list_st() = default;
+  ssl_ech_server_config_list_st(const ssl_ech_server_config_list_st &) = delete;
+  ssl_ech_server_config_list_st &operator=(
+      const ssl_ech_server_config_list_st &) = delete;
+
+  bssl::GrowableArray<bssl::ECHServerConfig> configs;
+  CRYPTO_refcount_t references = 1;
+
+ private:
+  ~ssl_ech_server_config_list_st() = default;
+  friend void SSL_ECH_SERVER_CONFIG_LIST_free(SSL_ECH_SERVER_CONFIG_LIST *);
+};
 
 #endif  // OPENSSL_HEADER_SSL_INTERNAL_H
diff --git a/ssl/ssl_lib.cc b/ssl/ssl_lib.cc
index 0990d3c..4e820f0 100644
--- a/ssl/ssl_lib.cc
+++ b/ssl/ssl_lib.cc
@@ -2186,6 +2186,63 @@
   return 1;
 }
 
+SSL_ECH_SERVER_CONFIG_LIST *SSL_ECH_SERVER_CONFIG_LIST_new() {
+  return New<SSL_ECH_SERVER_CONFIG_LIST>();
+}
+
+void SSL_ECH_SERVER_CONFIG_LIST_up_ref(SSL_ECH_SERVER_CONFIG_LIST *configs) {
+  CRYPTO_refcount_inc(&configs->references);
+}
+
+void SSL_ECH_SERVER_CONFIG_LIST_free(SSL_ECH_SERVER_CONFIG_LIST *configs) {
+  if (configs == nullptr ||
+      !CRYPTO_refcount_dec_and_test_zero(&configs->references)) {
+    return;
+  }
+
+  configs->~ssl_ech_server_config_list_st();
+  OPENSSL_free(configs);
+}
+
+int SSL_ECH_SERVER_CONFIG_LIST_add(SSL_ECH_SERVER_CONFIG_LIST *configs,
+                                   int is_retry_config,
+                                   const uint8_t *ech_config,
+                                   size_t ech_config_len,
+                                   const uint8_t *private_key,
+                                   size_t private_key_len) {
+  ECHServerConfig parsed_config;
+  if (!parsed_config.Init(MakeConstSpan(ech_config, ech_config_len),
+                          MakeConstSpan(private_key, private_key_len),
+                          !!is_retry_config)) {
+    OPENSSL_PUT_ERROR(SSL, SSL_R_DECODE_ERROR);
+    return 0;
+  }
+  if (!configs->configs.Push(std::move(parsed_config))) {
+    OPENSSL_PUT_ERROR(SSL, ERR_R_MALLOC_FAILURE);
+    return 0;
+  }
+  return 1;
+}
+
+int SSL_CTX_set1_ech_server_config_list(SSL_CTX *ctx,
+                                        SSL_ECH_SERVER_CONFIG_LIST *list) {
+  bool has_retry_config = false;
+  for (const bssl::ECHServerConfig &config : list->configs) {
+    if (config.is_retry_config()) {
+      has_retry_config = true;
+      break;
+    }
+  }
+  if (!has_retry_config) {
+    OPENSSL_PUT_ERROR(SSL, SSL_R_ECH_SERVER_WOULD_HAVE_NO_RETRY_CONFIGS);
+    return 0;
+  }
+  UniquePtr<SSL_ECH_SERVER_CONFIG_LIST> owned_list = UpRef(list);
+  MutexWriteLock lock(&ctx->lock);
+  ctx->ech_server_config_list.swap(owned_list);
+  return 1;
+}
+
 int SSL_select_next_proto(uint8_t **out, uint8_t *out_len, const uint8_t *peer,
                           unsigned peer_len, const uint8_t *supported,
                           unsigned supported_len) {
diff --git a/ssl/ssl_test.cc b/ssl/ssl_test.cc
index 637f4d5..0c6bf50 100644
--- a/ssl/ssl_test.cc
+++ b/ssl/ssl_test.cc
@@ -29,6 +29,7 @@
 #include <openssl/bio.h>
 #include <openssl/cipher.h>
 #include <openssl/crypto.h>
+#include <openssl/curve25519.h>
 #include <openssl/err.h>
 #include <openssl/hmac.h>
 #include <openssl/pem.h>
@@ -1440,6 +1441,230 @@
   EXPECT_EQ(0, X509_NAME_cmp(sk_X509_NAME_value(list, 2), name1));
 }
 
+// kECHConfig contains a serialized ECHConfig value.
+static const uint8_t kECHConfig[] = {
+    // version
+    0xfe, 0x09,
+    // length
+    0x00, 0x42,
+    // contents.public_name
+    0x00, 0x0e, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x2e, 0x65, 0x78, 0x61,
+    0x6d, 0x70, 0x6c, 0x65,
+    // contents.public_key
+    0x00, 0x20, 0xa6, 0x9a, 0x41, 0x48, 0x5d, 0x32, 0x96, 0xa4, 0xe0, 0xc3,
+    0x6a, 0xee, 0xf6, 0x63, 0x0f, 0x59, 0x32, 0x6f, 0xdc, 0xff, 0x81, 0x29,
+    0x59, 0xa5, 0x85, 0xd3, 0x9b, 0x3b, 0xde, 0x98, 0x55, 0x5c,
+    // contents.kem_id
+    0x00, 0x20,
+    // contents.cipher_suites
+    0x00, 0x08, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x03,
+    // contents.maximum_name_length
+    0x00, 0x10,
+    // contents.extensions
+    0x00, 0x00};
+
+// kECHConfigPublicKey is the public key encoded in |kECHConfig|.
+static const uint8_t kECHConfigPublicKey[X25519_PUBLIC_VALUE_LEN] = {
+    0xa6, 0x9a, 0x41, 0x48, 0x5d, 0x32, 0x96, 0xa4, 0xe0, 0xc3, 0x6a,
+    0xee, 0xf6, 0x63, 0x0f, 0x59, 0x32, 0x6f, 0xdc, 0xff, 0x81, 0x29,
+    0x59, 0xa5, 0x85, 0xd3, 0x9b, 0x3b, 0xde, 0x98, 0x55, 0x5c};
+
+// kECHConfigPrivateKey is the X25519 private key corresponding to
+// |kECHConfigPublicKey|.
+static const uint8_t kECHConfigPrivateKey[X25519_PRIVATE_KEY_LEN] = {
+    0xbc, 0xb5, 0x51, 0x29, 0x31, 0x10, 0x30, 0xc9, 0xed, 0x26, 0xde,
+    0xd4, 0xb3, 0xdf, 0x3a, 0xce, 0x06, 0x8a, 0xee, 0x17, 0xab, 0xce,
+    0xd7, 0xdb, 0xf3, 0x11, 0xe5, 0xa8, 0xf3, 0xb1, 0x8e, 0x24};
+
+// MakeECHConfig serializes an ECHConfig and writes it to |*out| with the
+// specified parameters. |cipher_suites| is a list of code points which should
+// contain pairs of KDF and AEAD IDs.
+bool MakeECHConfig(std::vector<uint8_t> *out, uint16_t kem_id,
+                   Span<const uint8_t> public_key,
+                   Span<const uint16_t> cipher_suites,
+                   Span<const uint8_t> extensions) {
+  bssl::ScopedCBB cbb;
+  CBB contents, child;
+  static const char kPublicName[] = "example.com";
+  if (!CBB_init(cbb.get(), 64) ||
+      !CBB_add_u16(cbb.get(), TLSEXT_TYPE_encrypted_client_hello) ||
+      !CBB_add_u16_length_prefixed(cbb.get(), &contents) ||
+      !CBB_add_u16_length_prefixed(&contents, &child) ||
+      !CBB_add_bytes(&child, reinterpret_cast<const uint8_t *>(kPublicName),
+                     strlen(kPublicName)) ||
+      !CBB_add_u16_length_prefixed(&contents, &child) ||
+      !CBB_add_bytes(&child, public_key.data(), public_key.size()) ||
+      !CBB_add_u16(&contents, kem_id) ||
+      !CBB_add_u16_length_prefixed(&contents, &child)) {
+    return false;
+  }
+  for (uint16_t cipher_suite : cipher_suites) {
+    if (!CBB_add_u16(&child, cipher_suite)) {
+      return false;
+    }
+  }
+  if (!CBB_add_u16(&contents, strlen(kPublicName)) ||  // maximum_name_length
+      !CBB_add_u16_length_prefixed(&contents, &child) ||
+      !CBB_add_bytes(&child, extensions.data(), extensions.size()) ||
+      !CBB_flush(cbb.get())) {
+    return false;
+  }
+
+  out->assign(CBB_data(cbb.get()), CBB_data(cbb.get()) + CBB_len(cbb.get()));
+  return true;
+}
+
+TEST(SSLTest, ECHServerConfigList) {
+  // kWrongPrivateKey is an unrelated, but valid X25519 private key.
+  const uint8_t kWrongPrivateKey[X25519_PRIVATE_KEY_LEN] = {
+      0xbb, 0xfe, 0x08, 0xf7, 0x31, 0xde, 0x9c, 0x8a, 0xf2, 0x06, 0x4a,
+      0x18, 0xd7, 0x8b, 0x79, 0x31, 0xe2, 0x53, 0xdd, 0x63, 0x8f, 0x58,
+      0x42, 0xda, 0x21, 0x0e, 0x61, 0x97, 0x29, 0xcc, 0x17, 0x71};
+
+  bssl::UniquePtr<SSL_CTX> ctx(SSL_CTX_new(TLS_method()));
+  ASSERT_TRUE(ctx);
+
+  bssl::UniquePtr<SSL_ECH_SERVER_CONFIG_LIST> config_list(
+      SSL_ECH_SERVER_CONFIG_LIST_new());
+  ASSERT_TRUE(config_list);
+
+  // Adding an ECHConfig with the wrong private key is an error.
+  ASSERT_FALSE(SSL_ECH_SERVER_CONFIG_LIST_add(
+      config_list.get(), /*is_retry_config=*/1, kECHConfig, sizeof(kECHConfig),
+      kWrongPrivateKey, sizeof(kWrongPrivateKey)));
+  uint32_t err = ERR_get_error();
+  EXPECT_EQ(ERR_LIB_SSL, ERR_GET_LIB(err));
+  EXPECT_EQ(SSL_R_ECH_SERVER_CONFIG_AND_PRIVATE_KEY_MISMATCH,
+            ERR_GET_REASON(err));
+  ERR_clear_error();
+
+  // Adding an ECHConfig with the matching private key succeeds.
+  ASSERT_TRUE(SSL_ECH_SERVER_CONFIG_LIST_add(
+      config_list.get(), /*is_retry_config=*/1, kECHConfig, sizeof(kECHConfig),
+      kECHConfigPrivateKey, sizeof(kECHConfigPrivateKey)));
+
+  ASSERT_TRUE(
+      SSL_CTX_set1_ech_server_config_list(ctx.get(), config_list.get()));
+
+  // Build a new config list and replace the old one on |ctx|.
+  bssl::UniquePtr<SSL_ECH_SERVER_CONFIG_LIST> next_config_list(
+      SSL_ECH_SERVER_CONFIG_LIST_new());
+  ASSERT_TRUE(SSL_ECH_SERVER_CONFIG_LIST_add(
+      next_config_list.get(), /*is_retry_config=*/1, kECHConfig,
+      sizeof(kECHConfig), kECHConfigPrivateKey, sizeof(kECHConfigPrivateKey)));
+  ASSERT_TRUE(
+      SSL_CTX_set1_ech_server_config_list(ctx.get(), next_config_list.get()));
+}
+
+TEST(SSLTest, ECHServerConfigListTruncatedPublicKey) {
+  std::vector<uint8_t> ech_config;
+  ASSERT_TRUE(MakeECHConfig(
+      &ech_config, EVP_HPKE_DHKEM_X25519_HKDF_SHA256,
+      MakeConstSpan(kECHConfigPublicKey, sizeof(kECHConfigPublicKey) - 1),
+      std::vector<uint16_t>{EVP_HPKE_HKDF_SHA256, EVP_HPKE_AEAD_AES_128_GCM},
+      /*extensions=*/{}));
+
+  bssl::UniquePtr<SSL_CTX> ctx(SSL_CTX_new(TLS_method()));
+  ASSERT_TRUE(ctx);
+
+  bssl::UniquePtr<SSL_ECH_SERVER_CONFIG_LIST> config_list(
+      SSL_ECH_SERVER_CONFIG_LIST_new());
+  ASSERT_TRUE(config_list);
+  ASSERT_FALSE(SSL_ECH_SERVER_CONFIG_LIST_add(
+      config_list.get(), /*is_retry_config=*/1, ech_config.data(),
+      ech_config.size(), kECHConfigPrivateKey, sizeof(kECHConfigPrivateKey)));
+
+  uint32_t err = ERR_peek_error();
+  EXPECT_EQ(ERR_LIB_SSL, ERR_GET_LIB(err));
+  EXPECT_EQ(SSL_R_UNSUPPORTED_ECH_SERVER_CONFIG, ERR_GET_REASON(err));
+  ERR_clear_error();
+}
+
+// Test that |SSL_CTX_set1_ech_server_config_list| fails when the config list
+// has no retry configs.
+TEST(SSLTest, ECHServerConfigsWithoutRetryConfigs) {
+  bssl::UniquePtr<SSL_CTX> ctx(SSL_CTX_new(TLS_method()));
+  ASSERT_TRUE(ctx);
+
+  bssl::UniquePtr<SSL_ECH_SERVER_CONFIG_LIST> config_list(
+      SSL_ECH_SERVER_CONFIG_LIST_new());
+  ASSERT_TRUE(config_list);
+
+  // Adding an ECHConfig with the matching private key succeeds.
+  ASSERT_TRUE(SSL_ECH_SERVER_CONFIG_LIST_add(
+      config_list.get(), /*is_retry_config=*/0, kECHConfig, sizeof(kECHConfig),
+      kECHConfigPrivateKey, sizeof(kECHConfigPrivateKey)));
+
+  ASSERT_FALSE(
+      SSL_CTX_set1_ech_server_config_list(ctx.get(), config_list.get()));
+  uint32_t err = ERR_peek_error();
+  EXPECT_EQ(ERR_LIB_SSL, ERR_GET_LIB(err));
+  EXPECT_EQ(SSL_R_ECH_SERVER_WOULD_HAVE_NO_RETRY_CONFIGS, ERR_GET_REASON(err));
+  ERR_clear_error();
+
+  // Add the same ECHConfig to the list, but this time mark it as a retry
+  // config.
+  ASSERT_TRUE(SSL_ECH_SERVER_CONFIG_LIST_add(
+      config_list.get(), /*is_retry_config=*/1, kECHConfig, sizeof(kECHConfig),
+      kECHConfigPrivateKey, sizeof(kECHConfigPrivateKey)));
+  ASSERT_TRUE(
+      SSL_CTX_set1_ech_server_config_list(ctx.get(), config_list.get()));
+}
+
+// Test that the server APIs reject ECHConfigs with unsupported features.
+TEST(SSLTest, UnsupportedECHConfig) {
+  bssl::UniquePtr<SSL_ECH_SERVER_CONFIG_LIST> config_list(
+      SSL_ECH_SERVER_CONFIG_LIST_new());
+  ASSERT_TRUE(config_list);
+
+  // Unsupported versions are rejected.
+  static const uint8_t kUnsupportedVersion[] = {0xff, 0xff, 0x00, 0x00};
+  EXPECT_FALSE(SSL_ECH_SERVER_CONFIG_LIST_add(
+      config_list.get(), /*is_retry_config=*/1, kUnsupportedVersion,
+      sizeof(kUnsupportedVersion), kECHConfigPrivateKey,
+      sizeof(kECHConfigPrivateKey)));
+
+  // Unsupported cipher suites are rejected. (We only support HKDF-SHA256.)
+  std::vector<uint8_t> ech_config;
+  ASSERT_TRUE(MakeECHConfig(
+      &ech_config, EVP_HPKE_DHKEM_X25519_HKDF_SHA256, kECHConfigPublicKey,
+      std::vector<uint16_t>{EVP_HPKE_HKDF_SHA384, EVP_HPKE_AEAD_AES_128_GCM},
+      /*extensions=*/{}));
+  EXPECT_FALSE(SSL_ECH_SERVER_CONFIG_LIST_add(
+      config_list.get(), /*is_retry_config=*/1, ech_config.data(),
+      ech_config.size(), kECHConfigPrivateKey, sizeof(kECHConfigPrivateKey)));
+
+  // Unsupported KEMs are rejected.
+  static const uint8_t kP256PublicKey[] = {
+      0x04, 0xe6, 0x2b, 0x69, 0xe2, 0xbf, 0x65, 0x9f, 0x97, 0xbe, 0x2f,
+      0x1e, 0x0d, 0x94, 0x8a, 0x4c, 0xd5, 0x97, 0x6b, 0xb7, 0xa9, 0x1e,
+      0x0d, 0x46, 0xfb, 0xdd, 0xa9, 0xa9, 0x1e, 0x9d, 0xdc, 0xba, 0x5a,
+      0x01, 0xe7, 0xd6, 0x97, 0xa8, 0x0a, 0x18, 0xf9, 0xc3, 0xc4, 0xa3,
+      0x1e, 0x56, 0xe2, 0x7c, 0x83, 0x48, 0xdb, 0x16, 0x1a, 0x1c, 0xf5,
+      0x1d, 0x7e, 0xf1, 0x94, 0x2d, 0x4b, 0xcf, 0x72, 0x22, 0xc1};
+  static const uint8_t kP256PrivateKey[] = {
+      0x07, 0x0f, 0x08, 0x72, 0x7a, 0xd4, 0xa0, 0x4a, 0x9c, 0xdd, 0x59,
+      0xc9, 0x4d, 0x89, 0x68, 0x77, 0x08, 0xb5, 0x6f, 0xc9, 0x5d, 0x30,
+      0x77, 0x0e, 0xe8, 0xd1, 0xc9, 0xce, 0x0a, 0x8b, 0xb4, 0x6a};
+  ASSERT_TRUE(MakeECHConfig(
+      &ech_config, 0x0010 /* DHKEM(P-256, HKDF-SHA256) */, kP256PublicKey,
+      std::vector<uint16_t>{EVP_HPKE_HKDF_SHA256, EVP_HPKE_AEAD_AES_128_GCM},
+      /*extensions=*/{}));
+  EXPECT_FALSE(SSL_ECH_SERVER_CONFIG_LIST_add(
+      config_list.get(), /*is_retry_config=*/1, ech_config.data(),
+      ech_config.size(), kP256PrivateKey, sizeof(kP256PrivateKey)));
+
+  // Unsupported extensions are rejected.
+  static const uint8_t kExtensions[] = {0x00, 0x01, 0x00, 0x00};
+  ASSERT_TRUE(MakeECHConfig(
+      &ech_config, EVP_HPKE_DHKEM_X25519_HKDF_SHA256, kECHConfigPublicKey,
+      std::vector<uint16_t>{EVP_HPKE_HKDF_SHA256, EVP_HPKE_AEAD_AES_128_GCM},
+      kExtensions));
+  EXPECT_FALSE(SSL_ECH_SERVER_CONFIG_LIST_add(
+      config_list.get(), /*is_retry_config=*/1, ech_config.data(),
+      ech_config.size(), kECHConfigPrivateKey, sizeof(kECHConfigPrivateKey)));
+}
+
 static void AppendSession(SSL_SESSION *session, void *arg) {
   std::vector<SSL_SESSION*> *out =
       reinterpret_cast<std::vector<SSL_SESSION*>*>(arg);
diff --git a/ssl/t1_lib.cc b/ssl/t1_lib.cc
index 155c713..4216fcd 100644
--- a/ssl/t1_lib.cc
+++ b/ssl/t1_lib.cc
@@ -209,11 +209,11 @@
 }
 
 bool ssl_client_hello_init(const SSL *ssl, SSL_CLIENT_HELLO *out,
-                           const SSLMessage &msg) {
+                           Span<const uint8_t> body) {
   OPENSSL_memset(out, 0, sizeof(*out));
   out->ssl = const_cast<SSL *>(ssl);
-  out->client_hello = CBS_data(&msg.body);
-  out->client_hello_len = CBS_len(&msg.body);
+  out->client_hello = body.data();
+  out->client_hello_len = body.size();
 
   CBS client_hello, random, session_id;
   CBS_init(&client_hello, out->client_hello, out->client_hello_len);
@@ -591,7 +591,7 @@
 }
 
 
-// Encrypted Client Hello (ECH)
+// Encrypted ClientHello (ECH)
 //
 // https://tools.ietf.org/html/draft-ietf-tls-esni-09
 
@@ -748,6 +748,35 @@
   return true;
 }
 
+static bool ext_ech_add_serverhello(SSL_HANDSHAKE *hs, CBB *out) {
+  SSL *const ssl = hs->ssl;
+  if (ssl_protocol_version(ssl) < TLS1_3_VERSION ||  //
+      hs->ech_accept ||                              //
+      hs->ech_server_config_list == nullptr) {
+    return true;
+  }
+
+  // Write the list of retry configs to |out|. Note
+  // |SSL_CTX_set1_ech_server_config_list| ensures |ech_server_config_list|
+  // contains at least one retry config.
+  CBB body, retry_configs;
+  if (!CBB_add_u16(out, TLSEXT_TYPE_encrypted_client_hello) ||
+      !CBB_add_u16_length_prefixed(out, &body) ||
+      !CBB_add_u16_length_prefixed(&body, &retry_configs)) {
+    return false;
+  }
+  for (const ECHServerConfig &config : hs->ech_server_config_list->configs) {
+    if (!config.is_retry_config()) {
+      continue;
+    }
+    if (!CBB_add_bytes(&retry_configs, config.raw().data(),
+                       config.raw().size())) {
+      return false;
+    }
+  }
+  return CBB_flush(out);
+}
+
 static bool ext_ech_is_inner_add_clienthello(SSL_HANDSHAKE *hs, CBB *out) {
   return true;
 }
@@ -3264,7 +3293,7 @@
     ext_ech_add_clienthello,
     ext_ech_parse_serverhello,
     ext_ech_parse_clienthello,
-    dont_add_serverhello,
+    ext_ech_add_serverhello,
   },
   {
     TLSEXT_TYPE_ech_is_inner,
diff --git a/ssl/test/runner/common.go b/ssl/test/runner/common.go
index 83439a0..50d38d1 100644
--- a/ssl/test/runner/common.go
+++ b/ssl/test/runner/common.go
@@ -16,6 +16,8 @@
 	"strings"
 	"sync"
 	"time"
+
+	"boringssl.googlesource.com/boringssl/ssl/test/runner/hpke"
 )
 
 const (
@@ -129,6 +131,7 @@
 	extensionDuplicate                  uint16 = 0xffff // not IANA assigned
 	extensionEncryptedClientHello       uint16 = 0xfe09 // not IANA assigned
 	extensionECHIsInner                 uint16 = 0xda09 // not IANA assigned
+	extensionECHOuterExtensions         uint16 = 0xfd00 // not IANA assigned
 )
 
 // TLS signaling cipher suite values
@@ -266,6 +269,7 @@
 	QUICTransportParamsLegacy  []byte                // the legacy QUIC transport params received from the peer
 	HasApplicationSettings     bool                  // whether ALPS was negotiated
 	PeerApplicationSettings    []byte                // application settings received from the peer
+	ECHAccepted                bool                  // whether ECH was accepted on this connection
 }
 
 // ClientAuthType declares the policy the server will follow for
@@ -422,6 +426,19 @@
 	// in the client's handshake to support virtual hosting.
 	ServerName string
 
+	// ClientECHConfig, when non-nil, is the ECHConfig the client will use to
+	// attempt ECH.
+	ClientECHConfig *ECHConfig
+
+	// ECHCipherSuites, for the client, is the list of HPKE cipher suites in
+	// decreasing order of preference. If empty, the default will be used.
+	ECHCipherSuites []HPKECipherSuite
+
+	// ECHOuterExtensions is the list of extensions that the client will
+	// compress with the ech_outer_extensions extension. If empty, no extensions
+	// will be compressed.
+	ECHOuterExtensions []uint16
+
 	// ClientAuth determines the server's policy for
 	// TLS Client Authentication. The default is NoClientCert.
 	ClientAuth ClientAuthType
@@ -846,23 +863,74 @@
 	// encrypted_client_hello extension containing a ClientECH structure.
 	ExpectClientECH bool
 
-	// ExpectServerAcceptECH causes the client to expect that the server will
-	// indicate ECH acceptance in the ServerHello.
-	ExpectServerAcceptECH bool
+	// IgnoreECHConfigCipherPreferences, when true, causes the client to ignore
+	// the cipher preferences in the ECHConfig and select the most preferred ECH
+	// cipher suite unconditionally.
+	IgnoreECHConfigCipherPreferences bool
+
+	// ExpectECHRetryConfigs, when non-nil, contains the expected bytes of the
+	// server's retry configs.
+	ExpectECHRetryConfigs []byte
 
 	// SendECHRetryConfigs, if not empty, contains the ECH server's serialized
 	// retry configs.
 	SendECHRetryConfigs []byte
 
-	// SendEncryptedClientHello, when true, causes the client to send a
-	// placeholder encrypted_client_hello extension on the ClientHelloOuter
-	// message.
-	SendPlaceholderEncryptedClientHello bool
+	// SendInvalidECHIsInner, if not empty, causes the client to send the
+	// specified byte string in the ech_is_inner extension.
+	SendInvalidECHIsInner []byte
 
-	// SendECHIsInner, when non-nil, causes the client to send an ech_is_inner
-	// extension on the ClientHelloOuter message. When nil, the extension will
-	// be omitted.
-	SendECHIsInner []byte
+	// OmitECHIsInner, if true, causes the client to omit the ech_is_inner
+	// extension on the ClientHelloInner message.
+	OmitECHIsInner bool
+
+	// OmitSecondECHIsInner, if true, causes the client to omit the ech_is_inner
+	// extension on the second ClientHelloInner message.
+	OmitSecondECHIsInner bool
+
+	// AlwaysSendECHIsInner, if true, causes the client to send the
+	// ech_is_inner extension on all ClientHello messages. The server is then
+	// expected to unconditionally confirm the extension when negotiating
+	// TLS 1.3 or later.
+	AlwaysSendECHIsInner bool
+
+	// TruncateClientECHEnc, if true, causes the client to send a shortened
+	// ClientECH.enc value in its encrypted_client_hello extension.
+	TruncateClientECHEnc bool
+
+	// OfferSessionInClientHelloOuter, if true, causes the client to offer
+	// sessions in ClientHelloOuter.
+	OfferSessionInClientHelloOuter bool
+
+	// FirstExtensionInClientHelloOuter, if non-zero, causes the client to place
+	// the specified extension first in ClientHelloOuter.
+	FirstExtensionInClientHelloOuter uint16
+
+	// OnlyCompressSecondClientHelloInner, if true, causes the client to
+	// only apply outer_extensions to the second ClientHello.
+	OnlyCompressSecondClientHelloInner bool
+
+	// OmitSecondEncryptedClientHello, if true, causes the client to omit the
+	// second encrypted_client_hello extension.
+	OmitSecondEncryptedClientHello bool
+
+	// CorruptEncryptedClientHello, if true, causes the client to incorrectly
+	// encrypt the encrypted_client_hello extension.
+	CorruptEncryptedClientHello bool
+
+	// CorruptSecondEncryptedClientHello, if true, causes the client to
+	// incorrectly encrypt the second encrypted_client_hello extension.
+	CorruptSecondEncryptedClientHello bool
+
+	// AllowTLS12InClientHelloInner, if true, causes the client to include
+	// TLS 1.2 and earlier in ClientHelloInner.
+	AllowTLS12InClientHelloInner bool
+
+	// MinimalClientHelloOuter, if true, causes the client to omit most fields
+	// in ClientHelloOuter. Note this will make handshake attempts with the
+	// ClientHelloOuter fail and should only be used in tests that expect
+	// success.
+	MinimalClientHelloOuter bool
 
 	// SwapNPNAndALPN switches the relative order between NPN and ALPN in
 	// both ClientHello and ServerHello.
@@ -1875,6 +1943,19 @@
 	return defaultCurves
 }
 
+var defaultECHCipherSuitePreferences = []HPKECipherSuite{
+	{KDF: hpke.HKDFSHA256, AEAD: hpke.AES128GCM},
+	{KDF: hpke.HKDFSHA256, AEAD: hpke.AES256GCM},
+	{KDF: hpke.HKDFSHA256, AEAD: hpke.ChaCha20Poly1305},
+}
+
+func (c *Config) echCipherSuitePreferences() []HPKECipherSuite {
+	if c == nil || len(c.ECHCipherSuites) == 0 {
+		return defaultECHCipherSuitePreferences
+	}
+	return c.ECHCipherSuites
+}
+
 func wireToVersion(vers uint16, isDTLS bool) (uint16, bool) {
 	if isDTLS {
 		switch vers {
@@ -1904,16 +1985,21 @@
 	return vers, true
 }
 
-func (c *Config) supportedVersions(isDTLS bool) []uint16 {
+func (c *Config) supportedVersions(isDTLS, requireTLS13 bool) []uint16 {
 	versions := allTLSWireVersions
 	if isDTLS {
 		versions = allDTLSWireVersions
 	}
 	var ret []uint16
-	for _, vers := range versions {
-		if _, ok := c.isSupportedVersion(vers, isDTLS); ok {
-			ret = append(ret, vers)
+	for _, wireVers := range versions {
+		vers, ok := c.isSupportedVersion(wireVers, isDTLS)
+		if !ok {
+			continue
 		}
+		if requireTLS13 && vers < VersionTLS13 {
+			continue
+		}
+		ret = append(ret, wireVers)
 	}
 	return ret
 }
diff --git a/ssl/test/runner/conn.go b/ssl/test/runner/conn.go
index 6c26e94..4932a0b 100644
--- a/ssl/test/runner/conn.go
+++ b/ssl/test/runner/conn.go
@@ -119,6 +119,9 @@
 	// handshake data may be received until the next flight or epoch change.
 	seenHandshakePackEnd bool
 
+	// echAccepted indicates whether ECH was accepted for this connection.
+	echAccepted bool
+
 	tmp [16]byte
 }
 
@@ -1860,6 +1863,7 @@
 		state.QUICTransportParamsLegacy = c.quicTransportParamsLegacy
 		state.HasApplicationSettings = c.hasApplicationSettings
 		state.PeerApplicationSettings = c.peerApplicationSettings
+		state.ECHAccepted = c.echAccepted
 	}
 
 	return state
diff --git a/ssl/test/runner/handshake_client.go b/ssl/test/runner/handshake_client.go
index 774b093..74e9407 100644
--- a/ssl/test/runner/handshake_client.go
+++ b/ssl/test/runner/handshake_client.go
@@ -24,16 +24,19 @@
 )
 
 type clientHandshakeState struct {
-	c             *Conn
-	serverHello   *serverHelloMsg
-	hello         *clientHelloMsg
-	suite         *cipherSuite
-	finishedHash  finishedHash
-	keyShares     map[CurveID]ecdhCurve
-	masterSecret  []byte
-	session       *ClientSessionState
-	finishedBytes []byte
-	peerPublicKey crypto.PublicKey
+	c                 *Conn
+	serverHello       *serverHelloMsg
+	hello             *clientHelloMsg
+	innerHello        *clientHelloMsg
+	echHPKEContext    *hpke.Context
+	suite             *cipherSuite
+	finishedHash      finishedHash
+	innerFinishedHash finishedHash
+	keyShares         map[CurveID]ecdhCurve
+	masterSecret      []byte
+	session           *ClientSessionState
+	finishedBytes     []byte
+	peerPublicKey     crypto.PublicKey
 }
 
 func mapClientHelloVersion(vers uint16, isDTLS bool) uint16 {
@@ -92,240 +95,16 @@
 	c.sendHandshakeSeq = 0
 	c.recvHandshakeSeq = 0
 
-	nextProtosLength := 0
-	for _, proto := range c.config.NextProtos {
-		if l := len(proto); l > 255 {
-			return errors.New("tls: invalid NextProtos value")
-		} else {
-			nextProtosLength += 1 + l
-		}
-	}
-	if nextProtosLength > 0xffff {
-		return errors.New("tls: NextProtos values too large")
+	hs := &clientHandshakeState{
+		c:         c,
+		keyShares: make(map[CurveID]ecdhCurve),
 	}
 
-	// Translate the bugs that modify ClientHello extension order into a
-	// list of prefix extensions. The marshal function will try these
-	// extensions before any others, followed by any remaining extensions in
-	// the default order.
-	var prefixExtensions []uint16
-	if c.config.Bugs.PSKBinderFirst && !c.config.Bugs.OnlyCorruptSecondPSKBinder {
-		prefixExtensions = append(prefixExtensions, extensionPreSharedKey)
-	}
-	if c.config.Bugs.SwapNPNAndALPN {
-		prefixExtensions = append(prefixExtensions, extensionALPN)
-		prefixExtensions = append(prefixExtensions, extensionNextProtoNeg)
-	}
-
-	quicTransportParams := c.config.QUICTransportParams
-	quicTransportParamsLegacy := c.config.QUICTransportParams
-	if !c.config.QUICTransportParamsUseLegacyCodepoint.IncludeStandard() {
-		quicTransportParams = nil
-	}
-	if !c.config.QUICTransportParamsUseLegacyCodepoint.IncludeLegacy() {
-		quicTransportParamsLegacy = nil
-	}
-
-	minVersion := c.config.minVersion(c.isDTLS)
-	maxVersion := c.config.maxVersion(c.isDTLS)
-	hello := &clientHelloMsg{
-		isDTLS:                    c.isDTLS,
-		compressionMethods:        []uint8{compressionNone},
-		random:                    make([]byte, 32),
-		ocspStapling:              !c.config.Bugs.NoOCSPStapling,
-		sctListSupported:          !c.config.Bugs.NoSignedCertificateTimestamps,
-		serverName:                c.config.ServerName,
-		echIsInner:                c.config.Bugs.SendECHIsInner,
-		supportedCurves:           c.config.curvePreferences(),
-		supportedPoints:           []uint8{pointFormatUncompressed},
-		nextProtoNeg:              len(c.config.NextProtos) > 0,
-		secureRenegotiation:       []byte{},
-		alpnProtocols:             c.config.NextProtos,
-		quicTransportParams:       quicTransportParams,
-		quicTransportParamsLegacy: quicTransportParamsLegacy,
-		duplicateExtension:        c.config.Bugs.DuplicateExtension,
-		channelIDSupported:        c.config.ChannelID != nil,
-		tokenBindingParams:        c.config.TokenBindingParams,
-		tokenBindingVersion:       c.config.TokenBindingVersion,
-		extendedMasterSecret:      maxVersion >= VersionTLS10,
-		srtpProtectionProfiles:    c.config.SRTPProtectionProfiles,
-		srtpMasterKeyIdentifier:   c.config.Bugs.SRTPMasterKeyIdentifer,
-		customExtension:           c.config.Bugs.CustomExtension,
-		omitExtensions:            c.config.Bugs.OmitExtensions,
-		emptyExtensions:           c.config.Bugs.EmptyExtensions,
-		delegatedCredentials:      !c.config.Bugs.DisableDelegatedCredentials,
-		prefixExtensions:          prefixExtensions,
-	}
-
-	if maxVersion >= VersionTLS13 {
-		hello.vers = mapClientHelloVersion(VersionTLS12, c.isDTLS)
-		if !c.config.Bugs.OmitSupportedVersions {
-			hello.supportedVersions = c.config.supportedVersions(c.isDTLS)
-		}
-		hello.pskKEModes = []byte{pskDHEKEMode}
-	} else {
-		hello.vers = mapClientHelloVersion(maxVersion, c.isDTLS)
-	}
-
-	if c.config.Bugs.SendClientVersion != 0 {
-		hello.vers = c.config.Bugs.SendClientVersion
-	}
-
-	if len(c.config.Bugs.SendSupportedVersions) > 0 {
-		hello.supportedVersions = c.config.Bugs.SendSupportedVersions
-	}
-
-	disableEMS := c.config.Bugs.NoExtendedMasterSecret
-	if c.cipherSuite != nil {
-		disableEMS = c.config.Bugs.NoExtendedMasterSecretOnRenegotiation
-	}
-
-	if disableEMS {
-		hello.extendedMasterSecret = false
-	}
-
-	if c.config.Bugs.NoSupportedCurves {
-		hello.supportedCurves = nil
-	}
-
-	if c.config.Bugs.SendPSKKeyExchangeModes != nil {
-		hello.pskKEModes = c.config.Bugs.SendPSKKeyExchangeModes
-	}
-
-	if c.config.Bugs.SendCompressionMethods != nil {
-		hello.compressionMethods = c.config.Bugs.SendCompressionMethods
-	}
-
-	if c.config.Bugs.SendSupportedPointFormats != nil {
-		hello.supportedPoints = c.config.Bugs.SendSupportedPointFormats
-	}
-
-	if len(c.clientVerify) > 0 && !c.config.Bugs.EmptyRenegotiationInfo {
-		if c.config.Bugs.BadRenegotiationInfo {
-			hello.secureRenegotiation = append(hello.secureRenegotiation, c.clientVerify...)
-			hello.secureRenegotiation[0] ^= 0x80
-		} else {
-			hello.secureRenegotiation = c.clientVerify
-		}
-	}
-
-	if c.config.Bugs.DuplicateCompressedCertAlgs {
-		hello.compressedCertAlgs = []uint16{1, 1}
-	} else if len(c.config.CertCompressionAlgs) > 0 {
-		hello.compressedCertAlgs = make([]uint16, 0, len(c.config.CertCompressionAlgs))
-		for id := range c.config.CertCompressionAlgs {
-			hello.compressedCertAlgs = append(hello.compressedCertAlgs, uint16(id))
-		}
-	}
-
-	if c.noRenegotiationInfo() {
-		hello.secureRenegotiation = nil
-	}
-
-	for protocol := range c.config.ApplicationSettings {
-		hello.alpsProtocols = append(hello.alpsProtocols, protocol)
-	}
-
-	if c.config.Bugs.SendPlaceholderEncryptedClientHello {
-		hello.clientECH = &clientECH{
-			hpkeKDF:  hpke.HKDFSHA256,
-			hpkeAEAD: hpke.AES128GCM,
-			configID: []byte{0x02, 0x0d, 0xe8, 0xae, 0xf5, 0x8b, 0x59, 0xb5},
-			enc:      []byte{0xe2, 0xf0, 0x96, 0x64, 0x18, 0x35, 0x10, 0xb3},
-			payload:  []byte{0xa8, 0x53, 0x3a, 0x8d, 0xe8, 0x5f, 0x7c, 0xd7},
-		}
-	}
-
-	var keyShares map[CurveID]ecdhCurve
-	if maxVersion >= VersionTLS13 {
-		keyShares = make(map[CurveID]ecdhCurve)
-		hello.hasKeyShares = true
-		hello.trailingKeyShareData = c.config.Bugs.TrailingKeyShareData
-		curvesToSend := c.config.defaultCurves()
-		for _, curveID := range hello.supportedCurves {
-			if !curvesToSend[curveID] {
-				continue
-			}
-			curve, ok := curveForCurveID(curveID, c.config)
-			if !ok {
-				continue
-			}
-			publicKey, err := curve.offer(c.config.rand())
-			if err != nil {
-				return err
-			}
-
-			if c.config.Bugs.SendCurve != 0 {
-				curveID = c.config.Bugs.SendCurve
-			}
-			if c.config.Bugs.InvalidECDHPoint {
-				publicKey[0] ^= 0xff
-			}
-
-			hello.keyShares = append(hello.keyShares, keyShareEntry{
-				group:       curveID,
-				keyExchange: publicKey,
-			})
-			keyShares[curveID] = curve
-
-			if c.config.Bugs.DuplicateKeyShares {
-				hello.keyShares = append(hello.keyShares, hello.keyShares[len(hello.keyShares)-1])
-			}
-		}
-
-		if c.config.Bugs.MissingKeyShare {
-			hello.hasKeyShares = false
-		}
-	}
-
-	possibleCipherSuites := c.config.cipherSuites()
-	hello.cipherSuites = make([]uint16, 0, len(possibleCipherSuites))
-
-NextCipherSuite:
-	for _, suiteID := range possibleCipherSuites {
-		for _, suite := range cipherSuites {
-			if suite.id != suiteID {
-				continue
-			}
-			// Don't advertise TLS 1.2-only cipher suites unless
-			// we're attempting TLS 1.2.
-			if maxVersion < VersionTLS12 && suite.flags&suiteTLS12 != 0 {
-				continue
-			}
-			hello.cipherSuites = append(hello.cipherSuites, suiteID)
-			continue NextCipherSuite
-		}
-	}
-
-	if c.config.Bugs.AdvertiseAllConfiguredCiphers {
-		hello.cipherSuites = possibleCipherSuites
-	}
-
-	if c.config.Bugs.SendRenegotiationSCSV {
-		hello.cipherSuites = append(hello.cipherSuites, renegotiationSCSV)
-	}
-
-	if c.config.Bugs.SendFallbackSCSV {
-		hello.cipherSuites = append(hello.cipherSuites, fallbackSCSV)
-	}
-
-	_, err := io.ReadFull(c.config.rand(), hello.random)
-	if err != nil {
-		c.sendAlert(alertInternalError)
-		return errors.New("tls: short read from Rand: " + err.Error())
-	}
-
-	if maxVersion >= VersionTLS12 && !c.config.Bugs.NoSignatureAlgorithms {
-		hello.signatureAlgorithms = c.config.verifySignatureAlgorithms()
-	}
-
+	// Pick a session to resume.
 	var session *ClientSessionState
 	var cacheKey string
 	sessionCache := c.config.ClientSessionCache
-
 	if sessionCache != nil {
-		hello.ticketSupported = !c.config.SessionTicketsDisabled
-
 		// Try to resume a previously negotiated TLS session, if
 		// available.
 		cacheKey = clientSessionCacheKey(c.conn.RemoteAddr(), c.config)
@@ -339,7 +118,7 @@
 			// previous session are still valid.
 			cipherSuiteOk := false
 			if candidateSession.vers <= VersionTLS12 {
-				for _, id := range hello.cipherSuites {
+				for _, id := range c.config.cipherSuites() {
 					if id == candidateSession.cipherSuite.id {
 						cipherSuiteOk = true
 						break
@@ -351,149 +130,84 @@
 				cipherSuiteOk = true
 			}
 
-			versOk := candidateSession.vers >= minVersion &&
-				candidateSession.vers <= maxVersion
+			_, versOk := c.config.isSupportedVersion(candidateSession.wireVersion, c.isDTLS)
 			if ticketOk && versOk && cipherSuiteOk {
 				session = candidateSession
+				hs.session = session
 			}
 		}
 	}
 
-	if session != nil && c.config.time().Before(session.ticketExpiration) {
-		ticket := session.sessionTicket
-		if c.config.Bugs.FilterTicket != nil && len(ticket) > 0 {
-			// Copy the ticket so FilterTicket may act in-place.
-			ticket = make([]byte, len(session.sessionTicket))
-			copy(ticket, session.sessionTicket)
-
-			ticket, err = c.config.Bugs.FilterTicket(ticket)
-			if err != nil {
-				return err
-			}
+	// Set up ECH parameters.
+	var err error
+	var earlyHello *clientHelloMsg
+	if c.config.ClientECHConfig != nil {
+		if c.config.ClientECHConfig.KEM != hpke.X25519WithHKDFSHA256 {
+			return errors.New("tls: unsupported KEM type in ECHConfig")
 		}
 
-		if session.vers >= VersionTLS13 || c.config.Bugs.SendBothTickets {
-			// TODO(nharper): Support sending more
-			// than one PSK identity.
-			ticketAge := uint32(c.config.time().Sub(session.ticketCreationTime) / time.Millisecond)
-			if c.config.Bugs.SendTicketAge != 0 {
-				ticketAge = uint32(c.config.Bugs.SendTicketAge / time.Millisecond)
-			}
-			psk := pskIdentity{
-				ticket:              ticket,
-				obfuscatedTicketAge: session.ticketAgeAdd + ticketAge,
-			}
-			hello.pskIdentities = []pskIdentity{psk}
-
-			if c.config.Bugs.ExtraPSKIdentity {
-				hello.pskIdentities = append(hello.pskIdentities, psk)
-			}
+		echCipherSuite, ok := chooseECHCipherSuite(c.config.ClientECHConfig, c.config)
+		if !ok {
+			return errors.New("tls: did not find compatible cipher suite in ECHConfig")
 		}
 
-		if session.vers < VersionTLS13 || c.config.Bugs.SendBothTickets {
-			if ticket != nil {
-				hello.sessionTicket = ticket
-				// A random session ID is used to detect when the
-				// server accepted the ticket and is resuming a session
-				// (see RFC 5077).
-				sessionIDLen := 16
-				if c.config.Bugs.TicketSessionIDLength != 0 {
-					sessionIDLen = c.config.Bugs.TicketSessionIDLength
-				}
-				if c.config.Bugs.EmptyTicketSessionID {
-					sessionIDLen = 0
-				}
-				hello.sessionID = make([]byte, sessionIDLen)
-				if _, err := io.ReadFull(c.config.rand(), hello.sessionID); err != nil {
-					c.sendAlert(alertInternalError)
-					return errors.New("tls: short read from Rand: " + err.Error())
-				}
-			} else {
-				hello.sessionID = session.sessionID
-			}
+		info := []byte("tls ech\x00")
+		info = append(info, MarshalECHConfig(c.config.ClientECHConfig)...)
+
+		var echEnc []byte
+		hs.echHPKEContext, echEnc, err = hpke.SetupBaseSenderX25519(echCipherSuite.KDF, echCipherSuite.AEAD, c.config.ClientECHConfig.PublicKey, info, nil)
+		if err != nil {
+			return errors.New("tls: ech: failed to set up client's HPKE sender context")
 		}
-	}
 
-	// Request compatibility mode from the client by sending a fake session
-	// ID. Although BoringSSL always enables compatibility mode, other
-	// implementations make it conditional on the ClientHello. We test
-	// BoringSSL's expected behavior with SendClientHelloSessionID.
-	if len(hello.sessionID) == 0 && maxVersion >= VersionTLS13 {
-		hello.sessionID = make([]byte, 32)
-		if _, err := io.ReadFull(c.config.rand(), hello.sessionID); err != nil {
-			c.sendAlert(alertInternalError)
-			return errors.New("tls: short read from Rand: " + err.Error())
-		}
-	}
-	if c.config.Bugs.MockQUICTransport != nil && !c.config.Bugs.CompatModeWithQUIC {
-		hello.sessionID = []byte{}
-	}
-
-	if c.config.Bugs.SendCipherSuites != nil {
-		hello.cipherSuites = c.config.Bugs.SendCipherSuites
-	}
-
-	var sendEarlyData bool
-	if len(hello.pskIdentities) > 0 && c.config.Bugs.SendEarlyData != nil {
-		hello.hasEarlyData = true
-		sendEarlyData = true
-	}
-	if c.config.Bugs.SendFakeEarlyDataLength > 0 {
-		hello.hasEarlyData = true
-	}
-	if c.config.Bugs.OmitEarlyDataExtension {
-		hello.hasEarlyData = false
-	}
-	if c.config.Bugs.SendClientHelloSessionID != nil {
-		hello.sessionID = c.config.Bugs.SendClientHelloSessionID
-	}
-
-	// PSK binders must be computed after the rest of the ClientHello is
-	// constructed.
-	if len(hello.pskIdentities) > 0 {
-		version := session.wireVersion
-		// We may have a pre-1.3 session if SendBothTickets is
-		// set.
-		if session.vers < VersionTLS13 {
-			version = VersionTLS13
-		}
-		generatePSKBinders(version, hello, session, nil, nil, c.config)
-	}
-
-	if c.config.Bugs.SendClientHelloWithFixes != nil {
-		hello, err = replaceClientHello(hello, c.config.Bugs.SendClientHelloWithFixes)
+		hs.innerHello, err = hs.createClientHello(nil, nil)
 		if err != nil {
 			return err
 		}
+		hs.hello, err = hs.createClientHello(hs.innerHello, echEnc)
+		if err != nil {
+			return err
+		}
+		earlyHello = hs.innerHello
+	} else {
+		hs.hello, err = hs.createClientHello(nil, nil)
+		if err != nil {
+			return err
+		}
+		earlyHello = hs.hello
+	}
+
+	if len(earlyHello.pskIdentities) == 0 || c.config.Bugs.SendEarlyData == nil {
+		earlyHello = nil
 	}
 
 	if c.config.Bugs.SendV2ClientHello {
-		hello.isV2ClientHello = true
+		hs.hello.isV2ClientHello = true
 
 		// The V2ClientHello "challenge" field is variable-length and is
 		// left-padded or truncated to become the SSL3/TLS random.
 		challengeLength := c.config.Bugs.V2ClientHelloChallengeLength
 		if challengeLength == 0 {
-			challengeLength = len(hello.random)
+			challengeLength = len(hs.hello.random)
 		}
-		if challengeLength <= len(hello.random) {
-			skip := len(hello.random) - challengeLength
+		if challengeLength <= len(hs.hello.random) {
+			skip := len(hs.hello.random) - challengeLength
 			for i := 0; i < skip; i++ {
-				hello.random[i] = 0
+				hs.hello.random[i] = 0
 			}
-			hello.v2Challenge = hello.random[skip:]
+			hs.hello.v2Challenge = hs.hello.random[skip:]
 		} else {
-			hello.v2Challenge = make([]byte, challengeLength)
-			copy(hello.v2Challenge, hello.random)
-			if _, err := io.ReadFull(c.config.rand(), hello.v2Challenge[len(hello.random):]); err != nil {
+			hs.hello.v2Challenge = make([]byte, challengeLength)
+			copy(hs.hello.v2Challenge, hs.hello.random)
+			if _, err := io.ReadFull(c.config.rand(), hs.hello.v2Challenge[len(hs.hello.random):]); err != nil {
 				c.sendAlert(alertInternalError)
 				return fmt.Errorf("tls: short read from Rand: %s", err)
 			}
 		}
 
-		c.writeV2Record(hello.marshal())
+		c.writeV2Record(hs.hello.marshal())
 	} else {
-		helloBytes := hello.marshal()
+		helloBytes := hs.hello.marshal()
 		var appendToHello byte
 		if c.config.Bugs.PartialClientFinishedWithClientHello {
 			appendToHello = typeFinished
@@ -523,10 +237,10 @@
 	}
 
 	// Derive early write keys and set Conn state to allow early writes.
-	if sendEarlyData {
+	if earlyHello != nil {
 		finishedHash := newFinishedHash(session.wireVersion, c.isDTLS, session.cipherSuite)
 		finishedHash.addEntropy(session.secret)
-		finishedHash.Write(hello.marshal())
+		finishedHash.Write(earlyHello.marshal())
 
 		if !c.config.Bugs.SkipChangeCipherSpec {
 			c.wireVersion = session.wireVersion
@@ -562,9 +276,9 @@
 				return errors.New("dtls: bad HelloVerifyRequest version")
 			}
 
-			hello.raw = nil
-			hello.cookie = helloVerifyRequest.cookie
-			c.writeRecord(recordTypeHandshake, hello.marshal())
+			hs.hello.raw = nil
+			hs.hello.cookie = helloVerifyRequest.cookie
+			c.writeRecord(recordTypeHandshake, hs.hello.marshal())
 			c.flushHandshake()
 
 			if err := c.simulatePacketLoss(nil); err != nil {
@@ -608,21 +322,26 @@
 		return errors.New("tls: server selected SSL 3.0")
 	}
 
-	suite := mutualCipherSuite(hello.cipherSuites, suiteID)
-	if suite == nil {
+	cipherSuites := hs.hello.cipherSuites
+	if hs.innerHello != nil && c.config.Bugs.MinimalClientHelloOuter {
+		// hs.hello has a placeholder list of ciphers if testing with
+		// MinimalClientHelloOuter, so we use hs.innerHello instead. (We do not
+		// attempt to support actual different cipher suite preferences between
+		// the two.)
+		cipherSuites = hs.innerHello.cipherSuites
+	}
+	hs.suite = mutualCipherSuite(cipherSuites, suiteID)
+	if hs.suite == nil {
 		c.sendAlert(alertHandshakeFailure)
 		return fmt.Errorf("tls: server selected an unsupported cipher suite")
 	}
 
-	hs := &clientHandshakeState{
-		c:            c,
-		hello:        hello,
-		suite:        suite,
-		finishedHash: newFinishedHash(c.wireVersion, c.isDTLS, suite),
-		keyShares:    keyShares,
-		session:      session,
+	hs.finishedHash = newFinishedHash(c.wireVersion, c.isDTLS, hs.suite)
+	hs.finishedHash.WriteHandshake(hs.hello.marshal(), hs.c.sendHandshakeSeq-1)
+	if hs.innerHello != nil {
+		hs.innerFinishedHash = newFinishedHash(c.wireVersion, c.isDTLS, hs.suite)
+		hs.innerFinishedHash.WriteHandshake(hs.innerHello.marshal(), hs.c.sendHandshakeSeq-1)
 	}
-	hs.finishedHash.WriteHandshake(hello.marshal(), hs.c.sendHandshakeSeq-1)
 
 	if c.vers >= VersionTLS13 {
 		if err := hs.doTLS13Handshake(msg); err != nil {
@@ -714,13 +433,469 @@
 	}
 
 	c.handshakeComplete = true
-	c.cipherSuite = suite
+	c.cipherSuite = hs.suite
 	copy(c.clientRandom[:], hs.hello.random)
 	copy(c.serverRandom[:], hs.serverHello.random)
 
 	return nil
 }
 
+func chooseECHCipherSuite(echConfig *ECHConfig, config *Config) (HPKECipherSuite, bool) {
+	if echConfig.KEM != hpke.X25519WithHKDFSHA256 {
+		return HPKECipherSuite{}, false
+	}
+
+	for _, wantSuite := range config.echCipherSuitePreferences() {
+		if config.Bugs.IgnoreECHConfigCipherPreferences {
+			return wantSuite, true
+		}
+		for _, cipherSuite := range echConfig.CipherSuites {
+			if cipherSuite == wantSuite {
+				return cipherSuite, true
+			}
+		}
+	}
+	return HPKECipherSuite{}, false
+}
+
+// createClientHello creates a new ClientHello message. If |innerHello| is not
+// nil, this is a ClientHelloOuter that should contain an encrypted |innerHello|
+// with |echEnc| as the encapsulated public key. Otherwise, the ClientHello
+// should reflect the connection's true preferences.
+func (hs *clientHandshakeState) createClientHello(innerHello *clientHelloMsg, echEnc []byte) (*clientHelloMsg, error) {
+	c := hs.c
+	nextProtosLength := 0
+	for _, proto := range c.config.NextProtos {
+		if l := len(proto); l > 255 {
+			return nil, errors.New("tls: invalid NextProtos value")
+		} else {
+			nextProtosLength += 1 + l
+		}
+	}
+	if nextProtosLength > 0xffff {
+		return nil, errors.New("tls: NextProtos values too large")
+	}
+
+	quicTransportParams := c.config.QUICTransportParams
+	quicTransportParamsLegacy := c.config.QUICTransportParams
+	if !c.config.QUICTransportParamsUseLegacyCodepoint.IncludeStandard() {
+		quicTransportParams = nil
+	}
+	if !c.config.QUICTransportParamsUseLegacyCodepoint.IncludeLegacy() {
+		quicTransportParamsLegacy = nil
+	}
+
+	isInner := innerHello == nil && hs.echHPKEContext != nil
+
+	minVersion := c.config.minVersion(c.isDTLS)
+	maxVersion := c.config.maxVersion(c.isDTLS)
+	// The ClientHelloInner may not offer TLS 1.2 or below.
+	requireTLS13 := isInner && !c.config.Bugs.AllowTLS12InClientHelloInner
+	if requireTLS13 && minVersion < VersionTLS13 {
+		minVersion = VersionTLS13
+		if minVersion > maxVersion {
+			return nil, errors.New("tls: ECH requires TLS 1.3")
+		}
+	}
+
+	hello := &clientHelloMsg{
+		isDTLS:                    c.isDTLS,
+		compressionMethods:        []uint8{compressionNone},
+		random:                    make([]byte, 32),
+		ocspStapling:              !c.config.Bugs.NoOCSPStapling,
+		sctListSupported:          !c.config.Bugs.NoSignedCertificateTimestamps,
+		supportedCurves:           c.config.curvePreferences(),
+		supportedPoints:           []uint8{pointFormatUncompressed},
+		nextProtoNeg:              len(c.config.NextProtos) > 0,
+		secureRenegotiation:       []byte{},
+		alpnProtocols:             c.config.NextProtos,
+		quicTransportParams:       quicTransportParams,
+		quicTransportParamsLegacy: quicTransportParamsLegacy,
+		duplicateExtension:        c.config.Bugs.DuplicateExtension,
+		channelIDSupported:        c.config.ChannelID != nil,
+		tokenBindingParams:        c.config.TokenBindingParams,
+		tokenBindingVersion:       c.config.TokenBindingVersion,
+		extendedMasterSecret:      maxVersion >= VersionTLS10,
+		srtpProtectionProfiles:    c.config.SRTPProtectionProfiles,
+		srtpMasterKeyIdentifier:   c.config.Bugs.SRTPMasterKeyIdentifer,
+		customExtension:           c.config.Bugs.CustomExtension,
+		omitExtensions:            c.config.Bugs.OmitExtensions,
+		emptyExtensions:           c.config.Bugs.EmptyExtensions,
+		delegatedCredentials:      !c.config.Bugs.DisableDelegatedCredentials,
+	}
+
+	// Translate the bugs that modify ClientHello extension order into a
+	// list of prefix extensions. The marshal function will try these
+	// extensions before any others, followed by any remaining extensions in
+	// the default order.
+	if innerHello != nil && c.config.Bugs.FirstExtensionInClientHelloOuter != 0 {
+		hello.prefixExtensions = append(hello.prefixExtensions, c.config.Bugs.FirstExtensionInClientHelloOuter)
+	}
+	if c.config.Bugs.PSKBinderFirst && !c.config.Bugs.OnlyCorruptSecondPSKBinder {
+		hello.prefixExtensions = append(hello.prefixExtensions, extensionPreSharedKey)
+	}
+	if c.config.Bugs.SwapNPNAndALPN {
+		hello.prefixExtensions = append(hello.prefixExtensions, extensionALPN)
+		hello.prefixExtensions = append(hello.prefixExtensions, extensionNextProtoNeg)
+	}
+	if isInner && len(c.config.ECHOuterExtensions) > 0 && !c.config.Bugs.OnlyCompressSecondClientHelloInner {
+		applyECHOuterExtensions(hello, c.config.ECHOuterExtensions)
+	}
+
+	if maxVersion >= VersionTLS13 {
+		hello.vers = mapClientHelloVersion(VersionTLS12, c.isDTLS)
+		if !c.config.Bugs.OmitSupportedVersions {
+			hello.supportedVersions = c.config.supportedVersions(c.isDTLS, requireTLS13)
+		}
+		hello.pskKEModes = []byte{pskDHEKEMode}
+	} else {
+		hello.vers = mapClientHelloVersion(maxVersion, c.isDTLS)
+	}
+
+	if c.config.Bugs.SendClientVersion != 0 {
+		hello.vers = c.config.Bugs.SendClientVersion
+	}
+
+	if len(c.config.Bugs.SendSupportedVersions) > 0 {
+		hello.supportedVersions = c.config.Bugs.SendSupportedVersions
+	}
+
+	if innerHello != nil {
+		hello.serverName = c.config.ClientECHConfig.PublicName
+	} else {
+		hello.serverName = c.config.ServerName
+	}
+
+	disableEMS := c.config.Bugs.NoExtendedMasterSecret
+	if c.cipherSuite != nil {
+		disableEMS = c.config.Bugs.NoExtendedMasterSecretOnRenegotiation
+	}
+
+	if disableEMS {
+		hello.extendedMasterSecret = false
+	}
+
+	if c.config.Bugs.NoSupportedCurves {
+		hello.supportedCurves = nil
+	}
+
+	if c.config.Bugs.SendPSKKeyExchangeModes != nil {
+		hello.pskKEModes = c.config.Bugs.SendPSKKeyExchangeModes
+	}
+
+	if c.config.Bugs.SendCompressionMethods != nil {
+		hello.compressionMethods = c.config.Bugs.SendCompressionMethods
+	}
+
+	if c.config.Bugs.SendSupportedPointFormats != nil {
+		hello.supportedPoints = c.config.Bugs.SendSupportedPointFormats
+	}
+
+	if len(c.clientVerify) > 0 && !c.config.Bugs.EmptyRenegotiationInfo {
+		if c.config.Bugs.BadRenegotiationInfo {
+			hello.secureRenegotiation = append(hello.secureRenegotiation, c.clientVerify...)
+			hello.secureRenegotiation[0] ^= 0x80
+		} else {
+			hello.secureRenegotiation = c.clientVerify
+		}
+	}
+
+	if c.config.Bugs.DuplicateCompressedCertAlgs {
+		hello.compressedCertAlgs = []uint16{1, 1}
+	} else if len(c.config.CertCompressionAlgs) > 0 {
+		hello.compressedCertAlgs = make([]uint16, 0, len(c.config.CertCompressionAlgs))
+		for id := range c.config.CertCompressionAlgs {
+			hello.compressedCertAlgs = append(hello.compressedCertAlgs, uint16(id))
+		}
+	}
+
+	if c.noRenegotiationInfo() {
+		hello.secureRenegotiation = nil
+	}
+
+	for protocol := range c.config.ApplicationSettings {
+		hello.alpsProtocols = append(hello.alpsProtocols, protocol)
+	}
+
+	if maxVersion >= VersionTLS13 {
+		// Use the same key shares between ClientHelloInner and ClientHelloOuter.
+		if innerHello != nil {
+			hello.hasKeyShares = innerHello.hasKeyShares
+			hello.keyShares = innerHello.keyShares
+		} else {
+			hello.hasKeyShares = true
+			hello.trailingKeyShareData = c.config.Bugs.TrailingKeyShareData
+			curvesToSend := c.config.defaultCurves()
+			for _, curveID := range hello.supportedCurves {
+				if !curvesToSend[curveID] {
+					continue
+				}
+				curve, ok := curveForCurveID(curveID, c.config)
+				if !ok {
+					continue
+				}
+				publicKey, err := curve.offer(c.config.rand())
+				if err != nil {
+					return nil, err
+				}
+
+				if c.config.Bugs.SendCurve != 0 {
+					curveID = c.config.Bugs.SendCurve
+				}
+				if c.config.Bugs.InvalidECDHPoint {
+					publicKey[0] ^= 0xff
+				}
+
+				hello.keyShares = append(hello.keyShares, keyShareEntry{
+					group:       curveID,
+					keyExchange: publicKey,
+				})
+				hs.keyShares[curveID] = curve
+
+				if c.config.Bugs.DuplicateKeyShares {
+					hello.keyShares = append(hello.keyShares, hello.keyShares[len(hello.keyShares)-1])
+				}
+			}
+
+			if c.config.Bugs.MissingKeyShare {
+				hello.hasKeyShares = false
+			}
+		}
+	}
+
+	possibleCipherSuites := c.config.cipherSuites()
+	hello.cipherSuites = make([]uint16, 0, len(possibleCipherSuites))
+
+NextCipherSuite:
+	for _, suiteID := range possibleCipherSuites {
+		for _, suite := range cipherSuites {
+			if suite.id != suiteID {
+				continue
+			}
+			// Don't advertise TLS 1.2-only cipher suites unless
+			// we're attempting TLS 1.2.
+			if maxVersion < VersionTLS12 && suite.flags&suiteTLS12 != 0 {
+				continue
+			}
+			hello.cipherSuites = append(hello.cipherSuites, suiteID)
+			continue NextCipherSuite
+		}
+	}
+
+	if c.config.Bugs.AdvertiseAllConfiguredCiphers {
+		hello.cipherSuites = possibleCipherSuites
+	}
+
+	if c.config.Bugs.SendRenegotiationSCSV {
+		hello.cipherSuites = append(hello.cipherSuites, renegotiationSCSV)
+	}
+
+	if c.config.Bugs.SendFallbackSCSV {
+		hello.cipherSuites = append(hello.cipherSuites, fallbackSCSV)
+	}
+
+	_, err := io.ReadFull(c.config.rand(), hello.random)
+	if err != nil {
+		c.sendAlert(alertInternalError)
+		return nil, errors.New("tls: short read from Rand: " + err.Error())
+	}
+
+	if maxVersion >= VersionTLS12 && !c.config.Bugs.NoSignatureAlgorithms {
+		hello.signatureAlgorithms = c.config.verifySignatureAlgorithms()
+	}
+
+	if c.config.ClientSessionCache != nil {
+		hello.ticketSupported = !c.config.SessionTicketsDisabled
+	}
+
+	session := hs.session
+
+	// ClientHelloOuter cannot offer sessions.
+	if innerHello != nil && !c.config.Bugs.OfferSessionInClientHelloOuter {
+		session = nil
+	}
+
+	if session != nil && c.config.time().Before(session.ticketExpiration) {
+		ticket := session.sessionTicket
+		if c.config.Bugs.FilterTicket != nil && len(ticket) > 0 {
+			// Copy the ticket so FilterTicket may act in-place.
+			ticket = make([]byte, len(session.sessionTicket))
+			copy(ticket, session.sessionTicket)
+
+			ticket, err = c.config.Bugs.FilterTicket(ticket)
+			if err != nil {
+				return nil, err
+			}
+		}
+
+		if session.vers >= VersionTLS13 || c.config.Bugs.SendBothTickets {
+			// TODO(nharper): Support sending more
+			// than one PSK identity.
+			ticketAge := uint32(c.config.time().Sub(session.ticketCreationTime) / time.Millisecond)
+			if c.config.Bugs.SendTicketAge != 0 {
+				ticketAge = uint32(c.config.Bugs.SendTicketAge / time.Millisecond)
+			}
+			psk := pskIdentity{
+				ticket:              ticket,
+				obfuscatedTicketAge: session.ticketAgeAdd + ticketAge,
+			}
+			hello.pskIdentities = []pskIdentity{psk}
+
+			if c.config.Bugs.ExtraPSKIdentity {
+				hello.pskIdentities = append(hello.pskIdentities, psk)
+			}
+		}
+
+		if session.vers < VersionTLS13 || c.config.Bugs.SendBothTickets {
+			if ticket != nil {
+				hello.sessionTicket = ticket
+				// A random session ID is used to detect when the
+				// server accepted the ticket and is resuming a session
+				// (see RFC 5077).
+				sessionIDLen := 16
+				if c.config.Bugs.TicketSessionIDLength != 0 {
+					sessionIDLen = c.config.Bugs.TicketSessionIDLength
+				}
+				if c.config.Bugs.EmptyTicketSessionID {
+					sessionIDLen = 0
+				}
+				hello.sessionID = make([]byte, sessionIDLen)
+				if _, err := io.ReadFull(c.config.rand(), hello.sessionID); err != nil {
+					c.sendAlert(alertInternalError)
+					return nil, errors.New("tls: short read from Rand: " + err.Error())
+				}
+			} else {
+				hello.sessionID = session.sessionID
+			}
+		}
+	}
+
+	if innerHello == nil {
+		// Request compatibility mode from the client by sending a fake session
+		// ID. Although BoringSSL always enables compatibility mode, other
+		// implementations make it conditional on the ClientHello. We test
+		// BoringSSL's expected behavior with SendClientHelloSessionID.
+		if len(hello.sessionID) == 0 && maxVersion >= VersionTLS13 {
+			hello.sessionID = make([]byte, 32)
+			if _, err := io.ReadFull(c.config.rand(), hello.sessionID); err != nil {
+				c.sendAlert(alertInternalError)
+				return nil, errors.New("tls: short read from Rand: " + err.Error())
+			}
+		}
+		if c.config.Bugs.MockQUICTransport != nil && !c.config.Bugs.CompatModeWithQUIC {
+			hello.sessionID = []byte{}
+		}
+		if c.config.Bugs.SendClientHelloSessionID != nil {
+			hello.sessionID = c.config.Bugs.SendClientHelloSessionID
+		}
+	} else {
+		// ClientHelloOuter's session ID is copied from ClientHelloINnner.
+		hello.sessionID = innerHello.sessionID
+	}
+
+	if c.config.Bugs.SendCipherSuites != nil {
+		hello.cipherSuites = c.config.Bugs.SendCipherSuites
+	}
+
+	if innerHello == nil {
+		if len(hello.pskIdentities) > 0 && c.config.Bugs.SendEarlyData != nil {
+			hello.hasEarlyData = true
+		}
+		if c.config.Bugs.SendFakeEarlyDataLength > 0 {
+			hello.hasEarlyData = true
+		}
+		if c.config.Bugs.OmitEarlyDataExtension {
+			hello.hasEarlyData = false
+		}
+	} else {
+		hello.hasEarlyData = innerHello.hasEarlyData
+	}
+
+	if (isInner && !c.config.Bugs.OmitECHIsInner) || c.config.Bugs.AlwaysSendECHIsInner {
+		hello.echIsInner = []byte{}
+		if len(c.config.Bugs.SendInvalidECHIsInner) != 0 {
+			hello.echIsInner = c.config.Bugs.SendInvalidECHIsInner
+		}
+	}
+
+	if innerHello != nil {
+		hash, err := hpke.GetHKDFHash(hs.echHPKEContext.KDF())
+		if err != nil {
+			return nil, err
+		}
+		if err := hs.encryptClientHello(hello, innerHello, c.config.ClientECHConfig.configID(hash), echEnc); err != nil {
+			return nil, err
+		}
+		if c.config.Bugs.CorruptEncryptedClientHello {
+			hello.clientECH.payload[0] ^= 1
+		}
+	}
+
+	// PSK binders and ECH both must be computed last because they incorporate
+	// the rest of the ClientHello and conflict. ECH resolves this by forbidding
+	// clients from offering PSKs on ClientHelloOuter, but we still need to test
+	// servers handle it correctly so they tolerate GREASE. In other cases, we
+	// expect the server to reject ECH, so we put PSK last. Note this renders
+	// ECH undecryptable.
+	if len(hello.pskIdentities) > 0 {
+		version := session.wireVersion
+		// We may have a pre-1.3 session if SendBothTickets is set.
+		if session.vers < VersionTLS13 {
+			version = VersionTLS13
+		}
+		generatePSKBinders(version, hello, session, nil, nil, c.config)
+	}
+
+	if c.config.Bugs.SendClientHelloWithFixes != nil {
+		hello, err = replaceClientHello(hello, c.config.Bugs.SendClientHelloWithFixes)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return hello, nil
+}
+
+// encryptClientHello encrypts |innerHello| using the specified HPKE context and
+// adds the extension to |hello|.
+func (hs *clientHandshakeState) encryptClientHello(hello, innerHello *clientHelloMsg, configID, enc []byte) error {
+	c := hs.c
+
+	if c.config.Bugs.MinimalClientHelloOuter {
+		*hello = clientHelloMsg{
+			vers:               VersionTLS12,
+			random:             hello.random,
+			sessionID:          hello.sessionID,
+			cipherSuites:       []uint16{0x0a0a},
+			compressionMethods: hello.compressionMethods,
+		}
+	}
+
+	if c.config.Bugs.TruncateClientECHEnc {
+		enc = enc[:1]
+	}
+
+	aad := newByteBuilder()
+	aad.addU16(hs.echHPKEContext.KDF())
+	aad.addU16(hs.echHPKEContext.AEAD())
+	aad.addU8LengthPrefixed().addBytes(configID)
+	aad.addU16LengthPrefixed().addBytes(enc)
+	hello.marshalForOuterAAD(aad.addU24LengthPrefixed())
+
+	payload := hs.echHPKEContext.Seal(innerHello.marshalForEncodedInner(), aad.finish())
+
+	// Place the ECH extension in the outer CH.
+	hello.clientECH = &clientECH{
+		hpkeKDF:  hs.echHPKEContext.KDF(),
+		hpkeAEAD: hs.echHPKEContext.AEAD(),
+		configID: configID,
+		enc:      enc,
+		payload:  payload,
+	}
+
+	return nil
+}
+
 func (hs *clientHandshakeState) doTLS13Handshake(msg interface{}) error {
 	c := hs.c
 
@@ -735,6 +910,10 @@
 	if haveHelloRetryRequest {
 		hs.finishedHash.UpdateForHelloRetryRequest()
 		hs.writeServerHash(helloRetryRequest.marshal())
+		if hs.innerHello != nil {
+			hs.innerFinishedHash.UpdateForHelloRetryRequest()
+			hs.innerFinishedHash.WriteHandshake(helloRetryRequest.marshal(), c.recvHandshakeSeq-1)
+		}
 
 		if c.config.Bugs.FailIfHelloRetryRequested {
 			return errors.New("tls: unexpected HelloRetryRequest")
@@ -748,60 +927,18 @@
 		// Reset the encryption state, in case we sent 0-RTT data.
 		c.out.resetCipher()
 
-		firstHelloBytes := hs.hello.marshal()
-		if len(helloRetryRequest.cookie) > 0 {
-			hs.hello.tls13Cookie = helloRetryRequest.cookie
-		}
-
-		if c.config.Bugs.MisinterpretHelloRetryRequestCurve != 0 {
-			helloRetryRequest.hasSelectedGroup = true
-			helloRetryRequest.selectedGroup = c.config.Bugs.MisinterpretHelloRetryRequestCurve
-		}
-		if helloRetryRequest.hasSelectedGroup {
-			var hrrCurveFound bool
-			group := helloRetryRequest.selectedGroup
-			for _, curveID := range hs.hello.supportedCurves {
-				if group == curveID {
-					hrrCurveFound = true
-					break
-				}
-			}
-			if !hrrCurveFound || hs.keyShares[group] != nil {
-				c.sendAlert(alertHandshakeFailure)
-				return errors.New("tls: received invalid HelloRetryRequest")
-			}
-			curve, ok := curveForCurveID(group, c.config)
-			if !ok {
-				return errors.New("tls: Unable to get curve requested in HelloRetryRequest")
-			}
-			publicKey, err := curve.offer(c.config.rand())
-			if err != nil {
+		if hs.innerHello != nil {
+			if err := hs.applyHelloRetryRequest(helloRetryRequest, hs.innerHello, nil); err != nil {
 				return err
 			}
-			hs.keyShares[group] = curve
-			hs.hello.keyShares = []keyShareEntry{{
-				group:       group,
-				keyExchange: publicKey,
-			}}
-		}
-
-		if c.config.Bugs.SecondClientHelloMissingKeyShare {
-			hs.hello.hasKeyShares = false
-		}
-
-		hs.hello.hasEarlyData = c.config.Bugs.SendEarlyDataOnSecondClientHello
-		// The first ClientHello may have skipped this due to OnlyCorruptSecondPSKBinder.
-		if c.config.Bugs.PSKBinderFirst && c.config.Bugs.OnlyCorruptSecondPSKBinder {
-			hs.hello.prefixExtensions = append(hs.hello.prefixExtensions, extensionPreSharedKey)
-		}
-		if c.config.Bugs.OmitPSKsOnSecondClientHello {
-			hs.hello.pskIdentities = nil
-			hs.hello.pskBinders = nil
-		}
-		hs.hello.raw = nil
-
-		if len(hs.hello.pskIdentities) > 0 {
-			generatePSKBinders(c.wireVersion, hs.hello, hs.session, firstHelloBytes, helloRetryRequest.marshal(), c.config)
+			if err := hs.applyHelloRetryRequest(helloRetryRequest, hs.hello, hs.innerHello); err != nil {
+				return err
+			}
+			hs.innerFinishedHash.WriteHandshake(hs.innerHello.marshal(), c.sendHandshakeSeq)
+		} else {
+			if err := hs.applyHelloRetryRequest(helloRetryRequest, hs.hello, nil); err != nil {
+				return err
+			}
 		}
 		hs.writeClientHash(hs.hello.marshal())
 		toWrite := hs.hello.marshal()
@@ -862,9 +999,9 @@
 		return errors.New("tls: session IDs did not match.")
 	}
 
-	zeroSecret := hs.finishedHash.zeroSecret()
-
 	// Resolve PSK and compute the early secret.
+	zeroSecret := hs.finishedHash.zeroSecret()
+	pskSecret := zeroSecret
 	if hs.serverHello.hasPSKIdentity {
 		// We send at most one PSK identity.
 		if hs.session == nil || hs.serverHello.pskIdentity != 0 {
@@ -875,10 +1012,12 @@
 			c.sendAlert(alertHandshakeFailure)
 			return errors.New("tls: server resumed an invalid session for the cipher suite")
 		}
-		hs.finishedHash.addEntropy(hs.session.secret)
+		pskSecret = hs.session.secret
 		c.didResume = true
-	} else {
-		hs.finishedHash.addEntropy(zeroSecret)
+	}
+	hs.finishedHash.addEntropy(pskSecret)
+	if hs.innerHello != nil {
+		hs.innerFinishedHash.addEntropy(pskSecret)
 	}
 
 	if !hs.serverHello.hasKeyShare {
@@ -887,6 +1026,7 @@
 	}
 
 	// Resolve ECDHE and compute the handshake secret.
+	ecdheSecret := zeroSecret
 	if !c.config.Bugs.MissingKeyShare && !c.config.Bugs.SecondClientHelloMissingKeyShare {
 		curve, ok := hs.keyShares[hs.serverHello.keyShare.group]
 		if !ok {
@@ -895,28 +1035,45 @@
 		}
 		c.curveID = hs.serverHello.keyShare.group
 
-		ecdheSecret, err := curve.finish(hs.serverHello.keyShare.keyExchange)
+		var err error
+		ecdheSecret, err = curve.finish(hs.serverHello.keyShare.keyExchange)
 		if err != nil {
 			return err
 		}
-		hs.finishedHash.nextSecret()
-		hs.finishedHash.addEntropy(ecdheSecret)
+	}
+	hs.finishedHash.nextSecret()
+	hs.finishedHash.addEntropy(ecdheSecret)
+	if hs.innerHello != nil {
+		hs.innerFinishedHash.nextSecret()
+		hs.innerFinishedHash.addEntropy(ecdheSecret)
+	}
+
+	// Determine whether the server accepted ECH.
+	confirmHash := &hs.finishedHash
+	if hs.innerHello != nil {
+		confirmHash = &hs.innerFinishedHash
+	}
+	echConfirmed := bytes.Equal(hs.serverHello.random[24:], confirmHash.deriveSecretPeek([]byte("ech accept confirmation"), hs.serverHello.marshalForECHConf())[:8])
+	if hs.innerHello != nil {
+		c.echAccepted = echConfirmed
+		if c.echAccepted {
+			hs.hello = hs.innerHello
+			hs.finishedHash = hs.innerFinishedHash
+		}
+		hs.innerHello = nil
+		hs.innerFinishedHash = finishedHash{}
 	} else {
-		hs.finishedHash.nextSecret()
-		hs.finishedHash.addEntropy(zeroSecret)
-	}
-
-	// Determine whether the server indicated ECH acceptance.
-
-	// Generate ServerHelloECHConf, which is identical to the ServerHello except
-	// that the last 8 bytes of the random value are zeroes.
-	echAcceptConfirmation := hs.finishedHash.deriveSecretPeek([]byte("ech accept confirmation"), hs.serverHello.marshalForECHConf())
-	serverAcceptedECH := bytes.Equal(echAcceptConfirmation[:8], hs.serverHello.random[24:])
-	if c.config.Bugs.ExpectServerAcceptECH && !serverAcceptedECH {
-		return errors.New("tls: server did not indicate ECH acceptance")
-	}
-	if !c.config.Bugs.ExpectServerAcceptECH && serverAcceptedECH {
-		return errors.New("tls: server indicated ECH acceptance")
+		// When not offering ECH, we may still expect a confirmation signal to
+		// test the backend server behavior.
+		if hs.hello.echIsInner != nil {
+			if !echConfirmed {
+				return errors.New("tls: server did not send ECH confirmation when requested")
+			}
+		} else {
+			if echConfirmed {
+				return errors.New("tls: server did sent ECH confirmation when not requested")
+			}
+		}
 	}
 
 	hs.writeServerHash(hs.serverHello.marshal())
@@ -941,6 +1098,10 @@
 	}
 	hs.writeServerHash(encryptedExtensions.marshal())
 
+	if !bytes.Equal(encryptedExtensions.extensions.echRetryConfigs, c.config.Bugs.ExpectECHRetryConfigs) {
+		return errors.New("tls: server sent ECH retry_configs with unexpected contents")
+	}
+
 	err = hs.processServerExtensions(&encryptedExtensions.extensions)
 	if err != nil {
 		return err
@@ -1276,6 +1437,108 @@
 	return nil
 }
 
+// applyHelloRetryRequest updates |hello| in-place based on |helloRetryRequest|.
+// If |innerHello| is not nil, this is the second ClientHelloOuter and should
+// contain an encrypted copy of |innerHello|
+func (hs *clientHandshakeState) applyHelloRetryRequest(helloRetryRequest *helloRetryRequestMsg, hello, innerHello *clientHelloMsg) error {
+	c := hs.c
+	firstHelloBytes := hello.marshal()
+	isInner := innerHello == nil && hs.echHPKEContext != nil
+	if len(helloRetryRequest.cookie) > 0 {
+		hello.tls13Cookie = helloRetryRequest.cookie
+	}
+
+	if innerHello != nil {
+		hello.keyShares = innerHello.keyShares
+	} else {
+		if c.config.Bugs.MisinterpretHelloRetryRequestCurve != 0 {
+			helloRetryRequest.hasSelectedGroup = true
+			helloRetryRequest.selectedGroup = c.config.Bugs.MisinterpretHelloRetryRequestCurve
+		}
+		if helloRetryRequest.hasSelectedGroup {
+			var hrrCurveFound bool
+			group := helloRetryRequest.selectedGroup
+			for _, curveID := range hello.supportedCurves {
+				if group == curveID {
+					hrrCurveFound = true
+					break
+				}
+			}
+			if !hrrCurveFound || hs.keyShares[group] != nil {
+				c.sendAlert(alertHandshakeFailure)
+				return errors.New("tls: received invalid HelloRetryRequest")
+			}
+			curve, ok := curveForCurveID(group, c.config)
+			if !ok {
+				return errors.New("tls: Unable to get curve requested in HelloRetryRequest")
+			}
+			publicKey, err := curve.offer(c.config.rand())
+			if err != nil {
+				return err
+			}
+			hs.keyShares[group] = curve
+			hello.keyShares = []keyShareEntry{{
+				group:       group,
+				keyExchange: publicKey,
+			}}
+		}
+
+		if c.config.Bugs.SecondClientHelloMissingKeyShare {
+			hello.hasKeyShares = false
+		}
+	}
+
+	if isInner && c.config.Bugs.OmitSecondECHIsInner {
+		hello.echIsInner = nil
+	}
+
+	hello.hasEarlyData = c.config.Bugs.SendEarlyDataOnSecondClientHello
+	// The first ClientHello may have skipped this due to OnlyCorruptSecondPSKBinder.
+	if c.config.Bugs.PSKBinderFirst && c.config.Bugs.OnlyCorruptSecondPSKBinder {
+		hello.prefixExtensions = append(hello.prefixExtensions, extensionPreSharedKey)
+	}
+	// The first ClientHello may have skipped this due to OnlyCompressSecondClientHelloInner.
+	if isInner && len(c.config.ECHOuterExtensions) > 0 && c.config.Bugs.OnlyCompressSecondClientHelloInner {
+		applyECHOuterExtensions(hello, c.config.ECHOuterExtensions)
+	}
+	if c.config.Bugs.OmitPSKsOnSecondClientHello {
+		hello.pskIdentities = nil
+		hello.pskBinders = nil
+	}
+	hello.raw = nil
+
+	if innerHello != nil {
+		if c.config.Bugs.OmitSecondEncryptedClientHello {
+			hello.clientECH = nil
+		} else {
+			if err := hs.encryptClientHello(hello, innerHello, nil, nil); err != nil {
+				return err
+			}
+			if c.config.Bugs.CorruptSecondEncryptedClientHello {
+				hello.clientECH.payload[0] ^= 1
+			}
+		}
+	}
+
+	// PSK binders and ECH both must be inserted last because they incorporate
+	// the rest of the ClientHello and conflict. See corresponding comment in
+	// |createClientHello|.
+	if len(hello.pskIdentities) > 0 {
+		generatePSKBinders(c.wireVersion, hello, hs.session, firstHelloBytes, helloRetryRequest.marshal(), c.config)
+	}
+	return nil
+}
+
+// applyECHOuterExtensions updates |hello| to compress |outerExtensions| with
+// the ech_outer_extensions mechanism.
+func applyECHOuterExtensions(hello *clientHelloMsg, outerExtensions []uint16) {
+	// Ensure that the ech_outer_extensions extension and each of the
+	// extensions it names are serialized consecutively.
+	hello.prefixExtensions = append(hello.prefixExtensions, extensionECHOuterExtensions)
+	hello.prefixExtensions = append(hello.prefixExtensions, outerExtensions...)
+	hello.outerExtensions = outerExtensions
+}
+
 func (hs *clientHandshakeState) doFullHandshake() error {
 	c := hs.c
 
diff --git a/ssl/test/runner/handshake_messages.go b/ssl/test/runner/handshake_messages.go
index 158f7c7..aee47a2 100644
--- a/ssl/test/runner/handshake_messages.go
+++ b/ssl/test/runner/handshake_messages.go
@@ -5,8 +5,11 @@
 package runner
 
 import (
+	"crypto"
 	"encoding/binary"
 	"fmt"
+
+	"golang.org/x/crypto/hkdf"
 )
 
 func writeLen(buf []byte, v, size int) {
@@ -262,8 +265,7 @@
 	MaxNameLen   uint16
 }
 
-func MarshalECHConfig(e *ECHConfig) []byte {
-	bb := newByteBuilder()
+func (e *ECHConfig) marshal(bb *byteBuilder) {
 	// ECHConfig's wire format reuses the encrypted_client_hello extension
 	// codepoint as a version identifier.
 	bb.addU16(extensionEncryptedClientHello)
@@ -278,9 +280,33 @@
 	}
 	contents.addU16(e.MaxNameLen)
 	contents.addU16(0) // Empty extensions field
+}
+
+func MarshalECHConfig(e *ECHConfig) []byte {
+	bb := newByteBuilder()
+	e.marshal(bb)
 	return bb.finish()
 }
 
+func MarshalECHConfigList(configs ...*ECHConfig) []byte {
+	bb := newByteBuilder()
+	list := bb.addU16LengthPrefixed()
+	for _, config := range configs {
+		config.marshal(list)
+	}
+	return bb.finish()
+}
+
+func (e *ECHConfig) configID(h crypto.Hash) []byte {
+	configIDLength := 8
+	idReader := hkdf.Expand(h.New, hkdf.Extract(h.New, MarshalECHConfig(e), nil), []byte("tls ech config id"))
+	idBytes := make([]byte, configIDLength)
+	if n, err := idReader.Read(idBytes); err != nil || n != configIDLength {
+		panic("failed to compute configID for ECHConfig")
+	}
+	return idBytes
+}
+
 // The contents of a CH "encrypted_client_hello" extension.
 // https://tools.ietf.org/html/draft-ietf-tls-esni-09
 type clientECH struct {
@@ -343,6 +369,7 @@
 	compressedCertAlgs        []uint16
 	delegatedCredentials      bool
 	alpsProtocols             []string
+	outerExtensions           []uint16
 	prefixExtensions          []uint16
 }
 
@@ -358,34 +385,21 @@
 	}
 }
 
-func (m *clientHelloMsg) marshal() []byte {
-	if m.raw != nil {
-		return m.raw
-	}
+type clientHelloType int
 
-	if m.isV2ClientHello {
-		v2Msg := newByteBuilder()
-		v2Msg.addU8(1)
-		v2Msg.addU16(m.vers)
-		v2Msg.addU16(uint16(len(m.cipherSuites) * 3))
-		v2Msg.addU16(uint16(len(m.sessionID)))
-		v2Msg.addU16(uint16(len(m.v2Challenge)))
-		for _, spec := range m.cipherSuites {
-			v2Msg.addU24(int(spec))
-		}
-		v2Msg.addBytes(m.sessionID)
-		v2Msg.addBytes(m.v2Challenge)
-		m.raw = v2Msg.finish()
-		return m.raw
-	}
+const (
+	clientHelloNormal clientHelloType = iota
+	clientHelloOuterAAD
+	clientHelloEncodedInner
+)
 
-	handshakeMsg := newByteBuilder()
-	handshakeMsg.addU8(typeClientHello)
-	hello := handshakeMsg.addU24LengthPrefixed()
+func (m *clientHelloMsg) marshalBody(hello *byteBuilder, typ clientHelloType) {
 	hello.addU16(m.vers)
 	hello.addBytes(m.random)
 	sessionID := hello.addU8LengthPrefixed()
-	sessionID.addBytes(m.sessionID)
+	if typ != clientHelloEncodedInner {
+		sessionID.addBytes(m.sessionID)
+	}
 	if m.isDTLS {
 		cookie := hello.addU8LengthPrefixed()
 		cookie.addBytes(m.cookie)
@@ -441,7 +455,7 @@
 			body: serverNameList.finish(),
 		})
 	}
-	if m.clientECH != nil {
+	if m.clientECH != nil && typ != clientHelloOuterAAD {
 		body := newByteBuilder()
 		body.addU16(m.clientECH.hpkeKDF)
 		body.addU16(m.clientECH.hpkeAEAD)
@@ -460,6 +474,17 @@
 			body: m.echIsInner,
 		})
 	}
+	if m.outerExtensions != nil && typ == clientHelloEncodedInner {
+		body := newByteBuilder()
+		extensionsList := body.addU8LengthPrefixed()
+		for _, extID := range m.outerExtensions {
+			extensionsList.addU16(extID)
+		}
+		extensions = append(extensions, extension{
+			id:   extensionECHOuterExtensions,
+			body: body.finish(),
+		})
+	}
 	if m.ocspStapling {
 		certificateStatusRequest := newByteBuilder()
 		// RFC 4366, section 3.6
@@ -700,6 +725,12 @@
 	for _, ext := range extensions {
 		extMap[ext.id] = ext.body
 	}
+	// Elide each of the extensions named by |m.outerExtensions|.
+	if m.outerExtensions != nil && typ == clientHelloEncodedInner {
+		for _, extID := range m.outerExtensions {
+			delete(extMap, extID)
+		}
+	}
 	// Write each of the prefix extensions, if we have it.
 	for _, extID := range m.prefixExtensions {
 		if body, ok := extMap[extID]; ok {
@@ -738,7 +769,43 @@
 			hello.addU16(0)
 		}
 	}
+}
 
+func (m *clientHelloMsg) marshalForOuterAAD(bb *byteBuilder) {
+	m.marshalBody(bb, clientHelloOuterAAD)
+}
+
+func (m *clientHelloMsg) marshalForEncodedInner() []byte {
+	hello := newByteBuilder()
+	m.marshalBody(hello, clientHelloEncodedInner)
+	return hello.finish()
+}
+
+func (m *clientHelloMsg) marshal() []byte {
+	if m.raw != nil {
+		return m.raw
+	}
+
+	if m.isV2ClientHello {
+		v2Msg := newByteBuilder()
+		v2Msg.addU8(1)
+		v2Msg.addU16(m.vers)
+		v2Msg.addU16(uint16(len(m.cipherSuites) * 3))
+		v2Msg.addU16(uint16(len(m.sessionID)))
+		v2Msg.addU16(uint16(len(m.v2Challenge)))
+		for _, spec := range m.cipherSuites {
+			v2Msg.addU24(int(spec))
+		}
+		v2Msg.addBytes(m.sessionID)
+		v2Msg.addBytes(m.v2Challenge)
+		m.raw = v2Msg.finish()
+		return m.raw
+	}
+
+	handshakeMsg := newByteBuilder()
+	handshakeMsg.addU8(typeClientHello)
+	hello := handshakeMsg.addU24LengthPrefixed()
+	m.marshalBody(hello, clientHelloNormal)
 	m.raw = handshakeMsg.finish()
 	// Sanity-check padding.
 	if m.pad != 0 && (len(m.raw)-4)%m.pad != 0 {
@@ -1529,9 +1596,7 @@
 	}
 	if len(m.echRetryConfigs) > 0 {
 		extensions.addU16(extensionEncryptedClientHello)
-		body := extensions.addU16LengthPrefixed()
-		echConfigs := body.addU16LengthPrefixed()
-		echConfigs.addBytes(m.echRetryConfigs)
+		extensions.addU16LengthPrefixed().addBytes(m.echRetryConfigs)
 	}
 }
 
@@ -1647,21 +1712,23 @@
 			m.hasApplicationSettings = true
 			m.applicationSettings = body
 		case extensionEncryptedClientHello:
+			if version < VersionTLS13 {
+				return false
+			}
+			m.echRetryConfigs = body
+
+			// Validate the ECHConfig with a top-level parse.
 			var echConfigs byteReader
 			if !body.readU16LengthPrefixed(&echConfigs) {
 				return false
 			}
 			for len(echConfigs) > 0 {
-				// Validate the ECHConfig with a top-level parse.
-				echConfigReader := echConfigs
 				var version uint16
 				var contents byteReader
-				if !echConfigReader.readU16(&version) ||
-					!echConfigReader.readU16LengthPrefixed(&contents) {
+				if !echConfigs.readU16(&version) ||
+					!echConfigs.readU16LengthPrefixed(&contents) {
 					return false
 				}
-
-				m.echRetryConfigs = contents
 			}
 			if len(body) > 0 {
 				return false
diff --git a/ssl/test/runner/hpke/hpke.go b/ssl/test/runner/hpke/hpke.go
index 71cf1e7..548c98e 100644
--- a/ssl/test/runner/hpke/hpke.go
+++ b/ssl/test/runner/hpke/hpke.go
@@ -18,10 +18,12 @@
 package hpke
 
 import (
+	"crypto"
 	"crypto/aes"
 	"crypto/cipher"
 	"encoding/binary"
 	"errors"
+	"fmt"
 
 	"golang.org/x/crypto/chacha20poly1305"
 )
@@ -51,6 +53,20 @@
 	hpkeModePSK  uint8 = 1
 )
 
+// GetHKDFHash returns the crypto.Hash that corresponds to kdf. If kdf is not
+// one the supported KDF IDs, returns an error.
+func GetHKDFHash(kdf uint16) (crypto.Hash, error) {
+	switch kdf {
+	case HKDFSHA256:
+		return crypto.SHA256, nil
+	case HKDFSHA384:
+		return crypto.SHA384, nil
+	case HKDFSHA512:
+		return crypto.SHA512, nil
+	}
+	return 0, fmt.Errorf("unknown KDF: %d", kdf)
+}
+
 type GenerateKeyPairFunc func() (public []byte, secret []byte, e error)
 
 // Context holds the HPKE state for a sender or a receiver.
@@ -113,6 +129,12 @@
 	return context, nil
 }
 
+func (c *Context) KEM() uint16 { return c.kemID }
+
+func (c *Context) KDF() uint16 { return c.kdfID }
+
+func (c *Context) AEAD() uint16 { return c.aeadID }
+
 func (c *Context) Seal(plaintext, additionalData []byte) []byte {
 	ciphertext := c.aead.Seal(nil, c.computeNonce(), plaintext, additionalData)
 	c.incrementSeq()
diff --git a/ssl/test/runner/runner.go b/ssl/test/runner/runner.go
index 0e250c2..3c8c2b2 100644
--- a/ssl/test/runner/runner.go
+++ b/ssl/test/runner/runner.go
@@ -554,6 +554,8 @@
 	// peerApplicationSettings are the expected application settings for the
 	// connection. If nil, no application settings are expected.
 	peerApplicationSettings []byte
+	// echAccepted is whether ECH should have been accepted on this connection.
+	echAccepted bool
 }
 
 type testCase struct {
@@ -969,6 +971,16 @@
 		}
 	}
 
+	if expectations.echAccepted {
+		if !connState.ECHAccepted {
+			return errors.New("tls: server did not accept ECH")
+		}
+	} else {
+		if connState.ECHAccepted {
+			return errors.New("tls: server unexpectedly accepted ECH")
+		}
+	}
+
 	if test.exportKeyingMaterial > 0 {
 		actual := make([]byte, test.exportKeyingMaterial)
 		if _, err := io.ReadFull(tlsConn, actual); err != nil {
@@ -16440,203 +16452,795 @@
 	})
 }
 
+type echCipher struct {
+	name   string
+	cipher HPKECipherSuite
+}
+
+var echCiphers = []echCipher{
+	{
+		name:   "HKDF-SHA256-AES-128-GCM",
+		cipher: HPKECipherSuite{KDF: hpke.HKDFSHA256, AEAD: hpke.AES128GCM},
+	},
+	{
+		name:   "HKDF-SHA256-AES-256-GCM",
+		cipher: HPKECipherSuite{KDF: hpke.HKDFSHA256, AEAD: hpke.AES256GCM},
+	}, {
+		name:   "HKDF-SHA256-ChaCha20-Poly1305",
+		cipher: HPKECipherSuite{KDF: hpke.HKDFSHA256, AEAD: hpke.ChaCha20Poly1305},
+	},
+}
+
+// generateECHConfigWithSecretKey constructs a valid ECHConfig and corresponding
+// private key for the server. If the cipher list is empty, all ciphers are
+// included.
+func generateECHConfigWithSecretKey(publicName string, ciphers []HPKECipherSuite) (*ECHConfig, []byte, error) {
+	publicKeyR, secretKeyR, err := hpke.GenerateKeyPair()
+	if err != nil {
+		return nil, nil, err
+	}
+	if len(ciphers) == 0 {
+		ciphers = make([]HPKECipherSuite, 0, len(echCiphers))
+		for _, cipher := range echCiphers {
+			ciphers = append(ciphers, cipher.cipher)
+		}
+	}
+	result := ECHConfig{
+		PublicName:   publicName,
+		PublicKey:    publicKeyR,
+		KEM:          hpke.X25519WithHKDFSHA256,
+		CipherSuites: ciphers,
+		// For real-life purposes, the maxNameLen should be
+		// based on the set of domain names that the server
+		// represents.
+		MaxNameLen: 16,
+	}
+	return &result, secretKeyR, nil
+}
+
 func addEncryptedClientHelloTests() {
-	// Test ECH GREASE.
-
-	// Test the client's behavior when the server ignores ECH GREASE.
-	testCases = append(testCases, testCase{
-		testType: clientTest,
-		name:     "ECH-GREASE-Client-TLS13",
-		config: Config{
-			MinVersion: VersionTLS13,
-			MaxVersion: VersionTLS13,
-			Bugs: ProtocolBugs{
-				ExpectClientECH: true,
-			},
-		},
-		flags: []string{"-enable-ech-grease"},
-	})
-
-	// Test the client's ECH GREASE behavior when responding to server's
-	// HelloRetryRequest. This test implicitly checks that the first and second
-	// ClientHello messages have identical ECH extensions.
-	testCases = append(testCases, testCase{
-		testType: clientTest,
-		name:     "ECH-GREASE-Client-TLS13-HelloRetryRequest",
-		config: Config{
-			MaxVersion: VersionTLS13,
-			MinVersion: VersionTLS13,
-			// P-384 requires a HelloRetryRequest against BoringSSL's default
-			// configuration. Assert this with ExpectMissingKeyShare.
-			CurvePreferences: []CurveID{CurveP384},
-			Bugs: ProtocolBugs{
-				ExpectMissingKeyShare: true,
-				ExpectClientECH:       true,
-			},
-		},
-		flags: []string{"-enable-ech-grease", "-expect-hrr"},
-	})
-
-	retryConfigValid := ECHConfig{
-		PublicName: "example.com",
-		// A real X25519 public key obtained from hpke.GenerateKeyPair().
-		PublicKey: []byte{
-			0x23, 0x1a, 0x96, 0x53, 0x52, 0x81, 0x1d, 0x7a,
-			0x36, 0x76, 0xaa, 0x5e, 0xad, 0xdb, 0x66, 0x1c,
-			0x92, 0x45, 0x8a, 0x60, 0xc7, 0x81, 0x93, 0xb0,
-			0x47, 0x7b, 0x54, 0x18, 0x6b, 0x9a, 0x1d, 0x6d},
-		KEM: hpke.X25519WithHKDFSHA256,
-		CipherSuites: []HPKECipherSuite{
-			{
-				KDF:  hpke.HKDFSHA256,
-				AEAD: hpke.AES256GCM,
-			},
-		},
-		MaxNameLen: 42,
+	publicECHConfig, secretKey, err := generateECHConfigWithSecretKey("public.example", nil)
+	if err != nil {
+		panic(err)
+	}
+	publicECHConfig1, secretKey1, err := generateECHConfigWithSecretKey("public.example", nil)
+	if err != nil {
+		panic(err)
+	}
+	publicECHConfig2, secretKey2, err := generateECHConfigWithSecretKey("public.example", nil)
+	if err != nil {
+		panic(err)
+	}
+	publicECHConfig3, secretKey3, err := generateECHConfigWithSecretKey("public.example", nil)
+	if err != nil {
+		panic(err)
 	}
 
-	retryConfigUnsupportedVersion := []byte{
-		// version
-		0xba, 0xdd,
-		// length
-		0x00, 0x05,
-		// contents
-		0x05, 0x04, 0x03, 0x02, 0x01,
+	for _, protocol := range []protocol{tls, quic} {
+		prefix := protocol.String() + "-"
+
+		// There are two ClientHellos, so many of our tests have
+		// HelloRetryRequest variations.
+		for _, hrr := range []bool{false, true} {
+			var suffix string
+			var defaultCurves []CurveID
+			if hrr {
+				suffix = "-HelloRetryRequest"
+				// Require a HelloRetryRequest for every curve.
+				defaultCurves = []CurveID{}
+			}
+
+			// Test the server can accept ECH.
+			testCases = append(testCases, testCase{
+				testType: serverTest,
+				protocol: protocol,
+				name:     prefix + "ECH-Server" + suffix,
+				config: Config{
+					ServerName:      "secret.example",
+					ClientECHConfig: publicECHConfig,
+					DefaultCurves:   defaultCurves,
+				},
+				resumeSession: true,
+				flags: []string{
+					"-ech-server-config", base64.StdEncoding.EncodeToString(MarshalECHConfig(publicECHConfig)),
+					"-ech-server-key", base64.StdEncoding.EncodeToString(secretKey),
+					"-ech-is-retry-config", "1",
+					"-expect-server-name", "secret.example",
+				},
+				expectations: connectionExpectations{
+					echAccepted: true,
+				},
+			})
+
+			// Test the server can accept ECH with a minimal ClientHelloOuter.
+			// This confirms that the server does not unexpectedly pick up
+			// fields from the wrong ClientHello.
+			testCases = append(testCases, testCase{
+				testType: serverTest,
+				protocol: protocol,
+				name:     prefix + "ECH-Server-MinimalClientHelloOuter" + suffix,
+				config: Config{
+					ServerName:      "secret.example",
+					ClientECHConfig: publicECHConfig,
+					DefaultCurves:   defaultCurves,
+					Bugs: ProtocolBugs{
+						MinimalClientHelloOuter: true,
+					},
+				},
+				resumeSession: true,
+				flags: []string{
+					"-ech-server-config", base64.StdEncoding.EncodeToString(MarshalECHConfig(publicECHConfig)),
+					"-ech-server-key", base64.StdEncoding.EncodeToString(secretKey),
+					"-ech-is-retry-config", "1",
+					"-expect-server-name", "secret.example",
+				},
+				expectations: connectionExpectations{
+					echAccepted: true,
+				},
+			})
+
+			// Test that the server can decline ECH. In particular, it must send
+			// retry configs.
+			testCases = append(testCases, testCase{
+				testType: serverTest,
+				protocol: protocol,
+				name:     prefix + "ECH-Server-Decline" + suffix,
+				config: Config{
+					ServerName:    "secret.example",
+					DefaultCurves: defaultCurves,
+					// The client uses an ECHConfig that the server does not understand
+					// so we can observe which retry configs the server sends back.
+					ClientECHConfig: publicECHConfig,
+					Bugs: ProtocolBugs{
+						OfferSessionInClientHelloOuter: true,
+						ExpectECHRetryConfigs:          MarshalECHConfigList(publicECHConfig2, publicECHConfig3),
+					},
+				},
+				resumeSession: true,
+				flags: []string{
+					// Configure three ECHConfigs on the shim, only two of which
+					// should be sent in retry configs.
+					"-ech-server-config", base64.StdEncoding.EncodeToString(MarshalECHConfig(publicECHConfig1)),
+					"-ech-server-key", base64.StdEncoding.EncodeToString(secretKey1),
+					"-ech-is-retry-config", "0",
+					"-ech-server-config", base64.StdEncoding.EncodeToString(MarshalECHConfig(publicECHConfig2)),
+					"-ech-server-key", base64.StdEncoding.EncodeToString(secretKey2),
+					"-ech-is-retry-config", "1",
+					"-ech-server-config", base64.StdEncoding.EncodeToString(MarshalECHConfig(publicECHConfig3)),
+					"-ech-server-key", base64.StdEncoding.EncodeToString(secretKey3),
+					"-ech-is-retry-config", "1",
+					"-expect-server-name", "public.example",
+				},
+			})
+
+			// Test that the server considers a ClientHelloInner indicating TLS
+			// 1.2 to be a fatal error.
+			testCases = append(testCases, testCase{
+				testType: serverTest,
+				protocol: protocol,
+				name:     prefix + "ECH-Server-TLS12InInner" + suffix,
+				config: Config{
+					ServerName:      "secret.example",
+					DefaultCurves:   defaultCurves,
+					ClientECHConfig: publicECHConfig,
+					Bugs: ProtocolBugs{
+						AllowTLS12InClientHelloInner: true,
+					},
+				},
+				flags: []string{
+					"-ech-server-config", base64.StdEncoding.EncodeToString(MarshalECHConfig(publicECHConfig)),
+					"-ech-server-key", base64.StdEncoding.EncodeToString(secretKey),
+					"-ech-is-retry-config", "1"},
+				shouldFail:         true,
+				expectedLocalError: "remote error: illegal parameter",
+				expectedError:      ":INVALID_CLIENT_HELLO_INNER:",
+			})
+
+			// When ech_is_inner extension is absent from the ClientHelloInner, the
+			// server should fail the connection.
+			testCases = append(testCases, testCase{
+				testType: serverTest,
+				protocol: protocol,
+				name:     prefix + "ECH-Server-MissingECHIsInner" + suffix,
+				config: Config{
+					ServerName:      "secret.example",
+					DefaultCurves:   defaultCurves,
+					ClientECHConfig: publicECHConfig,
+					Bugs: ProtocolBugs{
+						OmitECHIsInner:       !hrr,
+						OmitSecondECHIsInner: hrr,
+					},
+				},
+				flags: []string{
+					"-ech-server-config", base64.StdEncoding.EncodeToString(MarshalECHConfig(publicECHConfig)),
+					"-ech-server-key", base64.StdEncoding.EncodeToString(secretKey),
+					"-ech-is-retry-config", "1",
+				},
+				shouldFail:         true,
+				expectedLocalError: "remote error: illegal parameter",
+				expectedError:      ":INVALID_CLIENT_HELLO_INNER:",
+			})
+
+			// Test that the server can decode ech_outer_extensions.
+			testCases = append(testCases, testCase{
+				testType: serverTest,
+				protocol: protocol,
+				name:     prefix + "ECH-Server-OuterExtensions" + suffix,
+				config: Config{
+					ServerName:      "secret.example",
+					DefaultCurves:   defaultCurves,
+					ClientECHConfig: publicECHConfig,
+					ECHOuterExtensions: []uint16{
+						extensionKeyShare,
+						extensionSupportedCurves,
+						// Include a custom extension, to test that unrecognized
+						// extensions are also decoded.
+						extensionCustom,
+					},
+					Bugs: ProtocolBugs{
+						CustomExtension: "test",
+						// Ensure ClientHelloOuter's extension order is different
+						// from ClientHelloInner. This tests that the server
+						// correctly reconstructs the extension order.
+						FirstExtensionInClientHelloOuter:   extensionSupportedCurves,
+						OnlyCompressSecondClientHelloInner: hrr,
+					},
+				},
+				flags: []string{
+					"-ech-server-config", base64.StdEncoding.EncodeToString(MarshalECHConfig(publicECHConfig)),
+					"-ech-server-key", base64.StdEncoding.EncodeToString(secretKey),
+					"-ech-is-retry-config", "1",
+					"-expect-server-name", "secret.example",
+				},
+				expectations: connectionExpectations{
+					echAccepted: true,
+				},
+			})
+
+			// Test that the server rejects duplicated values in ech_outer_extensions.
+			// Besides causing the server to reconstruct an invalid ClientHelloInner
+			// with duplicated extensions, this behavior would be vulnerable to DoS
+			// attacks.
+			testCases = append(testCases, testCase{
+				testType: serverTest,
+				protocol: protocol,
+				name:     prefix + "ECH-Server-OuterExtensions-Duplicate" + suffix,
+				config: Config{
+					ServerName:      "secret.example",
+					DefaultCurves:   defaultCurves,
+					ClientECHConfig: publicECHConfig,
+					ECHOuterExtensions: []uint16{
+						extensionSupportedCurves,
+						extensionSupportedCurves,
+					},
+					Bugs: ProtocolBugs{
+						OnlyCompressSecondClientHelloInner: hrr,
+					},
+				},
+				flags: []string{
+					"-ech-server-config", base64.StdEncoding.EncodeToString(MarshalECHConfig(publicECHConfig)),
+					"-ech-server-key", base64.StdEncoding.EncodeToString(secretKey),
+					"-ech-is-retry-config", "1",
+					"-expect-server-name", "secret.example",
+				},
+				shouldFail:         true,
+				expectedLocalError: "remote error: illegal parameter",
+				expectedError:      ":DUPLICATE_EXTENSION:",
+			})
+
+			// Test that the server rejects references to missing extensions in
+			// ech_outer_extensions.
+			testCases = append(testCases, testCase{
+				testType: serverTest,
+				protocol: protocol,
+				name:     prefix + "ECH-Server-OuterExtensions-Missing" + suffix,
+				config: Config{
+					ServerName:      "secret.example",
+					DefaultCurves:   defaultCurves,
+					ClientECHConfig: publicECHConfig,
+					ECHOuterExtensions: []uint16{
+						extensionCustom,
+					},
+					Bugs: ProtocolBugs{
+						OnlyCompressSecondClientHelloInner: hrr,
+					},
+				},
+				flags: []string{
+					"-ech-server-config", base64.StdEncoding.EncodeToString(MarshalECHConfig(publicECHConfig)),
+					"-ech-server-key", base64.StdEncoding.EncodeToString(secretKey),
+					"-ech-is-retry-config", "1",
+					"-expect-server-name", "secret.example",
+				},
+				shouldFail:         true,
+				expectedLocalError: "remote error: illegal parameter",
+				expectedError:      ":DECODE_ERROR:",
+			})
+
+			// Test that the server rejects a references to the ECH extension in
+			// ech_outer_extensions. The ECH extension is not authenticated in the
+			// AAD and would result in an invalid ClientHelloInner.
+			testCases = append(testCases, testCase{
+				testType: serverTest,
+				protocol: protocol,
+				name:     prefix + "ECH-Server-OuterExtensions-SelfReference" + suffix,
+				config: Config{
+					ServerName:      "secret.example",
+					DefaultCurves:   defaultCurves,
+					ClientECHConfig: publicECHConfig,
+					ECHOuterExtensions: []uint16{
+						extensionEncryptedClientHello,
+					},
+					Bugs: ProtocolBugs{
+						OnlyCompressSecondClientHelloInner: hrr,
+					},
+				},
+				flags: []string{
+					"-ech-server-config", base64.StdEncoding.EncodeToString(MarshalECHConfig(publicECHConfig)),
+					"-ech-server-key", base64.StdEncoding.EncodeToString(secretKey),
+					"-ech-is-retry-config", "1",
+					"-expect-server-name", "secret.example",
+				},
+				shouldFail:         true,
+				expectedLocalError: "remote error: illegal parameter",
+				expectedError:      ":DECODE_ERROR:",
+			})
+		}
+
+		// Test that ECH, which runs before an async early callback, interacts
+		// correctly in the state machine.
+		testCases = append(testCases, testCase{
+			testType: serverTest,
+			protocol: protocol,
+			name:     prefix + "ECH-Server-AsyncEarlyCallback",
+			config: Config{
+				ServerName:      "secret.example",
+				ClientECHConfig: publicECHConfig,
+			},
+			flags: []string{
+				"-async",
+				"-use-early-callback",
+				"-ech-server-config", base64.StdEncoding.EncodeToString(MarshalECHConfig(publicECHConfig)),
+				"-ech-server-key", base64.StdEncoding.EncodeToString(secretKey),
+				"-ech-is-retry-config", "1",
+				"-expect-server-name", "secret.example",
+			},
+			expectations: connectionExpectations{
+				echAccepted: true,
+			},
+		})
+
+		// Test ECH-enabled server with two ECHConfigs can decrypt client's ECH when
+		// it uses the second ECHConfig.
+		testCases = append(testCases, testCase{
+			testType: serverTest,
+			protocol: protocol,
+			name:     prefix + "ECH-Server-SecondECHConfig",
+			config: Config{
+				ServerName:      "secret.example",
+				ClientECHConfig: publicECHConfig1,
+			},
+			flags: []string{
+				"-ech-server-config", base64.StdEncoding.EncodeToString(MarshalECHConfig(publicECHConfig)),
+				"-ech-server-key", base64.StdEncoding.EncodeToString(secretKey),
+				"-ech-is-retry-config", "1",
+				"-ech-server-config", base64.StdEncoding.EncodeToString(MarshalECHConfig(publicECHConfig1)),
+				"-ech-server-key", base64.StdEncoding.EncodeToString(secretKey1),
+				"-ech-is-retry-config", "1",
+				"-expect-server-name", "secret.example",
+			},
+			expectations: connectionExpectations{
+				echAccepted: true,
+			},
+		})
+
+		// Test all supported ECH cipher suites.
+		for i, cipher := range echCiphers {
+			otherCipher := echCiphers[0]
+			if i == 0 {
+				otherCipher = echCiphers[1]
+			}
+
+			// Test the ECH server can handle the specified cipher.
+			testCases = append(testCases, testCase{
+				testType: serverTest,
+				protocol: protocol,
+				name:     prefix + "ECH-Server-Cipher-" + cipher.name,
+				config: Config{
+					ServerName:      "secret.example",
+					ClientECHConfig: publicECHConfig,
+					ECHCipherSuites: []HPKECipherSuite{cipher.cipher},
+				},
+				flags: []string{
+					"-ech-server-config", base64.StdEncoding.EncodeToString(MarshalECHConfig(publicECHConfig)),
+					"-ech-server-key", base64.StdEncoding.EncodeToString(secretKey),
+					"-ech-is-retry-config", "1",
+					"-expect-server-name", "secret.example",
+				},
+				expectations: connectionExpectations{
+					echAccepted: true,
+				},
+			})
+
+			// Test that the ECH server rejects the specified cipher if not
+			// listed in its ECHConfig.
+			config, key, err := generateECHConfigWithSecretKey("public.example", []HPKECipherSuite{otherCipher.cipher})
+			if err != nil {
+				panic(err)
+			}
+			testCases = append(testCases, testCase{
+				testType: serverTest,
+				protocol: protocol,
+				name:     prefix + "ECH-Server-DisabledCipher-" + cipher.name,
+				config: Config{
+					ServerName:      "secret.example",
+					ClientECHConfig: publicECHConfig,
+					ECHCipherSuites: []HPKECipherSuite{cipher.cipher},
+					Bugs: ProtocolBugs{
+						ExpectECHRetryConfigs: MarshalECHConfigList(config),
+					},
+				},
+				flags: []string{
+					"-ech-server-config", base64.StdEncoding.EncodeToString(MarshalECHConfig(config)),
+					"-ech-server-key", base64.StdEncoding.EncodeToString(key),
+					"-ech-is-retry-config", "1",
+					"-expect-server-name", "public.example",
+				},
+			})
+		}
+
+		// Test that the ECH server handles a short ClientECH.enc value by
+		// falling back to ClientHelloOuter.
+		testCases = append(testCases, testCase{
+			testType: serverTest,
+			protocol: protocol,
+			name:     prefix + "ECH-Server-ShortClientECHEnc",
+			config: Config{
+				ServerName:      "secret.example",
+				ClientECHConfig: publicECHConfig,
+				Bugs: ProtocolBugs{
+					ExpectECHRetryConfigs: MarshalECHConfigList(publicECHConfig),
+					TruncateClientECHEnc:  true,
+				},
+			},
+			flags: []string{
+				"-ech-server-config", base64.StdEncoding.EncodeToString(MarshalECHConfig(publicECHConfig)),
+				"-ech-server-key", base64.StdEncoding.EncodeToString(secretKey),
+				"-ech-is-retry-config", "1",
+				"-expect-server-name", "public.example",
+			},
+		})
+
+		// Test that the server handles decryption failure by falling back to
+		// ClientHelloOuter.
+		testCases = append(testCases, testCase{
+			testType: serverTest,
+			protocol: protocol,
+			name:     prefix + "ECH-Server-CorruptEncryptedClientHello",
+			config: Config{
+				ServerName:      "secret.example",
+				ClientECHConfig: publicECHConfig,
+				Bugs: ProtocolBugs{
+					ExpectECHRetryConfigs:       MarshalECHConfigList(publicECHConfig),
+					CorruptEncryptedClientHello: true,
+				},
+			},
+			flags: []string{
+				"-ech-server-config", base64.StdEncoding.EncodeToString(MarshalECHConfig(publicECHConfig)),
+				"-ech-server-key", base64.StdEncoding.EncodeToString(secretKey),
+				"-ech-is-retry-config", "1",
+			},
+		})
+
+		// Test that the server treats decryption failure in the second
+		// ClientHello as fatal.
+		testCases = append(testCases, testCase{
+			testType: serverTest,
+			protocol: protocol,
+			name:     prefix + "ECH-Server-CorruptSecondEncryptedClientHello",
+			config: Config{
+				ServerName:      "secret.example",
+				ClientECHConfig: publicECHConfig,
+				// Force a HelloRetryRequest.
+				DefaultCurves: []CurveID{},
+				Bugs: ProtocolBugs{
+					CorruptSecondEncryptedClientHello: true,
+				},
+			},
+			flags: []string{
+				"-ech-server-config", base64.StdEncoding.EncodeToString(MarshalECHConfig(publicECHConfig)),
+				"-ech-server-key", base64.StdEncoding.EncodeToString(secretKey),
+				"-ech-is-retry-config", "1",
+			},
+			shouldFail:         true,
+			expectedError:      ":DECRYPTION_FAILED:",
+			expectedLocalError: "remote error: error decrypting message",
+		})
+
+		// Test that the server treats a missing second ECH extension as fatal.
+		testCases = append(testCases, testCase{
+			testType: serverTest,
+			protocol: protocol,
+			name:     prefix + "ECH-Server-OmitSecondEncryptedClientHello",
+			config: Config{
+				ServerName:      "secret.example",
+				ClientECHConfig: publicECHConfig,
+				// Force a HelloRetryRequest.
+				DefaultCurves: []CurveID{},
+				Bugs: ProtocolBugs{
+					OmitSecondEncryptedClientHello: true,
+				},
+			},
+			flags: []string{
+				"-ech-server-config", base64.StdEncoding.EncodeToString(MarshalECHConfig(publicECHConfig)),
+				"-ech-server-key", base64.StdEncoding.EncodeToString(secretKey),
+				"-ech-is-retry-config", "1",
+			},
+			shouldFail:         true,
+			expectedError:      ":MISSING_EXTENSION:",
+			expectedLocalError: "remote error: missing extension",
+		})
+
+		// 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: publicECHConfig,
+			},
+			resumeSession: true,
+			earlyData:     true,
+			flags: []string{
+				"-ech-server-config", base64.StdEncoding.EncodeToString(MarshalECHConfig(publicECHConfig)),
+				"-ech-server-key", base64.StdEncoding.EncodeToString(secretKey),
+				"-ech-is-retry-config", "1",
+			},
+			expectations: connectionExpectations{
+				echAccepted: true,
+			},
+		})
+		testCases = append(testCases, testCase{
+			testType: serverTest,
+			protocol: protocol,
+			name:     prefix + "ECH-Server-EarlyDataRejected",
+			config: Config{
+				ServerName:      "secret.example",
+				ClientECHConfig: publicECHConfig,
+				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", base64.StdEncoding.EncodeToString(MarshalECHConfig(publicECHConfig)),
+				"-ech-server-key", base64.StdEncoding.EncodeToString(secretKey),
+				"-ech-is-retry-config", "1",
+			},
+			expectations: connectionExpectations{
+				echAccepted: true,
+			},
+		})
+
+		// Test servers with ECH disabled correctly ignore the extension and
+		// handshake with the ClientHelloOuter.
+		testCases = append(testCases, testCase{
+			testType: serverTest,
+			protocol: protocol,
+			name:     prefix + "ECH-Server-Disabled",
+			config: Config{
+				ServerName:      "secret.example",
+				ClientECHConfig: publicECHConfig,
+			},
+			flags: []string{
+				"-expect-server-name", "public.example",
+			},
+		})
+
+		// Test the client's behavior when the server ignores ECH GREASE.
+		testCases = append(testCases, testCase{
+			testType: clientTest,
+			protocol: protocol,
+			name:     prefix + "ECH-GREASE-Client-TLS13",
+			config: Config{
+				MinVersion: VersionTLS13,
+				MaxVersion: VersionTLS13,
+				Bugs: ProtocolBugs{
+					ExpectClientECH: true,
+				},
+			},
+			flags: []string{"-enable-ech-grease"},
+		})
+
+		// Test the client's ECH GREASE behavior when responding to server's
+		// HelloRetryRequest. This test implicitly checks that the first and second
+		// ClientHello messages have identical ECH extensions.
+		testCases = append(testCases, testCase{
+			testType: clientTest,
+			protocol: protocol,
+			name:     prefix + "ECH-GREASE-Client-TLS13-HelloRetryRequest",
+			config: Config{
+				MaxVersion: VersionTLS13,
+				MinVersion: VersionTLS13,
+				// P-384 requires a HelloRetryRequest against BoringSSL's default
+				// configuration. Assert this with ExpectMissingKeyShare.
+				CurvePreferences: []CurveID{CurveP384},
+				Bugs: ProtocolBugs{
+					ExpectMissingKeyShare: true,
+					ExpectClientECH:       true,
+				},
+			},
+			flags: []string{"-enable-ech-grease", "-expect-hrr"},
+		})
+
+		retryConfigValid := ECHConfig{
+			PublicName: "example.com",
+			// A real X25519 public key obtained from hpke.GenerateKeyPair().
+			PublicKey: []byte{
+				0x23, 0x1a, 0x96, 0x53, 0x52, 0x81, 0x1d, 0x7a,
+				0x36, 0x76, 0xaa, 0x5e, 0xad, 0xdb, 0x66, 0x1c,
+				0x92, 0x45, 0x8a, 0x60, 0xc7, 0x81, 0x93, 0xb0,
+				0x47, 0x7b, 0x54, 0x18, 0x6b, 0x9a, 0x1d, 0x6d},
+			KEM: hpke.X25519WithHKDFSHA256,
+			CipherSuites: []HPKECipherSuite{
+				{
+					KDF:  hpke.HKDFSHA256,
+					AEAD: hpke.AES256GCM,
+				},
+			},
+			MaxNameLen: 42,
+		}
+
+		retryConfigUnsupportedVersion := []byte{
+			// version
+			0xba, 0xdd,
+			// length
+			0x00, 0x05,
+			// contents
+			0x05, 0x04, 0x03, 0x02, 0x01,
+		}
+
+		validAndInvalidConfigsBuilder := newByteBuilder()
+		validAndInvalidConfigsBody := validAndInvalidConfigsBuilder.addU16LengthPrefixed()
+		validAndInvalidConfigsBody.addBytes(MarshalECHConfig(&retryConfigValid))
+		validAndInvalidConfigsBody.addBytes(retryConfigUnsupportedVersion)
+		validAndInvalidConfigs := validAndInvalidConfigsBuilder.finish()
+
+		// Test that the client accepts a well-formed encrypted_client_hello
+		// extension in response to ECH GREASE. The response includes one ECHConfig
+		// with a supported version and one with an unsupported version.
+		testCases = append(testCases, testCase{
+			testType: clientTest,
+			protocol: protocol,
+			name:     prefix + "ECH-GREASE-Client-TLS13-Retry-Configs",
+			config: Config{
+				MinVersion: VersionTLS13,
+				MaxVersion: VersionTLS13,
+				Bugs: ProtocolBugs{
+					ExpectClientECH: true,
+					// Include an additional well-formed ECHConfig with an invalid
+					// version. This ensures the client can iterate over the retry
+					// configs.
+					SendECHRetryConfigs: validAndInvalidConfigs,
+				},
+			},
+			flags: []string{"-enable-ech-grease"},
+		})
+
+		// Test that the client aborts with a decode_error alert when it receives a
+		// syntactically-invalid encrypted_client_hello extension from the server.
+		testCases = append(testCases, testCase{
+			testType: clientTest,
+			protocol: protocol,
+			name:     prefix + "ECH-GREASE-Client-TLS13-Invalid-Retry-Configs",
+			config: Config{
+				MinVersion: VersionTLS13,
+				MaxVersion: VersionTLS13,
+				Bugs: ProtocolBugs{
+					ExpectClientECH:     true,
+					SendECHRetryConfigs: []byte{0xba, 0xdd, 0xec, 0xcc},
+				},
+			},
+			flags:              []string{"-enable-ech-grease"},
+			shouldFail:         true,
+			expectedLocalError: "remote error: error decoding message",
+			expectedError:      ":ERROR_PARSING_EXTENSION:",
+		})
+
+		// Test that the server responds to an empty ech_is_inner extension with the
+		// acceptance confirmation.
+		testCases = append(testCases, testCase{
+			testType: serverTest,
+			protocol: protocol,
+			name:     prefix + "ECH-Server-ECHIsInner",
+			config: Config{
+				MinVersion: VersionTLS13,
+				MaxVersion: VersionTLS13,
+				Bugs: ProtocolBugs{
+					AlwaysSendECHIsInner: true,
+				},
+			},
+			resumeSession: true,
+		})
+
+		// Test that server fails the handshake when it sees a non-empty
+		// ech_is_inner extension.
+		testCases = append(testCases, testCase{
+			testType: serverTest,
+			protocol: protocol,
+			name:     prefix + "ECH-Server-ECHIsInner-NotEmpty",
+			config: Config{
+				MinVersion: VersionTLS13,
+				MaxVersion: VersionTLS13,
+				Bugs: ProtocolBugs{
+					AlwaysSendECHIsInner:  true,
+					SendInvalidECHIsInner: []byte{42, 42, 42},
+				},
+			},
+			shouldFail:         true,
+			expectedLocalError: "remote error: illegal parameter",
+			expectedError:      ":ERROR_PARSING_EXTENSION:",
+		})
+
+		// When ech_is_inner extension is absent, the server should not accept ECH.
+		testCases = append(testCases, testCase{
+			testType: serverTest,
+			protocol: protocol,
+			name:     prefix + "ECH-Server-ECHIsInner-Absent",
+			config: Config{
+				MinVersion: VersionTLS13,
+				MaxVersion: VersionTLS13,
+			},
+			resumeSession: true,
+		})
+
+		// Test that a TLS 1.3 server that receives an ech_is_inner extension can
+		// negotiate TLS 1.2 without clobbering the downgrade signal.
+		if protocol != quic {
+			testCases = append(testCases, testCase{
+				testType: serverTest,
+				protocol: protocol,
+				name:     prefix + "ECH-Server-ECHIsInner-Absent-TLS12",
+				config: Config{
+					MinVersion: VersionTLS12,
+					MaxVersion: VersionTLS13,
+					Bugs: ProtocolBugs{
+						// Omit supported_versions extension so the server negotiates
+						// TLS 1.2.
+						OmitSupportedVersions: true,
+						AlwaysSendECHIsInner:  true,
+					},
+				},
+				// Check that the client sees the TLS 1.3 downgrade signal in
+				// ServerHello.random.
+				shouldFail:         true,
+				expectedLocalError: "tls: downgrade from TLS 1.3 detected",
+			})
+		}
+
+		// Test that the handshake fails when the server has no ECHConfigs and the
+		// ClientHello contains both encrypted_client_hello and ech_is_inner
+		// extensions.
+		testCases = append(testCases, testCase{
+			testType: serverTest,
+			protocol: protocol,
+			name:     prefix + "ECH-Server-Disabled-EncryptedClientHello-ECHIsInner",
+			config: Config{
+				MinVersion:      VersionTLS13,
+				MaxVersion:      VersionTLS13,
+				ClientECHConfig: publicECHConfig,
+				Bugs: ProtocolBugs{
+					AlwaysSendECHIsInner: true,
+				},
+			},
+			shouldFail:         true,
+			expectedLocalError: "remote error: illegal parameter",
+			expectedError:      ":UNEXPECTED_EXTENSION:",
+		})
 	}
-
-	var validAndInvalidConfigs []byte
-	validAndInvalidConfigs = append(validAndInvalidConfigs, MarshalECHConfig(&retryConfigValid)...)
-	validAndInvalidConfigs = append(validAndInvalidConfigs, retryConfigUnsupportedVersion...)
-
-	// Test that the client accepts a well-formed encrypted_client_hello
-	// extension in response to ECH GREASE. The response includes one ECHConfig
-	// with a supported version and one with an unsupported version.
-	testCases = append(testCases, testCase{
-		testType: clientTest,
-		name:     "ECH-GREASE-Client-TLS13-Retry-Configs",
-		config: Config{
-			MinVersion: VersionTLS13,
-			MaxVersion: VersionTLS13,
-			Bugs: ProtocolBugs{
-				ExpectClientECH: true,
-				// Include an additional well-formed ECHConfig with an invalid
-				// version. This ensures the client can iterate over the retry
-				// configs.
-				SendECHRetryConfigs: validAndInvalidConfigs,
-			},
-		},
-		flags: []string{"-enable-ech-grease"},
-	})
-
-	// Test that the client aborts with a decode_error alert when it receives a
-	// syntactically-invalid encrypted_client_hello extension from the server.
-	testCases = append(testCases, testCase{
-		testType: clientTest,
-		name:     "ECH-GREASE-Client-TLS13-Invalid-Retry-Configs",
-		config: Config{
-			MinVersion: VersionTLS13,
-			MaxVersion: VersionTLS13,
-			Bugs: ProtocolBugs{
-				ExpectClientECH:     true,
-				SendECHRetryConfigs: []byte{0xba, 0xdd, 0xec, 0xcc},
-			},
-		},
-		flags:              []string{"-enable-ech-grease"},
-		shouldFail:         true,
-		expectedLocalError: "remote error: error decoding message",
-		expectedError:      ":ERROR_PARSING_EXTENSION:",
-	})
-
-	// Test that the server responds to an empty ECH extension with the acceptance
-	// confirmation.
-	testCases = append(testCases, testCase{
-		testType: serverTest,
-		name:     "ECH-Server-ECHIsInner",
-		config: Config{
-			MinVersion: VersionTLS13,
-			MaxVersion: VersionTLS13,
-			Bugs: ProtocolBugs{
-				SendECHIsInner:        []byte{},
-				ExpectServerAcceptECH: true,
-			},
-		},
-		resumeSession: true,
-	})
-
-	// Test that server fails the handshake when it sees a nonempty ech_is_inner
-	// extension.
-	testCases = append(testCases, testCase{
-		testType: serverTest,
-		name:     "ECH-Server-ECHIsInner-NotEmpty",
-		config: Config{
-			MinVersion: VersionTLS13,
-			MaxVersion: VersionTLS13,
-			Bugs: ProtocolBugs{
-				SendECHIsInner: []byte{42, 42, 42},
-			},
-		},
-		shouldFail:         true,
-		expectedLocalError: "remote error: illegal parameter",
-		expectedError:      ":ERROR_PARSING_EXTENSION:",
-	})
-
-	// When ech_is_inner extension is absent, the server should not accept ECH.
-	testCases = append(testCases, testCase{
-		testType: serverTest,
-		name:     "ECH-Server-ECHIsInner-Absent",
-		config: Config{
-			MinVersion: VersionTLS13,
-			MaxVersion: VersionTLS13,
-			Bugs: ProtocolBugs{
-				ExpectServerAcceptECH: false,
-			},
-		},
-		resumeSession: true,
-	})
-
-	// Test that a TLS 1.3 server that receives an ech_is_inner extension can
-	// negotiate TLS 1.2 without clobbering the downgrade signal.
-	testCases = append(testCases, testCase{
-		testType: serverTest,
-		name:     "ECH-Server-ECHIsInner-Absent-TLS12",
-		config: Config{
-			MinVersion: VersionTLS12,
-			MaxVersion: VersionTLS13,
-			Bugs: ProtocolBugs{
-				// Omit supported_versions extension so the server negotiates
-				// TLS 1.2.
-				OmitSupportedVersions: true,
-				SendECHIsInner:        []byte{},
-			},
-		},
-		// Check that the client sees the TLS 1.3 downgrade signal in
-		// ServerHello.random.
-		shouldFail:         true,
-		expectedLocalError: "tls: downgrade from TLS 1.3 detected",
-	})
-
-	// Test that the handshake fails when the client sends both
-	// encrypted_client_hello and ech_is_inner extensions.
-	//
-	// TODO(dmcardle) Replace this test once runner is capable of sending real
-	// ECH extensions. The equivalent test will send ech_is_inner and a real
-	// encrypted_client_hello extension derived from a key unknown to the
-	// server.
-	testCases = append(testCases, testCase{
-		testType: serverTest,
-		name:     "ECH-Server-EncryptedClientHello-ECHIsInner",
-		config: Config{
-			MinVersion: VersionTLS13,
-			MaxVersion: VersionTLS13,
-			Bugs: ProtocolBugs{
-				SendECHIsInner:                      []byte{},
-				SendPlaceholderEncryptedClientHello: true,
-			},
-		},
-		shouldFail:         true,
-		expectedLocalError: "remote error: illegal parameter",
-		expectedError:      ":UNEXPECTED_EXTENSION:",
-	})
 }
 
 func worker(statusChan chan statusMsg, c chan *testCase, shimPath string, wg *sync.WaitGroup) {
diff --git a/ssl/test/test_config.cc b/ssl/test/test_config.cc
index d37769e..7d66d23 100644
--- a/ssl/test/test_config.cc
+++ b/ssl/test/test_config.cc
@@ -238,6 +238,12 @@
     {"-verify-prefs", &TestConfig::verify_prefs},
     {"-expect-peer-verify-pref", &TestConfig::expect_peer_verify_prefs},
     {"-curves", &TestConfig::curves},
+    {"-ech-is-retry-config", &TestConfig::ech_is_retry_config},
+};
+
+const Flag<std::vector<std::string>> kBase64VectorFlags[] = {
+    {"-ech-server-config", &TestConfig::ech_server_configs},
+    {"-ech-server-key", &TestConfig::ech_server_keys},
 };
 
 const Flag<std::vector<std::pair<std::string, std::string>>>
@@ -245,6 +251,23 @@
         {"-application-settings", &TestConfig::application_settings},
 };
 
+bool DecodeBase64(std::string *out, const std::string &in) {
+  size_t len;
+  if (!EVP_DecodedLength(&len, in.size())) {
+    fprintf(stderr, "Invalid base64: %s.\n", in.c_str());
+    return false;
+  }
+  std::vector<uint8_t> buf(len);
+  if (!EVP_DecodeBase64(buf.data(), &len, buf.size(),
+                        reinterpret_cast<const uint8_t *>(in.data()),
+                        in.size())) {
+    fprintf(stderr, "Invalid base64: %s.\n", in.c_str());
+    return false;
+  }
+  out->assign(reinterpret_cast<const char *>(buf.data()), len);
+  return true;
+}
+
 bool ParseFlag(char *flag, int argc, char **argv, int *i,
                bool skip, TestConfig *out_config) {
   bool *bool_field = FindField(out_config, kBoolFlags, flag);
@@ -289,21 +312,12 @@
       fprintf(stderr, "Missing parameter.\n");
       return false;
     }
-    size_t len;
-    if (!EVP_DecodedLength(&len, strlen(argv[*i]))) {
-      fprintf(stderr, "Invalid base64: %s.\n", argv[*i]);
-      return false;
-    }
-    std::unique_ptr<uint8_t[]> decoded(new uint8_t[len]);
-    if (!EVP_DecodeBase64(decoded.get(), &len, len,
-                          reinterpret_cast<const uint8_t *>(argv[*i]),
-                          strlen(argv[*i]))) {
-      fprintf(stderr, "Invalid base64: %s.\n", argv[*i]);
+    std::string value;
+    if (!DecodeBase64(&value, argv[*i])) {
       return false;
     }
     if (!skip) {
-      base64_field->assign(reinterpret_cast<const char *>(decoded.get()),
-                           len);
+      *base64_field = std::move(value);
     }
     return true;
   }
@@ -337,6 +351,25 @@
     return true;
   }
 
+  std::vector<std::string> *base64_vector_field =
+      FindField(out_config, kBase64VectorFlags, flag);
+  if (base64_vector_field) {
+    *i = *i + 1;
+    if (*i >= argc) {
+      fprintf(stderr, "Missing parameter.\n");
+      return false;
+    }
+    std::string value;
+    if (!DecodeBase64(&value, argv[*i])) {
+      return false;
+    }
+    // Each instance of the flag adds to the list.
+    if (!skip) {
+      base64_vector_field->push_back(std::move(value));
+    }
+    return true;
+  }
+
   std::vector<std::pair<std::string, std::string>> *string_pair_vector_field =
       FindField(out_config, kStringPairVectorFlags, flag);
   if (string_pair_vector_field) {
@@ -347,8 +380,10 @@
     }
     const char *comma = strchr(argv[*i], ',');
     if (!comma) {
-      fprintf(stderr,
-              "Parameter should be a pair of comma-separated strings.\n");
+      fprintf(
+          stderr,
+          "Parameter should be a comma-separated triple composed of two base64 "
+          "strings followed by \"true\" or \"false\".\n");
       return false;
     }
     // Each instance of the flag adds to the list.
@@ -1595,6 +1630,36 @@
   if (enable_ech_grease) {
     SSL_set_enable_ech_grease(ssl.get(), 1);
   }
+  if (ech_server_configs.size() != ech_server_keys.size() ||
+      ech_server_configs.size() != ech_is_retry_config.size()) {
+    fprintf(stderr,
+            "-ech-server-config, -ech-server-key, and -ech-is-retry-config "
+            "flags must match.\n");
+    return nullptr;
+  }
+  if (!ech_server_configs.empty()) {
+    bssl::UniquePtr<SSL_ECH_SERVER_CONFIG_LIST> config_list(
+        SSL_ECH_SERVER_CONFIG_LIST_new());
+    if (!config_list) {
+      return nullptr;
+    }
+    for (size_t i = 0; i < ech_server_configs.size(); i++) {
+      const std::string &ech_config = ech_server_configs[i];
+      const std::string &ech_private_key = ech_server_keys[i];
+      const int is_retry_config = ech_is_retry_config[i];
+      if (!SSL_ECH_SERVER_CONFIG_LIST_add(
+              config_list.get(), is_retry_config,
+              reinterpret_cast<const uint8_t *>(ech_config.data()),
+              ech_config.size(),
+              reinterpret_cast<const uint8_t *>(ech_private_key.data()),
+              ech_private_key.size())) {
+        return nullptr;
+      }
+    }
+    if (!SSL_CTX_set1_ech_server_config_list(ssl_ctx, config_list.get())) {
+      return nullptr;
+    }
+  }
   if (!send_channel_id.empty()) {
     SSL_set_tls_channel_id_enabled(ssl.get(), 1);
     if (!async) {
diff --git a/ssl/test/test_config.h b/ssl/test/test_config.h
index babf1d5..4946bc7 100644
--- a/ssl/test/test_config.h
+++ b/ssl/test/test_config.h
@@ -40,6 +40,9 @@
   std::string cert_file;
   std::string expect_server_name;
   bool enable_ech_grease = false;
+  std::vector<std::string> ech_server_configs;
+  std::vector<std::string> ech_server_keys;
+  std::vector<int> ech_is_retry_config;
   std::string expect_certificate_types;
   bool require_any_client_certificate = false;
   std::string advertise_npn;
diff --git a/ssl/tls13_server.cc b/ssl/tls13_server.cc
index d33afa5..4ddb281 100644
--- a/ssl/tls13_server.cc
+++ b/ssl/tls13_server.cc
@@ -28,6 +28,7 @@
 #include <openssl/stack.h>
 
 #include "../crypto/internal.h"
+#include "../crypto/hpke/internal.h"
 #include "internal.h"
 
 
@@ -186,13 +187,8 @@
   // the common handshake logic. Resolve the remaining non-PSK parameters.
   SSL *const ssl = hs->ssl;
   SSLMessage msg;
-  if (!ssl->method->get_message(ssl, &msg)) {
-    return ssl_hs_read_message;
-  }
   SSL_CLIENT_HELLO client_hello;
-  if (!ssl_client_hello_init(ssl, &client_hello, msg)) {
-    OPENSSL_PUT_ERROR(SSL, SSL_R_CLIENTHELLO_PARSE_FAILED);
-    ssl_send_alert(ssl, SSL3_AL_FATAL, SSL_AD_DECODE_ERROR);
+  if (!hs->GetClientHello(&msg, &client_hello)) {
     return ssl_hs_error;
   }
 
@@ -347,13 +343,8 @@
 static enum ssl_hs_wait_t do_select_session(SSL_HANDSHAKE *hs) {
   SSL *const ssl = hs->ssl;
   SSLMessage msg;
-  if (!ssl->method->get_message(ssl, &msg)) {
-    return ssl_hs_read_message;
-  }
   SSL_CLIENT_HELLO client_hello;
-  if (!ssl_client_hello_init(ssl, &client_hello, msg)) {
-    OPENSSL_PUT_ERROR(SSL, SSL_R_CLIENTHELLO_PARSE_FAILED);
-    ssl_send_alert(ssl, SSL3_AL_FATAL, SSL_AD_DECODE_ERROR);
+  if (!hs->GetClientHello(&msg, &client_hello)) {
     return ssl_hs_error;
   }
 
@@ -527,6 +518,7 @@
   }
 
   ssl->method->next_message(ssl);
+  hs->ech_client_hello_buf.Reset();
   hs->tls13_state = state13_send_server_hello;
   return ssl_hs_ok;
 }
@@ -534,7 +526,6 @@
 static enum ssl_hs_wait_t do_send_hello_retry_request(SSL_HANDSHAKE *hs) {
   SSL *const ssl = hs->ssl;
 
-
   ScopedCBB cbb;
   CBB body, session_id, extensions;
   uint16_t group_id;
@@ -576,12 +567,78 @@
     return ssl_hs_error;
   }
   SSL_CLIENT_HELLO client_hello;
-  if (!ssl_client_hello_init(ssl, &client_hello, msg)) {
+  if (!ssl_client_hello_init(ssl, &client_hello, msg.body)) {
     OPENSSL_PUT_ERROR(SSL, SSL_R_CLIENTHELLO_PARSE_FAILED);
     ssl_send_alert(ssl, SSL3_AL_FATAL, SSL_AD_DECODE_ERROR);
     return ssl_hs_error;
   }
 
+  if (hs->ech_accept) {
+    // If we previously accepted the ClientHelloInner, check that the second
+    // ClientHello contains an encrypted_client_hello extension.
+    CBS ech_body;
+    if (!ssl_client_hello_get_extension(&client_hello, &ech_body,
+                                        TLSEXT_TYPE_encrypted_client_hello)) {
+      OPENSSL_PUT_ERROR(SSL, SSL_R_MISSING_EXTENSION);
+      ssl_send_alert(ssl, SSL3_AL_FATAL, SSL_AD_MISSING_EXTENSION);
+      return ssl_hs_error;
+    }
+
+    // Parse a ClientECH out of the extension body.
+    uint16_t kdf_id, aead_id;
+    CBS config_id, enc, payload;
+    if (!CBS_get_u16(&ech_body, &kdf_id) ||  //
+        !CBS_get_u16(&ech_body, &aead_id) ||
+        !CBS_get_u8_length_prefixed(&ech_body, &config_id) ||
+        !CBS_get_u16_length_prefixed(&ech_body, &enc) ||
+        !CBS_get_u16_length_prefixed(&ech_body, &payload) ||
+        CBS_len(&ech_body) != 0) {
+      OPENSSL_PUT_ERROR(SSL, SSL_R_DECODE_ERROR);
+      ssl_send_alert(ssl, SSL3_AL_FATAL, SSL_AD_DECODE_ERROR);
+      return ssl_hs_error;
+    }
+
+    // Check that ClientECH.cipher_suite is unchanged and that
+    // ClientECH.config_id and ClientECH.enc are empty.
+    if (kdf_id != EVP_HPKE_CTX_get_kdf_id(hs->ech_hpke_ctx.get()) ||
+        aead_id != EVP_HPKE_CTX_get_aead_id(hs->ech_hpke_ctx.get()) ||
+        CBS_len(&config_id) > 0 || CBS_len(&enc) > 0) {
+      OPENSSL_PUT_ERROR(SSL, SSL_R_DECODE_ERROR);
+      ssl_send_alert(ssl, SSL3_AL_FATAL, SSL_AD_ILLEGAL_PARAMETER);
+      return ssl_hs_error;
+    }
+
+    // Decrypt the payload with the HPKE context from the first ClientHello.
+    Array<uint8_t> encoded_client_hello_inner;
+    bool unused;
+    if (!ssl_client_hello_decrypt(
+            hs->ech_hpke_ctx.get(), &encoded_client_hello_inner, &unused,
+            &client_hello, kdf_id, aead_id, config_id, enc, payload)) {
+      // Decryption failure is fatal in the second ClientHello.
+      OPENSSL_PUT_ERROR(SSL, SSL_R_DECRYPTION_FAILED);
+      ssl_send_alert(ssl, SSL3_AL_FATAL, SSL_AD_DECRYPT_ERROR);
+      return ssl_hs_error;
+    }
+
+    // Recover the ClientHelloInner from the EncodedClientHelloInner.
+    uint8_t alert = SSL_AD_DECODE_ERROR;
+    bssl::Array<uint8_t> client_hello_inner;
+    if (!ssl_decode_client_hello_inner(ssl, &alert, &client_hello_inner,
+                                       encoded_client_hello_inner,
+                                       &client_hello)) {
+      OPENSSL_PUT_ERROR(SSL, SSL_R_DECODE_ERROR);
+      ssl_send_alert(ssl, SSL3_AL_FATAL, alert);
+      return ssl_hs_error;
+    }
+    hs->ech_client_hello_buf = std::move(client_hello_inner);
+
+    // Reparse |client_hello| from the buffer owned by |hs|.
+    if (!hs->GetClientHello(&msg, &client_hello)) {
+      OPENSSL_PUT_ERROR(SSL, ERR_R_INTERNAL_ERROR);
+      return ssl_hs_error;
+    }
+  }
+
   // We perform all our negotiation based on the first ClientHello (for
   // consistency with what |select_certificate_cb| observed), which is in the
   // transcript, so we can ignore most of this second one.
@@ -639,6 +696,7 @@
   }
 
   ssl->method->next_message(ssl);
+  hs->ech_client_hello_buf.Reset();
   hs->tls13_state = state13_send_server_hello;
   return ssl_hs_ok;
 }
@@ -649,9 +707,8 @@
   Span<uint8_t> random(ssl->s3->server_random);
   RAND_bytes(random.data(), random.size());
 
-  // If the ClientHello has an ech_is_inner extension, we must be the ECH
-  // backend server. In response to ech_is_inner, we will overwrite part of the
-  // ServerHello.random with the ECH acceptance confirmation.
+  assert(!hs->ech_accept || hs->ech_is_inner_present);
+
   if (hs->ech_is_inner_present) {
     // Construct the ServerHelloECHConf message, which is the same as
     // ServerHello, except the last 8 bytes of its random field are zeroed out.
diff --git a/tool/server.cc b/tool/server.cc
index 989d335..6993e09 100644
--- a/tool/server.cc
+++ b/tool/server.cc
@@ -61,6 +61,16 @@
         "-ocsp-response", kOptionalArgument, "OCSP response file to send",
     },
     {
+        "-echconfig-key",
+        kOptionalArgument,
+        "File containing the private key corresponding to the ECHConfig.",
+    },
+    {
+        "-echconfig",
+        kOptionalArgument,
+        "File containing one ECHConfig.",
+    },
+    {
         "-loop", kBooleanArgument,
         "The server will continue accepting new sequential connections.",
     },
@@ -261,6 +271,47 @@
     }
   }
 
+  if (args_map.count("-echconfig-key") + args_map.count("-echconfig") == 1) {
+    fprintf(stderr,
+            "-echconfig and -echconfig-key must be specified together.\n");
+    return false;
+  }
+
+  if (args_map.count("-echconfig-key") != 0) {
+    std::string echconfig_key_path = args_map["-echconfig-key"];
+    std::string echconfig_path = args_map["-echconfig"];
+
+    // Load the ECH private key.
+    ScopedFILE echconfig_key_file(fopen(echconfig_key_path.c_str(), "rb"));
+    std::vector<uint8_t> echconfig_key;
+    if (echconfig_key_file == nullptr ||
+        !ReadAll(&echconfig_key, echconfig_key_file.get())) {
+      fprintf(stderr, "Error reading %s\n", echconfig_key_path.c_str());
+      return false;
+    }
+
+    // Load the ECHConfig.
+    ScopedFILE echconfig_file(fopen(echconfig_path.c_str(), "rb"));
+    std::vector<uint8_t> echconfig;
+    if (echconfig_file == nullptr ||
+        !ReadAll(&echconfig, echconfig_file.get())) {
+      fprintf(stderr, "Error reading %s\n", echconfig_path.c_str());
+      return false;
+    }
+
+    bssl::UniquePtr<SSL_ECH_SERVER_CONFIG_LIST> configs(
+        SSL_ECH_SERVER_CONFIG_LIST_new());
+    if (!configs ||
+        !SSL_ECH_SERVER_CONFIG_LIST_add(configs.get(),
+                                        /*is_retry_config=*/1, echconfig.data(),
+                                        echconfig.size(), echconfig_key.data(),
+                                        echconfig_key.size()) ||
+        !SSL_CTX_set1_ech_server_config_list(ctx.get(), configs.get())) {
+      fprintf(stderr, "Error setting server's ECHConfig and private key\n");
+      return false;
+    }
+  }
+
   if (args_map.count("-cipher") != 0 &&
       !SSL_CTX_set_strict_cipher_list(ctx.get(), args_map["-cipher"].c_str())) {
     fprintf(stderr, "Failed setting cipher list\n");