Skip to content

Commit

Permalink
initial EncryptedClientHello support in ssl module
Browse files Browse the repository at this point in the history
  • Loading branch information
irl committed Nov 14, 2024
1 parent 1e3497e commit 593813e
Show file tree
Hide file tree
Showing 43 changed files with 5,885 additions and 12 deletions.
82 changes: 82 additions & 0 deletions .github/workflows/packages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
name: builder

on:
workflow_dispatch:
push:
schedule:
- cron: '30 5 * * *'

jobs:
packages:
runs-on: ubuntu-24.04
steps:
- name: Check out the repo
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: merge upstream
run: |
git remote add upstream https://github.com/python/cpython.git
git fetch upstream
git -c user.name=Github -c user.email=none merge upstream/main
- name: Cache ccache
uses: actions/cache@v4
with:
path: /home/runner/.cache/ccache
key: ccache

- name: Prepare build environment
run: |
sudo DEBIAN_FRONTEND=noninteractive apt update && sudo DEBIAN_FRONTEND=noninteractive apt install -y --no-install-recommends sbuild mmdebstrap debian-archive-keyring ccache uidmap
mkdir -p "$HOME/.cache/sbuild"
mmdebstrap --variant=buildd --include=apt,ccache,ca-certificates \
--keyring=/usr/share/keyrings/debian-archive-keyring.gpg \
--customize-hook='chroot "$1" update-ccache-symlinks' \
testing "$HOME/.cache/sbuild/testing-amd64.tar"
ccache --zero-stats --max-size=10.0G
chmod a+X "$HOME" "$HOME/.cache"
chmod -R a+rwX "$HOME/.cache/ccache"
cat << "EOF" > "$HOME/.sbuildrc"
$build_environment = { "CCACHE_DIR" => "/build/ccache" };
$path = "/usr/lib/ccache:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games";
$build_path = "/build/package/";
$dsc_dir = "package";
$unshare_bind_mounts = [ { directory => "$HOME/.cache/ccache", mountpoint => "/build/ccache" } ];
$verbose = 1;
EOF
mkdir "$HOME/apt_repo"
# - name: Run sbuild
# run: |
sed -i "1 s/([^)]*)/($(git describe --tags | sed 's/^[^0-9]*//;s/-/./g;s/_/./g')-$(date -u '+%Y%m%d.%H%M%S%N'))/" debian/changelog
sbuild -d testing --chroot-mode=unshare --no-clean-source --no-run-lintian \
--extra-repository="deb [trusted=yes] https://github.com/defo-project/openssl/raw/packages/ ./" \
--dpkg-source-opts="-Zgzip -z1 --format=1.0 -sn" --build-dir="$HOME/apt_repo"
cd "$HOME/apt_repo"
apt-ftparchive packages . > Packages
apt-ftparchive release . > Release

# - name: Test packages
# run: |
# mmdebstrap --verbose --chrooted-customize-hook="curl --ech true --doh-url 'https://1.1.1.1/dns-query' 'https://defo.ie/ech-check.php' | grep 'SSL_ECH_STATUS: success'" \
# --variant=essential --include=ca-certificates,curl testing /dev/null \
# "deb [signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] http://deb.debian.org/debian testing main" \
# "deb [trusted=yes] https://github.com/defo-project/openssl/raw/packages/ /" \
# "deb [trusted=yes] copy:/$HOME/apt_repo /"

- name: Upload apt repository
run: |
cd "$HOME/apt_repo"
BRANCH=packages
REPOSITORY="$(printf "%s" "$GITHUB_REPOSITORY" | tr / _)"
echo "echo \"deb [trusted=yes] $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/$BRANCH/ /\" | sudo tee /etc/apt/sources.list.d/$REPOSITORY.list" >> README.md
git init -b "$BRANCH"
git remote add origin "$(echo "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" | sed "s#https://#https://x-access-token:${{ secrets.GITHUB_TOKEN }}@#")"
git add .
git -c user.name=Github -c user.email=none commit --message="Generated with $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
git push --force origin "$BRANCH"
121 changes: 120 additions & 1 deletion Doc/library/ssl.rst
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,6 @@ purposes.
The context now uses :data:`VERIFY_X509_PARTIAL_CHAIN` and
:data:`VERIFY_X509_STRICT` in its default verify flags.


Exceptions
^^^^^^^^^^

Expand Down Expand Up @@ -990,6 +989,13 @@ Constants

.. versionadded:: 3.6

.. class:: ECHStatus

`enum.IntEnum` collection of Encrypted Client Hello (ECH) statuses
returned by :meth:`SSLSocket.get_ech_status`.

.. versionadded:: TODO XXX

.. data:: Purpose.SERVER_AUTH

Option for :func:`create_default_context` and
Expand Down Expand Up @@ -1294,6 +1300,22 @@ SSL sockets also have the following additional methods and attributes:

.. versionadded:: 3.3

.. method:: SSLSocket.get_ech_retry_config()

