Skip to content

Commit 46edd6f

Browse files
ZeroIntensityirlgpshead
authored
gh-89730: Implement support for Encrypted Client Hello (ECH) in ssl (GH-160318)
Co-authored-by: irl <iain@learmonth.me> Co-authored-by: Gregory P. Smith <68491+gpshead@users.noreply.github.com>
1 parent dec26ce commit 46edd6f

16 files changed

Lines changed: 1759 additions & 25 deletions

‎Doc/library/ssl.rst‎

Lines changed: 377 additions & 5 deletions
Large diffs are not rendered by default.

‎Doc/whatsnew/3.16.rst‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,6 +705,16 @@ sqlite3
705705
ssl
706706
---
707707

708+
* Added support for :ref:`Encrypted Client Hello <ssl-encrypted-client-hello>`
709+
(ECH) with OpenSSL 4.0 or later. Clients can supply an ECHConfigList
710+
discovered through DNS, for example using a third-party library such as
711+
``dnspython`` to query HTTPS or SVCB records, as the *ech_config* argument
712+
of :meth:`~ssl.SSLContext.wrap_socket` and :meth:`~ssl.SSLContext.wrap_bio`
713+
or with :meth:`~ssl.SSLContext.set_ech_config`.
714+
Servers can use :meth:`~ssl.SSLContext.load_ech_keys` to load ECH private
715+
keys and their configurations from PEM files.
716+
(Contributed by Peter Bierma and Iain R. Learmonth in :gh:`89730`.)
717+
708718
* :const:`ssl.OPENSSL_VERSION_INFO` is now a named tuple.
709719
Added :const:`~ssl.OPENSSL_API_VERSION_INFO` which provides information
710720
about the version of the OpenSSL library that was used for building

‎Include/internal/pycore_global_objects_fini_generated.h‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Include/internal/pycore_global_strings.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,7 @@ struct _Py_global_strings {
514514
STRUCT_FOR_ID(fold)
515515
STRUCT_FOR_ID(foldspaces)
516516
STRUCT_FOR_ID(follow_symlinks)
517+
STRUCT_FOR_ID(for_retry)
517518
STRUCT_FOR_ID(format)
518519
STRUCT_FOR_ID(format_spec)
519520
STRUCT_FOR_ID(frame_buffer)

‎Include/internal/pycore_runtime_init_generated.h‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Include/internal/pycore_unicodeobject_generated.h‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Lib/ssl.py‎

Lines changed: 100 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@
107107
from _ssl import _SSLContext, MemoryBIO, SSLSession
108108
from _ssl import (
109109
SSLError, SSLZeroReturnError, SSLWantReadError, SSLWantWriteError,
110-
SSLSyscallError, SSLEOFError, SSLCertVerificationError
110+
SSLSyscallError, SSLEOFError, SSLCertVerificationError,
111+
SSLECHRejectedError
111112
)
112113
from _ssl import txt2obj as _txt2obj, nid2obj as _nid2obj
113114
from _ssl import RAND_status, RAND_add, RAND_bytes
@@ -116,7 +117,8 @@
116117

117118
from _ssl import (
118119
HAS_SNI, HAS_ECDH, HAS_NPN, HAS_ALPN, HAS_SSLv2, HAS_SSLv3, HAS_TLSv1,
119-
HAS_TLSv1_1, HAS_TLSv1_2, HAS_TLSv1_3, HAS_PSK, HAS_PSK_TLS13, HAS_PHA
120+
HAS_TLSv1_1, HAS_TLSv1_2, HAS_TLSv1_3, HAS_PSK, HAS_PSK_TLS13, HAS_PHA,
121+
HAS_ECH
120122
)
121123
from _ssl import _DEFAULT_CIPHERS, _OPENSSL_API_VERSION
122124

@@ -150,6 +152,12 @@
150152
lambda name: name.startswith('CERT_'),
151153
source=_ssl)
152154

155+
if HAS_ECH:
156+
_IntEnum._convert_(
157+
'ECHStatus', __name__,
158+
lambda name: name.startswith('ECH_STATUS_'),
159+
source=_ssl)
160+
153161
PROTOCOL_SSLv23 = _SSLMethod.PROTOCOL_SSLv23 = _SSLMethod.PROTOCOL_TLS
154162
_PROTOCOL_NAMES = frozendict({
155163
value: name for name, value in _SSLMethod.__members__.items()})
@@ -374,7 +382,8 @@ def _ipaddress_match(cert_ipaddress, host_ip):
374382
return ip == host_ip
375383

376384

