Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@ ignore_missing_imports = True

[mypy-gunicorn.*]
ignore_missing_imports = True

[mypy-truststore]
ignore_missing_imports = True
1 change: 1 addition & 0 deletions CHANGES/11705.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*aiohttp* now prefers the `truststore <https://truststore.readthedocs.io/>`_ library for TLS certificate verification when the optional dependency is installed, with automatic fallback to the stdlib :mod:`ssl` defaults otherwise. This resolves common ``CERTIFICATE_VERIFY_FAILED`` errors for users behind enterprise TLS-intercepting proxies whose root CA lives in the macOS Keychain or Windows certificate stores. Install the optional dependency with ``pip install aiohttp[truststore]`` -- by :user:`Krishnachaitanyakc`.
1 change: 1 addition & 0 deletions CONTRIBUTORS.txt
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ Kirill Malovitsa
Kirill Potapenko
Konstantin Shutkin
Konstantin Valetov
Krishnachaitanya Balusu
Krzysztof Blazewicz
Kyrylo Perevozchikov
Kyungmin Lee
Expand Down
29 changes: 28 additions & 1 deletion aiohttp/connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@
ssl = None # type: ignore[assignment]
SSLContext = object # type: ignore[misc,assignment]

try:
import truststore # type: ignore[import-not-found,unused-ignore]
Comment thread
Dreamsorcerer marked this conversation as resolved.
Outdated
Comment thread
Dreamsorcerer marked this conversation as resolved.
Outdated

HAS_TRUSTSTORE = True
except ImportError:
HAS_TRUSTSTORE = False

EMPTY_SCHEMA_SET = frozenset({""})
HTTP_SCHEMA_SET = frozenset({"http", "https"})
WS_SCHEMA_SET = frozenset({"ws", "wss"})
Expand Down Expand Up @@ -846,12 +853,22 @@ def _make_ssl_context(verified: bool) -> SSLContext:

This method is not async-friendly and should be called from a thread
because it will load certificates from disk and do other blocking I/O.

When the optional ``truststore`` library is importable and ``verified``
is ``True``, the returned context is a :class:`truststore.SSLContext`,
which delegates certificate verification to the operating system's
native trust store (macOS Keychain, Windows certificate stores, OpenSSL
default paths on Linux). Otherwise the stdlib default context is used.
"""
if ssl is None:
# No ssl support
return None # type: ignore[unreachable]
sslcontext: SSLContext
if verified:
sslcontext = ssl.create_default_context()
if HAS_TRUSTSTORE:
sslcontext = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
else:
sslcontext = ssl.create_default_context()
else:
sslcontext = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
sslcontext.options |= ssl.OP_NO_SSLv2
Expand All @@ -868,6 +885,8 @@ def _make_ssl_context(verified: bool) -> SSLContext:
# since they do blocking I/O to load certificates from disk,
# and imports should always be done before the event loop starts
# or in a thread.
# _SSL_CONTEXT_VERIFIED is frozen at module import; tests that toggle
# HAS_TRUSTSTORE must call _make_ssl_context(True) fresh, not inspect this.
_SSL_CONTEXT_VERIFIED = _make_ssl_context(True)
_SSL_CONTEXT_UNVERIFIED = _make_ssl_context(False)

Expand Down Expand Up @@ -909,6 +928,14 @@ class TCPConnector(BaseConnector):
notifying the remote peer of connection closure,
while avoiding excessive delays during connector cleanup.
Note: Only takes effect on Python 3.11+.

When the optional ``truststore`` library is installed
(``pip install aiohttp[truststore]``), the default verified SSL context
delegates certificate verification to the operating system's native
trust store (macOS Keychain, Windows certificate stores, OpenSSL default
paths on Linux). Without the dependency, the stdlib defaults are used.
Passing an explicit :class:`ssl.SSLContext` via ``ssl=`` always wins
over the default behaviour.
"""

allowed_protocol_schema_set = HIGH_LEVEL_SCHEMA_SET | frozenset({"tcp"})
Expand Down
46 changes: 42 additions & 4 deletions docs/client_advanced.rst
Original file line number Diff line number Diff line change
Expand Up @@ -641,14 +641,52 @@ If you need to skip both ssl related errors
except aiohttp.ClientSSLError as e:
assert isinstance(e, ssl.CertificateError)

Example: Use truststore for system trust stores
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