When the status returned by `SSLSocket.get_ech_status` after completion of the
handshake is :data:`ECHStatus.ECH_STATUS_GREASE_ECH`, this method returns the
configuration value provided by the server to be used for a new connection using
ECH.

.. versionadded:: TODO XXX

.. method:: SSLSocket.get_ech_status()

Gets the status of Encrypted Client Hello (ECH) processing. Returns an
:class:`ECHStatus` instance.

.. versionadded:: TODO XXX

.. method:: SSLSocket.selected_alpn_protocol()

Return the protocol that was selected during the TLS handshake. If
Expand Down Expand Up @@ -1366,6 +1388,15 @@ SSL sockets also have the following additional methods and attributes:

.. versionadded:: 3.2

.. attribute:: SSLSocket.outer_server_hostname

Hostname of the server name used in the outer ClientHello when Encrypted Client
Hello (ECH) is used: :class:`str` type, or ``None`` for server-side socket or
if the outer server name was not specified in the constructor or the ECH
configuration.

.. versionadded:: TODO XXX

.. attribute:: SSLSocket.server_side

A boolean which is ``True`` for server-side sockets and ``False`` for
Expand Down Expand Up @@ -1667,6 +1698,24 @@ to speed up repeated connections from the same clients.

.. versionadded:: 3.5

.. method:: SSLContext.set_ech_config(ech_config)

Sets an Encrypted Client Hello (ECH) configuration, which may be discovered from
an HTTPS resource record in DNS or from :meth:`SSLSocket.get_ech_retry_config`.
Multiple calls to this functions will accumulate the set of values available for
a connection.

If the input value provided contains no suitable value (e.g. if it only contains
ECH configuration versions that are not supported), an :class:`SSLError` will be
raised.

The ech_config parameter should be a bytes-like object containing the raw ECH
configuration.

This method will raise :exc:`NotImplementedError` if :data:`HAS_ECH` is ``False``.

.. versionadded:: TODO XXX

.. method:: SSLContext.set_npn_protocols(protocols)

Specify which protocols the socket should advertise during the SSL/TLS
Expand All @@ -1686,6 +1735,28 @@ to speed up repeated connections from the same clients.

NPN has been superseded by ALPN

.. method:: SSLContext.set_outer_alpn_protocols(protocols)

Specify which protocols the socket should advertise during the TLS
handshake in the outer ClientHello when ECH is used. The `protocols`
argument accepts the same values as for
:meth:`~SSLContext.set_alpn_protocols`.

This method will raise :exc:`NotImplementedError` if :data:`HAS_ECH` is
``False``.

.. versionadded:: TODO XXX

.. method:: SSLContext.set_outer_server_hostname(server_hostname)

Specify which hostname the socket should advertise during the TLS
handshake in the outer ClientHello when ECH is used.

This method will raise :exc:`NotImplementedError` if :data:`HAS_ECH` is
``False``.

.. versionadded:: TODO XXX

.. attribute:: SSLContext.sni_callback

Register a callback function that will be called after the TLS Client Hello
Expand Down Expand Up @@ -2581,6 +2652,8 @@ provided.
- :meth:`~SSLSocket.verify_client_post_handshake`
- :meth:`~SSLSocket.unwrap`
- :meth:`~SSLSocket.get_channel_binding`
- :meth:`~SSLSocket.get_ech_retry_config`
- :meth:`~SSLSocket.get_ech_status`
- :meth:`~SSLSocket.version`

When compared to :class:`SSLSocket`, this object lacks the following
Expand Down Expand Up @@ -2800,6 +2873,52 @@ of TLS/SSL. Some new TLS 1.3 features are not yet available.
- TLS 1.3 features like early data, deferred TLS client cert request,
signature algorithm configuration, and rekeying are not supported yet.

Encrypted Client Hello
^^^^^^^^^^^^^^^^^^^^^^

.. versionadded:: TODO XXX

Encrypted Client Hello (ECH) allows for encrypting values that have previously only been
included unencrypted in the ClientHello records when establishing a TLS connection. To use
ECH it is necessary to provide configuration values that contain a version, algorithm
parameters, the public key to use for HPKE encryption and the "public_name" that is by
default used for the unencrypted (outer) SNI when ECH is attempted. These configuration
values may be discovered through DNS or through the "retry config" mechanism.

The following example assumes that you have discovered a set of ECH configuration values
from DNS, or `ech_configs` may be an empty list to rely on the "retry config" mechanism::

import socket
import ssl


def connect_with_tls_ech(hostname: str, ech_configs: List[str],
use_retry_config: bool=True) -> ssl.SSLSocket:
context = ssl.create_default_context()
for ech_config in ech_configs:
context.set_ech_config(ech_config)
with socket.create_connection((hostname, 443)) as sock:
with context.wrap_socket(sock, server_hostname=hostname) as ssock:
if (ssock.get_ech_status == ECHStatus.ECH_STATUS_GREASE_ECH
and use_retry_config):
return connect_with_ech(hostname, [ssock.get_ech_retry_config()],
False)
return ssock

