From 4b91de458aaa1e5b1d9198826f79bd05a5293eae Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 22 Oct 2026 13:52:22 -0400 Subject: [PATCH 01/22] EncryptedClientHello support in ssl module Exposes options for clients to use Encrypted Client Hello (ECH) when establishing TLS connections. It is up to the user to source the ECH public keys before making use of these options. Co-authored-by: irl --- Doc/library/ssl.rst | 120 +++++++++ Lib/ssl.py | 49 +++- Misc/ACKS | 1 + ...5-06-12-15-39-48.gh-issue-89730.Lu35Fu.rst | 8 + Modules/_ssl.c | 245 ++++++++++++++++++ Modules/clinic/_ssl.c.h | 153 ++++++++++- configure | 57 ++++ configure.ac | 6 + pyconfig.h.in | 3 + 9 files changed, 632 insertions(+), 10 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-06-12-15-39-48.gh-issue-89730.Lu35Fu.rst diff --git a/Doc/library/ssl.rst b/Doc/library/ssl.rst index 5f8a1e36bc05d94..ab00f777a1a55e8 100644 --- a/Doc/library/ssl.rst +++ b/Doc/library/ssl.rst @@ -1032,6 +1032,13 @@ Constants .. versionadded:: 3.6 +.. class:: ECHStatus + + :class:`enum.IntEnum` collection of Encrypted Client Hello (ECH) statuses + returned by :meth:`SSLSocket.get_ech_status`. + + .. versionadded:: next + .. data:: Purpose.SERVER_AUTH Option for :func:`create_default_context` and @@ -1368,6 +1375,22 @@ SSL sockets also have the following additional methods and attributes: .. versionadded:: 3.3 +.. method:: SSLSocket.get_ech_retry_config() + + When the status returned by :meth:`SSLSocket.get_ech_status` after completion of the + handshake is :data:`ECHStatus.ECH_STATUS_GREASE_ECH`, this method returns the + configuration value provided by the server to be used for a new connection using + ECH. + + .. versionadded:: next + +.. method:: SSLSocket.get_ech_status() + + Gets the status of Encrypted Client Hello (ECH) processing. Returns an + :class:`ECHStatus` instance. + + .. versionadded:: next + .. method:: SSLSocket.selected_alpn_protocol() Return the protocol that was selected during the TLS handshake. If @@ -1440,6 +1463,15 @@ SSL sockets also have the following additional methods and attributes: .. versionadded:: 3.2 +.. attribute:: SSLSocket.outer_server_hostname + + Hostname of the server name used in the outer ClientHello when Encrypted Client + Hello (ECH) is used: :class:`str` type, or ``None`` for server-side socket or + if the outer server name was not specified in the constructor or the ECH + configuration. + + .. versionadded:: next + .. attribute:: SSLSocket.server_side A boolean which is ``True`` for server-side sockets and ``False`` for @@ -1816,6 +1848,24 @@ to speed up repeated connections from the same clients. .. versionadded:: 3.5 +.. method:: SSLContext.set_ech_config(ech_config) + + Sets an Encrypted Client Hello (ECH) configuration, which may be discovered from + an HTTPS resource record in DNS or from :meth:`SSLSocket.get_ech_retry_config`. + Multiple calls to this functions will accumulate the set of values available for + a connection. + + If the input value provided contains no suitable value (e.g. if it only contains + ECH configuration versions that are not supported), an :class:`SSLError` will be + raised. + + The ech_config parameter should be a bytes-like object containing the raw ECH + configuration. + + This method will raise :exc:`NotImplementedError` if :data:`HAS_ECH` is ``False``. + + .. versionadded:: next + .. method:: SSLContext.set_npn_protocols(npn_protocols) Specify which protocols the socket should advertise during the SSL/TLS @@ -1835,6 +1885,28 @@ to speed up repeated connections from the same clients. NPN has been superseded by ALPN +.. method:: SSLContext.set_outer_alpn_protocols(protocols) + + Specify which protocols the socket should advertise during the TLS + handshake in the outer ClientHello when ECH is used. The *protocols* + argument accepts the same values as for + :meth:`~SSLContext.set_alpn_protocols`. + + This method will raise :exc:`NotImplementedError` if :data:`HAS_ECH` is + ``False``. + + .. versionadded:: next + +.. method:: SSLContext.set_outer_server_hostname(server_hostname) + + Specify which hostname the socket should advertise during the TLS + handshake in the outer ClientHello when ECH is used. + + This method will raise :exc:`NotImplementedError` if :data:`HAS_ECH` is + ``False``. + + .. versionadded:: next + .. attribute:: SSLContext.sni_callback Register a callback function that will be called after the TLS Client Hello @@ -2753,6 +2825,8 @@ provided. - :meth:`~SSLSocket.verify_client_post_handshake` - :meth:`~SSLSocket.unwrap` - :meth:`~SSLSocket.get_channel_binding` + - :meth:`~SSLSocket.get_ech_retry_config` + - :meth:`~SSLSocket.get_ech_status` - :meth:`~SSLSocket.version` When compared to :class:`SSLSocket`, this object lacks the following @@ -2977,6 +3051,52 @@ of TLS/SSL. Some new TLS 1.3 features are not yet available. - TLS 1.3 features like early data, deferred TLS client cert request, and rekeying are not supported yet. +Encrypted Client Hello +^^^^^^^^^^^^^^^^^^^^^^ + +.. versionadded:: next + +Encrypted Client Hello (ECH) allows for encrypting values that have previously only been +included unencrypted in the ClientHello records when establishing a TLS connection. To use +ECH it is necessary to provide configuration values that contain a version, algorithm +parameters, the public key to use for HPKE encryption and the "public_name" that is by +default used for the unencrypted (outer) SNI when ECH is attempted. These configuration +values may be discovered through DNS or through the "retry config" mechanism. + +The following example assumes that you have discovered a set of ECH configuration values +from DNS, or *ech_configs* may be an empty list to rely on the "retry config" mechanism:: + + import socket + import ssl + + + def connect_with_tls_ech(hostname: str, ech_configs: List[str], + use_retry_config: bool=True) -> ssl.SSLSocket: + context = ssl.create_default_context() + for ech_config in ech_configs: + context.set_ech_config(ech_config) + with socket.create_connection((hostname, 443)) as sock: + with context.wrap_socket(sock, server_hostname=hostname) as ssock: + if (ssock.get_ech_status == ECHStatus.ECH_STATUS_GREASE_ECH + and use_retry_config): + return connect_with_ech(hostname, [ssock.get_ech_retry_config()], + False) + return ssock + + hostname = "www.python.org" + ech_configs = [] # Replace with a call to a function to lookup + # ECH configurations in DNS + + ssock = connect_with_tls_ech(hostname, ech_configs) + +The following classes, methods, and attributes will be useful for using ECH: + + - :class:`ECHStatus` + - :meth:`SSLContext.set_ech_config` + - :meth:`SSLContext.set_outer_alpn_protocols` + - :meth:`SSLContext.set_outer_server_hostname` + - :meth:`SSLSocket.get_ech_status` + - :meth:`SSLSocket.get_ech_retry_config` .. seealso:: diff --git a/Lib/ssl.py b/Lib/ssl.py index 59593cfe7d5e700..7f4fedcc4432c19 100644 --- a/Lib/ssl.py +++ b/Lib/ssl.py @@ -150,6 +150,11 @@ lambda name: name.startswith('CERT_'), source=_ssl) +_IntEnum._convert_( + 'ECHStatus', __name__, + lambda name: name.startswith('ECH_STATUS_'), + source=_ssl) + PROTOCOL_SSLv23 = _SSLMethod.PROTOCOL_SSLv23 = _SSLMethod.PROTOCOL_TLS _PROTOCOL_NAMES = frozendict({ value: name for name, value in _SSLMethod.__members__.items()}) @@ -517,16 +522,13 @@ def shim_cb(sslobj, servername, sslctx): self.sni_callback = shim_cb def set_alpn_protocols(self, alpn_protocols): - protos = bytearray() - for protocol in alpn_protocols: - b = bytes(protocol, 'ascii') - if len(b) == 0 or len(b) > 255: - raise SSLError('ALPN protocols must be 1 to 255 in length') - protos.append(len(b)) - protos.extend(b) - + protos = encode_alpn_protocol_list(alpn_protocols) self._set_alpn_protocols(protos) + def set_outer_alpn_protocols(self, alpn_protocols): + protos = encode_alpn_protocol_list(alpn_protocols) + self._set_outer_alpn_protocols(protos) + def _load_windows_store_certs(self, storename, purpose): try: for cert, encoding, trust in enum_certificates(storename): @@ -846,6 +848,11 @@ def context(self): def context(self, ctx): self._sslobj.context = ctx + @property + def outer_server_hostname(self): + """The server name used in the outer ClientHello.""" + return self._sslobj.get_ech_status()[2] + @property def session(self): """The SSLSession for client socket.""" @@ -995,6 +1002,9 @@ def version(self): def verify_client_post_handshake(self): return self._sslobj.verify_client_post_handshake() + def get_ech_status(self): + return ECHStatus(self._sslobj.get_ech_status()[0]) + def _sslcopydoc(func): """Copy docstring from SSLObject to SSLSocket""" @@ -1021,11 +1031,14 @@ def __init__(self, *args, **kwargs): @classmethod def _create(cls, sock, server_side=False, do_handshake_on_connect=True, suppress_ragged_eofs=True, server_hostname=None, - context=None, session=None): + context=None, session=None, outer_server_hostname=None): if sock.getsockopt(SOL_SOCKET, SO_TYPE) != SOCK_STREAM: raise NotImplementedError("only stream sockets are supported") _check_sslobject_params(server_side=server_side, context=context, server_hostname=server_hostname, session=session) + if server_side and outer_server_hostname: + raise ValueError("outer_server_hostname can only be specified " + "in client mode") sock_timeout = sock.gettimeout() kwargs = dict( @@ -1121,6 +1134,11 @@ def context(self, ctx): self._context = ctx self._sslobj.context = ctx + @property + def outer_server_hostname(self): + """The server name used in the outer ClientHello.""" + return self._sslobj.get_ech_status()[2] + @property @_sslcopydoc def session(self): @@ -1422,6 +1440,9 @@ def verify_client_post_handshake(self): else: raise ValueError("No SSL wrapper around " + str(self)) + def get_ech_status(self): + return ECHStatus(self._sslobj.get_ech_status()[0]) + def _real_close(self): self._sslobj = None super()._real_close() @@ -1588,3 +1609,13 @@ def get_server_certificate(addr, ssl_version=PROTOCOL_TLS_CLIENT, def get_protocol_name(protocol_code): return _PROTOCOL_NAMES.get(protocol_code, '') + +def encode_alpn_protocol_list(alpn_protocols): + protos = bytearray() + for protocol in alpn_protocols: + b = bytes(protocol, 'ascii') + if not b or len(b) > 255: + raise SSLError('ALPN protocols must be 1 to 255 in length') + protos.append(len(b)) + protos.extend(b) + return protos diff --git a/Misc/ACKS b/Misc/ACKS index 00ed03ff51c98fb..65f6f423eb7c428 100644 --- a/Misc/ACKS +++ b/Misc/ACKS @@ -1107,6 +1107,7 @@ Michael Layzell Michael Lazar Peter Lazorchak Brian Leair +Iain Learmonth Mathieu Leduc-Hamel Amandine Lee Antony Lee diff --git a/Misc/NEWS.d/next/Library/2025-06-12-15-39-48.gh-issue-89730.Lu35Fu.rst b/Misc/NEWS.d/next/Library/2025-06-12-15-39-48.gh-issue-89730.Lu35Fu.rst new file mode 100644 index 000000000000000..05c7600cdd61321 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-06-12-15-39-48.gh-issue-89730.Lu35Fu.rst @@ -0,0 +1,8 @@ +Adds support for Encrypted Client Hello (ECH) to the ssl module. Clients may +use the options exposed to establish TLS connections using ECH. Third-party +libraries like ``dnspython`` can be used to query for HTTPS and SVCB records +that contain the public keys required to use ECH with specific servers. If +no public key is available, an option is available to "GREASE" the +connection, and it is possible to retrieve the public key from the retry +configuration sent by servers that support ECH as they terminate the initial +connection. diff --git a/Modules/_ssl.c b/Modules/_ssl.c index ee0df146421d7f8..2b25511a6d2e7f0 100644 --- a/Modules/_ssl.c +++ b/Modules/_ssl.c @@ -76,6 +76,10 @@ #include "openssl/bio.h" #include "openssl/dh.h" +#ifdef HAVE_OPENSSL_ECH +#include "openssl/ech.h" +#endif + #ifndef OPENSSL_THREADS # error "OPENSSL_THREADS is not defined, Python requires thread-safe OpenSSL" #endif @@ -2117,6 +2121,127 @@ cipher_to_dict(const SSL_CIPHER *cipher) ); } +/*[clinic input] +@critical_section +_ssl._SSLSocket.get_ech_status +[clinic start generated code]*/ + +static PyObject * +_ssl__SSLSocket_get_ech_status_impl(PySSLSocket *self) +/*[clinic end generated code: output=263bf7fc7888e2d6 input=71320052d6f2a10f]*/ +{ +#ifdef HAVE_OPENSSL_ECH + char *inner_sni = NULL, *outer_sni = NULL; + if (self->ssl == NULL) { + Py_RETURN_NONE; + } + int status = SSL_ech_get1_status(self->ssl, &inner_sni, &outer_sni); + PyObject *v, *retval = PyTuple_New(3); + if (retval == NULL) { + goto fail; + } + PyTuple_SET_ITEM(retval, 0, PyLong_FromLong(status)); + if (inner_sni != NULL) { + v = PyUnicode_FromString(inner_sni); + if (v == NULL) { + goto fail; + } + PyTuple_SET_ITEM(retval, 1, v); + OPENSSL_free(inner_sni); + } + else { + PyTuple_SET_ITEM(retval, 1, Py_None); + Py_INCREF(Py_None); + } + if (outer_sni != NULL) { + v = PyUnicode_FromString(outer_sni); + if (v == NULL) { + goto fail; + } + PyTuple_SET_ITEM(retval, 2, v); + OPENSSL_free(outer_sni); + } + else { + PyTuple_SET_ITEM(retval, 2, Py_None); + Py_INCREF(Py_None); + } + return retval; +fail: + if (inner_sni != NULL) { + OPENSSL_free(inner_sni); + } + if (outer_sni != NULL) { + OPENSSL_free(outer_sni); + } + return NULL; +#else + PyErr_SetString(PyExc_NotImplementedError, "OpenSSL does not have ECH support"); + return NULL; +#endif +} + +/*[clinic input] +@critical_section +_ssl._SSLSocket.get_ech_retry_config +[clinic start generated code]*/ + +static PyObject * +_ssl__SSLSocket_get_ech_retry_config_impl(PySSLSocket *self) +/*[clinic end generated code: output=967da2032df9a37a input=0e51b545e93f43ba]*/ +{ +#ifdef HAVE_OPENSSL_ECH + if (self->ssl == NULL) { + Py_RETURN_NONE; + } + unsigned char *ec; + size_t eclen; + if (SSL_ech_get1_retry_config(self->ssl, &ec, &eclen) != 1) { + _setSSLError(get_state_sock(self), NULL, 0, __FILE__, __LINE__); + return NULL; + } + return PyBytes_FromStringAndSize((char *)ec, eclen); +#else + PyErr_SetString(PyExc_NotImplementedError, "OpenSSL does not have ECH support"); + return NULL; +#endif +} + +/*[clinic input] +@critical_section +_ssl._SSLSocket.set_outer_server_name + + outer_server_name: str + / +[clinic start generated code]*/ + +static PyObject * +_ssl__SSLSocket_set_outer_server_name_impl(PySSLSocket *self, + const char *outer_server_name) +/*[clinic end generated code: output=24e7c6b7b3c2ce84 input=d527943943ffbdf3]*/ +{ +#ifdef HAVE_OPENSSL_ECH + if (self->ssl == NULL) { + Py_RETURN_NONE; + } + int result; + if (outer_server_name != NULL) { + result = SSL_ech_set1_outer_server_name(self->ssl, outer_server_name, 0); + } + else { + /* Do not send an outer SNI */ + result = SSL_ech_set1_outer_server_name(self->ssl, NULL, 1); + } + if (result != 1) { + _setSSLError(get_state_sock(self), NULL, 0, __FILE__, __LINE__); + return NULL; + } + Py_RETURN_NONE; +#else + PyErr_SetString(PyExc_NotImplementedError, "OpenSSL does not have ECH support"); + return NULL; +#endif +} + /*[clinic input] @critical_section _ssl._SSLSocket.shared_ciphers @@ -3344,6 +3469,9 @@ static PyMethodDef PySSLMethods[] = { _SSL__SSLSOCKET_COMPRESSION_METHODDEF _SSL__SSLSOCKET_SHUTDOWN_METHODDEF _SSL__SSLSOCKET_VERIFY_CLIENT_POST_HANDSHAKE_METHODDEF + _SSL__SSLSOCKET_GET_ECH_STATUS_METHODDEF + _SSL__SSLSOCKET_SET_OUTER_SERVER_NAME_METHODDEF + _SSL__SSLSOCKET_GET_ECH_RETRY_CONFIG_METHODDEF _SSL__SSLSOCKET_GET_UNVERIFIED_CHAIN_METHODDEF _SSL__SSLSOCKET_GET_VERIFIED_CHAIN_METHODDEF {NULL, NULL} @@ -3949,6 +4077,43 @@ _ssl__SSLContext__set_alpn_protocols_impl(PySSLContext *self, Py_RETURN_NONE; } +/*[clinic input] +@critical_section +_ssl._SSLContext._set_outer_alpn_protocols + protos: Py_buffer + / +[clinic start generated code]*/ + +static PyObject * +_ssl__SSLContext__set_outer_alpn_protocols_impl(PySSLContext *self, + Py_buffer *protos) +/*[clinic end generated code: output=823aea95e8dea835 input=b6ddf7d0791afd0e]*/ +{ +#ifdef HAVE_OPENSSL_ECH + if ((size_t)protos->len > UINT_MAX) { + PyErr_Format(PyExc_OverflowError, + "protocols longer than %u bytes", UINT_MAX); + return NULL; + } + PyMem_Free(self->alpn_protocols); + self->alpn_protocols = PyMem_Malloc(protos->len); + if (!self->alpn_protocols) { + return PyErr_NoMemory(); + } + memcpy(self->alpn_protocols, protos->buf, protos->len); + self->alpn_protocols_len = (unsigned int)protos->len; + if (SSL_CTX_ech_set1_outer_alpn_protos(self->ctx, self->alpn_protocols, + self->alpn_protocols_len)) + { + return PyErr_NoMemory(); + } + Py_RETURN_NONE; +#else + PyErr_SetString(PyExc_NotImplementedError, "OpenSSL does not have ECH support"); + return NULL; +#endif +} + /*[clinic input] @critical_section @getter @@ -5083,6 +5248,58 @@ _ssl__SSLContext_set_default_verify_paths_impl(PySSLContext *self) Py_RETURN_NONE; } +/*[clinic input] +@critical_section +_ssl._SSLContext.set_ech_config + + ech_config: Py_buffer + / + +Set the ECH configuration on the SSL context. + +The echconfig parameter should be a bytes-like object containing the raw ECH configuration. +[clinic start generated code]*/ + +static PyObject * +_ssl__SSLContext_set_ech_config_impl(PySSLContext *self, + Py_buffer *ech_config) +/*[clinic end generated code: output=cea53188b338cf80 input=3ef0514cae6ab6ee]*/ +{ +#ifdef HAVE_OPENSSL_ECH + BIO *es_in = BIO_new_mem_buf(ech_config->buf, ech_config->len); + OSSL_ECHSTORE *es; + if (es_in == NULL) { + goto fail; + } + es = OSSL_ECHSTORE_new(NULL, NULL)); + if (es == NULL) { + goto fail; + } + if (OSSL_ECHSTORE_read_echconfiglist(es, es_in) != 1) { + goto fail; + } + if (SSL_CTX_set1_echstore(self->ctx, es) != 1) { + goto fail; + } + OSSL_ECHSTORE_free(es); + BIO_free_all(es_in); + PyBuffer_Release(ech_config); + Py_RETURN_NONE; +fail: + _setSSLError(get_state_ctx(self), NULL, 0, __FILE__, __LINE__); + if (es != NULL) { + OSSL_ECHSTORE_free(es); + } + if (es_in != NULL) { + BIO_free_all(es_in); + } + return NULL; +#else + PyErr_SetString(PyExc_NotImplementedError, "OpenSSL does not have ECH support"); + return NULL; +#endif +} + /*[clinic input] @critical_section _ssl._SSLContext.set_ecdh_curve @@ -5762,6 +5979,8 @@ static struct PyMethodDef context_methods[] = { _SSL__SSLCONTEXT_SESSION_STATS_METHODDEF _SSL__SSLCONTEXT_SET_DEFAULT_VERIFY_PATHS_METHODDEF _SSL__SSLCONTEXT_SET_ECDH_CURVE_METHODDEF + _SSL__SSLCONTEXT_SET_ECH_CONFIG_METHODDEF + _SSL__SSLCONTEXT__SET_OUTER_ALPN_PROTOCOLS_METHODDEF _SSL__SSLCONTEXT_CERT_STORE_STATS_METHODDEF _SSL__SSLCONTEXT_GET_CA_CERTS_METHODDEF _SSL__SSLCONTEXT_GET_CIPHERS_METHODDEF @@ -6956,6 +7175,20 @@ sslmodule_init_constants(PyObject *m) ADD_INT_CONST("VERIFY_X509_PARTIAL_CHAIN", X509_V_FLAG_PARTIAL_CHAIN); #endif +#ifdef HAVE_OPENSSL_ECH + ADD_INT_CONST("ECH_STATUS_BACKEND", SSL_ECH_STATUS_BACKEND); + ADD_INT_CONST("ECH_STATUS_GREASE_ECH", SSL_ECH_STATUS_GREASE_ECH); + ADD_INT_CONST("ECH_STATUS_GREASE", SSL_ECH_STATUS_GREASE); + ADD_INT_CONST("ECH_STATUS_SUCCESS", SSL_ECH_STATUS_SUCCESS); + ADD_INT_CONST("ECH_STATUS_FAILED", SSL_ECH_STATUS_FAILED); + ADD_INT_CONST("ECH_STATUS_BAD_CALL", SSL_ECH_STATUS_BAD_CALL); + ADD_INT_CONST("ECH_STATUS_NOT_TRIED", SSL_ECH_STATUS_NOT_TRIED); + ADD_INT_CONST("ECH_STATUS_BAD_NAME", SSL_ECH_STATUS_BAD_NAME); + ADD_INT_CONST("ECH_STATUS_NOT_CONFIGURED", SSL_ECH_STATUS_NOT_CONFIGURED); + ADD_INT_CONST("ECH_STATUS_FAILED_ECH", SSL_ECH_STATUS_FAILED_ECH); + ADD_INT_CONST("ECH_STATUS_FAILED_ECH_BAD_NAME", SSL_ECH_STATUS_FAILED_ECH_BAD_NAME); +#endif + /* Alert Descriptions from ssl.h */ /* note RESERVED constants no longer intended for use have been removed */ /* http://www.iana.org/assignments/tls-parameters/tls-parameters.xml#tls-parameters-6 */ @@ -7045,6 +7278,12 @@ sslmodule_init_constants(PyObject *m) ADD_OPTION("OP_NO_TICKET", SSL_OP_NO_TICKET); ADD_OPTION("OP_LEGACY_SERVER_CONNECT", SSL_OP_LEGACY_SERVER_CONNECT); +#ifdef HAVE_OPENSSL_ECH + ADD_OPTION("OP_ECH_GREASE", SSL_OP_ECH_GREASE); + ADD_OPTION("OP_ECH_TRIALDECRYPT", SSL_OP_ECH_TRIALDECRYPT); + ADD_OPTION("OP_ECH_IGNORE_CID", SSL_OP_ECH_IGNORE_CID); + ADD_OPTION("OP_ECH_GREASE_RETRY_CONFIG", SSL_OP_ECH_GREASE_RETRY_CONFIG); +#endif #ifdef SSL_OP_SINGLE_ECDH_USE ADD_OPTION("OP_SINGLE_ECDH_USE", SSL_OP_SINGLE_ECDH_USE); #endif @@ -7172,6 +7411,12 @@ sslmodule_init_constants(PyObject *m) addbool(m, "HAS_PHA", 0); #endif +#ifdef OPENSSL_NO_ECH + addbool(m, "HAS_ECH", 0); +#else + addbool(m, "HAS_ECH", 1); +#endif + #undef addbool #undef ADD_INT_CONST diff --git a/Modules/clinic/_ssl.c.h b/Modules/clinic/_ssl.c.h index d0bce13f2fd945e..6c28d9dbb8751a5 100644 --- a/Modules/clinic/_ssl.c.h +++ b/Modules/clinic/_ssl.c.h @@ -156,6 +156,86 @@ _ssl__SSLSocket_get_unverified_chain(PyObject *self, PyObject *Py_UNUSED(ignored return return_value; } +PyDoc_STRVAR(_ssl__SSLSocket_get_ech_status__doc__, +"get_ech_status($self, /)\n" +"--\n" +"\n"); + +#define _SSL__SSLSOCKET_GET_ECH_STATUS_METHODDEF \ + {"get_ech_status", (PyCFunction)_ssl__SSLSocket_get_ech_status, METH_NOARGS, _ssl__SSLSocket_get_ech_status__doc__}, + +static PyObject * +_ssl__SSLSocket_get_ech_status_impl(PySSLSocket *self); + +static PyObject * +_ssl__SSLSocket_get_ech_status(PyObject *self, PyObject *Py_UNUSED(ignored)) +{ + PyObject *return_value = NULL; + + Py_BEGIN_CRITICAL_SECTION(self); + return_value = _ssl__SSLSocket_get_ech_status_impl((PySSLSocket *)self); + Py_END_CRITICAL_SECTION(); + + return return_value; +} + +PyDoc_STRVAR(_ssl__SSLSocket_get_ech_retry_config__doc__, +"get_ech_retry_config($self, /)\n" +"--\n" +"\n"); + +#define _SSL__SSLSOCKET_GET_ECH_RETRY_CONFIG_METHODDEF \ + {"get_ech_retry_config", (PyCFunction)_ssl__SSLSocket_get_ech_retry_config, METH_NOARGS, _ssl__SSLSocket_get_ech_retry_config__doc__}, + +static PyObject * +_ssl__SSLSocket_get_ech_retry_config_impl(PySSLSocket *self); + +static PyObject * +_ssl__SSLSocket_get_ech_retry_config(PyObject *self, PyObject *Py_UNUSED(ignored)) +{ + PyObject *return_value = NULL; + + Py_BEGIN_CRITICAL_SECTION(self); + return_value = _ssl__SSLSocket_get_ech_retry_config_impl((PySSLSocket *)self); + Py_END_CRITICAL_SECTION(); + + return return_value; +} + +PyDoc_STRVAR(_ssl__SSLSocket_set_outer_server_name__doc__, +"set_outer_server_name($self, outer_server_name, /)\n" +"--\n" +"\n"); + +#define _SSL__SSLSOCKET_SET_OUTER_SERVER_NAME_METHODDEF \ + {"set_outer_server_name", (PyCFunction)_ssl__SSLSocket_set_outer_server_name, METH_O, _ssl__SSLSocket_set_outer_server_name__doc__}, + +static PyObject * +_ssl__SSLSocket_set_outer_server_name_impl(PySSLSocket *self, + const char *outer_server_name); + +static PyObject * +_ssl__SSLSocket_set_outer_server_name(PyObject *self, PyObject *arg) +{ + PyObject *return_value = NULL; + const char *outer_server_name; + + if (!PyUnicode_Check(arg)) { + _PyArg_BadArgument("set_outer_server_name", "argument", "str", arg); + goto exit; + } + outer_server_name = _PyUnicode_AsUTF8NoNUL(arg); + if (outer_server_name == NULL) { + goto exit; + } + Py_BEGIN_CRITICAL_SECTION(self); + return_value = _ssl__SSLSocket_set_outer_server_name_impl((PySSLSocket *)self, outer_server_name); + Py_END_CRITICAL_SECTION(); + +exit: + return return_value; +} + PyDoc_STRVAR(_ssl__SSLSocket_shared_ciphers__doc__, "shared_ciphers($self, /)\n" "--\n" @@ -1189,6 +1269,40 @@ _ssl__SSLContext__set_alpn_protocols(PyObject *self, PyObject *arg) return return_value; } +PyDoc_STRVAR(_ssl__SSLContext__set_outer_alpn_protocols__doc__, +"_set_outer_alpn_protocols($self, protos, /)\n" +"--\n" +"\n"); + +#define _SSL__SSLCONTEXT__SET_OUTER_ALPN_PROTOCOLS_METHODDEF \ + {"_set_outer_alpn_protocols", (PyCFunction)_ssl__SSLContext__set_outer_alpn_protocols, METH_O, _ssl__SSLContext__set_outer_alpn_protocols__doc__}, + +static PyObject * +_ssl__SSLContext__set_outer_alpn_protocols_impl(PySSLContext *self, + Py_buffer *protos); + +static PyObject * +_ssl__SSLContext__set_outer_alpn_protocols(PyObject *self, PyObject *arg) +{ + PyObject *return_value = NULL; + Py_buffer protos = {NULL, NULL}; + + if (PyObject_GetBuffer(arg, &protos, PyBUF_SIMPLE) != 0) { + goto exit; + } + Py_BEGIN_CRITICAL_SECTION(self); + return_value = _ssl__SSLContext__set_outer_alpn_protocols_impl((PySSLContext *)self, &protos); + Py_END_CRITICAL_SECTION(); + +exit: + /* Cleanup for protos */ + if (protos.obj) { + PyBuffer_Release(&protos); + } + + return return_value; +} + static int _ssl__SSLContext_verify_mode_get_impl(PySSLContext *self); @@ -2029,6 +2143,43 @@ _ssl__SSLContext_set_default_verify_paths(PyObject *self, PyObject *Py_UNUSED(ig return return_value; } +PyDoc_STRVAR(_ssl__SSLContext_set_ech_config__doc__, +"set_ech_config($self, ech_config, /)\n" +"--\n" +"\n" +"Set the ECH configuration on the SSL context.\n" +"\n" +"The echconfig parameter should be a bytes-like object containing the raw ECH configuration."); + +#define _SSL__SSLCONTEXT_SET_ECH_CONFIG_METHODDEF \ + {"set_ech_config", (PyCFunction)_ssl__SSLContext_set_ech_config, METH_O, _ssl__SSLContext_set_ech_config__doc__}, + +static PyObject * +_ssl__SSLContext_set_ech_config_impl(PySSLContext *self, + Py_buffer *ech_config); + +static PyObject * +_ssl__SSLContext_set_ech_config(PyObject *self, PyObject *arg) +{ + PyObject *return_value = NULL; + Py_buffer ech_config = {NULL, NULL}; + + if (PyObject_GetBuffer(arg, &ech_config, PyBUF_SIMPLE) != 0) { + goto exit; + } + Py_BEGIN_CRITICAL_SECTION(self); + return_value = _ssl__SSLContext_set_ech_config_impl((PySSLContext *)self, &ech_config); + Py_END_CRITICAL_SECTION(); + +exit: + /* Cleanup for ech_config */ + if (ech_config.obj) { + PyBuffer_Release(&ech_config); + } + + return return_value; +} + PyDoc_STRVAR(_ssl__SSLContext_set_ecdh_curve__doc__, "set_ecdh_curve($self, name, /)\n" "--\n" @@ -3083,4 +3234,4 @@ _ssl_enum_crls(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObje #define _SSL_SSLSESSION_HAS_TICKET_GETSETDEF {"has_ticket", (getter)_ssl_SSLSession_has_ticket_get, (setter)NULL, _ssl_SSLSession_has_ticket__doc__}, -/*[clinic end generated code: output=14ced41bd24b60c3 input=a9049054013a1b77]*/ +/*[clinic end generated code: output=58f15684911456f5 input=a9049054013a1b77]*/ diff --git a/configure b/configure index 04260d2c4b58344..ed0d96bbd6f8251 100755 --- a/configure +++ b/configure @@ -35634,6 +35634,63 @@ LIBS=$save_LIBS +save_CFLAGS=$CFLAGS +save_CPPFLAGS=$CPPFLAGS +save_LDFLAGS=$LDFLAGS +save_LIBS=$LIBS + + + CFLAGS="$CFLAGS $OPENSSL_INCLUDES" + + + + { printf "%s\n" "$as_me:${as_lineno-$LINENO}: checking for SSL_ech_get1_status" >&5 +printf %s "checking for SSL_ech_get1_status... " >&6; } +if test ${ac_cv_func_SSL_ech_get1_status+y} +then : + printf %s "(cached) " >&6 +else case e in #( + e) cat confdefs.h - <<_ACEOF >conftest.$ac_ext +/* end confdefs.h. */ +#include +int +main (void) +{ +void *x=SSL_ech_get1_status + ; + return 0; +} +_ACEOF +if ac_fn_c_try_compile "$LINENO" +then : + ac_cv_func_SSL_ech_get1_status=yes +else case e in #( + e) ac_cv_func_SSL_ech_get1_status=no ;; +esac +fi +rm -f core conftest.err conftest.$ac_objext conftest.beam conftest.$ac_ext + ;; +esac +fi +{ printf "%s\n" "$as_me:${as_lineno-$LINENO}: result: $ac_cv_func_SSL_ech_get1_status" >&5 +printf "%s\n" "$ac_cv_func_SSL_ech_get1_status" >&6; } + if test "x$ac_cv_func_SSL_ech_get1_status" = xyes +then : + +printf "%s\n" "#define HAVE_OPENSSL_ECH 1" >>confdefs.h + +fi + + + + +CFLAGS=$save_CFLAGS +CPPFLAGS=$save_CPPFLAGS +LDFLAGS=$save_LDFLAGS +LIBS=$save_LIBS + + + # ssl module default cipher suite string diff --git a/configure.ac b/configure.ac index b53247194fc9003..34a52b7716d22ae 100644 --- a/configure.ac +++ b/configure.ac @@ -8189,6 +8189,12 @@ WITH_SAVE_ENV([ ]) ]) +WITH_SAVE_ENV([ + CFLAGS="$CFLAGS $OPENSSL_INCLUDES" + + PY_CHECK_FUNC([SSL_ech_get1_status], [@%:@include ], [HAVE_OPENSSL_ECH]) +]) + # ssl module default cipher suite string AH_TEMPLATE([PY_SSL_DEFAULT_CIPHERS], [Default cipher suites list for ssl module. diff --git a/pyconfig.h.in b/pyconfig.h.in index 0c8c7989a95f972..2a1d1026e6b9e62 100644 --- a/pyconfig.h.in +++ b/pyconfig.h.in @@ -1025,6 +1025,9 @@ /* Define to 1 if you have the 'openpty' function. */ #undef HAVE_OPENPTY +/* Define if you have the 'SSL_ech_get1_status' function. */ +#undef HAVE_OPENSSL_ECH + /* Define if you have the 'panel' library */ #undef HAVE_PANEL From 5649b6e3f78c51260a32affc19f3212566a3881f Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 22 Oct 2026 14:13:23 -0400 Subject: [PATCH 02/22] Let Codex go nuts. --- Doc/library/ssl.rst | 138 ++++++++++---- Doc/whatsnew/3.16.rst | 12 ++ Lib/ssl.py | 32 +++- Lib/test/test_ssl.py | 180 ++++++++++++++++++ ...5-06-12-15-39-48.gh-issue-89730.Lu35Fu.rst | 11 +- Modules/_ssl.c | 121 +++++------- Modules/clinic/_ssl.c.h | 20 +- configure | 57 ------ configure.ac | 6 - pyconfig.h.in | 3 - 10 files changed, 369 insertions(+), 211 deletions(-) diff --git a/Doc/library/ssl.rst b/Doc/library/ssl.rst index ab00f777a1a55e8..75d184673a5ae1c 100644 --- a/Doc/library/ssl.rst +++ b/Doc/library/ssl.rst @@ -869,6 +869,40 @@ Constants .. versionadded:: 3.12 +.. data:: OP_ECH_GREASE + + Include a GREASE ECH extension in ClientHello messages when ECH is not + being attempted. This can elicit an ECH retry configuration from a server. + + Only available when :data:`HAS_ECH` is ``True``. + + .. versionadded:: next + +.. data:: OP_ECH_TRIALDECRYPT + + Attempt server-side ECH decryption with all available ECH keys, rather + than only those with a matching configuration identifier. + + Only available when :data:`HAS_ECH` is ``True``. + + .. versionadded:: next + +.. data:: OP_ECH_IGNORE_CID + + Use a random ECH configuration identifier in client connections. + + Only available when :data:`HAS_ECH` is ``True``. + + .. versionadded:: next + +.. data:: OP_ECH_GREASE_RETRY_CONFIG + + Include GREASE entries in the ECH retry configurations sent by a server. + + Only available when :data:`HAS_ECH` is ``True``. + + .. versionadded:: next + .. data:: HAS_ALPN Whether the OpenSSL library has built-in support for the *Application-Layer @@ -964,6 +998,13 @@ Constants .. versionadded:: 3.14 +.. data:: HAS_ECH + + Whether the OpenSSL library has built-in support for Encrypted Client + Hello (ECH). This requires OpenSSL 4.0 or later with ECH enabled. + + .. versionadded:: next + .. data:: CHANNEL_BINDING_TYPES List of supported TLS channel binding types. Strings in this list @@ -1039,6 +1080,12 @@ Constants .. versionadded:: next +.. attribute:: ECHStatus.ECH_STATUS_GREASE_ECH + + The client sent a GREASE ECH extension and received a retry configuration. + + .. versionadded:: next + .. data:: Purpose.SERVER_AUTH Option for :func:`create_default_context` and @@ -1377,10 +1424,13 @@ SSL sockets also have the following additional methods and attributes: .. method:: SSLSocket.get_ech_retry_config() - When the status returned by :meth:`SSLSocket.get_ech_status` after completion of the - handshake is :data:`ECHStatus.ECH_STATUS_GREASE_ECH`, this method returns the - configuration value provided by the server to be used for a new connection using - ECH. + Return the server's ECH retry configuration as :class:`bytes`, or empty + bytes if no retry configuration is available. When the status returned by + :meth:`SSLSocket.get_ech_status` after the handshake is + :attr:`ECHStatus.ECH_STATUS_GREASE_ECH`, the configuration can be used + for a new connection using ECH. + + Raise :exc:`NotImplementedError` if :data:`HAS_ECH` is ``False``. .. versionadded:: next @@ -1389,6 +1439,8 @@ SSL sockets also have the following additional methods and attributes: Gets the status of Encrypted Client Hello (ECH) processing. Returns an :class:`ECHStatus` instance. + Raise :exc:`NotImplementedError` if :data:`HAS_ECH` is ``False``. + .. versionadded:: next .. method:: SSLSocket.selected_alpn_protocol() @@ -1467,8 +1519,10 @@ SSL sockets also have the following additional methods and attributes: Hostname of the server name used in the outer ClientHello when Encrypted Client Hello (ECH) is used: :class:`str` type, or ``None`` for server-side socket or - if the outer server name was not specified in the constructor or the ECH - configuration. + if no outer server name is available. + + Raise :exc:`NotImplementedError` if :data:`HAS_ECH` is ``False`` and the + socket has an SSL wrapper. .. versionadded:: next @@ -1852,15 +1906,14 @@ to speed up repeated connections from the same clients. Sets an Encrypted Client Hello (ECH) configuration, which may be discovered from an HTTPS resource record in DNS or from :meth:`SSLSocket.get_ech_retry_config`. - Multiple calls to this functions will accumulate the set of values available for - a connection. + Each call replaces the context's previous configuration. If the input value provided contains no suitable value (e.g. if it only contains ECH configuration versions that are not supported), an :class:`SSLError` will be raised. - The ech_config parameter should be a bytes-like object containing the raw ECH - configuration. + The *ech_config* parameter should be a :term:`bytes-like object` containing + a binary-encoded ECHConfigList. This method will raise :exc:`NotImplementedError` if :data:`HAS_ECH` is ``False``. @@ -1897,16 +1950,6 @@ to speed up repeated connections from the same clients. .. versionadded:: next -.. method:: SSLContext.set_outer_server_hostname(server_hostname) - - Specify which hostname the socket should advertise during the TLS - handshake in the outer ClientHello when ECH is used. - - This method will raise :exc:`NotImplementedError` if :data:`HAS_ECH` is - ``False``. - - .. versionadded:: next - .. attribute:: SSLContext.sni_callback Register a callback function that will be called after the TLS Client Hello @@ -3051,50 +3094,61 @@ of TLS/SSL. Some new TLS 1.3 features are not yet available. - TLS 1.3 features like early data, deferred TLS client cert request, and rekeying are not supported yet. +.. _ssl-encrypted-client-hello: + Encrypted Client Hello ^^^^^^^^^^^^^^^^^^^^^^ .. versionadded:: next -Encrypted Client Hello (ECH) allows for encrypting values that have previously only been -included unencrypted in the ClientHello records when establishing a TLS connection. To use -ECH it is necessary to provide configuration values that contain a version, algorithm -parameters, the public key to use for HPKE encryption and the "public_name" that is by -default used for the unencrypted (outer) SNI when ECH is attempted. These configuration -values may be discovered through DNS or through the "retry config" mechanism. +Encrypted Client Hello (ECH) encrypts values in the ClientHello record sent when +establishing a TLS connection. It requires an ECHConfigList containing algorithm +parameters, a public key, and a public name used in the unencrypted outer +ClientHello. ECH requires OpenSSL 4.0 or later with ECH enabled; check +:data:`HAS_ECH` before using it. -The following example assumes that you have discovered a set of ECH configuration values -from DNS, or *ech_configs* may be an empty list to rely on the "retry config" mechanism:: +Configuration values may be discovered through DNS or through the server's +retry configuration. The following example accepts a binary-encoded +ECHConfigList discovered through DNS. If none is supplied, it enables ECH +GREASE to request a retry configuration from the server:: import socket import ssl - def connect_with_tls_ech(hostname: str, ech_configs: List[str], - use_retry_config: bool=True) -> ssl.SSLSocket: + def connect_with_tls_ech(hostname, ech_config=None, use_retry_config=True): context = ssl.create_default_context() - for ech_config in ech_configs: + if ech_config is not None: context.set_ech_config(ech_config) - with socket.create_connection((hostname, 443)) as sock: - with context.wrap_socket(sock, server_hostname=hostname) as ssock: - if (ssock.get_ech_status == ECHStatus.ECH_STATUS_GREASE_ECH - and use_retry_config): - return connect_with_ech(hostname, [ssock.get_ech_retry_config()], - False) - return ssock + else: + context.options |= ssl.OP_ECH_GREASE + sock = socket.create_connection((hostname, 443)) + try: + ssock = context.wrap_socket(sock, server_hostname=hostname) + except BaseException: + sock.close() + raise + if (use_retry_config + and ssock.get_ech_status() + == ssl.ECHStatus.ECH_STATUS_GREASE_ECH): + try: + retry_config = ssock.get_ech_retry_config() + finally: + ssock.close() + return connect_with_tls_ech(hostname, retry_config, False) + return ssock hostname = "www.python.org" - ech_configs = [] # Replace with a call to a function to lookup - # ECH configurations in DNS + ech_config = None # Replace with an ECHConfigList discovered through DNS. - ssock = connect_with_tls_ech(hostname, ech_configs) + with connect_with_tls_ech(hostname, ech_config) as ssock: + print(ssock.get_ech_status()) The following classes, methods, and attributes will be useful for using ECH: - :class:`ECHStatus` - :meth:`SSLContext.set_ech_config` - :meth:`SSLContext.set_outer_alpn_protocols` - - :meth:`SSLContext.set_outer_server_hostname` - :meth:`SSLSocket.get_ech_status` - :meth:`SSLSocket.get_ech_retry_config` diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 925f425aba58693..04cee1750b6ca6d 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -688,6 +688,18 @@ sqlite3 ssl --- +* Added support for :ref:`Encrypted Client Hello ` + (ECH) with OpenSSL 4.0 or later. Clients can use + :meth:`~ssl.SSLContext.set_ech_config` to supply an ECHConfigList discovered + through DNS, for example using a third-party library such as ``dnspython`` + to query HTTPS or SVCB records. Clients can also enable + :data:`~ssl.OP_ECH_GREASE` and retrieve a server's retry configuration + with :meth:`~ssl.SSLSocket.get_ech_retry_config` for a subsequent connection. + :data:`~ssl.HAS_ECH` indicates whether ECH is available, and + :meth:`~ssl.SSLSocket.get_ech_status` reports the status as an + :class:`~ssl.ECHStatus` member. + (Contributed by Iain R. Learmonth and Peter Bierma in :gh:`89730`.) + * :const:`ssl.OPENSSL_VERSION_INFO` is now a named tuple. Added :const:`~ssl.OPENSSL_API_VERSION_INFO` which provides information about the version of the OpenSSL library that was used for building diff --git a/Lib/ssl.py b/Lib/ssl.py index 7f4fedcc4432c19..3ae382b10834a36 100644 --- a/Lib/ssl.py +++ b/Lib/ssl.py @@ -116,7 +116,8 @@ from _ssl import ( HAS_SNI, HAS_ECDH, HAS_NPN, HAS_ALPN, HAS_SSLv2, HAS_SSLv3, HAS_TLSv1, - HAS_TLSv1_1, HAS_TLSv1_2, HAS_TLSv1_3, HAS_PSK, HAS_PSK_TLS13, HAS_PHA + HAS_TLSv1_1, HAS_TLSv1_2, HAS_TLSv1_3, HAS_PSK, HAS_PSK_TLS13, HAS_PHA, + HAS_ECH ) from _ssl import _DEFAULT_CIPHERS, _OPENSSL_API_VERSION @@ -522,11 +523,11 @@ def shim_cb(sslobj, servername, sslctx): self.sni_callback = shim_cb def set_alpn_protocols(self, alpn_protocols): - protos = encode_alpn_protocol_list(alpn_protocols) + protos = _encode_alpn_protocol_list(alpn_protocols) self._set_alpn_protocols(protos) def set_outer_alpn_protocols(self, alpn_protocols): - protos = encode_alpn_protocol_list(alpn_protocols) + protos = _encode_alpn_protocol_list(alpn_protocols) self._set_outer_alpn_protocols(protos) def _load_windows_store_certs(self, storename, purpose): @@ -1003,8 +1004,13 @@ def verify_client_post_handshake(self): return self._sslobj.verify_client_post_handshake() def get_ech_status(self): + """Return the status of Encrypted Client Hello processing.""" return ECHStatus(self._sslobj.get_ech_status()[0]) + def get_ech_retry_config(self): + """Return the server's ECH retry configuration as bytes.""" + return self._sslobj.get_ech_retry_config() + def _sslcopydoc(func): """Copy docstring from SSLObject to SSLSocket""" @@ -1031,14 +1037,11 @@ def __init__(self, *args, **kwargs): @classmethod def _create(cls, sock, server_side=False, do_handshake_on_connect=True, suppress_ragged_eofs=True, server_hostname=None, - context=None, session=None, outer_server_hostname=None): + context=None, session=None): if sock.getsockopt(SOL_SOCKET, SO_TYPE) != SOCK_STREAM: raise NotImplementedError("only stream sockets are supported") _check_sslobject_params(server_side=server_side, context=context, server_hostname=server_hostname, session=session) - if server_side and outer_server_hostname: - raise ValueError("outer_server_hostname can only be specified " - "in client mode") sock_timeout = sock.gettimeout() kwargs = dict( @@ -1137,7 +1140,8 @@ def context(self, ctx): @property def outer_server_hostname(self): """The server name used in the outer ClientHello.""" - return self._sslobj.get_ech_status()[2] + if self._sslobj is not None: + return self._sslobj.get_ech_status()[2] @property @_sslcopydoc @@ -1440,8 +1444,15 @@ def verify_client_post_handshake(self): else: raise ValueError("No SSL wrapper around " + str(self)) + @_sslcopydoc def get_ech_status(self): - return ECHStatus(self._sslobj.get_ech_status()[0]) + if self._sslobj is not None: + return ECHStatus(self._sslobj.get_ech_status()[0]) + + @_sslcopydoc + def get_ech_retry_config(self): + if self._sslobj is not None: + return self._sslobj.get_ech_retry_config() def _real_close(self): self._sslobj = None @@ -1610,7 +1621,8 @@ def get_server_certificate(addr, ssl_version=PROTOCOL_TLS_CLIENT, def get_protocol_name(protocol_code): return _PROTOCOL_NAMES.get(protocol_code, '') -def encode_alpn_protocol_list(alpn_protocols): + +def _encode_alpn_protocol_list(alpn_protocols): protos = bytearray() for protocol in alpn_protocols: b = bytes(protocol, 'ascii') diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index 27d5e56368bfbc6..2d0e9228da6636d 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -441,6 +441,7 @@ def test_constants(self): self.assertEqual(ssl.HAS_ECDH, True) self.assertIsInstance(ssl.HAS_TLSv1_2, bool) self.assertEqual(ssl.HAS_TLSv1_3, True) + self.assertIsInstance(ssl.HAS_ECH, bool) ssl.OP_NO_SSLv2 ssl.OP_NO_SSLv3 ssl.OP_NO_TLSv1 @@ -2043,6 +2044,185 @@ def psk_server_callback(*args, _=ctx, **kwargs): ... ctx.set_psk_server_callback(psk_server_callback) +class ECHTests(unittest.TestCase): + # An ECHConfigList for localhost using X25519, HKDF-SHA256 and AES-128-GCM. + ECH_CONFIG = bytes.fromhex( + '003c fe0d 0038 db 0020 0020' + '696c2bdc8e1cfa03453ed9bf8c3adee2e676835c8746af33c10994b8e603405c' + '0004 0001 0001 00 09 6c6f63616c686f7374 0000' + ) + + def make_ssl_objects(self, context): + obj = context.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(), + server_hostname='localhost') + sock, peer = socket.socketpair() + self.addCleanup(sock.close) + self.addCleanup(peer.close) + ssock = context.wrap_socket(sock, server_hostname='localhost', + do_handshake_on_connect=False) + self.addCleanup(ssock.close) + return obj, ssock + + @unittest.skipIf(ssl.HAS_ECH, 'requires OpenSSL without ECH') + def test_not_implemented(self): + ctx = make_test_context() + self.assertFalse(ssl.ECHStatus.__members__) + with self.assertRaises(NotImplementedError): + ctx.set_ech_config(self.ECH_CONFIG) + with self.assertRaises(NotImplementedError): + ctx.set_outer_alpn_protocols(['h2']) + for obj in self.make_ssl_objects(ctx): + with self.subTest(type=type(obj)): + with self.assertRaises(NotImplementedError): + obj.get_ech_status() + with self.assertRaises(NotImplementedError): + obj.get_ech_retry_config() + with self.assertRaises(NotImplementedError): + obj.outer_server_hostname + with self.assertRaises(NotImplementedError): + obj._sslobj.set_outer_server_name('localhost') + + def test_socket_without_ssl_object(self): + ctx = make_test_context() + with ctx.wrap_socket(socket.socket()) as sock: + self.assertIsNone(sock.outer_server_hostname) + self.assertIsNone(sock.get_ech_status()) + self.assertIsNone(sock.get_ech_retry_config()) + self.assertIsNone(sock.outer_server_hostname) + self.assertIsNone(sock.get_ech_status()) + self.assertIsNone(sock.get_ech_retry_config()) + + @unittest.skipUnless(ssl.HAS_ECH, 'requires ECH') + def test_set_ech_config(self): + ctx = make_test_context() + for config in (self.ECH_CONFIG, bytearray(self.ECH_CONFIG), + memoryview(self.ECH_CONFIG)): + with self.subTest(type=type(config)): + self.assertIsNone(ctx.set_ech_config(config)) + config = bytearray(self.ECH_CONFIG) + ctx.set_ech_config(config) + config.clear() # The input buffer must have been released. + for config in (None, 'config', 42): + with self.subTest(config=config): + self.assertRaises(TypeError, ctx.set_ech_config, config) + for config in (b'', b'\x00', b'\x00\x00', b'invalid', + self.ECH_CONFIG[:-1]): + with self.subTest(config=config): + self.assertRaises(ssl.SSLError, ctx.set_ech_config, config) + self.assertIsNone(ctx.set_ech_config(self.ECH_CONFIG)) + + @unittest.skipUnless(ssl.HAS_ECH, 'requires ECH') + def test_status_before_handshake(self): + ctx = make_test_context() + for configured in (False, True): + if configured: + ctx.set_ech_config(self.ECH_CONFIG) + expected = ssl.ECHStatus.ECH_STATUS_NOT_TRIED + else: + expected = ssl.ECHStatus.ECH_STATUS_NOT_CONFIGURED + for obj in self.make_ssl_objects(ctx): + with self.subTest(configured=configured, type=type(obj)): + status = obj.get_ech_status() + self.assertIsInstance(status, ssl.ECHStatus) + self.assertEqual(status, expected) + self.assertEqual(obj._sslobj.get_ech_status(), + (expected, None, None)) + self.assertIsNone(obj.outer_server_hostname) + self.assertEqual(obj.get_ech_retry_config(), b'') + + @unittest.skipUnless(ssl.HAS_ECH, 'requires ECH') + def test_set_outer_server_name(self): + ctx = make_test_context() + ctx.set_ech_config(self.ECH_CONFIG) + for obj in self.make_ssl_objects(ctx): + setter = obj._sslobj.set_outer_server_name + self.assertIsNone(setter('localhost')) + self.assertIsNone(setter(None)) + self.assertRaises(TypeError, setter, 42) + self.assertRaises(ValueError, setter, 'host\x00name') + + @unittest.skipUnless(ssl.HAS_ECH, 'requires ECH') + def test_set_outer_alpn_protocols(self): + ctx = make_test_context() + for protocols in (['h2'], ['h2', 'http/1.1'], []): + with self.subTest(protocols=protocols): + self.assertIsNone(ctx.set_outer_alpn_protocols(protocols)) + for protocols in ([''], ['a' * 256]): + self.assertRaises(ssl.SSLError, + ctx.set_outer_alpn_protocols, protocols) + self.assertRaises(UnicodeEncodeError, + ctx.set_outer_alpn_protocols, ['non-ascii-\u00e9']) + self.assertRaises(TypeError, ctx.set_outer_alpn_protocols, [42]) + + @unittest.skipUnless(ssl.HAS_ECH, 'requires ECH') + def test_outer_alpn_does_not_replace_inner(self): + client_ctx, server_ctx, hostname = testing_context() + for ctx in (client_ctx, server_ctx): + ctx.set_alpn_protocols(['h2']) + ctx.set_outer_alpn_protocols(['http/1.1']) + client_ctx.set_outer_alpn_protocols([]) + client, server, *_ = connected_bio_pair(client_ctx, server_ctx, hostname) + self.assertEqual(client.selected_alpn_protocol(), 'h2') + self.assertEqual(server.selected_alpn_protocol(), 'h2') + + @unittest.skipUnless(ssl.HAS_ECH, 'requires ECH') + def test_grease(self): + client_ctx, server_ctx, hostname = testing_context() + client_ctx.options |= ssl.OP_ECH_GREASE + client, server, *_ = connected_bio_pair(client_ctx, server_ctx, hostname) + self.assertEqual(client.get_ech_status(), + ssl.ECHStatus.ECH_STATUS_GREASE) + self.assertIsNone(client.outer_server_hostname) + self.assertEqual(client.get_ech_retry_config(), b'') + self.assertEqual(server.get_ech_status(), + ssl.ECHStatus.ECH_STATUS_NOT_CONFIGURED) + + def make_rejected_client(self): + client_ctx, server_ctx, hostname = testing_context() + client_ctx.set_ech_config(self.ECH_CONFIG) + c_in, c_out = ssl.MemoryBIO(), ssl.MemoryBIO() + s_in, s_out = ssl.MemoryBIO(), ssl.MemoryBIO() + client = client_ctx.wrap_bio(c_in, c_out, server_hostname=hostname) + server = server_ctx.wrap_bio(s_in, s_out, server_side=True) + with self.assertRaisesRegex(ssl.SSLError, 'ECH_REQUIRED'): + for _ in range(5): + with contextlib.suppress(ssl.SSLWantReadError): + client.do_handshake() + s_in.write(c_out.read()) + with contextlib.suppress(ssl.SSLWantReadError): + server.do_handshake() + c_in.write(s_out.read()) + return client + + @unittest.skipUnless(ssl.HAS_ECH, 'requires ECH') + def test_rejected_ech(self): + client = self.make_rejected_client() + self.assertEqual(client.get_ech_status(), ssl.ECHStatus.ECH_STATUS_FAILED) + self.assertEqual(client._sslobj.get_ech_status(), + (ssl.ECHStatus.ECH_STATUS_FAILED, + 'localhost', 'localhost')) + self.assertEqual(client.outer_server_hostname, 'localhost') + self.assertEqual(client.get_ech_retry_config(), b'') + + @unittest.skipUnless(ssl.HAS_ECH, 'requires ECH') + @support.nomemtest + def test_status_allocation_failure(self): + import _testcapi + + client = self.make_rejected_client() + get_status = client._sslobj.get_ech_status + failures = 0 + for fail_at in range(10): + try: + _testcapi.call_with_nomemory(fail_at, fail_at + 1, get_status) + except MemoryError: + failures += 1 + self.assertEqual(get_status(), + (ssl.ECHStatus.ECH_STATUS_FAILED, + 'localhost', 'localhost')) + self.assertGreaterEqual(failures, 2) + + class SSLErrorTests(unittest.TestCase): def test_str(self): diff --git a/Misc/NEWS.d/next/Library/2025-06-12-15-39-48.gh-issue-89730.Lu35Fu.rst b/Misc/NEWS.d/next/Library/2025-06-12-15-39-48.gh-issue-89730.Lu35Fu.rst index 05c7600cdd61321..302227b69fb441f 100644 --- a/Misc/NEWS.d/next/Library/2025-06-12-15-39-48.gh-issue-89730.Lu35Fu.rst +++ b/Misc/NEWS.d/next/Library/2025-06-12-15-39-48.gh-issue-89730.Lu35Fu.rst @@ -1,8 +1,3 @@ -Adds support for Encrypted Client Hello (ECH) to the ssl module. Clients may -use the options exposed to establish TLS connections using ECH. Third-party -libraries like ``dnspython`` can be used to query for HTTPS and SVCB records -that contain the public keys required to use ECH with specific servers. If -no public key is available, an option is available to "GREASE" the -connection, and it is possible to retrieve the public key from the retry -configuration sent by servers that support ECH as they terminate the initial -connection. +Add client support for Encrypted Client Hello (ECH) to the :mod:`ssl` module +with OpenSSL 4.0 or later. ECH configurations can be supplied with +:meth:`ssl.SSLContext.set_ech_config`. diff --git a/Modules/_ssl.c b/Modules/_ssl.c index 2b25511a6d2e7f0..d7c6f96d9edc831 100644 --- a/Modules/_ssl.c +++ b/Modules/_ssl.c @@ -76,8 +76,9 @@ #include "openssl/bio.h" #include "openssl/dh.h" -#ifdef HAVE_OPENSSL_ECH -#include "openssl/ech.h" +#if OPENSSL_VERSION_NUMBER >= 0x40000000L && !defined(OPENSSL_NO_ECH) +# define HAVE_OPENSSL_ECH 1 +# include "openssl/ech.h" #endif #ifndef OPENSSL_THREADS @@ -2136,44 +2137,10 @@ _ssl__SSLSocket_get_ech_status_impl(PySSLSocket *self) Py_RETURN_NONE; } int status = SSL_ech_get1_status(self->ssl, &inner_sni, &outer_sni); - PyObject *v, *retval = PyTuple_New(3); - if (retval == NULL) { - goto fail; - } - PyTuple_SET_ITEM(retval, 0, PyLong_FromLong(status)); - if (inner_sni != NULL) { - v = PyUnicode_FromString(inner_sni); - if (v == NULL) { - goto fail; - } - PyTuple_SET_ITEM(retval, 1, v); - OPENSSL_free(inner_sni); - } - else { - PyTuple_SET_ITEM(retval, 1, Py_None); - Py_INCREF(Py_None); - } - if (outer_sni != NULL) { - v = PyUnicode_FromString(outer_sni); - if (v == NULL) { - goto fail; - } - PyTuple_SET_ITEM(retval, 2, v); - OPENSSL_free(outer_sni); - } - else { - PyTuple_SET_ITEM(retval, 2, Py_None); - Py_INCREF(Py_None); - } - return retval; -fail: - if (inner_sni != NULL) { - OPENSSL_free(inner_sni); - } - if (outer_sni != NULL) { - OPENSSL_free(outer_sni); - } - return NULL; + PyObject *result = Py_BuildValue("(izz)", status, inner_sni, outer_sni); + OPENSSL_free(inner_sni); + OPENSSL_free(outer_sni); + return result; #else PyErr_SetString(PyExc_NotImplementedError, "OpenSSL does not have ECH support"); return NULL; @@ -2193,13 +2160,15 @@ _ssl__SSLSocket_get_ech_retry_config_impl(PySSLSocket *self) if (self->ssl == NULL) { Py_RETURN_NONE; } - unsigned char *ec; - size_t eclen; - if (SSL_ech_get1_retry_config(self->ssl, &ec, &eclen) != 1) { + unsigned char *config = NULL; + size_t config_len; + if (SSL_ech_get1_retry_config(self->ssl, &config, &config_len) != 1) { _setSSLError(get_state_sock(self), NULL, 0, __FILE__, __LINE__); return NULL; } - return PyBytes_FromStringAndSize((char *)ec, eclen); + PyObject *result = PyBytes_FromStringAndSize((char *)config, config_len); + OPENSSL_free(config); + return result; #else PyErr_SetString(PyExc_NotImplementedError, "OpenSSL does not have ECH support"); return NULL; @@ -2210,14 +2179,14 @@ _ssl__SSLSocket_get_ech_retry_config_impl(PySSLSocket *self) @critical_section _ssl._SSLSocket.set_outer_server_name - outer_server_name: str + outer_server_name: str(accept={str, NoneType}) / [clinic start generated code]*/ static PyObject * _ssl__SSLSocket_set_outer_server_name_impl(PySSLSocket *self, const char *outer_server_name) -/*[clinic end generated code: output=24e7c6b7b3c2ce84 input=d527943943ffbdf3]*/ +/*[clinic end generated code: output=24e7c6b7b3c2ce84 input=8d1d96628e11b2fd]*/ { #ifdef HAVE_OPENSSL_ECH if (self->ssl == NULL) { @@ -4095,17 +4064,11 @@ _ssl__SSLContext__set_outer_alpn_protocols_impl(PySSLContext *self, "protocols longer than %u bytes", UINT_MAX); return NULL; } - PyMem_Free(self->alpn_protocols); - self->alpn_protocols = PyMem_Malloc(protos->len); - if (!self->alpn_protocols) { - return PyErr_NoMemory(); - } - memcpy(self->alpn_protocols, protos->buf, protos->len); - self->alpn_protocols_len = (unsigned int)protos->len; - if (SSL_CTX_ech_set1_outer_alpn_protos(self->ctx, self->alpn_protocols, - self->alpn_protocols_len)) - { - return PyErr_NoMemory(); + if (SSL_CTX_ech_set1_outer_alpn_protos(self->ctx, + protos->len ? protos->buf : NULL, + protos->len) != 1) { + _setSSLError(get_state_ctx(self), NULL, 0, __FILE__, __LINE__); + return NULL; } Py_RETURN_NONE; #else @@ -5257,42 +5220,44 @@ _ssl._SSLContext.set_ech_config Set the ECH configuration on the SSL context. -The echconfig parameter should be a bytes-like object containing the raw ECH configuration. +ech_config must be a bytes-like object containing the raw ECH +configuration. [clinic start generated code]*/ static PyObject * _ssl__SSLContext_set_ech_config_impl(PySSLContext *self, Py_buffer *ech_config) -/*[clinic end generated code: output=cea53188b338cf80 input=3ef0514cae6ab6ee]*/ +/*[clinic end generated code: output=cea53188b338cf80 input=3943e5aff04a583e]*/ { #ifdef HAVE_OPENSSL_ECH - BIO *es_in = BIO_new_mem_buf(ech_config->buf, ech_config->len); - OSSL_ECHSTORE *es; - if (es_in == NULL) { + if (ech_config->len > INT_MAX) { + PyErr_Format(PyExc_OverflowError, + "ECH configuration longer than %d bytes", INT_MAX); + return NULL; + } + OSSL_ECHSTORE *store = NULL; + BIO *config_bio = BIO_new_mem_buf(ech_config->buf, ech_config->len); + if (config_bio == NULL) { goto fail; } - es = OSSL_ECHSTORE_new(NULL, NULL)); - if (es == NULL) { + store = OSSL_ECHSTORE_new(NULL, NULL); + if (store == NULL) { goto fail; } - if (OSSL_ECHSTORE_read_echconfiglist(es, es_in) != 1) { + if (OSSL_ECHSTORE_read_echconfiglist(store, config_bio) != 1) { goto fail; } - if (SSL_CTX_set1_echstore(self->ctx, es) != 1) { + if (SSL_CTX_set1_echstore(self->ctx, store) != 1) { goto fail; } - OSSL_ECHSTORE_free(es); - BIO_free_all(es_in); - PyBuffer_Release(ech_config); + OSSL_ECHSTORE_free(store); + BIO_free(config_bio); Py_RETURN_NONE; + fail: _setSSLError(get_state_ctx(self), NULL, 0, __FILE__, __LINE__); - if (es != NULL) { - OSSL_ECHSTORE_free(es); - } - if (es_in != NULL) { - BIO_free_all(es_in); - } + OSSL_ECHSTORE_free(store); + BIO_free(config_bio); return NULL; #else PyErr_SetString(PyExc_NotImplementedError, "OpenSSL does not have ECH support"); @@ -7411,10 +7376,10 @@ sslmodule_init_constants(PyObject *m) addbool(m, "HAS_PHA", 0); #endif -#ifdef OPENSSL_NO_ECH - addbool(m, "HAS_ECH", 0); -#else +#ifdef HAVE_OPENSSL_ECH addbool(m, "HAS_ECH", 1); +#else + addbool(m, "HAS_ECH", 0); #endif #undef addbool diff --git a/Modules/clinic/_ssl.c.h b/Modules/clinic/_ssl.c.h index 6c28d9dbb8751a5..13662d355a1462d 100644 --- a/Modules/clinic/_ssl.c.h +++ b/Modules/clinic/_ssl.c.h @@ -220,12 +220,17 @@ _ssl__SSLSocket_set_outer_server_name(PyObject *self, PyObject *arg) PyObject *return_value = NULL; const char *outer_server_name; - if (!PyUnicode_Check(arg)) { - _PyArg_BadArgument("set_outer_server_name", "argument", "str", arg); - goto exit; + if (arg == Py_None) { + outer_server_name = NULL; + } + else if (PyUnicode_Check(arg)) { + outer_server_name = _PyUnicode_AsUTF8NoNUL(arg); + if (outer_server_name == NULL) { + goto exit; + } } - outer_server_name = _PyUnicode_AsUTF8NoNUL(arg); - if (outer_server_name == NULL) { + else { + _PyArg_BadArgument("set_outer_server_name", "argument", "str or None", arg); goto exit; } Py_BEGIN_CRITICAL_SECTION(self); @@ -2149,7 +2154,8 @@ PyDoc_STRVAR(_ssl__SSLContext_set_ech_config__doc__, "\n" "Set the ECH configuration on the SSL context.\n" "\n" -"The echconfig parameter should be a bytes-like object containing the raw ECH configuration."); +"ech_config must be a bytes-like object containing the raw ECH\n" +"configuration."); #define _SSL__SSLCONTEXT_SET_ECH_CONFIG_METHODDEF \ {"set_ech_config", (PyCFunction)_ssl__SSLContext_set_ech_config, METH_O, _ssl__SSLContext_set_ech_config__doc__}, @@ -3234,4 +3240,4 @@ _ssl_enum_crls(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObje #define _SSL_SSLSESSION_HAS_TICKET_GETSETDEF {"has_ticket", (getter)_ssl_SSLSession_has_ticket_get, (setter)NULL, _ssl_SSLSession_has_ticket__doc__}, -/*[clinic end generated code: output=58f15684911456f5 input=a9049054013a1b77]*/ +/*[clinic end generated code: output=e19d2f93856ff3df input=a9049054013a1b77]*/ diff --git a/configure b/configure index ed0d96bbd6f8251..04260d2c4b58344 100755 --- a/configure +++ b/configure @@ -35634,63 +35634,6 @@ LIBS=$save_LIBS -save_CFLAGS=$CFLAGS -save_CPPFLAGS=$CPPFLAGS -save_LDFLAGS=$LDFLAGS -save_LIBS=$LIBS - - - CFLAGS="$CFLAGS $OPENSSL_INCLUDES" - - - - { printf "%s\n" "$as_me:${as_lineno-$LINENO}: checking for SSL_ech_get1_status" >&5 -printf %s "checking for SSL_ech_get1_status... " >&6; } -if test ${ac_cv_func_SSL_ech_get1_status+y} -then : - printf %s "(cached) " >&6 -else case e in #( - e) cat confdefs.h - <<_ACEOF >conftest.$ac_ext -/* end confdefs.h. */ -#include -int -main (void) -{ -void *x=SSL_ech_get1_status - ; - return 0; -} -_ACEOF -if ac_fn_c_try_compile "$LINENO" -then : - ac_cv_func_SSL_ech_get1_status=yes -else case e in #( - e) ac_cv_func_SSL_ech_get1_status=no ;; -esac -fi -rm -f core conftest.err conftest.$ac_objext conftest.beam conftest.$ac_ext - ;; -esac -fi -{ printf "%s\n" "$as_me:${as_lineno-$LINENO}: result: $ac_cv_func_SSL_ech_get1_status" >&5 -printf "%s\n" "$ac_cv_func_SSL_ech_get1_status" >&6; } - if test "x$ac_cv_func_SSL_ech_get1_status" = xyes -then : - -printf "%s\n" "#define HAVE_OPENSSL_ECH 1" >>confdefs.h - -fi - - - - -CFLAGS=$save_CFLAGS -CPPFLAGS=$save_CPPFLAGS -LDFLAGS=$save_LDFLAGS -LIBS=$save_LIBS - - - # ssl module default cipher suite string diff --git a/configure.ac b/configure.ac index 34a52b7716d22ae..b53247194fc9003 100644 --- a/configure.ac +++ b/configure.ac @@ -8189,12 +8189,6 @@ WITH_SAVE_ENV([ ]) ]) -WITH_SAVE_ENV([ - CFLAGS="$CFLAGS $OPENSSL_INCLUDES" - - PY_CHECK_FUNC([SSL_ech_get1_status], [@%:@include ], [HAVE_OPENSSL_ECH]) -]) - # ssl module default cipher suite string AH_TEMPLATE([PY_SSL_DEFAULT_CIPHERS], [Default cipher suites list for ssl module. diff --git a/pyconfig.h.in b/pyconfig.h.in index 2a1d1026e6b9e62..0c8c7989a95f972 100644 --- a/pyconfig.h.in +++ b/pyconfig.h.in @@ -1025,9 +1025,6 @@ /* Define to 1 if you have the 'openpty' function. */ #undef HAVE_OPENPTY -/* Define if you have the 'SSL_ech_get1_status' function. */ -#undef HAVE_OPENSSL_ECH - /* Define if you have the 'panel' library */ #undef HAVE_PANEL From e679e7675e988a84683d1f03dfc6cee009e51775 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 22 Oct 2026 14:20:44 -0400 Subject: [PATCH 03/22] Don't set attributes when ECH is not available. --- Doc/library/ssl.rst | 14 ++++----- Lib/ssl.py | 66 ++++++++++++++++++++++------------------- Lib/test/test_ssl.py | 39 +++++++++++++----------- Modules/_ssl.c | 31 ++++--------------- Modules/clinic/_ssl.c.h | 42 +++++++++++++++++++++++++- 5 files changed, 111 insertions(+), 81 deletions(-) diff --git a/Doc/library/ssl.rst b/Doc/library/ssl.rst index 75d184673a5ae1c..758890a1b170803 100644 --- a/Doc/library/ssl.rst +++ b/Doc/library/ssl.rst @@ -1078,6 +1078,8 @@ Constants :class:`enum.IntEnum` collection of Encrypted Client Hello (ECH) statuses returned by :meth:`SSLSocket.get_ech_status`. + Only available when :data:`HAS_ECH` is ``True``. + .. versionadded:: next .. attribute:: ECHStatus.ECH_STATUS_GREASE_ECH @@ -1430,7 +1432,7 @@ SSL sockets also have the following additional methods and attributes: :attr:`ECHStatus.ECH_STATUS_GREASE_ECH`, the configuration can be used for a new connection using ECH. - Raise :exc:`NotImplementedError` if :data:`HAS_ECH` is ``False``. + Only available when :data:`HAS_ECH` is ``True``. .. versionadded:: next @@ -1439,7 +1441,7 @@ SSL sockets also have the following additional methods and attributes: Gets the status of Encrypted Client Hello (ECH) processing. Returns an :class:`ECHStatus` instance. - Raise :exc:`NotImplementedError` if :data:`HAS_ECH` is ``False``. + Only available when :data:`HAS_ECH` is ``True``. .. versionadded:: next @@ -1521,8 +1523,7 @@ SSL sockets also have the following additional methods and attributes: Hello (ECH) is used: :class:`str` type, or ``None`` for server-side socket or if no outer server name is available. - Raise :exc:`NotImplementedError` if :data:`HAS_ECH` is ``False`` and the - socket has an SSL wrapper. + Only available when :data:`HAS_ECH` is ``True``. .. versionadded:: next @@ -1915,7 +1916,7 @@ to speed up repeated connections from the same clients. The *ech_config* parameter should be a :term:`bytes-like object` containing a binary-encoded ECHConfigList. - This method will raise :exc:`NotImplementedError` if :data:`HAS_ECH` is ``False``. + Only available when :data:`HAS_ECH` is ``True``. .. versionadded:: next @@ -1945,8 +1946,7 @@ to speed up repeated connections from the same clients. argument accepts the same values as for :meth:`~SSLContext.set_alpn_protocols`. - This method will raise :exc:`NotImplementedError` if :data:`HAS_ECH` is - ``False``. + Only available when :data:`HAS_ECH` is ``True``. .. versionadded:: next diff --git a/Lib/ssl.py b/Lib/ssl.py index 3ae382b10834a36..cebb4ab6703dbb1 100644 --- a/Lib/ssl.py +++ b/Lib/ssl.py @@ -151,10 +151,11 @@ lambda name: name.startswith('CERT_'), source=_ssl) -_IntEnum._convert_( - 'ECHStatus', __name__, - lambda name: name.startswith('ECH_STATUS_'), - source=_ssl) +if HAS_ECH: + _IntEnum._convert_( + 'ECHStatus', __name__, + lambda name: name.startswith('ECH_STATUS_'), + source=_ssl) PROTOCOL_SSLv23 = _SSLMethod.PROTOCOL_SSLv23 = _SSLMethod.PROTOCOL_TLS _PROTOCOL_NAMES = frozendict({ @@ -526,9 +527,10 @@ def set_alpn_protocols(self, alpn_protocols): protos = _encode_alpn_protocol_list(alpn_protocols) self._set_alpn_protocols(protos) - def set_outer_alpn_protocols(self, alpn_protocols): - protos = _encode_alpn_protocol_list(alpn_protocols) - self._set_outer_alpn_protocols(protos) + if HAS_ECH: + def set_outer_alpn_protocols(self, alpn_protocols): + protos = _encode_alpn_protocol_list(alpn_protocols) + self._set_outer_alpn_protocols(protos) def _load_windows_store_certs(self, storename, purpose): try: @@ -849,10 +851,11 @@ def context(self): def context(self, ctx): self._sslobj.context = ctx - @property - def outer_server_hostname(self): - """The server name used in the outer ClientHello.""" - return self._sslobj.get_ech_status()[2] + if HAS_ECH: + @property + def outer_server_hostname(self): + """The server name used in the outer ClientHello.""" + return self._sslobj.get_ech_status()[2] @property def session(self): @@ -1003,13 +1006,14 @@ def version(self): def verify_client_post_handshake(self): return self._sslobj.verify_client_post_handshake() - def get_ech_status(self): - """Return the status of Encrypted Client Hello processing.""" - return ECHStatus(self._sslobj.get_ech_status()[0]) + if HAS_ECH: + def get_ech_status(self): + """Return the status of Encrypted Client Hello processing.""" + return ECHStatus(self._sslobj.get_ech_status()[0]) - def get_ech_retry_config(self): - """Return the server's ECH retry configuration as bytes.""" - return self._sslobj.get_ech_retry_config() + def get_ech_retry_config(self): + """Return the server's ECH retry configuration as bytes.""" + return self._sslobj.get_ech_retry_config() def _sslcopydoc(func): @@ -1137,11 +1141,12 @@ def context(self, ctx): self._context = ctx self._sslobj.context = ctx - @property - def outer_server_hostname(self): - """The server name used in the outer ClientHello.""" - if self._sslobj is not None: - return self._sslobj.get_ech_status()[2] + if HAS_ECH: + @property + def outer_server_hostname(self): + """The server name used in the outer ClientHello.""" + if self._sslobj is not None: + return self._sslobj.get_ech_status()[2] @property @_sslcopydoc @@ -1444,15 +1449,16 @@ def verify_client_post_handshake(self): else: raise ValueError("No SSL wrapper around " + str(self)) - @_sslcopydoc - def get_ech_status(self): - if self._sslobj is not None: - return ECHStatus(self._sslobj.get_ech_status()[0]) + if HAS_ECH: + @_sslcopydoc + def get_ech_status(self): + if self._sslobj is not None: + return ECHStatus(self._sslobj.get_ech_status()[0]) - @_sslcopydoc - def get_ech_retry_config(self): - if self._sslobj is not None: - return self._sslobj.get_ech_retry_config() + @_sslcopydoc + def get_ech_retry_config(self): + if self._sslobj is not None: + return self._sslobj.get_ech_retry_config() def _real_close(self): self._sslobj = None diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index 2d0e9228da6636d..748bc2ab1bb5ab0 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -2063,25 +2063,28 @@ def make_ssl_objects(self, context): self.addCleanup(ssock.close) return obj, ssock - @unittest.skipIf(ssl.HAS_ECH, 'requires OpenSSL without ECH') - def test_not_implemented(self): - ctx = make_test_context() - self.assertFalse(ssl.ECHStatus.__members__) - with self.assertRaises(NotImplementedError): - ctx.set_ech_config(self.ECH_CONFIG) - with self.assertRaises(NotImplementedError): - ctx.set_outer_alpn_protocols(['h2']) - for obj in self.make_ssl_objects(ctx): - with self.subTest(type=type(obj)): - with self.assertRaises(NotImplementedError): - obj.get_ech_status() - with self.assertRaises(NotImplementedError): - obj.get_ech_retry_config() - with self.assertRaises(NotImplementedError): - obj.outer_server_hostname - with self.assertRaises(NotImplementedError): - obj._sslobj.set_outer_server_name('localhost') + def test_availability(self): + self.assertEqual(hasattr(ssl, 'ECHStatus'), ssl.HAS_ECH) + for module in (ssl, _ssl): + names = [name for name in dir(module) + if name.startswith(('ECH_STATUS_', 'OP_ECH_'))] + with self.subTest(module=module): + self.assertEqual(bool(names), ssl.HAS_ECH) + + context_methods = ('set_ech_config', '_set_outer_alpn_protocols') + socket_methods = ('get_ech_status', 'get_ech_retry_config') + for owner, names in ( + (_ssl._SSLContext, context_methods), + (ssl.SSLContext, (*context_methods, 'set_outer_alpn_protocols')), + (_ssl._SSLSocket, (*socket_methods, 'set_outer_server_name')), + (ssl.SSLObject, (*socket_methods, 'outer_server_hostname')), + (ssl.SSLSocket, (*socket_methods, 'outer_server_hostname')), + ): + for name in names: + with self.subTest(owner=owner, name=name): + self.assertEqual(hasattr(owner, name), ssl.HAS_ECH) + @unittest.skipUnless(ssl.HAS_ECH, 'requires ECH') def test_socket_without_ssl_object(self): ctx = make_test_context() with ctx.wrap_socket(socket.socket()) as sock: diff --git a/Modules/_ssl.c b/Modules/_ssl.c index d7c6f96d9edc831..7bdf241af054924 100644 --- a/Modules/_ssl.c +++ b/Modules/_ssl.c @@ -2122,6 +2122,7 @@ cipher_to_dict(const SSL_CIPHER *cipher) ); } +#ifdef HAVE_OPENSSL_ECH /*[clinic input] @critical_section _ssl._SSLSocket.get_ech_status @@ -2131,7 +2132,6 @@ static PyObject * _ssl__SSLSocket_get_ech_status_impl(PySSLSocket *self) /*[clinic end generated code: output=263bf7fc7888e2d6 input=71320052d6f2a10f]*/ { -#ifdef HAVE_OPENSSL_ECH char *inner_sni = NULL, *outer_sni = NULL; if (self->ssl == NULL) { Py_RETURN_NONE; @@ -2141,10 +2141,6 @@ _ssl__SSLSocket_get_ech_status_impl(PySSLSocket *self) OPENSSL_free(inner_sni); OPENSSL_free(outer_sni); return result; -#else - PyErr_SetString(PyExc_NotImplementedError, "OpenSSL does not have ECH support"); - return NULL; -#endif } /*[clinic input] @@ -2156,7 +2152,6 @@ static PyObject * _ssl__SSLSocket_get_ech_retry_config_impl(PySSLSocket *self) /*[clinic end generated code: output=967da2032df9a37a input=0e51b545e93f43ba]*/ { -#ifdef HAVE_OPENSSL_ECH if (self->ssl == NULL) { Py_RETURN_NONE; } @@ -2169,10 +2164,6 @@ _ssl__SSLSocket_get_ech_retry_config_impl(PySSLSocket *self) PyObject *result = PyBytes_FromStringAndSize((char *)config, config_len); OPENSSL_free(config); return result; -#else - PyErr_SetString(PyExc_NotImplementedError, "OpenSSL does not have ECH support"); - return NULL; -#endif } /*[clinic input] @@ -2188,7 +2179,6 @@ _ssl__SSLSocket_set_outer_server_name_impl(PySSLSocket *self, const char *outer_server_name) /*[clinic end generated code: output=24e7c6b7b3c2ce84 input=8d1d96628e11b2fd]*/ { -#ifdef HAVE_OPENSSL_ECH if (self->ssl == NULL) { Py_RETURN_NONE; } @@ -2205,11 +2195,8 @@ _ssl__SSLSocket_set_outer_server_name_impl(PySSLSocket *self, return NULL; } Py_RETURN_NONE; -#else - PyErr_SetString(PyExc_NotImplementedError, "OpenSSL does not have ECH support"); - return NULL; -#endif } +#endif /* HAVE_OPENSSL_ECH */ /*[clinic input] @critical_section @@ -4046,6 +4033,7 @@ _ssl__SSLContext__set_alpn_protocols_impl(PySSLContext *self, Py_RETURN_NONE; } +#ifdef HAVE_OPENSSL_ECH /*[clinic input] @critical_section _ssl._SSLContext._set_outer_alpn_protocols @@ -4058,7 +4046,6 @@ _ssl__SSLContext__set_outer_alpn_protocols_impl(PySSLContext *self, Py_buffer *protos) /*[clinic end generated code: output=823aea95e8dea835 input=b6ddf7d0791afd0e]*/ { -#ifdef HAVE_OPENSSL_ECH if ((size_t)protos->len > UINT_MAX) { PyErr_Format(PyExc_OverflowError, "protocols longer than %u bytes", UINT_MAX); @@ -4071,11 +4058,8 @@ _ssl__SSLContext__set_outer_alpn_protocols_impl(PySSLContext *self, return NULL; } Py_RETURN_NONE; -#else - PyErr_SetString(PyExc_NotImplementedError, "OpenSSL does not have ECH support"); - return NULL; -#endif } +#endif /* HAVE_OPENSSL_ECH */ /*[clinic input] @critical_section @@ -5211,6 +5195,7 @@ _ssl__SSLContext_set_default_verify_paths_impl(PySSLContext *self) Py_RETURN_NONE; } +#ifdef HAVE_OPENSSL_ECH /*[clinic input] @critical_section _ssl._SSLContext.set_ech_config @@ -5229,7 +5214,6 @@ _ssl__SSLContext_set_ech_config_impl(PySSLContext *self, Py_buffer *ech_config) /*[clinic end generated code: output=cea53188b338cf80 input=3943e5aff04a583e]*/ { -#ifdef HAVE_OPENSSL_ECH if (ech_config->len > INT_MAX) { PyErr_Format(PyExc_OverflowError, "ECH configuration longer than %d bytes", INT_MAX); @@ -5259,11 +5243,8 @@ _ssl__SSLContext_set_ech_config_impl(PySSLContext *self, OSSL_ECHSTORE_free(store); BIO_free(config_bio); return NULL; -#else - PyErr_SetString(PyExc_NotImplementedError, "OpenSSL does not have ECH support"); - return NULL; -#endif } +#endif /* HAVE_OPENSSL_ECH */ /*[clinic input] @critical_section diff --git a/Modules/clinic/_ssl.c.h b/Modules/clinic/_ssl.c.h index 13662d355a1462d..d3d429f0c3ba931 100644 --- a/Modules/clinic/_ssl.c.h +++ b/Modules/clinic/_ssl.c.h @@ -156,6 +156,8 @@ _ssl__SSLSocket_get_unverified_chain(PyObject *self, PyObject *Py_UNUSED(ignored return return_value; } +#if defined(HAVE_OPENSSL_ECH) + PyDoc_STRVAR(_ssl__SSLSocket_get_ech_status__doc__, "get_ech_status($self, /)\n" "--\n" @@ -179,6 +181,10 @@ _ssl__SSLSocket_get_ech_status(PyObject *self, PyObject *Py_UNUSED(ignored)) return return_value; } +#endif /* defined(HAVE_OPENSSL_ECH) */ + +#if defined(HAVE_OPENSSL_ECH) + PyDoc_STRVAR(_ssl__SSLSocket_get_ech_retry_config__doc__, "get_ech_retry_config($self, /)\n" "--\n" @@ -202,6 +208,10 @@ _ssl__SSLSocket_get_ech_retry_config(PyObject *self, PyObject *Py_UNUSED(ignored return return_value; } +#endif /* defined(HAVE_OPENSSL_ECH) */ + +#if defined(HAVE_OPENSSL_ECH) + PyDoc_STRVAR(_ssl__SSLSocket_set_outer_server_name__doc__, "set_outer_server_name($self, outer_server_name, /)\n" "--\n" @@ -241,6 +251,8 @@ _ssl__SSLSocket_set_outer_server_name(PyObject *self, PyObject *arg) return return_value; } +#endif /* defined(HAVE_OPENSSL_ECH) */ + PyDoc_STRVAR(_ssl__SSLSocket_shared_ciphers__doc__, "shared_ciphers($self, /)\n" "--\n" @@ -1274,6 +1286,8 @@ _ssl__SSLContext__set_alpn_protocols(PyObject *self, PyObject *arg) return return_value; } +#if defined(HAVE_OPENSSL_ECH) + PyDoc_STRVAR(_ssl__SSLContext__set_outer_alpn_protocols__doc__, "_set_outer_alpn_protocols($self, protos, /)\n" "--\n" @@ -1308,6 +1322,8 @@ _ssl__SSLContext__set_outer_alpn_protocols(PyObject *self, PyObject *arg) return return_value; } +#endif /* defined(HAVE_OPENSSL_ECH) */ + static int _ssl__SSLContext_verify_mode_get_impl(PySSLContext *self); @@ -2148,6 +2164,8 @@ _ssl__SSLContext_set_default_verify_paths(PyObject *self, PyObject *Py_UNUSED(ig return return_value; } +#if defined(HAVE_OPENSSL_ECH) + PyDoc_STRVAR(_ssl__SSLContext_set_ech_config__doc__, "set_ech_config($self, ech_config, /)\n" "--\n" @@ -2186,6 +2204,8 @@ _ssl__SSLContext_set_ech_config(PyObject *self, PyObject *arg) return return_value; } +#endif /* defined(HAVE_OPENSSL_ECH) */ + PyDoc_STRVAR(_ssl__SSLContext_set_ecdh_curve__doc__, "set_ecdh_curve($self, name, /)\n" "--\n" @@ -3181,10 +3201,30 @@ _ssl_enum_crls(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObje #endif /* defined(_MSC_VER) */ +#ifndef _SSL__SSLSOCKET_GET_ECH_STATUS_METHODDEF + #define _SSL__SSLSOCKET_GET_ECH_STATUS_METHODDEF +#endif /* !defined(_SSL__SSLSOCKET_GET_ECH_STATUS_METHODDEF) */ + +#ifndef _SSL__SSLSOCKET_GET_ECH_RETRY_CONFIG_METHODDEF + #define _SSL__SSLSOCKET_GET_ECH_RETRY_CONFIG_METHODDEF +#endif /* !defined(_SSL__SSLSOCKET_GET_ECH_RETRY_CONFIG_METHODDEF) */ + +#ifndef _SSL__SSLSOCKET_SET_OUTER_SERVER_NAME_METHODDEF + #define _SSL__SSLSOCKET_SET_OUTER_SERVER_NAME_METHODDEF +#endif /* !defined(_SSL__SSLSOCKET_SET_OUTER_SERVER_NAME_METHODDEF) */ + #ifndef _SSL__SSLSOCKET_SENDFILE_METHODDEF #define _SSL__SSLSOCKET_SENDFILE_METHODDEF #endif /* !defined(_SSL__SSLSOCKET_SENDFILE_METHODDEF) */ +#ifndef _SSL__SSLCONTEXT__SET_OUTER_ALPN_PROTOCOLS_METHODDEF + #define _SSL__SSLCONTEXT__SET_OUTER_ALPN_PROTOCOLS_METHODDEF +#endif /* !defined(_SSL__SSLCONTEXT__SET_OUTER_ALPN_PROTOCOLS_METHODDEF) */ + +#ifndef _SSL__SSLCONTEXT_SET_ECH_CONFIG_METHODDEF + #define _SSL__SSLCONTEXT_SET_ECH_CONFIG_METHODDEF +#endif /* !defined(_SSL__SSLCONTEXT_SET_ECH_CONFIG_METHODDEF) */ + #ifndef _SSL_ENUM_CERTIFICATES_METHODDEF #define _SSL_ENUM_CERTIFICATES_METHODDEF #endif /* !defined(_SSL_ENUM_CERTIFICATES_METHODDEF) */ @@ -3240,4 +3280,4 @@ _ssl_enum_crls(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObje #define _SSL_SSLSESSION_HAS_TICKET_GETSETDEF {"has_ticket", (getter)_ssl_SSLSession_has_ticket_get, (setter)NULL, _ssl_SSLSession_has_ticket__doc__}, -/*[clinic end generated code: output=e19d2f93856ff3df input=a9049054013a1b77]*/ +/*[clinic end generated code: output=c2e4b98c8e04a32a input=a9049054013a1b77]*/ From 666eca407bada1a8b3a1c56db28161f2aa444605 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 22 Oct 2026 14:43:29 -0400 Subject: [PATCH 04/22] Clanker review. --- Lib/test/test_ssl.py | 22 ++++++++++++++++++++++ Modules/_ssl.c | 44 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index 748bc2ab1bb5ab0..9faa92687d5373d 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -2114,6 +2114,15 @@ def test_set_ech_config(self): self.assertRaises(ssl.SSLError, ctx.set_ech_config, config) self.assertIsNone(ctx.set_ech_config(self.ECH_CONFIG)) + @unittest.skipUnless(ssl.HAS_ECH, 'requires ECH') + def test_mixed_ech_config_versions(self): + ctx = make_test_context() + supported = self.ECH_CONFIG[2:] + unsupported = b'\xff\xff\x00\x01\x00' + for entries in (unsupported + supported, supported + unsupported): + config = len(entries).to_bytes(2, 'big') + entries + self.assertIsNone(ctx.set_ech_config(config)) + @unittest.skipUnless(ssl.HAS_ECH, 'requires ECH') def test_status_before_handshake(self): ctx = make_test_context() @@ -2168,6 +2177,19 @@ def test_outer_alpn_does_not_replace_inner(self): self.assertEqual(client.selected_alpn_protocol(), 'h2') self.assertEqual(server.selected_alpn_protocol(), 'h2') + @unittest.skipUnless(ssl.HAS_ECH, 'requires ECH') + def test_outer_alpn_without_ech_config(self): + for options in (0, ssl.OP_ECH_GREASE): + client_ctx, server_ctx, hostname = testing_context() + client_ctx.options |= options + client_ctx.set_alpn_protocols(['h2']) + client_ctx.set_outer_alpn_protocols(['http/1.1']) + server_ctx.set_alpn_protocols(['h2', 'http/1.1']) + client, server, *_ = connected_bio_pair( + client_ctx, server_ctx, hostname) + self.assertEqual(client.selected_alpn_protocol(), 'h2') + self.assertEqual(server.selected_alpn_protocol(), 'h2') + @unittest.skipUnless(ssl.HAS_ECH, 'requires ECH') def test_grease(self): client_ctx, server_ctx, hostname = testing_context() diff --git a/Modules/_ssl.c b/Modules/_ssl.c index 7bdf241af054924..13b87d6475bcbc9 100644 --- a/Modules/_ssl.c +++ b/Modules/_ssl.c @@ -942,6 +942,23 @@ newPySSLSocket(PySSLContext *sslctx, PySocketSockObject *sock, return NULL; } +#ifdef HAVE_OPENSSL_ECH + if (socket_type == PY_SSL_CLIENT) { + char *inner_sni = NULL, *outer_sni = NULL; + int status = SSL_ech_get1_status(self->ssl, &inner_sni, &outer_sni); + OPENSSL_free(inner_sni); + OPENSSL_free(outer_sni); + /* OpenSSL also applies outer ALPN to ordinary and GREASE handshakes. */ + if ((status == SSL_ECH_STATUS_NOT_CONFIGURED || + status == SSL_ECH_STATUS_GREASE) && + SSL_ech_set1_outer_alpn_protos(self->ssl, NULL, 0) != 1) { + _setSSLError(get_state_ctx(sslctx), NULL, 0, __FILE__, __LINE__); + Py_DECREF(self); + return NULL; + } + } +#endif + if (socket_type == PY_SSL_SERVER) { #define SID_CTX "Python" /* Set the session id context (server-side only) */ @@ -5220,10 +5237,35 @@ _ssl__SSLContext_set_ech_config_impl(PySSLContext *self, return NULL; } OSSL_ECHSTORE *store = NULL; - BIO *config_bio = BIO_new_mem_buf(ech_config->buf, ech_config->len); + BIO *config_bio = BIO_new(BIO_s_mem()); if (config_bio == NULL) { goto fail; } + const unsigned char *config = ech_config->buf; + int config_len = (int)ech_config->len; + if (config_len >= 2 && ((config[0] << 8) | config[1]) == config_len - 2) { + config += 2; + config_len -= 2; + /* OpenSSL only recognizes binary input with a supported first entry. */ + while (config_len >= 4 && + ((config[0] << 8) | config[1]) != OSSL_ECH_RFC9849_VERSION) { + int entry_len = 4 + ((config[2] << 8) | config[3]); + if (entry_len > config_len) { + break; + } + config += entry_len; + config_len -= entry_len; + } + unsigned char length[2] = { + (unsigned char)(config_len >> 8), (unsigned char)config_len + }; + if (BIO_write(config_bio, length, sizeof(length)) != sizeof(length)) { + goto fail; + } + } + if (BIO_write(config_bio, config, config_len) != config_len) { + goto fail; + } store = OSSL_ECHSTORE_new(NULL, NULL); if (store == NULL) { goto fail; From 5097bb00981dacb5b659e09da5a6b605800ece24 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 22 Oct 2026 15:38:22 -0400 Subject: [PATCH 05/22] Implement support for ECH servers. --- Doc/library/ssl.rst | 28 +++++ Doc/whatsnew/3.16.rst | 2 + .../pycore_global_objects_fini_generated.h | 1 + Include/internal/pycore_global_strings.h | 1 + .../internal/pycore_runtime_init_generated.h | 1 + .../internal/pycore_unicodeobject_generated.h | 1 + Lib/test/certdata/ech.pem | 7 ++ Lib/test/test_ssl.py | 93 ++++++++++++++- ...5-06-12-15-39-48.gh-issue-89730.Lu35Fu.rst | 5 +- Modules/_ssl.c | 109 +++++++++++++++++- Modules/clinic/_ssl.c.h | 86 +++++++++++++- 11 files changed, 328 insertions(+), 6 deletions(-) create mode 100644 Lib/test/certdata/ech.pem diff --git a/Doc/library/ssl.rst b/Doc/library/ssl.rst index 758890a1b170803..65698e04d949170 100644 --- a/Doc/library/ssl.rst +++ b/Doc/library/ssl.rst @@ -1709,6 +1709,29 @@ to speed up repeated connections from the same clients. .. versionadded:: 3.4 +.. method:: SSLContext.load_ech_keys(keyfile, *, for_retry=True) + + Load a server's Encrypted Client Hello (ECH) private key and configuration. + The *keyfile* must be the path to a PEM file containing an unencrypted + private key followed by its matching ECHConfigList. Load the server's TLS + certificate and private key separately with :meth:`load_cert_chain`. + + Each call adds keys and configurations to those already loaded into the + context. If *for_retry* is true, the new configurations are included in + ECH retry configurations sent to clients. + + Configure ECH keys on the initial server context before accepting + connections. After successful ECH decryption, :attr:`sni_callback` + receives the inner server name. + + Invalid or missing keys or configurations, or a key that does not match + its configuration, raise :exc:`SSLError`. File access errors raise + :exc:`OSError`. + + Only available when :data:`HAS_ECH` is ``True``. + + .. versionadded:: next + .. method:: SSLContext.load_verify_locations(cafile=None, capath=None, cadata=None) Load a set of "certification authority" (CA) certificates used to validate @@ -3107,6 +3130,10 @@ parameters, a public key, and a public name used in the unencrypted outer ClientHello. ECH requires OpenSSL 4.0 or later with ECH enabled; check :data:`HAS_ECH` before using it. +Servers can load a PEM file containing their ECH private key and configuration +with :meth:`SSLContext.load_ech_keys`. Clients use the corresponding public +ECHConfigList with :meth:`SSLContext.set_ech_config`. + Configuration values may be discovered through DNS or through the server's retry configuration. The following example accepts a binary-encoded ECHConfigList discovered through DNS. If none is supplied, it enables ECH @@ -3147,6 +3174,7 @@ GREASE to request a retry configuration from the server:: The following classes, methods, and attributes will be useful for using ECH: - :class:`ECHStatus` + - :meth:`SSLContext.load_ech_keys` - :meth:`SSLContext.set_ech_config` - :meth:`SSLContext.set_outer_alpn_protocols` - :meth:`SSLSocket.get_ech_status` diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 04cee1750b6ca6d..44c1e3088259b7a 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -695,6 +695,8 @@ ssl to query HTTPS or SVCB records. Clients can also enable :data:`~ssl.OP_ECH_GREASE` and retrieve a server's retry configuration with :meth:`~ssl.SSLSocket.get_ech_retry_config` for a subsequent connection. + Servers can use :meth:`~ssl.SSLContext.load_ech_keys` to load ECH private + keys and their configurations from PEM files. :data:`~ssl.HAS_ECH` indicates whether ECH is available, and :meth:`~ssl.SSLSocket.get_ech_status` reports the status as an :class:`~ssl.ECHStatus` member. diff --git a/Include/internal/pycore_global_objects_fini_generated.h b/Include/internal/pycore_global_objects_fini_generated.h index 416448a74385b0c..ed170233f3f2aef 100644 --- a/Include/internal/pycore_global_objects_fini_generated.h +++ b/Include/internal/pycore_global_objects_fini_generated.h @@ -501,6 +501,7 @@ _PyStaticObjects_CheckAll(PyInterpreterState *interp) { _PyStaticObject_CheckUnicodeSingleton((PyObject *)&_Py_ID(fold), "fold", 4); _PyStaticObject_CheckUnicodeSingleton((PyObject *)&_Py_ID(foldspaces), "foldspaces", 10); _PyStaticObject_CheckUnicodeSingleton((PyObject *)&_Py_ID(follow_symlinks), "follow_symlinks", 15); + _PyStaticObject_CheckUnicodeSingleton((PyObject *)&_Py_ID(for_retry), "for_retry", 9); _PyStaticObject_CheckUnicodeSingleton((PyObject *)&_Py_ID(format), "format", 6); _PyStaticObject_CheckUnicodeSingleton((PyObject *)&_Py_ID(format_spec), "format_spec", 11); _PyStaticObject_CheckUnicodeSingleton((PyObject *)&_Py_ID(frame_buffer), "frame_buffer", 12); diff --git a/Include/internal/pycore_global_strings.h b/Include/internal/pycore_global_strings.h index 7a6082035f5c979..159e898b8077725 100644 --- a/Include/internal/pycore_global_strings.h +++ b/Include/internal/pycore_global_strings.h @@ -513,6 +513,7 @@ struct _Py_global_strings { STRUCT_FOR_ID(fold) STRUCT_FOR_ID(foldspaces) STRUCT_FOR_ID(follow_symlinks) + STRUCT_FOR_ID(for_retry) STRUCT_FOR_ID(format) STRUCT_FOR_ID(format_spec) STRUCT_FOR_ID(frame_buffer) diff --git a/Include/internal/pycore_runtime_init_generated.h b/Include/internal/pycore_runtime_init_generated.h index e9fce38ac8c4ac3..283d983bf80ee22 100644 --- a/Include/internal/pycore_runtime_init_generated.h +++ b/Include/internal/pycore_runtime_init_generated.h @@ -1788,6 +1788,7 @@ extern "C" { INIT_ID(fold), \ INIT_ID(foldspaces), \ INIT_ID(follow_symlinks), \ + INIT_ID(for_retry), \ INIT_ID(format), \ INIT_ID(format_spec), \ INIT_ID(frame_buffer), \ diff --git a/Include/internal/pycore_unicodeobject_generated.h b/Include/internal/pycore_unicodeobject_generated.h index ac814a8de84c3d3..e3949c2912f5873 100644 --- a/Include/internal/pycore_unicodeobject_generated.h +++ b/Include/internal/pycore_unicodeobject_generated.h @@ -465,6 +465,7 @@ _PyUnicode_InitStaticStrings(PyInterpreterState *interp) { offsetof(struct _Py_global_strings, identifiers._py_fold._ascii.ob_base), offsetof(struct _Py_global_strings, identifiers._py_foldspaces._ascii.ob_base), offsetof(struct _Py_global_strings, identifiers._py_follow_symlinks._ascii.ob_base), + offsetof(struct _Py_global_strings, identifiers._py_for_retry._ascii.ob_base), offsetof(struct _Py_global_strings, identifiers._py_format._ascii.ob_base), offsetof(struct _Py_global_strings, identifiers._py_format_spec._ascii.ob_base), offsetof(struct _Py_global_strings, identifiers._py_frame_buffer._ascii.ob_base), diff --git a/Lib/test/certdata/ech.pem b/Lib/test/certdata/ech.pem new file mode 100644 index 000000000000000..44c694a73352635 --- /dev/null +++ b/Lib/test/certdata/ech.pem @@ -0,0 +1,7 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VuBCIEIHB2dPkVRt6UnAIk4X6e0hxXqV8Uawjx9uzHKpVKXPd1 +-----END PRIVATE KEY----- +-----BEGIN ECHCONFIG----- +AEH+DQA9DwAgACDNp6BdYPMEKr4zkcIk8geuJRQ9rKaTZiPhEqHT8wpJXAAEAAEA +AQAOcHVibGljLmV4YW1wbGUAAA== +-----END ECHCONFIG----- diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index 9faa92687d5373d..df884d731671044 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -14,6 +14,7 @@ from test.support import warnings_helper from test.support import asyncore import array +import base64 import re import socket import select @@ -2071,7 +2072,8 @@ def test_availability(self): with self.subTest(module=module): self.assertEqual(bool(names), ssl.HAS_ECH) - context_methods = ('set_ech_config', '_set_outer_alpn_protocols') + context_methods = ('set_ech_config', 'load_ech_keys', + '_set_outer_alpn_protocols') socket_methods = ('get_ech_status', 'get_ech_retry_config') for owner, names in ( (_ssl._SSLContext, context_methods), @@ -2123,6 +2125,95 @@ def test_mixed_ech_config_versions(self): config = len(entries).to_bytes(2, 'big') + entries self.assertIsNone(ctx.set_ech_config(config)) + def read_ech_key_and_config(self): + # Generated with: + # openssl ech -public_name public.example -suite 0x20,1,1 + with open(data_file('ech.pem'), 'rb') as f: + key, config = f.read().split(b'-----BEGIN ECHCONFIG-----') + config = base64.b64decode(config.split(b'-----END ECHCONFIG-----')[0]) + return key, config + + @unittest.skipUnless(ssl.HAS_ECH, 'requires ECH') + def test_load_ech_keys(self): + client_ctx, ctx, hostname = testing_context() + filename = data_file('ech.pem') + for path in (filename, os.fsencode(filename), + os_helper.FakePath(filename)): + self.assertIsNone(ctx.load_ech_keys(path)) + self.assertRaises(TypeError, ctx.load_ech_keys, None) + self.assertRaises(ValueError, ctx.load_ech_keys, 'key\x00file') + with self.assertRaises(OSError) as cm: + ctx.load_ech_keys(NONEXISTINGKEY) + self.assertEqual(cm.exception.errno, errno.ENOENT) + self.assertEqual(cm.exception.filename, NONEXISTINGKEY) + + key, config = self.read_ech_key_and_config() + public = (b'-----BEGIN ECHCONFIG-----\n' + base64.b64encode(config) + + b'\n-----END ECHCONFIG-----\n') + mismatched = bytearray(config) + mismatched[11] ^= 1 # Change one bit of the HPKE public key. + mismatched = (key + b'-----BEGIN ECHCONFIG-----\n' + + base64.b64encode(mismatched) + + b'\n-----END ECHCONFIG-----\n') + with open(ONLYKEY_PROTECTED, 'rb') as f: + encrypted = f.read() + public + self.addCleanup(os_helper.unlink, os_helper.TESTFN) + for pem in (b'invalid', public, mismatched, encrypted): + with self.subTest(pem=pem): + with open(os_helper.TESTFN, 'wb') as f: + f.write(pem) + self.assertRaises(ssl.SSLError, ctx.load_ech_keys, + os_helper.TESTFN) + # Failed loads must leave the previously loaded keys usable. + client_ctx.minimum_version = ssl.TLSVersion.TLSv1_3 + client_ctx.set_ech_config(config) + client, server, *_ = connected_bio_pair(client_ctx, ctx, hostname) + self.assertEqual(client.get_ech_status(), + ssl.ECHStatus.ECH_STATUS_SUCCESS) + + @unittest.skipUnless(ssl.HAS_ECH, 'requires ECH') + def test_ech_handshake(self): + key, config = self.read_ech_key_and_config() + other_config = bytearray(config) + other_config[6] ^= 1 # A different config_id using the same key. + self.addCleanup(os_helper.unlink, os_helper.TESTFN) + with open(os_helper.TESTFN, 'wb') as f: + f.write(key + b'-----BEGIN ECHCONFIG-----\n' + + base64.b64encode(other_config) + + b'\n-----END ECHCONFIG-----\n') + client_ctx, server_ctx, hostname = testing_context() + client_ctx.minimum_version = ssl.TLSVersion.TLSv1_3 + server_ctx.load_ech_keys(data_file('ech.pem'), for_retry=False) + server_ctx.load_ech_keys(os_helper.TESTFN) + # Both the old and newly appended configurations must work. + for current_config in (config, other_config): + with self.subTest(config_id=current_config[6]): + client_ctx.set_ech_config(current_config) + seen_names = [] + + def sni_callback(obj, name, ctx): + seen_names.append(name) + + server_ctx.sni_callback = sni_callback + client, server, c_in, c_out, s_in, s_out = connected_bio_pair( + client_ctx, server_ctx, hostname) + for obj in (client, server): + self.assertEqual(obj.get_ech_status(), + ssl.ECHStatus.ECH_STATUS_SUCCESS) + self.assertEqual(obj.outer_server_hostname, + 'public.example') + self.assertEqual(seen_names, [hostname]) + client.write(b'hello') + s_in.write(c_out.read()) + self.assertEqual(server.read(), b'hello') + server.write(b'world') + c_in.write(s_out.read()) + self.assertEqual(client.read(), b'world') + client_ctx, _, hostname = testing_context() + client_ctx.options |= ssl.OP_ECH_GREASE + client, server, *_ = connected_bio_pair(client_ctx, server_ctx, hostname) + self.assertEqual(client.get_ech_retry_config(), bytes(other_config)) + @unittest.skipUnless(ssl.HAS_ECH, 'requires ECH') def test_status_before_handshake(self): ctx = make_test_context() diff --git a/Misc/NEWS.d/next/Library/2025-06-12-15-39-48.gh-issue-89730.Lu35Fu.rst b/Misc/NEWS.d/next/Library/2025-06-12-15-39-48.gh-issue-89730.Lu35Fu.rst index 302227b69fb441f..bf4f8cfc476d2f1 100644 --- a/Misc/NEWS.d/next/Library/2025-06-12-15-39-48.gh-issue-89730.Lu35Fu.rst +++ b/Misc/NEWS.d/next/Library/2025-06-12-15-39-48.gh-issue-89730.Lu35Fu.rst @@ -1,3 +1,2 @@ -Add client support for Encrypted Client Hello (ECH) to the :mod:`ssl` module -with OpenSSL 4.0 or later. ECH configurations can be supplied with -:meth:`ssl.SSLContext.set_ech_config`. +Add support for Encrypted Client Hello (ECH) to the :mod:`ssl` module with +OpenSSL 4.0 or later. diff --git a/Modules/_ssl.c b/Modules/_ssl.c index 13b87d6475bcbc9..944a5ed63aad0b1 100644 --- a/Modules/_ssl.c +++ b/Modules/_ssl.c @@ -5273,7 +5273,10 @@ _ssl__SSLContext_set_ech_config_impl(PySSLContext *self, if (OSSL_ECHSTORE_read_echconfiglist(store, config_bio) != 1) { goto fail; } - if (SSL_CTX_set1_echstore(self->ctx, store) != 1) { + PyMutex_Lock(&self->tstate_mutex); + int result = SSL_CTX_set1_echstore(self->ctx, store); + PyMutex_Unlock(&self->tstate_mutex); + if (result != 1) { goto fail; } OSSL_ECHSTORE_free(store); @@ -5286,6 +5289,109 @@ _ssl__SSLContext_set_ech_config_impl(PySSLContext *self, BIO_free(config_bio); return NULL; } + +static int +ech_password_callback(char *buf, int size, int rwflag, void *userdata) +{ + /* ECH key files must be unencrypted. Never prompt for a password. */ + return -1; +} + +static int +load_ech_keys(SSL_CTX *ctx, BIO *bio, int for_retry) +{ + int result = 0; + int old_keys, new_keys; + OSSL_ECHSTORE *store = NULL; + EVP_PKEY *key = PEM_read_bio_PrivateKey(bio, NULL, + ech_password_callback, NULL); + if (key == NULL) { + goto end; + } + store = SSL_CTX_get1_echstore(ctx); + if (store == NULL) { + if (ERR_peek_error() != 0) { + goto end; + } + store = OSSL_ECHSTORE_new(NULL, NULL); + if (store == NULL) { + goto end; + } + } + if (OSSL_ECHSTORE_num_keys(store, &old_keys) != 1 || + OSSL_ECHSTORE_set1_key_and_read_pem(store, key, bio, for_retry) != 1 || + OSSL_ECHSTORE_num_keys(store, &new_keys) != 1) { + goto end; + } + /* OpenSSL may match a key already in the store. Require a new match. */ + if (new_keys <= old_keys) { + ERR_raise(ERR_LIB_SSL, SSL_R_PRIVATE_KEY_MISMATCH); + goto end; + } + result = SSL_CTX_set1_echstore(ctx, store); + +end: + EVP_PKEY_free(key); + OSSL_ECHSTORE_free(store); + return result; +} + +/*[clinic input] +@critical_section +_ssl._SSLContext.load_ech_keys + + keyfile: object + * + for_retry: bool = True + +Load server ECH keys and configurations from a PEM file. + +The file must contain an unencrypted private key followed by its +ECHConfigList. +If for_retry is true, advertise the configurations in ECH retry +responses. +[clinic start generated code]*/ + +static PyObject * +_ssl__SSLContext_load_ech_keys_impl(PySSLContext *self, PyObject *keyfile, + int for_retry) +/*[clinic end generated code: output=8de2cc58ce2f8077 input=3e6617d346227acd]*/ +{ + PyObject *keyfile_bytes; + if (!PyUnicode_FSConverter(keyfile, &keyfile_bytes)) { + return NULL; + } + const char *filename = PyBytes_AS_STRING(keyfile_bytes); + int result = 0; + int open_failed = 0; + + ERR_clear_error(); + PySSL_BEGIN_ALLOW_THREADS(self) + errno = 0; + BIO *bio = BIO_new_file(filename, "rb"); + if (bio == NULL) { + open_failed = 1; + } + else { + result = load_ech_keys(self->ctx, bio, for_retry); + BIO_free(bio); + } + PySSL_END_ALLOW_THREADS(self) + + if (result != 1) { + if (open_failed && errno != 0) { + PyErr_SetFromErrnoWithFilenameObject(PyExc_OSError, keyfile); + ERR_clear_error(); + } + else { + _setSSLError(get_state_ctx(self), NULL, 0, __FILE__, __LINE__); + } + Py_DECREF(keyfile_bytes); + return NULL; + } + Py_DECREF(keyfile_bytes); + Py_RETURN_NONE; +} #endif /* HAVE_OPENSSL_ECH */ /*[clinic input] @@ -5968,6 +6074,7 @@ static struct PyMethodDef context_methods[] = { _SSL__SSLCONTEXT_SET_DEFAULT_VERIFY_PATHS_METHODDEF _SSL__SSLCONTEXT_SET_ECDH_CURVE_METHODDEF _SSL__SSLCONTEXT_SET_ECH_CONFIG_METHODDEF + _SSL__SSLCONTEXT_LOAD_ECH_KEYS_METHODDEF _SSL__SSLCONTEXT__SET_OUTER_ALPN_PROTOCOLS_METHODDEF _SSL__SSLCONTEXT_CERT_STORE_STATS_METHODDEF _SSL__SSLCONTEXT_GET_CA_CERTS_METHODDEF diff --git a/Modules/clinic/_ssl.c.h b/Modules/clinic/_ssl.c.h index d3d429f0c3ba931..b6299b8e220c388 100644 --- a/Modules/clinic/_ssl.c.h +++ b/Modules/clinic/_ssl.c.h @@ -2206,6 +2206,86 @@ _ssl__SSLContext_set_ech_config(PyObject *self, PyObject *arg) #endif /* defined(HAVE_OPENSSL_ECH) */ +#if defined(HAVE_OPENSSL_ECH) + +PyDoc_STRVAR(_ssl__SSLContext_load_ech_keys__doc__, +"load_ech_keys($self, /, keyfile, *, for_retry=True)\n" +"--\n" +"\n" +"Load server ECH keys and configurations from a PEM file.\n" +"\n" +"The file must contain an unencrypted private key followed by its\n" +"ECHConfigList.\n" +"If for_retry is true, advertise the configurations in ECH retry\n" +"responses."); + +#define _SSL__SSLCONTEXT_LOAD_ECH_KEYS_METHODDEF \ + {"load_ech_keys", _PyCFunction_CAST(_ssl__SSLContext_load_ech_keys), METH_FASTCALL|METH_KEYWORDS, _ssl__SSLContext_load_ech_keys__doc__}, + +static PyObject * +_ssl__SSLContext_load_ech_keys_impl(PySSLContext *self, PyObject *keyfile, + int for_retry); + +static PyObject * +_ssl__SSLContext_load_ech_keys(PyObject *self, PyObject *const *args, Py_ssize_t nargs, PyObject *kwnames) +{ + PyObject *return_value = NULL; + #if defined(Py_BUILD_CORE) && !defined(Py_BUILD_CORE_MODULE) + + #define NUM_KEYWORDS 2 + static struct { + PyGC_Head _this_is_not_used; + PyObject_VAR_HEAD + Py_hash_t ob_hash; + PyObject *ob_item[NUM_KEYWORDS]; + } _kwtuple = { + .ob_base = PyVarObject_HEAD_INIT(&PyTuple_Type, NUM_KEYWORDS) + .ob_hash = -1, + .ob_item = { &_Py_ID(keyfile), &_Py_ID(for_retry), }, + }; + #undef NUM_KEYWORDS + #define KWTUPLE (&_kwtuple.ob_base.ob_base) + + #else // !Py_BUILD_CORE + # define KWTUPLE NULL + #endif // !Py_BUILD_CORE + + static const char * const _keywords[] = {"keyfile", "for_retry", NULL}; + static _PyArg_Parser _parser = { + .keywords = _keywords, + .fname = "load_ech_keys", + .kwtuple = KWTUPLE, + }; + #undef KWTUPLE + PyObject *argsbuf[2]; + Py_ssize_t noptargs = nargs + (kwnames ? PyTuple_GET_SIZE(kwnames) : 0) - 1; + PyObject *keyfile; + int for_retry = 1; + + args = _PyArg_UnpackKeywords(args, nargs, NULL, kwnames, &_parser, + /*minpos*/ 1, /*maxpos*/ 1, /*minkw*/ 0, /*varpos*/ 0, argsbuf); + if (!args) { + goto exit; + } + keyfile = args[0]; + if (!noptargs) { + goto skip_optional_kwonly; + } + for_retry = PyObject_IsTrue(args[1]); + if (for_retry < 0) { + goto exit; + } +skip_optional_kwonly: + Py_BEGIN_CRITICAL_SECTION(self); + return_value = _ssl__SSLContext_load_ech_keys_impl((PySSLContext *)self, keyfile, for_retry); + Py_END_CRITICAL_SECTION(); + +exit: + return return_value; +} + +#endif /* defined(HAVE_OPENSSL_ECH) */ + PyDoc_STRVAR(_ssl__SSLContext_set_ecdh_curve__doc__, "set_ecdh_curve($self, name, /)\n" "--\n" @@ -3225,6 +3305,10 @@ _ssl_enum_crls(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObje #define _SSL__SSLCONTEXT_SET_ECH_CONFIG_METHODDEF #endif /* !defined(_SSL__SSLCONTEXT_SET_ECH_CONFIG_METHODDEF) */ +#ifndef _SSL__SSLCONTEXT_LOAD_ECH_KEYS_METHODDEF + #define _SSL__SSLCONTEXT_LOAD_ECH_KEYS_METHODDEF +#endif /* !defined(_SSL__SSLCONTEXT_LOAD_ECH_KEYS_METHODDEF) */ + #ifndef _SSL_ENUM_CERTIFICATES_METHODDEF #define _SSL_ENUM_CERTIFICATES_METHODDEF #endif /* !defined(_SSL_ENUM_CERTIFICATES_METHODDEF) */ @@ -3280,4 +3364,4 @@ _ssl_enum_crls(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObje #define _SSL_SSLSESSION_HAS_TICKET_GETSETDEF {"has_ticket", (getter)_ssl_SSLSession_has_ticket_get, (setter)NULL, _ssl_SSLSession_has_ticket__doc__}, -/*[clinic end generated code: output=c2e4b98c8e04a32a input=a9049054013a1b77]*/ +/*[clinic end generated code: output=3078af6ab6373911 input=a9049054013a1b77]*/ From dc389ff87ef9980debba47aad28c909509b6f31f Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 22 Oct 2026 15:56:52 -0400 Subject: [PATCH 06/22] Some fixes. --- Lib/test/test_ssl.py | 22 ++++++++++++------- Modules/_ssl.c | 52 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 55 insertions(+), 19 deletions(-) diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index df884d731671044..0c2344cd71b2742 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -2046,6 +2046,12 @@ def psk_server_callback(*args, _=ctx, **kwargs): ... class ECHTests(unittest.TestCase): + requires_ech_x25519 = unittest.skipUnless( + ssl.HAS_ECH + and 'x25519' in ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT).get_groups(), + 'requires ECH with X25519', + ) + # An ECHConfigList for localhost using X25519, HKDF-SHA256 and AES-128-GCM. ECH_CONFIG = bytes.fromhex( '003c fe0d 0038 db 0020 0020' @@ -2097,7 +2103,7 @@ def test_socket_without_ssl_object(self): self.assertIsNone(sock.get_ech_status()) self.assertIsNone(sock.get_ech_retry_config()) - @unittest.skipUnless(ssl.HAS_ECH, 'requires ECH') + @requires_ech_x25519 def test_set_ech_config(self): ctx = make_test_context() for config in (self.ECH_CONFIG, bytearray(self.ECH_CONFIG), @@ -2116,7 +2122,7 @@ def test_set_ech_config(self): self.assertRaises(ssl.SSLError, ctx.set_ech_config, config) self.assertIsNone(ctx.set_ech_config(self.ECH_CONFIG)) - @unittest.skipUnless(ssl.HAS_ECH, 'requires ECH') + @requires_ech_x25519 def test_mixed_ech_config_versions(self): ctx = make_test_context() supported = self.ECH_CONFIG[2:] @@ -2133,7 +2139,7 @@ def read_ech_key_and_config(self): config = base64.b64decode(config.split(b'-----END ECHCONFIG-----')[0]) return key, config - @unittest.skipUnless(ssl.HAS_ECH, 'requires ECH') + @requires_ech_x25519 def test_load_ech_keys(self): client_ctx, ctx, hostname = testing_context() filename = data_file('ech.pem') @@ -2171,7 +2177,7 @@ def test_load_ech_keys(self): self.assertEqual(client.get_ech_status(), ssl.ECHStatus.ECH_STATUS_SUCCESS) - @unittest.skipUnless(ssl.HAS_ECH, 'requires ECH') + @requires_ech_x25519 def test_ech_handshake(self): key, config = self.read_ech_key_and_config() other_config = bytearray(config) @@ -2214,7 +2220,7 @@ def sni_callback(obj, name, ctx): client, server, *_ = connected_bio_pair(client_ctx, server_ctx, hostname) self.assertEqual(client.get_ech_retry_config(), bytes(other_config)) - @unittest.skipUnless(ssl.HAS_ECH, 'requires ECH') + @requires_ech_x25519 def test_status_before_handshake(self): ctx = make_test_context() for configured in (False, True): @@ -2233,7 +2239,7 @@ def test_status_before_handshake(self): self.assertIsNone(obj.outer_server_hostname) self.assertEqual(obj.get_ech_retry_config(), b'') - @unittest.skipUnless(ssl.HAS_ECH, 'requires ECH') + @requires_ech_x25519 def test_set_outer_server_name(self): ctx = make_test_context() ctx.set_ech_config(self.ECH_CONFIG) @@ -2310,7 +2316,7 @@ def make_rejected_client(self): c_in.write(s_out.read()) return client - @unittest.skipUnless(ssl.HAS_ECH, 'requires ECH') + @requires_ech_x25519 def test_rejected_ech(self): client = self.make_rejected_client() self.assertEqual(client.get_ech_status(), ssl.ECHStatus.ECH_STATUS_FAILED) @@ -2320,7 +2326,7 @@ def test_rejected_ech(self): self.assertEqual(client.outer_server_hostname, 'localhost') self.assertEqual(client.get_ech_retry_config(), b'') - @unittest.skipUnless(ssl.HAS_ECH, 'requires ECH') + @requires_ech_x25519 @support.nomemtest def test_status_allocation_failure(self): import _testcapi diff --git a/Modules/_ssl.c b/Modules/_ssl.c index 944a5ed63aad0b1..428ce341eaab7d5 100644 --- a/Modules/_ssl.c +++ b/Modules/_ssl.c @@ -5241,36 +5241,66 @@ _ssl__SSLContext_set_ech_config_impl(PySSLContext *self, if (config_bio == NULL) { goto fail; } + store = OSSL_ECHSTORE_new(NULL, NULL); + if (store == NULL) { + goto fail; + } const unsigned char *config = ech_config->buf; int config_len = (int)ech_config->len; if (config_len >= 2 && ((config[0] << 8) | config[1]) == config_len - 2) { config += 2; config_len -= 2; - /* OpenSSL only recognizes binary input with a supported first entry. */ - while (config_len >= 4 && - ((config[0] << 8) | config[1]) != OSSL_ECH_RFC9849_VERSION) { + /* Read entries separately so unsupported ones do not reject the list. */ + while (config_len >= 4) { int entry_len = 4 + ((config[2] << 8) | config[3]); if (entry_len > config_len) { break; } + if (((config[0] << 8) | config[1]) == OSSL_ECH_RFC9849_VERSION) { + unsigned char length[2] = { + (unsigned char)(entry_len >> 8), (unsigned char)entry_len + }; + if (BIO_reset(config_bio) != 1 || + BIO_write(config_bio, length, sizeof(length)) != sizeof(length) || + BIO_write(config_bio, config, entry_len) != entry_len) { + goto fail; + } + ERR_clear_error(); + if (OSSL_ECHSTORE_read_echconfiglist(store, config_bio) != 1) { + unsigned long err = ERR_peek_error(); + /* Unsupported HPKE suites may first raise provider errors. */ + while (ERR_GET_LIB(err) == ERR_LIB_PROV && + !ERR_FATAL_ERROR(err)) { + ERR_get_error(); + err = ERR_peek_error(); + } + /* Ignore unsupported suites and mandatory extensions, + * preserving malformed input and fatal errors. */ + if (ERR_GET_LIB(err) != ERR_LIB_SSL || + ERR_GET_REASON(err) != ERR_R_PASSED_INVALID_ARGUMENT) { + goto fail; + } + } + ERR_clear_error(); + } config += entry_len; config_len -= entry_len; } - unsigned char length[2] = { - (unsigned char)(config_len >> 8), (unsigned char)config_len - }; - if (BIO_write(config_bio, length, sizeof(length)) != sizeof(length)) { + if (config_len != 0) { + ERR_raise(ERR_LIB_SSL, SSL_R_ECH_DECODE_ERROR); goto fail; } } - if (BIO_write(config_bio, config, config_len) != config_len) { + else if (BIO_write(config_bio, config, config_len) != config_len || + OSSL_ECHSTORE_read_echconfiglist(store, config_bio) != 1) { goto fail; } - store = OSSL_ECHSTORE_new(NULL, NULL); - if (store == NULL) { + int num_entries; + if (OSSL_ECHSTORE_num_entries(store, &num_entries) != 1) { goto fail; } - if (OSSL_ECHSTORE_read_echconfiglist(store, config_bio) != 1) { + if (num_entries == 0) { + ERR_raise(ERR_LIB_SSL, ERR_R_PASSED_INVALID_ARGUMENT); goto fail; } PyMutex_Lock(&self->tstate_mutex); From 0167ac1ca59acb5292a6aa45442158d46e9b3681 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 22 Oct 2026 16:03:09 -0400 Subject: [PATCH 07/22] Some cleanup. --- Modules/_ssl.c | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/Modules/_ssl.c b/Modules/_ssl.c index 428ce341eaab7d5..e1c1416c1985fc4 100644 --- a/Modules/_ssl.c +++ b/Modules/_ssl.c @@ -949,9 +949,9 @@ newPySSLSocket(PySSLContext *sslctx, PySocketSockObject *sock, OPENSSL_free(inner_sni); OPENSSL_free(outer_sni); /* OpenSSL also applies outer ALPN to ordinary and GREASE handshakes. */ - if ((status == SSL_ECH_STATUS_NOT_CONFIGURED || - status == SSL_ECH_STATUS_GREASE) && - SSL_ech_set1_outer_alpn_protos(self->ssl, NULL, 0) != 1) { + if ((status == SSL_ECH_STATUS_NOT_CONFIGURED + || status == SSL_ECH_STATUS_GREASE) + && SSL_ech_set1_outer_alpn_protos(self->ssl, NULL, 0) != 1) { _setSSLError(get_state_ctx(sslctx), NULL, 0, __FILE__, __LINE__); Py_DECREF(self); return NULL; @@ -4069,8 +4069,8 @@ _ssl__SSLContext__set_outer_alpn_protocols_impl(PySSLContext *self, return NULL; } if (SSL_CTX_ech_set1_outer_alpn_protos(self->ctx, - protos->len ? protos->buf : NULL, - protos->len) != 1) { + protos->len ? protos->buf : NULL, + protos->len) != 1) { _setSSLError(get_state_ctx(self), NULL, 0, __FILE__, __LINE__); return NULL; } @@ -5260,24 +5260,23 @@ _ssl__SSLContext_set_ech_config_impl(PySSLContext *self, unsigned char length[2] = { (unsigned char)(entry_len >> 8), (unsigned char)entry_len }; - if (BIO_reset(config_bio) != 1 || - BIO_write(config_bio, length, sizeof(length)) != sizeof(length) || - BIO_write(config_bio, config, entry_len) != entry_len) { + if (BIO_reset(config_bio) != 1 + || BIO_write(config_bio, length, sizeof(length)) != sizeof(length) + || BIO_write(config_bio, config, entry_len) != entry_len) { goto fail; } ERR_clear_error(); if (OSSL_ECHSTORE_read_echconfiglist(store, config_bio) != 1) { unsigned long err = ERR_peek_error(); /* Unsupported HPKE suites may first raise provider errors. */ - while (ERR_GET_LIB(err) == ERR_LIB_PROV && - !ERR_FATAL_ERROR(err)) { + while (ERR_GET_LIB(err) == ERR_LIB_PROV && !ERR_FATAL_ERROR(err)) { ERR_get_error(); err = ERR_peek_error(); } /* Ignore unsupported suites and mandatory extensions, * preserving malformed input and fatal errors. */ - if (ERR_GET_LIB(err) != ERR_LIB_SSL || - ERR_GET_REASON(err) != ERR_R_PASSED_INVALID_ARGUMENT) { + if (ERR_GET_LIB(err) != ERR_LIB_SSL + || ERR_GET_REASON(err) != ERR_R_PASSED_INVALID_ARGUMENT) { goto fail; } } @@ -5291,8 +5290,8 @@ _ssl__SSLContext_set_ech_config_impl(PySSLContext *self, goto fail; } } - else if (BIO_write(config_bio, config, config_len) != config_len || - OSSL_ECHSTORE_read_echconfiglist(store, config_bio) != 1) { + else if (BIO_write(config_bio, config, config_len) != config_len + || OSSL_ECHSTORE_read_echconfiglist(store, config_bio) != 1) { goto fail; } int num_entries; @@ -5334,7 +5333,7 @@ load_ech_keys(SSL_CTX *ctx, BIO *bio, int for_retry) int old_keys, new_keys; OSSL_ECHSTORE *store = NULL; EVP_PKEY *key = PEM_read_bio_PrivateKey(bio, NULL, - ech_password_callback, NULL); + ech_password_callback, NULL); if (key == NULL) { goto end; } @@ -5348,12 +5347,12 @@ load_ech_keys(SSL_CTX *ctx, BIO *bio, int for_retry) goto end; } } - if (OSSL_ECHSTORE_num_keys(store, &old_keys) != 1 || - OSSL_ECHSTORE_set1_key_and_read_pem(store, key, bio, for_retry) != 1 || - OSSL_ECHSTORE_num_keys(store, &new_keys) != 1) { + if (OSSL_ECHSTORE_num_keys(store, &old_keys) != 1 + || OSSL_ECHSTORE_set1_key_and_read_pem(store, key, bio, for_retry) != 1 + || OSSL_ECHSTORE_num_keys(store, &new_keys) != 1) { goto end; } - /* OpenSSL may match a key already in the store. Require a new match. */ + // OpenSSL may match a key already in the store. Require a new match. if (new_keys <= old_keys) { ERR_raise(ERR_LIB_SSL, SSL_R_PRIVATE_KEY_MISMATCH); goto end; From 6ebce149e3de0df3036ad8fd31c67d6004d892fe Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 22 Oct 2026 18:44:17 -0400 Subject: [PATCH 08/22] Per-socket ECH config. --- Doc/library/ssl.rst | 114 ++++++++++++---- Doc/whatsnew/3.16.rst | 15 ++- Lib/ssl.py | 52 ++++++-- Lib/test/certdata/ech-localhost.pem | 7 + Lib/test/test_ssl.py | 159 +++++++++++++++++++++-- Modules/_ssl.c | 194 +++++++++++++++++++++++----- Modules/_ssl.h | 2 + Modules/clinic/_ssl.c.h | 47 ++++++- 8 files changed, 506 insertions(+), 84 deletions(-) create mode 100644 Lib/test/certdata/ech-localhost.pem diff --git a/Doc/library/ssl.rst b/Doc/library/ssl.rst index 65698e04d949170..1f1ebc1020b5d9d 100644 --- a/Doc/library/ssl.rst +++ b/Doc/library/ssl.rst @@ -321,6 +321,23 @@ Exceptions A human readable string of the verification error. +.. exception:: SSLECHRejectedError + + A subclass of :exc:`SSLError` raised when the handshake fails because the + server rejected :ref:`Encrypted Client Hello `. + The connection cannot be used. If :attr:`retry_config` is not empty, the + client may retry once with a new connection using that configuration. + + This exception is only raised when :data:`HAS_ECH` is ``True``. + + .. attribute:: retry_config + + The server's ECH retry configuration as a binary-encoded ECHConfigList, + or empty :class:`bytes` if the server did not provide one or the server + could not be authenticated under the outer server name. + + .. versionadded:: next + .. exception:: CertificateError An alias for :exc:`SSLCertVerificationError`. @@ -1430,7 +1447,10 @@ SSL sockets also have the following additional methods and attributes: bytes if no retry configuration is available. When the status returned by :meth:`SSLSocket.get_ech_status` after the handshake is :attr:`ECHStatus.ECH_STATUS_GREASE_ECH`, the configuration can be used - for a new connection using ECH. + for a new connection using ECH. When the server rejects a real ECH + attempt, the handshake raises :exc:`SSLECHRejectedError`, which carries + the same configuration in its :attr:`~SSLECHRejectedError.retry_config` + attribute. Only available when :data:`HAS_ECH` is ``True``. @@ -1445,6 +1465,26 @@ SSL sockets also have the following additional methods and attributes: .. versionadded:: next +.. method:: SSLSocket.set_ech_config(ech_config) + + Set the Encrypted Client Hello (ECH) configuration for this connection + only, replacing any configuration set on the context with + :meth:`SSLContext.set_ech_config`. The *ech_config* parameter accepts the + same values. Unlike the context's configuration, which applies to every + connection made from it, a connection's configuration is only used by that + connection and can therefore be chosen per destination host, which is how + ECH configurations are published in DNS. + + The configuration must be set before the handshake starts, so this is + mostly useful with ``do_handshake_on_connect=False`` or on a socket that + is not connected yet. :meth:`SSLContext.wrap_socket` and + :meth:`SSLContext.wrap_bio` accept the configuration as their *ech_config* + argument instead. + + Only available when :data:`HAS_ECH` is ``True``. + + .. versionadded:: next + .. method:: SSLSocket.selected_alpn_protocol() Return the protocol that was selected during the TLS handshake. If @@ -1932,6 +1972,14 @@ to speed up repeated connections from the same clients. an HTTPS resource record in DNS or from :meth:`SSLSocket.get_ech_retry_config`. Each call replaces the context's previous configuration. + The configuration applies to every connection made from the context, and a + server that cannot decrypt it rejects the connection with + :exc:`SSLECHRejectedError`. Because ECH configurations are published per + host, a context with a configuration should only be used for connections to + the hosts that publish it. To use a configuration for a single connection, + pass it as the *ech_config* argument of :meth:`wrap_socket` or + :meth:`wrap_bio` instead. + If the input value provided contains no suitable value (e.g. if it only contains ECH configuration versions that are not supported), an :class:`SSLError` will be raised. @@ -2080,7 +2128,7 @@ to speed up repeated connections from the same clients. .. method:: SSLContext.wrap_socket(sock, server_side=False, \ do_handshake_on_connect=True, suppress_ragged_eofs=True, \ - server_hostname=None, session=None) + server_hostname=None, session=None, ech_config=None) Wrap an existing Python socket *sock* and return an instance of :attr:`SSLContext.sslsocket_class` (default :class:`SSLSocket`). The @@ -2121,6 +2169,10 @@ to speed up repeated connections from the same clients. *session*, see :attr:`~SSLSocket.session`. + *ech_config*, see :meth:`SSLSocket.set_ech_config`. Specifying + *ech_config* will raise a :exc:`ValueError` if *server_side* is true, and + a :exc:`NotImplementedError` if :data:`HAS_ECH` is ``False``. + To wrap an :class:`SSLSocket` in another :class:`SSLSocket`, use :meth:`SSLContext.wrap_bio`. @@ -2135,6 +2187,9 @@ to speed up repeated connections from the same clients. The method returns an instance of :attr:`SSLContext.sslsocket_class` instead of hard-coded :class:`SSLSocket`. + .. versionchanged:: next + *ech_config* argument was added. + .. attribute:: SSLContext.sslsocket_class The return type of :meth:`SSLContext.wrap_socket`, defaults to @@ -2145,16 +2200,17 @@ to speed up repeated connections from the same clients. .. versionadded:: 3.7 .. method:: SSLContext.wrap_bio(incoming, outgoing, server_side=False, \ - server_hostname=None, session=None) + server_hostname=None, session=None, \ + ech_config=None) Wrap the BIO objects *incoming* and *outgoing* and return an instance of :attr:`SSLContext.sslobject_class` (default :class:`SSLObject`). The SSL routines will read input data from the incoming BIO and write data to the outgoing BIO. - The *server_side*, *server_hostname* and *session* parameters have the - same meaning as in :meth:`SSLContext.wrap_socket`, and are validated in - the same way: in particular a :exc:`ValueError` is raised when + The *server_side*, *server_hostname*, *session* and *ech_config* + parameters have the same meaning as in :meth:`SSLContext.wrap_socket`, + and are validated in the same way: in particular a :exc:`ValueError` is raised when :attr:`~SSLContext.check_hostname` is enabled but no *server_hostname* is given, since there would be no name to match the peer's certificate against. @@ -2173,6 +2229,9 @@ to speed up repeated connections from the same clients. *server_hostname* was accepted, and verified the certificate chain but never the peer's identity. + .. versionchanged:: next + *ech_config* argument was added. + .. attribute:: SSLContext.sslobject_class The return type of :meth:`SSLContext.wrap_bio`, defaults to @@ -2891,6 +2950,7 @@ provided. - :meth:`~SSLSocket.verify_client_post_handshake` - :meth:`~SSLSocket.unwrap` - :meth:`~SSLSocket.get_channel_binding` + - :meth:`~SSLSocket.set_ech_config` - :meth:`~SSLSocket.get_ech_retry_config` - :meth:`~SSLSocket.get_ech_status` - :meth:`~SSLSocket.version` @@ -3132,51 +3192,51 @@ ClientHello. ECH requires OpenSSL 4.0 or later with ECH enabled; check Servers can load a PEM file containing their ECH private key and configuration with :meth:`SSLContext.load_ech_keys`. Clients use the corresponding public -ECHConfigList with :meth:`SSLContext.set_ech_config`. +ECHConfigList, published in DNS for each host, as the *ech_config* argument of +:meth:`SSLContext.wrap_socket` or :meth:`SSLContext.wrap_bio`, or for every +connection from a context with :meth:`SSLContext.set_ech_config`. -Configuration values may be discovered through DNS or through the server's -retry configuration. The following example accepts a binary-encoded -ECHConfigList discovered through DNS. If none is supplied, it enables ECH -GREASE to request a retry configuration from the server:: +A server that has rotated its ECH keys rejects the connection and provides a +retry configuration, which the handshake raises as +:exc:`SSLECHRejectedError`. The following example accepts a binary-encoded +ECHConfigList discovered through DNS and retries once with the server's retry +configuration. If no configuration is supplied, it enables ECH GREASE, which a +server may answer with a retry configuration for a later connection:: import socket import ssl - def connect_with_tls_ech(hostname, ech_config=None, use_retry_config=True): + def connect_with_tls_ech(hostname, ech_config=None, retry=True): context = ssl.create_default_context() - if ech_config is not None: - context.set_ech_config(ech_config) - else: + if ech_config is None: context.options |= ssl.OP_ECH_GREASE sock = socket.create_connection((hostname, 443)) try: - ssock = context.wrap_socket(sock, server_hostname=hostname) - except BaseException: - sock.close() + return context.wrap_socket(sock, server_hostname=hostname, + ech_config=ech_config) + except ssl.SSLECHRejectedError as exc: + if retry and exc.retry_config: + return connect_with_tls_ech(hostname, exc.retry_config, + retry=False) raise - if (use_retry_config - and ssock.get_ech_status() - == ssl.ECHStatus.ECH_STATUS_GREASE_ECH): - try: - retry_config = ssock.get_ech_retry_config() - finally: - ssock.close() - return connect_with_tls_ech(hostname, retry_config, False) - return ssock hostname = "www.python.org" ech_config = None # Replace with an ECHConfigList discovered through DNS. with connect_with_tls_ech(hostname, ech_config) as ssock: print(ssock.get_ech_status()) + if ssock.get_ech_status() == ssl.ECHStatus.ECH_STATUS_GREASE_ECH: + print("retry configuration:", ssock.get_ech_retry_config()) The following classes, methods, and attributes will be useful for using ECH: - :class:`ECHStatus` + - :exc:`SSLECHRejectedError` - :meth:`SSLContext.load_ech_keys` - :meth:`SSLContext.set_ech_config` - :meth:`SSLContext.set_outer_alpn_protocols` + - :meth:`SSLSocket.set_ech_config` - :meth:`SSLSocket.get_ech_status` - :meth:`SSLSocket.get_ech_retry_config` diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 44c1e3088259b7a..a07d0d85556153b 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -689,12 +689,15 @@ ssl --- * Added support for :ref:`Encrypted Client Hello ` - (ECH) with OpenSSL 4.0 or later. Clients can use - :meth:`~ssl.SSLContext.set_ech_config` to supply an ECHConfigList discovered - through DNS, for example using a third-party library such as ``dnspython`` - to query HTTPS or SVCB records. Clients can also enable - :data:`~ssl.OP_ECH_GREASE` and retrieve a server's retry configuration - with :meth:`~ssl.SSLSocket.get_ech_retry_config` for a subsequent connection. + (ECH) with OpenSSL 4.0 or later. Clients can supply an ECHConfigList + discovered through DNS, for example using a third-party library such as + ``dnspython`` to query HTTPS or SVCB records, as the *ech_config* argument + of :meth:`~ssl.SSLContext.wrap_socket` and :meth:`~ssl.SSLContext.wrap_bio` + or with :meth:`~ssl.SSLContext.set_ech_config`. A rejected ECH attempt + raises :exc:`~ssl.SSLECHRejectedError`, which carries the server's retry + configuration. Clients can also enable :data:`~ssl.OP_ECH_GREASE` and + retrieve a server's retry configuration with + :meth:`~ssl.SSLSocket.get_ech_retry_config` for a subsequent connection. Servers can use :meth:`~ssl.SSLContext.load_ech_keys` to load ECH private keys and their configurations from PEM files. :data:`~ssl.HAS_ECH` indicates whether ECH is available, and diff --git a/Lib/ssl.py b/Lib/ssl.py index cebb4ab6703dbb1..df2eece13f2b138 100644 --- a/Lib/ssl.py +++ b/Lib/ssl.py @@ -107,7 +107,8 @@ from _ssl import _SSLContext, MemoryBIO, SSLSession from _ssl import ( SSLError, SSLZeroReturnError, SSLWantReadError, SSLWantWriteError, - SSLSyscallError, SSLEOFError, SSLCertVerificationError + SSLSyscallError, SSLEOFError, SSLCertVerificationError, + SSLECHRejectedError ) from _ssl import txt2obj as _txt2obj, nid2obj as _nid2obj from _ssl import RAND_status, RAND_add, RAND_bytes @@ -381,7 +382,8 @@ def _ipaddress_match(cert_ipaddress, host_ip): return ip == host_ip -def _check_sslobject_params(server_side, context=None, server_hostname=None, session=None): +def _check_sslobject_params(server_side, context=None, server_hostname=None, + session=None, ech_config=None): """Raises a ValueError if SSLObject._create() parameters aren't valid. """ if server_side: @@ -391,6 +393,11 @@ def _check_sslobject_params(server_side, context=None, server_hostname=None, ses if session is not None: raise ValueError("session can only be specified in " "client mode") + if ech_config is not None: + raise ValueError("ech_config can only be specified in " + "client mode") + if ech_config is not None and not HAS_ECH: + raise NotImplementedError("ECH is not supported by the OpenSSL library") if context.check_hostname and not server_hostname: raise ValueError("check_hostname requires server_hostname") @@ -471,7 +478,7 @@ def _encode_hostname(self, hostname): def wrap_socket(self, sock, server_side=False, do_handshake_on_connect=True, suppress_ragged_eofs=True, - server_hostname=None, session=None): + server_hostname=None, session=None, ech_config=None): # SSLSocket class handles server_hostname encoding before it calls # ctx._wrap_socket() return self.sslsocket_class._create( @@ -481,17 +488,18 @@ def wrap_socket(self, sock, server_side=False, suppress_ragged_eofs=suppress_ragged_eofs, server_hostname=server_hostname, context=self, - session=session + session=session, + ech_config=ech_config, ) def wrap_bio(self, incoming, outgoing, server_side=False, - server_hostname=None, session=None): + server_hostname=None, session=None, ech_config=None): # Need to encode server_hostname here because _wrap_bio() can only # handle ASCII str. return self.sslobject_class._create( incoming, outgoing, server_side=server_side, server_hostname=self._encode_hostname(server_hostname), - session=session, context=self, + session=session, context=self, ech_config=ech_config, ) def set_npn_protocols(self, npn_protocols): @@ -830,15 +838,19 @@ def __init__(self, *args, **kwargs): @classmethod def _create(cls, incoming, outgoing, server_side=False, - server_hostname=None, session=None, context=None): + server_hostname=None, session=None, context=None, + ech_config=None): _check_sslobject_params(server_side=server_side, context=context, - server_hostname=server_hostname, session=session) + server_hostname=server_hostname, + session=session, ech_config=ech_config) self = cls.__new__(cls) sslobj = context._wrap_bio( incoming, outgoing, server_side=server_side, server_hostname=server_hostname, owner=self, session=session ) + if ech_config is not None: + sslobj.set_ech_config(ech_config) self._sslobj = sslobj return self @@ -1007,6 +1019,14 @@ def verify_client_post_handshake(self): return self._sslobj.verify_client_post_handshake() if HAS_ECH: + def set_ech_config(self, ech_config): + """Set the ECH configuration for this connection. + + This replaces any configuration inherited from the context + and must be called before the handshake starts. + """ + self._sslobj.set_ech_config(ech_config) + def get_ech_status(self): """Return the status of Encrypted Client Hello processing.""" return ECHStatus(self._sslobj.get_ech_status()[0]) @@ -1041,11 +1061,12 @@ def __init__(self, *args, **kwargs): @classmethod def _create(cls, sock, server_side=False, do_handshake_on_connect=True, suppress_ragged_eofs=True, server_hostname=None, - context=None, session=None): + context=None, session=None, ech_config=None): if sock.getsockopt(SOL_SOCKET, SO_TYPE) != SOCK_STREAM: raise NotImplementedError("only stream sockets are supported") _check_sslobject_params(server_side=server_side, context=context, - server_hostname=server_hostname, session=session) + server_hostname=server_hostname, + session=session, ech_config=ech_config) sock_timeout = sock.gettimeout() kwargs = dict( @@ -1059,6 +1080,7 @@ def _create(cls, sock, server_side=False, do_handshake_on_connect=True, try: self._context = context self._session = session + self._ech_config = ech_config self._closed = False self._sslobj = None self.server_side = server_side @@ -1117,6 +1139,8 @@ def _create(cls, sock, server_side=False, do_handshake_on_connect=True, self, server_side, self.server_hostname, owner=self, session=self._session, ) + if self._ech_config is not None: + self._sslobj.set_ech_config(self._ech_config) if do_handshake_on_connect: timeout = self.gettimeout() if timeout == 0.0: @@ -1450,6 +1474,12 @@ def verify_client_post_handshake(self): raise ValueError("No SSL wrapper around " + str(self)) if HAS_ECH: + @_sslcopydoc + def set_ech_config(self, ech_config): + self._ech_config = ech_config + if self._sslobj is not None: + self._sslobj.set_ech_config(ech_config) + @_sslcopydoc def get_ech_status(self): if self._sslobj is not None: @@ -1487,6 +1517,8 @@ def _real_connect(self, addr, connect_ex): owner=self, session=self._session ) try: + if self._ech_config is not None: + self._sslobj.set_ech_config(self._ech_config) if connect_ex: rc = super().connect_ex(addr) else: diff --git a/Lib/test/certdata/ech-localhost.pem b/Lib/test/certdata/ech-localhost.pem new file mode 100644 index 000000000000000..38498a6d47aa259 --- /dev/null +++ b/Lib/test/certdata/ech-localhost.pem @@ -0,0 +1,7 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VuBCIEIOBT5ODzi8lfV8HJImiRzoJnJ+//Sp5tNYcRYZcFgIBB +-----END PRIVATE KEY----- +-----BEGIN ECHCONFIG----- +ADz+DQA4zAAgACBsYAicNGwCXdscjN3LfV0mNOuybXo0BN6sF8jEJ9hbUQAEAAEA +AQAJbG9jYWxob3N0AAA= +-----END ECHCONFIG----- diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index 0c2344cd71b2742..b5b457bf5f83c24 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -2078,9 +2078,11 @@ def test_availability(self): with self.subTest(module=module): self.assertEqual(bool(names), ssl.HAS_ECH) + self.assertTrue(issubclass(ssl.SSLECHRejectedError, ssl.SSLError)) context_methods = ('set_ech_config', 'load_ech_keys', '_set_outer_alpn_protocols') - socket_methods = ('get_ech_status', 'get_ech_retry_config') + socket_methods = ('set_ech_config', 'get_ech_status', + 'get_ech_retry_config') for owner, names in ( (_ssl._SSLContext, context_methods), (ssl.SSLContext, (*context_methods, 'set_outer_alpn_protocols')), @@ -2131,14 +2133,152 @@ def test_mixed_ech_config_versions(self): config = len(entries).to_bytes(2, 'big') + entries self.assertIsNone(ctx.set_ech_config(config)) - def read_ech_key_and_config(self): + def read_ech_key_and_config(self, filename='ech.pem'): # Generated with: # openssl ech -public_name public.example -suite 0x20,1,1 - with open(data_file('ech.pem'), 'rb') as f: + # ech-localhost.pem uses -public_name localhost, so that the test + # certificate also authenticates the outer ClientHello. + with open(data_file(filename), 'rb') as f: key, config = f.read().split(b'-----BEGIN ECHCONFIG-----') config = base64.b64decode(config.split(b'-----END ECHCONFIG-----')[0]) return key, config + def stale_config(self, config): + # A config whose HPKE public key the server does not have. + stale = bytearray(config) + stale[11] ^= 1 + return bytes(stale) + + def handshake_bio_pair(self, client, server, c_in, c_out, s_in, s_out): + for _ in range(5): + with contextlib.suppress(ssl.SSLWantReadError): + client.do_handshake() + s_in.write(c_out.read()) + with contextlib.suppress(ssl.SSLWantReadError): + server.do_handshake() + c_in.write(s_out.read()) + + def echo_server_thread(self, server_ctx, sock): + def serve(): + try: + with server_ctx.wrap_socket(sock, server_side=True) as ssock: + ssock.sendall(ssock.recv(1024)) + except OSError: + pass + finally: + sock.close() + thread = threading.Thread(target=serve) + thread.start() + self.addCleanup(thread.join) + + @requires_ech_x25519 + def test_connection_ech_config(self): + key, config = self.read_ech_key_and_config('ech-localhost.pem') + client_ctx, server_ctx, hostname = testing_context() + server_ctx.load_ech_keys(data_file('ech-localhost.pem')) + # The context has a configuration the server cannot decrypt; the + # connection's configuration must replace it. + client_ctx.set_ech_config(self.stale_config(config)) + for ctx_config in (False, True): + with self.subTest(context_configured=ctx_config): + if not ctx_config: + client_ctx, _, hostname = testing_context() + c_in, c_out = ssl.MemoryBIO(), ssl.MemoryBIO() + s_in, s_out = ssl.MemoryBIO(), ssl.MemoryBIO() + client = client_ctx.wrap_bio(c_in, c_out, server_hostname=hostname, + ech_config=config) + server = server_ctx.wrap_bio(s_in, s_out, server_side=True) + self.handshake_bio_pair(client, server, c_in, c_out, s_in, s_out) + for obj in (client, server): + self.assertEqual(obj.get_ech_status(), + ssl.ECHStatus.ECH_STATUS_SUCCESS) + with self.assertRaisesRegex(ValueError, 'after handshake'): + client.set_ech_config(config) + + with self.assertRaisesRegex(ValueError, 'client mode'): + server_ctx.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(), + server_side=True, ech_config=config) + with socket.socket() as sock: + with self.assertRaisesRegex(ValueError, 'client mode'): + server_ctx.wrap_socket(sock, server_side=True, + ech_config=config) + obj = client_ctx.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO(), + server_hostname=hostname) + self.assertRaises(TypeError, obj.set_ech_config, 'config') + self.assertRaises(ssl.SSLError, obj.set_ech_config, b'invalid') + self.assertIsNone(obj.set_ech_config(bytearray(config))) + + # An unconnected socket applies the configuration when connecting. + with client_ctx.wrap_socket(socket.socket(), server_hostname=hostname, + ech_config=config) as sock: + self.assertIs(sock._ech_config, config) + sock.set_ech_config(bytearray(config)) + self.assertEqual(sock._ech_config, config) + + @requires_ech_x25519 + def test_rejected_ech_retry_config(self): + key, config = self.read_ech_key_and_config('ech-localhost.pem') + stale = self.stale_config(config) + client_ctx, server_ctx, hostname = testing_context() + server_ctx.load_ech_keys(data_file('ech-localhost.pem')) + + c_in, c_out = ssl.MemoryBIO(), ssl.MemoryBIO() + s_in, s_out = ssl.MemoryBIO(), ssl.MemoryBIO() + client = client_ctx.wrap_bio(c_in, c_out, server_hostname=hostname, + ech_config=stale) + server = server_ctx.wrap_bio(s_in, s_out, server_side=True) + with self.assertRaises(ssl.SSLECHRejectedError) as cm: + self.handshake_bio_pair(client, server, c_in, c_out, s_in, s_out) + self.assertEqual(cm.exception.reason, 'ECH_REQUIRED') + self.assertEqual(cm.exception.retry_config, config) + self.assertEqual(client.get_ech_status(), + ssl.ECHStatus.ECH_STATUS_FAILED_ECH) + self.assertEqual(client.get_ech_retry_config(), config) + + # The retry configuration from the exception must be usable for a + # new connection, even though wrap_socket() closed the first one. + sock, peer = socket.socketpair() + sock.settimeout(support.SHORT_TIMEOUT) + self.echo_server_thread(server_ctx, peer) + with self.assertRaises(ssl.SSLECHRejectedError) as cm: + client_ctx.wrap_socket(sock, server_hostname=hostname, + ech_config=stale) + self.assertEqual(cm.exception.retry_config, config) + sock, peer = socket.socketpair() + sock.settimeout(support.SHORT_TIMEOUT) + self.echo_server_thread(server_ctx, peer) + with client_ctx.wrap_socket(sock, server_hostname=hostname, + ech_config=cm.exception.retry_config) as ssock: + self.assertEqual(ssock.get_ech_status(), + ssl.ECHStatus.ECH_STATUS_SUCCESS) + ssock.sendall(b'hello') + self.assertEqual(ssock.recv(1024), b'hello') + + @requires_ech_x25519 + def test_outer_alpn_with_connection_ech_config(self): + key, config = self.read_ech_key_and_config('ech-localhost.pem') + client_ctx, server_ctx, hostname = testing_context() + server_ctx.load_ech_keys(data_file('ech-localhost.pem')) + client_ctx.set_alpn_protocols(['h2']) + client_ctx.set_outer_alpn_protocols(['http/1.1']) + server_ctx.set_alpn_protocols(['h2', 'http/1.1']) + for current, expected in ((config, 'h2'), + (self.stale_config(config), 'http/1.1')): + with self.subTest(expected=expected): + c_in, c_out = ssl.MemoryBIO(), ssl.MemoryBIO() + s_in, s_out = ssl.MemoryBIO(), ssl.MemoryBIO() + client = client_ctx.wrap_bio(c_in, c_out, server_hostname=hostname, + ech_config=current) + server = server_ctx.wrap_bio(s_in, s_out, server_side=True) + # When the server rejects ECH it only ever saw the outer + # ClientHello, so it selects from the outer ALPN. OpenSSL's + # client then aborts because that choice is not in its + # inner list, so only the server's view is checked here. + with contextlib.suppress(ssl.SSLError): + self.handshake_bio_pair(client, server, + c_in, c_out, s_in, s_out) + self.assertEqual(server.selected_alpn_protocol(), expected) + @requires_ech_x25519 def test_load_ech_keys(self): client_ctx, ctx, hostname = testing_context() @@ -2306,14 +2446,11 @@ def make_rejected_client(self): s_in, s_out = ssl.MemoryBIO(), ssl.MemoryBIO() client = client_ctx.wrap_bio(c_in, c_out, server_hostname=hostname) server = server_ctx.wrap_bio(s_in, s_out, server_side=True) - with self.assertRaisesRegex(ssl.SSLError, 'ECH_REQUIRED'): - for _ in range(5): - with contextlib.suppress(ssl.SSLWantReadError): - client.do_handshake() - s_in.write(c_out.read()) - with contextlib.suppress(ssl.SSLWantReadError): - server.do_handshake() - c_in.write(s_out.read()) + with self.assertRaisesRegex(ssl.SSLECHRejectedError, + 'ECH_REQUIRED') as cm: + self.handshake_bio_pair(client, server, c_in, c_out, s_in, s_out) + # A server without ECH sends no retry configuration. + self.assertEqual(cm.exception.retry_config, b'') return client @requires_ech_x25519 diff --git a/Modules/_ssl.c b/Modules/_ssl.c index e1c1416c1985fc4..7c1b2021078b14b 100644 --- a/Modules/_ssl.c +++ b/Modules/_ssl.c @@ -317,6 +317,10 @@ typedef struct { SSL_CTX *ctx; unsigned char *alpn_protocols; unsigned int alpn_protocols_len; +#ifdef HAVE_OPENSSL_ECH + unsigned char *outer_alpn_protocols; + unsigned int outer_alpn_protocols_len; +#endif PyObject *set_sni_cb; int check_hostname; int protocol; @@ -456,6 +460,9 @@ PyDoc_STRVAR(SSLWantWriteError_doc, PyDoc_STRVAR(SSLSyscallError_doc, "System error when attempting SSL operation."); +PyDoc_STRVAR(SSLECHRejectedError_doc, +"SSL/TLS handshake failed because the server rejected Encrypted Client Hello."); + PyDoc_STRVAR(SSLEOFError_doc, "SSL/TLS connection terminated abruptly."); @@ -490,6 +497,7 @@ fill_and_set_sslerror(_sslmodulestate *state, { PyObject *err_value = NULL, *reason_obj = NULL, *lib_obj = NULL; PyObject *verify_obj = NULL, *verify_code_obj = NULL; + PyObject *retry_obj = NULL; PyObject *init_value, *msg, *key; PyUnicodeWriter *writer = NULL; @@ -634,11 +642,32 @@ fill_and_set_sslerror(_sslmodulestate *state, goto fail; } +#ifdef HAVE_OPENSSL_ECH + if ((sslsock != NULL) && (type == state->PySSLECHRejectedErrorObject)) { + unsigned char *config = NULL; + size_t config_len = 0; + + /* OpenSSL only releases retry configurations once the outer + * handshake has authenticated the server; otherwise report none. */ + if (SSL_ech_get1_retry_config(sslsock->ssl, &config, &config_len) != 1) { + config = NULL; + config_len = 0; + } + retry_obj = PyBytes_FromStringAndSize((const char *)config, config_len); + OPENSSL_free(config); + if (retry_obj == NULL) + goto fail; + if (PyObject_SetAttr(err_value, state->str_retry_config, retry_obj)) + goto fail; + } +#endif + PyErr_SetObject(type, err_value); fail: Py_XDECREF(err_value); Py_XDECREF(verify_code_obj); Py_XDECREF(verify_obj); + Py_XDECREF(retry_obj); PyUnicodeWriter_Discard(writer); } @@ -743,6 +772,12 @@ PySSL_SetError(PySSLSocket *sslsock, _PySSLError err, PyObject *exc, ERR_GET_REASON(e) == SSL_R_CERTIFICATE_VERIFY_FAILED) { type = state->PySSLCertVerificationErrorObject; } +#ifdef HAVE_OPENSSL_ECH + if (ERR_GET_LIB(e) == ERR_LIB_SSL && + ERR_GET_REASON(e) == SSL_R_ECH_REQUIRED) { + type = state->PySSLECHRejectedErrorObject; + } +#endif #if defined(SSL_R_UNEXPECTED_EOF_WHILE_READING) /* OpenSSL 3.0 changed transport EOF from SSL_ERROR_SYSCALL with * zero return value to SSL_ERROR_SSL with a special error code. */ @@ -2183,6 +2218,59 @@ _ssl__SSLSocket_get_ech_retry_config_impl(PySSLSocket *self) return result; } +static OSSL_ECHSTORE *ech_store_from_config(_sslmodulestate *state, + Py_buffer *ech_config); + +/*[clinic input] +@critical_section +_ssl._SSLSocket.set_ech_config + + ech_config: Py_buffer + / + +Set the ECH configuration for this connection. + +This replaces any configuration inherited from the context and +must be called before the handshake starts. +[clinic start generated code]*/ + +static PyObject * +_ssl__SSLSocket_set_ech_config_impl(PySSLSocket *self, Py_buffer *ech_config) +/*[clinic end generated code: output=c9d2639cc222eae9 input=c331e306213d39d9]*/ +{ + if (self->socket_type != PY_SSL_CLIENT) { + PyErr_SetString(PyExc_ValueError, + "Cannot set ECH configuration for server-side SSLSocket."); + return NULL; + } + if (!SSL_in_before(self->ssl)) { + PyErr_SetString(PyExc_ValueError, + "Cannot set ECH configuration after handshake."); + return NULL; + } + _sslmodulestate *state = get_state_sock(self); + OSSL_ECHSTORE *store = ech_store_from_config(state, ech_config); + if (store == NULL) { + return NULL; + } + int result = SSL_set1_echstore(self->ssl, store); + OSSL_ECHSTORE_free(store); + if (result != 1) { + _setSSLError(state, NULL, 0, __FILE__, __LINE__); + return NULL; + } + /* Restore the outer ALPN cleared when this socket was created without + * an ECH configuration. */ + PySSLContext *ctx = self->ctx; + if (ctx->outer_alpn_protocols != NULL + && SSL_ech_set1_outer_alpn_protos(self->ssl, ctx->outer_alpn_protocols, + ctx->outer_alpn_protocols_len) != 1) { + _setSSLError(state, NULL, 0, __FILE__, __LINE__); + return NULL; + } + Py_RETURN_NONE; +} + /*[clinic input] @critical_section _ssl._SSLSocket.set_outer_server_name @@ -3443,6 +3531,7 @@ static PyMethodDef PySSLMethods[] = { _SSL__SSLSOCKET_SHUTDOWN_METHODDEF _SSL__SSLSOCKET_VERIFY_CLIENT_POST_HANDSHAKE_METHODDEF _SSL__SSLSOCKET_GET_ECH_STATUS_METHODDEF + _SSL__SSLSOCKET_SET_ECH_CONFIG_METHODDEF _SSL__SSLSOCKET_SET_OUTER_SERVER_NAME_METHODDEF _SSL__SSLSOCKET_GET_ECH_RETRY_CONFIG_METHODDEF _SSL__SSLSOCKET_GET_UNVERIFIED_CHAIN_METHODDEF @@ -3604,6 +3693,10 @@ _ssl__SSLContext_impl(PyTypeObject *type, int proto_version) self->keylog_filename = NULL; self->keylog_bio = NULL; self->alpn_protocols = NULL; +#ifdef HAVE_OPENSSL_ECH + self->outer_alpn_protocols = NULL; + self->outer_alpn_protocols_len = 0; +#endif self->set_sni_cb = NULL; self->state = get_ssl_state(module); #ifndef OPENSSL_NO_PSK @@ -3756,6 +3849,9 @@ context_dealloc(PyObject *op) SSL_CTX_set_tlsext_servername_callback(self->ctx, NULL); SSL_CTX_free(self->ctx); PyMem_FREE(self->alpn_protocols); +#ifdef HAVE_OPENSSL_ECH + PyMem_Free(self->outer_alpn_protocols); +#endif tp->tp_free(self); Py_DECREF(tp); } @@ -4068,9 +4164,20 @@ _ssl__SSLContext__set_outer_alpn_protocols_impl(PySSLContext *self, "protocols longer than %u bytes", UINT_MAX); return NULL; } + PyMem_Free(self->outer_alpn_protocols); + self->outer_alpn_protocols = NULL; + self->outer_alpn_protocols_len = 0; + if (protos->len) { + self->outer_alpn_protocols = PyMem_Malloc(protos->len); + if (!self->outer_alpn_protocols) { + return PyErr_NoMemory(); + } + memcpy(self->outer_alpn_protocols, protos->buf, protos->len); + self->outer_alpn_protocols_len = (unsigned int)protos->len; + } if (SSL_CTX_ech_set1_outer_alpn_protos(self->ctx, - protos->len ? protos->buf : NULL, - protos->len) != 1) { + self->outer_alpn_protocols, + self->outer_alpn_protocols_len) != 1) { _setSSLError(get_state_ctx(self), NULL, 0, __FILE__, __LINE__); return NULL; } @@ -5213,23 +5320,12 @@ _ssl__SSLContext_set_default_verify_paths_impl(PySSLContext *self) } #ifdef HAVE_OPENSSL_ECH -/*[clinic input] -@critical_section -_ssl._SSLContext.set_ech_config - - ech_config: Py_buffer - / - -Set the ECH configuration on the SSL context. - -ech_config must be a bytes-like object containing the raw ECH -configuration. -[clinic start generated code]*/ - -static PyObject * -_ssl__SSLContext_set_ech_config_impl(PySSLContext *self, - Py_buffer *ech_config) -/*[clinic end generated code: output=cea53188b338cf80 input=3943e5aff04a583e]*/ +/* Parse an ECHConfigList into a new store, reading each entry separately so + * entries this OpenSSL build cannot use (unsupported HPKE suites, mandatory + * extensions) are skipped rather than rejecting the whole list. Return NULL + * with a Python exception set. */ +static OSSL_ECHSTORE * +ech_store_from_config(_sslmodulestate *state, Py_buffer *ech_config) { if (ech_config->len > INT_MAX) { PyErr_Format(PyExc_OverflowError, @@ -5250,7 +5346,6 @@ _ssl__SSLContext_set_ech_config_impl(PySSLContext *self, if (config_len >= 2 && ((config[0] << 8) | config[1]) == config_len - 2) { config += 2; config_len -= 2; - /* Read entries separately so unsupported ones do not reject the list. */ while (config_len >= 4) { int entry_len = 4 + ((config[2] << 8) | config[3]); if (entry_len > config_len) { @@ -5302,23 +5397,50 @@ _ssl__SSLContext_set_ech_config_impl(PySSLContext *self, ERR_raise(ERR_LIB_SSL, ERR_R_PASSED_INVALID_ARGUMENT); goto fail; } - PyMutex_Lock(&self->tstate_mutex); - int result = SSL_CTX_set1_echstore(self->ctx, store); - PyMutex_Unlock(&self->tstate_mutex); - if (result != 1) { - goto fail; - } - OSSL_ECHSTORE_free(store); BIO_free(config_bio); - Py_RETURN_NONE; + return store; fail: - _setSSLError(get_state_ctx(self), NULL, 0, __FILE__, __LINE__); + _setSSLError(state, NULL, 0, __FILE__, __LINE__); OSSL_ECHSTORE_free(store); BIO_free(config_bio); return NULL; } +/*[clinic input] +@critical_section +_ssl._SSLContext.set_ech_config + + ech_config: Py_buffer + / + +Set the ECH configuration on the SSL context. + +ech_config must be a bytes-like object containing the raw ECH +configuration. +[clinic start generated code]*/ + +static PyObject * +_ssl__SSLContext_set_ech_config_impl(PySSLContext *self, + Py_buffer *ech_config) +/*[clinic end generated code: output=cea53188b338cf80 input=3943e5aff04a583e]*/ +{ + OSSL_ECHSTORE *store = ech_store_from_config(get_state_ctx(self), + ech_config); + if (store == NULL) { + return NULL; + } + PyMutex_Lock(&self->tstate_mutex); + int result = SSL_CTX_set1_echstore(self->ctx, store); + PyMutex_Unlock(&self->tstate_mutex); + OSSL_ECHSTORE_free(store); + if (result != 1) { + _setSSLError(get_state_ctx(self), NULL, 0, __FILE__, __LINE__); + return NULL; + } + Py_RETURN_NONE; +} + static int ech_password_callback(char *buf, int size, int rwflag, void *userdata) { @@ -7226,6 +7348,13 @@ do { \ SSLEOFError_doc, state->PySSLErrorObject ); + + add_exception( + state->PySSLECHRejectedErrorObject, + "SSLECHRejectedError", + SSLECHRejectedError_doc, + state->PySSLErrorObject + ); #undef add_exception return 0; @@ -7782,6 +7911,10 @@ sslmodule_init_strings(PyObject *module) if (state->str_verify_code == NULL) { return -1; } + state->str_retry_config = PyUnicode_InternFromString("retry_config"); + if (state->str_retry_config == NULL) { + return -1; + } return 0; } @@ -7829,6 +7962,7 @@ sslmodule_traverse(PyObject *m, visitproc visit, void *arg) Py_VISIT(state->PySSLWantWriteErrorObject); Py_VISIT(state->PySSLSyscallErrorObject); Py_VISIT(state->PySSLEOFErrorObject); + Py_VISIT(state->PySSLECHRejectedErrorObject); Py_VISIT(state->err_codes_to_names); Py_VISIT(state->lib_codes_to_names); Py_VISIT(state->Sock_Type); @@ -7853,6 +7987,7 @@ sslmodule_clear(PyObject *m) Py_CLEAR(state->PySSLWantWriteErrorObject); Py_CLEAR(state->PySSLSyscallErrorObject); Py_CLEAR(state->PySSLEOFErrorObject); + Py_CLEAR(state->PySSLECHRejectedErrorObject); Py_CLEAR(state->err_codes_to_names); Py_CLEAR(state->lib_codes_to_names); Py_CLEAR(state->Sock_Type); @@ -7860,6 +7995,7 @@ sslmodule_clear(PyObject *m) Py_CLEAR(state->str_reason); Py_CLEAR(state->str_verify_code); Py_CLEAR(state->str_verify_message); + Py_CLEAR(state->str_retry_config); return 0; } diff --git a/Modules/_ssl.h b/Modules/_ssl.h index 22d93ddcc6d6ebb..f0a9ac09ed3cb7a 100644 --- a/Modules/_ssl.h +++ b/Modules/_ssl.h @@ -23,6 +23,7 @@ typedef struct { PyObject *PySSLWantWriteErrorObject; PyObject *PySSLSyscallErrorObject; PyObject *PySSLEOFErrorObject; + PyObject *PySSLECHRejectedErrorObject; /* Error mappings */ PyObject *err_codes_to_names; PyObject *lib_codes_to_names; @@ -33,6 +34,7 @@ typedef struct { PyObject *str_reason; PyObject *str_verify_code; PyObject *str_verify_message; + PyObject *str_retry_config; /* keylog lock */ PyThread_type_lock keylog_lock; } _sslmodulestate; diff --git a/Modules/clinic/_ssl.c.h b/Modules/clinic/_ssl.c.h index b6299b8e220c388..340088197c279c7 100644 --- a/Modules/clinic/_ssl.c.h +++ b/Modules/clinic/_ssl.c.h @@ -212,6 +212,47 @@ _ssl__SSLSocket_get_ech_retry_config(PyObject *self, PyObject *Py_UNUSED(ignored #if defined(HAVE_OPENSSL_ECH) +PyDoc_STRVAR(_ssl__SSLSocket_set_ech_config__doc__, +"set_ech_config($self, ech_config, /)\n" +"--\n" +"\n" +"Set the ECH configuration for this connection.\n" +"\n" +"This replaces any configuration inherited from the context and\n" +"must be called before the handshake starts."); + +#define _SSL__SSLSOCKET_SET_ECH_CONFIG_METHODDEF \ + {"set_ech_config", (PyCFunction)_ssl__SSLSocket_set_ech_config, METH_O, _ssl__SSLSocket_set_ech_config__doc__}, + +static PyObject * +_ssl__SSLSocket_set_ech_config_impl(PySSLSocket *self, Py_buffer *ech_config); + +static PyObject * +_ssl__SSLSocket_set_ech_config(PyObject *self, PyObject *arg) +{ + PyObject *return_value = NULL; + Py_buffer ech_config = {NULL, NULL}; + + if (PyObject_GetBuffer(arg, &ech_config, PyBUF_SIMPLE) != 0) { + goto exit; + } + Py_BEGIN_CRITICAL_SECTION(self); + return_value = _ssl__SSLSocket_set_ech_config_impl((PySSLSocket *)self, &ech_config); + Py_END_CRITICAL_SECTION(); + +exit: + /* Cleanup for ech_config */ + if (ech_config.obj) { + PyBuffer_Release(&ech_config); + } + + return return_value; +} + +#endif /* defined(HAVE_OPENSSL_ECH) */ + +#if defined(HAVE_OPENSSL_ECH) + PyDoc_STRVAR(_ssl__SSLSocket_set_outer_server_name__doc__, "set_outer_server_name($self, outer_server_name, /)\n" "--\n" @@ -3289,6 +3330,10 @@ _ssl_enum_crls(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObje #define _SSL__SSLSOCKET_GET_ECH_RETRY_CONFIG_METHODDEF #endif /* !defined(_SSL__SSLSOCKET_GET_ECH_RETRY_CONFIG_METHODDEF) */ +#ifndef _SSL__SSLSOCKET_SET_ECH_CONFIG_METHODDEF + #define _SSL__SSLSOCKET_SET_ECH_CONFIG_METHODDEF +#endif /* !defined(_SSL__SSLSOCKET_SET_ECH_CONFIG_METHODDEF) */ + #ifndef _SSL__SSLSOCKET_SET_OUTER_SERVER_NAME_METHODDEF #define _SSL__SSLSOCKET_SET_OUTER_SERVER_NAME_METHODDEF #endif /* !defined(_SSL__SSLSOCKET_SET_OUTER_SERVER_NAME_METHODDEF) */ @@ -3364,4 +3409,4 @@ _ssl_enum_crls(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObje #define _SSL_SSLSESSION_HAS_TICKET_GETSETDEF {"has_ticket", (getter)_ssl_SSLSession_has_ticket_get, (setter)NULL, _ssl_SSLSession_has_ticket__doc__}, -/*[clinic end generated code: output=3078af6ab6373911 input=a9049054013a1b77]*/ +/*[clinic end generated code: output=c0d1443b798ac9a1 input=a9049054013a1b77]*/ From 3874a27b5a25a7ccdace04a7ed868fab111af484 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 22 Oct 2026 19:27:26 -0400 Subject: [PATCH 09/22] Support password callbacks for ECH keys. --- Doc/library/ssl.rst | 10 ++++-- Lib/test/certdata/ech-encrypted.pem | 10 ++++++ Lib/test/test_ssl.py | 17 +++++++++ Modules/_ssl.c | 56 ++++++++++++++++++----------- Modules/clinic/_ssl.c.h | 39 ++++++++++++-------- 5 files changed, 94 insertions(+), 38 deletions(-) create mode 100644 Lib/test/certdata/ech-encrypted.pem diff --git a/Doc/library/ssl.rst b/Doc/library/ssl.rst index 1f1ebc1020b5d9d..0ec7f622fea6039 100644 --- a/Doc/library/ssl.rst +++ b/Doc/library/ssl.rst @@ -1749,13 +1749,17 @@ to speed up repeated connections from the same clients. .. versionadded:: 3.4 -.. method:: SSLContext.load_ech_keys(keyfile, *, for_retry=True) +.. method:: SSLContext.load_ech_keys(keyfile, password=None, *, for_retry=True) Load a server's Encrypted Client Hello (ECH) private key and configuration. - The *keyfile* must be the path to a PEM file containing an unencrypted - private key followed by its matching ECHConfigList. Load the server's TLS + The *keyfile* must be the path to a PEM file containing a private key + followed by its matching ECHConfigList. Load the server's TLS certificate and private key separately with :meth:`load_cert_chain`. + The *password* argument is used to decrypt encrypted private keys. It accepts + a string, bytes, bytearray, or a callable returning one of those types, as in + :meth:`load_cert_chain`. Passwords are never requested interactively. + Each call adds keys and configurations to those already loaded into the context. If *for_retry* is true, the new configurations are included in ECH retry configurations sent to clients. diff --git a/Lib/test/certdata/ech-encrypted.pem b/Lib/test/certdata/ech-encrypted.pem new file mode 100644 index 000000000000000..0048723fd86ff35 --- /dev/null +++ b/Lib/test/certdata/ech-encrypted.pem @@ -0,0 +1,10 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIGjMF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBD3JUo9q4ZoU88DCeVZ +APneAgIIADAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQfSzQWTI/Dp1LfyXS +BeBEwwRABtyV0+/wbPT6Q1XyvwtD5xj+KaZ+u90mlvmt8StV4SPXixcNIZIoAeKp +Bnm8St0P4/0EiTwy2WDWwfaJUpUWuA== +-----END ENCRYPTED PRIVATE KEY----- +-----BEGIN ECHCONFIG----- +AEH+DQA9DwAgACDNp6BdYPMEKr4zkcIk8geuJRQ9rKaTZiPhEqHT8wpJXAAEAAEA +AQAOcHVibGljLmV4YW1wbGUAAA== +-----END ECHCONFIG----- diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index b5b457bf5f83c24..d45475cf188133b 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -2317,6 +2317,23 @@ def test_load_ech_keys(self): self.assertEqual(client.get_ech_status(), ssl.ECHStatus.ECH_STATUS_SUCCESS) + @requires_ech_x25519 + def test_load_ech_keys_password(self): + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + # ech.pem's key encrypted with: + # openssl pkey -in ech.pem -aes-256-cbc -passout pass:somepass + filename = data_file('ech-encrypted.pem') + for password in (KEY_PASSWORD, lambda: KEY_PASSWORD): + self.assertIsNone(ctx.load_ech_keys(filename, password=password)) + + def password_error(): + raise ValueError('password callback failed') + + with self.assertRaisesRegex(ValueError, 'password callback failed'): + ctx.load_ech_keys(filename, password=password_error) + self.assertIsNone(ctx.load_ech_keys(data_file('ech.pem'), + password=password_error)) + @requires_ech_x25519 def test_ech_handshake(self): key, config = self.read_ech_key_and_config() diff --git a/Modules/_ssl.c b/Modules/_ssl.c index 7c1b2021078b14b..c4a68acd9c0fc2f 100644 --- a/Modules/_ssl.c +++ b/Modules/_ssl.c @@ -5442,20 +5442,14 @@ _ssl__SSLContext_set_ech_config_impl(PySSLContext *self, } static int -ech_password_callback(char *buf, int size, int rwflag, void *userdata) -{ - /* ECH key files must be unencrypted. Never prompt for a password. */ - return -1; -} - -static int -load_ech_keys(SSL_CTX *ctx, BIO *bio, int for_retry) +load_ech_keys(SSL_CTX *ctx, BIO *bio, int for_retry, + _PySSLPasswordInfo *pw_info) { int result = 0; int old_keys, new_keys; OSSL_ECHSTORE *store = NULL; EVP_PKEY *key = PEM_read_bio_PrivateKey(bio, NULL, - ech_password_callback, NULL); + _password_callback, pw_info); if (key == NULL) { goto end; } @@ -5488,60 +5482,80 @@ load_ech_keys(SSL_CTX *ctx, BIO *bio, int for_retry) } /*[clinic input] -@critical_section _ssl._SSLContext.load_ech_keys keyfile: object + password: object = None * for_retry: bool = True Load server ECH keys and configurations from a PEM file. -The file must contain an unencrypted private key followed by its -ECHConfigList. +The file must contain a private key followed by its ECHConfigList. +The password argument may be a string, bytes, bytearray, or a +callable returning one of those types. It is only needed for +encrypted keys. If for_retry is true, advertise the configurations in ECH retry responses. [clinic start generated code]*/ static PyObject * _ssl__SSLContext_load_ech_keys_impl(PySSLContext *self, PyObject *keyfile, - int for_retry) -/*[clinic end generated code: output=8de2cc58ce2f8077 input=3e6617d346227acd]*/ + PyObject *password, int for_retry) +/*[clinic end generated code: output=73b9b4832b89f40d input=016a625a1505de4a]*/ { PyObject *keyfile_bytes; + PyObject *ret = NULL; + _PySSLPasswordInfo pw_info = { NULL, NULL, NULL, 0, 0 }; if (!PyUnicode_FSConverter(keyfile, &keyfile_bytes)) { return NULL; } + if (password != Py_None) { + if (PyCallable_Check(password)) { + pw_info.callable = password; + } else if (!_pwinfo_set(&pw_info, password, + "password should be a string or callable")) { + goto done; + } + } const char *filename = PyBytes_AS_STRING(keyfile_bytes); int result = 0; int open_failed = 0; ERR_clear_error(); - PySSL_BEGIN_ALLOW_THREADS(self) + PyMutex_Lock(&self->tstate_mutex); + PySSL_BEGIN_ALLOW_THREADS_S(pw_info.thread_state); errno = 0; BIO *bio = BIO_new_file(filename, "rb"); if (bio == NULL) { open_failed = 1; } else { - result = load_ech_keys(self->ctx, bio, for_retry); + result = load_ech_keys(self->ctx, bio, for_retry, &pw_info); BIO_free(bio); } - PySSL_END_ALLOW_THREADS(self) + PySSL_END_ALLOW_THREADS_S(pw_info.thread_state); + PyMutex_Unlock(&self->tstate_mutex); if (result != 1) { - if (open_failed && errno != 0) { + if (pw_info.error) { + /* The password callback has already set the exception. */ + ERR_clear_error(); + } + else if (open_failed && errno != 0) { PyErr_SetFromErrnoWithFilenameObject(PyExc_OSError, keyfile); ERR_clear_error(); } else { _setSSLError(get_state_ctx(self), NULL, 0, __FILE__, __LINE__); } - Py_DECREF(keyfile_bytes); - return NULL; + goto done; } + ret = Py_NewRef(Py_None); +done: + PyMem_Free(pw_info.password); Py_DECREF(keyfile_bytes); - Py_RETURN_NONE; + return ret; } #endif /* HAVE_OPENSSL_ECH */ diff --git a/Modules/clinic/_ssl.c.h b/Modules/clinic/_ssl.c.h index 340088197c279c7..0b5db5d1829cb1f 100644 --- a/Modules/clinic/_ssl.c.h +++ b/Modules/clinic/_ssl.c.h @@ -2250,13 +2250,15 @@ _ssl__SSLContext_set_ech_config(PyObject *self, PyObject *arg) #if defined(HAVE_OPENSSL_ECH) PyDoc_STRVAR(_ssl__SSLContext_load_ech_keys__doc__, -"load_ech_keys($self, /, keyfile, *, for_retry=True)\n" +"load_ech_keys($self, /, keyfile, password=None, *, for_retry=True)\n" "--\n" "\n" "Load server ECH keys and configurations from a PEM file.\n" "\n" -"The file must contain an unencrypted private key followed by its\n" -"ECHConfigList.\n" +"The file must contain a private key followed by its ECHConfigList.\n" +"The password argument may be a string, bytes, bytearray, or a\n" +"callable returning one of those types. It is only needed for\n" +"encrypted keys.\n" "If for_retry is true, advertise the configurations in ECH retry\n" "responses."); @@ -2265,7 +2267,7 @@ PyDoc_STRVAR(_ssl__SSLContext_load_ech_keys__doc__, static PyObject * _ssl__SSLContext_load_ech_keys_impl(PySSLContext *self, PyObject *keyfile, - int for_retry); + PyObject *password, int for_retry); static PyObject * _ssl__SSLContext_load_ech_keys(PyObject *self, PyObject *const *args, Py_ssize_t nargs, PyObject *kwnames) @@ -2273,7 +2275,7 @@ _ssl__SSLContext_load_ech_keys(PyObject *self, PyObject *const *args, Py_ssize_t PyObject *return_value = NULL; #if defined(Py_BUILD_CORE) && !defined(Py_BUILD_CORE_MODULE) - #define NUM_KEYWORDS 2 + #define NUM_KEYWORDS 3 static struct { PyGC_Head _this_is_not_used; PyObject_VAR_HEAD @@ -2282,7 +2284,7 @@ _ssl__SSLContext_load_ech_keys(PyObject *self, PyObject *const *args, Py_ssize_t } _kwtuple = { .ob_base = PyVarObject_HEAD_INIT(&PyTuple_Type, NUM_KEYWORDS) .ob_hash = -1, - .ob_item = { &_Py_ID(keyfile), &_Py_ID(for_retry), }, + .ob_item = { &_Py_ID(keyfile), &_Py_ID(password), &_Py_ID(for_retry), }, }; #undef NUM_KEYWORDS #define KWTUPLE (&_kwtuple.ob_base.ob_base) @@ -2291,35 +2293,44 @@ _ssl__SSLContext_load_ech_keys(PyObject *self, PyObject *const *args, Py_ssize_t # define KWTUPLE NULL #endif // !Py_BUILD_CORE - static const char * const _keywords[] = {"keyfile", "for_retry", NULL}; + static const char * const _keywords[] = {"keyfile", "password", "for_retry", NULL}; static _PyArg_Parser _parser = { .keywords = _keywords, .fname = "load_ech_keys", .kwtuple = KWTUPLE, }; #undef KWTUPLE - PyObject *argsbuf[2]; + PyObject *argsbuf[3]; Py_ssize_t noptargs = nargs + (kwnames ? PyTuple_GET_SIZE(kwnames) : 0) - 1; PyObject *keyfile; + PyObject *password = Py_None; int for_retry = 1; args = _PyArg_UnpackKeywords(args, nargs, NULL, kwnames, &_parser, - /*minpos*/ 1, /*maxpos*/ 1, /*minkw*/ 0, /*varpos*/ 0, argsbuf); + /*minpos*/ 1, /*maxpos*/ 2, /*minkw*/ 0, /*varpos*/ 0, argsbuf); if (!args) { goto exit; } keyfile = args[0]; + if (!noptargs) { + goto skip_optional_pos; + } + if (args[1]) { + password = args[1]; + if (!--noptargs) { + goto skip_optional_pos; + } + } +skip_optional_pos: if (!noptargs) { goto skip_optional_kwonly; } - for_retry = PyObject_IsTrue(args[1]); + for_retry = PyObject_IsTrue(args[2]); if (for_retry < 0) { goto exit; } skip_optional_kwonly: - Py_BEGIN_CRITICAL_SECTION(self); - return_value = _ssl__SSLContext_load_ech_keys_impl((PySSLContext *)self, keyfile, for_retry); - Py_END_CRITICAL_SECTION(); + return_value = _ssl__SSLContext_load_ech_keys_impl((PySSLContext *)self, keyfile, password, for_retry); exit: return return_value; @@ -3409,4 +3420,4 @@ _ssl_enum_crls(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObje #define _SSL_SSLSESSION_HAS_TICKET_GETSETDEF {"has_ticket", (getter)_ssl_SSLSession_has_ticket_get, (setter)NULL, _ssl_SSLSession_has_ticket__doc__}, -/*[clinic end generated code: output=c0d1443b798ac9a1 input=a9049054013a1b77]*/ +/*[clinic end generated code: output=25bebcfb2c509fd3 input=a9049054013a1b77]*/ From 25e48272fea5320b809a55508da3270d21c62ae2 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 22 Oct 2026 19:51:26 -0400 Subject: [PATCH 10/22] Adjust comments. --- Modules/_ssl.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Modules/_ssl.c b/Modules/_ssl.c index c4a68acd9c0fc2f..c3ea8392c807f45 100644 --- a/Modules/_ssl.c +++ b/Modules/_ssl.c @@ -5343,6 +5343,8 @@ ech_store_from_config(_sslmodulestate *state, Py_buffer *ech_config) } const unsigned char *config = ech_config->buf; int config_len = (int)ech_config->len; + // RFC 9849 says that clients have to skip unknown entries, so we have to do + // this extra parsing step ourselves. if (config_len >= 2 && ((config[0] << 8) | config[1]) == config_len - 2) { config += 2; config_len -= 2; @@ -5468,7 +5470,7 @@ load_ech_keys(SSL_CTX *ctx, BIO *bio, int for_retry, || OSSL_ECHSTORE_num_keys(store, &new_keys) != 1) { goto end; } - // OpenSSL may match a key already in the store. Require a new match. + // OpenSSL may match a key already in the store. We want to to require a new match. if (new_keys <= old_keys) { ERR_raise(ERR_LIB_SSL, SSL_R_PRIVATE_KEY_MISMATCH); goto end; From 10be52f3c92f5ed1bbe6de60b793330e1975eff6 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Thu, 22 Oct 2026 20:03:16 -0400 Subject: [PATCH 11/22] Clean up the docs. --- Doc/library/ssl.rst | 110 +++++++++++++++++++++--------------------- Doc/whatsnew/3.16.rst | 11 +---- 2 files changed, 57 insertions(+), 64 deletions(-) diff --git a/Doc/library/ssl.rst b/Doc/library/ssl.rst index 0ec7f622fea6039..d9f4c5b4412e2e3 100644 --- a/Doc/library/ssl.rst +++ b/Doc/library/ssl.rst @@ -332,8 +332,8 @@ Exceptions .. attribute:: retry_config - The server's ECH retry configuration as a binary-encoded ECHConfigList, - or empty :class:`bytes` if the server did not provide one or the server + The server's ECH retry configuration as an ECHConfigList, + or ``b""`` if the server did not provide one or the server could not be authenticated under the outer server name. .. versionadded:: next @@ -1443,14 +1443,8 @@ SSL sockets also have the following additional methods and attributes: .. method:: SSLSocket.get_ech_retry_config() - Return the server's ECH retry configuration as :class:`bytes`, or empty - bytes if no retry configuration is available. When the status returned by - :meth:`SSLSocket.get_ech_status` after the handshake is - :attr:`ECHStatus.ECH_STATUS_GREASE_ECH`, the configuration can be used - for a new connection using ECH. When the server rejects a real ECH - attempt, the handshake raises :exc:`SSLECHRejectedError`, which carries - the same configuration in its :attr:`~SSLECHRejectedError.retry_config` - attribute. + Return the server's ECH retry configuration as :class:`bytes`, or ``b""`` + if no retry configuration is available. Only available when :data:`HAS_ECH` is ``True``. @@ -1470,16 +1464,11 @@ SSL sockets also have the following additional methods and attributes: Set the Encrypted Client Hello (ECH) configuration for this connection only, replacing any configuration set on the context with :meth:`SSLContext.set_ech_config`. The *ech_config* parameter accepts the - same values. Unlike the context's configuration, which applies to every - connection made from it, a connection's configuration is only used by that - connection and can therefore be chosen per destination host, which is how - ECH configurations are published in DNS. + same values. The configuration must be set before the handshake starts, so this is mostly useful with ``do_handshake_on_connect=False`` or on a socket that - is not connected yet. :meth:`SSLContext.wrap_socket` and - :meth:`SSLContext.wrap_bio` accept the configuration as their *ech_config* - argument instead. + is not connected yet. Only available when :data:`HAS_ECH` is ``True``. @@ -1560,8 +1549,8 @@ SSL sockets also have the following additional methods and attributes: .. attribute:: SSLSocket.outer_server_hostname Hostname of the server name used in the outer ClientHello when Encrypted Client - Hello (ECH) is used: :class:`str` type, or ``None`` for server-side socket or - if no outer server name is available. + Hello (ECH) is used, or ``None`` for server-side socket or if no outer + server name is available. Only available when :data:`HAS_ECH` is ``True``. @@ -1753,25 +1742,16 @@ to speed up repeated connections from the same clients. Load a server's Encrypted Client Hello (ECH) private key and configuration. The *keyfile* must be the path to a PEM file containing a private key - followed by its matching ECHConfigList. Load the server's TLS - certificate and private key separately with :meth:`load_cert_chain`. + followed by its matching ECHConfigList. The *password* argument is used to decrypt encrypted private keys. It accepts a string, bytes, bytearray, or a callable returning one of those types, as in - :meth:`load_cert_chain`. Passwords are never requested interactively. + :meth:`load_cert_chain`. Each call adds keys and configurations to those already loaded into the context. If *for_retry* is true, the new configurations are included in ECH retry configurations sent to clients. - Configure ECH keys on the initial server context before accepting - connections. After successful ECH decryption, :attr:`sni_callback` - receives the inner server name. - - Invalid or missing keys or configurations, or a key that does not match - its configuration, raise :exc:`SSLError`. File access errors raise - :exc:`OSError`. - Only available when :data:`HAS_ECH` is ``True``. .. versionadded:: next @@ -1974,7 +1954,6 @@ to speed up repeated connections from the same clients. Sets an Encrypted Client Hello (ECH) configuration, which may be discovered from an HTTPS resource record in DNS or from :meth:`SSLSocket.get_ech_retry_config`. - Each call replaces the context's previous configuration. The configuration applies to every connection made from the context, and a server that cannot decrypt it rejects the connection with @@ -1984,7 +1963,7 @@ to speed up repeated connections from the same clients. pass it as the *ech_config* argument of :meth:`wrap_socket` or :meth:`wrap_bio` instead. - If the input value provided contains no suitable value (e.g. if it only contains + If the input value provided contains no suitable value (such as if it only contains ECH configuration versions that are not supported), an :class:`SSLError` will be raised. @@ -3207,31 +3186,52 @@ ECHConfigList discovered through DNS and retries once with the server's retry configuration. If no configuration is supplied, it enables ECH GREASE, which a server may answer with a retry configuration for a later connection:: - import socket - import ssl + import http.client + import socket + import ssl - def connect_with_tls_ech(hostname, ech_config=None, retry=True): - context = ssl.create_default_context() - if ech_config is None: - context.options |= ssl.OP_ECH_GREASE - sock = socket.create_connection((hostname, 443)) - try: - return context.wrap_socket(sock, server_hostname=hostname, - ech_config=ech_config) - except ssl.SSLECHRejectedError as exc: - if retry and exc.retry_config: - return connect_with_tls_ech(hostname, exc.retry_config, - retry=False) - raise - - hostname = "www.python.org" - ech_config = None # Replace with an ECHConfigList discovered through DNS. - - with connect_with_tls_ech(hostname, ech_config) as ssock: - print(ssock.get_ech_status()) - if ssock.get_ech_status() == ssl.ECHStatus.ECH_STATUS_GREASE_ECH: - print("retry configuration:", ssock.get_ech_retry_config()) + def connect_with_tls_ech(hostname, ech_config=None, use_retry_config=True): + context = ssl.create_default_context() + if ech_config is not None: + context.set_ech_config(ech_config) + else: + context.options |= ssl.OP_ECH_GREASE + sock = socket.create_connection((hostname, 443), timeout=10) + try: + ssock = context.wrap_socket(sock, server_hostname=hostname) + except BaseException: + sock.close() + raise + if (use_retry_config + and ssock.get_ech_status() + == ssl.ECHStatus.ECH_STATUS_GREASE_ECH): + try: + retry_config = ssock.get_ech_retry_config() + finally: + ssock.close() + return connect_with_tls_ech(hostname, retry_config, False) + return ssock + + hostname = "python.org" + ech_config = None # Replace with an ECHConfigList discovered through DNS. + + with connect_with_tls_ech(hostname, ech_config) as ssock: + print("ECH status:", ssock.get_ech_status().name) + request = ( + "GET / HTTP/1.1\r\n" + f"Host: {hostname}\r\n" + "Connection: close\r\n\r\n" + ) + ssock.sendall(request.encode("ascii")) + with http.client.HTTPResponse(ssock) as response: + response.begin() + print(f"HTTP {response.status} {response.reason}") + for name, value in response.getheaders(): + print(f"{name}: {value}") + print() + print(response.read().decode("utf-8", errors="replace")) + The following classes, methods, and attributes will be useful for using ECH: diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index a07d0d85556153b..d9d70e1ed8a0a5c 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -693,17 +693,10 @@ ssl discovered through DNS, for example using a third-party library such as ``dnspython`` to query HTTPS or SVCB records, as the *ech_config* argument of :meth:`~ssl.SSLContext.wrap_socket` and :meth:`~ssl.SSLContext.wrap_bio` - or with :meth:`~ssl.SSLContext.set_ech_config`. A rejected ECH attempt - raises :exc:`~ssl.SSLECHRejectedError`, which carries the server's retry - configuration. Clients can also enable :data:`~ssl.OP_ECH_GREASE` and - retrieve a server's retry configuration with - :meth:`~ssl.SSLSocket.get_ech_retry_config` for a subsequent connection. + or with :meth:`~ssl.SSLContext.set_ech_config`. Servers can use :meth:`~ssl.SSLContext.load_ech_keys` to load ECH private keys and their configurations from PEM files. - :data:`~ssl.HAS_ECH` indicates whether ECH is available, and - :meth:`~ssl.SSLSocket.get_ech_status` reports the status as an - :class:`~ssl.ECHStatus` member. - (Contributed by Iain R. Learmonth and Peter Bierma in :gh:`89730`.) + (Contributed by Peter Bierma and Iain R. Learmonth in :gh:`89730`.) * :const:`ssl.OPENSSL_VERSION_INFO` is now a named tuple. Added :const:`~ssl.OPENSSL_API_VERSION_INFO` which provides information From 7e3fc0510a81a00cb46f79df6ae99469de0a7ac2 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Fri, 23 Oct 2026 15:13:09 -0400 Subject: [PATCH 12/22] Update Modules/_ssl.c Co-authored-by: Gregory P. Smith <68491+gpshead@users.noreply.github.com> --- Modules/_ssl.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/_ssl.c b/Modules/_ssl.c index c3ea8392c807f45..16b76999b501edf 100644 --- a/Modules/_ssl.c +++ b/Modules/_ssl.c @@ -5470,7 +5470,7 @@ load_ech_keys(SSL_CTX *ctx, BIO *bio, int for_retry, || OSSL_ECHSTORE_num_keys(store, &new_keys) != 1) { goto end; } - // OpenSSL may match a key already in the store. We want to to require a new match. + // OpenSSL may match a key already in the store. We want to require a new match. if (new_keys <= old_keys) { ERR_raise(ERR_LIB_SSL, SSL_R_PRIVATE_KEY_MISMATCH); goto end; From 07024d56eea4f418bbb943c27babd4b9116c67ef Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Fri, 23 Oct 2026 15:21:44 -0400 Subject: [PATCH 13/22] Don't suggest retrying for real ECH when using GREASE. --- Doc/library/ssl.rst | 78 +++++++++++++++++++++------------------------ 1 file changed, 37 insertions(+), 41 deletions(-) diff --git a/Doc/library/ssl.rst b/Doc/library/ssl.rst index d9f4c5b4412e2e3..6b469766f83e911 100644 --- a/Doc/library/ssl.rst +++ b/Doc/library/ssl.rst @@ -889,7 +889,9 @@ Constants .. data:: OP_ECH_GREASE Include a GREASE ECH extension in ClientHello messages when ECH is not - being attempted. This can elicit an ECH retry configuration from a server. + being attempted, so these connections resemble connections that use ECH. + Clients must ignore any ECH retry configurations received in response to + GREASE (see :rfc:`9849`, section 6.2.1). Only available when :data:`HAS_ECH` is ``True``. @@ -1953,7 +1955,8 @@ to speed up repeated connections from the same clients. .. method:: SSLContext.set_ech_config(ech_config) Sets an Encrypted Client Hello (ECH) configuration, which may be discovered from - an HTTPS resource record in DNS or from :meth:`SSLSocket.get_ech_retry_config`. + an HTTPS resource record in DNS or from + :attr:`SSLECHRejectedError.retry_config` after ECH rejection. The configuration applies to every connection made from the context, and a server that cannot decrypt it rejects the connection with @@ -3181,56 +3184,49 @@ connection from a context with :meth:`SSLContext.set_ech_config`. A server that has rotated its ECH keys rejects the connection and provides a retry configuration, which the handshake raises as -:exc:`SSLECHRejectedError`. The following example accepts a binary-encoded -ECHConfigList discovered through DNS and retries once with the server's retry -configuration. If no configuration is supplied, it enables ECH GREASE, which a -server may answer with a retry configuration for a later connection:: +:exc:`SSLECHRejectedError`. The following example requires a binary-encoded +ECHConfigList discovered through DNS and retries once with +:attr:`SSLECHRejectedError.retry_config` if it is not empty:: import http.client import socket import ssl - def connect_with_tls_ech(hostname, ech_config=None, use_retry_config=True): - context = ssl.create_default_context() - if ech_config is not None: - context.set_ech_config(ech_config) - else: - context.options |= ssl.OP_ECH_GREASE - sock = socket.create_connection((hostname, 443), timeout=10) - try: - ssock = context.wrap_socket(sock, server_hostname=hostname) - except BaseException: - sock.close() - raise - if (use_retry_config - and ssock.get_ech_status() - == ssl.ECHStatus.ECH_STATUS_GREASE_ECH): - try: - retry_config = ssock.get_ech_retry_config() - finally: - ssock.close() - return connect_with_tls_ech(hostname, retry_config, False) - return ssock + def connect_with_tls_ech(hostname, ech_config): + context = ssl.create_default_context() + context.set_ech_config(ech_config) + for attempt in range(2): + sock = socket.create_connection((hostname, 443), timeout=10) + try: + return context.wrap_socket(sock, server_hostname=hostname) + except ssl.SSLECHRejectedError as exc: + sock.close() + if attempt or not exc.retry_config: + raise + context.set_ech_config(exc.retry_config) + except BaseException: + sock.close() + raise hostname = "python.org" - ech_config = None # Replace with an ECHConfigList discovered through DNS. + ech_config = ... # Replace with an ECHConfigList discovered through DNS. with connect_with_tls_ech(hostname, ech_config) as ssock: - print("ECH status:", ssock.get_ech_status().name) - request = ( - "GET / HTTP/1.1\r\n" - f"Host: {hostname}\r\n" - "Connection: close\r\n\r\n" - ) - ssock.sendall(request.encode("ascii")) - with http.client.HTTPResponse(ssock) as response: - response.begin() - print(f"HTTP {response.status} {response.reason}") - for name, value in response.getheaders(): + print("ECH status:", ssock.get_ech_status().name) + request = ( + "GET / HTTP/1.1\r\n" + f"Host: {hostname}\r\n" + "Connection: close\r\n\r\n" + ) + ssock.sendall(request.encode("ascii")) + with http.client.HTTPResponse(ssock) as response: + response.begin() + print(f"HTTP {response.status} {response.reason}") + for name, value in response.getheaders(): print(f"{name}: {value}") - print() - print(response.read().decode("utf-8", errors="replace")) + print() + print(response.read().decode("utf-8", errors="replace")) The following classes, methods, and attributes will be useful for using ECH: From 8bde466cbfc0b9f8f513c2fb03505f56177b8a8e Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Fri, 23 Oct 2026 15:34:23 -0400 Subject: [PATCH 14/22] Add a comment and test for workaround. --- Lib/test/test_ssl.py | 16 ++++++++++++++++ Modules/_ssl.c | 8 +------- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index d45475cf188133b..543b54c4cc8520e 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -2133,6 +2133,22 @@ def test_mixed_ech_config_versions(self): config = len(entries).to_bytes(2, 'big') + entries self.assertIsNone(ctx.set_ech_config(config)) + @requires_ech_x25519 + def test_mixed_ech_config_unsupported_suites(self): + ctx = make_test_context() + supported = self.ECH_CONFIG[2:] + for algorithm, offset in (('KEM', 5), ('KDF', 43), ('AEAD', 45)): + unsupported = bytearray(supported) + unsupported[offset:offset + 2] = b'\xff\xff' + with self.subTest(algorithm=algorithm): + config = len(unsupported).to_bytes(2, 'big') + unsupported + self.assertRaises(ssl.SSLError, ctx.set_ech_config, config) + for entries in (unsupported + supported, + supported + unsupported): + with self.subTest(entries=entries): + config = len(entries).to_bytes(2, 'big') + entries + self.assertIsNone(ctx.set_ech_config(config)) + def read_ech_key_and_config(self, filename='ech.pem'): # Generated with: # openssl ech -public_name public.example -suite 0x20,1,1 diff --git a/Modules/_ssl.c b/Modules/_ssl.c index 16b76999b501edf..805c43320ed6be6 100644 --- a/Modules/_ssl.c +++ b/Modules/_ssl.c @@ -5320,10 +5320,6 @@ _ssl__SSLContext_set_default_verify_paths_impl(PySSLContext *self) } #ifdef HAVE_OPENSSL_ECH -/* Parse an ECHConfigList into a new store, reading each entry separately so - * entries this OpenSSL build cannot use (unsupported HPKE suites, mandatory - * extensions) are skipped rather than rejecting the whole list. Return NULL - * with a Python exception set. */ static OSSL_ECHSTORE * ech_store_from_config(_sslmodulestate *state, Py_buffer *ech_config) { @@ -5365,13 +5361,11 @@ ech_store_from_config(_sslmodulestate *state, Py_buffer *ech_config) ERR_clear_error(); if (OSSL_ECHSTORE_read_echconfiglist(store, config_bio) != 1) { unsigned long err = ERR_peek_error(); - /* Unsupported HPKE suites may first raise provider errors. */ while (ERR_GET_LIB(err) == ERR_LIB_PROV && !ERR_FATAL_ERROR(err)) { ERR_get_error(); err = ERR_peek_error(); } - /* Ignore unsupported suites and mandatory extensions, - * preserving malformed input and fatal errors. */ + // This is a workaround until OpenSSL can skip unusable entries itself. if (ERR_GET_LIB(err) != ERR_LIB_SSL || ERR_GET_REASON(err) != ERR_R_PASSED_INVALID_ARGUMENT) { goto fail; From 2f402d760c672a8a29f6e17e0022f9ac37093e48 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Fri, 23 Oct 2026 15:40:45 -0400 Subject: [PATCH 15/22] Fix retry config docs. --- Doc/library/ssl.rst | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/Doc/library/ssl.rst b/Doc/library/ssl.rst index 6b469766f83e911..fd923b48c146af4 100644 --- a/Doc/library/ssl.rst +++ b/Doc/library/ssl.rst @@ -326,15 +326,20 @@ Exceptions A subclass of :exc:`SSLError` raised when the handshake fails because the server rejected :ref:`Encrypted Client Hello `. The connection cannot be used. If :attr:`retry_config` is not empty, the - client may retry once with a new connection using that configuration. + client should retry once with a new connection using that configuration. + If it is empty, the authenticated server has securely disabled ECH, and the + client should retry with a new connection without ECH (see :rfc:`9849`, + section 6.1.6). + + If certificate verification for the outer server name fails, the handshake + raises :exc:`SSLCertVerificationError` instead. This exception is only raised when :data:`HAS_ECH` is ``True``. .. attribute:: retry_config The server's ECH retry configuration as an ECHConfigList, - or ``b""`` if the server did not provide one or the server - could not be authenticated under the outer server name. + or ``b""`` if the server did not provide one. .. versionadded:: next @@ -3185,8 +3190,11 @@ connection from a context with :meth:`SSLContext.set_ech_config`. A server that has rotated its ECH keys rejects the connection and provides a retry configuration, which the handshake raises as :exc:`SSLECHRejectedError`. The following example requires a binary-encoded -ECHConfigList discovered through DNS and retries once with -:attr:`SSLECHRejectedError.retry_config` if it is not empty:: +ECHConfigList discovered through DNS and retries once with the server's +:attr:`SSLECHRejectedError.retry_config` if it is not empty, or without ECH +if the authenticated server has securely disabled it. + +The following example shows how to connect to a website with HTTP using ECH:: import http.client import socket @@ -3202,9 +3210,12 @@ ECHConfigList discovered through DNS and retries once with return context.wrap_socket(sock, server_hostname=hostname) except ssl.SSLECHRejectedError as exc: sock.close() - if attempt or not exc.retry_config: + if attempt: raise - context.set_ech_config(exc.retry_config) + if exc.retry_config: + context.set_ech_config(exc.retry_config) + else: + context = ssl.create_default_context() except BaseException: sock.close() raise From f9e1a29eb3a9d210914847710ecf8620dfd4b146 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Fri, 23 Oct 2026 15:45:23 -0400 Subject: [PATCH 16/22] Fix the retry config setter. --- Modules/_ssl.c | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Modules/_ssl.c b/Modules/_ssl.c index 805c43320ed6be6..b7902ba207e925d 100644 --- a/Modules/_ssl.c +++ b/Modules/_ssl.c @@ -2208,10 +2208,11 @@ _ssl__SSLSocket_get_ech_retry_config_impl(PySSLSocket *self) Py_RETURN_NONE; } unsigned char *config = NULL; - size_t config_len; + size_t config_len = 0; if (SSL_ech_get1_retry_config(self->ssl, &config, &config_len) != 1) { - _setSSLError(get_state_sock(self), NULL, 0, __FILE__, __LINE__); - return NULL; + // OpenSSL does not release unauthenticated retry configurations. + ERR_clear_error(); + config_len = 0; } PyObject *result = PyBytes_FromStringAndSize((char *)config, config_len); OPENSSL_free(config); From 33f1324c06dfb3185d3f2f84647d8943acbd80d2 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Fri, 23 Oct 2026 15:58:48 -0400 Subject: [PATCH 17/22] Support base64 and don't try to skip unusable entries. The ssl module should just be a thin wrapper around OpenSSL. It's not our job to try to change the behavior. --- Doc/library/ssl.rst | 4 ++-- Lib/test/test_ssl.py | 23 ++++++++++++++---- Modules/_ssl.c | 52 ++++------------------------------------- Modules/clinic/_ssl.c.h | 6 ++--- 4 files changed, 28 insertions(+), 57 deletions(-) diff --git a/Doc/library/ssl.rst b/Doc/library/ssl.rst index fd923b48c146af4..b6d0913dd79a72c 100644 --- a/Doc/library/ssl.rst +++ b/Doc/library/ssl.rst @@ -1976,7 +1976,7 @@ to speed up repeated connections from the same clients. raised. The *ech_config* parameter should be a :term:`bytes-like object` containing - a binary-encoded ECHConfigList. + an ECHConfigList in binary or Base64 encoding. Only available when :data:`HAS_ECH` is ``True``. @@ -3189,7 +3189,7 @@ connection from a context with :meth:`SSLContext.set_ech_config`. A server that has rotated its ECH keys rejects the connection and provides a retry configuration, which the handshake raises as -:exc:`SSLECHRejectedError`. The following example requires a binary-encoded +:exc:`SSLECHRejectedError`. The following example uses a binary-encoded ECHConfigList discovered through DNS and retries once with the server's :attr:`SSLECHRejectedError.retry_config` if it is not empty, or without ECH if the authenticated server has securely disabled it. diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index 543b54c4cc8520e..d3eee670b38451a 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -2119,19 +2119,28 @@ def test_set_ech_config(self): with self.subTest(config=config): self.assertRaises(TypeError, ctx.set_ech_config, config) for config in (b'', b'\x00', b'\x00\x00', b'invalid', - self.ECH_CONFIG[:-1]): + self.ECH_CONFIG[:-1], self.ECH_CONFIG + b'\x00'): with self.subTest(config=config): self.assertRaises(ssl.SSLError, ctx.set_ech_config, config) self.assertIsNone(ctx.set_ech_config(self.ECH_CONFIG)) + @requires_ech_x25519 + def test_set_ech_config_base64(self): + ctx = make_test_context() + self.assertIsNone(ctx.set_ech_config(base64.b64encode(self.ECH_CONFIG))) + @requires_ech_x25519 def test_mixed_ech_config_versions(self): ctx = make_test_context() supported = self.ECH_CONFIG[2:] unsupported = b'\xff\xff\x00\x01\x00' - for entries in (unsupported + supported, supported + unsupported): - config = len(entries).to_bytes(2, 'big') + entries - self.assertIsNone(ctx.set_ech_config(config)) + # OpenSSL's binary format detection requires a known version first. + entries = unsupported + supported + config = len(entries).to_bytes(2, 'big') + entries + self.assertRaises(ssl.SSLError, ctx.set_ech_config, config) + entries = supported + unsupported + config = len(entries).to_bytes(2, 'big') + entries + self.assertIsNone(ctx.set_ech_config(config)) @requires_ech_x25519 def test_mixed_ech_config_unsupported_suites(self): @@ -2143,11 +2152,15 @@ def test_mixed_ech_config_unsupported_suites(self): with self.subTest(algorithm=algorithm): config = len(unsupported).to_bytes(2, 'big') + unsupported self.assertRaises(ssl.SSLError, ctx.set_ech_config, config) + # OpenSSL currently rejects the whole list even if another + # entry has a supported suite. That should probably be fixed + # upstream, since it disagrees with the RFC. for entries in (unsupported + supported, supported + unsupported): with self.subTest(entries=entries): config = len(entries).to_bytes(2, 'big') + entries - self.assertIsNone(ctx.set_ech_config(config)) + self.assertRaises(ssl.SSLError, ctx.set_ech_config, + config) def read_ech_key_and_config(self, filename='ech.pem'): # Generated with: diff --git a/Modules/_ssl.c b/Modules/_ssl.c index b7902ba207e925d..751b994833f9f77 100644 --- a/Modules/_ssl.c +++ b/Modules/_ssl.c @@ -5340,50 +5340,8 @@ ech_store_from_config(_sslmodulestate *state, Py_buffer *ech_config) } const unsigned char *config = ech_config->buf; int config_len = (int)ech_config->len; - // RFC 9849 says that clients have to skip unknown entries, so we have to do - // this extra parsing step ourselves. - if (config_len >= 2 && ((config[0] << 8) | config[1]) == config_len - 2) { - config += 2; - config_len -= 2; - while (config_len >= 4) { - int entry_len = 4 + ((config[2] << 8) | config[3]); - if (entry_len > config_len) { - break; - } - if (((config[0] << 8) | config[1]) == OSSL_ECH_RFC9849_VERSION) { - unsigned char length[2] = { - (unsigned char)(entry_len >> 8), (unsigned char)entry_len - }; - if (BIO_reset(config_bio) != 1 - || BIO_write(config_bio, length, sizeof(length)) != sizeof(length) - || BIO_write(config_bio, config, entry_len) != entry_len) { - goto fail; - } - ERR_clear_error(); - if (OSSL_ECHSTORE_read_echconfiglist(store, config_bio) != 1) { - unsigned long err = ERR_peek_error(); - while (ERR_GET_LIB(err) == ERR_LIB_PROV && !ERR_FATAL_ERROR(err)) { - ERR_get_error(); - err = ERR_peek_error(); - } - // This is a workaround until OpenSSL can skip unusable entries itself. - if (ERR_GET_LIB(err) != ERR_LIB_SSL - || ERR_GET_REASON(err) != ERR_R_PASSED_INVALID_ARGUMENT) { - goto fail; - } - } - ERR_clear_error(); - } - config += entry_len; - config_len -= entry_len; - } - if (config_len != 0) { - ERR_raise(ERR_LIB_SSL, SSL_R_ECH_DECODE_ERROR); - goto fail; - } - } - else if (BIO_write(config_bio, config, config_len) != config_len - || OSSL_ECHSTORE_read_echconfiglist(store, config_bio) != 1) { + if (BIO_write(config_bio, config, config_len) != config_len + || OSSL_ECHSTORE_read_echconfiglist(store, config_bio) != 1) { goto fail; } int num_entries; @@ -5413,14 +5371,14 @@ _ssl._SSLContext.set_ech_config Set the ECH configuration on the SSL context. -ech_config must be a bytes-like object containing the raw ECH -configuration. +ech_config must be a bytes-like object containing an ECHConfigList +in binary or Base64 encoding. [clinic start generated code]*/ static PyObject * _ssl__SSLContext_set_ech_config_impl(PySSLContext *self, Py_buffer *ech_config) -/*[clinic end generated code: output=cea53188b338cf80 input=3943e5aff04a583e]*/ +/*[clinic end generated code: output=cea53188b338cf80 input=69016efcb2cd1ed2]*/ { OSSL_ECHSTORE *store = ech_store_from_config(get_state_ctx(self), ech_config); diff --git a/Modules/clinic/_ssl.c.h b/Modules/clinic/_ssl.c.h index 0b5db5d1829cb1f..32444f156b07cdf 100644 --- a/Modules/clinic/_ssl.c.h +++ b/Modules/clinic/_ssl.c.h @@ -2213,8 +2213,8 @@ PyDoc_STRVAR(_ssl__SSLContext_set_ech_config__doc__, "\n" "Set the ECH configuration on the SSL context.\n" "\n" -"ech_config must be a bytes-like object containing the raw ECH\n" -"configuration."); +"ech_config must be a bytes-like object containing an ECHConfigList\n" +"in binary or Base64 encoding."); #define _SSL__SSLCONTEXT_SET_ECH_CONFIG_METHODDEF \ {"set_ech_config", (PyCFunction)_ssl__SSLContext_set_ech_config, METH_O, _ssl__SSLContext_set_ech_config__doc__}, @@ -3420,4 +3420,4 @@ _ssl_enum_crls(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObje #define _SSL_SSLSESSION_HAS_TICKET_GETSETDEF {"has_ticket", (getter)_ssl_SSLSession_has_ticket_get, (setter)NULL, _ssl_SSLSession_has_ticket__doc__}, -/*[clinic end generated code: output=25bebcfb2c509fd3 input=a9049054013a1b77]*/ +/*[clinic end generated code: output=7d79dfbef44c8ceb input=a9049054013a1b77]*/ From a3d735a696f0b98ce62426bcae7d34b5c4d9daeb Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Fri, 23 Oct 2026 16:03:52 -0400 Subject: [PATCH 18/22] Add debug checks. --- Doc/library/ssl.rst | 4 ++++ Lib/test/test_ssl.py | 12 ++++++++++++ Modules/_ssl.c | 12 ++++++++++++ 3 files changed, 28 insertions(+) diff --git a/Doc/library/ssl.rst b/Doc/library/ssl.rst index b6d0913dd79a72c..4d1fe3b020dbb70 100644 --- a/Doc/library/ssl.rst +++ b/Doc/library/ssl.rst @@ -1759,6 +1759,8 @@ to speed up repeated connections from the same clients. context. If *for_retry* is true, the new configurations are included in ECH retry configurations sent to clients. + Raises :exc:`SSLError` if the context uses :data:`PROTOCOL_TLS_CLIENT`. + Only available when :data:`HAS_ECH` is ``True``. .. versionadded:: next @@ -1978,6 +1980,8 @@ to speed up repeated connections from the same clients. The *ech_config* parameter should be a :term:`bytes-like object` containing an ECHConfigList in binary or Base64 encoding. + Raises :exc:`SSLError` if the context uses :data:`PROTOCOL_TLS_SERVER`. + Only available when :data:`HAS_ECH` is ``True``. .. versionadded:: next diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index d3eee670b38451a..c3ee77019f5ca26 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -2129,6 +2129,18 @@ def test_set_ech_config_base64(self): ctx = make_test_context() self.assertIsNone(ctx.set_ech_config(base64.b64encode(self.ECH_CONFIG))) + @requires_ech_x25519 + def test_context_roles(self): + client_ctx, server_ctx, _ = testing_context() + with self.assertRaisesRegex(ssl.SSLError, 'PROTOCOL_TLS_SERVER'): + server_ctx.set_ech_config(self.ECH_CONFIG) + with self.assertRaisesRegex(ssl.SSLError, 'PROTOCOL_TLS_CLIENT'): + client_ctx.load_ech_keys(data_file('ech.pem')) + with warnings_helper.check_warnings(): + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS) + self.assertIsNone(ctx.set_ech_config(self.ECH_CONFIG)) + self.assertIsNone(ctx.load_ech_keys(data_file('ech.pem'))) + @requires_ech_x25519 def test_mixed_ech_config_versions(self): ctx = make_test_context() diff --git a/Modules/_ssl.c b/Modules/_ssl.c index 751b994833f9f77..b429c5146683239 100644 --- a/Modules/_ssl.c +++ b/Modules/_ssl.c @@ -5380,6 +5380,12 @@ _ssl__SSLContext_set_ech_config_impl(PySSLContext *self, Py_buffer *ech_config) /*[clinic end generated code: output=cea53188b338cf80 input=69016efcb2cd1ed2]*/ { + if (self->protocol == PY_SSL_VERSION_TLS_SERVER) { + _setSSLError(get_state_ctx(self), + "Cannot set ECH configuration on a " + "PROTOCOL_TLS_SERVER context", 0, __FILE__, __LINE__); + return NULL; + } OSSL_ECHSTORE *store = ech_store_from_config(get_state_ctx(self), ech_config); if (store == NULL) { @@ -5459,6 +5465,12 @@ _ssl__SSLContext_load_ech_keys_impl(PySSLContext *self, PyObject *keyfile, PyObject *password, int for_retry) /*[clinic end generated code: output=73b9b4832b89f40d input=016a625a1505de4a]*/ { + if (self->protocol == PY_SSL_VERSION_TLS_CLIENT) { + _setSSLError(get_state_ctx(self), + "Cannot load ECH keys into a " + "PROTOCOL_TLS_CLIENT context", 0, __FILE__, __LINE__); + return NULL; + } PyObject *keyfile_bytes; PyObject *ret = NULL; _PySSLPasswordInfo pw_info = { NULL, NULL, NULL, 0, 0 }; From 97eee2293bb769fdc3f8965b696f898aa2216b5e Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Fri, 23 Oct 2026 16:11:18 -0400 Subject: [PATCH 19/22] Add some missing things to the docs. --- Doc/library/ssl.rst | 82 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 77 insertions(+), 5 deletions(-) diff --git a/Doc/library/ssl.rst b/Doc/library/ssl.rst index 4d1fe3b020dbb70..7fe9c12fb2e1725 100644 --- a/Doc/library/ssl.rst +++ b/Doc/library/ssl.rst @@ -1106,12 +1106,78 @@ Constants .. versionadded:: next +.. attribute:: ECHStatus.ECH_STATUS_SUCCESS + + ECH was accepted without a certificate verification error. + + .. versionadded:: next + +.. attribute:: ECHStatus.ECH_STATUS_FAILED + + An internal or protocol error occurred, or ECH was rejected without a retry + configuration. + + .. versionadded:: next + +.. attribute:: ECHStatus.ECH_STATUS_FAILED_ECH + + ECH was rejected and the server supplied an authenticated retry + configuration. + + .. versionadded:: next + +.. attribute:: ECHStatus.ECH_STATUS_BAD_NAME + + ECH was accepted, but verification of the peer's certificate failed. + + .. versionadded:: next + +.. attribute:: ECHStatus.ECH_STATUS_FAILED_ECH_BAD_NAME + + ECH was rejected and a retry configuration was received, but certificate + verification for the outer server name failed. The retry configuration + must not be used. + + .. versionadded:: next + +.. attribute:: ECHStatus.ECH_STATUS_NOT_TRIED + + ECH is configured but has not been attempted (such as before the + handshake starts). + + .. versionadded:: next + +.. attribute:: ECHStatus.ECH_STATUS_NOT_CONFIGURED + + No ECH configuration is set for this connection. + + .. versionadded:: next + +.. attribute:: ECHStatus.ECH_STATUS_GREASE + + ECH GREASE is enabled or was used, but no retry configuration was received. + + .. versionadded:: next + .. attribute:: ECHStatus.ECH_STATUS_GREASE_ECH The client sent a GREASE ECH extension and received a retry configuration. .. versionadded:: next +.. attribute:: ECHStatus.ECH_STATUS_BACKEND + + The server received a ClientHello marked as an inner ClientHello, as used + by a backend server in an ECH split deployment. + + .. versionadded:: next + +.. attribute:: ECHStatus.ECH_STATUS_BAD_CALL + + Invalid arguments were supplied to OpenSSL's ECH status API. + + .. versionadded:: next + .. data:: Purpose.SERVER_AUTH Option for :func:`create_default_context` and @@ -1451,7 +1517,10 @@ SSL sockets also have the following additional methods and attributes: .. method:: SSLSocket.get_ech_retry_config() Return the server's ECH retry configuration as :class:`bytes`, or ``b""`` - if no retry configuration is available. + if no usable retry configuration is available. + + Return ``None`` if the socket has no underlying SSL object, such as before + it connects or after it is closed. Only available when :data:`HAS_ECH` is ``True``. @@ -1459,9 +1528,12 @@ SSL sockets also have the following additional methods and attributes: .. method:: SSLSocket.get_ech_status() - Gets the status of Encrypted Client Hello (ECH) processing. Returns an + Return the status of Encrypted Client Hello (ECH) processing as an :class:`ECHStatus` instance. + Return ``None`` if the socket has no underlying SSL object, such as before + it connects or after it is closed. + Only available when :data:`HAS_ECH` is ``True``. .. versionadded:: next @@ -1555,9 +1627,9 @@ SSL sockets also have the following additional methods and attributes: .. attribute:: SSLSocket.outer_server_hostname - Hostname of the server name used in the outer ClientHello when Encrypted Client - Hello (ECH) is used, or ``None`` for server-side socket or if no outer - server name is available. + The server name used in the outer ClientHello when Encrypted Client Hello + (ECH) is used, as a :class:`str`. Available on both client-side and + server-side sockets. Returns ``None`` if no outer server name is available. Only available when :data:`HAS_ECH` is ``True``. From 7e43ba3c51550ac515e5831eb7a536e17bd4b68a Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Fri, 23 Oct 2026 16:22:26 -0400 Subject: [PATCH 20/22] Remove set_outer_server_name It was in the original DEfO PR, but since it wasn't exposed to Python, it's probably not very useful. --- Lib/test/test_ssl.py | 13 +---------- Modules/_ssl.c | 31 -------------------------- Modules/clinic/_ssl.c.h | 49 +---------------------------------------- 3 files changed, 2 insertions(+), 91 deletions(-) diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index c3ee77019f5ca26..a2365c705d9ea6d 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -2086,7 +2086,7 @@ def test_availability(self): for owner, names in ( (_ssl._SSLContext, context_methods), (ssl.SSLContext, (*context_methods, 'set_outer_alpn_protocols')), - (_ssl._SSLSocket, (*socket_methods, 'set_outer_server_name')), + (_ssl._SSLSocket, socket_methods), (ssl.SSLObject, (*socket_methods, 'outer_server_hostname')), (ssl.SSLSocket, (*socket_methods, 'outer_server_hostname')), ): @@ -2437,17 +2437,6 @@ def test_status_before_handshake(self): self.assertIsNone(obj.outer_server_hostname) self.assertEqual(obj.get_ech_retry_config(), b'') - @requires_ech_x25519 - def test_set_outer_server_name(self): - ctx = make_test_context() - ctx.set_ech_config(self.ECH_CONFIG) - for obj in self.make_ssl_objects(ctx): - setter = obj._sslobj.set_outer_server_name - self.assertIsNone(setter('localhost')) - self.assertIsNone(setter(None)) - self.assertRaises(TypeError, setter, 42) - self.assertRaises(ValueError, setter, 'host\x00name') - @unittest.skipUnless(ssl.HAS_ECH, 'requires ECH') def test_set_outer_alpn_protocols(self): ctx = make_test_context() diff --git a/Modules/_ssl.c b/Modules/_ssl.c index b429c5146683239..4252200419a1cfd 100644 --- a/Modules/_ssl.c +++ b/Modules/_ssl.c @@ -2272,36 +2272,6 @@ _ssl__SSLSocket_set_ech_config_impl(PySSLSocket *self, Py_buffer *ech_config) Py_RETURN_NONE; } -/*[clinic input] -@critical_section -_ssl._SSLSocket.set_outer_server_name - - outer_server_name: str(accept={str, NoneType}) - / -[clinic start generated code]*/ - -static PyObject * -_ssl__SSLSocket_set_outer_server_name_impl(PySSLSocket *self, - const char *outer_server_name) -/*[clinic end generated code: output=24e7c6b7b3c2ce84 input=8d1d96628e11b2fd]*/ -{ - if (self->ssl == NULL) { - Py_RETURN_NONE; - } - int result; - if (outer_server_name != NULL) { - result = SSL_ech_set1_outer_server_name(self->ssl, outer_server_name, 0); - } - else { - /* Do not send an outer SNI */ - result = SSL_ech_set1_outer_server_name(self->ssl, NULL, 1); - } - if (result != 1) { - _setSSLError(get_state_sock(self), NULL, 0, __FILE__, __LINE__); - return NULL; - } - Py_RETURN_NONE; -} #endif /* HAVE_OPENSSL_ECH */ /*[clinic input] @@ -3533,7 +3503,6 @@ static PyMethodDef PySSLMethods[] = { _SSL__SSLSOCKET_VERIFY_CLIENT_POST_HANDSHAKE_METHODDEF _SSL__SSLSOCKET_GET_ECH_STATUS_METHODDEF _SSL__SSLSOCKET_SET_ECH_CONFIG_METHODDEF - _SSL__SSLSOCKET_SET_OUTER_SERVER_NAME_METHODDEF _SSL__SSLSOCKET_GET_ECH_RETRY_CONFIG_METHODDEF _SSL__SSLSOCKET_GET_UNVERIFIED_CHAIN_METHODDEF _SSL__SSLSOCKET_GET_VERIFIED_CHAIN_METHODDEF diff --git a/Modules/clinic/_ssl.c.h b/Modules/clinic/_ssl.c.h index 32444f156b07cdf..98a929b33def825 100644 --- a/Modules/clinic/_ssl.c.h +++ b/Modules/clinic/_ssl.c.h @@ -251,49 +251,6 @@ _ssl__SSLSocket_set_ech_config(PyObject *self, PyObject *arg) #endif /* defined(HAVE_OPENSSL_ECH) */ -#if defined(HAVE_OPENSSL_ECH) - -PyDoc_STRVAR(_ssl__SSLSocket_set_outer_server_name__doc__, -"set_outer_server_name($self, outer_server_name, /)\n" -"--\n" -"\n"); - -#define _SSL__SSLSOCKET_SET_OUTER_SERVER_NAME_METHODDEF \ - {"set_outer_server_name", (PyCFunction)_ssl__SSLSocket_set_outer_server_name, METH_O, _ssl__SSLSocket_set_outer_server_name__doc__}, - -static PyObject * -_ssl__SSLSocket_set_outer_server_name_impl(PySSLSocket *self, - const char *outer_server_name); - -static PyObject * -_ssl__SSLSocket_set_outer_server_name(PyObject *self, PyObject *arg) -{ - PyObject *return_value = NULL; - const char *outer_server_name; - - if (arg == Py_None) { - outer_server_name = NULL; - } - else if (PyUnicode_Check(arg)) { - outer_server_name = _PyUnicode_AsUTF8NoNUL(arg); - if (outer_server_name == NULL) { - goto exit; - } - } - else { - _PyArg_BadArgument("set_outer_server_name", "argument", "str or None", arg); - goto exit; - } - Py_BEGIN_CRITICAL_SECTION(self); - return_value = _ssl__SSLSocket_set_outer_server_name_impl((PySSLSocket *)self, outer_server_name); - Py_END_CRITICAL_SECTION(); - -exit: - return return_value; -} - -#endif /* defined(HAVE_OPENSSL_ECH) */ - PyDoc_STRVAR(_ssl__SSLSocket_shared_ciphers__doc__, "shared_ciphers($self, /)\n" "--\n" @@ -3345,10 +3302,6 @@ _ssl_enum_crls(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObje #define _SSL__SSLSOCKET_SET_ECH_CONFIG_METHODDEF #endif /* !defined(_SSL__SSLSOCKET_SET_ECH_CONFIG_METHODDEF) */ -#ifndef _SSL__SSLSOCKET_SET_OUTER_SERVER_NAME_METHODDEF - #define _SSL__SSLSOCKET_SET_OUTER_SERVER_NAME_METHODDEF -#endif /* !defined(_SSL__SSLSOCKET_SET_OUTER_SERVER_NAME_METHODDEF) */ - #ifndef _SSL__SSLSOCKET_SENDFILE_METHODDEF #define _SSL__SSLSOCKET_SENDFILE_METHODDEF #endif /* !defined(_SSL__SSLSOCKET_SENDFILE_METHODDEF) */ @@ -3420,4 +3373,4 @@ _ssl_enum_crls(PyObject *module, PyObject *const *args, Py_ssize_t nargs, PyObje #define _SSL_SSLSESSION_HAS_TICKET_GETSETDEF {"has_ticket", (getter)_ssl_SSLSession_has_ticket_get, (setter)NULL, _ssl_SSLSession_has_ticket__doc__}, -/*[clinic end generated code: output=7d79dfbef44c8ceb input=a9049054013a1b77]*/ +/*[clinic end generated code: output=fb3476aa2f825376 input=a9049054013a1b77]*/ From a4abe869bddd05a8ed0c4e21dbb044fe6d9e9ee8 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Fri, 23 Oct 2026 16:28:33 -0400 Subject: [PATCH 21/22] Fix race. --- Modules/_ssl.c | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Modules/_ssl.c b/Modules/_ssl.c index 4252200419a1cfd..1e1e36d08742f64 100644 --- a/Modules/_ssl.c +++ b/Modules/_ssl.c @@ -2262,10 +2262,16 @@ _ssl__SSLSocket_set_ech_config_impl(PySSLSocket *self, Py_buffer *ech_config) } /* Restore the outer ALPN cleared when this socket was created without * an ECH configuration. */ - PySSLContext *ctx = self->ctx; - if (ctx->outer_alpn_protocols != NULL - && SSL_ech_set1_outer_alpn_protos(self->ssl, ctx->outer_alpn_protocols, - ctx->outer_alpn_protocols_len) != 1) { + PySSLContext *ctx = (PySSLContext *)Py_NewRef(self->ctx); + Py_BEGIN_CRITICAL_SECTION2(self, ctx); + if (ctx->outer_alpn_protocols != NULL) { + result = SSL_ech_set1_outer_alpn_protos(self->ssl, + ctx->outer_alpn_protocols, + ctx->outer_alpn_protocols_len); + } + Py_END_CRITICAL_SECTION2(); + Py_DECREF(ctx); + if (result != 1) { _setSSLError(state, NULL, 0, __FILE__, __LINE__); return NULL; } From 6b12d199eb1df0835b42120edb35430ecb7338de Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Fri, 23 Oct 2026 16:35:32 -0400 Subject: [PATCH 22/22] Add a comment for the OpenSSL bug. --- Doc/library/ssl.rst | 8 ++++++++ Lib/test/test_ssl.py | 7 ++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/Doc/library/ssl.rst b/Doc/library/ssl.rst index 7fe9c12fb2e1725..62cd6aa8da75ef7 100644 --- a/Doc/library/ssl.rst +++ b/Doc/library/ssl.rst @@ -2084,6 +2084,14 @@ to speed up repeated connections from the same clients. argument accepts the same values as for :meth:`~SSLContext.set_alpn_protocols`. + .. note:: + + OpenSSL 4.0 validates the server's ALPN selection against the inner + protocol list even when ECH is rejected. If the server selects an outer + protocol absent from the inner list, the handshake fails with a :exc:`SSLError`. + To support ECH rejection and retry on affected versions, choose outer + protocols that also appear in the inner list. + Only available when :data:`HAS_ECH` is ``True``. .. versionadded:: next diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index a2365c705d9ea6d..0a0e9e178e9bd60 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -2312,9 +2312,10 @@ def test_outer_alpn_with_connection_ech_config(self): ech_config=current) server = server_ctx.wrap_bio(s_in, s_out, server_side=True) # When the server rejects ECH it only ever saw the outer - # ClientHello, so it selects from the outer ALPN. OpenSSL's - # client then aborts because that choice is not in its - # inner list, so only the server's view is checked here. + # ClientHello, so it selects from the outer ALPN. OpenSSL 4.0 + # incorrectly checks that choice against the inner list, + # raising BAD_EXTENSION instead of SSLECHRejectedError. + # https://github.com/python/cpython/pull/160318#discussion_r4362699854 with contextlib.suppress(ssl.SSLError): self.handshake_bio_pair(client, server, c_in, c_out, s_in, s_out)