Merge pull request #453 from h2o/kazuho/ech-grease

add support for ECH greasing
diff --git a/include/picotls.h b/include/picotls.h
index 972bc9d..fe74d40 100644
--- a/include/picotls.h
+++ b/include/picotls.h
@@ -579,6 +579,7 @@
 
 typedef const struct st_ptls_hpke_cipher_suite_t {
     ptls_hpke_cipher_suite_id_t id;
+    const char *name; /* in form of "<kdf>/<aead>" using the sames specified in IANA HPKE registry */
     ptls_hash_algorithm_t *hash;
     ptls_aead_algorithm_t *aead;
 } ptls_hpke_cipher_suite_t;
@@ -942,7 +943,8 @@
              */
             struct {
                 /**
-                 * config offered by server e.g., by HTTPS RR
+                 * Config offered by server e.g., by HTTPS RR. If config.base is non-NULL but config.len is zero, a grease ECH will
+                 * be sent, assuming that X25519-SHA256 KEM and SHA256-AES-128-GCM HPKE cipher is available.
                  */
                 ptls_iovec_t configs;
                 /**
diff --git a/lib/openssl.c b/lib/openssl.c
index db3f98b..511315e 100644
--- a/lib/openssl.c
+++ b/lib/openssl.c
@@ -2009,19 +2009,23 @@
 
 ptls_hpke_cipher_suite_t ptls_openssl_hpke_aes128gcmsha256 = {
     .id = {.kdf = PTLS_HPKE_HKDF_SHA256, .aead = PTLS_HPKE_AEAD_AES_128_GCM},
+    .name = "HKDF-SHA256/AES-128-GCM",
     .hash = &ptls_openssl_sha256,
     .aead = &ptls_openssl_aes128gcm};
 ptls_hpke_cipher_suite_t ptls_openssl_hpke_aes128gcmsha512 = {
     .id = {.kdf = PTLS_HPKE_HKDF_SHA512, .aead = PTLS_HPKE_AEAD_AES_128_GCM},
+    .name = "HKDF-SHA512/AES-128-GCM",
     .hash = &ptls_openssl_sha512,
     .aead = &ptls_openssl_aes128gcm};
 ptls_hpke_cipher_suite_t ptls_openssl_hpke_aes256gcmsha384 = {
     .id = {.kdf = PTLS_HPKE_HKDF_SHA384, .aead = PTLS_HPKE_AEAD_AES_256_GCM},
+    .name = "HKDF-SHA384/AES-256-GCM",
     .hash = &ptls_openssl_sha384,
     .aead = &ptls_openssl_aes256gcm};
 #if PTLS_OPENSSL_HAVE_CHACHA20_POLY1305
 ptls_hpke_cipher_suite_t ptls_openssl_hpke_chacha20poly1305sha256 = {
     .id = {.kdf = PTLS_HPKE_HKDF_SHA256, .aead = PTLS_HPKE_AEAD_CHACHA20POLY1305},
+    .name = "HKDF-SHA256/ChaCha20Poly1305",
     .hash = &ptls_openssl_sha256,
     .aead = &ptls_openssl_chacha20poly1305};
 #endif
diff --git a/lib/picotls.c b/lib/picotls.c
index 976212f..83ac410 100644
--- a/lib/picotls.c
+++ b/lib/picotls.c
@@ -172,6 +172,7 @@
  */
 struct st_ptls_ech_t {
     uint8_t offered : 1;
+    uint8_t offered_grease : 1;
     uint8_t accepted : 1;
     uint8_t config_id;
     ptls_hpke_kem_t *kem;
@@ -1161,6 +1162,50 @@
     return ret;
 }
 
+static void client_setup_ech_grease(struct st_ptls_ech_t *ech, void (*random_bytes)(void *, size_t), ptls_hpke_kem_t **kems,
+                                    ptls_hpke_cipher_suite_t **ciphers, const char *sni_name)
+{
+    static const size_t x25519_key_size = 32;
+    uint8_t random_secret[PTLS_AES128_KEY_SIZE + PTLS_AES_IV_SIZE];
+
+    /* pick up X25519, AES-128-GCM or bail out */
+    for (size_t i = 0; kems[i] != NULL; ++i) {
+        if (kems[i]->id == PTLS_HPKE_KEM_X25519_SHA256) {
+            ech->kem = kems[i];
+            break;
+        }
+    }
+    for (size_t i = 0; ciphers[i] != NULL; ++i) {
+        if (ciphers[i]->id.kdf == PTLS_HPKE_HKDF_SHA256 && ciphers[i]->id.aead == PTLS_HPKE_AEAD_AES_128_GCM) {
+            ech->cipher = ciphers[i];
+            break;
+        }
+    }
+    if (ech->kem == NULL || ech->cipher == NULL)
+        goto Fail;
+
+    /* aead is generated from random */
+    random_bytes(random_secret, sizeof(random_secret));
+    ech->aead = ptls_aead_new_direct(ech->cipher->aead, 1, random_secret, random_secret + PTLS_AES128_KEY_SIZE);
+
+    /* `enc` is random bytes */
+    if ((ech->client.enc.base = malloc(x25519_key_size)) == NULL)
+        goto Fail;
+    ech->client.enc.len = x25519_key_size;
+    random_bytes(ech->client.enc.base, ech->client.enc.len);
+
+    /* setup the rest (inner_client_random is left zeros) */
+    random_bytes(&ech->config_id, sizeof(ech->config_id));
+    ech->client.max_name_length = 64;
+    if ((ech->client.public_name = duplicate_as_str(sni_name, strlen(sni_name))) == NULL)
+        goto Fail;
+
+    return;
+
+Fail:
+    clear_ech(ech, 0);
+}
+
 #define ECH_CONFIRMATION_SERVER_HELLO "ech accept confirmation"
 #define ECH_CONFIRMATION_HRR "hrr ech accept confirmation"
 static int ech_calc_confirmation(ptls_key_schedule_t *sched, void *dst, const uint8_t *inner_random, const char *label,
@@ -2265,13 +2310,18 @@
 
     if (properties != NULL) {
         /* try to use ECH (ignore broken ECHConfigList; it is delivered insecurely) */
-        if (!is_second_flight && sni_name != NULL && tls->ctx->ech.client.ciphers != NULL &&
-            properties->client.ech.configs.len != 0) {
-            struct st_decoded_ech_config_t decoded;
-            client_decode_ech_config_list(tls->ctx, &decoded, properties->client.ech.configs);
-            if (decoded.kem != NULL && decoded.cipher != NULL) {
-                if ((ret = client_setup_ech(&tls->ech, &decoded, tls->ctx->random_bytes)) != 0)
-                    goto Exit;
+        if (!is_second_flight && sni_name != NULL && tls->ctx->ech.client.ciphers != NULL) {
+            if (properties->client.ech.configs.len != 0) {
+                struct st_decoded_ech_config_t decoded;
+                client_decode_ech_config_list(tls->ctx, &decoded, properties->client.ech.configs);
+                if (decoded.kem != NULL && decoded.cipher != NULL) {
+                    if ((ret = client_setup_ech(&tls->ech, &decoded, tls->ctx->random_bytes)) != 0)
+                        goto Exit;
+                }
+            } else {
+                /* zero-length config indicates ECH greasing */
+                client_setup_ech_grease(&tls->ech, tls->ctx->random_bytes, tls->ctx->ech.client.kems, tls->ctx->ech.client.ciphers,
+                                        sni_name);
             }
         }
         /* setup resumption-related data. If successful, resumption_secret becomes a non-zero value. */
@@ -2404,7 +2454,11 @@
             memcpy(tls->ech.client.first_ech.base,
                    emitter->buf->base + ech_size_offset - outer_ech_header_size(tls->ech.client.enc.len), len);
             tls->ech.client.first_ech.len = len;
-            tls->ech.offered = 1;
+            if (properties->client.ech.configs.len != 0) {
+                tls->ech.offered = 1;
+            } else {
+                tls->ech.offered_grease = 1;
+            }
         }
         /* update hash */
         ptls__key_schedule_update_hash(tls->key_schedule, emitter->buf->base + mess_start, emitter->buf->off - mess_start, 1);
@@ -2546,7 +2600,7 @@
                               break;
                           case PTLS_EXTENSION_TYPE_ENCRYPTED_CLIENT_HELLO:
                               assert(sh->is_retry_request);
-                              if (!tls->ech.offered) {
+                              if (!(tls->ech.offered || tls->ech.offered_grease)) {
                                   ret = PTLS_ALERT_UNSUPPORTED_EXTENSION;
                                   goto Exit;
                               }
@@ -2659,6 +2713,8 @@
     key_schedule_select_outer(tls->key_schedule);
 
 Exit:
+    PTLS_PROBE(ECH_SELECTION, tls, !!tls->ech.accepted);
+    PTLS_LOG_CONN(ech_selection, tls, { PTLS_LOG_ELEMENT_BOOL(is_ech, tls->ech.accepted); });
     ptls_clear_memory(confirm_hash_expected, sizeof(confirm_hash_expected));
     return ret;
 }
@@ -2682,10 +2738,17 @@
         if ((ret = key_schedule_select_cipher(tls->key_schedule, tls->cipher_suite, 0)) != 0)
             goto Exit;
         key_schedule_transform_post_ch1hash(tls->key_schedule);
-        if (tls->ech.aead != NULL &&
-            (ret = client_ech_select_hello(tls, message, sh.retry_request.ech != NULL ? sh.retry_request.ech - message.base : 0,
-                                           ECH_CONFIRMATION_HRR)) != 0)
-            goto Exit;
+        if (tls->ech.aead != NULL) {
+            size_t confirm_hash_off = 0;
+            if (tls->ech.offered) {
+                if (sh.retry_request.ech != NULL)
+                    confirm_hash_off = sh.retry_request.ech - message.base;
+            } else {
+                assert(tls->ech.offered_grease);
+            }
+            if ((ret = client_ech_select_hello(tls, message, confirm_hash_off, ECH_CONFIRMATION_HRR)) != 0)
+                goto Exit;
+        }
         ptls__key_schedule_update_hash(tls->key_schedule, message.base, message.len, 0);
         return handle_hello_retry_request(tls, emitter, &sh, message, properties);
     }
@@ -2695,9 +2758,14 @@
         goto Exit;
 
     /* check if ECH is accepted */
-    static const size_t confirm_hash_off =
-        PTLS_HANDSHAKE_HEADER_SIZE + 2 /* legacy_version */ + PTLS_HELLO_RANDOM_SIZE - PTLS_ECH_CONFIRM_LENGTH;
     if (tls->ech.aead != NULL) {
+        size_t confirm_hash_off = 0;
+        if (tls->ech.offered) {
+            confirm_hash_off =
+                PTLS_HANDSHAKE_HEADER_SIZE + 2 /* legacy_version */ + PTLS_HELLO_RANDOM_SIZE - PTLS_ECH_CONFIRM_LENGTH;
+        } else {
+            assert(tls->ech.offered_grease);
+        }
         if ((ret = client_ech_select_hello(tls, message, confirm_hash_off, ECH_CONFIRMATION_SERVER_HELLO)) != 0)
             goto Exit;
     }
@@ -2837,7 +2905,7 @@
             break;
         case PTLS_EXTENSION_TYPE_ENCRYPTED_CLIENT_HELLO: {
             /* accept retry_configs only if we offered ECH but rejected */
-            if (!(tls->ech.offered && !ptls_is_ech_handshake(tls, NULL, NULL))) {
+            if (!((tls->ech.offered || tls->ech.offered_grease) && !ptls_is_ech_handshake(tls, NULL, NULL))) {
                 ret = PTLS_ALERT_UNSUPPORTED_EXTENSION;
                 goto Exit;
             }
@@ -4198,6 +4266,10 @@
                      ch->ech.cipher_suite, ch->ech.enc, ptls_iovec_init(ech_info_prefix, sizeof(ech_info_prefix)))) != NULL)
                 tls->ech.config_id = ch->ech.config_id;
         }
+        if (!is_second_flight) {
+            PTLS_PROBE(ECH_SELECTION, tls, tls->ech.aead != NULL);
+            PTLS_LOG_CONN(ech_selection, tls, { PTLS_LOG_ELEMENT_BOOL(is_ech, tls->ech.aead != NULL); });
+        }
         if (tls->ech.aead != NULL) {
             /* now that AEAD context is available, create AAD and decrypt inner CH */
             if ((ech.encoded_ch_inner = malloc(ch->ech.payload.len - tls->ech.aead->algo->tag_size)) == NULL ||
diff --git a/picotls-probes.d b/picotls-probes.d
index 614a571..fbafa83 100644
--- a/picotls-probes.d
+++ b/picotls-probes.d
@@ -26,4 +26,5 @@
     probe client_random(struct st_ptls_t *tls, const void *bytes);
     probe receive_message(struct st_ptls_t *tls, uint8_t message, const void *bytes, size_t len, int result);
     probe new_secret(struct st_ptls_t *tls, const char *label, const char *secret_hex);
+    probe ech_selection(struct st_ptls_t *tls, int is_ech);
 };
diff --git a/picotls.xcodeproj/project.pbxproj b/picotls.xcodeproj/project.pbxproj
index ef65447..9a917b3 100644
--- a/picotls.xcodeproj/project.pbxproj
+++ b/picotls.xcodeproj/project.pbxproj
@@ -203,6 +203,7 @@
 		081F00CC291A358800534A86 /* asn1.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = asn1.h; sourceTree = "<group>"; };
 		081F00CD291A358800534A86 /* pembase64.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = pembase64.h; sourceTree = "<group>"; };
 		081F00CE291A358800534A86 /* ptlsbcrypt.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ptlsbcrypt.h; sourceTree = "<group>"; };
+		08B3298229419DFC009D6766 /* ech-live.t */ = {isa = PBXFileReference; lastKnownFileType = text; path = "ech-live.t"; sourceTree = "<group>"; xcLanguageSpecificationIdentifier = xcode.lang.perl; };
 		08F0FDF52910F67A00EE657D /* hpke.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = hpke.c; sourceTree = "<group>"; };
 		105900241DC8D37500FB4085 /* aes.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; name = aes.c; path = src/aes.c; sourceTree = "<group>"; };
 		105900251DC8D37500FB4085 /* aes.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = aes.h; path = src/aes.h; sourceTree = "<group>"; };
@@ -260,7 +261,7 @@
 		E95EBCCA227EA0180022C32D /* dtrace-utils.cmake */ = {isa = PBXFileReference; lastKnownFileType = text; path = "dtrace-utils.cmake"; sourceTree = "<group>"; };
 		E97577002212405300D1EF74 /* ffx.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ffx.h; sourceTree = "<group>"; };
 		E97577022212405D00D1EF74 /* ffx.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; path = ffx.c; sourceTree = "<group>"; };
-		E97577072213148800D1EF74 /* e2e.t */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.perl; path = e2e.t; sourceTree = "<group>"; };
+		E97577072213148800D1EF74 /* e2e.t */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.perl; path = e2e.t; sourceTree = "<group>"; xcLanguageSpecificationIdentifier = xcode.lang.perl; };
 		E99B75DE1F5CDDB500CF503E /* asn1.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; path = asn1.c; sourceTree = "<group>"; };
 		E99B75DF1F5CDDB500CF503E /* pembase64.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; path = pembase64.c; sourceTree = "<group>"; };
 		E9B43DBF24619D1700824E51 /* fusion.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = fusion.c; sourceTree = "<group>"; };
@@ -452,6 +453,7 @@
 			children = (
 				106530FE1DAD8A3C005B2C60 /* cli.c */,
 				E97577072213148800D1EF74 /* e2e.t */,
