Skip to content

Commit 5097bb0

Browse files
committed
Implement support for ECH servers.
1 parent 666eca4 commit 5097bb0

11 files changed

Lines changed: 328 additions & 6 deletions

File tree

‎Doc/library/ssl.rst‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1709,6 +1709,29 @@ to speed up repeated connections from the same clients.
17091709

17101710
.. versionadded:: 3.4
17111711

1712+
.. method:: SSLContext.load_ech_keys(keyfile, *, for_retry=True)
1713+
1714+
Load a server's Encrypted Client Hello (ECH) private key and configuration.
1715+
The *keyfile* must be the path to a PEM file containing an unencrypted
1716+
private key followed by its matching ECHConfigList. Load the server's TLS
1717+
certificate and private key separately with :meth:`load_cert_chain`.
1718+
1719+
Each call adds keys and configurations to those already loaded into the
1720+
context. If *for_retry* is true, the new configurations are included in
1721+
ECH retry configurations sent to clients.
1722+
1723+
Configure ECH keys on the initial server context before accepting
1724+
connections. After successful ECH decryption, :attr:`sni_callback`
1725+
receives the inner server name.
1726+
1727+
Invalid or missing keys or configurations, or a key that does not match
1728+
its configuration, raise :exc:`SSLError`. File access errors raise
1729+
:exc:`OSError`.
1730+
1731+
Only available when :data:`HAS_ECH` is ``True``.
1732+
1733+
.. versionadded:: next
1734+
17121735
.. method:: SSLContext.load_verify_locations(cafile=None, capath=None, cadata=None)
17131736

17141737
Load a set of "certification authority" (CA) certificates used to validate
@@ -3107,6 +3130,10 @@ parameters, a public key, and a public name used in the unencrypted outer
31073130
ClientHello. ECH requires OpenSSL 4.0 or later with ECH enabled; check
31083131
:data:`HAS_ECH` before using it.
31093132

3133+
Servers can load a PEM file containing their ECH private key and configuration
3134+
with :meth:`SSLContext.load_ech_keys`. Clients use the corresponding public
3135+
ECHConfigList with :meth:`SSLContext.set_ech_config`.
3136+
31103137
Configuration values may be discovered through DNS or through the server's
31113138
retry configuration. The following example accepts a binary-encoded
31123139
ECHConfigList discovered through DNS. If none is supplied, it enables ECH
@@ -3147,6 +3174,7 @@ GREASE to request a retry configuration from the server::
31473174
The following classes, methods, and attributes will be useful for using ECH:
31483175

31493176
- :class:`ECHStatus`
3177+
- :meth:`SSLContext.load_ech_keys`
31503178
- :meth:`SSLContext.set_ech_config`
31513179
- :meth:`SSLContext.set_outer_alpn_protocols`
31523180
- :meth:`SSLSocket.get_ech_status`

‎Doc/whatsnew/3.16.rst‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,6 +695,8 @@ ssl
695695
to query HTTPS or SVCB records. Clients can also enable
696696
:data:`~ssl.OP_ECH_GREASE` and retrieve a server's retry configuration
697697
with :meth:`~ssl.SSLSocket.get_ech_retry_config` for a subsequent connection.
698+
Servers can use :meth:`~ssl.SSLContext.load_ech_keys` to load ECH private
699+
keys and their configurations from PEM files.
698700
:data:`~ssl.HAS_ECH` indicates whether ECH is available, and
699701
:meth:`~ssl.SSLSocket.get_ech_status` reports the status as an
700702
:class:`~ssl.ECHStatus` member.