By default, *aiohttp* relies on :func:`ssl.create_default_context`, which uses
OpenSSL's bundled certificate paths. On macOS and Windows this set does not
include certificates that the user, an administrator, or an MDM solution has
installed into the operating system's native trust store (the macOS Keychain or
the Windows certificate stores). As a result, requests can fail with
``CERTIFICATE_VERIFY_FAILED`` in environments where ``curl`` and the system
browsers succeed -- a common situation behind enterprise TLS-intercepting
proxies that rely on a locally-installed root CA.

The `truststore <https://truststore.readthedocs.io/>`_ library provides an
``SSLContext`` that delegates verification to the OS-native APIs. *aiohttp*
prefers it automatically whenever the library is importable -- there is no
constructor flag to set::

pip install aiohttp[truststore]

With the optional dependency installed, the default
:class:`~aiohttp.TCPConnector` uses :class:`truststore.SSLContext` for its
verified SSL context. Without it, the stdlib defaults are used and behaviour
is unchanged.

To force the stdlib trust paths even when ``truststore`` is installed, pass
an explicit context built from :func:`ssl.create_default_context`::

conn = aiohttp.TCPConnector(ssl=ssl.create_default_context())
async with aiohttp.ClientSession(connector=conn) as sess:
...

Installing the extra causes the verified default SSL context to be built at
``import aiohttp`` time via OS trust-store APIs; this is intentional, so any
blocking I/O happens before the event loop starts, consistent with how the
stdlib default context has always been built at import time.

Example: Use certifi
^^^^^^^^^^^^^^^^^^^^

By default, Python uses the system CA certificates. In rare cases, these may not be
installed or Python is unable to find them, resulting in a error like
`ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate`
In environments where the OpenSSL default certificate paths are missing or
unreadable, requests can fail with::

ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate

One way to work around this problem is to use the `certifi` package::
One way to work around this problem is to bundle a known-good certificate
authority list via the `certifi` package::

ssl_context = ssl.create_default_context(cafile=certifi.where())
async with ClientSession(connector=TCPConnector(ssl=ssl_context)) as sess:
Expand Down
20 changes: 20 additions & 0 deletions docs/client_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1278,6 +1278,26 @@ is controlled by *force_close* constructor's parameter).
*ssl_context* may be used for configuring certification
authority channel, supported SSL options etc.

.. note::

When the optional `truststore <https://truststore.readthedocs.io/>`_
library is importable (install it via
``pip install aiohttp[truststore]``), *aiohttp* automatically uses
:class:`truststore.SSLContext` for its default verified SSL context.
This delegates certificate verification to the operating system's
native trust store (macOS Keychain, Windows certificate stores, OpenSSL
default paths on Linux) and fixes ``CERTIFICATE_VERIFY_FAILED`` errors
in environments where OS-managed roots -- including those installed by
an administrator or an enterprise TLS-intercepting proxy -- are not in
OpenSSL's default paths. Without the dependency, the stdlib defaults
are used. Passing an explicit :class:`ssl.SSLContext` via ``ssl=``
always wins.

.. versionchanged:: 3.14

Automatically prefer ``truststore`` when the optional dependency
is installed.

:param tuple local_addr: tuple of ``(local_host, local_port)`` used to bind
socket locally if specified.

Expand Down
3 changes: 3 additions & 0 deletions docs/spelling_wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ keepalive
keepalived
keepalives
keepaliving
Keychain
kib
KiB
kwarg
Expand All @@ -196,6 +197,7 @@ lookup
lookups
lossless
lowercased
macOS
Mako
manylinux
metadata
Expand Down Expand Up @@ -358,6 +360,7 @@ toolbar
toplevel
towncrier
tp
truststore
tuples
UI
un
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ speedups = [
"brotlicffi >= 1.2; platform_python_implementation != 'CPython'",
"backports.zstd; platform_python_implementation == 'CPython' and python_version < '3.14' and sys_platform != 'android' and sys_platform != 'ios'",
]
truststore = [
"truststore >= 0.10",
]

[[project.maintainers]]
name = "aiohttp team"
Expand Down
1 change: 1 addition & 0 deletions requirements/test-common.in
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ pytest-xdist
pytest_codspeed
python-on-whales
trustme; platform_machine != "i686" # no 32-bit wheels
truststore; implementation_name == "cpython"
zlib_ng
114 changes: 114 additions & 0 deletions tests/test_truststore.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Tests for automatic ``truststore`` preference on :class:`TCPConnector`.