+				08B3298229419DFC009D6766 /* ech-live.t */,
 				E9B43DE224619D7E00824E51 /* fusion.c */,
 				081F00C92918823200534A86 /* hpke.c */,
 				1059003D1DC8D4E300FB4085 /* minicrypto.c */,
diff --git a/t/cli.c b/t/cli.c
index 18bba34..67bfd4d 100644
--- a/t/cli.c
+++ b/t/cli.c
@@ -57,21 +57,6 @@
 /* sentinels indicating that the endpoint is in benchmark mode */
 static const char input_file_is_benchmark[] = "is:benchmark";
 
-static struct {
-    ptls_iovec_t config_list;
-    struct {
-        struct {
-            ptls_hpke_kem_t *kem;
-            ptls_key_exchange_context_t *ctx;
-        } list[16];
-        size_t count;
-    } keyex;
-    struct {
-        ptls_iovec_t configs;
-        char *fn;
-    } retry;
-} ech;
-
 static ptls_hpke_kem_t *find_kem(ptls_key_exchange_algorithm_t *algo)
 {
     for (size_t i = 0; ptls_openssl_hpke_kems[i] != NULL; ++i)
@@ -82,65 +67,6 @@
     return NULL;
 }
 
-static ptls_aead_context_t *create_ech_opener(ptls_ech_create_opener_t *self, ptls_hpke_kem_t **kem,
-                                              ptls_hpke_cipher_suite_t **cipher, ptls_t *tls, uint8_t config_id,
-                                              ptls_hpke_cipher_suite_id_t cipher_id, ptls_iovec_t enc, ptls_iovec_t info_prefix)
-{
-    const uint8_t *src = ech.config_list.base, *const end = src + ech.config_list.len;
-    size_t index = 0;
-    int ret = 0;
-
-    /* look for the cipher implementation; this should better be specific to each ECHConfig (as each of them may advertise different
-     * set of values) */
-    *cipher = NULL;
-    for (size_t i = 0; ptls_openssl_hpke_cipher_suites[i] != NULL; ++i) {
-        if (ptls_openssl_hpke_cipher_suites[i]->id.kdf == cipher_id.kdf &&
-            ptls_openssl_hpke_cipher_suites[i]->id.aead == cipher_id.aead) {
-            *cipher = ptls_openssl_hpke_cipher_suites[i];
-            break;
-        }
-    }
-    if (*cipher == NULL)
-        goto Exit;
-
-    ptls_decode_open_block(src, end, 2, {
-        uint16_t version;
-        if ((ret = ptls_decode16(&version, &src, end)) != 0)
-            goto Exit;
-        do {
-            ptls_decode_open_block(src, end, 2, {
-                if (src == end) {
-                    ret = PTLS_ALERT_DECODE_ERROR;
-                    goto Exit;
-                }
-                if (*src == config_id) {
-                    /* this is the ECHConfig that we have been looking for */
-                    if (index >= ech.keyex.count) {
-                        fprintf(stderr, "ECH key missing for config %zu\n", index);
-                        return NULL;
-                    }
-                    uint8_t *info = malloc(info_prefix.len + end - (src - 4));
-                    memcpy(info, info_prefix.base, info_prefix.len);
-                    memcpy(info + info_prefix.len, src - 4, end - (src - 4));
-                    ptls_aead_context_t *aead;
-                    ptls_hpke_setup_base_r(ech.keyex.list[index].kem, *cipher, ech.keyex.list[index].ctx, &aead, enc,
-                                           ptls_iovec_init(info, info_prefix.len + end - (src - 4)));
-                    free(info);
-                    *kem = ech.keyex.list[index].kem;
-                    return aead;
-                }
-                ++index;
-                src = end;
-            });
-        } while (src != end);
-    });
-
-Exit:
-    if (ret != 0)
-        fprintf(stderr, "ECH decode error:%d\n", ret);
-    return NULL;
-}
-
 static void shift_buffer(ptls_buffer_t *buf, size_t delta)
 {
     if (delta != 0) {
@@ -243,6 +169,7 @@
                     if ((ret = ptls_handshake(tls, &encbuf, bytebuf + off, &leftlen, hsprop)) == 0) {
                         state = IN_1RTT;
                         assert(ptls_is_server(tls) || hsprop->client.early_data_acceptance != PTLS_EARLY_DATA_ACCEPTANCE_UNKNOWN);
+                        ech_save_retry_configs();
                         /* release data sent as early-data, if server accepted it */
                         if (hsprop->client.early_data_acceptance == PTLS_EARLY_DATA_ACCEPTED)
                             shift_buffer(&ptbuf, early_bytes_sent);
@@ -253,15 +180,7 @@
                     } else {
                         if (ret == PTLS_ALERT_ECH_REQUIRED) {
                             assert(!ptls_is_server(tls));
-                            if (ech.retry.configs.base != NULL) {
-                                FILE *fp;
-                                if ((fp = fopen(ech.retry.fn, "wt")) == NULL) {
-                                    fprintf(stderr, "failed to write to ECH config file:%s:%s\n", ech.retry.fn, strerror(errno));
-                                    exit(1);
-                                }
-                                fwrite(ech.retry.configs.base, 1, ech.retry.configs.len, fp);
-                                fclose(fp);
-                            }
+                            ech_save_retry_configs();
                         }
                         if (encbuf.off != 0)
                             repeat_while_eintr(write(sockfd, encbuf.base, encbuf.off), { break; });
@@ -465,8 +384,9 @@
            "  -N named-group       named group to be used (default: secp256r1)\n"
            "  -s session-file      file to read/write the session ticket\n"
            "  -S                   require public key exchange when resuming a session\n"
-           "  -E echconfiglist     file that contains ECHConfigList; overwritten when\n"
-           "                       receiving retry_configs from the server\n"
+           "  -E echconfiglist     file that contains ECHConfigList or an empty file to\n"
+           "                       grease ECH; will be overwritten when receiving\n"
+           "                       retry_configs from the server\n"
            "  -e                   when resuming a session, send first 8,192 bytes of input\n"
            "                       as early data\n"
            "  -r public-key-file   use raw public keys (RFC 7250). When set and running as a\n"
@@ -520,7 +440,6 @@
 
     ptls_key_exchange_algorithm_t *key_exchanges[128] = {NULL};
     ptls_cipher_suite_t *cipher_suites[128] = {NULL};
-    ptls_ech_create_opener_t ech_opener = {.cb = create_ech_opener};
     ptls_context_t ctx = {
         .random_bytes = ptls_openssl_random_bytes,
         .get_time = &ptls_get_time,
@@ -594,42 +513,12 @@
         case 'S':
             ctx.require_dhe_on_psk = 1;
             break;
-        case 'E': {
-            FILE *fp;
-            if ((fp = fopen(optarg, "rt")) == NULL) {
-                fprintf(stderr, "failed to open ECHConfigList file:%s:%s\n", optarg, strerror(errno));
-                return 1;
-            }
-            ech.config_list.base = malloc(65536);
-            if ((ech.config_list.len = fread(ech.config_list.base, 1, 65536, fp)) == 65536) {
-                fprintf(stderr, "ECHConfigList is too large:%s\n", optarg);
-                return 1;
-            }
-            fclose(fp);
-            ech.retry.fn = optarg;
-        } break;
-        case 'K': {
-            FILE *fp;
-            EVP_PKEY *pkey;
-            int ret;
-            if ((fp = fopen(optarg, "rt")) == NULL) {
-                fprintf(stderr, "failed to open ECH private key file:%s:%s\n", optarg, strerror(errno));
-                return 1;
-            }
-            if ((pkey = PEM_read_PrivateKey(fp, NULL, NULL, NULL)) == NULL) {
-                fprintf(stderr, "failed to load private key from file:%s\n", optarg);
-                return 1;
-            }
-            if ((ret = ptls_openssl_create_key_exchange(&ech.keyex.list[ech.keyex.count].ctx, pkey)) != 0) {
-                fprintf(stderr, "failed to load private key from file:%s:picotls-error:%d", optarg, ret);
-                return 1;
-            }
-            ech.keyex.list[ech.keyex.count].kem = find_kem(ech.keyex.list[ech.keyex.count].ctx->algo);
-            ++ech.keyex.count;
-            EVP_PKEY_free(pkey);
-            fclose(fp);
-            ctx.ech.server.create_opener = &ech_opener;
-        } break;
+        case 'E':
+            ech_setup_configs(optarg);
+            break;
+        case 'K':
+            ech_setup_key(&ctx, optarg);
+            break;
         case 'l':
             setup_log_event(&ctx, optarg);
             break;
diff --git a/t/ech-live.t b/t/ech-live.t
new file mode 100755
index 0000000..b917273
--- /dev/null
+++ b/t/ech-live.t
@@ -0,0 +1,46 @@
+#! /usr/bin/env perl
+
+use strict;
+use warnings;
+use File::Temp qw(tempdir);
+use POSIX ":sys_wait_h";
+use Test::More;
+
+$ENV{BINARY_DIR} ||= ".";
+my $cli = "$ENV{BINARY_DIR}/cli";
+my $tempdir = tempdir(CLEANUP => 1);
+
+plan skip_all => "skipping live tests (setenv LIVE_TESTS=1 to run them)"
+    unless $ENV{LIVE_TESTS};
+
+subtest "crypto.cloudflare.com" => sub {
+    my $req_fn = "$tempdir/req";
+    my $ech_config_fn = "$tempdir/echconfiglist";
+    my $fetch = sub {
+        open my $fh, "$cli -I -E $ech_config_fn crypto.cloudflare.com 443 < $req_fn |"
+            or die "failed to open $cli to connect to crypto.cloudflare.com";
+        join "", <$fh>;
+    };
+
+    { # build request as a temporary file
+        open my $fh, ">", $req_fn
+            or die "failed to create file:$req_fn:$!";
+        print $fh "GET /cdn-cgi/trace HTTP/1.0\r\nHost: crypto.cloudflare.com\r\n\r\n";
+        close $fh;
+    }
+
+    { # create empty ECHConfigList file so as to grease and obtain true config
+        open my $fh, ">", $ech_config_fn
+            or die "failed to create file:$ech_config_fn:$!";
+        close $fh;
+    }
+
+    my $resp = $fetch->();
+    like $resp, qr/^sni=plaintext$/m, "response to grease";
+    isnt +(stat $req_fn)[7], 0, "echconfiglist is non-empty";
+
+    $resp = $fetch->();
+    like $resp, qr/^sni=encrypted$/m, "response to innerCH";
+};
+
+done_testing;
diff --git a/t/minicrypto.c b/t/minicrypto.c
index 30395cf..17a05d2 100644
--- a/t/minicrypto.c
+++ b/t/minicrypto.c
@@ -155,7 +155,7 @@
                              ptls_minicrypto_key_exchanges,
                              ptls_minicrypto_cipher_suites,
                              {&cert, 1},
-                             {NULL},
+                             {{NULL}},
                              NULL,
                              NULL,
                              &sign_certificate.super};
diff --git a/t/openssl.c b/t/openssl.c
index 8e79b15..b4c73b0 100644
--- a/t/openssl.c
+++ b/t/openssl.c
@@ -599,7 +599,7 @@
                                      ptls_minicrypto_key_exchanges,
                                      ptls_minicrypto_cipher_suites,
                                      {&minicrypto_certificate, 1},
-                                     {NULL},
+                                     {{NULL}},
                                      NULL,
                                      NULL,
                                      &minicrypto_sign_certificate.super};
diff --git a/t/util.h b/t/util.h
index 3c59243..0ead3f7 100644
--- a/t/util.h
+++ b/t/util.h
@@ -251,6 +251,149 @@
     ctx->encrypt_ticket = &sc.super;
 }
 
+static struct {
+    ptls_iovec_t config_list;
+    struct {
+        struct {
+            ptls_hpke_kem_t *kem;
+            ptls_key_exchange_context_t *ctx;
+        } list[16];
+        size_t count;
+    } keyex;
+    struct {
+        ptls_iovec_t configs;
+        char *fn;
+    } retry;
+} ech;
+
+static ptls_aead_context_t *ech_create_opener(ptls_ech_create_opener_t *self, ptls_hpke_kem_t **kem,
+                                              ptls_hpke_cipher_suite_t **cipher, ptls_t *tls, uint8_t config_id,
+                                              ptls_hpke_cipher_suite_id_t cipher_id, ptls_iovec_t enc, ptls_iovec_t info_prefix)
+{
+    const uint8_t *src = ech.config_list.base, *const end = src + ech.config_list.len;
+    size_t index = 0;
+    int ret = 0;
+
+    /* look for the cipher implementation; this should better be specific to each ECHConfig (as each of them may advertise different
+     * set of values) */
+    *cipher = NULL;
+    for (size_t i = 0; ptls_openssl_hpke_cipher_suites[i] != NULL; ++i) {
+        if (ptls_openssl_hpke_cipher_suites[i]->id.kdf == cipher_id.kdf &&
+            ptls_openssl_hpke_cipher_suites[i]->id.aead == cipher_id.aead) {
+            *cipher = ptls_openssl_hpke_cipher_suites[i];
+            break;
+        }
+    }
+    if (*cipher == NULL)
+        goto Exit;
+
+    ptls_decode_open_block(src, end, 2, {
+        uint16_t version;
+        if ((ret = ptls_decode16(&version, &src, end)) != 0)
+            goto Exit;
+        do {
+            ptls_decode_open_block(src, end, 2, {
+                if (src == end) {
+                    ret = PTLS_ALERT_DECODE_ERROR;
+                    goto Exit;
+                }
+                if (*src == config_id) {
+                    /* this is the ECHConfig that we have been looking for */
+                    if (index >= ech.keyex.count) {
+                        fprintf(stderr, "ECH key missing for config %zu\n", index);
+                        return NULL;
+                    }
+                    uint8_t *info = malloc(info_prefix.len + end - (src - 4));
+                    memcpy(info, info_prefix.base, info_prefix.len);
+                    memcpy(info + info_prefix.len, src - 4, end - (src - 4));
+                    ptls_aead_context_t *aead;
+                    ptls_hpke_setup_base_r(ech.keyex.list[index].kem, *cipher, ech.keyex.list[index].ctx, &aead, enc,
+                                           ptls_iovec_init(info, info_prefix.len + end - (src - 4)));
+                    free(info);
+                    *kem = ech.keyex.list[index].kem;
+                    return aead;
+                }
+                ++index;
+                src = end;
+            });
+        } while (src != end);
+    });
+
+Exit:
+    if (ret != 0)
+        fprintf(stderr, "ECH decode error:%d\n", ret);
+    return NULL;
+}
+
+static void ech_save_retry_configs(void)
+{
+    if (ech.retry.configs.base == NULL)
+        return;
+
+    FILE *fp;
+    if ((fp = fopen(ech.retry.fn, "wt")) == NULL) {
+        fprintf(stderr, "failed to write to ECH config file:%s:%s\n", ech.retry.fn, strerror(errno));
+        exit(1);
+    }
+    fwrite(ech.retry.configs.base, 1, ech.retry.configs.len, fp);
+    fclose(fp);
+}
+
+static void ech_setup_configs(const char *fn)
+{
+    FILE *fp;
+
+    if ((fp = fopen(fn, "rt")) == NULL) {
+        fprintf(stderr, "failed to open ECHConfigList file:%s:%s\n", fn, strerror(errno));
+        exit(1);
+    }
+    ech.config_list.base = malloc(65536);
+    if ((ech.config_list.len = fread(ech.config_list.base, 1, 65536, fp)) == 65536) {
+        fprintf(stderr, "ECHConfigList is too large:%s\n", fn);
+        exit(1);
+    }
+    fclose(fp);
+    ech.retry.fn = strdup(fn);
+}
+
+static void ech_setup_key(ptls_context_t *ctx, const char *fn)
+{
+    FILE *fp;
+    EVP_PKEY *pkey;
+    int ret;
+
+    if ((fp = fopen(fn, "rt")) == NULL) {
+        fprintf(stderr, "failed to open ECH private key file:%s:%s\n", fn, strerror(errno));
+        exit(1);
+    }
+    if ((pkey = PEM_read_PrivateKey(fp, NULL, NULL, NULL)) == NULL) {
+        fprintf(stderr, "failed to load private key from file:%s\n", fn);
+        exit(1);
+    }
+    if ((ret = ptls_openssl_create_key_exchange(&ech.keyex.list[ech.keyex.count].ctx, pkey)) != 0) {
+        fprintf(stderr, "failed to load private key from file:%s:picotls-error:%d", fn, ret);
+        exit(1);
+    }
+    EVP_PKEY_free(pkey);
+    fclose(fp);
+
+    for (size_t i = 0; ptls_openssl_hpke_kems[i] != NULL; ++i) {
+        if (ptls_openssl_hpke_kems[i]->keyex == ech.keyex.list[ech.keyex.count].ctx->algo) {
+            ech.keyex.list[ech.keyex.count].kem = ptls_openssl_hpke_kems[i];
+            break;
+        }
+    }
+    if (ech.keyex.list[ech.keyex.count].kem == NULL) {
+        fprintf(stderr, "kem unknown for private key:%s\n", fn);
+        exit(1);
+    }
+
+    ++ech.keyex.count;
+
+    static ptls_ech_create_opener_t opener = {.cb = ech_create_opener};
+    ctx->ech.server.create_opener = &opener;
+}
+
 static inline int resolve_address(struct sockaddr *sa, socklen_t *salen, const char *host, const char *port, int family, int type,
                                   int proto)
 {