Commit 2230c67f94 for openssl.org

commit 2230c67f94eaadd75351c36c232192458c821448
Author: sftcd <stephen.farrell@cs.tcd.ie>
Date:   Tue Feb 17 16:48:18 2026 +0000

    ECH: change from I-D to RFC 9849 and resolve TODO(ECH) cases

    Reviewed-by: Matt Caswell <matt@openssl.org>
    Reviewed-by: Tomas Mraz <tomas@openssl.org>
    MergeDate: Thu Feb 19 09:22:37 2026
    (Merged from https://github.com/openssl/openssl/pull/30048)

diff --git a/CHANGES.md b/CHANGES.md
index 249242276c..7aae59f5dc 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -293,6 +293,11 @@ OpenSSL 4.0

    *Joachim Vandersmissen* (with additional support from *Viktor Dukhovni*)

+ * Implemented RFC 9849, adding support for Encrypted Client Hello (ECH).
+   See doc/design/ech-api.md for details.
+
+   *Stephen Farrell* (with much support from *Matt Caswell* and *Tomáš Mráz*)
+
 OpenSSL 3.6
 -----------

diff --git a/NEWS.md b/NEWS.md
index 016faaa314..6d572984a4 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -27,6 +27,9 @@ OpenSSL 4.0

 ### Major changes between OpenSSL 3.6 and OpenSSL 4.0 [under development]

+  * Support for Encrypted Client Hello (ECH) was added. See `doc/designs/ech-api.md`
+    for details.
+
   * Windows supports the use of staic or dynamic vcruntime linkage

   * The `OPENSSL_sk_set_cmp_thunks()` call was added to the `STACK_OF` api
diff --git a/apps/ech.c b/apps/ech.c
index 74579d82d1..4626ec6363 100644
--- a/apps/ech.c
+++ b/apps/ech.c
@@ -165,9 +165,9 @@ int ech_main(int argc, char **argv)
     }
     /* Check ECH-specific inputs */
     switch (ech_version) {
-    case OSSL_ECH_RFCXXXX_VERSION: /* fall through */
+    case OSSL_ECH_RFC9849_VERSION: /* fall through */
     case 13:
-        ech_version = OSSL_ECH_RFCXXXX_VERSION;
+        ech_version = OSSL_ECH_RFC9849_VERSION;
         break;
     default:
         BIO_printf(bio_err, "Un-supported version (0x%04x)\n", ech_version);
diff --git a/doc/designs/ech-api.md b/doc/designs/ech-api.md
index 1edf710e76..18cd8199f4 100644
--- a/doc/designs/ech-api.md
+++ b/doc/designs/ech-api.md
@@ -1,11 +1,8 @@
 Encrypted ClientHello (ECH) APIs
 ================================

-The ECH [feature branch](https://github.com/openssl/openssl/tree/feature/ech).
-has an implementation of Encrypted Client Hello (ECH) and these are design
-notes for the APIs implemented there.
-
-This text was last updated on 2025-11-20.
+OpenSSL 4.0 includes an implementation of Encrypted Client Hello (ECH), as
+specified in RFC 9849. These are design notes for the APIs implemented for ECH.

 The ECH Protocol
 ----------------
@@ -50,25 +47,18 @@ interfaces.
 ECH Specification
 -----------------

-ECH is an IETF TLS WG specification. It has been stable since
+RFC 9849 is an IETF TLS WG specification. It has been stable since
 [draft-13](https://datatracker.ietf.org/doc/draft-ietf-tls-esni/13/), published
-in August 2021.  The latest draft can be found
-[here](https://datatracker.ietf.org/doc/draft-ietf-tls-esni/).
-The specification is currently in the RFC editor's queue and is
-part of a [cluster](https://www.rfc-editor.org/cluster_info.php?cid=C430)
-of related drafts that will be published together.
-
-The only current ECHConfig version defined is 0xfe0d which will be the
-value to be used in the eventual RFC when that issues. (We'll replace the
-XXXX with the relevant RFC number once that's known.)
-TODO(ECH): Update XXXX when RFC published, and check other occurrences
-of XXXX throughout the source tree.
+in August 2021.
+
+The only current ECHConfig version defined is 0xfe0d which is the value used in
+RFC 9849.

 ```c
-/* version from RFC XXXX */
-#  define OSSL_ECH_RFCXXXX_VERSION 0xfe0d
+/* version from RFC 9849 */
+#  define OSSL_ECH_RFC9849_VERSION 0xfe0d
 /* latest version from an RFC */
-#  define OSSL_ECH_CURRENT_VERSION OSSL_ECH_RFCXXXX_VERSION
+#  define OSSL_ECH_CURRENT_VERSION OSSL_ECH_RFC9849_VERSION
 ```

 Note that 0xfe0d is also the value of the ECH extension codepoint:
@@ -85,14 +75,11 @@ ECH PEM file format

 Servers supporting ECH need to read a set of ECH private keys and
 ECHConfigLists from storage. There is a specification for a
-[PEM file format for ECH](https://datatracker.ietf.org/doc/draft-farrell-tls-pemesni/)
-that is supported by the library. That specification is being
-processed within the IETF as an area-director sponsored draft,
-so is not a TLS WG work item, but will be an IETF stream
-RFC when completed.
+[PEM file format for ECH](https://datatracker.ietf.org/doc/rfc9934)
+that is supported by the library.

 This PEM file format is supported by code for a number of TLS servers,
-including (at the time of writing) lighttpd, freenginx, apache2 and haproxy.
+including (at the time of writing) lighttpd, freenginx, nginx, apache2 and haproxy.
 ECH support in those servers is currently an experimental feature or similar.

 Minimal Sample Code
@@ -134,8 +121,7 @@ ECH), and application data messages (where padding may affect efficiency more).
 ECHConfig Extensions
 --------------------

-The ECH protocol supports extensibility [within the ECHConfig
-structure](https://www.ietf.org/archive/id/draft-ietf-tls-esni-25.html#name-configuration-extensions)
+The ECH protocol supports extensibility within the ECHConfig structure
 via a typical TLS type, length, value scheme.  However, to date, there are no
 extensions defined, nor do other implementations provide APIs for adding or
 manipulating ECHConfig extensions. We therefore take the same approach here.
@@ -326,7 +312,7 @@ typedef struct ossl_echext_st {
 DEFINE_STACK_OF(OSSL_ECHEXT)

 typedef struct ossl_echstore_entry_st {
-    uint16_t version; /* 0xfe0d for RFC XXXX */
+    uint16_t version; /* 0xfe0d for RFC 9849 */
     char *public_name;
     size_t pub_len;
     unsigned char *pub;
@@ -391,56 +377,19 @@ In addition to the obvious fields from each ECHConfig, we also store:
   This value is useful when servers periodically re-load sets of files
   or PEM structures from memory, e.g. for the haproxy server.

-Split-mode handling
--------------------
+ECH Split-mode
+--------------

-TODO(ECH): This ECH split-mode API should be considered tentative. It's design
-should be revisited now, and either omitted from the initial release that'd
-only support shared-mode ECH, or else (better:-), agreed and included in the
-same time frame.
+RFC 9849 defines two modes for ECH - 'shared-mode' which is supported in
+this release, and a 'split-mode' which is not yet supported.

 ECH split-mode involves a front-end server that only does ECH decryption and
 then passes on the decrypted inner CH to a back-end TLS server that negotiates
-the actual TLS session with the client, based on the inner CH content. The
-function to support this simply takes the outer CH, indicates whether
-decryption has succeeded or not, and if it has, returns the inner CH and SNI
-values (allowing routing to the correct back-end). Both the supplied (outer)
-CH and returned (inner) CH here include the record layer header.
-
-```c
-int SSL_CTX_ech_raw_decrypt(SSL_CTX *ctx,
-                            int *decrypted_ok,
-                            char **inner_sni, char **outer_sni,
-                            unsigned char *outer_ch, size_t outer_len,
-                            unsigned char *inner_ch, size_t *inner_len,
-                            unsigned char **hrrtok, size_t *toklen);
-```
+the actual TLS session with the client, based on the inner CH content.

-The caller allocates the `inner_ch` buffer, on input `inner_len` should
-contain the size of the `inner_ch` buffer, on output the size of the actual
-inner CH. Note that, when ECH decryption succeeds, the inner CH will always be
-smaller than the outer CH.
-
-If there is no ECH present in the outer CH then this will return 1 (i.e., the
-call will succeed) but `decrypted_ok` will be zero. The same will result if a
-GREASEd ECH is present or decryption fails for some other (indistinguishable)
-reason.
-
-If the caller wishes to support HelloRetryRequest (HRR), then it must supply
-the same `hrrtok` and `toklen` pointers to both calls to
-`SSL_CTX_ech_raw_decrypt()` (for the initial and second ClientHello
-messages). When done, the caller must free the `hrrtok` using
-`OPENSSL_free()`.  If the caller doesn't need to support HRR, then it can
-supply NULL values for these parameters. The value of the token is the client's
-ephemeral public value, which is not sensitive having being sent in clear in
-the first ClientHello.  This value is missing from the second ClientHello but
-is needed for ECH decryption.
-
-Note that `SSL_CTX_ech_raw_decrypt()` only takes a ClientHello as input. If
-the flight containing the ClientHello contains other messages (e.g. a
-ChangeCipherSuite or Early data), then the caller is responsible for
-disentangling those, and for assembling a new flight containing the inner
-ClientHello.
+This release does however support servers that act as the back-end TLS server
+in an ECH split-mode scenario, as the functionality required is internal and
+also required when using shared-mode.

 Different encodings
 -------------------
diff --git a/doc/man1/openssl-ech.pod.in b/doc/man1/openssl-ech.pod.in
index e2a91b2118..f905b950c0 100644
--- a/doc/man1/openssl-ech.pod.in
+++ b/doc/man1/openssl-ech.pod.in
@@ -23,8 +23,7 @@ B<openssl> B<ech>

 The L<openssl-ech(1)> command generates Encrypted Client Hello (ECH) key pairs
 in the ECHConfig PEM file format as specified in
-L<https://datatracker.ietf.org/doc/draft-farrell-tls-pemesni/>.
-TODO(ECH): update I-D reference to RFC when possible.
+L<https://datatracker.ietf.org/doc/rfc9934>.

 That format consists of an optional private key in PKCS#8 format and a base64
 encoded ECHConfigList containing an entry with a matching public value (and
diff --git a/doc/man1/openssl-s_client.pod.in b/doc/man1/openssl-s_client.pod.in
index 7ecfc1f3b6..87d540aee1 100644
--- a/doc/man1/openssl-s_client.pod.in
+++ b/doc/man1/openssl-s_client.pod.in
@@ -191,9 +191,7 @@ If the host string is an IPv6 address, it must be enclosed in C<[> and C<]>.
 Specifies the ECHConfigList value to use for Encrypted Client Hello (ECH) for
 the TLS session. The value must be a base64 encoded ECHConfigList.

-The ECHConfigList structure is defined in RFC XXXX. (That's currently in
-L<https://datatracker.ietf.org/doc/html/draft-ietf-tls-esni/>.)
-TODO(ECH): replace XXXX when RFC published.
+The ECHConfigList structure is defined in RFC 9849.

 =item B<-proxy_user> I<userid>

diff --git a/doc/man3/SSL_CTX_set_client_hello_cb.pod b/doc/man3/SSL_CTX_set_client_hello_cb.pod
index 78f5a89302..7ae2f0a06c 100644
--- a/doc/man3/SSL_CTX_set_client_hello_cb.pod
+++ b/doc/man3/SSL_CTX_set_client_hello_cb.pod
@@ -111,7 +111,10 @@ The SSL_client_hello_get0_*() functions return raw ClientHello data, whereas
 SSL_client_hello_get1_extensions_present() returns only recognized extensions
 (so unknown/GREASE-extensions are not included).

-TODO(ECH): How ECH is handled here needs to be documented.
+When Encrypted Client Hello (ECH) is used, and ECH decryption has succeeded,
+then the inner ClientHello message is the content visible to the ClientHello
+callback functions. If ECH decryption failed, then the outer ClientHello
+message content will be provided.

 =head1 RETURN VALUES

diff --git a/doc/man3/SSL_set1_echstore.pod b/doc/man3/SSL_set1_echstore.pod
index 39b5efbce1..691ddfcf9d 100644
--- a/doc/man3/SSL_set1_echstore.pod
+++ b/doc/man3/SSL_set1_echstore.pod
@@ -61,8 +61,6 @@ SSL_CTX_ech_set_callback,SSL_set1_ech_config_list

 =head1 DESCRIPTION

-TODO(ECH): Replace XXXX and links to I-D when RFC issued.
-
 The Encrypted Client Hello (ECH) APIs described here are built around
 the concept of an B<OSSL_ECHSTORE> which contains ECH configuration
 information relevant for an B<SSL_CTX> or B<SSL> connection.
@@ -122,14 +120,8 @@ it will be set to 0. Similarly if the ECHConfig for this entry will be included
 in "retry-configs" then the I<for_retry> will be set to 1 or zero if that
 ECHConfig will not be included in retry-configs.

-The ECH fallback scheme involving retry-configs is described at
-L<https://datatracker.ietf.org/doc/html/draft-ietf-tls-esni-25#section-6.1.6>.
-
-=begin comment
-
-TODO(ECH): The I-D above should be replaced with the RFC when published.
-
-=end comment
+The ECH fallback scheme involving retry-configs is described
+in Section 6.1.1 of RFC 9849.

 OSSL_ECHSTORE_downselect() provides the caller a way to select one particular
 ECHConfig value based on the zero-based I<index> from those stored in the
@@ -301,9 +293,9 @@ ECH version - the only supported version is 0xfe0d currently.

 =over 4

-=item B<OSSL_ECH_RFCXXXX_VERSION> 0xfe0d, official ECHConfig version
+=item B<OSSL_ECH_RFC9849_VERSION> 0xfe0d, official ECHConfig version

-=item B<OSSL_ECH_CURRENT_VERSION> OSSL_ECH_RFCXXXX_VERSION
+=item B<OSSL_ECH_CURRENT_VERSION> OSSL_ECH_RFC9849_VERSION

 =back

@@ -398,11 +390,11 @@ which (unusually for OpenSSL) return 0 on success and 1 on error.

 =over 4

-=item Encrypted ClientHello specification: L<https://datatracker.ietf.org/doc/draft-ietf-tls-esni/>
+=item Encrypted ClientHello specification: L<https://datatracker.ietf.org/doc/rfc9849>

-=item ECH PEM file format specification: L<https://datatracker.ietf.org/doc/draft-farrell-tls-pemesni/>
+=item ECH PEM file format specification: L<https://datatracker.ietf.org/doc/rfc9934>

-=item RFC8701: L<https://datatracker.ietf.org/doc/html/rfc8701>
+=item RFC8701: L<https://datatracker.ietf.org/doc/rfc8701>

 =back

diff --git a/include/openssl/ech.h b/include/openssl/ech.h
index ab724ed907..780b14d8d4 100644
--- a/include/openssl/ech.h
+++ b/include/openssl/ech.h
@@ -35,13 +35,12 @@
 #define OSSL_ECH_ALLEXTS_MAX 32 /* max total number of extension we allow */

 /*
- * ECH version. We only support RFC XXXX as of now.  As/if new ECHConfig
+ * ECH version. We only support RFC 9849 as of now.  As/if new ECHConfig
  * versions are added, those will be noted here.
- * TODO(ECH): Replace XXXX with the actual RFC number once known.
  */
-#define OSSL_ECH_RFCXXXX_VERSION 0xfe0d /* official ECHConfig version */
+#define OSSL_ECH_RFC9849_VERSION 0xfe0d /* official ECHConfig version */
 /* latest version from an RFC */
-#define OSSL_ECH_CURRENT_VERSION OSSL_ECH_RFCXXXX_VERSION
+#define OSSL_ECH_CURRENT_VERSION OSSL_ECH_RFC9849_VERSION

 /* Return codes from SSL_ech_get1_status */
 #define SSL_ECH_STATUS_BACKEND 4 /* ECH backend: saw an ech_is_inner */
diff --git a/ssl/ech/ech_internal.c b/ssl/ech/ech_internal.c
index 579185e70a..fbc6c5bbf6 100644
--- a/ssl/ech/ech_internal.c
+++ b/ssl/ech/ech_internal.c
@@ -399,7 +399,7 @@ int ossl_ech_pick_matching_cfg(SSL_CONNECTION *s, OSSL_ECHSTORE_ENTRY **ee,
     }
     for (cind = 0; cind < num && (suitematch == 0 || namematch == 0); cind++) {
         lee = sk_OSSL_ECHSTORE_ENTRY_value(es->entries, cind);
-        if (lee == NULL || lee->version != OSSL_ECH_RFCXXXX_VERSION)
+        if (lee == NULL || lee->version != OSSL_ECH_RFC9849_VERSION)
             continue;
         if (nameoverride == 1 && hnlen == 0) {
             namematch = 1;
@@ -1829,7 +1829,7 @@ end:
 #endif
     /* we need to remove possible (actually, v. likely) padding */
     *innerlen = clearlen;
-    if (ee->version == OSSL_ECH_RFCXXXX_VERSION) {
+    if (ee->version == OSSL_ECH_RFC9849_VERSION) {
         /* draft-13 pads after the encoded CH with zeros */
         size_t extsoffset = 0;
         size_t extslen = 0;
diff --git a/ssl/ech/ech_store.c b/ssl/ech/ech_store.c
index 58b20f351a..77a723ffaf 100644
--- a/ssl/ech/ech_store.c
+++ b/ssl/ech/ech_store.c
@@ -45,11 +45,7 @@ static const char B64_alphabet[] = "\x41\x42\x43\x44\x45\x46\x47\x48\x49\x4a\x4b
                                    "\x32\x33\x34\x35\x36\x37\x38\x39\x2b\x2f\x3d\x3b";

 #ifndef TLSEXT_MINLEN_host_name
-/*
- * TODO(ECH): Decide this. The shortest DNS name we allow, e.g. "a.bc" - maybe
- * that should be defined elsewhere, or should the check be skipped in case
- * there's a local deployment that uses shorter names?
- */
+/* The shortest DNS name we allow, e.g. "a.bc" */
 #define TLSEXT_MINLEN_host_name 4
 #endif

@@ -161,8 +157,8 @@ static int ech_check_format(const unsigned char *val, size_t len, int *fmt)
         return 0;
     /* binary encoding starts with two octet length and ECH version */
     if (len == 2 + ((size_t)(val[0]) * 256 + (size_t)(val[1]))
-        && val[2] == ((OSSL_ECH_RFCXXXX_VERSION / 256) & 0xff)
-        && val[3] == ((OSSL_ECH_RFCXXXX_VERSION % 256) & 0xff)) {
+        && val[2] == ((OSSL_ECH_RFC9849_VERSION / 256) & 0xff)
+        && val[3] == ((OSSL_ECH_RFC9849_VERSION % 256) & 0xff)) {
         *fmt = OSSL_ECH_FMT_BIN;
         return 1;
     }
@@ -338,7 +334,7 @@ static int ech_decode_one_entry(OSSL_ECHSTORE_ENTRY **rent, PACKET *pkt,
     }
     ech_content_length = (unsigned int)PACKET_remaining(&ver_pkt);
     switch (ee->version) {
-    case OSSL_ECH_RFCXXXX_VERSION:
+    case OSSL_ECH_RFC9849_VERSION:
         break;
     default:
         /* skip over in case we get something we can handle later */
@@ -666,7 +662,7 @@ int OSSL_ECHSTORE_new_config(OSSL_ECHSTORE *es,
     }
     /* this used have more versions and will again in future */
     switch (echversion) {
-    case OSSL_ECH_RFCXXXX_VERSION:
+    case OSSL_ECH_RFC9849_VERSION:
         break;
     default:
         ERR_raise(ERR_LIB_SSL, ERR_R_PASSED_INVALID_ARGUMENT);
@@ -941,7 +937,7 @@ int OSSL_ECHSTORE_get1_info(OSSL_ECHSTORE *es, int index, time_t *loaded_secs,
         ERR_raise(ERR_LIB_SSL, ERR_R_INTERNAL_ERROR);
         goto err;
     }
-    if (ee->version != OSSL_ECH_RFCXXXX_VERSION) {
+    if (ee->version != OSSL_ECH_RFC9849_VERSION) {
         /* just note we don't support that one today */
         BIO_printf(out, "[Unsupported version (%04x)]", ee->version);
     } else {
diff --git a/ssl/statem/statem_clnt.c b/ssl/statem/statem_clnt.c
index a953a175ee..79fa092f59 100644
--- a/ssl/statem/statem_clnt.c
+++ b/ssl/statem/statem_clnt.c
@@ -1237,7 +1237,7 @@ __owur CON_FUNC_RETURN tls_construct_client_hello(SSL_CONNECTION *s,
             SSLfatal(s, SSL_AD_INTERNAL_ERROR, ERR_R_UNSUPPORTED);
             return 0;
         }
-        if (ee->version != OSSL_ECH_RFCXXXX_VERSION) {
+        if (ee->version != OSSL_ECH_RFC9849_VERSION) {
             /* we only support that version for now */
             SSLfatal(s, SSL_AD_INTERNAL_ERROR, ERR_R_UNSUPPORTED);
             return 0;