The ``truststore`` library delegates TLS certificate verification to the
operating system's native trust store. When ``truststore`` is importable,
``aiohttp.connector`` automatically uses ``truststore.SSLContext`` for its
default verified context; otherwise it falls back to the stdlib
``ssl.create_default_context()``. This module covers both branches.

These tests intentionally do not perform live TLS handshakes — they exercise
the SSL-context construction and dispatch logic only.
"""

import ssl
from unittest import mock

import pytest

from aiohttp import TCPConnector, connector as connector_module
from aiohttp.client_reqrep import Fingerprint


def _has_truststore() -> bool:
try:
import truststore # type: ignore[import-not-found,unused-ignore] # noqa: F401
except ImportError:
return False
return True


def test_has_truststore_matches_importability() -> None:
"""``HAS_TRUSTSTORE`` reflects whether the library can be imported."""
assert connector_module.HAS_TRUSTSTORE is _has_truststore()


@pytest.mark.skipif(not _has_truststore(), reason="truststore not installed")
def test_make_ssl_context_uses_truststore_when_available() -> None:
"""Verified context is a truststore.SSLContext when the lib is installed."""
import truststore # type: ignore[import-not-found,unused-ignore]

ctx = connector_module._make_ssl_context(True)
assert isinstance(ctx, truststore.SSLContext)


def test_make_ssl_context_falls_back_to_stdlib_when_truststore_absent() -> None:
"""Verified context is a plain ssl.SSLContext when truststore is missing.

Uses ``type() is`` rather than ``isinstance``: ``truststore.SSLContext``
subclasses ``ssl.SSLContext``, so ``isinstance`` would pass in both
branches and silently hide a regression here.
"""
with mock.patch.object(connector_module, "HAS_TRUSTSTORE", False):
ctx = connector_module._make_ssl_context(True)
assert type(ctx) is ssl.SSLContext


def test_make_ssl_context_unverified_path_does_not_touch_truststore() -> None:
"""Unverified context never uses truststore, regardless of HAS_TRUSTSTORE."""
with mock.patch.object(connector_module, "HAS_TRUSTSTORE", True):
ctx = connector_module._make_ssl_context(False)
assert type(ctx) is ssl.SSLContext
assert ctx.verify_mode == ssl.CERT_NONE


@pytest.mark.skipif(not _has_truststore(), reason="truststore not installed")
def test_make_ssl_context_verified_with_truststore_sets_alpn() -> None:
"""``set_alpn_protocols`` works on a truststore-backed context."""
ctx = connector_module._make_ssl_context(True)
assert isinstance(ctx, ssl.SSLContext)


@pytest.mark.skipif(not _has_truststore(), reason="truststore not installed")
async def test_get_ssl_context_returns_module_level_verified() -> None:
"""Default verified request returns the module-level ``_SSL_CONTEXT_VERIFIED``."""
import truststore # type: ignore[import-not-found,unused-ignore]

conn = TCPConnector()
try:
req = mock.Mock()
req.is_ssl.return_value = True
req.ssl = True
returned = conn._get_ssl_context(req)
assert returned is connector_module._SSL_CONTEXT_VERIFIED
assert isinstance(returned, truststore.SSLContext)
finally:
await conn.close()


@pytest.mark.skipif(not _has_truststore(), reason="truststore not installed")
async def test_explicit_ssl_context_overrides_default() -> None:
"""An explicit ``ssl=<SSLContext>`` argument wins over the default."""
explicit_ctx = ssl.create_default_context()
conn = TCPConnector(ssl=explicit_ctx)
try:
req = mock.Mock()
req.is_ssl.return_value = True
req.ssl = True
assert conn._get_ssl_context(req) is explicit_ctx
finally:
await conn.close()


@pytest.mark.skipif(not _has_truststore(), reason="truststore not installed")
async def test_fingerprint_uses_unverified_context_even_with_truststore() -> None:
"""A ``Fingerprint`` replaces CA verification; truststore must not apply."""
fingerprint = Fingerprint(b"\x00" * 32)
conn = TCPConnector(ssl=fingerprint)
try:
req = mock.Mock()
req.is_ssl.return_value = True
req.ssl = True
returned = conn._get_ssl_context(req)
assert returned is connector_module._SSL_CONTEXT_UNVERIFIED
finally:
await conn.close()
Loading