377-
def _check_sslobject_params(server_side, context=None, server_hostname=None, session=None):
385+
def _check_sslobject_params(server_side, context=None, server_hostname=None,
386+
session=None, ech_config=None):
378387
"""Raises a ValueError if SSLObject._create() parameters aren't valid.
379388
"""
380389
if server_side:
@@ -384,6 +393,11 @@ def _check_sslobject_params(server_side, context=None, server_hostname=None, ses
384393
if session is not None:
385394
raise ValueError("session can only be specified in "
386395
"client mode")
396+
if ech_config is not None:
397+
raise ValueError("ech_config can only be specified in "
398+
"client mode")
399+
if ech_config is not None and not HAS_ECH:
400+
raise NotImplementedError("ECH is not supported by the OpenSSL library")
387401
if context.check_hostname and not server_hostname:
388402
raise ValueError("check_hostname requires server_hostname")
389403

@@ -464,7 +478,7 @@ def _encode_hostname(self, hostname):
464478
def wrap_socket(self, sock, server_side=False,
465479
do_handshake_on_connect=True,
466480
suppress_ragged_eofs=True,
467-
server_hostname=None, session=None):
481+
server_hostname=None, session=None, ech_config=None):
468482
# SSLSocket class handles server_hostname encoding before it calls
469483
# ctx._wrap_socket()
470484
return self.sslsocket_class._create(
@@ -474,17 +488,18 @@ def wrap_socket(self, sock, server_side=False,
474488
suppress_ragged_eofs=suppress_ragged_eofs,
475489
server_hostname=server_hostname,
476490
context=self,
477-
session=session
491+
session=session,
492+
ech_config=ech_config,
478493
)
479494

480495
def wrap_bio(self, incoming, outgoing, server_side=False,
481-
server_hostname=None, session=None):
496+
server_hostname=None, session=None, ech_config=None):
482497
# Need to encode server_hostname here because _wrap_bio() can only
483498
# handle ASCII str.
484499
return self.sslobject_class._create(
485500
incoming, outgoing, server_side=server_side,
486501
server_hostname=self._encode_hostname(server_hostname),
487-
session=session, context=self,
502+
session=session, context=self, ech_config=ech_config,
488503
)
489504

490505
def set_npn_protocols(self, npn_protocols):
@@ -517,16 +532,14 @@ def shim_cb(sslobj, servername, sslctx):
517532
self.sni_callback = shim_cb
518533

519534
def set_alpn_protocols(self, alpn_protocols):
520-
protos = bytearray()
521-
for protocol in alpn_protocols:
522-
b = bytes(protocol, 'ascii')
523-
if len(b) == 0 or len(b) > 255:
524-
raise SSLError('ALPN protocols must be 1 to 255 in length')
525-
protos.append(len(b))
526-
protos.extend(b)
527-
535+
protos = _encode_alpn_protocol_list(alpn_protocols)
528536
self._set_alpn_protocols(protos)
529537

538+
if HAS_ECH:
539+
def set_outer_alpn_protocols(self, alpn_protocols):
540+
protos = _encode_alpn_protocol_list(alpn_protocols)
541+
self._set_outer_alpn_protocols(protos)
542+
530543
def _load_windows_store_certs(self, storename, purpose):
531544
try:
532545
for cert, encoding, trust in enum_certificates(storename):
@@ -825,15 +838,19 @@ def __init__(self, *args, **kwargs):
825838

826839
@classmethod
827840
def _create(cls, incoming, outgoing, server_side=False,
828-
server_hostname=None, session=None, context=None):
841+
server_hostname=None, session=None, context=None,
842+
ech_config=None):
829843
_check_sslobject_params(server_side=server_side, context=context,
830-
server_hostname=server_hostname, session=session)
844+
server_hostname=server_hostname,
845+
session=session, ech_config=ech_config)
831846
self = cls.__new__(cls)
832847
sslobj = context._wrap_bio(
833848
incoming, outgoing, server_side=server_side,
834849
server_hostname=server_hostname,
835850
owner=self, session=session
836851
)
852+
if ech_config is not None:
853+
sslobj.set_ech_config(ech_config)
837854
self._sslobj = sslobj
838855
return self
839856

@@ -846,6 +863,12 @@ def context(self):
846863
def context(self, ctx):
847864
self._sslobj.context = ctx
848865

866+
if HAS_ECH:
867+
@property
868+
def outer_server_hostname(self):
869+
"""The server name used in the outer ClientHello."""
870+
return self._sslobj.get_ech_status()[2]
871+
849872
@property
850873
def session(self):
851874
"""The SSLSession for client socket."""
@@ -995,6 +1018,23 @@ def version(self):
9951018
def verify_client_post_handshake(self):
9961019
return self._sslobj.verify_client_post_handshake()
9971020

