Commit 7952bc4b8a for openssl.org

commit 7952bc4b8a31ba35d12f9109da0ab82a7ce3318a
Author: sftcd <stephen.farrell@cs.tcd.ie>
Date:   Tue Mar 17 21:08:36 2026 +0000

    ECH: Conformance test changes in response to AISLE review

    Reviewed-by: Matt Caswell <matt@openssl.foundation>
    Reviewed-by: Tomas Mraz <tomas@openssl.foundation>
    MergeDate: Wed Apr  8 08:59:19 2026
    (Merged from https://github.com/openssl/openssl/pull/30419)

diff --git a/doc/man3/SSL_set1_echstore.pod b/doc/man3/SSL_set1_echstore.pod
index ada6712c6a..cd983c4ae9 100644
--- a/doc/man3/SSL_set1_echstore.pod
+++ b/doc/man3/SSL_set1_echstore.pod
@@ -327,7 +327,7 @@ Return codes from SSL_ech_get_status

 =item B<SSL_ECH_STATUS_NOT_TRIED>  -101, ECH wasn't attempted

-=item B<SSL_ECH_STATUS_BAD_NAME>   -102, ECH ok but server cert bad
+=item B<SSL_ECH_STATUS_BAD_NAME>   -102, ECH ok but server or client cert bad

 =item B<SSL_ECH_STATUS_NOT_CONFIGURED> -103, ECH wasn't configured

diff --git a/ssl/ech/ech_internal.c b/ssl/ech/ech_internal.c
index 338c111bfd..78e78bf8d1 100644
--- a/ssl/ech/ech_internal.c
+++ b/ssl/ech/ech_internal.c
@@ -1054,22 +1054,17 @@ int ossl_ech_calc_confirm(SSL_CONNECTION *s, int for_hrr,
     if (for_hrr == 0) { /* zap magic octets at fixed place for SH */
         conf_loc = tbuf + chend + shoffset;
     } else {
-        if (s->server == 1) { /* we get to say where we put ECH:-) */
-            conf_loc = tbuf + tlen - OSSL_ECH_SIGNAL_LEN;
-        } else {
-            if (s->ext.ech.hrrsignal_p == NULL) {
-                /* No ECH found so we'll exit, but set random output */
-                if (RAND_bytes_ex(sctx->libctx, acbuf,
-                        OSSL_ECH_SIGNAL_LEN, 0)
-                    <= 0) {
-                    SSLfatal(s, SSL_AD_INTERNAL_ERROR, SSL_R_ECH_REQUIRED);
-                    goto end;
-                }
-                rv = 1;
+        if (s->server == 0 && s->ext.ech.hrrsignal_p == NULL) {
+            /* No ECH found so we'll exit, but set random output */
+            if (RAND_bytes_ex(sctx->libctx, acbuf, OSSL_ECH_SIGNAL_LEN, 0)
+                <= 0) {
+                SSLfatal(s, SSL_AD_INTERNAL_ERROR, SSL_R_ECH_REQUIRED);
                 goto end;
             }
-            conf_loc = s->ext.ech.hrrsignal_p;
+            rv = 1;
+            goto end;
         }
+        conf_loc = tbuf + tlen - OSSL_ECH_SIGNAL_LEN;
     }
     memset(conf_loc, 0, OSSL_ECH_SIGNAL_LEN);
 #ifdef OSSL_ECH_SUPERVERBOSE
@@ -1096,7 +1091,11 @@ int ossl_ech_calc_confirm(SSL_CONNECTION *s, int for_hrr,
     ossl_ech_pbuf("cx: result", acbuf, OSSL_ECH_SIGNAL_LEN);
 #endif
     /* put confirm value back into transcript */
-    memcpy(conf_loc, acbuf, OSSL_ECH_SIGNAL_LEN);
+    if (s->server == 0 && for_hrr == 1) { /* put back the one we got */
+        memcpy(conf_loc, s->ext.ech.hrrsignal, OSSL_ECH_SIGNAL_LEN);
+    } else {
+        memcpy(conf_loc, acbuf, OSSL_ECH_SIGNAL_LEN);
+    }
     /* on a server, we need to reset the hs buffer now */
     if (s->server && s->hello_retry_request == SSL_HRR_NONE)
         ossl_ech_reset_hs_buffer(s, s->ext.ech.innerch, s->ext.ech.innerch_len);
@@ -1475,9 +1474,11 @@ static int ech_find_outers(SSL_CONNECTION *s, PACKET *pkt,
     }
     *n_outers = olen / 2;
     for (i = 0; i != *n_outers; i++) {
+        /* check for ones that are not allowed */
         if (!PACKET_get_net_2(&op, &pi_tmp)
-            || pi_tmp == TLSEXT_TYPE_outer_extensions) {
-            SSLfatal(s, SSL_AD_DECODE_ERROR, SSL_R_BAD_EXTENSION);
+            || pi_tmp == TLSEXT_TYPE_outer_extensions
+            || pi_tmp == TLSEXT_TYPE_ech) {
+            SSLfatal(s, SSL_AD_ILLEGAL_PARAMETER, SSL_R_BAD_EXTENSION);
             goto err;
         }
         outers[i] = (uint16_t)pi_tmp;
@@ -1491,25 +1492,20 @@ err:
  * copy one extension from outer to inner
  * di is the reconstituted inner CH
  * type2copy is the outer type to copy
- * extsbuf is the outer extensions buffer
- * extslen is the outer extensions buffer length
+ * exts is the outer extensions packet (changing as we go)
  * return 1 for good 0 for error
  */
 static int ech_copy_ext(SSL_CONNECTION *s, WPACKET *di, uint16_t type2copy,
-    const unsigned char *extsbuf, size_t extslen)
+    PACKET *exts)
 {
-    PACKET exts;
     unsigned int etype, elen;
     const unsigned char *eval;

-    if (PACKET_buf_init(&exts, extsbuf, extslen) != 1) {
-        SSLfatal(s, SSL_AD_INTERNAL_ERROR, ERR_R_INTERNAL_ERROR);
-        goto err;
-    }
-    while (PACKET_remaining(&exts) > 0) {
-        if (!PACKET_get_net_2(&exts, &etype)
-            || !PACKET_get_net_2(&exts, &elen)
-            || !PACKET_get_bytes(&exts, &eval, elen)) {
+    /* Skip until we find the thing to copy */
+    while (PACKET_remaining(exts) > 0) {
+        if (!PACKET_get_net_2(exts, &etype)
+            || !PACKET_get_net_2(exts, &elen)
+            || !PACKET_get_bytes(exts, &eval, elen)) {
             SSLfatal(s, SSL_AD_DECODE_ERROR, SSL_R_BAD_EXTENSION);
             goto err;
         }
@@ -1547,6 +1543,7 @@ static int ech_reconstitute_inner(SSL_CONNECTION *s, WPACKET *di, PACKET *ei,
     unsigned int pi_tmp, etype, elen, outer_extslen;
     PACKET outer, session_id;
     size_t i;
+    int outers_done = 0;

     if (PACKET_buf_init(&outer, ob, ob_len) != 1) {
         SSLfatal(s, SSL_AD_INTERNAL_ERROR, ERR_R_INTERNAL_ERROR);
@@ -1620,10 +1617,18 @@ static int ech_reconstitute_inner(SSL_CONNECTION *s, WPACKET *di, PACKET *ei,
             goto err;
         }
         if (etype == TLSEXT_TYPE_outer_extensions) {
+            PACKET exts;
+
+            if (outers_done++) { /* just do this once */
+                SSLfatal(s, SSL_AD_INTERNAL_ERROR, ERR_R_INTERNAL_ERROR);
+                goto err;
+            }
+            if (PACKET_buf_init(&exts, outer_exts, outer_extslen) != 1) {
+                SSLfatal(s, SSL_AD_INTERNAL_ERROR, ERR_R_INTERNAL_ERROR);
+                goto err;
+            }
             for (i = 0; i != n_outers; i++) {
-                if (ech_copy_ext(s, di, outers[i],
-                        outer_exts, outer_extslen)
-                    != 1)
+                if (ech_copy_ext(s, di, outers[i], &exts) != 1)
                     /* SSLfatal called already */
                     goto err;
             }
@@ -1739,7 +1744,7 @@ static unsigned char *hpke_decrypt_encch(SSL_CONNECTION *s,
     size_t aad_len, unsigned char *aad,
     int forhrr, size_t *innerlen)
 {
-    size_t cipherlen = 0;
+    size_t cipherlen = 0, zind = 0;
     unsigned char *cipher = NULL;
     size_t senderpublen = 0;
     unsigned char *senderpub = NULL;
@@ -1876,24 +1881,18 @@ end:
             SSLfatal(s, SSL_AD_DECODE_ERROR, SSL_R_BAD_EXTENSION);
             goto paderr;
         }
-        /*
-         * The RFC calls for that padding to be all zeros. I'm not so
-         * keen on that being a good idea to enforce, so we'll make it
-         * easy to not do so (but check by default)
-         */
-#define CHECKZEROS
-#ifdef CHECKZEROS
-        {
-            size_t zind = 0;
+        /* The RFC calls for that padding to be all zeros */

-            if (*innerlen < ch_len)
+        if (*innerlen < ch_len) {
+            SSLfatal(s, SSL_AD_DECODE_ERROR, SSL_R_BAD_EXTENSION);
+            goto paderr;
+        }
+        for (zind = ch_len; zind != *innerlen; zind++) {
+            if (clear[zind] != 0x00) {
+                SSLfatal(s, SSL_AD_ILLEGAL_PARAMETER, SSL_R_BAD_EXTENSION);
                 goto paderr;
-            for (zind = ch_len; zind != *innerlen; zind++) {
-                if (clear[zind] != 0x00)
-                    goto paderr;
             }
         }
-#endif
         *innerlen = ch_len;
 #ifdef OSSL_ECH_SUPERVERBOSE
         ossl_ech_pbuf("unpadded clear", clear, *innerlen);
@@ -2034,7 +2033,7 @@ int ossl_ech_early_decrypt(SSL_CONNECTION *s, PACKET *outerpkt, PACKET *newpkt)
     es = s->ext.ech.es;
     num = (es == NULL || es->entries == NULL ? 0
                                              : sk_OSSL_ECHSTORE_ENTRY_num(es->entries));
-    for (cfgind = 0; cfgind != num; cfgind++) {
+    for (cfgind = 0; cfgind != num && foundcfg == 0; cfgind++) {
         ee = sk_OSSL_ECHSTORE_ENTRY_value(es->entries, cfgind);
         OSSL_TRACE_BEGIN(TLS)
         {
@@ -2044,8 +2043,15 @@ int ossl_ech_early_decrypt(SSL_CONNECTION *s, PACKET *outerpkt, PACKET *newpkt)
         }
         OSSL_TRACE_END(TLS);
         if (extval->config_id == ee->config_id) {
-            foundcfg = 1;
-            break;
+            unsigned int suite_id;
+
+            /* check aead and kdf match a loaded suite for the config_id */
+            for (suite_id = 0; suite_id != ee->nsuites && foundcfg == 0; suite_id++) {
+                if (ee->suites[suite_id].kdf_id == extval->kdf_id
+                    && ee->suites[suite_id].aead_id == extval->aead_id) {
+                    foundcfg = 1;
+                }
+            }
         }
     }
     if (foundcfg == 1) {
diff --git a/ssl/ech/ech_local.h b/ssl/ech/ech_local.h
index b42196e210..1b31023cba 100644
--- a/ssl/ech/ech_local.h
+++ b/ssl/ech/ech_local.h
@@ -242,6 +242,7 @@ typedef struct ossl_ech_conn_st {
      * not.
      */
     int retry_configs_ok;
+    int inner_ech_seen_ok; /* set if we see inner ECH as expected */
     int grease; /* 1 if we're GREASEing, 0 otherwise */
     char *grease_suite; /* HPKE suite string for GREASEing */
     unsigned char *sent; /* GREASEy ECH value sent, in case needed for re-tx */
diff --git a/ssl/ech/ech_store.c b/ssl/ech/ech_store.c
index fb0b72bde8..1f5d79e021 100644
--- a/ssl/ech/ech_store.c
+++ b/ssl/ech/ech_store.c
@@ -249,8 +249,10 @@ err:
 static int ech_final_config_checks(OSSL_ECHSTORE_ENTRY *ee)
 {
     OSSL_HPKE_SUITE hpke_suite;
-    int ind, num;
-    int goodsuitefound = 0;
+    int ind, num, rv = 0, goodsuitefound = 0;
+    X509_VERIFY_PARAM *vpm = X509_VERIFY_PARAM_new();
+    char *lastlabel = NULL;
+    size_t lllen;

     /* check local support for some suite */
     for (ind = 0; ind != (int)ee->nsuites; ind++) {
@@ -267,7 +269,7 @@ static int ech_final_config_checks(OSSL_ECHSTORE_ENTRY *ee)
     }
     if (goodsuitefound == 0) {
         ERR_raise(ERR_LIB_SSL, ERR_R_PASSED_INVALID_ARGUMENT);
-        return 0;
+        goto err;
     }
     /* check no mandatory exts (with high bit set in type) */
     num = (ee->exts == NULL ? 0 : sk_OSSL_ECHEXT_num(ee->exts));
@@ -276,16 +278,48 @@ static int ech_final_config_checks(OSSL_ECHSTORE_ENTRY *ee)

         if (oe->type & 0x8000) {
             ERR_raise(ERR_LIB_SSL, ERR_R_PASSED_INVALID_ARGUMENT);
-            return 0;
+            goto err;
         }
     }
-    /* check public_name rules, as per spec section 4 */
+    /* check public_name rules, as per spec section 6.1.7 */
     if (ee->public_name == NULL
         || ee->public_name[0] == '\0'
         || ee->public_name[0] == '.'
-        || ee->public_name[strlen(ee->public_name) - 1] == '.')
-        return 0;
-    return 1;
+        || ee->public_name[strlen(ee->public_name) - 1] == '.'
+        || strlen(ee->public_name) > 255)
+        goto err;
+    /*
+     * Use X509_VERIFY_PARAM_add1_host to avoid coding same checks twice.
+     * This checks max 63 octets per label, overall length and some other
+     * DNS label checks.
+     */
+    if (X509_VERIFY_PARAM_add1_host(vpm, ee->public_name, 0) == 0)
+        goto err;
+    /*
+     * but we still have to check the last label restrictions, which
+     * are intended to avoid confusion with IP address literals in
+     * encodings browsers support, as per WHATWG (convincing, eh:-)
+     */
+    lastlabel = strrchr(ee->public_name, '.');
+    if (lastlabel == NULL) /* if there are no dots */
+        lastlabel = ee->public_name;
+    lllen = strlen(lastlabel);
+    if (lllen < 2)
+        goto err;
+    if (lastlabel[0] == '.') {
+        lastlabel++;
+        lllen--;
+    }
+    if (strspn(lastlabel, "0123456789") == lllen)
+        goto err;
+    if (lastlabel[0] == '0' && lllen > 2
+        && (lastlabel[1] == 'x' || lastlabel[1] == 'X')
+        && strspn(lastlabel + 2, "0123456789abcdefABCDEF") == (lllen - 2))
+        goto err;
+    rv = 1;
+err:
+    X509_VERIFY_PARAM_free(vpm);
+    return rv;
 }

 /**
@@ -393,6 +427,13 @@ static int ech_decode_one_entry(OSSL_ECHSTORE_ENTRY **rent, PACKET *pkt,
         ERR_raise(ERR_LIB_SSL, SSL_R_ECH_DECODE_ERROR);
         goto err;
     }
+    /*
+     * We don't really handle ECHConfig extensions as of now,
+     * (none are well-defined), so we're only skipping over
+     * whatever we find here. If/when adding real extensions
+     * then it may be necessary to also check that the set of
+     * extensions loaded contain no duplicate types.
+     */
     if (!PACKET_get_length_prefixed_2(&ver_pkt, &exts)) {
         ERR_raise(ERR_LIB_SSL, SSL_R_ECH_DECODE_ERROR);
         goto err;
@@ -774,6 +815,8 @@ int OSSL_ECHSTORE_new_config(OSSL_ECHSTORE *es,
     epkt_mem->data = NULL;
     epkt_mem->length = 0;
     ee->loadtime = time(0);
+    if (ech_final_config_checks(ee) != 1) /* check our work */
+        goto err;
     /* push entry into store */
     if (es->entries == NULL)
         es->entries = sk_OSSL_ECHSTORE_ENTRY_new_null();
diff --git a/ssl/statem/extensions.c b/ssl/statem/extensions.c
index 417255241c..cc6585493f 100644
--- a/ssl/statem/extensions.c
+++ b/ssl/statem/extensions.c
@@ -53,6 +53,7 @@
  */
 #ifndef OPENSSL_NO_ECH
 static int init_ech(SSL_CONNECTION *s, unsigned int context);
+static int final_ech(SSL_CONNECTION *s, unsigned int context, int sent);
 #endif /* OPENSSL_NO_ECH */

 static int final_renegotiate(SSL_CONNECTION *s, unsigned int context, int sent);
@@ -452,7 +453,7 @@ static const EXTENSION_DEFINITION ext_defs[] = {
         init_ech,
         tls_parse_ctos_ech, tls_parse_stoc_ech,
         tls_construct_stoc_ech, tls_construct_ctos_ech,
-        NULL },
+        final_ech },
     { TLSEXT_TYPE_outer_extensions,
         SSL_EXT_CLIENT_HELLO | SSL_EXT_TLS1_3_ONLY,
         OSSL_ECH_HANDLING_CALL_BOTH,
@@ -1229,6 +1230,16 @@ static int init_ech(SSL_CONNECTION *s, unsigned int context)
         s->ext.ech.done = 0;
     return 1;
 }
+
+static int final_ech(SSL_CONNECTION *s, unsigned int context, int sent)
+{
+    if (s->server && s->ext.ech.success == 1
+        && s->ext.ech.inner_ech_seen_ok != 1) {
+        SSLfatal(s, SSL_AD_ILLEGAL_PARAMETER, SSL_R_ECH_REQUIRED);
+        return 0;
+    }
+    return 1;
+}
 #endif /* OPENSSL_NO_ECH */

 static int final_server_name(SSL_CONNECTION *s, unsigned int context, int sent)
diff --git a/ssl/statem/extensions_srvr.c b/ssl/statem/extensions_srvr.c
index 29be982383..3036812af9 100644
--- a/ssl/statem/extensions_srvr.c
+++ b/ssl/statem/extensions_srvr.c
@@ -2478,6 +2478,7 @@ int tls_parse_ctos_ech(SSL_CONNECTION *s, PACKET *pkt, unsigned int context,
         SSLfatal(s, SSL_AD_DECODE_ERROR, SSL_R_BAD_EXTENSION);
         return 0;
     }
+    s->ext.ech.inner_ech_seen_ok = 1;
     if (s->ext.ech.success != 1 && s->ext.ech.backend != 1) {
         SSLfatal(s, SSL_AD_DECODE_ERROR, SSL_R_BAD_EXTENSION);
         return 0;
diff --git a/ssl/statem/statem_clnt.c b/ssl/statem/statem_clnt.c
index c2b812bd40..3003466a02 100644
--- a/ssl/statem/statem_clnt.c
+++ b/ssl/statem/statem_clnt.c
@@ -1862,8 +1862,6 @@ MSG_PROCESS_RETURN tls_process_server_hello(SSL_CONNECTION *s, PACKET *pkt)
             OSSL_TRACE_END(TLS);
             s->ext.ech.success = 1;
         }
-        /* we're done with that hrrsignal (if we got one) */
-        s->ext.ech.hrrsignal_p = NULL;
         if (!hrr && s->ext.ech.success == 1) {
             if (ossl_ech_swaperoo(s) != 1
                 || ossl_ech_intbuf_fetch(s, &abuf, &alen) != 1
@@ -1871,12 +1869,25 @@ MSG_PROCESS_RETURN tls_process_server_hello(SSL_CONNECTION *s, PACKET *pkt)
                 SSLfatal(s, SSL_AD_INTERNAL_ERROR, ERR_R_INTERNAL_ERROR);
                 goto err;
             }
-        } else if (!hrr) {
+        } else if (hrr == 1 && s->ext.ech.success == 1) {
+            OSSL_TRACE(TLS, "ECH HRR accept ok, continuing.\n");
             /*
              * If we got retry_configs then we should be validating
              * the outer CH, so we better set the hostname for the
              * connection accordingly.
              */
+        } else if (!hrr && s->ext.ech.success == 0
+            && s->ext.ech.hrrsignal_p != NULL) {
+            /*
+             * we previously saw a good HRR ECH acceptance but now
+             * the SH.random ECH acceptance signal is bad so that's an
+             * illegal protocol error
+             */
+            SSLfatal(s, SSL_AD_ILLEGAL_PARAMETER, SSL_R_ECH_REQUIRED);
+            goto err;
+        } else {
+            OSSL_TRACE1(TLS, "ECH falling back to public_name: %s\n",
+                s->ext.ech.outer_hostname != NULL ? s->ext.ech.outer_hostname : "NONE");
             s->ext.ech.former_inner = s->ext.hostname;
             s->ext.hostname = NULL;
             if (s->ext.ech.outer_hostname != NULL) {
diff --git a/test/ech_corrupt_test.c b/test/ech_corrupt_test.c
index ee1edeff5b..7f11a3f7fa 100644
--- a/test/ech_corrupt_test.c
+++ b/test/ech_corrupt_test.c
@@ -384,6 +384,38 @@ static const unsigned char too_many_outers[] = {
     0x00, 0x00, 0x00
 };

+static const unsigned char tlsv12_like_inner[] = {
+    0x03, 0x03, /* version, then client-random */
+    0x23, 0xc3, 0xa0, 0x49, 0xea, 0x17, 0x9e, 0x30,
+    0x6f, 0x0e, 0xc9, 0x79, 0xd0, 0xd1, 0xfd, 0xea,
+    0x63, 0xfd, 0x20, 0x04, 0xaa, 0xb3, 0x2a, 0x29,
+    0xf5, 0x96, 0x60, 0x29, 0x42, 0x7e, 0x5c, 0x7b,
+    0x00, /* zero'd session ID */
+    0x00, 0x02, /* ciphersuite len, just one */
+    0xc0, 0x2c, /* a TLSv1.2 ciphersuite */
+    0x01, 0x00, /* no compression */
+    0x00, 0x2c, /* extslen */
+    0xfd, 0x00, /* outers */
+    0x00, 0x0b, /* len of outers */
+    0x0a, /* above minus one (10) 5 outers */
+    0x00, 0x0a, /* the 'normal' outers, minus supported_versions */
+    0x00, 0x23,
+    0x00, 0x16,
+    0x00, 0x17,
+    0x00, 0x0d,
+    /* and now the inner SNI, inner ECH and padding octets */
+    0x00, 0x00, 0x00, 0x14, 0x00, 0x12, 0x00, 0x00,
+    0x0f, 0x66, 0x6f, 0x6f, 0x2e, 0x65, 0x78, 0x61,
+    0x6d, 0x70, 0x6c, 0x65, 0x2e, 0x63, 0x6f, 0x6d,
+    0xfe, 0x0d, 0x00, 0x01, 0x01,
+    0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00
+};
+
 /*
  * a full padded, encoded inner client hello, but
  * without an inner supported extensions, (take
@@ -443,6 +475,30 @@ static const unsigned char tlsv12_inner[] = {
     0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
 };

+/*
+ * a full padded, encoded inner with no inner ECH
+ * we change 0xfe 0x0d to 0xFF 0xFD in the ext type
+ */
+static const unsigned char encoded_inner_no_ech[] = {
+    0x03, 0x03, 0x7b, 0xe8, 0xc1, 0x18, 0xd7, 0xd1,
+    0x9c, 0x39, 0xa4, 0xfa, 0xce, 0x75, 0x72, 0x40,
+    0xcf, 0x37, 0xbb, 0x4c, 0xcd, 0xa7, 0x62, 0xda,
+    0x04, 0xd2, 0xdb, 0xe2, 0x89, 0x33, 0x36, 0x15,
+    0x96, 0xc9, 0x00, 0x00, 0x08, 0x13, 0x02, 0x13,
+    0x03, 0x13, 0x01, 0x00, 0xff, 0x01, 0x00, 0x00,
+    0x32, 0xfd, 0x00, 0x00, 0x11, 0x10,
+    0x00, 0x0a, 0x00, 0x23, 0x00, 0x16, 0x00, 0x17,
+    0x00, 0x0d, 0x00, 0x2b, 0x00, 0x2d, 0x00, 0x33,
+    0x00, 0x00, 0x00, 0x14, 0x00, 0x12, 0x00, 0x00,
+    0x0f, 0x66, 0x6f, 0x6f, 0x2e, 0x65, 0x78, 0x61,
+    0x6d, 0x70, 0x6c, 0x65, 0x2e, 0x63, 0x6f, 0x6d,
+    0xFF, 0xFD, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00
+};
+
 /* A set of test vectors */
 static TEST_ECHINNER test_inners[] = {
     /* 1. basic case - copy to show test code works with no change */
@@ -471,8 +527,7 @@ static TEST_ECHINNER test_inners[] = {
         encoded_inner_outers, sizeof(encoded_inner_outers),
         bad_pad_encoded_inner_post, sizeof(bad_pad_encoded_inner_post),
         0, /* expected result */
-        SSL_R_DECRYPTION_FAILED_OR_BAD_RECORD_MAC },
-
+        SSL_R_TLS_ALERT_ILLEGAL_PARAMETER },
     /*
      * 6. unsupported extension instead of outers - resulting decoded
      * inner missing so much it seems to be the wrong protocol
@@ -597,7 +652,7 @@ static TEST_ECHINNER test_inners[] = {
      * allow max tlsv1.3
      */
     { NULL, 0,
-        no_supported_exts, sizeof(no_supported_exts),
+        tlsv12_like_inner, sizeof(tlsv12_like_inner),
         NULL, 0,
         0, /* expected result */ SSL_R_UNSUPPORTED_PROTOCOL },
     /*
@@ -614,7 +669,7 @@ static TEST_ECHINNER test_inners[] = {
      * allow min tlsv1.2
      */
     { NULL, 0,
-        no_supported_exts, sizeof(no_supported_exts),
+        tlsv12_like_inner, sizeof(tlsv12_like_inner),
         NULL, 0,
         0, /* expected result */ SSL_R_UNSUPPORTED_PROTOCOL },
     /* 25. smuggled TLSv1.2 CH */
@@ -622,6 +677,11 @@ static TEST_ECHINNER test_inners[] = {
         tlsv12_inner, sizeof(tlsv12_inner),
         NULL, 0,
         0, /* expected result */ SSL_R_BAD_EXTENSION },
+    /* 26. otherwise-correct case that fails due to lack of inner ECH */
+    { NULL, 0,
+        encoded_inner_no_ech, sizeof(encoded_inner_no_ech),
+        NULL, 0,
+        0, /* expected result */ SSL_R_ECH_REQUIRED },
 };

 /*
@@ -677,8 +737,7 @@ static TEST_SH test_shs[] = {
         SSL_R_DECRYPTION_FAILED_OR_BAD_RECORD_MAC },
     /* 5. flip bits in HRR.exts ECH confirmation value */
     { OSSL_ECH_BORK_HRR | OSSL_ECH_BORK_FLIP,
-        NULL, 0, 0,
-        SSL_R_DECRYPTION_FAILED_OR_BAD_RECORD_MAC },
+        NULL, 0, 0, SSL_R_ECH_REQUIRED },
     /* 6. truncate HRR.exts ECH confirmation value */
     { OSSL_ECH_BORK_HRR | OSSL_ECH_BORK_REPLACE,
         shortech, sizeof(shortech), 0, SSL_R_LENGTH_MISMATCH },
diff --git a/test/ech_test.c b/test/ech_test.c
index c26a33dc12..49bc26ad47 100644
--- a/test/ech_test.c
+++ b/test/ech_test.c
@@ -1208,6 +1208,67 @@ end:
     return rv;
 }

+/*
+ * Check whether various public_name values are good or bad according to
+ * our RFC 9849 checker, which imposes some oddball restrictions on those.
+ * Read section 6.1.7 of RFC 9849 for details.
+ */
+static int ech_bad_public_names(void)
+{
+    int rv = 0, i;
+    OSSL_ECHSTORE *es = NULL;
+    OSSL_HPKE_SUITE hpke_suite = OSSL_HPKE_SUITE_DEFAULT;
+    const char *bad_names[] = {
+        ".dot.", /* leading dot */
+        "dot.", /* trailing dot */
+        ".dot", /* check both, why not */
+        /* a label > 62 chars (70 in this case) */
+        "abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghij.org",
+        /* last label numeric */
+        "last.one.is.numeric.456",
+        "456",
+        /* last label ascii-hex */
+        "last.ah.0x123",
+        "0x123"
+    };
+    const char *good_names[] = {
+        "example.com",
+        "0x123.stuff",
+        "0x",
+        "a-b.c",
+        "example.com.c",
+        "a.b.0x1234567890abcdefX",
+        "a.b.1234567890Y"
+    };
+
+    if (!TEST_ptr(es = OSSL_ECHSTORE_new(libctx, propq)))
+        goto end;
+    for (i = 0; i != OSSL_NELEM(bad_names); i++) {
+        if (verbose)
+            TEST_info("checking bad name |%s|", bad_names[i]);
+        if (!TEST_false(OSSL_ECHSTORE_new_config(es, 0xfe0d, 0, bad_names[i],
+                hpke_suite))) {
+            if (verbose)
+                TEST_info("bad name |%s| erroneously accepted", bad_names[i]);
+            goto end;
+        }
+    }
+    for (i = 0; i != OSSL_NELEM(good_names); i++) {
+        if (verbose)
+            TEST_info("checking good name |%s|", good_names[i]);
+        if (!TEST_true(OSSL_ECHSTORE_new_config(es, 0xfe0d, 0, good_names[i],
+                hpke_suite))) {
+            if (verbose)
+                TEST_info("good name |%s| erroneously rejected", good_names[i]);
+            goto end;
+        }
+    }
+    rv = 1;
+end:
+    OSSL_ECHSTORE_free(es);
+    return rv;
+}
+
 /* values that can be used in helper below */
 #define OSSL_ECH_TEST_BASIC 0
 #define OSSL_ECH_TEST_HRR 1
@@ -2000,6 +2061,7 @@ int setup_tests(void)
     ADD_ALL_TESTS(ech_test_file_read, OSSL_NELEM(fnames));
     ADD_TEST(ech_api_basic_calls);
     ADD_TEST(ech_boring_compat);
+    ADD_TEST(ech_bad_public_names);
     suite_combos = OSSL_NELEM(kem_str_list) * OSSL_NELEM(kdf_str_list)
         * OSSL_NELEM(aead_str_list);
     ADD_ALL_TESTS(test_ech_suites, suite_combos);