diff --git a/Doc/library/ssl.rst b/Doc/library/ssl.rst index 5f8a1e36bc05d94..62cd6aa8da75ef7 100644 --- a/Doc/library/ssl.rst +++ b/Doc/library/ssl.rst @@ -321,6 +321,28 @@ 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 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. + + .. versionadded:: next + .. exception:: CertificateError An alias for :exc:`SSLCertVerificationError`. @@ -869,6 +891,42 @@ Constants .. versionadded:: 3.12 +.. data:: OP_ECH_GREASE + + Include a GREASE ECH extension in ClientHello messages when ECH is not + 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``. + + .. 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 +1022,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 @@ -1032,6 +1097,87 @@ Constants .. versionadded:: 3.6 +.. class:: ECHStatus + + :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_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 @@ -1368,6 +1514,45 @@ SSL sockets also have the following additional methods and attributes: .. versionadded:: 3.3 +.. method:: SSLSocket.get_ech_retry_config() + + Return the server's ECH retry configuration as :class:`bytes`, or ``b""`` + 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``. + + .. versionadded:: next + +.. method:: SSLSocket.get_ech_status() + + 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 + +.. 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. + + 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. + + 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 @@ -1440,6 +1625,16 @@ SSL sockets also have the following additional methods and attributes: .. versionadded:: 3.2 +.. attribute:: SSLSocket.outer_server_hostname + + 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``. + + .. versionadded:: next + .. attribute:: SSLSocket.server_side A boolean which is ``True`` for server-side sockets and ``False`` for @@ -1622,6 +1817,26 @@ to speed up repeated connections from the same clients. .. versionadded:: 3.4 +.. 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 a private key + 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`. + + 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. + + Raises :exc:`SSLError` if the context uses :data:`PROTOCOL_TLS_CLIENT`. + + 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 @@ -1816,6 +2031,33 @@ 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 + :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 + :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 (such as if it only contains + ECH configuration versions that are not supported), an :class:`SSLError` will be + raised. + + 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 + .. method:: SSLContext.set_npn_protocols(npn_protocols) Specify which protocols the socket should advertise during the SSL/TLS @@ -1835,6 +2077,25 @@ 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`. + + .. 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 + .. attribute:: SSLContext.sni_callback Register a callback function that will be called after the TLS Client Hello @@ -1942,7 +2203,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 @@ -1983,6 +2244,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`. @@ -1997,6 +2262,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 @@ -2007,16 +2275,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. @@ -2035,6 +2304,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 @@ -2753,6 +3025,9 @@ 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` When compared to :class:`SSLSocket`, this object lacks the following @@ -2977,6 +3252,88 @@ 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) 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. + +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, 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`. + +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 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. + +The following example shows how to connect to a website with HTTP using ECH:: + + import http.client + import socket + import ssl + + + 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: + raise + if exc.retry_config: + context.set_ech_config(exc.retry_config) + else: + context = ssl.create_default_context() + except BaseException: + sock.close() + raise + + hostname = "python.org" + 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(f"{name}: {value}") + print() + print(response.read().decode("utf-8", errors="replace")) + + +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` .. seealso:: diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 925f425aba58693..d9d70e1ed8a0a5c 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -688,6 +688,16 @@ sqlite3 ssl --- +* Added support for :ref:`Encrypted Client Hello ` + (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`. + Servers can use :meth:`~ssl.SSLContext.load_ech_keys` to load ECH private + keys and their configurations from PEM files. + (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 about the version of the OpenSSL library that was used for building 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/ssl.py b/Lib/ssl.py index 59593cfe7d5e700..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 @@ -116,7 +117,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 @@ -150,6 +152,12 @@ lambda name: name.startswith('CERT_'), 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({ value: name for name, value in _SSLMethod.__members__.items()}) @@ -374,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: @@ -384,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") @@ -464,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( @@ -474,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): @@ -517,16 +532,14 @@ 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) + 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: for cert, encoding, trust in enum_certificates(storename): @@ -825,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 @@ -846,6 +863,12 @@ def context(self): def context(self, ctx): self._sslobj.context = ctx + 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): """The SSLSession for client socket.""" @@ -995,6 +1018,23 @@ def version(self): 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]) + + 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""" @@ -1021,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( @@ -1039,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 @@ -1097,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: @@ -1121,6 +1165,13 @@ def context(self, ctx): self._context = ctx self._sslobj.context = ctx + 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 def session(self): @@ -1422,6 +1473,23 @@ def verify_client_post_handshake(self): else: 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: + 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 super()._real_close() @@ -1449,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: @@ -1588,3 +1658,14 @@ 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/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/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/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 27d5e56368bfbc6..0a0e9e178e9bd60 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 @@ -441,6 +442,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 +2045,491 @@ def psk_server_callback(*args, _=ctx, **kwargs): ... ctx.set_psk_server_callback(psk_server_callback) +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' + '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 + + 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) + + self.assertTrue(issubclass(ssl.SSLECHRejectedError, ssl.SSLError)) + context_methods = ('set_ech_config', 'load_ech_keys', + '_set_outer_alpn_protocols') + 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')), + (_ssl._SSLSocket, socket_methods), + (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: + 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()) + + @requires_ech_x25519 + 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], 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_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() + supported = self.ECH_CONFIG[2:] + unsupported = b'\xff\xff\x00\x01\x00' + # 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): + 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) + # 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.assertRaises(ssl.SSLError, 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 + # 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 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) + self.assertEqual(server.selected_alpn_protocol(), expected) + + @requires_ech_x25519 + 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) + + @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() + 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)) + + @requires_ech_x25519 + 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_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_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() + 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.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 + 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'') + + @requires_ech_x25519 + @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/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..bf4f8cfc476d2f1 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-06-12-15-39-48.gh-issue-89730.Lu35Fu.rst @@ -0,0 +1,2 @@ +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 ee0df146421d7f8..1e1e36d08742f64 100644 --- a/Modules/_ssl.c +++ b/Modules/_ssl.c @@ -76,6 +76,11 @@ #include "openssl/bio.h" #include "openssl/dh.h" +#if OPENSSL_VERSION_NUMBER >= 0x40000000L && !defined(OPENSSL_NO_ECH) +# define HAVE_OPENSSL_ECH 1 +# include "openssl/ech.h" +#endif + #ifndef OPENSSL_THREADS # error "OPENSSL_THREADS is not defined, Python requires thread-safe OpenSSL" #endif @@ -312,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; @@ -451,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."); @@ -485,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; @@ -629,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); } @@ -738,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. */ @@ -937,6 +977,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) */ @@ -2117,6 +2174,112 @@ cipher_to_dict(const SSL_CIPHER *cipher) ); } +#ifdef HAVE_OPENSSL_ECH +/*[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]*/ +{ + 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 *result = Py_BuildValue("(izz)", status, inner_sni, outer_sni); + OPENSSL_free(inner_sni); + OPENSSL_free(outer_sni); + return result; +} + +/*[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]*/ +{ + if (self->ssl == NULL) { + Py_RETURN_NONE; + } + unsigned char *config = NULL; + size_t config_len = 0; + if (SSL_ech_get1_retry_config(self->ssl, &config, &config_len) != 1) { + // OpenSSL does not release unauthenticated retry configurations. + ERR_clear_error(); + config_len = 0; + } + PyObject *result = PyBytes_FromStringAndSize((char *)config, config_len); + OPENSSL_free(config); + 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 = (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; + } + Py_RETURN_NONE; +} + +#endif /* HAVE_OPENSSL_ECH */ + /*[clinic input] @critical_section _ssl._SSLSocket.shared_ciphers @@ -3344,6 +3507,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_ECH_CONFIG_METHODDEF + _SSL__SSLSOCKET_GET_ECH_RETRY_CONFIG_METHODDEF _SSL__SSLSOCKET_GET_UNVERIFIED_CHAIN_METHODDEF _SSL__SSLSOCKET_GET_VERIFIED_CHAIN_METHODDEF {NULL, NULL} @@ -3503,6 +3669,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 @@ -3655,6 +3825,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); } @@ -3949,6 +4122,45 @@ _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 + 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]*/ +{ + if ((size_t)protos->len > UINT_MAX) { + PyErr_Format(PyExc_OverflowError, + "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, + self->outer_alpn_protocols, + self->outer_alpn_protocols_len) != 1) { + _setSSLError(get_state_ctx(self), NULL, 0, __FILE__, __LINE__); + return NULL; + } + Py_RETURN_NONE; +} +#endif /* HAVE_OPENSSL_ECH */ + /*[clinic input] @critical_section @getter @@ -5083,6 +5295,212 @@ _ssl__SSLContext_set_default_verify_paths_impl(PySSLContext *self) Py_RETURN_NONE; } +#ifdef HAVE_OPENSSL_ECH +static OSSL_ECHSTORE * +ech_store_from_config(_sslmodulestate *state, Py_buffer *ech_config) +{ + 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(BIO_s_mem()); + 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 (BIO_write(config_bio, config, config_len) != config_len + || OSSL_ECHSTORE_read_echconfiglist(store, config_bio) != 1) { + goto fail; + } + int num_entries; + if (OSSL_ECHSTORE_num_entries(store, &num_entries) != 1) { + goto fail; + } + if (num_entries == 0) { + ERR_raise(ERR_LIB_SSL, ERR_R_PASSED_INVALID_ARGUMENT); + goto fail; + } + BIO_free(config_bio); + return store; + +fail: + _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 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=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) { + 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 +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, + _password_callback, pw_info); + 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. We want to 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] +_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 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, + 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 }; + 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(); + 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, &pw_info); + BIO_free(bio); + } + PySSL_END_ALLOW_THREADS_S(pw_info.thread_state); + PyMutex_Unlock(&self->tstate_mutex); + + if (result != 1) { + 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__); + } + goto done; + } + ret = Py_NewRef(Py_None); +done: + PyMem_Free(pw_info.password); + Py_DECREF(keyfile_bytes); + return ret; +} +#endif /* HAVE_OPENSSL_ECH */ + /*[clinic input] @critical_section _ssl._SSLContext.set_ecdh_curve @@ -5762,6 +6180,9 @@ 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_LOAD_ECH_KEYS_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 @@ -6883,6 +7304,13 @@ do { \ SSLEOFError_doc, state->PySSLErrorObject ); + + add_exception( + state->PySSLECHRejectedErrorObject, + "SSLECHRejectedError", + SSLECHRejectedError_doc, + state->PySSLErrorObject + ); #undef add_exception return 0; @@ -6956,6 +7384,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 +7487,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 +7620,12 @@ sslmodule_init_constants(PyObject *m) addbool(m, "HAS_PHA", 0); #endif +#ifdef HAVE_OPENSSL_ECH + addbool(m, "HAS_ECH", 1); +#else + addbool(m, "HAS_ECH", 0); +#endif + #undef addbool #undef ADD_INT_CONST @@ -7413,6 +7867,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; } @@ -7460,6 +7918,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); @@ -7484,6 +7943,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); @@ -7491,6 +7951,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 d0bce13f2fd945e..98a929b33def825 100644 --- a/Modules/clinic/_ssl.c.h +++ b/Modules/clinic/_ssl.c.h @@ -156,6 +156,101 @@ _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" +"\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; +} + +#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" +"\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; +} + +#endif /* defined(HAVE_OPENSSL_ECH) */ + +#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) */ + PyDoc_STRVAR(_ssl__SSLSocket_shared_ciphers__doc__, "shared_ciphers($self, /)\n" "--\n" @@ -1189,6 +1284,44 @@ _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" +"\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; +} + +#endif /* defined(HAVE_OPENSSL_ECH) */ + static int _ssl__SSLContext_verify_mode_get_impl(PySSLContext *self); @@ -2029,6 +2162,139 @@ _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" +"\n" +"Set the ECH configuration on the SSL context.\n" +"\n" +"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__}, + +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; +} + +#endif /* defined(HAVE_OPENSSL_ECH) */ + +#if defined(HAVE_OPENSSL_ECH) + +PyDoc_STRVAR(_ssl__SSLContext_load_ech_keys__doc__, +"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 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."); + +#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, + PyObject *password, 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 3 + 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(password), &_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", "password", "for_retry", NULL}; + static _PyArg_Parser _parser = { + .keywords = _keywords, + .fname = "load_ech_keys", + .kwtuple = KWTUPLE, + }; + #undef KWTUPLE + 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*/ 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[2]); + if (for_retry < 0) { + goto exit; + } +skip_optional_kwonly: + return_value = _ssl__SSLContext_load_ech_keys_impl((PySSLContext *)self, keyfile, password, for_retry); + +exit: + return return_value; +} + +#endif /* defined(HAVE_OPENSSL_ECH) */ + PyDoc_STRVAR(_ssl__SSLContext_set_ecdh_curve__doc__, "set_ecdh_curve($self, name, /)\n" "--\n" @@ -3024,10 +3290,34 @@ _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_ECH_CONFIG_METHODDEF + #define _SSL__SSLSOCKET_SET_ECH_CONFIG_METHODDEF +#endif /* !defined(_SSL__SSLSOCKET_SET_ECH_CONFIG_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__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) */ @@ -3083,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=14ced41bd24b60c3 input=a9049054013a1b77]*/ +/*[clinic end generated code: output=fb3476aa2f825376 input=a9049054013a1b77]*/