1021+
if HAS_ECH:
1022+
def set_ech_config(self, ech_config):
1023+
"""Set the ECH configuration for this connection.
1024+
1025+
This replaces any configuration inherited from the context
1026+
and must be called before the handshake starts.
1027+
"""
1028+
self._sslobj.set_ech_config(ech_config)
1029+
1030+
def get_ech_status(self):
1031+
"""Return the status of Encrypted Client Hello processing."""
1032+
return ECHStatus(self._sslobj.get_ech_status()[0])
1033+
1034+
def get_ech_retry_config(self):
1035+
"""Return the server's ECH retry configuration as bytes."""
1036+
return self._sslobj.get_ech_retry_config()
1037+
9981038

9991039
def _sslcopydoc(func):
10001040
"""Copy docstring from SSLObject to SSLSocket"""
@@ -1021,11 +1061,12 @@ def __init__(self, *args, **kwargs):
10211061
@classmethod
10221062
def _create(cls, sock, server_side=False, do_handshake_on_connect=True,
10231063
suppress_ragged_eofs=True, server_hostname=None,
1024-
context=None, session=None):
1064+
context=None, session=None, ech_config=None):
10251065
if sock.getsockopt(SOL_SOCKET, SO_TYPE) != SOCK_STREAM:
10261066
raise NotImplementedError("only stream sockets are supported")
10271067
_check_sslobject_params(server_side=server_side, context=context,
1028-
server_hostname=server_hostname, session=session)
1068+
server_hostname=server_hostname,
1069+
session=session, ech_config=ech_config)
10291070

10301071
sock_timeout = sock.gettimeout()
10311072
kwargs = dict(
@@ -1039,6 +1080,7 @@ def _create(cls, sock, server_side=False, do_handshake_on_connect=True,
10391080
try:
10401081
self._context = context
10411082
self._session = session
1083+
self._ech_config = ech_config
10421084
self._closed = False
10431085
self._sslobj = None
10441086
self.server_side = server_side
@@ -1097,6 +1139,8 @@ def _create(cls, sock, server_side=False, do_handshake_on_connect=True,
10971139
self, server_side, self.server_hostname,
10981140
owner=self, session=self._session,
10991141
)
1142+
if self._ech_config is not None:
1143+
self._sslobj.set_ech_config(self._ech_config)
11001144
if do_handshake_on_connect:
11011145
timeout = self.gettimeout()
11021146
if timeout == 0.0:
@@ -1121,6 +1165,13 @@ def context(self, ctx):
11211165
self._context = ctx
11221166
self._sslobj.context = ctx
11231167

1168+
if HAS_ECH:
1169+
@property
1170+
def outer_server_hostname(self):
1171+
"""The server name used in the outer ClientHello."""
1172+
if self._sslobj is not None:
1173+
return self._sslobj.get_ech_status()[2]
1174+
11241175
@property
11251176
@_sslcopydoc
11261177
def session(self):
@@ -1422,6 +1473,23 @@ def verify_client_post_handshake(self):
14221473
else:
14231474
raise ValueError("No SSL wrapper around " + str(self))
14241475

1476+
if HAS_ECH:
1477+
@_sslcopydoc
1478+
def set_ech_config(self, ech_config):
1479+
self._ech_config = ech_config
1480+
if self._sslobj is not None:
1481+
self._sslobj.set_ech_config(ech_config)
1482+
1483+
@_sslcopydoc
1484+
def get_ech_status(self):
1485+
if self._sslobj is not None:
1486+
return ECHStatus(self._sslobj.get_ech_status()[0])
1487+
1488+
@_sslcopydoc
1489+
def get_ech_retry_config(self):
1490+
if self._sslobj is not None:
1491+
return self._sslobj.get_ech_retry_config()
1492+
14251493
def _real_close(self):
14261494
self._sslobj = None
14271495
super()._real_close()
@@ -1449,6 +1517,8 @@ def _real_connect(self, addr, connect_ex):
14491517
owner=self, session=self._session
14501518
)
14511519
try:
1520+
if self._ech_config is not None:
1521+
self._sslobj.set_ech_config(self._ech_config)
14521522
if connect_ex:
14531523
rc = super().connect_ex(addr)
14541524
else:
@@ -1588,3 +1658,14 @@ def get_server_certificate(addr, ssl_version=PROTOCOL_TLS_CLIENT,
15881658

15891659
def get_protocol_name(protocol_code):
15901660
return _PROTOCOL_NAMES.get(protocol_code, '<unknown>')
1661+
1662+
1663+
def _encode_alpn_protocol_list(alpn_protocols):
1664+
protos = bytearray()
1665+
for protocol in alpn_protocols:
1666+
b = bytes(protocol, 'ascii')
1667+
if not b or len(b) > 255:
1668+
raise SSLError('ALPN protocols must be 1 to 255 in length')
1669+
protos.append(len(b))
1670+
protos.extend(b)
1671+
return protos

‎Lib/test/certdata/ech-encrypted.pem‎

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Lib/test/certdata/ech-localhost.pem‎

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Lib/test/certdata/ech.pem‎

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)