hostname = "www.python.org"
ech_configs = [] # Replace with a call to a function to lookup
# ECH configurations in DNS

ssock = connect_with_tls_ech(hostname, ech_configs)

The following classes, methods, and attributes will be useful for using ECH:

- :class:`ECHStatus`
- :meth:`SSLContext.set_ech_config`
- :meth:`SSLContext.set_outer_alpn_protocols`
- :meth:`SSLContext.set_outer_server_hostname`
- :meth:`SSLSocket.get_ech_status`
- :meth:`SSLSocket.get_ech_retry_config`

.. seealso::

Expand Down
61 changes: 51 additions & 10 deletions Lib/ssl.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,11 @@
lambda name: name.startswith('CERT_'),
source=_ssl)

_IntEnum._convert_(
'ECHStatus', __name__,
lambda name: name.startswith('ECH_STATUS_'),
source=_ssl)

PROTOCOL_SSLv23 = _SSLMethod.PROTOCOL_SSLv23 = _SSLMethod.PROTOCOL_TLS
_PROTOCOL_NAMES = {value: name for name, value in _SSLMethod.__members__.items()}

Expand Down Expand Up @@ -459,7 +464,7 @@ def wrap_socket(self, sock, server_side=False,
suppress_ragged_eofs=suppress_ragged_eofs,
server_hostname=server_hostname,
context=self,
session=session
session=session,
)

def wrap_bio(self, incoming, outgoing, server_side=False,
Expand Down Expand Up @@ -502,16 +507,13 @@ def shim_cb(sslobj, servername, sslctx):
self.sni_callback = shim_cb

def set_alpn_protocols(self, alpn_protocols):
protos = bytearray()
for protocol in alpn_protocols:
b = bytes(protocol, 'ascii')
if len(b) == 0 or len(b) > 255:
raise SSLError('ALPN protocols must be 1 to 255 in length')
protos.append(len(b))
protos.extend(b)

protos = encode_alpn_protocol_list(alpn_protocols)
self._set_alpn_protocols(protos)

def set_outer_alpn_protocols(self, alpn_protocols):
protos = encode_alpn_protocol_list(alpn_protocols)
self._set_outer_alpn_protocols(protos)

def _load_windows_store_certs(self, storename, purpose):
try:
for cert, encoding, trust in enum_certificates(storename):
Expand Down Expand Up @@ -831,6 +833,14 @@ def context(self):
def context(self, ctx):
self._sslobj.context = ctx

@property
def outer_server_hostname(self) -> str:
"""The server name used in the outer ClientHello."""
if self._sslobj:
return self._sslobj.get_ech_status()[2]
else:
raise ValueError("No SSL wrapper around " + str(self))

@property
def session(self):
"""The SSLSession for client socket."""
Expand Down Expand Up @@ -968,6 +978,9 @@ def version(self):
def verify_client_post_handshake(self):
return self._sslobj.verify_client_post_handshake()

def get_ech_status(self):
return ECHStatus(self._sslobj.get_ech_status()[0])


def _sslcopydoc(func):
"""Copy docstring from SSLObject to SSLSocket"""
Expand All @@ -990,13 +1003,16 @@ def __init__(self, *args, **kwargs):
@classmethod
def _create(cls, sock, server_side=False, do_handshake_on_connect=True,
suppress_ragged_eofs=True, server_hostname=None,
context=None, session=None):
context=None, session=None, outer_server_hostname=None):
if sock.getsockopt(SOL_SOCKET, SO_TYPE) != SOCK_STREAM:
raise NotImplementedError("only stream sockets are supported")
if server_side:
if server_hostname:
raise ValueError("server_hostname can only be specified "
"in client mode")
if outer_server_hostname:
raise ValueError("outer_server_hostname can only be specified "
"in client mode")
if session is not None:
raise ValueError("session can only be specified in "
"client mode")
Expand Down Expand Up @@ -1092,6 +1108,14 @@ def context(self, ctx):
self._context = ctx
self._sslobj.context = ctx

@property
def outer_server_hostname(self) -> str:
"""The server name used in the outer ClientHello."""
if self._sslobj:
return self._sslobj.get_ech_status()[2]
else:
raise ValueError("No SSL wrapper around " + str(self))

@property
@_sslcopydoc
def session(self):
Expand Down Expand Up @@ -1358,6 +1382,13 @@ def verify_client_post_handshake(self):
else:
raise ValueError("No SSL wrapper around " + str(self))


def get_ech_status(self):
if self._sslobj:
return ECHStatus(self._sslobj.get_ech_status()[0])
else:
raise ValueError("No SSL wrapper around " + str(self))

def _real_close(self):
self._sslobj = None
super()._real_close()
Expand Down Expand Up @@ -1527,3 +1558,13 @@ def get_server_certificate(addr, ssl_version=PROTOCOL_TLS_CLIENT,

def get_protocol_name(protocol_code):
return _PROTOCOL_NAMES.get(protocol_code, '<unknown>')

def encode_alpn_protocol_list(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)
return protos
Loading

0 comments on commit 593813e

Please sign in to comment.