‎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
@@ -513,6 +513,7 @@ struct _Py_global_strings {
513513
STRUCT_FOR_ID(fold)
514514
STRUCT_FOR_ID(foldspaces)
515515
STRUCT_FOR_ID(follow_symlinks)
516+
STRUCT_FOR_ID(for_retry)
516517
STRUCT_FOR_ID(format)
517518
STRUCT_FOR_ID(format_spec)
518519
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/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.

‎Lib/test/test_ssl.py‎

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from test.support import warnings_helper
1515
from test.support import asyncore
1616
import array
17+
import base64
1718
import re
1819
import socket
1920
import select
@@ -2071,7 +2072,8 @@ def test_availability(self):
20712072
with self.subTest(module=module):
20722073
self.assertEqual(bool(names), ssl.HAS_ECH)
20732074

2074-
context_methods = ('set_ech_config', '_set_outer_alpn_protocols')
2075+
context_methods = ('set_ech_config', 'load_ech_keys',
2076+
'_set_outer_alpn_protocols')
20752077
socket_methods = ('get_ech_status', 'get_ech_retry_config')
20762078
for owner, names in (
20772079
(_ssl._SSLContext, context_methods),
@@ -2123,6 +2125,95 @@ def test_mixed_ech_config_versions(self):
21232125
config = len(entries).to_bytes(2, 'big') + entries
21242126
self.assertIsNone(ctx.set_ech_config(config))
21252127

2128+
def read_ech_key_and_config(self):
2129+
# Generated with:
2130+
# openssl ech -public_name public.example -suite 0x20,1,1
2131+
with open(data_file('ech.pem'), 'rb') as f:
2132+
key, config = f.read().split(b'-----BEGIN ECHCONFIG-----')
2133+
config = base64.b64decode(config.split(b'-----END ECHCONFIG-----')[0])
2134+
return key, config
2135+
2136+
@unittest.skipUnless(ssl.HAS_ECH, 'requires ECH')
2137+
def test_load_ech_keys(self):
2138+
client_ctx, ctx, hostname = testing_context()
2139+
filename = data_file('ech.pem')
2140+
for path in (filename, os.fsencode(filename),
2141+
os_helper.FakePath(filename)):
2142+
self.assertIsNone(ctx.load_ech_keys(path))
2143+
self.assertRaises(TypeError, ctx.load_ech_keys, None)
2144+
self.assertRaises(ValueError, ctx.load_ech_keys, 'key\x00file')
2145+
with self.assertRaises(OSError) as cm:
2146+
ctx.load_ech_keys(NONEXISTINGKEY)
2147+
self.assertEqual(cm.exception.errno, errno.ENOENT)
2148+
self.assertEqual(cm.exception.filename, NONEXISTINGKEY)
2149+
2150+
key, config = self.read_ech_key_and_config()
2151+
public = (b'-----BEGIN ECHCONFIG-----\n' + base64.b64encode(config)
2152+
+ b'\n-----END ECHCONFIG-----\n')
2153+
mismatched = bytearray(config)
2154+
mismatched[11] ^= 1 # Change one bit of the HPKE public key.
2155+
mismatched = (key + b'-----BEGIN ECHCONFIG-----\n'
2156+
+ base64.b64encode(mismatched)
2157+
+ b'\n-----END ECHCONFIG-----\n')
2158+
with open(ONLYKEY_PROTECTED, 'rb') as f:
2159+
encrypted = f.read() + public
2160+
self.addCleanup(os_helper.unlink, os_helper.TESTFN)
2161+
for pem in (b'invalid', public, mismatched, encrypted):
2162+
with self.subTest(pem=pem):
2163+
with open(os_helper.TESTFN, 'wb') as f:
2164+
f.write(pem)
2165+
self.assertRaises(ssl.SSLError, ctx.load_ech_keys,
2166+
os_helper.TESTFN)
2167+
# Failed loads must leave the previously loaded keys usable.
2168+
client_ctx.minimum_version = ssl.TLSVersion.TLSv1_3
2169+
client_ctx.set_ech_config(config)
2170+
client, server, *_ = connected_bio_pair(client_ctx, ctx, hostname)
2171+
self.assertEqual(client.get_ech_status(),
2172+
ssl.ECHStatus.ECH_STATUS_SUCCESS)
2173+
2174+
@unittest.skipUnless(ssl.HAS_ECH, 'requires ECH')
2175+
def test_ech_handshake(self):
2176+
key, config = self.read_ech_key_and_config()
2177+
other_config = bytearray(config)
2178+
other_config[6] ^= 1 # A different config_id using the same key.
2179+
self.addCleanup(os_helper.unlink, os_helper.TESTFN)
2180+
with open(os_helper.TESTFN, 'wb') as f:
2181+
f.write(key + b'-----BEGIN ECHCONFIG-----\n'
2182+
+ base64.b64encode(other_config)
2183+
+ b'\n-----END ECHCONFIG-----\n')
2184+
client_ctx, server_ctx, hostname = testing_context()
2185+
client_ctx.minimum_version = ssl.TLSVersion.TLSv1_3
2186+
server_ctx.load_ech_keys(data_file('ech.pem'), for_retry=False)
2187+
server_ctx.load_ech_keys(os_helper.TESTFN)
2188+
# Both the old and newly appended configurations must work.
2189+
for current_config in (config, other_config):
2190+
with self.subTest(config_id=current_config[6]):
2191+
client_ctx.set_ech_config(current_config)
2192+
seen_names = []
2193+
2194+
def sni_callback(obj, name, ctx):
2195+
seen_names.append(name)
2196+
2197+
server_ctx.sni_callback = sni_callback
2198+
client, server, c_in, c_out, s_in, s_out = connected_bio_pair(
2199+
client_ctx, server_ctx, hostname)
2200+
for obj in (client, server):
2201+
self.assertEqual(obj.get_ech_status(),
2202+
ssl.ECHStatus.ECH_STATUS_SUCCESS)
2203+
self.assertEqual(obj.outer_server_hostname,
2204+
'public.example')
2205+
self.assertEqual(seen_names, [hostname])
2206+
client.write(b'hello')
2207+
s_in.write(c_out.read())
2208+
self.assertEqual(server.read(), b'hello')
2209+
server.write(b'world')
2210+
c_in.write(s_out.read())
2211+
self.assertEqual(client.read(), b'world')
2212+
client_ctx, _, hostname = testing_context()
2213+
client_ctx.options |= ssl.OP_ECH_GREASE
2214+
client, server, *_ = connected_bio_pair(client_ctx, server_ctx, hostname)
2215+
self.assertEqual(client.get_ech_retry_config(), bytes(other_config))
2216+
21262217
@unittest.skipUnless(ssl.HAS_ECH, 'requires ECH')
21272218
def test_status_before_handshake(self):
21282219
ctx = make_test_context()
Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
1-
Add client support for Encrypted Client Hello (ECH) to the :mod:`ssl` module
2-
with OpenSSL 4.0 or later. ECH configurations can be supplied with
3-
:meth:`ssl.SSLContext.set_ech_config`.
1+
Add support for Encrypted Client Hello (ECH) to the :mod:`ssl` module with
2+
OpenSSL 4.0 or later.

‎Modules/_ssl.c‎

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5273,7 +5273,10 @@ _ssl__SSLContext_set_ech_config_impl(PySSLContext *self,
52735273
if (OSSL_ECHSTORE_read_echconfiglist(store, config_bio) != 1) {
52745274
goto fail;
52755275
}
5276-
if (SSL_CTX_set1_echstore(self->ctx, store) != 1) {
5276+
PyMutex_Lock(&self->tstate_mutex);
5277+
int result = SSL_CTX_set1_echstore(self->ctx, store);
5278+
PyMutex_Unlock(&self->tstate_mutex);
5279+
if (result != 1) {
52775280
goto fail;
52785281
}
52795282
OSSL_ECHSTORE_free(store);
@@ -5286,6 +5289,109 @@ _ssl__SSLContext_set_ech_config_impl(PySSLContext *self,
52865289
BIO_free(config_bio);
52875290
return NULL;
52885291
}
5292+
5293+
static int
5294+
ech_password_callback(char *buf, int size, int rwflag, void *userdata)
5295+
{
5296+
/* ECH key files must be unencrypted. Never prompt for a password. */
5297+
return -1;
5298+
}
5299+
5300+
static int
5301+
load_ech_keys(SSL_CTX *ctx, BIO *bio, int for_retry)
5302+
{
5303+
int result = 0;
5304+
int old_keys, new_keys;
5305+
OSSL_ECHSTORE *store = NULL;
5306+
EVP_PKEY *key = PEM_read_bio_PrivateKey(bio, NULL,
5307+
ech_password_callback, NULL);
5308+
if (key == NULL) {
5309+
goto end;
5310+
}
5311+
store = SSL_CTX_get1_echstore(ctx);
5312+
if (store == NULL) {
5313+
if (ERR_peek_error() != 0) {
5314+
goto end;
5315+
}
5316+
store = OSSL_ECHSTORE_new(NULL, NULL);
5317+
if (store == NULL) {
5318+
goto end;
5319+
}
5320+
}
5321+
if (OSSL_ECHSTORE_num_keys(store, &old_keys) != 1 ||
5322+
OSSL_ECHSTORE_set1_key_and_read_pem(store, key, bio, for_retry) != 1 ||
5323+
OSSL_ECHSTORE_num_keys(store, &new_keys) != 1) {
5324+
goto end;
5325+
}
5326+
/* OpenSSL may match a key already in the store. Require a new match. */
5327+
if (new_keys <= old_keys) {
5328+
ERR_raise(ERR_LIB_SSL, SSL_R_PRIVATE_KEY_MISMATCH);
5329+
goto end;
5330+
}
5331+
result = SSL_CTX_set1_echstore(ctx, store);
5332+
5333+
end:
5334+
EVP_PKEY_free(key);
5335+
OSSL_ECHSTORE_free(store);
5336+
return result;
5337+
}
5338+
5339+
/*[clinic input]
5340+
@critical_section
5341+
_ssl._SSLContext.load_ech_keys
5342+
5343+
keyfile: object
5344+
*
5345+
for_retry: bool = True
5346+
5347+
Load server ECH keys and configurations from a PEM file.
5348+
5349+
The file must contain an unencrypted private key followed by its
5350+
ECHConfigList.
5351+
If for_retry is true, advertise the configurations in ECH retry
5352+
responses.
5353+
[clinic start generated code]*/
5354+
5355+
static PyObject *
5356+
_ssl__SSLContext_load_ech_keys_impl(PySSLContext *self, PyObject *keyfile,
5357+
int for_retry)
5358+
/*[clinic end generated code: output=8de2cc58ce2f8077 input=3e6617d346227acd]*/
5359+
{
5360+
PyObject *keyfile_bytes;
5361+
if (!PyUnicode_FSConverter(keyfile, &keyfile_bytes)) {
5362+
return NULL;
5363+
}
5364+
const char *filename = PyBytes_AS_STRING(keyfile_bytes);
5365+
int result = 0;
5366+
int open_failed = 0;
5367+
5368+
ERR_clear_error();
5369+
PySSL_BEGIN_ALLOW_THREADS(self)
5370+
errno = 0;
5371+
BIO *bio = BIO_new_file(filename, "rb");
5372+
if (bio == NULL) {
5373+
open_failed = 1;
5374+
}
5375+
else {
5376+
result = load_ech_keys(self->ctx, bio, for_retry);
5377+
BIO_free(bio);
5378+
}
5379+
PySSL_END_ALLOW_THREADS(self)
5380+
5381+
if (result != 1) {
5382+
if (open_failed && errno != 0) {
5383+
PyErr_SetFromErrnoWithFilenameObject(PyExc_OSError, keyfile);
5384+
ERR_clear_error();
5385+
}
5386+
else {
5387+
_setSSLError(get_state_ctx(self), NULL, 0, __FILE__, __LINE__);
5388+
}
5389+
Py_DECREF(keyfile_bytes);
5390+
return NULL;
5391+
}
5392+
Py_DECREF(keyfile_bytes);
5393+
Py_RETURN_NONE;
5394+
}
52895395
#endif /* HAVE_OPENSSL_ECH */
52905396

52915397
/*[clinic input]
@@ -5968,6 +6074,7 @@ static struct PyMethodDef context_methods[] = {
59686074
_SSL__SSLCONTEXT_SET_DEFAULT_VERIFY_PATHS_METHODDEF
59696075
_SSL__SSLCONTEXT_SET_ECDH_CURVE_METHODDEF
59706076
_SSL__SSLCONTEXT_SET_ECH_CONFIG_METHODDEF
6077+
_SSL__SSLCONTEXT_LOAD_ECH_KEYS_METHODDEF
59716078
_SSL__SSLCONTEXT__SET_OUTER_ALPN_PROTOCOLS_METHODDEF
59726079
_SSL__SSLCONTEXT_CERT_STORE_STATS_METHODDEF
59736080
_SSL__SSLCONTEXT_GET_CA_CERTS_METHODDEF

0 commit comments

Comments
 (0)